commit 03a281d0096f1e87b340e7ce2ac58b41d5310ae8 Author: dbledeez Date: Tue Jul 14 23:25:11 2026 -0700 panel v0.9.1 — open-source game server manager Co-Authored-By: Claude Fable 5 diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 00000000..7b3e7c10 --- /dev/null +++ b/.claudeignore @@ -0,0 +1,34 @@ +# Runtime data — gitignored and churns constantly +# (CA certs, agent certs, per-instance sidecar metadata, container logs, backups) +data/ + +# Compiled binaries (dev artifacts, not source) +agent.exe +controller.exe +panelctl.exe +*.exe + +# Generated protobuf stubs — regenerated by `buf generate` +# Keep them searchable since they're committed, but deprioritize vs. source proto +# (no ignore — just noting that proto/panel/v1/*.pb.go is generated) + +# Go build + test caches +*.test +*.prof +vendor/ + +# Git internals +.git/ + +# Editor / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db + +# Archive / backup files +*.rar +*.zip +*.7z +*.tar +*.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f0991919 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Binaries +*.exe +*.dll +*.so +*.dylib +/bin/ +/controller/controller +/agent/agent + +# Test / coverage +*.test +*.out +coverage.* + +# UI regression gate (the *.test Go-binary rule above would swallow this dir) +!/controller/.test/ +/controller/.test/node_modules/ +/controller/.test/test-results/ +/controller/.test/playwright-report/ + +# Go +vendor/ + +# Generated proto stubs (regenerated via buf) +# Comment these lines if you want to check generated code in +# /proto/panel/v1/*.pb.go +# /proto/panel/v1/*_grpc.pb.go + +# IDE +.idea/ +.vscode/ +*.swp + +# Local AI-tooling artifacts (auto-synced, machine-specific) +.claude/ +AGENTS.md + +# C# mod build artifacts +modules/7dtd/mod-source/**/bin/ +modules/7dtd/mod-source/**/obj/ + +# OS +.DS_Store +Thumbs.db + +# Local dev +/data/ +*.local.yaml +*.local.env +.env +.env.local + +# Internal CA private key material — NEVER commit +/ca/ +/certs/ +*.pem +*.key +!**/testdata/*.pem +!**/testdata/*.key + +# dev stub data (deckstub) +/Temp/ + +# Private ops notes (maintainer-local, not part of the public release) +memory/ +HANDOFF-new-ui.md +scripts/sync-agent-to-panel.sh + +# Proprietary embedded mod bundles (maintainer-local; built in via -tags refugebot) +controller/cmd/controller/embedded_mods/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c76845f4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# panel + +Open-source Go-based game-server management panel (AMP-class). Distributed Controller/Target over gRPC, Docker runtime, CEL-driven automation, 26 game module directories, live dashboard + `panelctl` CLI. Single static binary per component. + +## First thing every session + +Read the latest `memory/changelog_*.md` (the file with the most recent date in its name) before doing anything else. Each changelog covers what shipped in a previous autonomous session — bugs, root causes, files touched, and live one-off cleanups that didn't survive a recreate. Skipping this means re-discovering bugs you already fixed last time. + +If the user opens with a generic ask ("what should we work on?", "any bugs left over?"), the changelog's "What still needs to happen" tail section is the answer. + +## Start here + +- **[README.md](README.md)** — public overview: quickstart, modules, panelctl commands, architecture diagram. +- **[memory/](memory/)** — project-specific deep refs migrated from the user's global auto-memory. Covers repo layout, build commands, module quirks, UX preferences, gotchas. **Always start with `memory/README.md`** — it's the index pointing at every other deep ref + every changelog. + +## Component entrypoints + +- **Controller:** `./controller/cmd/controller` (port 8080 web / 8443 gRPC). Embeds DB migrations, admin bootstrap, static dashboard. **Default UI** is the Refuge Command Center split assets: `static/new.html` (shell, 1,723 lines) + `static/rcc.js` (18,928 lines) + `static/rcc.css` (5,727 lines), served hash-stamped/immutable. `static/index.html` (23,982 lines) is the **frozen classic UI** — opt-in via `panel_ui=classic` cookie, no new features land there. +- **Agent:** `./agent/cmd/agent` (any host with Docker; dials controller). Loads modules from `./modules/`, talks to local Docker daemon. +- **CLI:** `./controller/cmd/panelctl` — `agents | instances | create | start | stop | update | backup | rcon | watch …` plus `admin ` (HTTP, see [memory/admin_cli.md](memory/admin_cli.md)). + +## Build + run (dev) + +```bash +export PATH="/c/Program Files/Go/bin:$PATH" +cd /c/Users/dbled/sources/panel && \ + go build -o /c/Users/dbled/AppData/Local/Temp/controller.exe ./controller/cmd/controller && \ + go build -o /c/Users/dbled/AppData/Local/Temp/agent.exe ./agent/cmd/agent + +powershell -Command "Get-Process -Name controller,agent -ErrorAction SilentlyContinue | Stop-Process -Force" +# Then relaunch each (see memory/build.md for full cycle) +``` + +Static/HTML/CSS/JS changes for the default UI live in `controller/cmd/controller/static/new.html` + `rcc.js` + `rcc.css` — all embedded via `//go:embed`, so controller rebuild+restart is required on any change. Do NOT add features to `static/index.html`; the classic UI is frozen. + +**Gate before any controller deploy:** `node controller/.test/gate-parse.mjs` — extracts and parses every inline ` + + diff --git a/controller/cmd/controller/static/login.html b/controller/cmd/controller/static/login.html new file mode 100644 index 00000000..b08803b3 --- /dev/null +++ b/controller/cmd/controller/static/login.html @@ -0,0 +1,201 @@ + + + + +Sign in · Refuge Command Center + + + + + + + + + + + + +
+
+ + + REFUGE + Command Center + +
+

Welcome back

+
Sign in to manage your game servers.
+ + + + + + Sign in with Steam + + +
OR SIGN IN WITH EMAIL
+ + + + + + +
+ +
+ + + diff --git a/controller/cmd/controller/static/manifest.json b/controller/cmd/controller/static/manifest.json new file mode 100644 index 00000000..8ac575da --- /dev/null +++ b/controller/cmd/controller/static/manifest.json @@ -0,0 +1,31 @@ +{ + "name": "Refuge Game Server Panel", + "short_name": "RGSP", + "description": "Open-source self-hosted control plane for Docker-backed game servers across multiple agents.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "display_override": ["standalone", "minimal-ui"], + "orientation": "any", + "background_color": "#0b0e15", + "theme_color": "#0b0e15", + "categories": ["productivity", "utilities", "games"], + "lang": "en", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Servers", + "short_name": "Servers", + "description": "View running servers", + "url": "/", + "icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }] + } + ] +} diff --git a/controller/cmd/controller/static/new.html b/controller/cmd/controller/static/new.html new file mode 100644 index 00000000..6d451aa9 --- /dev/null +++ b/controller/cmd/controller/static/new.html @@ -0,0 +1,1724 @@ + + + + +Refuge Command Center + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ +

Servers

+
+
+ running + + online +
+ + + + +
+ +
+ +
+
+ + + running + of — +
+
+ + + players + + +
+
+ + + agents + +
+
+ + + CPU + + +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + diff --git a/controller/cmd/controller/static/rcc.css b/controller/cmd/controller/static/rcc.css new file mode 100644 index 00000000..d5b2a78d --- /dev/null +++ b/controller/cmd/controller/static/rcc.css @@ -0,0 +1,5809 @@ +/* ===================================================================== + * REFUGE COMMAND-CENTER — design-token spine for the "new panel". + * Re-themed 2026-06-12 onto the COMMAND DECK framework (the refugebot + * admin design system): squared geometry, hairline frames, flat graphite + * surfaces, one signal-orange accent + scan-blue data accent, Chakra + * Petch / IBM Plex type. Token NAMES are unchanged — every region + * stylesheet keys off these vars, so redefining values re-skins the app. + * Deck component overrides live at the END of this stylesheet. + * Fonts are BUNDLED (static/fonts/, go:embed) — no external font loads. + * ===================================================================== */ +@font-face{font-family:"Chakra Petch";font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/chakra-petch-500-latin.woff2) format("woff2");unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +@font-face{font-family:"Chakra Petch";font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/chakra-petch-600-latin.woff2) format("woff2");unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +@font-face{font-family:"Chakra Petch";font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/chakra-petch-700-latin.woff2) format("woff2");unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/ibm-plex-mono-400-latin.woff2) format("woff2");unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/ibm-plex-mono-500-latin.woff2) format("woff2");unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/ibm-plex-mono-600-latin.woff2) format("woff2");unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +@font-face{font-family:"IBM Plex Sans";font-style:normal;font-weight:400 600;font-display:swap;src:url(/fonts/ibm-plex-sans-latin.woff2) format("woff2");unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;} +:root { + /* ---- COMMAND DECK base — cold graphite, blue-leaning (matches the + refugebot admin "Command Deck" framework: squared geometry, + hairline frames, one signal-orange accent) ---- */ + --bg: #07090d; + --bg-deep: #05070a; + --bg-raised: #0a0d12; + --surface: #0d1117; + --surface-2: #11161d; + --surface-3: #161c25; + --border: rgba(232,237,242,.10); + --border-soft: rgba(232,237,242,.05); + --border-strong: rgba(232,237,242,.22); + --hairline: rgba(232,237,242,.07); + + /* ---- ink ---- */ + --text: #e8edf2; + --text-dim: #aab4c0; + --muted: #6b7684; + + /* ---- accents: signal orange (interactive/brand) + scan blue (data) ---- */ + --accent: #ff6b1a; /* signal */ + --accent-2: #5fb2ff; /* scan — cool data accent */ + --accent-3: #2ee06b; /* ok green */ + --accent-hot: #ff8a47; /* signal-hi */ + --amber: #ffc53d; + --ok: #2ee06b; + --warn: #ffc53d; + --err: #ff4242; + + --mono: 'IBM Plex Mono',ui-monospace,Consolas,monospace; + --sans: 'IBM Plex Sans',-apple-system,"Segoe UI",Roboto,sans-serif; + --display: 'Chakra Petch',system-ui,sans-serif; + + /* ---- radius scale — squared geometry, 12px is dead ---- */ + --radius: 2px; + --radius-lg: 2px; + --radius-sm: 2px; + --radius-pill: 3px; /* pills are rectangular tags now */ + + /* ---- elevation — flat; shadows reserved for floating layers ---- */ + --shadow-sm: none; + --shadow-md: 0 8px 24px rgba(0,0,0,.45); + --shadow-lg: 0 16px 48px rgba(0,0,0,.55); + --shadow-pop: 0 16px 48px rgba(0,0,0,.55), 0 0 0 1px var(--border-strong) inset; + + /* ---- "glow" is now a hairline ring (values stay box-shadow-shaped so + every box-shadow:var(--glow-*) consumer keeps working) ---- */ + --glow-accent: 0 0 0 1px rgba(255,107,26,.45); + --glow-violet: 0 0 0 1px rgba(95,178,255,.40); + --glow-ok: 0 0 0 1px rgba(46,224,107,.40); + --glow-err: 0 0 0 1px rgba(255,66,66,.45); + + /* ---- gradients flattened to one-step surfaces / signal fills ---- */ + --grad-accent: linear-gradient(180deg,#ff7b30 0%,#ff6b1a 100%); + --grad-accent-2:linear-gradient(180deg,#ff8a47 0%,#ff6b1a 100%); + --grad-hot: linear-gradient(180deg,#ff8a47 0%,#ff6b1a 100%); + --grad-surface: linear-gradient(180deg,#11161d 0%,#0d1117 100%); + --grad-card: linear-gradient(180deg,#0d1117 0%,#0d1117 100%); + --grad-card-hover: linear-gradient(180deg,#11161d 0%,#11161d 100%); + --grad-btn: linear-gradient(180deg,#11161d 0%,#0d1117 100%); + --grad-rail: linear-gradient(180deg,#0a0d12 0%,#0a0d12 100%); + --grad-danger: linear-gradient(180deg,#ff4242 0%,#e63232 100%); + --grad-ok: linear-gradient(180deg,#2ee06b 0%,#27c75e 100%); + + /* ---- motion — nothing bounces on the deck ---- */ + --ease-out: cubic-bezier(.2,0,.2,1); + --ease-in-out: cubic-bezier(.2,0,.2,1); + --ease-spring: cubic-bezier(.2,0,.2,1); + + /* density var the card grid reads (settings: cozy/comfortable/compact) */ + --svr-grid-min: 280px; + + /* ---- Deck-native tokens (mirrors refugebot site.css; used by the + Command Deck override layer at the end of this stylesheet). + Where a Deck name duplicated a spine token's literal value it is + now an alias — one flat token system, identical computed values. ---- */ + --signal:var(--accent); --signal-hi:var(--accent-hot); --signal-dim:rgba(255,107,26,.13); + --on-signal:var(--bg-raised); + --scan:var(--accent-2); --scan-dim:rgba(95,178,255,.12); + --ok-dim:rgba(46,224,107,.12); --err-dim:rgba(255,66,66,.12); --warn-dim:rgba(255,197,61,.12); + --line:var(--border); --line-hi:var(--border-strong); + --ink:var(--text); --ink-2:var(--text-dim); --ink-3:var(--muted); --ink-4:#3d4754; + --notch:10px; + --chamfer:polygon(0 0,calc(100% - var(--notch)) 0,100% var(--notch),100% 100%,0 100%); + --chamfer-sm:polygon(0 0,calc(100% - 6px) 0,100% 6px,100% 100%,0 100%); + --hazard:repeating-linear-gradient(135deg,rgba(255,66,66,.75) 0 10px,rgba(255,66,66,.08) 10px 20px); + --fast:120ms linear; --med:240ms cubic-bezier(.2,0,.2,1); +} + +/* faint scanline + grid texture token reused by chrome surfaces */ + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--sans); + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + min-height: 100vh; +} + +/* ---- Deck backdrop: flat graphite. The aurora is DEAD — depth comes from + hairlines and one-step surface shifts, never glow. A single faint + registration grid (the Deck's rationed texture) sits behind the shell; + static, so it composites once and never costs a frame. */ +body::before { + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; + background-image: + linear-gradient(rgba(255,255,255,.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); + background-size: 56px 56px; + mask-image: radial-gradient(ellipse 90% 80% at 50% 20%, #000 25%, transparent 75%); +} +body::after { content: none; } +.app-shell, .sidebar, .topbar, main, .app-main, .app-content, header.nav { position: relative; z-index: 1; } + +/* ---- shared keyframes used across surfaces ---- */ +@keyframes pulseDot { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.45;transform:scale(.72)} } +@keyframes pulseGlow { 0%,100%{opacity:.25} 50%{opacity:.9} } +@keyframes ringspin { to { transform: rotate(360deg); } } +@keyframes shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} } +@keyframes cardIn { from{opacity:0;transform:translateY(14px) scale(.97)} to{opacity:1;transform:none} } +@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:none} } +@keyframes popIn { from{opacity:0;transform:translateY(16px) scale(.96)} to{opacity:1;transform:none} } +@keyframes sweep { 0%{transform:translateX(-120%)} 100%{transform:translateX(220%)} } + +/* page-transition: content fades+rises when a new view is shown */ +.view-enter { animation: fadeUp .34s var(--ease-out) both; } + +/* scrollbars */ +*::-webkit-scrollbar { width: 11px; height: 11px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { background: rgba(232,237,242,.16); border-radius: 999px; border: 3px solid transparent; background-clip: content-box; } +*::-webkit-scrollbar-thumb:hover { background: rgba(232,237,242,.3); background-clip: content-box; } + +/* generic utility tokens many regions lean on */ +.dim { color: var(--text-dim); } +.mono { font-family: var(--mono); } +.muted { color: var(--muted); } + + +/* ===================== REGION: shell ===================== */ +/* =============== APP SHELL (sidebar + topbar + content) =============== */ +.app-shell { + display: grid; + grid-template-columns: 220px 1fr; + min-height: 100vh; +} + +.sidebar { + position: sticky; top: 0; height: 100vh; + display: flex; flex-direction: column; + background: var(--grad-rail); + border-right: 1px solid var(--border); + padding: 0; + overflow-y: auto; +} + +.side-brand { + display: flex; align-items: center; gap: 11px; + padding: 16px 16px 14px; + border-bottom: 1px solid var(--border-soft); + background: + radial-gradient(circle at 20% 0%, rgba(255,107,26,.08), transparent 60%), + linear-gradient(180deg, rgba(255,107,26,.02) 0%, transparent 100%); + position: relative; +} + +.side-brand .logo { + width: 36px; height: 36px; border-radius: var(--radius); + background: var(--grad-accent); + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + box-shadow: + var(--glow-accent), + inset 0 1px 0 rgba(255,255,255,.32), + inset 0 -1px 0 rgba(0,0,0,.18); + position: relative; + overflow: hidden; +} + +.side-brand .logo::before { + content: ""; position: absolute; inset: 0; + background: radial-gradient(circle at 30% 25%, rgba(255,255,255,.4), transparent 55%); + pointer-events: none; +} + +.side-brand .logo .mark { + font-size: 14px; font-weight: 800; color: #0a0e17; + letter-spacing: -.5px; line-height: 1; + font-family: var(--sans); + text-shadow: 0 1px 0 rgba(255,255,255,.2); + position: relative; z-index: 1; +} + +.side-brand .word { + display: flex; flex-direction: column; gap: 2px; min-width: 0; +} + +.side-brand .word .name { + font-weight: 700; font-size: 14.5px; + background: var(--grad-accent-2); + -webkit-background-clip: text; background-clip: text; color: transparent; + letter-spacing: .2px; line-height: 1; +} + +.side-brand .word .sub { + font-size: 9.5px; font-weight: 600; + color: var(--text-dim); opacity: .85; + text-transform: uppercase; letter-spacing: .9px; + line-height: 1; +} + +.side-nav { + display: flex; flex-direction: column; gap: 1px; + padding: 10px 8px; +} + +.side-section { + font-size: 10px; font-weight: 600; + color: var(--muted); text-transform: uppercase; letter-spacing: 1.1px; + padding: 10px 10px 6px; +} + +.side-link { + display: flex; align-items: center; gap: 10px; + padding: 7px 10px; border-radius: var(--radius-sm); + font-size: 12.5px; color: var(--text-dim); font-weight: 500; + text-decoration: none; cursor: pointer; + transition: background .12s var(--ease-out), color .12s var(--ease-out), box-shadow .18s var(--ease-out); + position: relative; +} + +.side-link:hover { + background: rgba(255,107,26,.08); + color: var(--text); text-decoration: none; +} + +.side-link.active { + background: rgba(255,107,26,.12); + color: var(--accent); +} + +.side-link.active::before { + content: ""; position: absolute; left: -8px; top: 6px; bottom: 6px; width: 2px; + background: var(--grad-accent); border-radius: 0 2px 2px 0; +} + +.side-link .ic { + font-size: 14px; width: 18px; + display: inline-flex; align-items: center; justify-content: center; + flex: 0 0 18px; +} + +.side-link .ic svg { fill: currentColor; } + +.side-link .count { + margin-left: auto; font-size: 10.5px; + background: rgba(255,107,26,.06); + padding: 1px 7px; border-radius: var(--radius); + font-variant-numeric: tabular-nums; + color: var(--accent); + font-weight: 600; +} + +.side-actions { + padding: 8px 12px 12px; + display: flex; flex-direction: column; gap: 6px; + border-top: 1px solid var(--border-soft); +} + +.side-actions button { width: 100%; justify-content: center; } + +.side-foot { + margin-top: auto; + padding: 10px 14px 6px; + display: flex; align-items: center; gap: 8px; + font-size: 11.5px; color: var(--text-dim); + border-top: 1px solid var(--border-soft); +} + +.side-user { + display: flex; align-items: center; gap: 8px; + padding: 6px 14px 12px; + font-size: 11px; color: var(--muted); +} + +.side-user #whoami { + flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +/* Top bar inside the content pane — page title + search + breadcrumbs. */ +.app-main { + display: flex; flex-direction: column; + min-width: 0; /* prevent grid blowout */ + min-height: 100vh; +} + +.topbar { + position: sticky; top: 0; z-index: 8; + display: flex; align-items: center; gap: 14px; + padding: 13px 24px; + background: var(--grad-surface); + border-bottom: 1px solid var(--border); + backdrop-filter: saturate(1.3) blur(10px); + -webkit-backdrop-filter: saturate(1.3) blur(10px); +} + +.topbar h1 { + margin: 0; font-size: 15px; font-weight: 600; + letter-spacing: .2px; + color: var(--text); + display: flex; align-items: center; gap: 8px; +} + +.topbar h1 .muted { color: var(--text-dim); font-weight: 400; } + +.topbar-search { + position: relative; flex: 1; max-width: 320px; +} + +.topbar-search::before { + content: "🔍"; position: absolute; left: 10px; top: 50%; + transform: translateY(-50%); font-size: 11px; opacity: .6; + pointer-events: none; +} + +.topbar-search input { + width: 100%; padding: 7px 10px 7px 28px; + font-size: 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + transition: border-color .15s var(--ease-out), box-shadow .18s var(--ease-out); +} + +.topbar-search input:focus { + outline: none; + border-color: var(--accent); +} + +.topbar-spacer { flex: 1; } + +/* Live health pill — running/total + agents online. Updates every poll. */ +.topbar-health { + display: inline-flex; align-items: center; gap: 10px; + padding: 6px 12px; border-radius: 999px; + background: rgba(255,107,26,.06); + border: 1px solid rgba(255,107,26,.18); + font-size: 12px; color: var(--text-dim); + transition: border-color .15s var(--ease-out), background .15s var(--ease-out), box-shadow .18s var(--ease-out); +} + +.topbar-health:hover { + border-color: rgba(255,107,26,.35); + background: rgba(255,107,26,.10); +} + +.topbar-health .seg { display: inline-flex; align-items: center; gap: 5px; } + +.topbar-health .seg strong { + color: var(--text); font-weight: 700; font-variant-numeric: tabular-nums; +} + +.topbar-health .seg.run-seg .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--ok); + box-shadow: var(--glow-ok); + animation: pulseDot 2.4s ease-in-out infinite; +} + +.topbar-health .seg.agent-seg .dot { + width: 7px; height: 7px; border-radius: 50%; + background: var(--accent); + box-shadow: var(--glow-accent); +} + +.topbar-health .seg.agent-seg.warn .dot { + background: var(--warn); + box-shadow: 0 0 6px rgba(255,193,75,.6); +} + +.topbar-health .vsep { + width: 1px; height: 16px; + background: var(--border-soft); +} + +/* Topbar primary action — "+ New server". Always visible (regardless + of sidebar drawer state) so creating a server is a single tap. */ +.topbar-new-btn { + display: inline-flex; align-items: center; gap: 7px; + padding: 7px 13px; + border-radius: var(--radius); + background: var(--grad-accent); + border: 1px solid transparent; + color: #0a0e17; + font-size: 12.5px; font-weight: 700; + letter-spacing: .15px; + cursor: pointer; + transition: transform .12s var(--ease-out), box-shadow .18s var(--ease-out), background .15s var(--ease-out); + box-shadow: var(--glow-accent), inset 0 1px 0 rgba(255,255,255,.22); + white-space: nowrap; + flex: 0 0 auto; + min-height: 0; +} + +.topbar-new-btn:hover { + transform: translateY(-1px); + background: linear-gradient(135deg, #ff6b1a 0%, #2ee06b 48%, #5fb2ff 100%); +} + +.topbar-new-btn:active { transform: translateY(0); } + +.topbar-new-btn svg { fill: currentColor; pointer-events: none; flex: 0 0 auto; } + +@media (max-width: 900px) { + .topbar-new-btn { padding: 7px 11px; font-size: 12px; } +} + +@media (max-width: 480px) { + /* iPhone SE etc — collapse to a round icon-only button to save room. */ + .topbar-new-btn { width: 36px; padding: 0; justify-content: center; border-radius: 50%; } + .topbar-new-btn .lbl { display: none; } +} + +/* Topbar icon button — Settings cog. */ +.topbar-icon-btn { + width: 34px; height: 34px; + border-radius: var(--radius-sm); + background: rgba(255,107,26,.05); + border: 1px solid var(--border); + color: var(--text-dim); + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; + transition: color .15s var(--ease-out), background .15s var(--ease-out), border-color .15s var(--ease-out), transform .15s var(--ease-out), box-shadow .18s var(--ease-out); + box-shadow: none; + padding: 0; +} + +.topbar-icon-btn:hover { + color: var(--accent); + background: rgba(255,107,26,.12); + border-color: rgba(255,107,26,.3); + transform: none; +} + +.topbar-icon-btn svg { + width: 18px; height: 18px; fill: currentColor; + transition: transform .35s var(--ease-out); +} + +.topbar-icon-btn:hover svg { transform: rotate(45deg); } + +.app-content { + padding: 18px 24px 24px; + display: flex; flex-direction: column; gap: 16px; + max-width: none; /* override old main max-width; we have the sidebar now */ + margin: 0; +} + + +/* ===================== REGION: herostats ===================== */ + /* =============== HERO STATS (compact chip bar) =============== + Replaces the previous 4-up giant card grid (~85px tall) with a + single horizontal chip bar (~36px tall). Same IDs are preserved so + the existing refreshHeroStats() logic continues to find each value. + Each chip carries a colored dot + value + label + secondary hint; + wraps gracefully on narrow viewports. */ + /* Cluster overview band — the at-a-glance header. Reflowed from a row of + small pills into a row of proper stat cards: big number on top, label + + hint below, an accent rail down the left edge, live dot top-right. Same + markup (.stat-chip > .dot/.val/.lbl/.hint), restyled. */ + .hero-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + align-items: stretch; + margin-bottom: 4px; + } + .stat-chip { + position: relative; + display: flex; flex-direction: column; align-items: flex-start; gap: 6px; + padding: 16px 18px 15px 20px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: + linear-gradient(180deg, rgba(255,255,255,.025), rgba(255,255,255,0)), + var(--bg-raised); + overflow: hidden; + transition: all .2s var(--ease-out); + cursor: default; + } + /* Left accent rail, colored per stat tone. */ + .stat-chip::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: var(--text-dim); opacity: .8; + } + .stat-chip.ok::before { background: var(--grad-accent-2); } + .stat-chip.acc2::before { background: linear-gradient(180deg,var(--accent-2),#5fb2ff); } + .stat-chip.warn::before { background: linear-gradient(180deg,var(--amber),#ff8a3d); } + .stat-chip.acc::before { background: var(--grad-accent); } + /* Faint radial wash echoing the tone, behind the content. */ + .stat-chip::after { + content: ""; position: absolute; right: -30%; top: -60%; + width: 80%; height: 200%; border-radius: 50%; + background: radial-gradient(closest-side, rgba(255,107,26,.10), transparent 70%); + pointer-events: none; opacity: .9; + } + .stat-chip.ok::after { background: radial-gradient(closest-side, rgba(46,224,107,.12), transparent 70%); } + .stat-chip.acc2::after { background: radial-gradient(closest-side, rgba(95,178,255,.12), transparent 70%); } + .stat-chip.warn::after { background: radial-gradient(closest-side, rgba(255,177,59,.12), transparent 70%); } + .stat-chip:hover { + border-color: rgba(255,107,26,.3); + transform: translateY(-2px); + box-shadow: 0 8px 28px rgba(0,0,0,.4), var(--glow-accent); + } + .stat-chip .dot { + position: absolute; top: 14px; right: 14px; + width: 8px; height: 8px; border-radius: 50%; + background: var(--text-dim); + transition: all .2s var(--ease-out); + } + .stat-chip.ok .dot { background: var(--ok); box-shadow: 0 0 12px rgba(46,224,107,.8), 0 0 24px rgba(46,224,107,.4); animation: pulseDot 2.4s ease-in-out infinite; } + .stat-chip.acc2 .dot { background: var(--accent-2); box-shadow: 0 0 10px rgba(95,178,255,.7), 0 0 20px rgba(95,178,255,.35); } + .stat-chip.warn .dot { background: var(--amber); box-shadow: 0 0 10px rgba(255,177,59,.7), 0 0 20px rgba(255,177,59,.35); animation: pulseDot 2s ease-in-out infinite; } + .stat-chip .val { + order: 2; + font-size: 30px; font-weight: 800; + font-variant-numeric: tabular-nums; + color: var(--text); + letter-spacing: -.5px; + line-height: 1; + transition: color .2s var(--ease-out); + } + .stat-chip.ok .val { color: var(--ok); text-shadow: 0 0 14px rgba(46,224,107,.4); } + .stat-chip.acc2 .val { color: var(--accent-2); text-shadow: 0 0 14px rgba(95,178,255,.35); } + .stat-chip.warn .val { color: var(--amber); text-shadow: 0 0 14px rgba(255,177,59,.35); } + .stat-chip.acc .val { color: var(--accent); text-shadow: 0 0 14px rgba(255,107,26,.35); } + .stat-chip .lbl { + order: 1; + font-size: 11px; font-weight: 700; text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-dim); + line-height: 1; + transition: color .2s var(--ease-out); + } + .stat-chip:hover .lbl { color: var(--text); } + .stat-chip .hint { + order: 3; + font-size: 11.5px; color: var(--muted); + line-height: 1; + opacity: .9; + transition: opacity .2s var(--ease-out); + } + /* Cluster-health sparkline — sits across the bottom of the card, behind + the text, as a subtle trend band. preserveAspectRatio=none lets it + stretch to the card width. */ + .stat-spark { + position: absolute; left: 0; right: 0; bottom: 0; + width: 100%; height: 30px; + pointer-events: none; z-index: 0; opacity: .85; + } + .stat-spark .spark-line { fill: none; stroke-width: 1.5; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; } + .stat-spark .spark-area { stroke: none; } + /* Keep the number + label legible above the sparkline. */ + .stat-chip .val, .stat-chip .lbl, .stat-chip .hint, .stat-chip .dot { position: relative; z-index: 1; } + @keyframes chipGlow { + from { box-shadow: var(--border); } + to { box-shadow: var(--glow-accent); } + } + @media (max-width: 720px) { + .hero-stats { grid-template-columns: repeat(2, 1fr); gap: 8px; } + .stat-chip { padding: 12px 12px 11px 15px; } + .stat-chip .val { font-size: 24px; } + } + + /* =============== GLOBAL ACTION INDICATORS (in-place button states) =============== */ + @keyframes act-spin { to { transform: rotate(360deg); } } + @keyframes act-pop { from { transform: scale(.5); opacity: 0; } to { transform: scale(1); opacity: 1; } } + @keyframes act-progress { 0% { left: -30%; } 100% { left: 100%; } } + .act-spin { + width: 12px; height: 12px; flex-shrink: 0; + border: 1.6px solid rgba(255,107,26,.35); + border-top-color: currentColor; + border-radius: 50%; + animation: act-spin .65s linear infinite; + display: inline-block; vertical-align: -2px; + margin-right: 2px; + } + button.is-loading, a.is-loading { + position: relative; + pointer-events: none; + opacity: 1 !important; + } + button.is-loading::after { + /* thin progress bar along the bottom edge during loading */ + content: ""; position: absolute; bottom: 0; height: 2px; left: -30%; width: 30%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: .95; + animation: act-progress 1s ease-in-out infinite; + box-shadow: 0 0 6px var(--accent); + } + button.is-success { + background: linear-gradient(180deg, rgba(46,224,107,.25), rgba(46,224,107,.1)) !important; + border-color: rgba(46,224,107,.6) !important; + color: var(--ok) !important; + pointer-events: none; + box-shadow: 0 0 16px rgba(46,224,107,.35) !important; + } + button.is-error { + background: linear-gradient(180deg, rgba(255,66,66,.25), rgba(255,66,66,.1)) !important; + border-color: rgba(255,66,66,.6) !important; + color: var(--err) !important; + box-shadow: 0 0 16px rgba(255,66,66,.35) !important; + } + button.is-success .act-check, button.is-error .act-x { + display: inline-block; animation: act-pop .18s var(--ease-out) both; + font-weight: 700; margin-right: 2px; + } + + /* Action-state overlay (appended to the button during runAction). Covers + the button's underlying label so the indicator is visible without + overwriting the innerHTML. When removeOverlay fires the button is + instantly back to its own label, which updateCard may have updated + to the new semantic state (Stop → Start) in the meantime — zero + mid-flight label desync. */ + .act-overlay { + position: absolute; inset: 0; + display: inline-flex; align-items: center; justify-content: center; gap: 5px; + font: inherit; color: inherit; padding: inherit; border-radius: inherit; + pointer-events: none; + backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); + animation: act-pop .15s var(--ease-out) both; + } + .act-overlay.is-success { background: linear-gradient(180deg, rgba(46,224,107,.32), rgba(46,224,107,.16)); color: var(--ok); box-shadow: inset 0 0 16px rgba(46,224,107,.2); } + .act-overlay.is-error { background: linear-gradient(180deg, rgba(255,66,66,.32), rgba(255,66,66,.18)); color: var(--err); box-shadow: inset 0 0 16px rgba(255,66,66,.2); } + .act-overlay.is-loading::after { + content: ""; position: absolute; bottom: 0; height: 2px; left: -30%; width: 30%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + opacity: .95; + animation: act-progress 1s ease-in-out infinite; + box-shadow: 0 0 6px var(--accent); + } + + /* Global progress overlay — pinned top strip when any long-running + action is in flight (uploads, recreates, etc). */ + .act-progress-global { + position: fixed; top: 0; left: 0; right: 0; height: 3px; + background: linear-gradient(90deg, transparent 0%, var(--accent) 25%, var(--accent-2) 50%, var(--accent) 75%, transparent 100%); + background-size: 200% 100%; + animation: act-global-slide 1.2s linear infinite; + z-index: 9999; + opacity: 0; pointer-events: none; + transition: opacity .15s; + box-shadow: 0 0 12px var(--accent); + } + .act-progress-global.on { opacity: 1; } + @keyframes act-global-slide { + from { background-position: 200% 0; } + to { background-position: -100% 0; } + } + +/* ===================== REGION: responsive ===================== */ + /* =============== responsive: tablet (900-1100px) =============== */ + @media (max-width: 1100px) and (min-width: 901px) { + .app-shell { grid-template-columns: 200px 1fr; } + .sidebar { width: 200px; } + } + + /* =============== responsive: small tablet / large phone (720-900px) =============== + Sidebar becomes a slide-in drawer triggered by the topbar hamburger. */ + @media (max-width: 900px) { + .app-shell { grid-template-columns: 1fr; min-height: 100dvh; } + .sidebar { + position: fixed; left: 0; top: 0; bottom: 0; + width: 280px; max-width: 86vw; + z-index: 1000; + transform: translateX(-100%); + transition: transform .26s var(--ease-out); + box-shadow: 0 0 50px rgba(0,0,0,.6); + overflow-y: auto; + } + body.sidebar-open .sidebar { transform: translateX(0); } + .sidebar-backdrop { + position: fixed; inset: 0; + background: rgba(7,11,20,.6); + backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); + z-index: 999; + opacity: 0; pointer-events: none; + transition: opacity .22s ease-out; + } + body.sidebar-open .sidebar-backdrop { opacity: 1; pointer-events: auto; } + body.sidebar-open { overflow: hidden; } + /* Hamburger gets revealed in the topbar. */ + .topbar-menu-btn { display: inline-flex !important; } + /* Topbar tightens up so hamburger + title + cog all fit. */ + .topbar { padding: 11px 14px; gap: 10px; } + .topbar h1 { font-size: 14px; } + .topbar-search { display: none; } /* search lives in the filter strip on mobile */ + .topbar-health { display: none; } /* counts live in hero stats already */ + .app-content { padding: 14px 12px 24px; gap: 12px; } + /* Hero stats already wrap as chips — extra mobile tightening lives + in the .hero-stats rule above. Nothing to override here. */ + /* Filter strip wraps tighter. */ + .svr-toolbar { padding: 10px; gap: 8px; border-radius: var(--radius); } + .svr-tb-label { display: none; } + .svr-tb-sep { display: none; } + .svr-tb-spacer { display: none; } + .svr-pill { padding: 5px 10px; font-size: 12px; } + /* Server tile grid: smaller minmax = 2 per row at iPhone widths. */ + .svr-grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } + .svr-group-grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } + /* Modals fill the screen — no margin to waste. */ + .modal-bg { padding: 0; align-items: stretch; justify-content: stretch; } + .modal { + width: 100vw !important; height: 100dvh !important; + max-width: 100vw !important; max-height: 100dvh !important; + border-radius: 0 !important; + margin: 0 !important; + } + /* Modal heads stack title above actions on phones. */ + .modal-head { + flex-wrap: wrap; gap: 8px; padding: 10px 12px; + position: sticky; top: 0; z-index: 5; + background: var(--grad-card); + } + /* Instance modal — tabs scrollable, head wraps. */ + .im-head { padding: 12px 13px; flex-wrap: wrap; gap: 11px; } + .im-head .game-tile { width: 42px; height: 42px; flex: 0 0 42px; font-size: 22px; } + .im-head .titles .t { font-size: 16px; } + .im-head .titles { flex: 1 1 60%; } + .im-head .quick { width: 100%; flex-wrap: wrap; } + .im-act-primary span, .im-act-ghost span { font-size: 12.5px; } + .im-tabs { padding: 6px 8px; gap: 2px; } + .im-tab { padding: 7px 11px; font-size: 12px; } + .im-tab .icn { font-size: 12px; } + /* Field cards full-width on small panes. */ + .cfg-grid { grid-template-columns: 1fr; gap: 10px; } + /* Editor takes full viewport. */ + #editor-modal .modal { height: 100dvh !important; } + .modal-body.editor textarea { font-size: 14px; padding: 12px 14px; } + .editor-search-bar { flex-wrap: wrap; padding: 7px 10px; } + .editor-search-bar .es-help { display: none; } + .editor-search-bar .es-input-wrap { flex: 1 1 100%; max-width: none; } + /* Docs modal — rail collapses to a top horizontal scroll strip. */ + #docs-modal .modal { height: 100dvh !important; } + .docs-shell { flex-direction: column; } + .docs-rail { + width: auto; flex: 0 0 auto; + border-right: none; border-bottom: 1px solid var(--border-soft); + flex-direction: row; flex-wrap: nowrap; + overflow-x: auto; overflow-y: hidden; + gap: 6px; padding: 10px 12px; + } + .docs-rail-section { flex-direction: row; gap: 4px; } + .docs-rail-section-h { display: none; } + .docs-rail-search { + position: sticky; left: 0; flex: 0 0 auto; + border-bottom: none; padding: 0; margin: 0; + width: 160px; + } + .docs-rail-search input { padding: 5px 10px 5px 28px; font-size: 12px; } + .docs-rail-link { + flex: 0 0 auto; + padding: 7px 11px; font-size: 12px; + } + .docs-rail-link.indent { padding-left: 20px; } + .docs-rail-link.active::before { display: none; } + .docs-content { padding: 18px 18px 60px; font-size: 13px; } + .docs-content h1 { font-size: 22px; } + .docs-content h2 { font-size: 16px; } + /* Settings modal — sections stack cleaner. */ + .settings-row { flex-direction: column; align-items: flex-start; gap: 8px; } + .settings-row-ctrl select { width: 100%; min-width: 0; } + .about-card { flex-direction: column; align-items: flex-start; gap: 12px; text-align: left; } + /* Touch targets — buttons get a healthy tap size. */ + button.small { min-height: 36px; } + button { min-height: 38px; } + button.act-primary, button.act-restart, button.act-manage { min-height: 0; } /* tile buttons stay round */ + /* Toast positioning out of the safe area. */ + #toasts { + bottom: max(16px, env(safe-area-inset-bottom)); + right: max(12px, env(safe-area-inset-right)); + left: max(12px, env(safe-area-inset-left)); + max-width: none; + } + } + + @media (max-width: 720px) { + /* Settings tab inside the instance modal — single column on phones. */ + .im-set-grid { grid-template-columns: 1fr !important; gap: 10px; padding: 12px; } + /* Docs tables tighten so cell text wraps. */ + .docs-table { font-size: 11.5px; } + .docs-table th, .docs-table td { padding: 6px 8px; } + .docs-table th { font-size: 10px; } + /* DayZ / ARK mod grid: 1 per row instead of overflow. */ + .dzm-grid { grid-template-columns: 1fr !important; } + /* New-server modal module picker tightens. */ + .module-picker-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)) !important; gap: 10px; max-height: 60dvh; } + /* Form grids stack label above input on phones. */ + .form-grid { grid-template-columns: 1fr !important; gap: 6px 0; padding: 12px 14px; } + .form-grid > .row { width: 100%; } + /* Player table + mod row scrolls horizontally if needed. */ + .dzm-mod-row { flex-wrap: wrap; gap: 8px; } + /* Side-rail (Config tab) already collapses to top pills @820px; tighten further. */ + .cfg-rail-link { padding: 6px 9px; } + } + + /* =============== responsive: small phone (≤480px, e.g. iPhone SE) =============== */ + @media (max-width: 480px) { + .app-content { padding: 12px 10px 24px; gap: 10px; } + .hero-stats { grid-template-columns: repeat(2, 1fr); gap: 8px; } + .stat-chip { padding: 11px 11px 10px 14px; } + .stat-chip .val { font-size: 22px; } + .stat-chip .lbl { font-size: 10px; } + .svr-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; } + .svr-group-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; } + .svr-tile-name { font-size: 14px; } + .svr-tile-meta { font-size: 11px; } + .topbar { padding: 9px 12px; } + .modal-head h3 { font-size: 13px; } + } + + /* The hamburger button — hidden by default on desktop, shown on mobile via the rule above. */ + .topbar-menu-btn { + display: none; + width: 38px; height: 38px; + border-radius: var(--radius-sm); + background: rgba(255,107,26,.04); + border: 1px solid var(--border-soft); + color: var(--accent); + align-items: center; justify-content: center; + cursor: pointer; padding: 0; + flex: 0 0 38px; + transition: background .15s var(--ease-out), border-color .15s var(--ease-out), color .15s var(--ease-out), box-shadow .15s var(--ease-out); + box-shadow: none; + } + .topbar-menu-btn:hover { + background: rgba(255,107,26,.08); + border-color: rgba(255,107,26,.25); + color: var(--accent); + box-shadow: var(--glow-accent); + } + .topbar-menu-btn svg { width: 20px; height: 20px; fill: currentColor; } + + /* Backdrop element behind the open sidebar drawer (only meaningful below 900px). */ + .sidebar-backdrop { display: none; } + + /* =============== PULL-TO-REFRESH (mobile + PWA) =============== + The PWA has no browser chrome → no reload button. A drag-down + gesture from the top of the page triggers a soft re-poll of all + dashboard data. Indicator floats below the topbar / status bar. */ + #ptr-indicator { + position: fixed; + top: 0; left: 50%; + transform: translateY(-70px) translateX(-50%); + width: 46px; height: 46px; + border-radius: 50%; + background: var(--grad-card); + border: 1px solid var(--border-soft); + box-shadow: 0 8px 22px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.03); + z-index: 998; /* below modals (1000), above sidebar */ + display: flex; align-items: center; justify-content: center; + pointer-events: none; + opacity: 0; + color: var(--text-dim); + transition: background .15s var(--ease-out), border-color .15s var(--ease-out), color .15s var(--ease-out); + } + #ptr-indicator.ready { + border-color: rgba(255,107,26,.55); + color: var(--accent); + background: linear-gradient(180deg, rgba(15,22,34,.95) 0%, rgba(7,10,16,.95) 100%); + box-shadow: 0 8px 22px rgba(255,107,26,.32), inset 0 1px 0 rgba(255,255,255,.1), var(--glow-accent); + } + #ptr-indicator .ptr-arrow { + font-size: 20px; line-height: 1; font-weight: 700; + transition: transform .25s var(--ease-out); + display: inline-flex; + } + #ptr-indicator.ready .ptr-arrow { transform: rotate(180deg); } + #ptr-indicator.loading .ptr-arrow { display: none; } + #ptr-indicator .ptr-spin { + width: 22px; height: 22px; + border: 2.5px solid rgba(255,107,26,.16); + border-top-color: var(--accent); + border-radius: 50%; + animation: ptr-spin .8s linear infinite; + display: none; + } + #ptr-indicator.loading .ptr-spin { display: inline-block; } + @keyframes ptr-spin { to { transform: rotate(360deg); } } + /* Stop the iOS rubber-band overscroll on body so our custom indicator + isn't fighting the native bounce on every pull. */ + html, body { overscroll-behavior-y: contain; } + + /* Fine-grained scrollbars tuned to the theme. */ + ::-webkit-scrollbar { width: 10px; height: 10px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: rgba(255,107,26,.08); border-radius: var(--radius-sm); border: 2px solid transparent; background-clip: content-box; } + ::-webkit-scrollbar-thumb:hover { background: rgba(255,107,26,.15); background-clip: content-box; } + * { scrollbar-width: thin; scrollbar-color: rgba(255,107,26,.08) transparent; } + *, *::before, *::after { box-sizing: border-box; } + html, body { height: 100%; } + /* Suppress horizontal scroll on the body — wide elements are expected + to introduce their own internal scroll (e.g. tab strip, code blocks), + never cause the whole page to slide. */ + html, body { overflow-x: hidden; } + /* Kill the iOS double-tap-zoom delay on interactive elements; tap reads + as a click instantly. */ + button, a, .svr-pill, .docs-rail-link, .cfg-rail-link, .im-tab, + .svr-tile, .stat-card, label.toggle, [role="button"] { + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + } + /* Don't let iOS Safari adjust font sizes when the device rotates. */ + html { -webkit-text-size-adjust: 100%; } + body { + margin: 0; + font-family: var(--sans); + background: var(--bg); + color: var(--text); + font-size: 13px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + letter-spacing: .1px; + } + a { color: var(--accent); text-decoration: none; } + a:hover { text-decoration: underline; } + + /* =============== TOP NAV =============== */ + header.nav { + position: sticky; top: 0; z-index: 10; + display: flex; align-items: center; gap: 12px; + padding: 10px 22px; + background: var(--grad-surface); + border-bottom: 1px solid var(--border); + box-shadow: 0 1px 0 rgba(255,107,26,.08) inset, 0 2px 10px rgba(0,0,0,.35); + backdrop-filter: saturate(1.3) blur(10px); + -webkit-backdrop-filter: saturate(1.3) blur(10px); + } + .brand { + display: flex; align-items: center; gap: 10px; + font-weight: 600; font-size: 14px; letter-spacing: .2px; + font-family: var(--sans); + } + .brand .txt { + background: linear-gradient(135deg, #ff6b1a 0%, #5fb2ff 100%); + -webkit-background-clip: text; background-clip: text; color: transparent; + letter-spacing: .3px; + } + .brand .logo { + width: 28px; height: 28px; border-radius: var(--radius); + background: var(--grad-accent); + display: inline-flex; align-items: center; justify-content: center; + font-size: 14px; + box-shadow: 0 3px 10px rgba(255,107,26,.3), inset 0 1px 0 rgba(255,255,255,.3), var(--glow-accent); + position: relative; + } + .brand .logo::after { + content: ""; position: absolute; inset: 0; border-radius: inherit; + background: radial-gradient(ellipse at 30% 20%, rgba(255,255,255,.3), transparent 60%); + } + .nav-spacer { flex: 1; } + .nav .muted { color: var(--text-dim); font-size: 13px; } + .dot { + display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: var(--muted); vertical-align: middle; margin-right: 6px; + } + .dot.ok { background: var(--ok); box-shadow: 0 0 6px rgba(46,224,107,.6); } + .dot.err { background: var(--err); box-shadow: 0 0 6px rgba(255,66,66,.6); } + + /* =============== LAYOUT =============== */ + main { + max-width: 1400px; + margin: 0 auto; + padding: 20px 22px; + display: flex; flex-direction: column; gap: 16px; + } + section.panel { + background: var(--grad-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + overflow: hidden; + transition: background .2s var(--ease-out), box-shadow .2s var(--ease-out), border-color .2s var(--ease-out); + } + section.panel:hover { + background: var(--grad-card-hover); + box-shadow: var(--shadow-lg); + border-color: var(--border-strong); + } + section.panel > header { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-soft); + background: linear-gradient(180deg, rgba(255,107,26,.025), transparent); + } + section.panel > header h2 { + margin: 0; font-size: 11.5px; font-weight: 600; + text-transform: uppercase; letter-spacing: 1px; color: var(--text-dim); + display: flex; align-items: center; gap: 8px; + } + section.panel > header h2::before { + content: ""; width: 2px; height: 12px; + background: var(--grad-accent); border-radius: 1px; + } + section.panel > header .sub { color: var(--text-dim); font-size: 11.5px; font-weight: 400; } + section.panel > header .actions { margin-left: auto; display: flex; gap: 6px; } + +/* ===================== REGION: cards ===================== */ + /* =============== GRID OF SERVER CARDS =============== */ + .cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 14px; + padding: 16px; + } + .card { + background: var(--grad-card); + border: 1.5px solid rgba(255,107,26,.12); + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + position: relative; + transition: transform .16s var(--ease-out), border-color .18s, box-shadow .24s, background .2s; + box-shadow: var(--shadow-md), inset 0 1px 1px rgba(255,107,26,.06); + } + .card::after { + content: ""; position: absolute; inset: 0; border-radius: inherit; + pointer-events: none; opacity: 0; transition: opacity .24s var(--ease-out); + background: linear-gradient(135deg, rgba(255,107,26,.12) 0%, rgba(95,178,255,.06) 50%, transparent 80%); + } + .card:hover { + border-color: rgba(255,107,26,.35); + background: var(--grad-card-hover); + transform: translateY(-3px); + box-shadow: var(--shadow-lg), var(--glow-accent); + } + .card:hover::after { opacity: 1; } + .card:active { transform: translateY(-1px); transition-duration: .08s; } + .card.card-new { animation: cardsCardIn .28s var(--ease-out) both; } + @keyframes cardsCardIn { + from { opacity: 0; transform: translateY(12px) scale(.96); } + to { opacity: 1; transform: none; } + } + + .card.is-stopping { border-color: rgba(255,177,59,.55); } + .card.is-stopping::before { + background: radial-gradient(closest-side, rgba(255,177,59,.4), rgba(255,177,59,0) 72%); + animation: cardsPulseGlow 1.6s ease-in-out infinite; + } + + .card.is-deleting { + opacity: .32; transform: scale(.985); + transition: opacity .35s ease, transform .35s ease; + pointer-events: none; + border-color: rgba(255,66,66,.5); + } + .card.is-deleting::after { + content: "DELETING…"; position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + font-size: 11px; font-weight: 700; letter-spacing: 1.2px; + color: #ff848f; background: rgba(20,8,8,.55); + pointer-events: none; z-index: 3; + } + + .card.is-installing, .card.is-starting, .card.is-ready { + position: relative; + overflow: visible; + will-change: border-color; + transition: border-color .35s ease-out; + } + .card.is-installing::before, .card.is-starting::before, .card.is-ready::before { + content: ""; position: absolute; inset: -5px; border-radius: inherit; + z-index: -1; + pointer-events: none; + will-change: opacity, transform; + transform: scale(1); + opacity: 0; + } + + .card.is-installing { border-color: rgba(255,107,26,.7); } + .card.is-installing::before { + background: radial-gradient(closest-side, rgba(255,107,26,.55), rgba(255,107,26,0) 72%); + animation: cardsPulseGlow 2.4s cubic-bezier(.45,.05,.55,.95) infinite; + } + + .card.is-starting { border-color: rgba(255,177,59,.65); } + .card.is-starting::before { + background: radial-gradient(closest-side, rgba(255,177,59,.5), rgba(255,177,59,0) 72%); + animation: cardsPulseGlow 2.4s cubic-bezier(.45,.05,.55,.95) infinite; + } + + .card.is-ready { border-color: rgba(46,224,107,.85); } + .card.is-ready::before { + background: radial-gradient(closest-side, rgba(46,224,107,.75), rgba(46,224,107,0) 70%); + animation: readyFlash 1.4s ease-out forwards; + } + @keyframes cardsPulseGlow { + 0%, 100% { opacity: 0; transform: scale(.96); } + 50% { opacity: .95; transform: scale(1.04); } + } + @keyframes readyFlash { + 0% { opacity: 1; transform: scale(1.00); } + 60% { opacity: .4; transform: scale(1.06); } + 100% { opacity: 0; transform: scale(1.08); } + } + + .card-hero { + position: relative; + aspect-ratio: 460/215; + background-size: cover; background-position: top center; + background-color: #070b14; + isolation: isolate; + overflow: hidden; + box-shadow: inset 0 -16px 8px -6px #070b14; + } + + .hero-players { + position: absolute; bottom: 8px; right: 10px; + z-index: 2; + font-size: 10px; font-weight: 600; + font-variant-numeric: tabular-nums; + color: rgba(255,255,255,.88); + text-shadow: 0 1px 3px rgba(0,0,0,.9), 0 0 4px rgba(255,107,26,.3); + letter-spacing: .2px; + pointer-events: none; + } + .hero-players.empty { color: rgba(255,255,255,.5); } + + .hero-port { + position: absolute; bottom: 30px; left: 12px; + z-index: 2; + font-family: var(--mono); + font-size: 10px; font-weight: 600; + color: rgba(255,107,26,.95); + background: rgba(7,11,20,.65); + padding: 3px 8px; border-radius: var(--radius-sm); + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255,107,26,.25); + pointer-events: none; + letter-spacing: .2px; + font-variant-numeric: tabular-nums; + } + .port-tag { + font-family: var(--mono); font-size: 10px; + color: rgba(255,107,26,.8); + background: rgba(255,107,26,.08); + padding: 2px 7px; border-radius: 4px; + margin-left: 6px; font-weight: 500; + letter-spacing: .2px; + font-variant-numeric: tabular-nums; + border: 1px solid var(--border-soft); + } + + .card-hero::before { + content: ""; position: absolute; inset: 0; + background: + linear-gradient(180deg, + rgba(7,11,20,0) 0%, + rgba(7,11,20,.2) 30%, + rgba(7,11,20,.6) 65%, + rgba(7,11,20,1) 85%, + rgba(7,11,20,1) 100%); + z-index: 1; + } + + .card-hero::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(120deg, transparent 35%, rgba(255,107,26,.18) 50%, transparent 65%); + transform: translateX(-120%); + transition: transform .8s var(--ease-out); + mix-blend-mode: screen; + z-index: 1; + } + .card:hover .card-hero::after { transform: translateX(120%); } + + .hero-top { + position: absolute; top: 8px; left: 10px; right: 10px; + z-index: 2; + display: flex; align-items: center; justify-content: space-between; gap: 6px; + } + + .hero-sub { + font-size: 9.5px; font-weight: 700; letter-spacing: .9px; + color: rgba(255,255,255,.95); + background: rgba(7,11,20,.7); + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + padding: 4px 10px; border-radius: var(--radius-pill); + border: 1px solid rgba(255,107,26,.25); + text-transform: uppercase; + max-width: 60%; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + box-shadow: inset 0 1px 1px rgba(255,255,255,.08); + } + + .hero-title { + position: absolute; bottom: 8px; left: 12px; right: 92px; + z-index: 2; + font-weight: 700; font-size: 16px; letter-spacing: .1px; + color: #fff; + text-shadow: 0 2px 8px rgba(0,0,0,.9), 0 0 8px rgba(255,107,26,.2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + + .card-head { + display: flex; align-items: center; gap: 10px; + padding: 12px; + border-bottom: 1px solid var(--border-soft); + background: linear-gradient(180deg, rgba(16,26,42,.5) 0%, rgba(11,17,28,.8) 100%); + } + + .game-tile { + width: 40px; height: 40px; flex: 0 0 40px; + border-radius: var(--radius-sm); + display: flex; align-items: center; justify-content: center; + font-size: 20px; + box-shadow: + inset 0 1px 2px rgba(255,107,26,.15), + inset 0 -1px 2px rgba(0,0,0,.3), + 0 4px 12px rgba(0,0,0,.4); + position: relative; + overflow: hidden; + border: 1px solid var(--border-soft); + } + .game-tile img { + position: absolute; inset: 0; + width: 100%; height: 100%; + object-fit: cover; + object-position: center 30%; + border-radius: inherit; + display: block; + } + .game-tile::after { + content: ""; position: absolute; inset: 0; border-radius: inherit; + background: linear-gradient(180deg, rgba(255,255,255,.18), transparent 50%); + pointer-events: none; + z-index: 1; + } + .game-tile[data-has-icon="1"]::after { + background: linear-gradient(180deg, rgba(255,255,255,.12), transparent 40%); + } + + .card-head .meta { min-width: 0; flex: 1; } + .card-head .title { + font-weight: 600; font-size: 13px; color: var(--text); + letter-spacing: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .card-head .sub { font-size: 11px; color: var(--text-dim); margin-top: 2px; font-weight: 400; } + + .status-pill { + display: inline-flex; align-items: center; gap: 7px; + padding: 6px 14px; border-radius: var(--radius-pill); + font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .8px; + border: 1.5px solid; + position: relative; + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); + white-space: nowrap; + flex-shrink: 0; + transition: background .25s, color .25s, border-color .25s, transform .15s, box-shadow .25s; + } + .status-pill::before { + content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%; + background: currentColor; box-shadow: 0 0 10px currentColor, 0 0 0 2px rgba(0,0,0,.3); + } + + .card.is-installing .status-pill, + .card.is-starting .status-pill { transform: scale(1.08); } + + .status-pill.running { + background: linear-gradient(180deg, rgba(46,224,107,.4), rgba(46,224,107,.18)); + color: #d1fae5; + border-color: rgba(46,224,107,.6); + box-shadow: 0 0 12px rgba(46,224,107,.25); + } + .status-pill.running::before { animation: cardsPulseDot 2s ease-in-out infinite; } + @keyframes cardsPulseDot { + 0%, 100% { opacity: 1; transform: scale(1); box-shadow: 0 0 10px currentColor, 0 0 0 2px rgba(0,0,0,.3); } + 50% { opacity: .5; transform: scale(.85); box-shadow: 0 0 5px currentColor, 0 0 0 1px rgba(0,0,0,.2); } + } + @keyframes im-dzm-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(250%); } + } + + +/* ===================== REGION: dayzmods ===================== */ + +/* ============ DayZ Mods tab (Workshop manager) — REFUGE COMMAND-CENTER ============ */ +@keyframes im-dzm-cardIn { + from { opacity: 0; transform: translateY(6px) scale(.98); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +@keyframes im-dzm-successFlash { + 0% { box-shadow: 0 0 0 0 rgba(46,224,107,.6), 0 6px 18px rgba(0,0,0,.45); } + 60% { box-shadow: 0 0 0 12px rgba(46,224,107,0), 0 6px 18px rgba(0,0,0,.45); } + 100% { box-shadow: 0 0 0 0 rgba(46,224,107,0), 0 6px 18px rgba(0,0,0,.45); } +} +@keyframes im-dzm-activePulse { +} +@keyframes im-dzm-spin { to { transform: rotate(360deg); } } + +/* Hero search bar. Full-width input, leading icon, neon-accented hero gradient. */ +.dzm-search-wrap { + position: relative; + display: flex; + align-items: stretch; + gap: 10px; + padding: 4px; + border-radius: var(--radius-sm); + background: var(--surface-2); + border: 1px solid var(--border-soft); + transition: border-color .2s, box-shadow .2s, background .2s; +} +.dzm-search-wrap:focus-within { + border-color: var(--accent); + background: var(--surface-3); + box-shadow: 0 0 0 2px rgba(255,107,26,.18); +} +.dzm-search-icon { + display: flex; align-items: center; justify-content: center; + width: 38px; color: var(--text-dim); font-size: 15px; + flex-shrink: 0; +} +.dzm-search-wrap input.dzm-search-input { + flex: 1; min-width: 0; + border: none; background: transparent; + padding: 10px 0; + font-size: 14px; + color: var(--text); +} +.dzm-search-wrap input.dzm-search-input:focus { + outline: none; box-shadow: none; background: transparent; border: none; +} +.dzm-search-spin { + width: 16px; height: 16px; + border: 2px solid rgba(255,107,26,.2); + border-top-color: var(--accent); + border-radius: 50%; + align-self: center; + animation: im-dzm-spin .7s linear infinite; + margin-right: 8px; + opacity: 0; transition: opacity .2s; +} +.dzm-search-spin.on { opacity: 1; } +.dzm-search-clear { + align-self: center; margin-right: 4px; + background: transparent; border: none; color: var(--text-dim); + width: 28px; height: 28px; padding: 0; font-size: 14px; + border-radius: 50%; cursor: pointer; + transition: color .15s, background .15s; +} +.dzm-search-clear:hover { background: rgba(255,107,26,.08); color: var(--accent); } + +.dzm-search-opts { + display: flex; gap: 14px; align-items: center; + margin-top: 8px; + font-size: 12px; color: var(--text-dim); +} +.dzm-search-opts label { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; } +.dzm-search-opts input[type=checkbox] { margin: 0; accent-color: var(--accent); } + +/* Result grid + cards — game-art hero with dark scrim + animated sheen. */ +.dzm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + margin-top: 4px; +} +.dzm-card { + position: relative; + display: flex; flex-direction: column; + padding: 0; + border-radius: var(--radius); + background: var(--grad-card); + border: 1px solid var(--border); + overflow: hidden; + cursor: pointer; + transition: transform .18s var(--ease-out), border-color .18s, box-shadow .18s, background .18s; + animation: im-dzm-cardIn .22s var(--ease-out); +} +.dzm-card:hover { + transform: translateY(-3px); + border-color: rgba(255,107,26,.28); + background: var(--grad-card-hover); + box-shadow: var(--glow-accent), 0 8px 24px rgba(0,0,0,.45); +} +.dzm-card-img { + width: 100%; height: 120px; + object-fit: cover; background: #111; + display: block; + transition: transform .25s var(--ease-out); + position: relative; +} +.dzm-card-img::after { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(180deg, transparent 30%, rgba(0,0,0,.4) 100%); + pointer-events: none; +} +.dzm-card:hover .dzm-card-img { transform: scale(1.04); } +.dzm-card-body { + display: flex; flex-direction: column; gap: 6px; + padding: 10px 12px; + flex: 1; +} +.dzm-card-title { + font-weight: 600; font-size: 13px; line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; + color: var(--text); +} +.dzm-card-meta { + font-size: 11px; color: var(--text-dim); + display: flex; gap: 6px; align-items: center; +} +.dzm-card-foot { + display: flex; gap: 6px; align-items: center; justify-content: space-between; + margin-top: auto; +} +.dzm-card-badge-installed { + font-size: 10.5px; padding: 3px 8px; border-radius: var(--radius); + background: linear-gradient(135deg, rgba(46,224,107,.25), rgba(46,224,107,.18)); + color: var(--ok); font-weight: 600; + border: 1px solid rgba(46,224,107,.35); +} + +/* Single-mod preview card (when user pastes an ID/URL) — hero card with accent accent. */ +.dzm-preview { + display: grid; + grid-template-columns: 200px 1fr auto; + gap: 14px; align-items: stretch; + padding: 14px; + border-radius: var(--radius); + background: var(--grad-card); + border: 1px solid var(--border); + animation: im-dzm-cardIn .22s var(--ease-out); + box-shadow: var(--shadow-md); +} +.dzm-preview-img { width: 100%; height: 120px; object-fit: cover; border-radius: var(--radius-sm); background: #111; } +.dzm-preview-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.dzm-preview-title { font-size: 16px; font-weight: 700; color: var(--text); } +.dzm-preview-meta { font-size: 12px; color: var(--text-dim); display: flex; gap: 12px; flex-wrap: wrap; } +.dzm-preview-actions { display: flex; flex-direction: column; gap: 6px; align-items: stretch; justify-content: center; min-width: 120px; } + +/* Installed-mod list entries — side accent stripe, hover lift + glow. */ +.dzm-list { display: flex; flex-direction: column; gap: 8px; } +.dzm-mod-row { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + background: var(--grad-card); + border: 1px solid var(--border); + transition: border-color .15s, background .15s, transform .15s, box-shadow .15s; + animation: im-dzm-cardIn .22s var(--ease-out); + position: relative; +} +.dzm-mod-row:hover { + border-color: rgba(255,107,26,.22); + background: var(--grad-card-hover); + transform: translateX(2px); + box-shadow: var(--shadow-sm); +} +.dzm-mod-row.client { border-left: 3px solid var(--accent); } +.dzm-mod-row.server { border-left: 3px solid var(--accent-2); } +.dzm-mod-row.just-added { animation: im-dzm-successFlash 1.8s var(--ease-out); border-color: rgba(46,224,107,.35); } +.dzm-mod-row-main { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } +.dzm-mod-row-title { + font-weight: 600; font-size: 13px; line-height: 1.3; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: var(--text); +} +.dzm-mod-row-sub { font-size: 11px; color: var(--text-dim); display: flex; gap: 8px; align-items: center; } +.dzm-mod-row-kind { + font-size: 10px; font-weight: 600; + padding: 2px 8px; border-radius: var(--radius); + transition: background .15s, color .15s; +} +.dzm-mod-row-kind.client { background: rgba(255,107,26,.16); color: var(--accent); } +.dzm-mod-row-kind.server { background: rgba(95,178,255,.16); color: var(--accent-2); } + +/* Section headings. */ +.dzm-section { + display: flex; justify-content: space-between; align-items: center; + margin: 16px 0 10px; +} +.dzm-section h4 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); } +.dzm-section .dim { font-weight: 400; color: var(--text-dim); } + +/* Empty-state placeholder. */ +.dzm-empty { + text-align: center; padding: 28px 16px; + color: var(--text-dim); font-size: 13px; + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + background: rgba(255,107,26,.02); +} +.dzm-empty .big { font-size: 24px; margin-bottom: 6px; color: var(--muted); } + +/* Download job cards — pulse on active, mint glow on done, red on error. */ +.dzm-job { + padding: 12px 14px; + border-radius: var(--radius-sm); + background: var(--grad-card); + border: 1px solid var(--border); + transition: border-color .2s, box-shadow .2s; + animation: im-dzm-cardIn .22s var(--ease-out); +} +.dzm-job.active { animation: im-dzm-cardIn .22s var(--ease-out), im-dzm-activePulse 2.2s var(--ease-in-out) infinite; border-color: rgba(255,107,26,.28); box-shadow: var(--glow-accent); } +.dzm-job.done { border-color: rgba(46,224,107,.4); animation: im-dzm-cardIn .22s var(--ease-out), im-dzm-successFlash 1.8s var(--ease-out); box-shadow: var(--glow-ok); } +.dzm-job.error { border-color: rgba(255,66,66,.35); box-shadow: var(--glow-err); } +.dzm-job.login_needed { border-color: rgba(255,177,59,.35); box-shadow: 0 0 16px rgba(255,177,59,.3), 0 0 40px rgba(255,177,59,.12); } +.dzm-job-progress { + height: 8px; border-radius: 4px; overflow: hidden; + background: rgba(255,255,255,.05); + margin: 8px 0 8px; + box-shadow: inset 0 0 4px rgba(0,0,0,.3); +} +.dzm-job-progress > div { + height: 100%; + transition: width .3s var(--ease-out); + background: var(--grad-accent); +} +.dzm-job-log { + font-family: var(--mono); font-size: 10.5px; + color: var(--text-dim); + margin: 0; padding: 6px 10px; + background: rgba(0,0,0,.4); + border-radius: 4px; + border: 1px solid var(--border-soft); + white-space: pre-wrap; word-break: break-all; + max-height: 60px; overflow: auto; +} + +/* Status pills — neon glow + animated dot for live states. */ +.status-pill.stopped { background: linear-gradient(180deg, rgba(93,107,134,.45), rgba(93,107,134,.22)); color: #c5d1e0; border: 1px solid rgba(93,107,134,.5); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.status-pill.starting { background: linear-gradient(180deg, rgba(255,177,59,.48), rgba(255,177,59,.20)); color: #fef3c7; border: 1px solid rgba(255,177,59,.6); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.status-pill.starting::before { animation: pulseDot .9s ease-in-out infinite; background: #fcd34d; width: 6px; height: 6px; border-radius: 50%; } +.status-pill.crashed { background: linear-gradient(180deg, rgba(255,66,66,.48), rgba(255,66,66,.20)); color: #fee2e2; border: 1px solid rgba(255,66,66,.6); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.status-pill.deleting { background: linear-gradient(180deg, rgba(255,66,66,.55), rgba(255,66,66,.28)); color: #fef2f2; border: 1px solid rgba(255,66,66,.7); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.status-pill.deleting::before { animation: pulseDot .7s ease-in-out infinite; background: #fca5a5; width: 6px; height: 6px; border-radius: 50%; } +.status-pill.stopping { background: linear-gradient(180deg, rgba(251,146,60,.48), rgba(251,146,60,.20)); color: #fed7aa; border: 1px solid rgba(251,146,60,.6); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.status-pill.stopping::before { animation: pulseDot 1.0s ease-in-out infinite; background: #fdba74; width: 6px; height: 6px; border-radius: 50%; } +.status-pill.creating { background: linear-gradient(180deg, rgba(255,107,26,.48), rgba(255,107,26,.20)); color: #cffafe; border: 1px solid rgba(255,107,26,.6); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.status-pill.installing { background: linear-gradient(180deg, rgba(255,107,26,.52), rgba(255,107,26,.25)); color: #cffafe; border: 1px solid rgba(255,107,26,.65); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; } +.status-pill.installing::before { animation: pulseDot .8s ease-in-out infinite; background: #a5f3fc; width: 6px; height: 6px; border-radius: 50%; } +.status-pill.ready { background: linear-gradient(180deg, rgba(46,224,107,.52), rgba(46,224,107,.26)); color: #d1fae5; border: 1px solid rgba(46,224,107,.7); border-radius: var(--radius-pill); padding: 4px 10px; font-size: 11px; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; box-shadow: var(--glow-ok); } +.status-pill.ready::before { animation: none; background: var(--ok); width: 6px; height: 6px; border-radius: 50%; box-shadow: 0 0 12px var(--ok); } + +.metrics { padding: 9px 12px 10px; display: flex; flex-direction: column; gap: 6px; } +.metric { display: flex; align-items: center; gap: 8px; font-size: 11px; } +.metric .label { + width: 40px; color: var(--text-dim); + text-transform: uppercase; letter-spacing: .7px; + font-size: 9.5px; font-weight: 600; +} +.metric .bar { + flex: 1; height: 5px; + background: rgba(0,0,0,.42); + border-radius: 3px; overflow: hidden; +} +.metric .fill { + height: 100%; width: 0%; border-radius: inherit; + transition: width .6s var(--ease-out), background .3s; + position: relative; + background: var(--grad-accent); +} +.metric .fill::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.25) 50%, transparent 100%); + transform: translateX(-100%); + /* PERF: the shimmer used to run infinitely on EVERY stat bar of EVERY + card — dozens of always-on animation layers. Now it plays only on the + card you're actually looking at (hover). */ +} +.card:hover .metric .fill::after, .metric:hover .fill::after { + animation: barShine 3s ease-in-out infinite; +} +@keyframes barShine { 50% { transform: translateX(100%); } 100% { transform: translateX(100%); } } +.metric .val { + width: 80px; text-align: right; + font-family: var(--mono); font-size: 10.5px; color: var(--text); + font-variant-numeric: tabular-nums; +} + +.actions-row { + display: flex; gap: 6px; flex-wrap: wrap; + padding: 10px 12px 12px; + border-top: 1px solid var(--border-soft); + background: linear-gradient(180deg, rgba(255,107,26,.02), rgba(95,178,255,.01)); +} + + +/* ===================== REGION: forms ===================== */ +/* =============== FORMS & BUTTONS =============== */ +button { + font-family: var(--sans); font-size: 12.5px; font-weight: 600; + padding: 7px 13px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-soft); + background: rgba(255,255,255,.05); color: var(--text); + cursor: pointer; + position: relative; overflow: hidden; + transition: background .15s var(--ease-out), border-color .15s, transform .1s, box-shadow .15s, color .12s; + letter-spacing: .1px; + line-height: 1.3; +} +button:hover { + background: rgba(255,255,255,.09); + border-color: var(--border); + color: #fff; +} +button:active { transform: scale(.98); transition-duration: .05s; } + +button.primary { + background: var(--grad-accent); + color: #05070a; + border-color: transparent; + font-weight: 700; + box-shadow: 0 2px 12px rgba(255,107,26,.28); +} +button.primary:hover { + filter: brightness(1.06); + box-shadow: 0 4px 18px rgba(255,107,26,.45); + border-color: transparent; + color: #05070a; +} +button.primary:active { transform: scale(.98); } + +button.danger { + color: #fca5a5; + border-color: rgba(255,66,66,.4); + background: rgba(255,66,66,.12); +} +button.danger:hover { + background: rgba(255,66,66,.22); + border-color: var(--err); + color: #fff; +} +button.danger:active { transform: scale(.98); } + +button.ghost { background: transparent; border-color: transparent; box-shadow: none; } +button.ghost:hover { background: rgba(255,107,26,.1); border-color: rgba(255,107,26,.3); } +button.small { padding: 5px 10px; font-size: 11.5px; border-radius: var(--radius-sm); } +button:disabled { opacity: .4; cursor: not-allowed; filter: none; transform: none !important; } +button:disabled:hover { filter: none; transform: none; box-shadow: 0 1px 0 rgba(255,255,255,.04) inset, 0 1px 2px rgba(0,0,0,.25); } + +input, select, textarea { + font-family: var(--sans); font-size: 12.5px; + padding: 7px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + transition: border-color .18s, box-shadow .18s, background .18s; + line-height: 1.3; +} +input::placeholder, textarea::placeholder { + color: rgba(154,163,179,.42); + font-style: italic; + font-weight: 400; + letter-spacing: 0; +} +input:focus::placeholder, textarea:focus::placeholder { color: rgba(154,163,179,.28); } +input:hover, select:hover, textarea:hover { + border-color: var(--border); + background: var(--surface-3); +} +input:focus, select:focus, textarea:focus { + outline: none; + border-color: var(--accent); + background: var(--surface-3); +} +input.mono, textarea.mono { font-family: var(--mono); font-size: 12.5px; } + +/* Hide the native number spinners — they look ugly on dark backgrounds. */ +input[type="number"]::-webkit-outer-spin-button, +input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } +input[type="number"] { -moz-appearance: textfield; font-family: var(--mono); } + +select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' stroke='%23ff6b1a' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + background-size: 12px; + -webkit-appearance: none; -moz-appearance: none; appearance: none; + padding-right: 30px; +} + +label { + font-size: 11.5px; color: var(--text-dim); + font-weight: 600; text-transform: uppercase; letter-spacing: .7px; +} + +/* iOS-style toggle switch (replaces checkbox in config forms). */ +.toggle { + position: relative; display: inline-flex; align-items: center; gap: 10px; + cursor: pointer; user-select: none; +} +.toggle input { position: absolute; opacity: 0; pointer-events: none; width: 0; height: 0; } +.toggle .track { + width: 40px; height: 22px; + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: 999px; + position: relative; + transition: background .2s, border-color .2s, box-shadow .2s; + flex-shrink: 0; +} +.toggle .track::after { + content: ""; position: absolute; + top: 2px; left: 2px; + width: 16px; height: 16px; border-radius: 50%; + background: #eef1fa; + box-shadow: 0 2px 4px rgba(0,0,0,.4); + transition: transform .22s var(--ease-out), background .2s; +} +.toggle input:checked + .track { + background: var(--grad-accent); + border-color: rgba(255,107,26,.55); + box-shadow: var(--glow-accent), inset 0 1px 0 rgba(255,255,255,.2); +} +.toggle input:checked + .track::after { transform: translateX(18px); background: #fff; } +.toggle .label-txt { font-size: 12.5px; font-weight: 500; color: var(--text); text-transform: none; letter-spacing: 0; } +.toggle .label-txt.on { color: var(--accent); } + +.form-grid { + display: grid; gap: 10px 14px; padding: 16px 18px; + grid-template-columns: 100px 1fr; align-items: center; +} +.form-grid > .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.form-grid input, .form-grid select { min-width: 0; flex: 1; } +.form-grid .row > * { min-width: 0; } +.form-grid .row > input, .form-grid .row > select { flex: 1 1 140px; } + +/* =============== MODULE PICKER TILES (New Server modal) =============== */ +.module-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; + width: 100%; + max-height: 480px; + overflow-y: auto; + padding: 2px 2px 4px; +} +.module-tile { + position: relative; + aspect-ratio: 16 / 9; + border-radius: var(--radius); + overflow: hidden; + cursor: pointer; + border: 1px solid var(--border); + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-color: var(--bg-raised); + transition: transform .18s var(--ease-out), border-color .15s, box-shadow .2s; + animation: cardIn .22s var(--ease-out) both; +} +.module-tile:hover { + transform: translateY(-3px) scale(1.02); + border-color: rgba(255,107,26,.5); + z-index: 1; +} +.module-tile.selected { + border-color: transparent; + box-shadow: + 0 0 0 2px var(--accent), + 0 0 24px rgba(255,107,26,.35), + 0 8px 28px rgba(0,0,0,.5); + transform: translateY(-2px); +} +.module-tile::before { + content: ""; position: absolute; inset: 0; + background: linear-gradient(135deg, rgba(255,107,26,.22) 0%, transparent 60%); + opacity: 0; transition: opacity .2s; +} +.module-tile:hover::before { opacity: 1; } +.module-tile .mt-name { + position: absolute; left: 0; right: 0; bottom: 0; + padding: 22px 12px 10px; + background: linear-gradient(0deg, rgba(0,0,0,.95) 15%, rgba(0,0,0,.6) 60%, rgba(0,0,0,0)); + font-size: 13.5px; font-weight: 600; + color: #fff; + letter-spacing: 0; + line-height: 1.3; + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + text-shadow: 0 1px 3px rgba(0,0,0,.5); +} +.module-tile .mt-check { + position: absolute; top: 8px; right: 8px; + width: 24px; height: 24px; border-radius: 50%; + background: var(--accent); + color: #0b1020; font-size: 15px; line-height: 24px; text-align: center; + font-weight: 800; + opacity: 0; transform: scale(.6); + transition: opacity .15s, transform .15s var(--ease-out); +} +.module-tile.selected .mt-check { opacity: 1; transform: scale(1); } + +/* Server-ID input pair: a friendly "Display name" field that mirrors + to the real kebab-case id on the side, and a live preview of the + final id + a toggle to edit the raw id manually. */ +.id-preview { + display: flex; align-items: center; gap: 8px; + margin-top: 6px; font-size: 11.5px; color: var(--text-dim); +} +.id-preview .mono { + font-family: var(--mono); color: var(--accent); + background: rgba(255,107,26,.12); + padding: 2px 8px; border-radius: 4px; + border: 1px solid rgba(255,107,26,.28); +} +.id-preview .edit-link { + margin-left: auto; font-size: 11px; cursor: pointer; + color: var(--text-dim); text-decoration: underline dotted; +} +.id-preview .edit-link:hover { color: var(--accent); } +/* The hint/help line under an input, living in the same grid cell without stretching the input. */ +.form-hint { + font-size: 11px; color: var(--text-dim); + margin-top: 2px; min-width: 0; + line-height: 1.4; +} + +/* =============== TABLES =============== */ +table { width: 100%; border-collapse: collapse; } +th, td { padding: 11px 18px; text-align: left; border-bottom: 1px solid var(--border-soft); } +th { + font-size: 10.5px; font-weight: 700; + text-transform: uppercase; letter-spacing: .8px; color: var(--text-dim); + background: linear-gradient(180deg, rgba(255,107,26,.04), transparent); +} +tr:last-child td { border-bottom: none; } +tr { transition: background .12s; } +tr:hover { background: rgba(255,107,26,.08); } +.mono { font-family: var(--mono); font-size: 12px; } +.dim { color: var(--text-dim); font-size: 12px; } + +/* =============== GLOBAL ACTION BAR =============== */ +.global-bar { + display: flex; gap: 10px; align-items: center; + padding: 4px 2px; +} +.global-bar .sub { + color: var(--text-dim); font-size: 12.5px; font-weight: 500; +} + +/* ===================== REGION: agentpanes ===================== */ +/* =============== AGENT PANES (each pane = full panel card) =============== */ +#agents-wrap { + display: flex; flex-direction: column; gap: 18px; +} +.agent-pane { + background: var(--grad-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + overflow: hidden; + transition: box-shadow .2s, border-color .2s, background .2s; +} +.agent-pane:hover { + box-shadow: var(--shadow-lg); + border-color: var(--border-strong); + background: var(--grad-card-hover); +} +.agent-pane-head { + display: flex; align-items: center; gap: 14px; + padding: 16px 22px; + background: linear-gradient(180deg, rgba(255,107,26,.04), rgba(255,107,26,.01)); + border-bottom: 1px solid var(--border-soft); + cursor: pointer; + user-select: none; + transition: background .15s, border-color .15s; +} +.agent-pane-head:hover { + background: linear-gradient(180deg, rgba(255,107,26,.08), rgba(255,107,26,.03)); + border-bottom-color: var(--border); +} +.agent-pane-head .chev { + color: var(--text-dim); font-size: 12px; flex-shrink: 0; + transition: transform .25s var(--ease-out), color .15s; + font-family: var(--mono); font-weight: 700; +} +.agent-pane-head:hover .chev { + color: var(--accent); +} +.agent-pane.collapsed .chev { + transform: rotate(-90deg); +} +.agent-icon { + width: 42px; height: 42px; border-radius: var(--radius); + background: var(--grad-accent); + display: flex; align-items: center; justify-content: center; font-size: 20px; + box-shadow: 0 6px 14px rgba(255,107,26,.35), inset 0 1px 0 rgba(255,255,255,.25); + flex-shrink: 0; + position: relative; + transition: box-shadow .25s; +} +.agent-pane-head:hover .agent-icon { + box-shadow: var(--glow-accent), inset 0 1px 0 rgba(255,255,255,.3); +} +.agent-icon::after { + content: ""; position: absolute; inset: 0; border-radius: inherit; + background: radial-gradient(ellipse at 30% 20%, rgba(255,255,255,.3), transparent 60%); + pointer-events: none; +} +.agent-icon.offline { + background: linear-gradient(135deg, #2a3244, #1f2632); + box-shadow: 0 4px 10px rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.08); + filter: grayscale(.75) opacity(.6); +} +.agent-pane-head .info { + min-width: 0; flex: 1; +} +.agent-pane-head .name { + font-weight: 700; font-size: 15.5px; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + letter-spacing: .15px; + display: flex; align-items: center; gap: 10px; + font-family: var(--display); +} +.agent-pane-head .name .id-dot { + font-family: var(--mono); font-size: 11px; font-weight: 500; color: var(--text-dim); + letter-spacing: .4px; +} +.agent-pane-head .name .offline-tag { + display: inline-block; + background: rgba(255,66,66,.12); color: #ff8a96; + border: 1px solid rgba(255,66,66,.3); + padding: 3px 9px; border-radius: var(--radius-pill); + font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; + font-family: var(--mono); + transition: all .15s; +} +.agent-pane-head:hover .offline-tag { + border-color: rgba(255,66,66,.5); + box-shadow: 0 0 10px rgba(255,66,66,.15); +} +.agent-pane-head .details { + font-size: 12px; color: var(--text-dim); margin-top: 3px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: var(--sans); +} +.agent-pane-head .details .mono { + font-family: var(--mono); font-size: 11.5px; color: var(--accent); +} +.agent-pane-head .notes { + font-size: 11.5px; color: var(--muted); margin-top: 3px; + font-style: italic; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: var(--sans); +} +.agent-pane-head .stats { + font-size: 12px; color: var(--text-dim); + white-space: nowrap; + display: flex; gap: 16px; align-items: center; + flex-shrink: 0; + font-family: var(--mono); +} +.agent-pane-head .stats .count { + display: flex; flex-direction: column; align-items: flex-end; gap: 1px; + font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; +} +.agent-pane-head .stats .count strong { + color: var(--accent); font-weight: 700; font-size: 18px; font-family: var(--mono); +} +.agent-pane-head .stats .running-dot { + display: inline-block; width: 7px; height: 7px; border-radius: 50%; + background: var(--ok); margin-right: 5px; + box-shadow: 0 0 8px var(--ok), 0 0 16px rgba(46,224,107,.2); + animation: pulseDot 2s ease-in-out infinite; +} +.agent-pane-head .actions { + display: flex; gap: 8px; flex-shrink: 0; +} +.agent-pane-head .actions button { + position: relative; z-index: 2; +} +.agent-pane-body { + transition: max-height .35s var(--ease-out), opacity .25s; + overflow: hidden; + max-height: 9999px; +} +.agent-pane.collapsed .agent-pane-body { + max-height: 0 !important; opacity: 0; +} +.agent-pane-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 18px; + padding: 20px; +} +.agent-pane-empty { + padding: 36px 22px; + color: var(--text-dim); font-size: 13px; + text-align: center; + background: linear-gradient(180deg, rgba(255,107,26,.02), transparent); + border-top: 1px solid var(--border-soft); + transition: background .2s; +} +.agent-pane-empty.offline { + background: linear-gradient(180deg, rgba(255,66,66,.04), transparent); + color: var(--muted); +} +.agent-pane-empty .big { + font-size: 36px; opacity: .25; margin-bottom: 8px; + animation: glowBreathe 4s ease-in-out infinite; +} +.agent-pane-empty button { + margin-top: 12px; +} + +/* =============== EVENT FEED =============== */ +#events { + font-family: var(--mono); font-size: 12px; height: 320px; overflow-y: auto; + padding: 14px 18px; + background: linear-gradient(180deg, rgba(4,6,10,.8), rgba(7,10,16,.95)); + border-top: 1px solid var(--border-soft); + position: relative; +} +#events::before { + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 1px; + background: linear-gradient(90deg, transparent, rgba(255,107,26,.1), transparent); + pointer-events: none; +} +.ev { + padding: 3px 0; line-height: 1.7; white-space: pre-wrap; word-break: break-all; + color: var(--text-dim); + transition: color .15s; +} +.ev:hover { + color: var(--text); +} +.ev .t { + color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; margin-right: 8px; + opacity: .6; + transition: opacity .15s; +} +.ev:hover .t { + opacity: .8; +} +.ev .k { + color: var(--accent); font-weight: 700; letter-spacing: .4px; +} +.ev.log .k { + color: #64748b; +} +.ev.state .k { + color: #ffc24b; +} +.ev.player .k { + color: #2ee06b; +} +.ev.log .i { + color: #a8b5d6; +} +.ev.state .i { + color: #fde09e; +} +.ev.player .i { + color: var(--ok); +} +.ev .i { + color: #a8b5d6; +} + +/* ===================== REGION: modal ===================== */ +/* =============== MODAL =============== */ +.modal-bg { + position: fixed; inset: 0; background: rgba(0,0,0,.82); z-index: 100; + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(6px); + animation: bgFadeIn .18s ease-out; +} +.modal-bg.closing { animation: bgFadeOut .14s ease-in forwards; } +.modal-bg.closing .modal { animation: modalOut .14s cubic-bezier(.4, 0, .7, .4) forwards; } +@keyframes bgFadeIn { from { opacity: 0; } to { opacity: 1; } } +@keyframes bgFadeOut { from { opacity: 1; } to { opacity: 0; } } +.modal { + background: linear-gradient(180deg, rgba(22,32,50,.95) 0%, rgba(11,17,28,.98) 100%); + border: 1px solid rgba(255,107,26,.18); + border-radius: var(--radius-lg); + width: min(1100px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + display: flex; flex-direction: column; overflow: hidden; + animation: modalIn .22s cubic-bezier(.16, 1, .3, 1); + will-change: transform, opacity; + position: relative; + transform-origin: center 30%; +} +.modal::before { + content:""; position:absolute; inset:0; border-radius: inherit; pointer-events:none; + background: radial-gradient(circle at 0% 0%, rgba(255,107,26,.08), transparent 40%), + radial-gradient(circle at 100% 0%, rgba(95,178,255,.06), transparent 40%); +} +.modal > * { position: relative; z-index: 1; } +@keyframes modalIn { + from { opacity: 0; transform: translateY(10px) scale(.94); } + to { opacity: 1; transform: none; } +} +@keyframes modalOut { + from { opacity: 1; transform: none; } + to { opacity: 0; transform: translateY(6px) scale(.97); } +} +@media (prefers-reduced-motion: reduce) { + .modal-bg { animation: bgFadeIn .12s linear; } + .modal-bg.closing { animation: bgFadeOut .1s linear forwards; } + .modal { animation: modalReducedIn .12s linear; will-change: opacity; } + .modal-bg.closing .modal { animation: modalReducedOut .1s linear forwards; } + @keyframes modalReducedIn { from { opacity: 0; } to { opacity: 1; } } + @keyframes modalReducedOut { from { opacity: 1; } to { opacity: 0; } } +} +.modal.small { width: min(480px, calc(100vw - 32px)); } +.modal.wide { width: min(780px, calc(100vw - 32px)); } +.modal.large { width: min(1240px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); } +.modal-head { + padding: 11px 16px; border-bottom: 1px solid var(--border-soft); + display: flex; align-items: center; gap: 10px; + background: linear-gradient(180deg, rgba(255,107,26,.05), transparent); +} +.modal-head .title { flex: 1; font-family: var(--mono); font-size: 12.5px; color: var(--text-dim); } +.modal-head h3 { margin: 0; font-size: 13.5px; font-weight: 600; flex: 1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--text);} +.modal-body { flex: 1; overflow: auto; } +/* ============================================================ + New-server modal — DEAD-SIMPLE single scrolling box (new.html). + THIS is the file the panel actually serves by default. + The modal body is ONE overflow:auto container holding the form, + options, game grid AND the actions bar. No flex height juggling, + no nested scrollers, no fixed pixel height. The Create button is + sticky to the bottom of that scroller and also in normal flow, so + it can never be trapped off-screen — if content (7DTD's tall + config) overflows, you just scroll the one box. + ============================================================ */ +#new-server-modal .modal { + display: block; /* override the global flex modal */ + height: auto; + max-height: calc(100dvh - 24px); + overflow: hidden; /* clip rounded corners; body scrolls */ +} +.modal-body.new-server-body { + display: block; + max-height: calc(100dvh - 24px - 46px); /* viewport cap minus the head bar */ + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + padding: 0; +} +.ns-form-top { + padding: 14px 20px 12px; + border-bottom: 1px solid var(--border-soft); +} +.ns-form-top .id-preview { margin-top: 6px; } +.ns-game-section { padding: 12px 20px 8px; } +.ns-game-header { + display: flex; align-items: baseline; gap: 12px; + padding-bottom: 8px; +} +.ns-game-scroll { padding-right: 4px; } +.ns-game-scroll .module-picker-grid { + max-height: none; + overflow: visible; + align-content: start; +} +.ns-game-scroll .module-tile { animation: none; } +.ns-game-scroll .module-tile { transform: translateZ(0); } +.ns-actions { + position: sticky; bottom: 0; z-index: 3; + padding: 12px 20px; + border-top: 1px solid var(--border-soft); + background: var(--surface); /* opaque so scrolled content doesn't show through */ + display: flex; justify-content: flex-end; gap: 8px; +} +@media (max-width: 720px) { + #new-server-modal .modal { height: 100dvh; max-height: 100dvh; border-radius: 0; } + .modal-body.new-server-body { max-height: calc(100dvh - 46px); } + .ns-form-top { padding: 12px 14px 10px; } + .ns-game-section { padding: 10px 14px 6px; } + .ns-actions { padding: 10px 14px; } +} +#editor-modal .modal { + width: min(1280px, calc(100vw - 32px)); + height: min(86vh, 920px); + max-height: 86vh; + display: flex; flex-direction: column; +} +.modal-body.editor { + display: flex; flex-direction: column; + flex: 1; min-height: 0; + background: linear-gradient(180deg, #030507 0%, #070a0e 100%); + overflow: hidden; + position: relative; +} +.editor-find-hl { + position: absolute; + pointer-events: none; + z-index: 5; + display: none; + border-left: 4px solid var(--accent); + background: linear-gradient(90deg, rgba(255,107,26,.22) 0%, rgba(95,178,255,.12) 50%, rgba(255,107,26,.08) 100%); + border-radius: 0 4px 4px 0; + transition: top .25s var(--ease-out); +} +.editor-find-hl.pulse { + animation: editorFindPulse 1.6s var(--ease-out); +} +@keyframes editorFindPulse { + 0% { + background: linear-gradient(90deg, rgba(255,107,26,.65) 0%, rgba(95,178,255,.42) 50%, rgba(255,107,26,.18) 100%); + border-left-width: 6px; + transform: translateX(-3px); + } + 18% { + transform: translateX(0); + } + 60% { + background: linear-gradient(90deg, rgba(255,107,26,.42) 0%, rgba(95,178,255,.28) 50%, rgba(255,107,26,.14) 100%); + } + 100% { + background: linear-gradient(90deg, rgba(255,107,26,.22) 0%, rgba(95,178,255,.12) 50%, rgba(255,107,26,.08) 100%); + border-left-width: 4px; + } +} +.editor-search-bar { + display: flex; align-items: center; gap: 8px; + padding: 8px 12px; + background: var(--bg-raised); + border-bottom: 1px solid var(--border-soft); + flex-shrink: 0; +} +.editor-search-bar .es-input-wrap { + position: relative; flex: 1; max-width: 360px; +} +.editor-search-bar .es-input-wrap::before { + content: "🔍"; position: absolute; left: 9px; top: 50%; + transform: translateY(-50%); font-size: 11px; opacity: .6; + pointer-events: none; +} +.editor-search-bar input[type="text"] { + width: 100%; padding: 6px 10px 6px 28px; font-size: 12.5px; + font-family: var(--mono); + background: var(--surface-2); + border: 1px solid var(--border-soft); + color: var(--text); + border-radius: var(--radius-sm); + transition: border-color .2s, box-shadow .2s; +} +.editor-search-bar input[type="text"]:focus { + outline: none; + border-color: var(--accent); +} +.editor-search-bar .es-count { + font-size: 11.5px; color: var(--text-dim); + font-family: var(--mono); min-width: 56px; text-align: center; + padding: 4px 8px; border-radius: var(--radius-sm); + background: rgba(255,255,255,.04); border: 1px solid var(--border-soft); +} +.editor-search-bar .es-count.has-match { color: var(--accent); border-color: rgba(255,107,26,.35); } +.editor-search-bar .es-count.no-match { color: var(--err); border-color: rgba(255,66,66,.25); } +.editor-search-bar button.es-nav { + width: 28px; height: 28px; padding: 0; + border-radius: var(--radius-sm); + background: rgba(255,255,255,.04); + border: 1px solid var(--border-soft); + color: var(--text-dim); + font-size: 12px; cursor: pointer; + display: inline-flex; align-items: center; justify-content: center; + transition: color .15s, background .15s, border-color .15s; + box-shadow: none; +} +.editor-search-bar button.es-nav:hover { color: var(--text); background: rgba(255,107,26,.12); border-color: rgba(255,107,26,.3); transform: none; } +.editor-search-bar button.es-nav:disabled { opacity: .35; cursor: not-allowed; } +.editor-search-bar .es-help { + font-size: 10.5px; color: var(--muted); + margin-left: auto; +} +.editor-search-bar .es-help kbd { + display: inline-block; padding: 1px 5px; border-radius: 3px; + background: rgba(255,107,26,.08); border: 1px solid var(--border-soft); + font-family: var(--mono); font-size: 10px; color: var(--text-dim); + margin: 0 2px; +} +.modal-body.editor textarea { + flex: 1; width: 100%; padding: 14px 18px; border: none; outline: none; resize: none; + background: linear-gradient(180deg, #030507 0%, #070a0e 100%); color: var(--text); + font-family: var(--mono); font-size: 13px; line-height: 1.6; + min-height: 0; + tab-size: 2; +} +.modal-body .pad { padding: 14px 16px; } +.modal-body code { background: rgba(255,107,26,.08); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); font-size: 11.5px; color: var(--accent); } + +/* =============== INSTANCE MODAL =============== */ +.im-head { + display: flex; align-items: center; gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid var(--border); + background: var(--bg-raised); + position: relative; + overflow: hidden; + isolation: isolate; +} +.im-head .banner { + position: absolute; inset: 0; + background-size: cover; background-position: center 35%; + opacity: 0; transition: opacity .3s; + z-index: -2; +} +.im-head.has-banner .banner { opacity: .28; } +.im-head.has-banner::after { + content:""; position:absolute; inset:0; z-index:-1; + background: linear-gradient(90deg, rgba(12,17,27,.92) 0%, rgba(12,17,27,.72) 55%, rgba(12,17,27,.55) 100%); +} +.im-head.has-banner .titles .t { color:#fff; text-shadow: 0 2px 8px rgba(0,0,0,.85); } +.im-head .game-tile { + width: 56px; height: 56px; flex: 0 0 56px; font-size: 30px; + border-radius: var(--radius); overflow: hidden; + display:flex;align-items:center;justify-content:center; + box-shadow: inset 0 1px 0 rgba(255,255,255,.12), 0 6px 18px rgba(0,0,0,.5); +} +.im-head .titles { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; } +.im-title-row { display: flex; align-items: center; gap: 10px; min-width: 0; } +.im-head .titles .t { + font-size: 19px; font-weight: 800; letter-spacing: -.3px; color: var(--text); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap; min-width: 0; +} +.im-head .titles .s { font-size: 12px; color: var(--text-dim); } +#im-bloodmoon { + flex: 0 0 auto; padding: 3px 9px; border-radius: var(--radius-pill); + font-size: 10.5px; font-weight: 700; + background: rgba(255,60,60,.18); border: 1px solid rgba(255,60,60,.5); color: #ff6b6b; +} +/* Live header stat chips. */ +.im-head-stats { display: flex; align-items: center; gap: 16px; margin-top: 5px; flex-wrap: wrap; } +.im-hstat { display: inline-flex; align-items: baseline; gap: 5px; font-size: 12px; color: var(--text-dim); } +.im-hstat[hidden] { display: none; } +.im-hstat .ic { font-size: 11px; opacity: .7; align-self: center; } +.im-hstat b { font-size: 14px; font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; } +.im-hstat .lbl { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; opacity: .65; } +.im-head .quick { display:flex; align-items: center; gap:7px; flex: 0 0 auto; } + +/* Modal header action buttons — match the card action row. */ +.im-act-primary { + height: 36px; padding: 0 16px; + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + border-radius: var(--radius-sm); border: 1px solid transparent; + font-size: 13px; font-weight: 700; cursor: pointer; + transition: transform .12s var(--ease-out), background .18s, box-shadow .2s, color .15s, border-color .18s; +} +.im-act-primary svg { width: 15px; height: 15px; } +.im-act-primary:active { transform: scale(.97); } +.im-act-primary.act-start { background: var(--grad-accent-2); color: #04200f; box-shadow: 0 2px 12px rgba(46,224,107,.3); } +.im-act-primary.act-start:hover:not(:disabled) { filter: brightness(1.07); box-shadow: 0 4px 18px rgba(46,224,107,.5); } +.im-act-primary.act-stop { background: rgba(255,66,66,.16); color: #fca5a5; border-color: rgba(255,66,66,.45); } +.im-act-primary.act-stop:hover:not(:disabled) { background: rgba(255,66,66,.26); color: #fff; border-color: var(--err); } +.im-act-ghost { + height: 36px; padding: 0 14px; + display: inline-flex; align-items: center; gap: 6px; + border-radius: var(--radius-sm); + background: rgba(255,255,255,.05); border: 1px solid var(--border-soft); + color: var(--text-dim); font-size: 13px; font-weight: 600; cursor: pointer; + transition: background .18s, border-color .18s, color .15s, transform .12s; +} +.im-act-ghost svg { width: 15px; height: 15px; } +.im-act-ghost:hover:not(:disabled) { background: rgba(255,177,59,.14); border-color: rgba(255,177,59,.5); color: var(--amber); } +.im-act-ghost:active { transform: scale(.97); } +.im-act-primary:disabled, .im-act-ghost:disabled { opacity: .4; cursor: not-allowed; } +.im-act-close { + width: 36px; height: 36px; flex: 0 0 36px; margin-left: 2px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: var(--radius-sm); + background: transparent; border: 1px solid var(--border-soft); + color: var(--text-dim); cursor: pointer; + transition: background .18s, border-color .18s, color .15s; +} +.im-act-close svg { width: 16px; height: 16px; } +.im-act-close:hover { background: rgba(255,66,66,.14); border-color: rgba(255,66,66,.5); color: #fca5a5; } +.im-tabs { + display: flex; gap: 3px; padding: 7px 14px; + background: var(--bg-deep); + border-bottom: 1px solid var(--border); + overflow-x: auto; + position: relative; + scrollbar-width: thin; + align-items: center; +} +.im-tabs::-webkit-scrollbar { height: 4px; } +.im-tabs::-webkit-scrollbar-thumb { background: rgba(255,107,26,.15); border-radius: 4px; } +.im-tab { + padding: 8px 13px; cursor: pointer; + font-size: 12.5px; font-weight: 600; + color: var(--text-dim); + border: 1px solid transparent; background: none; box-shadow: none; + border-radius: var(--radius-pill); + transition: color .16s, background .16s, border-color .16s; + white-space: nowrap; + position: relative; + display: inline-flex; align-items: center; gap: 6px; line-height: 1; + flex: 0 0 auto; +} +.im-tab:hover { color: var(--text); background: rgba(255,255,255,.05); } +.im-tab.active { + color: var(--accent); + background: rgba(255,107,26,.12); + border-color: rgba(255,107,26,.4); +} +.im-tab .icn { + font-size: 13px; + display: inline-flex; align-items: center; justify-content: center; + line-height: 1; height: 1em; width: 1em; + filter: saturate(1.05); +} +/* Divider between core tabs and the game-specific tab group. */ +.im-tab-divider { + width: 1px; height: 20px; flex: 0 0 1px; + background: var(--border); margin: 0 6px; +} +.im-panel { + display: none; flex: 1; overflow: auto; flex-direction: column; + animation: fadeSlide .26s var(--ease-out); +} +.im-panel.active { display: flex; } +@keyframes fadeSlide { from { opacity: 0; transform: translateY(8px) scale(.997); } to { opacity: 1; transform: none; } } +.modal-body.im-body { display: flex; flex-direction: column; padding: 0; } + +/* === EAH tab styling === */ +.eah-pills { display: flex; gap: 6px; padding: 12px 16px 8px; flex-wrap: wrap; border-bottom: 1px solid var(--border-soft); align-items: center; } +.eah-keyhint { margin-left: auto; font-size: 11px; color: rgba(255,107,26,.35); user-select: none; cursor: help; letter-spacing: .03em; } +textarea.eah-note { width: 100%; min-height: 28px; resize: vertical; font: inherit; font-size: 12px; padding: 4px 6px; border-radius: 4px; border: 1px solid var(--border-soft); background: rgba(11,17,28,.8); color: inherit; box-sizing: border-box; transition: border-color .25s, background .25s, box-shadow .25s; } +textarea.eah-note.saving { border-color: rgba(255,177,59,.4); background: rgba(255,177,59,.06); box-shadow: 0 0 10px rgba(255,177,59,.12); } +textarea.eah-note.saved { border-color: rgba(46,224,107,.4); background: rgba(46,224,107,.08); box-shadow: 0 0 12px rgba(46,224,107,.15); } +textarea.eah-note.save-err { border-color: rgba(255,66,66,.45); background: rgba(255,66,66,.08); box-shadow: 0 0 10px rgba(255,66,66,.15); } +dialog.eah-ban-dlg[open] { padding: 0; border: 1px solid rgba(255,107,26,.2); border-radius: var(--radius-sm); background: linear-gradient(180deg, rgba(22,32,50,.95) 0%, rgba(11,17,28,.98) 100%); color: #e8eaf2; max-width: 360px; } +dialog.eah-ban-dlg::backdrop { background: rgba(0,0,0,.65); } +dialog.eah-ban-dlg[open] .eah-ban-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } +dialog.eah-ban-dlg[open] .eah-ban-grid button { padding: 8px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-soft); background: rgba(255,107,26,.08); color: inherit; cursor: pointer; transition: background .15s, border-color .15s, box-shadow .15s; } +dialog.eah-ban-dlg[open] .eah-ban-grid button.danger { border-color: rgba(255,66,66,.3); } +dialog.eah-ban-dlg[open] .eah-ban-grid button.danger:hover { background: rgba(255,66,66,.15); border-color: rgba(255,66,66,.5); box-shadow: 0 0 10px rgba(255,66,66,.12); } +.live-row { padding: 2px 0; border-bottom: 1px dashed rgba(255,107,26,.06); white-space: pre-wrap; word-break: break-word; } +.live-row:hover { background: rgba(255,107,26,.04); } +.live-ts { color: rgba(147,161,189,.5); margin-right: 6px; } +.live-cmd { color: var(--accent); font-weight: 600; margin-right: 6px; } +.live-player { color: var(--amber); margin-right: 6px; } +.live-pf { color: var(--ok); margin-right: 6px; } +.live-detail { color: rgba(234,241,255,.8); } +.eah-pill { + background: rgba(255,107,26,.08); border: 1px solid var(--border-soft); + color: var(--text); padding: 6px 12px; border-radius: 999px; + font-size: 12.5px; cursor: pointer; transition: background .15s, border-color .15s, box-shadow .15s; +} +.eah-pill.active { background: var(--grad-accent); border-color: rgba(255,107,26,.4); color: #fff; box-shadow: 0 0 16px rgba(255,107,26,.3); font-weight: 600; } +.eah-body { flex: 1; overflow: auto; padding: 8px 16px 16px; } +.eah-body .pad { padding: 12px; } +.eah-body .empty { padding: 24px; text-align: center; color: var(--muted); } +.eah-body .empty.err { color: var(--err); font-family: var(--mono); white-space: pre-wrap; } +.eah-table { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.eah-table th, .eah-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border-soft); } +.eah-table th { color: var(--muted); font-weight: 500; font-size: 11.5px; text-transform: uppercase; letter-spacing: .04em; background: rgba(255,107,26,.04); } +.eah-table tbody tr:hover { background: rgba(255,107,26,.06); } +.eah-table button { margin-right: 4px; } +.eah-search { display: flex; gap: 8px; padding: 8px 12px; } +.eah-search input, .eah-search select { + flex: 1; padding: 6px 10px; background: rgba(11,17,28,.8); + border: 1px solid var(--border-soft); border-radius: 4px; color: var(--text); + transition: border-color .2s, box-shadow .2s; +} +.eah-search input:focus, .eah-search select:focus { + outline: none; + border-color: rgba(255,107,26,.35); +} +.eah-search select { flex: 0 0 200px; } +.eah-item-grid { display: grid; grid-template-columns: 1fr; gap: 4px; padding: 8px 12px; max-height: 60vh; overflow: auto; } +.eah-item-row { padding: 6px 10px; border-radius: 4px; background: rgba(255,107,26,.06); display: flex; gap: 10px; align-items: center; transition: background .12s; } +.eah-item-row:hover { background: rgba(255,107,26,.12); } +.eah-item-icon { width: 32px; height: 32px; flex: 0 0 32px; image-rendering: pixelated; background: rgba(11,17,28,.6); border-radius: 4px; } +.eah-modal { + position: fixed; inset: 0; background: rgba(0,0,0,.7); + z-index: 9999; display: flex; align-items: center; justify-content: center; +} +.eah-modal-card { + width: min(640px, 90vw); max-height: 80vh; display: flex; flex-direction: column; + background: linear-gradient(180deg, rgba(22,32,50,.95) 0%, rgba(11,17,28,.98) 100%); border: 1px solid rgba(255,107,26,.18); border-radius: var(--radius-sm); + box-shadow: 0 30px 80px rgba(0,0,0,.7); +} +.eah-modal-head { + padding: 12px 16px; font-weight: 500; display: flex; justify-content: space-between; align-items: center; + border-bottom: 1px solid var(--border-soft); +} +.eah-modal-head button { background: transparent; border: none; color: var(--muted); font-size: 16px; cursor: pointer; transition: color .15s; } +.eah-modal-head button:hover { color: var(--text); } +.eah-modal-card input#eah-give-q { margin: 12px; padding: 8px 12px; background: rgba(11,17,28,.8); border: 1px solid var(--border-soft); border-radius: 4px; color: var(--text); transition: border-color .2s, box-shadow .2s; } +.eah-modal-list { flex: 1; overflow: auto; padding: 0 12px 12px; } +.eah-give-row { padding: 8px 10px; border-radius: 4px; cursor: pointer; transition: background .12s; } +.eah-give-row:hover { background: rgba(255,107,26,.12); } +.eah-modal-foot { padding: 12px 16px; border-top: 1px solid var(--border-soft); } +.eah-scenario { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 4px; background: rgba(255,107,26,.06); margin-bottom: 6px; } +.eah-job { padding: 12px; border-radius: 4px; background: rgba(255,107,26,.06); } +.eah-job .err { color: var(--err); } +.eah-job-log { max-height: 240px; overflow: auto; font-size: 11.5px; padding: 8px; background: rgba(11,17,28,.8); border-radius: 4px; white-space: pre-wrap; border: 1px solid var(--border-soft); } +.eah-tpl { display: flex; gap: 8px; align-items: center; padding: 10px 12px; border-radius: 4px; background: rgba(255,107,26,.06); margin-bottom: 6px; } +.eah-map-canvas { background: rgba(11,17,28,.6); border-radius: var(--radius-sm); padding: 4px; border: 1px solid var(--border-soft); } +.chat-row { padding: 4px 6px; border-radius: 3px; display: grid; grid-template-columns: 110px 80px 140px 1fr; gap: 8px; align-items: baseline; font-size: 12.5px; transition: background .12s; } +.chat-row:hover { background: rgba(255,107,26,.06); } +.chat-ch { font-size: 11px; padding: 1px 6px; border-radius: 999px; text-align: center; font-weight: 500; } +.chat-ch-0 { background: rgba(46,224,107,.15); color: var(--ok); } +.chat-ch-1 { background: rgba(255,177,59,.15); color: var(--amber); } +.chat-ch-2 { background: rgba(95,178,255,.15); color: var(--accent-2); } +.chat-ch-3 { background: rgba(255,107,26,.15); color: var(--accent); } +.chat-ch-4 { background: rgba(255,138,71,.15); color: var(--accent-hot); } +.health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } +.health-card { padding: 10px 12px; border-radius: var(--radius-sm); background: rgba(255,107,26,.06); border: 1px solid var(--border-soft); transition: background .12s, border-color .12s; } +.health-card:hover { background: rgba(255,107,26,.1); border-color: rgba(255,107,26,.2); } +.galaxy-star { cursor: pointer; } +.galaxy-star:hover circle { stroke: var(--accent); stroke-width: 2; } +.galaxy-sector { padding: 6px 8px; margin-top: 6px; border-radius: 4px; background: rgba(255,107,26,.06); } +.galaxy-pf { padding: 2px 0 2px 12px; font-size: 12.5px; } +.ban-remaining { font-variant-numeric: tabular-nums; } + +/* Console tab — matches the main-dashboard Live events look so the two + feel like the same surface at different scopes. */ +.console-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; position: relative; } +/* "N new lines ↓" chip — shows while the user is scrolled up reading + history and new output is streaming in below. Click = jump to live. */ +.console-newchip { + position: absolute; + bottom: 68px; /* clears the command input bar */ + left: 50%; transform: translateX(-50%); + display: none; + padding: 5px 14px; + border: 1px solid rgba(255,107,26,.5); + border-radius: 999px; + background: var(--bg-raised); + color: var(--accent); + font-family: var(--mono); font-size: 11.5px; font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 14px rgba(0,0,0,.5); + z-index: 4; +} +.console-newchip.on { display: inline-block; } +.console-newchip:hover { background: rgba(255,107,26,.15); } +.im-install-banner { + display: flex; gap: 14px; align-items: flex-start; + padding: 14px 16px; + margin: 8px 12px; + background: linear-gradient(135deg, rgba(255,107,26,.14), rgba(95,178,255,.08)); + border: 1px solid rgba(255,107,26,.3); + border-radius: var(--radius-sm); + color: #e0e7ff; + animation: installBannerIn .25s var(--ease-out); +} +@keyframes installBannerIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: none; } +} +.im-install-banner .ib-spinner { + flex: 0 0 auto; + width: 22px; height: 22px; + border: 2.5px solid rgba(255,107,26,.25); + border-top-color: var(--accent); + border-radius: 50%; + animation: ibSpin .9s linear infinite; + margin-top: 2px; +} +@keyframes ibSpin { to { transform: rotate(360deg); } } +.im-install-banner .ib-body { flex: 1; min-width: 0; } +.im-install-banner .ib-title { + font-size: 13px; font-weight: 700; letter-spacing: .01em; + color: #ffffff; +} +.im-install-banner .ib-sub { + font-family: var(--mono); font-size: 11.5px; + color: rgba(255,107,26,.8); + margin-top: 4px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.im-install-banner .ib-hint { + font-size: 11.5px; color: rgba(234,241,255,.55); + margin-top: 6px; line-height: 1.45; +} +.console-out { + flex: 1; margin: 0; padding: 12px 16px; + font-family: var(--mono); font-size: 12px; + line-height: 1.55; + overflow: auto; white-space: pre-wrap; word-break: break-word; + /* The pit: flat near-black ground with faint scanlines, phosphor-warm + body text — voiced after the refugebot server console. */ + background-color: #050608; + background-image: repeating-linear-gradient( + 0deg, rgba(255,255,255,.012) 0 1px, transparent 1px 3px); + border-radius: 0; + /* Perf: isolate console layout/paint from the rest of the modal. + NOTE: no content-visibility on lines — lazy rendering made scrolling + feel like page-flips and turned scrollHeight into an estimate, which + broke jump-to-bottom (the repin chip glitch). */ + contain: layout paint; +} +.console-out .ln { + padding: 1px 0; display: flex; gap: 10px; align-items: baseline; +} +.console-out .ln .ts { + color: rgba(216,203,168,.38); flex-shrink: 0; font-size: 11px; + font-variant-numeric: tabular-nums; +} +.console-out .ln .txt { min-width: 0; flex: 1; } +.console-out .ln.cmd .txt { color: var(--accent); font-weight: 600; } +.console-out .ln.reply .txt { color: rgba(95,178,255,.9); } +.console-out .ln.err .txt { color: var(--err); } +.console-out .ln.sys .txt { color: var(--warn); font-style: italic; } +.console-out .ln.log .txt { color: #d8cba8; } +.console-input { + display: flex; gap: 8px; padding: 12px 16px; + border-top: 1px solid var(--border); background: var(--bg-raised); +} +.console-input input { font-family: var(--mono); } +.console-input > input { flex: 1; } + +/* Console command autocomplete + reference drawer. */ +.cch-wrap { position: relative; flex: 1; display: flex; min-width: 0; } +.cch-wrap > input { flex: 1; width: 100%; min-width: 0; } +.cch-drop { + position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; + max-height: 260px; overflow: auto; + background: var(--surface-2); border: 1px solid rgba(255,107,26,.2); + border-radius: var(--radius-sm); + box-shadow: 0 6px 22px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,107,26,.1); + z-index: 5; + display: none; +} +.cch-drop.on { display: block; } +.cch-item { + padding: 8px 10px; cursor: pointer; + border-bottom: 1px solid var(--border-soft); + font-size: 12px; line-height: 1.35; + transition: background .12s; +} +.cch-item:last-child { border-bottom: none; } +.cch-item.sel, .cch-item:hover { background: rgba(255,107,26,.12); } +.cch-item .cmd { font-family: var(--mono); font-weight: 600; color: var(--accent); } +.cch-item .args { font-family: var(--mono); color: var(--text-dim); margin-left: 6px; } +.cch-item .desc { color: var(--text-dim); font-size: 11px; margin-top: 2px; } + +.cch-drawer { + margin-top: 10px; + max-height: 0; overflow: hidden; + transition: max-height .25s var(--ease-out); + border-radius: var(--radius-sm); + border: 1px solid transparent; +} +.cch-drawer.on { + max-height: 420px; overflow: auto; + border-color: rgba(255,107,26,.2); + background: linear-gradient(180deg, rgba(22,32,50,.9) 0%, rgba(11,17,28,.92) 100%); +} +.cch-drawer .cch-head { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 12px; border-bottom: 1px solid var(--border-soft); + position: sticky; top: 0; background: linear-gradient(180deg, rgba(22,32,50,.9) 0%, rgba(11,17,28,.92) 100%); z-index: 1; +} +.cch-drawer .cch-head input { + flex: 1; margin-right: 8px; + font-family: var(--sans); font-size: 12px; + padding: 6px 10px; +} +.cch-cat { padding: 8px 12px; } +.cch-cat h5 { + margin: 0 0 4px; font-size: 11px; font-weight: 700; + color: var(--accent); text-transform: uppercase; letter-spacing: .8px; +} +.cch-cat .cch-row { + padding: 6px 8px; border-radius: var(--radius-sm); + display: flex; gap: 10px; align-items: flex-start; + cursor: pointer; font-size: 12px; + transition: background .1s; +} +.cch-cat .cch-row:hover { background: rgba(255,107,26,.1); } +.cch-cat .cch-row .mono { + font-family: var(--mono); font-size: 11.5px; + color: var(--accent); min-width: 130px; flex-shrink: 0; +} +.cch-cat .cch-row .args { + font-family: var(--mono); font-size: 11px; color: var(--text-dim); +} +.cch-cat .cch-row .desc { color: var(--text-dim); font-size: 11.5px; } +.cch-cat .cch-row .ex { + font-family: var(--mono); font-size: 10.5px; color: var(--muted); + margin-top: 2px; display: block; +} + +.cch-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-dim); } +.console-info { padding: 9px 18px; font-size: 11.5px; color: var(--text-dim); background: var(--bg-raised); border-bottom: 1px solid var(--border-soft); display:flex; gap:14px; align-items:center; transition: color .25s ease; } +.console-info .autoscroll { margin-left: auto; display:inline-flex; align-items:center; gap:6px; cursor: pointer; user-select: none; } +.console-info #im-c-clear { + padding: 4px 11px; border-radius: var(--radius-sm); font-size: 11.5px; font-weight: 600; + background: rgba(255,255,255,.05); border: 1px solid var(--border-soft); color: var(--text-dim); cursor: pointer; + transition: background .15s, border-color .15s, color .15s; +} +.console-info #im-c-clear:hover { background: rgba(255,255,255,.08); color: var(--text); border-color: var(--border); } +#im-c-mode.rcon-warn { color: var(--warn); font-weight: 600; } +#im-c-mode.rcon-warn::before { content: ""; display:inline-block; width:7px; height:7px; border-radius:50%; background:var(--warn); box-shadow:0 0 10px var(--warn); margin-right:6px; vertical-align:middle; animation: pulseDot 1.1s ease-in-out infinite; } + +/* Steam OpenID "Sign in through Steam" button. Uses Valve's official + button asset so the visual matches what users expect from any other + Steam-auth page. Wrapping in means the click navigates to the + OpenID redirect endpoint (no JS needed for the happy path). */ +.btn-steam-signin { + display: inline-flex; align-items: center; padding: 0; line-height: 0; + border: 1px solid transparent; border-radius: var(--radius-sm); overflow: hidden; + transition: transform .15s, box-shadow .15s, border-color .15s; + cursor: pointer; flex: 0 0 auto; +} +.btn-steam-signin:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(255,107,26,.2); border-color: rgba(255,107,26,.3); } +.btn-steam-signin img { display: block; height: 24px; } + +/* Fatal-log banner — surfaces when a known crash signature appears in + the live log stream. Sticky between the console-info chip and the + log output so it's impossible to miss while scrolling logs. */ +.im-fatal-banner { + position: relative; + margin: 10px 16px 0; + padding: 12px 36px 12px 14px; + border: 1px solid rgba(255,66,66,.35); + border-left: 4px solid var(--err); + background: linear-gradient(180deg, rgba(255,66,66,.12), rgba(255,66,66,.06)); + border-radius: var(--radius-sm); + font-size: 12.5px; + line-height: 1.5; + animation: im-fatal-slide-in .3s ease-out; +} +@keyframes im-fatal-slide-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } } +.im-fatal-head { display: flex; align-items: center; gap: 8px; font-weight: 700; color: var(--err); margin-bottom: 4px; font-size: 13px; } +.im-fatal-icon { font-size: 16px; } +.im-fatal-guidance { color: var(--text); margin-bottom: 6px; } +.im-fatal-line { color: var(--text-dim); font-size: 11px; opacity: .8; padding: 6px 8px; background: rgba(0,0,0,.25); border-radius: 4px; word-break: break-all; border: 1px solid rgba(255,66,66,.15); } +.im-fatal-close { + position: absolute; top: 6px; right: 6px; + background: transparent; border: 0; color: var(--text-dim); + font-size: 18px; line-height: 1; cursor: pointer; padding: 4px 8px; + transition: color .15s; +} +.im-fatal-close:hover { color: var(--text); } + +/* Steam-login banner inside the per-instance modal — fires when an + instance was created with a Steam-gated update_provider but no + credentials are cached yet. Sticky between the modal head and tab + body so it's impossible to miss. */ +.im-steam-banner { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; margin: 0; + background: linear-gradient(180deg, rgba(255,107,26,.14), rgba(255,107,26,.06)); + border-bottom: 1px solid rgba(255,107,26,.25); + color: rgba(234,241,255,.9); font-size: 13px; +} +.im-steam-banner button { margin-left: auto; } + +/* Delete-instance modal: type-to-confirm field colors green when the + id matches the live instance's id — a small but reassuring "yes, + this is what you mean" signal before the destructive action. */ +#del-confirm.ok { border-color: rgba(46,224,107,.4); background: linear-gradient(180deg, rgba(46,224,107,.08), rgba(46,224,107,.02)); box-shadow: 0 0 12px rgba(46,224,107,.15); } + +/* Orphaned-instances pane: the agent that owned these is no longer + connected/registered — instances exist in the DB but can't be + managed until the agent comes back. Renders with a muted red + accent so operators notice there's a problem to investigate. */ +.agent-pane.orphan { border-color: rgba(255,66,66,.25); background: linear-gradient(180deg, rgba(255,66,66,.06), rgba(255,66,66,.02)); } +.agent-pane.orphan .agent-pane-head { color: var(--err); } +.agent-pane.orphan .agent-icon { background: rgba(255,66,66,.12); border-color: rgba(255,66,66,.3); } + +/* Quick-action buttons on card - prevent click-through */ +.actions-row button, .actions-row .spacer { position: relative; z-index: 2; } + +/* ===== Players tab roster ===== */ +.pl-hero { + display: flex; align-items: center; gap: 16px; + margin: 16px; padding: 16px 18px; + background: var(--bg-raised); border: 1px solid var(--border-soft); + border-radius: var(--radius); +} +.pl-hero-count { display: flex; align-items: baseline; gap: 7px; flex: 0 0 auto; } +.pl-hero-num { font-size: 34px; font-weight: 800; color: var(--accent-2); letter-spacing: -.5px; line-height: 1; } +.pl-hero-cap { font-size: 16px; font-weight: 600; color: var(--text-dim); } +.pl-hero-lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); margin-left: 6px; align-self: center; } +.pl-hero-bar { flex: 1; height: 8px; border-radius: 999px; background: rgba(255,255,255,.07); overflow: hidden; min-width: 80px; } +.pl-hero-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .5s var(--ease-out); } +.pl-hero-pct { font-size: 12px; color: var(--text-dim); flex: 0 0 auto; font-variant-numeric: tabular-nums; } +.pl-roster { + display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; padding: 0 16px 16px; +} +.pl-card { + display: flex; align-items: center; gap: 11px; + padding: 11px 13px; + background: var(--bg-raised); border: 1px solid var(--border-soft); + border-radius: var(--radius); min-width: 0; + transition: border-color .15s, background .15s; +} +.pl-card:hover { border-color: rgba(95,178,255,.4); } +.pl-avatar { + width: 36px; height: 36px; flex: 0 0 36px; border-radius: 50%; + display: inline-flex; align-items: center; justify-content: center; + font-size: 15px; font-weight: 700; color: #fff; + background: hsl(var(--ph, 260) 55% 42%); + box-shadow: inset 0 1px 0 rgba(255,255,255,.18); +} +.pl-card-main { flex: 1; min-width: 0; } +.pl-card-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pl-card-meta { display: flex; gap: 8px; font-size: 11px; color: var(--text-dim); margin-top: 2px; overflow: hidden; } +.pl-card-meta .mono { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pl-card-extra { opacity: .8; } +.pl-kick { + flex: 0 0 auto; padding: 5px 11px; border-radius: var(--radius-sm); + background: rgba(255,66,66,.12); border: 1px solid rgba(255,66,66,.4); + color: #fca5a5; font-size: 11.5px; font-weight: 600; cursor: pointer; + transition: background .15s, color .15s, border-color .15s; +} +.pl-kick:hover:not(:disabled) { background: rgba(255,66,66,.25); color: #fff; border-color: var(--err); } +.pl-kick:disabled { opacity: .6; cursor: default; } + +/* Settings tab layout */ +.im-set-grid { + display: grid; gap: 16px; padding: 18px; + grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); +} +.im-set-card { + background: var(--bg-raised); border: 1px solid var(--border-soft); border-radius: var(--radius); overflow: hidden; + transition: border-color .2s, box-shadow .2s; +} +.im-set-card.danger { grid-column: 1 / -1; border-color: rgba(255,66,66,.25); } +.im-set-card.danger:hover { border-color: rgba(255,66,66,.35); box-shadow: 0 0 20px rgba(255,66,66,.12); } +.im-set-card > header { + padding: 10px 14px; font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .7px; + color: var(--text-dim); background: rgba(255,255,255,.012); + border-bottom: 1px solid var(--border-soft); +} +.im-set-card.danger > header { color: var(--err); background: linear-gradient(180deg, rgba(255,66,66,.08), transparent); } +.im-set-card > .body { padding: 14px; } +.im-set-card .kv { display: flex; gap: 12px; padding: 6px 0; align-items: baseline; } +.im-set-card .kv label { + min-width: 90px; text-transform: uppercase; font-size: 10.5px; font-weight: 600; + letter-spacing: .5px; color: var(--text-dim); +} +.im-set-ports-tbl { width: 100%; } +.im-set-ports-tbl th { font-size: 10.5px; } +.im-set-ports-tbl td { padding: 8px 10px; } + +/* Sub-tab navigation inside the Config tab. Each schema group becomes + a pill; clicking reveals just that group. Fits way more settings than + a single scroll. */ +.im-cfg-nav { + display: flex; gap: 4px; padding: 5px; + background: rgba(255,107,26,.06); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + margin-bottom: 18px; + flex-wrap: wrap; + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); +} +.cfg-tab { + padding: 8px 16px; border-radius: var(--radius-sm); + background: transparent; border: none; color: var(--text-dim); + cursor: pointer; font-size: 13px; font-weight: 500; + transition: color .18s, background .18s, box-shadow .2s; + white-space: nowrap; box-shadow: none; +} +.cfg-tab:hover { color: var(--text); background: rgba(255,107,26,.1); transform: none; } +.cfg-tab.active { + background: var(--grad-accent); + color: #0a0e17; font-weight: 700; + box-shadow: 0 4px 14px rgba(255,107,26,.35), inset 0 1px 0 rgba(255,255,255,.15); +} +.im-cfg-pane { display: none; } +.im-cfg-pane.active { + display: block; + animation: cfgPaneIn .22s var(--ease-out); +} +@keyframes cfgPaneIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.im-cfg-pane-desc { + font-size: 12.5px; color: var(--text-dim); + margin-bottom: 14px; padding: 10px 14px; + background: rgba(255,107,26,.1); border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); +} +/* Files-tab root picker — horizontal pills, one per browseable_root. */ +.fs-roots { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.fs-roots .label { + font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .7px; + color: var(--text-dim); margin-right: 4px; +} +.fs-root-btn { + padding: 6px 12px; border-radius: var(--radius-sm); + background: transparent; border: 1px solid var(--border-soft); + color: var(--text-dim); font-size: 12.5px; font-weight: 500; + cursor: pointer; transition: all .15s; box-shadow: none; + display: flex; flex-direction: column; align-items: flex-start; gap: 2px; +} +.fs-root-btn:hover { color: var(--text); border-color: rgba(255,107,26,.25); transform: none; } +.fs-root-btn.active { + background: var(--grad-accent); + color: #0a0e17; font-weight: 700; border-color: transparent; + box-shadow: 0 3px 10px rgba(255,107,26,.25); +} +.fs-root-btn .name { font-family: var(--sans); line-height: 1.1; } +.fs-root-btn .hint { font-size: 10.5px; opacity: .75; font-weight: 500; line-height: 1.1; } + +/* Files-tab table — selectable rows + checkbox column. */ +#im-f-wrap table { width: 100%; border-collapse: collapse; } +#im-f-wrap th, #im-f-wrap td { padding: 6px 10px; vertical-align: middle; } +#im-f-wrap th { font-size: 11px; text-transform: uppercase; letter-spacing: .5px; color: var(--text-dim); border-bottom: 1px solid var(--border-soft); position: sticky; top: 0; background: rgba(255,107,26,.04); z-index: 2; } +#im-f-wrap tbody tr { border-bottom: 1px solid var(--border-soft); transition: background .12s; } +#im-f-wrap tbody tr:hover { background: rgba(255,107,26,.08); } +#im-f-wrap tbody tr.selected { background: rgba(255,107,26,.14); } +#im-f-wrap tbody tr.selected:hover { background: rgba(255,107,26,.2); } +#im-f-wrap td.fs-chk { width: 26px; padding-right: 0; } +#im-f-wrap td.fs-chk input { cursor: pointer; } +#im-f-wrap td.fs-name-cell { user-select: none; } +#im-f-wrap td.fs-actions-cell { white-space: nowrap; text-align: right; } + +/* Drag-and-drop overlay covers the file pane when files are dragged + from the OS over the modal. */ +.fs-droppy { + position: absolute; inset: 0; + background: rgba(255,107,26,.16); + border: 2px dashed var(--accent); + border-radius: var(--radius); + display: flex; align-items: center; justify-content: center; + z-index: 4; pointer-events: none; + backdrop-filter: blur(2px); +} +.fs-droppy-msg { + font-size: 16px; font-weight: 700; color: var(--accent); + background: rgba(0,0,0,.4); padding: 14px 22px; border-radius: var(--radius); +} + +/* Upload progress overlay. */ +.fs-prog { + position: absolute; right: 16px; bottom: 16px; z-index: 5; + pointer-events: auto; +} +.fs-prog-card { + min-width: 280px; padding: 12px 14px; + background: linear-gradient(180deg, rgba(22,32,50,.95) 0%, rgba(11,17,28,.98) 100%); border: 1px solid rgba(255,107,26,.18); + border-radius: var(--radius); box-shadow: 0 8px 28px rgba(0,0,0,.5); + display: flex; flex-direction: column; gap: 6px; +} +.fs-prog-title { font-size: 13px; font-weight: 600; color: var(--text); } +.fs-prog-meta { font-size: 11.5px; color: var(--text-dim); } +.fs-prog-bar { width: 100%; height: 6px; background: rgba(255,107,26,.12); border-radius: 999px; overflow: hidden; } +.fs-prog-fill { height: 100%; background: var(--grad-accent); transition: width .15s; } +.fs-prog-actions { display: flex; justify-content: flex-end; gap: 6px; } + +/* Right-click context menu. */ +.fs-ctx { + position: fixed; z-index: 1200; + min-width: 200px; padding: 4px; + background: linear-gradient(180deg, rgba(22,32,50,.95) 0%, rgba(11,17,28,.98) 100%); border: 1px solid rgba(255,107,26,.18); + border-radius: var(--radius-sm); box-shadow: 0 8px 28px rgba(0,0,0,.5); + font-size: 13px; + max-height: calc(100vh - 16px); overflow-y: auto; overscroll-behavior: contain; +} +.fs-ctx-item { + display: flex; align-items: center; gap: 8px; + padding: 7px 12px; border-radius: var(--radius-sm); + cursor: pointer; color: var(--text); user-select: none; + line-height: 1.2; transition: background .12s; +} +.fs-ctx-item:hover { background: rgba(255,107,26,.14); } +.fs-ctx-item.danger { color: var(--err); } +.fs-ctx-item.danger:hover { background: rgba(255,66,66,.14); } +.fs-ctx-item.disabled { opacity: .4; pointer-events: none; } +.fs-ctx-sep { height: 1px; margin: 4px 6px; background: rgba(255,107,26,.12); } +.fs-ctx-icn { width: 18px; flex: 0 0 18px; text-align: center; } + +/* Mods helper: large textarea + chip hints. */ +.im-cfg-field.wide { grid-column: 1 / -1; } +.im-cfg-field textarea { + width: 100%; min-height: 90px; + font-family: var(--mono); font-size: 12.5px; line-height: 1.7; + resize: vertical; + background: var(--surface-2); + border: 1px solid var(--border-soft); + color: var(--text); + border-radius: var(--radius-sm); + padding: 8px 10px; + transition: border-color .2s, box-shadow .2s; +} +.im-cfg-field textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(255,107,26,.18); +} +.cfg-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; } +.cfg-chip { + padding: 4px 10px; border-radius: var(--radius-pill); + background: rgba(255,255,255,.05); border: 1px solid var(--border-soft); + font-size: 11px; color: var(--text-dim); + font-family: var(--mono); +} + +.im-cfg-group { + margin-bottom: 16px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + overflow: hidden; + background: var(--bg-raised); + transition: border-color .2s, box-shadow .2s; +} +.im-cfg-group-h { + padding: 12px 16px; + font-size: 11.5px; font-weight: 700; + text-transform: uppercase; letter-spacing: .9px; + color: var(--text-dim); + background: rgba(255,255,255,.012); + border-bottom: 1px solid var(--border-soft); + display: flex; align-items: center; gap: 8px; +} +.im-cfg-group-h::before { + content: ""; width: 3px; height: 14px; + background: var(--grad-accent); border-radius: 2px; +} +.im-cfg-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 18px 20px; + padding: 20px; +} +.im-cfg-field { display: flex; flex-direction: column; gap: 7px; min-width: 0; } +.im-cfg-field label { + color: var(--text); text-transform: none; letter-spacing: .1px; + display: flex; flex-direction: column; align-items: stretch; gap: 2px; + line-height: 1.25; +} +.im-cfg-field label > .name-line { + font-size: 13px; font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.im-cfg-field label .mono.dim { + display: block; + font-size: 10.5px; opacity: .65; font-weight: 400; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.im-cfg-field input, .im-cfg-field select, .im-cfg-field textarea { width: 100%; } +.im-cfg-field .toggle { padding: 4px 0; } +.im-cfg-field .form-hint { font-size: 11px; color: var(--text-dim); margin-top: 2px; line-height: 1.4; } + + +/* ===================== REGION: portsconfig ===================== */ + /* =============== PORTS TABLE =============== */ + /* Replaces the old "stack of inputs" ports UI. Single-glance scannable + grid: friendly name + manifest key, colored protocol pill, container + port (read-only), editable host port, internal/required chips. */ + .ports-table { + display: flex; flex-direction: column; + border: 1px solid var(--border-soft); border-radius: var(--radius); + overflow: hidden; background: var(--bg-raised); + } + .ports-row { + display: grid; + grid-template-columns: minmax(160px, 2fr) 80px minmax(80px, 1fr) minmax(140px, 1.2fr) minmax(120px, 1fr); + gap: 12px; align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border-soft); + transition: background .18s var(--ease-out), box-shadow .18s var(--ease-out), transform .12s var(--ease-out); + } + .ports-row:last-child { border-bottom: 0; } + .ports-row:not(.ports-head):hover { + background: linear-gradient(135deg, rgba(255,107,26,.08), rgba(95,178,255,.06)); + transform: translateX(2px); + } + .ports-head { + background: rgba(255,255,255,.012); + font-size: 11px; font-weight: 700; + text-transform: uppercase; letter-spacing: 1.1px; + color: var(--text-dim); padding: 10px 16px; + border-bottom: 1px solid var(--border-soft); + } + .port-name { display: flex; flex-direction: column; gap: 2px; min-width: 0; } + .port-name-friendly { + font-size: 13px; font-weight: 600; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .port-name-key { font-size: 10.5px; opacity: .65; line-height: 1.1; font-family: var(--mono); } + .port-proto { + display: inline-block; + padding: 4px 10px; border-radius: var(--radius-pill); + font-size: 10.5px; font-weight: 700; letter-spacing: .6px; + color: var(--proto-c, var(--accent)); + border: 1px solid color-mix(in srgb, var(--proto-c, var(--accent)) 50%, transparent); + background: color-mix(in srgb, var(--proto-c, var(--accent)) 18%, transparent); + box-shadow: 0 0 8px color-mix(in srgb, var(--proto-c, var(--accent)) 20%, transparent); + text-transform: uppercase; + } + .ports-row input[type="number"] { + width: 100%; + font-family: var(--mono); letter-spacing: .5px; + background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); + padding: 8px 11px; font-size: 13px; + transition: border-color .15s, box-shadow .15s, background .15s; + } + .ports-row input[type="number"]:focus { outline: 0; border-color: var(--accent); box-shadow: var(--glow-accent); background: rgba(255,107,26,.03); } + .ports-row input[type="number"]:disabled { opacity: .5; cursor: not-allowed; } + .ports-row input[type="number"].dup { border-color: var(--err); background: rgba(255,66,66,.08); box-shadow: 0 0 0 3px rgba(255,66,66,.15); } + .port-flags { display: flex; gap: 6px; flex-wrap: wrap; } + .port-chip { + display: inline-block; padding: 3px 10px; + border-radius: var(--radius-pill); border: 1px solid var(--border-strong); + font-size: 10.5px; color: var(--text-dim); + background: rgba(255,107,26,.06); + white-space: nowrap; transition: all .12s var(--ease-out); + } + .port-chip-int { color: var(--amber); border-color: rgba(255,177,59,.4); background: rgba(255,177,59,.12); } + .ports-foot { + margin-top: 16px; display: flex; gap: 14px; + align-items: center; flex-wrap: wrap; + } + .ports-warn { + color: var(--err); font-size: 12.5px; font-weight: 500; + padding: 8px 13px; border-radius: var(--radius-sm); + background: linear-gradient(135deg, rgba(255,66,66,.12), rgba(255,66,66,.05)); + border: 1px solid rgba(255,66,66,.35); + } + /* On narrow modals (mobile, half-screen) collapse to two-column rows + with the protocol + container port stacking under the name. */ + @media (max-width: 720px) { + .ports-row, .ports-head { + grid-template-columns: 1fr 100px; + grid-template-rows: auto auto; + } + .ports-row > div:nth-child(1) { grid-column: 1; grid-row: 1; } + .ports-row > div:nth-child(2) { grid-column: 2; grid-row: 1; justify-self: end; } + .ports-row > div:nth-child(3) { display: none; } + .ports-row > div:nth-child(4) { grid-column: 1 / -1; grid-row: 2; } + .ports-row > div:nth-child(5) { grid-column: 1 / -1; grid-row: 3; } + .ports-head > div:not(:nth-child(1)):not(:nth-child(2)) { display: none; } + } + + /* =============== UNIFIED CONFIG TAB (rail + main + sticky footer) =============== + One nav, one Apply footer, one dirty-state model — across env-config / + ports / INI. Replaces the older 3-stacked-cards layout. */ + .cfg-shell { + display: flex; flex-direction: column; + height: 100%; min-height: 420px; + } + .cfg-body { + display: grid; grid-template-columns: 240px 1fr; + flex: 1; min-height: 0; + } + .cfg-rail { + border-right: 1px solid var(--border-soft); + padding: 14px 10px; + overflow-y: auto; + background: var(--grad-rail); + display: flex; flex-direction: column; gap: 18px; + } + .cfg-rail-section { display: flex; flex-direction: column; gap: 3px; } + .cfg-rail-section-h { + font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: 1.1px; + color: var(--accent); opacity: .75; + padding: 0 8px 6px; box-shadow: inset 0 -1px 0 rgba(255,107,26,.15); + } + .cfg-rail-link { + display: grid; grid-template-columns: 24px 1fr auto; + align-items: center; gap: 9px; + padding: 9px 11px; border-radius: var(--radius-sm); + background: transparent; border: 1px solid transparent; + color: var(--text-dim); cursor: pointer; + text-align: left; line-height: 1.25; + transition: all .18s var(--ease-out); + box-shadow: none; + position: relative; + } + .cfg-rail-link:hover { + color: var(--text); + background: rgba(255,107,26,.06); + border-color: rgba(255,107,26,.2); + transform: translateX(1px); + } + .cfg-rail-link.active { + background: linear-gradient(135deg, rgba(255,107,26,.16), rgba(95,178,255,.12)); + color: var(--accent); + border-color: rgba(255,107,26,.4); + } + .cfg-rail-link.active::before { + content: ""; position: absolute; + left: -10px; top: 6px; bottom: 6px; width: 3px; + background: var(--grad-accent); border-radius: 0 2px 2px 0; + animation: sweep .8s var(--ease-out) forwards; + } + .cfg-rail-icon { + width: 24px; height: 24px; + display: inline-flex; align-items: center; justify-content: center; + font-size: 14px; line-height: 1; + } + .cfg-rail-text { + display: flex; flex-direction: column; gap: 1px; + min-width: 0; + } + .cfg-rail-title { + font-size: 13px; font-weight: 600; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .cfg-rail-sub { + font-size: 10.5px; opacity: .68; font-weight: 500; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .cfg-rail-count { + font-size: 10.5px; font-weight: 600; + color: var(--text-dim); opacity: .7; + background: rgba(255,107,26,.08); + padding: 2px 8px; border-radius: var(--radius-pill); + border: 1px solid var(--border-soft); + font-family: var(--mono); + transition: all .12s var(--ease-out); + } + .cfg-rail-link.active .cfg-rail-count { + background: rgba(255,107,26,.18); + border-color: rgba(255,107,26,.4); + color: var(--accent); + } + .cfg-rail-dirty { + position: absolute; top: 7px; right: 7px; + width: 8px; height: 8px; border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 10px var(--amber); + animation: pulseDot .8s ease-in-out infinite; + } + + .cfg-main { + display: flex; flex-direction: column; + min-width: 0; min-height: 0; + } + .cfg-main-head { + display: flex; align-items: center; gap: 10px; + padding: 16px 20px 14px; + border-bottom: 1px solid var(--border-soft); + background: linear-gradient(180deg, rgba(255,107,26,.06), transparent); + } + .cfg-main-title { + display: flex; align-items: baseline; gap: 8px; + font-size: 14px; font-weight: 700; color: var(--text); + } + .cfg-main-title .cfg-main-sub { + font-size: 11.5px; font-weight: 500; color: var(--text-dim); + } + .cfg-search-wrap { + margin-left: auto; + position: relative; + width: 240px; max-width: 50%; + } + .cfg-search-wrap .cfg-search-icon { + position: absolute; left: 11px; top: 50%; transform: translateY(-50%); + font-size: 12px; color: var(--text-dim); pointer-events: none; + } + .cfg-search { + width: 100%; padding: 8px 11px 8px 32px; + font-size: 12.5px; background: var(--surface-2); border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); transition: all .15s var(--ease-out); + } + .cfg-search:focus { outline: 0; border-color: var(--accent); box-shadow: var(--glow-accent); } + .cfg-pane-stage { + flex: 1; min-height: 0; overflow-y: auto; + padding: 18px; + transition: opacity .18s var(--ease-out), transform .22s var(--ease-out), filter .15s var(--ease-out); + } + .cfg-pane-stage.cfg-pane-leaving { + opacity: 0; transform: translateX(-12px); + filter: blur(2px); pointer-events: none; + } + .cfg-pane { + display: none; + animation: cfgRailPaneIn .32s var(--ease-out); + } + .cfg-pane.active { display: block; } + @keyframes cfgRailPaneIn { + 0% { opacity: 0; transform: translateX(14px) scale(.992); filter: blur(2px); } + 60% { filter: blur(0); } + 100% { opacity: 1; transform: none; filter: blur(0); } + } + /* Staggered card entrance (per-card --i index set inline at render). */ + @keyframes cfgCardIn { + from { opacity: 0; transform: translateY(10px) scale(.985); } + to { opacity: 1; transform: none; } + } + .cfg-grid > .cfg-card { + animation: cfgCardIn .42s var(--ease-out) backwards; + animation-delay: calc(var(--i, 0) * 22ms); + } + .cfg-pane-desc { + font-size: 12.5px; color: var(--text-dim); + margin-bottom: 16px; padding: 12px 15px; + background: linear-gradient(135deg, rgba(255,107,26,.1), rgba(255,107,26,.05)); + border-left: 3px solid var(--accent); + border-radius: var(--radius-sm); line-height: 1.6; + } + /* ===== 7DTD V3.0 SandboxCode panel ===== */ + .sb-toolbar { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 14px; } + .sb-tool { display: flex; flex-direction: column; gap: 5px; min-width: 220px; } + .sb-tool-grow { flex: 1 1 320px; } + .sb-tool > label { font-size: 11px; font-weight: 600; color: var(--text-dim); } + .sb-paste-row { display: flex; gap: 8px; } + .sb-paste-row .cfg-input { flex: 1; } + .sb-codeline { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + font-size: 12px; color: var(--text-dim); margin-bottom: 16px; + padding: 8px 12px; background: rgba(255,255,255,.03); border-radius: var(--radius-sm); + } + .sb-codeline code { + font-family: ui-monospace, monospace; color: var(--accent); + background: rgba(255,107,26,.1); padding: 2px 7px; border-radius: 4px; + word-break: break-all; + } + .btn-xs { font-size: 10.5px; padding: 3px 8px; } + details.sb-cat { + margin-bottom: 10px; border: 1px solid var(--border, rgba(255,255,255,.08)); + border-radius: var(--radius-sm); overflow: hidden; + background: rgba(255,255,255,.02); + } + details.sb-cat > summary { + cursor: pointer; padding: 11px 14px; font-weight: 600; font-size: 13px; + list-style: none; display: flex; align-items: center; gap: 8px; + user-select: none; + } + details.sb-cat > summary::-webkit-details-marker { display: none; } + details.sb-cat > summary::before { content: "▸"; color: var(--accent); transition: transform .15s; } + details.sb-cat[open] > summary::before { transform: rotate(90deg); } + details.sb-cat > summary:hover { background: rgba(255,107,26,.06); } + .sb-cat-count { + margin-left: auto; font-size: 10.5px; color: var(--text-dim); + background: rgba(255,255,255,.06); padding: 1px 8px; border-radius: var(--radius-pill); + } + .sb-grid { padding: 6px 14px 14px; } + .sbopt-row { position: relative; } + .sbopt-row .sb-dot { + display: none; width: 6px; height: 6px; border-radius: 50%; + background: var(--accent); margin-left: 6px; vertical-align: middle; + } + .sbopt-row.sb-changed .sb-dot { display: inline-block; } + .sbopt-row.sb-changed .cfg-card-label { color: var(--accent); } + .cfg-section { + margin-bottom: 22px; + } + .cfg-section-h { + display: flex; align-items: center; gap: 8px; + font-size: 11px; font-weight: 700; + text-transform: uppercase; letter-spacing: 1px; + color: var(--accent); opacity: .85; + padding-bottom: 9px; margin-bottom: 13px; + border-bottom: 2px solid var(--border-strong); + } + .cfg-section-h::before { + content: ""; width: 3px; height: 13px; + background: var(--grad-accent); border-radius: 2px; + } + + /* Field cards — used across env / ports / INI panes for visual consistency. */ + .cfg-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px 14px; + } + .cfg-card { + position: relative; + display: flex; flex-direction: column; gap: 8px; + padding: 13px 15px; + background: var(--grad-card); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + min-width: 0; + transition: all .18s var(--ease-out); + box-shadow: var(--shadow-sm); + } + .cfg-card:hover { + border-color: rgba(255,107,26,.3); + background: var(--grad-card-hover); + transform: translateY(-2px); + } + .cfg-card:focus-within { + border-color: var(--accent); + box-shadow: var(--glow-accent), var(--shadow-md); + } + .cfg-card[data-dirty="1"] { + border-color: var(--amber); + background: linear-gradient(180deg, rgba(255,177,59,.1), rgba(255,177,59,.04)); + box-shadow: -4px 0 0 0 var(--amber), var(--shadow-md), 0 0 16px rgba(255,177,59,.12); + } + .cfg-card.wide { grid-column: 1 / -1; } + .cfg-card-head { + display: flex; align-items: flex-start; gap: 9px; + min-width: 0; + } + .cfg-card-label { + flex: 1; min-width: 0; + display: flex; flex-direction: column; gap: 3px; + cursor: pointer; + } + .cfg-card-name { + font-size: 13px; font-weight: 600; color: var(--text); + line-height: 1.25; + text-transform: none; letter-spacing: 0; + } + .cfg-card-key { + font-family: var(--mono); + font-size: 10.5px; font-weight: 400; + color: var(--text-dim); opacity: .65; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .cfg-card-dot { + flex: 0 0 auto; + width: 8px; height: 8px; border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 10px var(--amber); + margin-top: 5px; + display: none; + animation: pulseDot .8s ease-in-out infinite; + } + .cfg-card[data-dirty="1"] .cfg-card-dot { display: block; } + .cfg-card-input { display: block; } + .cfg-card-input input, + .cfg-card-input select, + .cfg-card-input textarea { + width: 100%; + } + .cfg-card-input input[type="text"], + .cfg-card-input input[type="password"], + .cfg-card-input input[type="number"], + .cfg-card-input select, + .cfg-card-input textarea { + background: var(--surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text); + padding: 7px 10px; + font-family: var(--sans); + transition: all .15s var(--ease-out); + } + .cfg-card-input input[type="text"]:focus, + .cfg-card-input input[type="password"]:focus, + .cfg-card-input input[type="number"]:focus, + .cfg-card-input select:focus, + .cfg-card-input textarea:focus { + outline: 0; border-color: var(--accent); box-shadow: var(--glow-accent); + } + .cfg-card-input .toggle { padding: 2px 0; } + .cfg-card-hint { + font-size: 11.5px; line-height: 1.55; + color: var(--text-dim); + display: flex; align-items: flex-start; gap: 7px; + padding-left: 1px; + } + .cfg-card-hint::before { + content: "›"; color: var(--accent); opacity: .75; flex: 0 0 auto; + font-weight: 700; line-height: 1.55; + font-size: 13px; margin-top: -1px; + } + .cfg-card-warn { + display: flex; align-items: center; gap: 9px; + padding: 9px 12px; + background: linear-gradient(135deg, rgba(255,66,66,.12), rgba(255,66,66,.05)); + border: 1px solid rgba(255,66,66,.35); + border-radius: var(--radius-sm); + font-size: 11.5px; line-height: 1.4; + color: var(--err); + } + .cfg-card-warn .warn-icon { + flex: 0 0 auto; + font-size: 14px; line-height: 1; + filter: saturate(1.2) drop-shadow(0 0 4px rgba(255,66,66,.4)); + } + .cfg-card-warn .warn-text { flex: 1; min-width: 0; } + + /* "Hidden by filter" — fade out (not display:none, so the layout doesn't jump + on every keystroke); pointer-events off to mute non-matching cards. */ + .cfg-card[data-filter-hide="1"] { + opacity: .22; pointer-events: none; + filter: grayscale(.5); + transition: opacity .25s var(--ease-out), filter .25s var(--ease-out); + } + + /* Sticky footer Apply — knows about all dirties (env + ports + ini) + and dispatches each scope to its right endpoint sequentially. */ + .cfg-foot { + border-top: 1px solid var(--border); + padding: 13px 18px; + display: flex; gap: 12px; align-items: center; + background: linear-gradient(180deg, rgba(20,24,36,.85), rgba(14,17,25,.95)); + flex: 0 0 auto; + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); + box-shadow: inset 0 1px 0 rgba(255,107,26,.08); + } + .cfg-foot-status { + font-size: 12.5px; color: var(--text-dim); + display: flex; align-items: center; gap: 8px; + flex: 1; min-width: 0; + } + .cfg-foot-status.dirty { color: var(--text); } + .cfg-foot-status .cfg-foot-pip { + width: 8px; height: 8px; border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 10px var(--amber); + flex: 0 0 auto; + animation: pulseDot .8s ease-in-out infinite; + } + .cfg-foot-progress { + font-size: 11.5px; color: var(--accent); + font-family: var(--mono); + } + .cfg-foot-btn { font-size: 12.5px; padding: 8px 16px; border-radius: var(--radius-sm); } + + /* Empty / error states inside the pane. */ + .cfg-empty { + text-align: center; + padding: 50px 20px; + color: var(--text-dim); + } + .cfg-empty .big { font-size: 36px; margin-bottom: 8px; } + .cfg-empty .hint { display: block; font-size: 12px; margin-top: 6px; opacity: .8; } + + /* Responsive: stack rail above main on narrow modals. */ + @media (max-width: 820px) { + .cfg-body { grid-template-columns: 1fr; } + .cfg-rail { + border-right: 0; border-bottom: 1px solid var(--border-soft); + flex-direction: row; flex-wrap: nowrap; gap: 6px; + overflow-x: auto; overflow-y: hidden; + padding: 10px 14px; + } + .cfg-rail-section { flex-direction: row; gap: 6px; } + .cfg-rail-section-h { display: none; } + .cfg-rail-link { + grid-template-columns: auto auto auto; + padding: 6px 10px; + flex: 0 0 auto; + } + .cfg-rail-link.active::before { display: none; } + .cfg-rail-sub { display: none; } + .cfg-search-wrap { width: 160px; } + } + + +/* ===================== REGION: freshtiles ===================== */ +/* =============== FRESH SERVERS LAYOUT (flat grid + glass tiles) =============== + REFUGE COMMAND-CENTER variant: neon-on-graphite, animated accent glows, + depth, gradient game-art heroes, live-pulse status, crisp glass chrome. */ + +.svr-toolbar { + display: flex; flex-wrap: wrap; gap: 10px; align-items: center; + row-gap: 8px; + padding: 12px 14px; + background: var(--grad-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 18px; + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); + box-shadow: var(--shadow-md); +} +.svr-tb-group { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; min-width: 0; } +/* Group toggle + sort select travel as one unbreakable cluster so the + sort select can never orphan onto its own ragged row; when the toolbar + wraps, the cluster right-aligns as a coherent second row. */ +.svr-tb-cluster { display: flex; gap: 10px; align-items: center; flex-wrap: nowrap; flex: 0 0 auto; margin-left: auto; } +@media (max-width: 1500px) { + /* The cluster's margin-left:auto owns right-alignment now; a growing + spacer would steal width from the scrolling GAME strip. */ + .svr-toolbar .svr-tb-spacer { flex: 0 0 8px; } + /* GAME pills scroll sideways instead of stacking a third toolbar row. */ + .svr-tb-games { + flex-wrap: nowrap; overflow-x: auto; overflow-y: hidden; + flex: 3 1 160px; min-width: 160px; max-width: 100%; + scrollbar-width: thin; + padding-bottom: 2px; + } +} +.svr-tb-label { + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: .9px; color: var(--text-dim); opacity: .7; + margin-right: 4px; +} +.svr-tb-sep { width: 1px; height: 24px; background: var(--border-soft); margin: 0 6px; flex: 0 0 1px; } +.svr-tb-spacer { flex: 1; min-width: 8px; } +/* Group-by segmented control: pills sit flush as one connected switch. */ +.svr-group-toggle { gap: 0; padding: 2px; border-radius: var(--radius-pill); background: var(--bg-deep); border: 1px solid var(--border-soft); } +.svr-group-toggle .svr-tb-label { margin: 0 6px 0 8px; } +.svr-group-toggle .svr-pill { border: 0; background: transparent; padding: 5px 11px; border-radius: var(--radius-pill); } +.svr-group-toggle .svr-pill:hover { background: rgba(255,255,255,.05); } +.svr-group-toggle .svr-pill.active { background: var(--grad-accent); color: #05070a; font-weight: 700; box-shadow: 0 2px 10px rgba(255,107,26,.3); } +.svr-pill { + padding: 6px 12px; border-radius: var(--radius-pill); + background: rgba(255,255,255,.04); border: 1px solid var(--border-soft); + color: var(--text-dim); font-size: 12.5px; font-weight: 500; + cursor: pointer; transition: color .15s, background .15s, border-color .15s, box-shadow .2s, transform .12s; + white-space: nowrap; box-shadow: none; + display: inline-flex; align-items: center; gap: 7px; +} +.svr-pill:hover { + color: var(--text); border-color: var(--accent); + background: rgba(255,107,26,.08); transform: translateY(-1px); +} +.svr-pill.active { + background: var(--grad-accent); color: #0a0e17; font-weight: 700; + border-color: transparent; + box-shadow: var(--glow-accent), inset 0 1px 0 rgba(255,255,255,.28); + transform: translateY(-2px); +} +.svr-pill .pill-count { + font-family: var(--mono); font-size: 11px; + background: rgba(0,0,0,.22); padding: 1px 7px; border-radius: var(--radius-pill); + color: inherit; opacity: .9; line-height: 1.4; +} +.svr-pill.active .pill-count { background: rgba(255,255,255,.22); } +.svr-pill .pill-dot { + width: 7px; height: 7px; border-radius: 50%; + background: currentColor; box-shadow: 0 0 6px currentColor; + flex: 0 0 7px; +} +.svr-pill[data-status="running"] .pill-dot { + background: var(--ok); box-shadow: 0 0 10px var(--ok), 0 0 24px rgba(46,224,107,.4); + animation: pulseDot 2.2s ease-in-out infinite; +} +.svr-pill[data-status="stopped"] .pill-dot { background: var(--muted); } +.svr-pill[data-status="issues"] .pill-dot { background: var(--err); box-shadow: 0 0 10px var(--err), 0 0 24px rgba(255,66,66,.4); } +@keyframes pillPulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } } +.svr-pill.agent-offline { + border-color: rgba(255,66,66,.4); + color: #fca5a5; +} +.svr-pill.agent-offline.active { + background: var(--grad-danger); + color: #fef2f2; + border-color: transparent; + box-shadow: var(--glow-err); +} +.svr-tb-btn { + padding: 7px 13px; border-radius: var(--radius-sm); + background: rgba(255,255,255,.04); border: 1px solid var(--border-soft); + color: var(--text-dim); font-size: 12px; font-weight: 600; + cursor: pointer; transition: all .15s; box-shadow: none; + display: inline-flex; align-items: center; gap: 6px; +} +.svr-tb-btn:hover { + color: var(--text); border-color: var(--accent); background: rgba(255,107,26,.12); + transform: translateY(-1px); +} +.svr-tb-btn select { + background: transparent; border: none; color: inherit; + font-size: 12px; font-weight: 600; padding: 0; + appearance: none; -webkit-appearance: none; + cursor: pointer; + background-image: none; + padding-right: 0; +} +.svr-tb-btn select:focus { outline: none; box-shadow: none; } +.svr-tb-btn select option { background: var(--bg-raised); color: var(--text); } + +/* =============== BATCH ACTIONS BAR =============== + Primary accent on mint/teal, secondary actions in muted. */ +.svr-batchbar { + display: flex; flex-wrap: wrap; gap: 8px; align-items: center; + padding: 10px 14px; + background: linear-gradient(180deg, rgba(255,107,26,.06), rgba(255,107,26,.02)); + border: 1px solid rgba(255,107,26,.2); + border-radius: var(--radius); + margin-bottom: 14px; + font-size: 12.5px; +} +.svr-batch-label { + font-size: 10.5px; font-weight: 700; text-transform: uppercase; + letter-spacing: .9px; color: var(--accent); opacity: .8; + margin-right: 4px; +} +.svr-batch-sep { width: 1px; height: 22px; background: rgba(255,107,26,.3); margin: 0 6px; } +.svr-batch-btn { + padding: 6px 13px; border-radius: var(--radius-sm); + background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.12); + color: var(--text); font-size: 12.5px; font-weight: 600; + cursor: pointer; transition: background .15s, border-color .15s, color .15s, transform .12s, box-shadow .2s; + white-space: nowrap; + display: inline-flex; align-items: center; gap: 6px; +} +.svr-batch-btn:not(:disabled):hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0,0,0,.4); +} +.svr-batch-btn:disabled { + opacity: .35; cursor: not-allowed; +} +.svr-batch-btn.start { + background: linear-gradient(135deg, rgba(46,224,107,.16), rgba(46,224,107,.06)); + border-color: rgba(46,224,107,.4); color: var(--ok); +} +.svr-batch-btn.start:not(:disabled):hover { + background: linear-gradient(135deg, rgba(46,224,107,.32), rgba(46,224,107,.18)); + color: #fff; border-color: var(--ok); + box-shadow: var(--glow-ok), 0 8px 20px rgba(46,224,107,.3); +} +.svr-batch-btn.stop { + background: linear-gradient(135deg, rgba(255,66,66,.14), rgba(255,66,66,.04)); + border-color: rgba(255,66,66,.3); color: #fca5a5; +} +.svr-batch-btn.stop:not(:disabled):hover { + background: linear-gradient(135deg, rgba(255,66,66,.3), rgba(255,66,66,.16)); + color: #fff; border-color: var(--err); + box-shadow: var(--glow-err), 0 8px 20px rgba(255,66,66,.3); +} +.svr-batch-btn.restart { + background: linear-gradient(135deg, rgba(255,177,59,.14), rgba(255,177,59,.04)); + border-color: rgba(255,177,59,.3); color: var(--amber); +} +.svr-batch-btn.restart:not(:disabled):hover { + background: linear-gradient(135deg, rgba(255,177,59,.32), rgba(255,177,59,.16)); + color: #fff; border-color: var(--amber); + box-shadow: 0 0 16px rgba(255,177,59,.35), 0 8px 20px rgba(255,177,59,.3); +} +.svr-batch-btn.game { + background: linear-gradient(135deg, rgba(255,107,26,.14), rgba(255,107,26,.04)); + border-color: rgba(255,107,26,.3); color: var(--accent); +} +.svr-batch-btn.game:not(:disabled):hover { + background: linear-gradient(135deg, rgba(255,107,26,.32), rgba(255,107,26,.16)); + color: #fff; border-color: var(--accent); + box-shadow: var(--glow-accent), 0 8px 20px rgba(255,107,26,.3); +} +.svr-batch-progress { + display: inline-flex; align-items: center; gap: 8px; + padding: 5px 12px; border-radius: var(--radius-pill); + background: rgba(255,107,26,.12); + border: 1px solid rgba(255,107,26,.32); + color: var(--accent); + font-family: var(--mono); font-size: 11.5px; +} +.svr-batch-progress .dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--accent); box-shadow: 0 0 8px var(--accent); + animation: pulseDot 1.2s ease-in-out infinite; +} +.svr-batch-progress .line { color: var(--text); max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.svr-batch-cancel { + padding: 3px 10px; border-radius: var(--radius-sm); + background: rgba(255,66,66,.16); + border: 1px solid rgba(255,66,66,.4); + color: #fca5a5; + font-size: 11px; font-weight: 600; + cursor: pointer; +} +.svr-batch-cancel:hover { background: rgba(255,66,66,.4); color: #fff; border-color: var(--err); } +@media (max-width: 720px) { + .svr-batchbar { padding: 9px 11px; gap: 6px; border-radius: var(--radius); } + .svr-batch-btn { padding: 5px 10px; font-size: 11.5px; } + .svr-batch-progress { display: none; } +} + +/* The flat servers grid */ +.svr-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--svr-grid-min, 300px), 1fr)); + gap: 14px; +} +@media (max-width: 600px) { + .svr-grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } +} +.svr-grid.grouped { + display: flex; + flex-direction: column; + gap: 18px; +} +.svr-agent-section { display: flex; flex-direction: column; gap: 10px; } +.svr-agent-header { + display: flex; align-items: center; gap: 10px; + padding: 6px 4px 6px 2px; + border-bottom: 1px solid var(--border-soft); +} +.svr-agent-icon { + font-size: 17px; line-height: 1; flex: 0 0 auto; + filter: drop-shadow(0 1px 3px rgba(0,0,0,.5)); +} +.svr-agent-dot { + width: 8px; height: 8px; border-radius: 50%; + background: var(--muted); + flex: 0 0 auto; +} +.svr-agent-dot.on { background: var(--ok); box-shadow: 0 0 10px var(--ok), 0 0 20px rgba(46,224,107,.35); } +.svr-agent-dot.off { background: var(--err); } +.svr-agent-lab { font-weight: 600; font-size: 14px; color: var(--text); } +.svr-agent-badge { + font-size: 11px; padding: 1px 7px; border-radius: var(--radius-pill); + border: 1px solid var(--border-soft); + color: var(--text-dim); + text-transform: uppercase; letter-spacing: .5px; +} +.svr-agent-badge.offline { color: var(--err); border-color: rgba(255,66,66,.4); } +.svr-agent-badge.orphan { color: var(--amber); border-color: rgba(255,177,59,.4); } +.svr-agent-meta { margin-left: auto; font-size: 12px; color: var(--text-dim); } +.svr-group-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--svr-grid-min, 300px), 1fr)); + gap: 14px; +} +@media (max-width: 600px) { + .svr-group-grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } + .svr-agent-meta { font-size: 11px; } +} +.svr-empty { + text-align: center; padding: 60px 24px; + color: var(--text-dim); + border: 1px dashed var(--border-soft); + border-radius: var(--radius); + background: rgba(255,255,255,.012); +} +.svr-empty .big { font-size: 48px; margin-bottom: 8px; opacity: .8; } +.svr-empty .hint { display: block; font-size: 12.5px; margin-top: 8px; opacity: .85; } + +/* The tile itself — glass card with art bleed. Game-art hero with dark scrim. */ +/* Overhauled server card — a vertical card: art banner on top, always- + visible info body, always-visible action row at the bottom. No more + click-to-flip strip; nothing is hidden behind a mode toggle. */ +.svr-tile { + position: relative; + display: flex; flex-direction: column; + /* Cap width so a lone card in a wide track (single-game groups) reads + as a card, not a stretched banner; extra track width stays empty. */ + max-width: 380px; + width: 100%; + border-radius: var(--radius); + /* NOTE: no overflow:hidden here — the ⋯ More menu pops out of the card + and must not be clipped. The banner clips its own art (top corners + rounded below); the action row's bottom corners are rounded too. */ + background: var(--bg-raised); + border: 1px solid var(--border); + transition: transform .2s var(--ease-out), border-color .2s, box-shadow .3s; + isolation: isolate; + animation: svrTileIn .42s var(--ease-out) backwards; + animation-delay: calc(var(--i, 0) * 30ms); +} +@keyframes svrTileIn { from { opacity: 0; transform: translateY(10px) scale(.985); } to { opacity: 1; transform: none; } } +.svr-tile:hover { + transform: translateY(-4px); + border-color: rgba(255,107,26,.45); + box-shadow: 0 16px 40px rgba(0,0,0,.5), var(--glow-accent); + z-index: 2; +} +.svr-tile.is-deleting { + opacity: .35; pointer-events: none; + filter: grayscale(.8) blur(1px); + transition: opacity .25s, filter .25s; +} + +/* Art banner — fixed-height header strip showing the game art. */ +.svr-card-banner { + position: relative; + height: 96px; + flex: 0 0 96px; + overflow: hidden; + border-radius: var(--radius) var(--radius) 0 0; +} +.svr-tile-art { + position: absolute; inset: 0; + background-size: cover; background-position: center; + background-color: var(--bg-deep); + filter: saturate(.92) brightness(.82); + transition: filter .3s var(--ease-out), transform .5s var(--ease-out); + z-index: 0; +} +.svr-tile:hover .svr-tile-art { + filter: saturate(1.08) brightness(.95); + transform: scale(1.06); +} +.svr-tile-art::after { + content: ""; position: absolute; inset: 0; + background: linear-gradient(180deg, rgba(7,10,16,0) 30%, rgba(7,10,16,.55) 80%, var(--bg-raised) 100%); +} +.svr-tile-art-fallback { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + font-size: 40px; opacity: .8; + filter: drop-shadow(0 4px 14px rgba(0,0,0,.6)); + z-index: 0; +} + +/* Status badge — pill with pulsing dot, pinned to the banner top-right. */ +.svr-tile-status { + position: absolute; top: 9px; right: 9px; z-index: 3; + width: max-content; + max-width: calc(100% - 18px); + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 9px 3px 8px; border-radius: var(--radius-pill); + background: rgba(7,10,16,.82); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255,255,255,.12); + font-size: 9.5px; font-weight: 700; letter-spacing: .4px; + text-transform: uppercase; + color: var(--text-dim); + transition: color .2s, border-color .2s, background .2s, box-shadow .2s; + text-shadow: 0 1px 3px rgba(0,0,0,.6); + flex: 0 0 auto; +} +.svr-tile-status .dot { + width: 8px; height: 8px; border-radius: 50%; background: currentColor; + flex: 0 0 8px; +} +.svr-tile-status.running { color: var(--ok); border-color: rgba(46,224,107,.5); } +.svr-tile-status.running .dot { box-shadow: 0 0 10px var(--ok), 0 0 24px rgba(46,224,107,.4); animation: pulseDot 2.4s ease-in-out infinite; } +.svr-tile-status.starting, .svr-tile-status.installing, .svr-tile-status.stopping, .svr-tile-status.restarting, .svr-tile-status.updating { + color: var(--amber); border-color: rgba(255,177,59,.5); +} +.svr-tile-status.starting .dot, .svr-tile-status.installing .dot, .svr-tile-status.stopping .dot, .svr-tile-status.restarting .dot, .svr-tile-status.updating .dot { + box-shadow: 0 0 10px var(--amber), 0 0 24px rgba(255,177,59,.3); + animation: pulseDot 1.4s ease-in-out infinite; +} +.svr-tile-status.stopped { color: var(--muted); border-color: rgba(255,255,255,.2); } +.svr-tile-status.crashed, .svr-tile-status.deleting, .svr-tile-status.delete { + color: #fca5a5; border-color: rgba(255,66,66,.5); + box-shadow: 0 0 12px rgba(255,66,66,.25); +} +.svr-tile-status.ready { color: var(--ok); border-color: rgba(46,224,107,.5); } + +/* Agent chip — top-left frosted label. */ +.svr-tile-agent { + position: absolute; top: 10px; left: 10px; + font-size: 10.5px; font-weight: 600; + padding: 3px 9px; border-radius: var(--radius-sm); + background: rgba(11,14,21,.8); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255,107,26,.2); + color: var(--accent); + z-index: 3; opacity: .95; + font-family: var(--mono); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: calc(100% - 100px); + line-height: 1.4; + transition: border-color .2s, color .2s; +} +.svr-tile-agent.offline { + border-color: rgba(255,66,66,.4); + color: #fca5a5; +} +.svr-tile-agent.offline::before { + content: "⚠ "; opacity: .8; +} + +/* Cluster badge — color-coded chip pinned bottom-left of the banner so you can + tell at a glance which cluster a server belongs to. Hue comes from the + cluster id (hashed) so every cluster gets a stable, distinct color with no + hardcoding. Hidden by default; shown only for instances that are cluster + members (data-cluster set in updateCard). The ★ marks the cluster master. */ +.svr-tile-cluster { + position: absolute; bottom: 9px; left: 10px; z-index: 3; + display: none; align-items: center; gap: 5px; + max-width: calc(100% - 20px); + padding: 2px 8px 2px 7px; border-radius: var(--radius-pill); + background: rgba(7,10,16,.82); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--cluster-color, rgba(255,255,255,.3)); + color: var(--cluster-color, #fff); + font-size: 9.5px; font-weight: 700; letter-spacing: .3px; + text-transform: uppercase; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + line-height: 1.5; +} +.svr-tile-cluster.is-member { display: inline-flex; } +.svr-tile-cluster .cdot { + width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; + background: var(--cluster-color, #fff); + box-shadow: 0 0 8px var(--cluster-color, transparent); +} +.svr-tile-cluster .cmaster { font-size: 9px; opacity: .95; } + +/* WI-14: "Update available" badge — amber pill, bottom-right of the banner + (cluster badge owns bottom-left). Lit by updateCard when the last manual + update check found installed != latest buildid. Check-only indicator. */ +.svr-tile-update { + position: absolute; bottom: 9px; right: 10px; z-index: 3; + display: none; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: var(--radius-pill); + background: rgba(7,10,16,.82); + border: 1px solid var(--warn, #f59e0b); + color: var(--warn, #f59e0b); + font-size: 9.5px; font-weight: 700; letter-spacing: .3px; + text-transform: uppercase; white-space: nowrap; line-height: 1.5; +} +.svr-tile-update.is-stale { display: inline-flex; } + +/* Update-check result pills in the instance modal's Updates tab. */ +.uc-pill { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 9px; border-radius: var(--radius-pill); + font-size: 11px; font-weight: 700; letter-spacing: .3px; + border: 1px solid var(--border); +} +.uc-pill.uc-ok { color: var(--ok, #22c55e); border-color: var(--ok, #22c55e); } +.uc-pill.uc-stale { color: var(--warn, #f59e0b); border-color: var(--warn, #f59e0b); } +.uc-pill.uc-err { color: var(--danger, #ef4444); border-color: var(--danger, #ef4444); } + +/* Body / info panel — static, between the banner and the action row. */ +.svr-tile-body { + padding: 11px 13px 10px; + color: var(--text); + display: flex; flex-direction: column; gap: 6px; + flex: 1 1 auto; +} +.svr-tile-row { + display: flex; align-items: baseline; gap: 8px; min-width: 0; +} +.svr-tile-name { + font-size: 15px; font-weight: 700; line-height: 1.25; + color: var(--text); + /* Two-line clamp — operator complaint #1 was single-line ellipsis + truncating almost every real server name. Full name on el.title. */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; overflow-wrap: anywhere; + overflow: hidden; + flex: 1; min-width: 0; +} +.svr-tile-uptime { + flex: 0 0 auto; + font-size: 11px; font-weight: 600; + color: var(--text-dim); + font-family: var(--mono); + margin-left: auto; /* sits at the right edge of the meta row */ +} +.svr-tile-uptime.empty { display: none; } +.svr-tile-meta { + display: flex; align-items: center; gap: 5px; + font-size: 11.5px; color: var(--text-dim); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + line-height: 1.4; +} +.svr-tile-meta .module { + color: var(--text-dim); font-weight: 500; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; +} +.svr-tile-meta .sep { opacity: .4; flex: 0 0 auto; } +.svr-tile-meta .port { + font-family: var(--mono); color: var(--accent); font-weight: 600; + flex: 0 0 auto; +} +.svr-tile-meta .players { color: var(--text); font-weight: 600; flex: 0 0 auto; } +.svr-tile-meta .players.empty { display: none; } +.svr-tile-meta .players.empty + .sep { display: none; } + +.svr-tile-bars { + display: grid; grid-template-columns: 1fr 1fr; gap: 9px; + margin-top: 2px; +} +.svr-tile-bars .bar-row { + display: flex; align-items: center; gap: 6px; + min-width: 0; +} +.svr-tile-bars .lbl { + flex: 0 0 auto; + font-size: 8.5px; font-weight: 700; letter-spacing: .55px; + text-transform: uppercase; + color: var(--muted); +} +.svr-tile-bars .bar { + flex: 1; min-width: 0; + height: 5px; border-radius: 3px; + background: rgba(255,255,255,.08); + overflow: hidden; + box-shadow: inset 0 1px 1px rgba(0,0,0,.3); +} +.svr-tile-bars .fill { + height: 100%; width: 0%; + background: var(--grad-accent-2); + transition: width .45s var(--ease-out), background .3s; +} +.svr-tile-bars .val { + flex: 0 0 auto; + font-size: 10px; font-weight: 600; + font-family: var(--mono); + color: var(--text-dim); + min-width: 30px; text-align: right; +} + +/* =============== CARD ACTION ROW (always visible) =============== + A clear, labeled control bar at the bottom of every card: one primary + Start/Stop button (with a text label), then secondary icon buttons + (Restart / Console / Files / Manage), then a ⋯ More menu for the rare + and dangerous actions. No mode flip, nothing hidden. */ +.svr-card-actions { + display: flex; align-items: center; gap: 6px; + padding: 10px 11px 11px; + border-top: 1px solid var(--border-soft); + background: rgba(255,255,255,.012); + border-radius: 0 0 var(--radius) var(--radius); +} + +/* Primary Start/Stop — labeled, fills the leftover space. */ +.svr-act-primary { + flex: 1 1 auto; min-width: 78px; + height: 34px; padding: 0 14px; + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + font-size: 12.5px; font-weight: 700; letter-spacing: .2px; + cursor: pointer; + position: relative; overflow: hidden; isolation: isolate; + transition: transform .12s var(--ease-out), background .18s, box-shadow .2s, color .15s, border-color .18s; +} +.svr-act-primary svg { width: 15px; height: 15px; flex: 0 0 15px; transition: transform .2s var(--ease-out); } +.svr-act-primary:active { transform: scale(.97); } +.svr-act-primary.act-start { + background: var(--grad-accent-2); color: #04200f; + box-shadow: 0 2px 12px rgba(46,224,107,.28); +} +.svr-act-primary.act-start:hover { box-shadow: 0 4px 18px rgba(46,224,107,.5); filter: brightness(1.06); } +.svr-act-primary.act-start:hover svg { transform: translateX(1px) scale(1.08); } +.svr-act-primary.act-stop { + background: rgba(255,66,66,.14); color: #fca5a5; + border-color: rgba(255,66,66,.45); +} +.svr-act-primary.act-stop:hover { background: rgba(255,66,66,.24); color: #fff; border-color: var(--err); } + +/* Secondary icon buttons. */ +.svr-act-icon { + flex: 0 0 auto; + width: 34px; height: 34px; padding: 0; + display: inline-flex; align-items: center; justify-content: center; + border-radius: var(--radius-sm); + background: rgba(255,255,255,.04); + border: 1px solid var(--border-soft); + color: var(--text-dim); + cursor: pointer; + transition: transform .12s var(--ease-out), background .18s, border-color .18s, color .15s; +} +.svr-act-icon svg { width: 16px; height: 16px; transition: transform .2s var(--ease-out); } +.svr-act-icon:hover { + background: rgba(255,107,26,.12); border-color: rgba(255,107,26,.5); color: var(--accent); +} +.svr-act-icon:active { transform: scale(.94); } +.svr-act-icon[data-restart-btn]:hover svg { transform: rotate(-180deg); } +.svr-act-manage:hover svg { transform: rotate(45deg); } + +/* "More" menu popover. */ +.svr-act-more-wrap { position: relative; flex: 0 0 auto; } +.svr-act-menu { + position: absolute; right: 0; top: calc(100% + 6px); + z-index: 30; + min-width: 172px; + padding: 5px; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 14px 40px rgba(0,0,0,.55); + display: flex; flex-direction: column; gap: 2px; + animation: cardMenuIn .14s var(--ease-out); +} +.svr-act-menu.drop-up { top: auto; bottom: calc(100% + 6px); transform-origin: bottom right; } +@keyframes cardMenuIn { from { opacity: 0; transform: translateY(-4px) scale(.98); } to { opacity: 1; transform: none; } } +.svr-act-menu[hidden] { display: none; } +.svr-menu-item { + display: flex; align-items: center; gap: 9px; + padding: 8px 10px; + border: 0; border-radius: var(--radius-sm); + background: transparent; + color: var(--text); font-size: 12.5px; font-weight: 500; + text-align: left; cursor: pointer; width: 100%; + transition: background .14s, color .14s; +} +.svr-menu-item svg { width: 15px; height: 15px; flex: 0 0 15px; opacity: .85; } +.svr-menu-item:hover { background: rgba(255,255,255,.06); } +.svr-menu-item.danger { color: #fca5a5; } +.svr-menu-item.danger:hover { background: rgba(255,66,66,.15); color: #fff; } +.svr-menu-sep { height: 1px; background: var(--border-soft); margin: 4px 2px; } + +/* Action-progress overlay (loading/success/error) on the primary button. */ +.svr-act-primary .act-overlay { + gap: 0; padding: 0; z-index: 3; border-radius: inherit; +} +.svr-act-primary .act-overlay.is-loading { background: var(--grad-accent); color: #05070a; } +.svr-act-primary .act-overlay.is-success { background: var(--grad-accent-2); color: #04200f; } +.svr-act-primary .act-overlay.is-error { background: linear-gradient(180deg, rgba(255,66,66,.95), rgba(220,38,38,.92)); color: #fef2f2; } + +@media (max-width: 480px) { + .svr-card-actions { padding: 8px; gap: 5px; } + .svr-act-icon { width: 32px; height: 32px; } + .svr-act-primary { height: 32px; } +} + +/* Compact agents drawer (collapsed by default, expands when "Manage agents" clicked) */ +.agents-drawer { + margin-top: 22px; + border: 1px solid var(--border-soft); + border-radius: var(--radius); + overflow: hidden; + background: rgba(255,255,255,.012); +} +.agents-drawer summary { + padding: 12px 16px; + cursor: pointer; + font-size: 12.5px; font-weight: 600; color: var(--text-dim); + display: flex; align-items: center; gap: 10px; + list-style: none; + user-select: none; + transition: background .15s, color .15s; +} +.agents-drawer summary::-webkit-details-marker { display: none; } +.agents-drawer summary:hover { background: rgba(255,255,255,.025); color: var(--text); } +.agents-drawer summary::before { + content: "▸"; + transition: transform .2s; + color: var(--accent); opacity: .7; + display: inline-block; font-size: 10px; +} +.agents-drawer[open] summary::before { transform: rotate(90deg); } +.agents-drawer-body { + padding: 4px 16px 16px; + display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; +} +.agent-mini-card { + display: flex; align-items: center; gap: 10px; + padding: 10px 12px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: rgba(255,255,255,.018); + transition: border-color .15s, background .15s; +} +.agent-mini-card:hover { border-color: var(--accent); background: rgba(255,107,26,.08); } +.agent-mini-card.offline { border-color: rgba(255,66,66,.25); background: rgba(255,66,66,.04); } +.agent-mini-card .agent-icon-mini { + width: 32px; height: 32px; flex: 0 0 32px; + border-radius: var(--radius-sm); + background: rgba(255,107,26,.15); border: 1px solid rgba(255,107,26,.3); + display: inline-flex; align-items: center; justify-content: center; + font-size: 16px; +} +.agent-mini-card.offline .agent-icon-mini { background: rgba(255,66,66,.12); border-color: rgba(255,66,66,.32); } +.agent-mini-card .info { flex: 1; min-width: 0; } +.agent-mini-card .info .label { font-size: 12.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agent-mini-card .info .meta { font-size: 10.5px; color: var(--text-dim); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agent-mini-card .actions { display: flex; gap: 5px; } + + +/* ===================== REGION: cmdkdocs ===================== */ +/* =============== COMMAND PALETTE (Ctrl/Cmd+K) =============== + Search-everything jumper. Lists servers, quick actions (Start/ + Stop/Restart per server), pages, and docs sections; arrow keys + navigate, Enter executes. Sized + positioned like a Spotlight / + VS Code command palette: narrow, near the top of the viewport. */ +#cmdk-modal { align-items: flex-start; padding-top: 11vh; } +#cmdk-modal .modal { + width: min(620px, calc(100vw - 32px)); + max-height: 70vh; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--surface); + border: 1px solid var(--border-strong); + animation: popIn .28s var(--ease-out); +} +.cmdk-input-wrap { + display: flex; align-items: center; gap: 11px; + padding: 16px 20px; + border-bottom: 1px solid var(--border-soft); + background: linear-gradient(180deg, rgba(255,107,26,.04), transparent); +} +.cmdk-input-wrap .cmdk-icon { + font-size: 17px; opacity: .7; flex: 0 0 auto; + color: var(--accent); +} +#cmdk-input { + flex: 1; min-width: 0; + border: none; background: transparent; + font-size: 15px; color: var(--text); + padding: 0; + font-family: var(--sans); + font-weight: 500; +} +#cmdk-input:focus { + outline: none; box-shadow: none; background: transparent; +} +#cmdk-input::placeholder { font-style: normal; opacity: .55; color: var(--text-dim); } +.cmdk-input-wrap .cmdk-keyhint { + font-size: 10.5px; color: var(--text-dim); opacity: .75; + white-space: nowrap; + flex: 0 0 auto; + display: inline-flex; gap: 4px; align-items: center; + font-weight: 500; +} +.cmdk-input-wrap .cmdk-keyhint kbd { + display: inline-block; padding: 3px 7px; border-radius: 4px; + background: rgba(255,107,26,.08); border: 1px solid rgba(255,107,26,.25); + border-bottom-width: 1px; + font-family: var(--mono); font-size: 10px; color: var(--accent); + font-weight: 600; +} +.cmdk-results { + max-height: calc(70vh - 62px); + overflow-y: auto; + padding: 8px 8px 10px; +} +.cmdk-section-h { + font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: 1.1px; + color: var(--accent); opacity: .6; + padding: 10px 14px 6px; + text-shadow: 0 0 8px rgba(255,107,26,.15); +} +.cmdk-row { + display: flex; align-items: center; gap: 11px; + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all .14s var(--ease-out); + background: transparent; + border: 1px solid transparent; +} +.cmdk-row:hover { + background: rgba(255,107,26,.08); + border-color: rgba(255,107,26,.18); +} +.cmdk-row.selected { + background: linear-gradient(135deg, rgba(255,107,26,.16), rgba(95,178,255,.12)); + border-color: rgba(255,107,26,.4); +} +.cmdk-row .ic { + width: 28px; height: 28px; flex: 0 0 28px; + display: inline-flex; align-items: center; justify-content: center; + font-size: 14px; opacity: 1; + border-radius: var(--radius-sm); + background: rgba(255,107,26,.12); + transition: all .12s var(--ease-out); +} +.cmdk-row:hover .ic { + box-shadow: 0 0 18px rgba(255,107,26,.25); + transform: scale(1.05); +} +.cmdk-row .text { flex: 1; min-width: 0; } +.cmdk-row .title { + font-size: 13px; font-weight: 600; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + letter-spacing: .2px; +} +.cmdk-row .sub { + font-size: 11px; color: var(--text-dim); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-top: 2px; + opacity: .7; +} +.cmdk-row .badge { + font-size: 10px; font-weight: 700; + padding: 3px 9px; border-radius: var(--radius-pill); + background: rgba(255,107,26,.1); + color: var(--accent); + flex: 0 0 auto; + border: 1px solid rgba(255,107,26,.25); + text-transform: uppercase; + letter-spacing: .5px; +} +.cmdk-row.kind-action .ic { + color: var(--accent); + background: linear-gradient(135deg, rgba(255,107,26,.16), rgba(91,140,255,.12)); +} +.cmdk-row.kind-server .ic { + color: var(--accent-2); + background: linear-gradient(135deg, rgba(95,178,255,.16), rgba(167,139,250,.12)); + box-shadow: 0 0 16px rgba(95,178,255,.2); +} +.cmdk-row.kind-page .ic { + color: var(--accent-3); + background: linear-gradient(135deg, rgba(46,224,107,.16), rgba(46,224,107,.12)); + box-shadow: 0 0 16px rgba(46,224,107,.2); +} +.cmdk-row.kind-doc .ic { + color: var(--warn); + background: linear-gradient(135deg, rgba(255,177,59,.16), rgba(251,191,36,.12)); + box-shadow: 0 0 16px rgba(251,191,36,.2); +} +.cmdk-empty { + padding: 40px 20px; text-align: center; + color: var(--text-dim); +} +.cmdk-empty .big { + font-size: 36px; opacity: .7; margin-bottom: 8px; +} +.cmdk-empty code { + font-family: var(--mono); color: var(--accent); + background: rgba(255,107,26,.08); + padding: 4px 8px; + border-radius: 4px; + font-weight: 600; +} +/* Topbar Cmd+K hint (clickable) */ +.topbar-cmdk-hint { + display: inline-flex; align-items: center; gap: 4px; + padding: 7px 12px; + border-radius: var(--radius-sm); + background: rgba(255,107,26,.08); + border: 1px solid rgba(255,107,26,.2); + font-size: 11px; color: var(--accent); + cursor: pointer; user-select: none; + transition: all .15s var(--ease-out); + font-weight: 500; +} +.topbar-cmdk-hint:hover { + color: #ffffff; + background: rgba(255,107,26,.14); + border-color: rgba(255,107,26,.35); + transform: translateY(-1px); +} +.topbar-cmdk-hint kbd { + display: inline-block; padding: 1px 5px; border-radius: 3px; + background: rgba(255,107,26,.12); border: 1px solid rgba(255,107,26,.25); + font-family: var(--mono); font-size: 10.5px; color: var(--accent); + line-height: 1.5; + font-weight: 600; +} +@media (max-width: 720px) { + .topbar-cmdk-hint { display: none; } /* collapse on phones — Ctrl+K still works */ +} + +/* 720–1200px band: keep the topbar a single 62px line. Without this the + title / Ctrl-K hint wrapped and the bar grew to ~78px. */ +@media (max-width: 1200px) { + .topbar h1 { white-space: nowrap; } + .topbar-cmdk-hint { white-space: nowrap; } + .topbar-cmdk-hint kbd { white-space: nowrap; } + .topbar-search { min-width: 150px; } + .topbar-health { display: none; } /* duplicates the hero chips */ +} + +/* =============== DOCS MODAL =============== */ +#docs-modal .modal { + width: min(1180px, calc(100vw - 32px)); + height: min(86vh, 920px); + max-height: 86vh; + display: flex; flex-direction: column; + background: var(--surface); + border: 1px solid var(--border-strong); + box-shadow: var(--shadow-pop); + animation: popIn .32s var(--ease-out); +} +/* The modal-body default is `display: block; overflow: auto`; for the + docs we need it to be a flex container that constrains its child's + height so docs-content can actually scroll on its own. */ +#docs-modal .modal-body { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 0; + min-height: 0; +} +.docs-shell { display: flex; flex: 1; min-height: 0; } +.docs-rail { + width: 240px; flex: 0 0 240px; + border-right: 1px solid var(--border-soft); + background: var(--grad-rail); + overflow-y: auto; + padding: 16px 12px 28px; + display: flex; flex-direction: column; gap: 20px; +} +.docs-rail-search { + position: relative; padding: 0 4px 8px; + border-bottom: 1px solid var(--border-soft); + margin-bottom: 6px; +} +.docs-rail-search::before { + content: "🔍"; position: absolute; left: 12px; top: 8px; + font-size: 12px; opacity: .65; pointer-events: none; +} +.docs-rail-search input { + width: 100%; padding: 8px 10px 8px 30px; + font-size: 12.5px; + background: rgba(255,107,26,.08); + border: 1px solid rgba(255,107,26,.2); + border-radius: var(--radius-sm); + color: var(--text); + font-family: var(--sans); + transition: all .15s var(--ease-out); +} +.docs-rail-search input:focus { + outline: none; + background: rgba(255,107,26,.12); + border-color: rgba(255,107,26,.35); +} +.docs-rail-search input::placeholder { + color: var(--text-dim); + opacity: .6; +} +.docs-rail-section { display: flex; flex-direction: column; gap: 3px; } +.docs-rail-section-h { + font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: 1.1px; + color: var(--accent); opacity: .65; + padding: 3px 10px 8px; + text-shadow: 0 0 8px rgba(255,107,26,.15); +} +.docs-rail-link { + display: flex; align-items: center; gap: 9px; + padding: 8px 10px; border-radius: var(--radius-sm); + background: transparent; border: 1px solid transparent; + color: var(--text-dim); cursor: pointer; + text-align: left; line-height: 1.4; + transition: all .15s var(--ease-out); + box-shadow: none; font-size: 12.5px; font-weight: 500; + text-decoration: none; + position: relative; +} +.docs-rail-link.indent { padding-left: 26px; font-size: 12px; opacity: .85; } +.docs-rail-link:hover { + color: var(--text); + background: rgba(255,107,26,.1); + border-color: rgba(255,107,26,.2); +} +.docs-rail-link.active { + background: linear-gradient(135deg, rgba(46,224,107,.18), rgba(255,107,26,.12)); + color: var(--accent-3); + border-color: rgba(46,224,107,.35); + font-weight: 600; + box-shadow: 0 0 16px rgba(46,224,107,.15); +} +.docs-rail-link.active::before { + content: ""; position: absolute; left: -13px; top: 5px; bottom: 5px; width: 3px; + background: linear-gradient(180deg, var(--accent-3), var(--accent)); + border-radius: 0 2px 2px 0; + box-shadow: 0 0 12px rgba(46,224,107,.6); + animation: glowBreathe .8s ease-in-out infinite; +} +.docs-rail-link .ic { width: 16px; text-align: center; flex: 0 0 16px; font-size: 13px; } + +.docs-content { + flex: 1; min-width: 0; min-height: 0; + overflow-y: auto; + padding: 32px 42px 90px; + color: var(--text); + line-height: 1.68; + font-size: 13.5px; + scroll-behavior: smooth; + background: linear-gradient(180deg, rgba(11,17,28,.5), transparent); +} +.docs-content section { + margin-bottom: 40px; scroll-margin-top: 24px; + position: relative; /* anchor for the flash stripe */ +} +/* When the user navigates to a section, an accent stripe pulses on the + left edge so the landing is unmistakable, then fades. */ +.docs-content section.docs-flash::before { + content: ""; position: absolute; + left: -26px; top: -6px; bottom: -6px; width: 4px; + background: var(--grad-accent); + border-radius: 2px; + animation: docsSectionFlash 1.8s var(--ease-out) forwards; + pointer-events: none; +} +@keyframes docsSectionFlash { + 40% { opacity: 1; transform: scaleY(1); } +} +.docs-content section.docs-flash > h1, +.docs-content section.docs-flash > h2 { + animation: docsTitleFlash 1.5s var(--ease-out); +} +@keyframes docsTitleFlash { + 0% { transform: translateX(-6px); opacity: .6; } + 22% { transform: translateX(0); opacity: 1; } + 100% { transform: none; opacity: 1; } +} +.docs-content h1 { + font-size: 28px; font-weight: 800; margin: 0 0 14px; + background: linear-gradient(135deg, #ffffff 0%, #ff6b1a 50%, #5fb2ff 100%); + -webkit-background-clip: text; background-clip: text; color: transparent; + letter-spacing: -.5px; + font-family: var(--display); + text-shadow: 0 0 40px rgba(255,107,26,.25); +} +.docs-content h2 { + font-size: 18px; font-weight: 700; + margin: 32px 0 12px; + color: var(--text); + padding-bottom: 10px; + border-bottom: 1px solid var(--border-soft); + display: flex; align-items: center; gap: 10px; + letter-spacing: .1px; +} +.docs-content h2::before { + content: ""; width: 4px; height: 18px; + background: var(--grad-accent); border-radius: 2px; + flex: 0 0 auto; +} +.docs-content h3 { + font-size: 14.5px; font-weight: 700; + margin: 22px 0 10px; + color: var(--accent); + letter-spacing: .15px; + text-shadow: 0 0 12px rgba(255,107,26,.2); +} +.docs-content p { margin: 10px 0; } +.docs-content ul, .docs-content ol { margin: 10px 0; padding-left: 24px; } +.docs-content li { margin: 5px 0; } +.docs-content code { + background: rgba(255,107,26,.1); + border: 1px solid rgba(255,107,26,.22); + padding: 2px 7px; border-radius: 4px; + font-family: var(--mono); font-size: 12px; + color: #b6d4ff; + font-weight: 500; +} +.docs-content pre { + background: linear-gradient(180deg, #0a0f1a, #050709); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + padding: 14px 16px; + margin: 12px 0; + overflow-x: auto; + font-size: 12px; line-height: 1.58; +} +.docs-content pre code { background: none; border: none; padding: 0; color: #c8d8f0; } +.docs-content kbd { + display: inline-block; padding: 2px 7px; border-radius: 4px; + background: rgba(255,107,26,.1); border: 1px solid rgba(255,107,26,.25); + border-bottom-width: 1px; + font-family: var(--mono); font-size: 11.5px; color: var(--accent); + margin: 0 3px; line-height: 1.5; + font-weight: 600; +} +.docs-content a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px dashed rgba(255,107,26,.4); + transition: all .15s var(--ease-out); + font-weight: 500; +} +.docs-content a:hover { + border-bottom-style: solid; + color: #ffffff; + text-shadow: 0 0 12px rgba(255,107,26,.3); +} +.docs-content strong { color: var(--text); font-weight: 700; } +.docs-callout { + margin: 14px 0; + padding: 12px 16px; + border-radius: var(--radius); + border: 1px solid; + display: flex; gap: 12px; align-items: flex-start; + font-size: 12.5px; line-height: 1.6; +} +.docs-callout .ic { flex: 0 0 auto; font-size: 18px; line-height: 1.2; } +.docs-callout.tip { + background: rgba(255,107,26,.08); + border-color: rgba(255,107,26,.3); + color: #b6d8ff; +} +.docs-callout.warn { + background: rgba(255,177,59,.08); + border-color: rgba(255,177,59,.3); + color: #ffd077; + box-shadow: 0 0 16px rgba(255,177,59,.08) inset; +} +.docs-callout.danger{ + background: rgba(255,66,66,.08); + border-color: rgba(255,66,66,.35); + color: #ff9db3; + box-shadow: 0 0 16px rgba(255,66,66,.1) inset; +} +.docs-callout p:first-child { margin-top: 0; } +.docs-callout p:last-child { margin-bottom: 0; } +.docs-table { + width: 100%; border-collapse: collapse; margin: 14px 0; + font-size: 12.5px; +} +.docs-table th, .docs-table td { + text-align: left; padding: 10px 12px; + border-bottom: 1px solid var(--hairline); + vertical-align: top; +} +.docs-table th { + font-weight: 700; font-size: 10.5px; text-transform: uppercase; + letter-spacing: .7px; color: var(--accent); + background: rgba(255,107,26,.06); + border-bottom: 1px solid var(--border-soft); +} +.docs-table tr:hover td { + background: rgba(255,107,26,.04); +} +.docs-content section[data-doc-hidden="1"] { display: none; } +.docs-content .docs-no-results { + padding: 70px 20px; text-align: center; + color: var(--text-dim); +} +.docs-no-results > * { opacity: .7; } +.docs-rail-link[data-doc-hidden="1"] { display: none; } + +/* ===================== REGION: modupload ===================== */ +/* =============== 7DTD MOD UPLOAD ZONE =============== + Drag-and-drop zip uploader. Accepts multiple files at once, + handles nested zips, detects ModInfo.xml on the server, and + writes each mod's contents into /game/Mods//. */ +.sdtd-upload-zone { + position: relative; + border: 2px dashed rgba(255,107,26,.22); + border-radius: var(--radius); + padding: 26px 22px; + text-align: center; + background: linear-gradient(135deg, rgba(255,107,26,.04), rgba(95,178,255,.02)); + transition: + border-color .25s var(--ease-out), + background .25s var(--ease-out), + transform .2s var(--ease-out), + box-shadow .25s ease-out; + cursor: pointer; + overflow: hidden; +} +.sdtd-upload-zone:hover { + border-color: rgba(255,107,26,.35); + background: linear-gradient(135deg, rgba(255,107,26,.08), rgba(95,178,255,.04)); +} +.sdtd-upload-zone.drag-over { + border-color: var(--accent); + background: linear-gradient(135deg, rgba(255,107,26,.14), rgba(95,178,255,.08)); + transform: scale(1.012); + animation: sdtdDragPulse 1.4s ease-in-out infinite; +} +@keyframes sdtdDragPulse { +} +/* Animated grid sweep behind the zone — subtle, gives the UI life + while the operator is dragging. */ +.sdtd-upload-zone::before { + content: ""; position: absolute; inset: 0; + background: + radial-gradient(circle at 20% 30%, rgba(255,107,26,.12), transparent 60%), + radial-gradient(circle at 80% 70%, rgba(95,178,255,.08), transparent 60%); + pointer-events: none; opacity: 0; + transition: opacity .25s var(--ease-out); +} +.sdtd-upload-zone.drag-over::before { opacity: 1; } +/* Floating arrow + dot decoration that animates in on drag-over */ +.sdtd-upload-zone .upload-arrow { + font-size: 42px; line-height: 1; + color: var(--text-dim); opacity: .55; + margin-bottom: 10px; + transition: transform .35s var(--ease-out), color .25s, opacity .25s; + display: inline-block; +} +.sdtd-upload-zone.drag-over .upload-arrow { + transform: translateY(-6px) scale(1.18); + color: var(--accent); opacity: 1; + filter: drop-shadow(0 6px 14px rgba(255,107,26,.55)); + animation: sdtdArrowBob 1.4s ease-in-out infinite; +} +@keyframes sdtdArrowBob { + 0%, 100% { transform: translateY(-6px) scale(1.18); } + 50% { transform: translateY(-12px) scale(1.22); } +} +.sdtd-upload-zone .upload-headline { + font-size: 14.5px; font-weight: 600; color: var(--text); + margin-bottom: 4px; + transition: color .2s; +} +.sdtd-upload-zone.drag-over .upload-headline { color: var(--accent); } +.sdtd-upload-zone .upload-sub { + font-size: 12px; color: var(--text-dim); + line-height: 1.55; +} +.sdtd-upload-zone input[type="file"] { display: none; } + +/* Per-zip / per-mod progress + result list */ +.sdtd-upload-progress { + margin-top: 14px; + padding: 12px 14px; + border: 1px solid rgba(255,107,26,.16); + border-radius: var(--radius-sm); + background: linear-gradient(135deg, rgba(255,107,26,.06), rgba(95,178,255,.02)); +} +.sdtd-upload-progress-head { + display: flex; align-items: center; gap: 9px; + margin-bottom: 8px; + font-size: 13px; font-weight: 600; color: var(--text); +} +.sdtd-upload-progress-spin { + width: 16px; height: 16px; + border: 2px solid rgba(255,107,26,.16); + border-top-color: var(--accent); + border-radius: 50%; + animation: ptr-spin .9s linear infinite; + flex: 0 0 16px; +} +.sdtd-upload-bar { + height: 4px; border-radius: 3px; + background: rgba(255,107,26,.06); + overflow: hidden; +} +.sdtd-upload-bar-fill { + width: 35%; height: 100%; + background: var(--grad-accent); + border-radius: 3px; + animation: bkBarSlide 1.6s ease-in-out infinite; +} + +.sdtd-upload-results { + margin-top: 14px; + display: flex; flex-direction: column; gap: 8px; +} +.sdtd-upload-row { + display: flex; align-items: center; gap: 11px; + padding: 9px 12px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + background: rgba(22,32,50,.4); + animation: cardIn .35s var(--ease-out) backwards; +} +.sdtd-upload-row.installed { border-color: rgba(46,224,107,.22); background: linear-gradient(135deg, rgba(46,224,107,.08), rgba(46,224,107,.02)); } +.sdtd-upload-row.skipped { border-color: rgba(255,177,59,.20); background: linear-gradient(135deg, rgba(255,177,59,.07), rgba(255,177,59,.02)); } +.sdtd-upload-row.failed { border-color: rgba(255,66,66,.22); background: linear-gradient(135deg, rgba(255,66,66,.08), rgba(255,66,66,.02)); } +.sdtd-upload-row .ic { + width: 24px; height: 24px; flex: 0 0 24px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 50%; font-size: 14px; font-weight: 700; +} +.sdtd-upload-row.installed .ic { background: rgba(46,224,107,.2); color: #2ee06b; } +.sdtd-upload-row.skipped .ic { background: rgba(255,177,59,.18); color: #ffc53d; } +.sdtd-upload-row.failed .ic { background: rgba(255,66,66,.2); color: #ff4242; } +.sdtd-upload-row .info { flex: 1; min-width: 0; } +.sdtd-upload-row .name { + font-size: 13px; font-weight: 600; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sdtd-upload-row .meta { + font-size: 11px; color: var(--text-dim); margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sdtd-upload-row .badge { + flex: 0 0 auto; + font-size: 10px; font-weight: 700; letter-spacing: .4px; + text-transform: uppercase; + padding: 2px 8px; border-radius: var(--radius-pill); + border: 1px solid; +} +.sdtd-upload-row.installed .badge { color: #2ee06b; border-color: rgba(46,224,107,.4); background: rgba(46,224,107,.08); } +.sdtd-upload-row.skipped .badge { color: #ffc53d; border-color: rgba(255,177,59,.4); background: rgba(255,177,59,.08); } +.sdtd-upload-row.failed .badge { color: #ff4242; border-color: rgba(255,66,66,.4); background: rgba(255,66,66,.08); } + +/* =============== BACKUPS TAB ===============*/ +.bk-auto-wrap { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 8px; border-radius: var(--radius-sm); + background: rgba(255,107,26,.04); + border: 1px solid var(--border-soft); + transition: border-color .15s, background .15s; +} +.bk-auto-wrap:hover { border-color: rgba(255,107,26,.16); background: rgba(255,107,26,.08); } +.bk-auto-label { font-size: 11.5px; color: var(--text-dim); font-weight: 600; } +.bk-auto-select { + background: transparent; border: none; color: var(--text); + font-size: 12px; font-weight: 600; padding: 4px 4px; + appearance: none; -webkit-appearance: none; + cursor: pointer; + background-image: none; + padding-right: 0; +} +.bk-auto-select:focus { outline: none; box-shadow: none; } +.bk-auto-select option { background: var(--bg-raised); color: var(--text); } +.bk-auto-wrap.active { + background: rgba(255,107,26,.12); + border-color: rgba(255,107,26,.32); +} +.bk-auto-wrap.active .bk-auto-label { color: var(--accent); } + +/* Animated progress strip — sits between the header and the backup + list while a backup is running. SSE log lines from the sidecar + update the bottom line so the operator sees real activity, not a + blind spinner. */ +.im-b-progress { + margin: 12px 18px 0; + padding: 12px 14px; + background: linear-gradient(180deg, rgba(255,107,26,.08), rgba(95,178,255,.04)); + border: 1px solid rgba(255,107,26,.20); + border-radius: var(--radius-sm); + box-shadow: 0 6px 18px rgba(255,107,26,.08); + animation: bkProgressIn .25s var(--ease-out); +} +@keyframes bkProgressIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } } +.im-b-progress-head { + display: flex; align-items: center; gap: 9px; + margin-bottom: 8px; +} +.im-b-progress-head strong { font-size: 13px; color: var(--text); flex: 1; min-width: 0; } +.im-b-progress-elapsed { flex: 0 0 auto; font-size: 11px; } +.im-b-progress-spin { + width: 16px; height: 16px; + border: 2px solid rgba(255,107,26,.16); + border-top-color: var(--accent); + border-radius: 50%; + animation: ptr-spin .9s linear infinite; + flex: 0 0 16px; +} +.im-b-progress-bar { + height: 4px; border-radius: 3px; + background: rgba(255,107,26,.06); + overflow: hidden; + margin-bottom: 8px; +} +.im-b-progress-bar-fill { + width: 35%; height: 100%; + background: var(--grad-accent); + border-radius: 3px; + animation: bkBarSlide 1.6s ease-in-out infinite; +} +@keyframes bkBarSlide { + 0% { transform: translateX(-30%); width: 30%; } + 50% { transform: translateX(60%); width: 50%; } + 100% { transform: translateX(190%); width: 30%; } +} +.im-b-progress-line { + font-size: 11px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + opacity: .8; +} + +/* "Intelligent backups" info note + per-game include/exclude box. */ +.bk-smart-note { + display: flex; align-items: flex-start; gap: 11px; + padding: 12px 14px; + background: linear-gradient(135deg, rgba(255,107,26,.06), rgba(95,178,255,.03)); + border: 1px solid rgba(255,107,26,.14); + border-radius: var(--radius-sm); + margin-bottom: 16px; + font-size: 12px; line-height: 1.55; + color: var(--text-dim); +} +.bk-smart-note .ic { font-size: 18px; line-height: 1; flex: 0 0 auto; } +.bk-smart-note strong { color: var(--text); } + +/* Backup card list — replaces the old . */ +.bk-card-list { display: flex; flex-direction: column; gap: 10px; } +.bk-card { + background: var(--bg-raised); + border: 1px solid var(--border-soft); + border-radius: var(--radius); + padding: 13px 16px; + display: grid; + grid-template-columns: 1fr auto; + gap: 10px 14px; + align-items: center; + transition: border-color .15s, background .15s, box-shadow .2s; + animation: cardIn .35s var(--ease-out) backwards; +} +.bk-card-list > .bk-card:nth-child(1) { animation-delay: 0; } +.bk-card-list > .bk-card:nth-child(2) { animation-delay: 30ms; } +.bk-card-list > .bk-card:nth-child(3) { animation-delay: 60ms; } +.bk-card-list > .bk-card:nth-child(n+4) { animation-delay: 90ms; } +@keyframes bkCardIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.bk-card:hover { + border-color: rgba(255,107,26,.22); + background: var(--grad-card-hover); +} +.bk-card-info { min-width: 0; display: flex; flex-direction: column; gap: 4px; } +.bk-card-when { + font-size: 13.5px; font-weight: 600; color: var(--text); + display: flex; align-items: baseline; gap: 8px; +} +.bk-card-when .ago { font-size: 11px; font-weight: 400; color: var(--text-dim); } +.bk-card-meta { + font-size: 11.5px; color: var(--text-dim); + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + line-height: 1.4; +} +.bk-card-meta .size { font-family: var(--mono); color: var(--accent); font-weight: 600; } +.bk-card-meta .desc { color: var(--text); font-style: italic; } +.bk-card-meta .auto-tag { + padding: 1px 8px; border-radius: var(--radius-pill); + background: rgba(255,107,26,.12); border: 1px solid rgba(255,107,26,.28); + font-size: 10px; font-weight: 700; letter-spacing: .4px; + text-transform: uppercase; color: var(--accent); +} +.bk-card-meta .id { font-family: var(--mono); font-size: 10.5px; opacity: .55; } +.bk-card-actions { display: flex; gap: 6px; flex-shrink: 0; } +.bk-card-actions button { + min-height: 0; + padding: 7px 11px; + font-size: 12px; font-weight: 600; + border-radius: var(--radius-sm); + transition: background .15s, border-color .15s, color .15s, transform .12s; +} +.bk-card-actions button.bk-browse { + background: linear-gradient(135deg, rgba(255,107,26,.14), rgba(255,107,26,.04)); + border: 1px solid rgba(255,107,26,.32); + color: var(--accent); +} +.bk-card-actions button.bk-browse:hover { + background: linear-gradient(135deg, rgba(255,107,26,.28), rgba(255,107,26,.12)); + color: #fff; border-color: var(--accent); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(255,107,26,.24); +} +.bk-card-actions button.bk-restore { + background: linear-gradient(135deg, rgba(46,224,107,.14), rgba(46,224,107,.04)); + border: 1px solid rgba(46,224,107,.32); + color: #2ee06b; +} +.bk-card-actions button.bk-restore:hover { + background: linear-gradient(135deg, rgba(46,224,107,.32), rgba(46,224,107,.16)); + color: #fff; border-color: #2ee06b; + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(46,224,107,.28); +} +.bk-card-actions button.bk-del { + background: linear-gradient(135deg, rgba(255,66,66,.12), rgba(255,66,66,.04)); + border: 1px solid rgba(255,66,66,.24); + color: #ff4242; +} +.bk-card-actions button.bk-del:hover { + background: linear-gradient(135deg, rgba(255,66,66,.32), rgba(255,66,66,.16)); + color: #fff; border-color: #ff4242; + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(255,66,66,.28); +} +@media (max-width: 720px) { + .bk-card { grid-template-columns: 1fr; } + .bk-card-actions { justify-content: flex-end; } +} + +/* =============== BULK CONFIRM MODAL =============== + Replaces the native window.confirm()/prompt() the batch action + runner used to use. Same modal handles confirm-only flows AND + prompts that need a freeform RCON message. */ +#bulk-confirm-modal .bcm-pad { padding: 16px 20px 18px; display: flex; flex-direction: column; gap: 14px; } +#bulk-confirm-modal .bcm-message { + margin: 0; font-size: 14px; color: var(--text); line-height: 1.5; +} +#bulk-confirm-modal .bcm-message strong { color: #fff; } +#bulk-confirm-modal .bcm-input-wrap { display: flex; flex-direction: column; gap: 6px; } +#bulk-confirm-modal .bcm-input-label { + font-size: 11px; font-weight: 700; text-transform: uppercase; + letter-spacing: .8px; color: var(--text-dim); opacity: .85; +} +#bulk-confirm-modal .bcm-input { + padding: 9px 12px; border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: rgba(22,32,50,.5); + color: var(--text); + font-size: 13px; +} +#bulk-confirm-modal .bcm-list-wrap { + background: rgba(22,32,50,.4); + border: 1px solid var(--border-soft); + border-radius: var(--radius-sm); + padding: 10px 14px 12px; + max-height: 180px; overflow: auto; +} +#bulk-confirm-modal .bcm-list-head { + font-size: 11px; font-weight: 700; text-transform: uppercase; + letter-spacing: .8px; color: var(--text-dim); + margin-bottom: 6px; +} +#bulk-confirm-modal .bcm-list { list-style: none; margin: 0; padding: 0; font-family: var(--mono); font-size: 12px; } +#bulk-confirm-modal .bcm-list li { + padding: 3px 0; color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +#bulk-confirm-modal .bcm-list li.more { color: var(--text-dim); font-style: italic; font-family: var(--sans); } +#bulk-confirm-modal .bcm-grace { + font-size: 12px; color: var(--text-dim); + background: linear-gradient(135deg, rgba(255,107,26,.08), rgba(95,178,255,.04)); + border: 1px solid rgba(255,107,26,.18); + border-radius: var(--radius-sm); + padding: 8px 12px; + line-height: 1.5; +} +#bulk-confirm-modal .bcm-buttons { + display: flex; justify-content: flex-end; gap: 10px; + margin-top: 4px; +} +#bulk-confirm-modal .bcm-cancel, +#bulk-confirm-modal .bcm-ok { + padding: 9px 18px; border-radius: var(--radius-sm); + font-size: 13px; font-weight: 600; + cursor: pointer; + transition: background .15s, border-color .15s, color .15s, transform .12s, box-shadow .2s; +} +#bulk-confirm-modal .bcm-cancel { + background: rgba(255,255,255,.04); + border: 1px solid var(--border); + color: var(--text-dim); +} +#bulk-confirm-modal .bcm-cancel:hover { color: var(--text); border-color: rgba(255,107,26,.16); background: rgba(255,107,26,.08); } +#bulk-confirm-modal .bcm-ok { + background: linear-gradient(135deg, rgba(255,107,26,.24), rgba(255,107,26,.12)); + border: 1px solid rgba(255,107,26,.40); + color: #fff; + box-shadow: 0 4px 14px rgba(255,107,26,.20); +} +#bulk-confirm-modal .bcm-ok:hover { + background: linear-gradient(135deg, rgba(255,107,26,.40), rgba(255,107,26,.24)); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(255,107,26,.32); +} +#bulk-confirm-modal .bcm-ok.danger { + background: linear-gradient(135deg, rgba(255,66,66,.24), rgba(255,66,66,.12)); + border-color: rgba(255,66,66,.40); +} +#bulk-confirm-modal .bcm-ok.danger:hover { + background: linear-gradient(135deg, rgba(255,66,66,.40), rgba(255,66,66,.24)); + box-shadow: 0 6px 18px rgba(255,66,66,.32); +} + + +/* ===================== REGION: backupark ===================== */ + /* =============== BACKUP BROWSER ===============*/ + /* Two-pane layout: left is a virtualized-ish tree (each row a flat + div, indented by path depth), right is either a text viewer for + plain-text entries or a "binary, download to view" placeholder. */ + #bk-browser-modal .modal { width: min(1120px, calc(100vw - 32px)); } + #bk-browser-modal .modal-body { + padding: 0; + height: min(70vh, 720px); + min-height: 420px; + display: grid; + grid-template-columns: 340px 1fr; + } + .bkb-tree { + border-right: 1px solid var(--border-strong); + overflow: auto; + padding: 8px 0; + background: linear-gradient(180deg, rgba(15,22,33,.95) 0%, rgba(7,10,16,.92) 100%); + font-size: 13px; + } + .bkb-tree .bkb-search { + padding: 8px 12px 10px; + position: sticky; top: 0; z-index: 2; + background: linear-gradient(180deg, rgba(20,29,44,.96), rgba(15,22,33,.92)); + border-bottom: 1px solid var(--border-strong); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + .bkb-tree .bkb-search input { + width: 100%; + padding: 7px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + font-size: 12px; + transition: border-color 180ms var(--ease-out), box-shadow 180ms var(--ease-out); + } + .bkb-tree .bkb-search input:focus { + outline: none; + border-color: var(--accent); + } + .bkb-row { + display: flex; align-items: center; gap: 6px; + padding: 4px 12px 4px 6px; + cursor: pointer; + user-select: none; + border-radius: var(--radius-sm); + margin: 0 6px; + transition: background 120ms var(--ease-out), color 120ms var(--ease-out); + } + .bkb-row:hover { background: rgba(255,107,26,.06); } + .bkb-row.selected { background: rgba(255,107,26,.12); color: var(--accent); text-shadow: 0 0 8px rgba(255,107,26,.3); } + .bkb-row .bkb-twist { + width: 14px; flex: 0 0 14px; + color: var(--text-dim); + font-family: var(--mono); + font-size: 10px; + text-align: center; + transition: transform 120ms var(--ease-out); + } + .bkb-row .bkb-twist.open { transform: rotate(90deg); } + .bkb-row .bkb-icon { width: 16px; flex: 0 0 16px; opacity: .8; text-align: center; } + .bkb-row .bkb-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); } + .bkb-row .bkb-size { color: var(--text-dim); font-family: var(--mono); font-size: 11px; flex-shrink: 0; } + .bkb-row.dir .bkb-name { color: var(--text); } + .bkb-content { + display: flex; flex-direction: column; min-height: 0; + background: linear-gradient(180deg, rgba(15,22,33,.92) 0%, rgba(7,10,16,.94) 100%); + } + .bkb-content-head { + padding: 9px 14px; + border-bottom: 1px solid var(--border-strong); + display: flex; align-items: center; gap: 12px; + font-family: var(--mono); font-size: 12px; + background: linear-gradient(180deg, rgba(20,29,44,.88), rgba(15,22,33,.85)); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + .bkb-content-head .path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } + .bkb-content-head .bytes { color: var(--text-dim); } + .bkb-content-head button { + padding: 5px 10px; font-size: 11.5px; + border-radius: var(--radius-sm); + background: linear-gradient(180deg, rgba(255,107,26,.12), rgba(255,107,26,.05)); + border: 1px solid var(--accent); + color: var(--accent); + cursor: pointer; + transition: all 140ms var(--ease-out); + font-weight: 600; + } + .bkb-content-head button:hover { + background: linear-gradient(180deg, rgba(255,107,26,.22), rgba(255,107,26,.15)); + color: #fff; + } + .bkb-viewer { + flex: 1; min-height: 0; + overflow: auto; + padding: 12px 16px; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.55; + white-space: pre; + tab-size: 2; + } + .bkb-viewer.text { color: var(--text); } + .bkb-empty, .bkb-binary, .bkb-loading { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; + color: var(--text-dim); gap: 10px; padding: 40px; + text-align: center; + } + .bkb-empty .icon, .bkb-binary .icon { font-size: 42px; opacity: .55; } + .bkb-banner { + margin: 0; padding: 8px 14px; + background: linear-gradient(90deg, rgba(255,177,59,.08), rgba(255,177,59,.02)); + border-bottom: 1px solid rgba(255,177,59,.25); + color: var(--amber); + font-size: 12px; + font-weight: 600; + } + @media (max-width: 720px) { + #bk-browser-modal .modal-body { grid-template-columns: 1fr; height: 75vh; } + .bkb-tree { max-height: 38vh; border-right: none; border-bottom: 1px solid var(--border-strong); } + } + + /* =============== ARK MODS STAGING ===============*/ + /* Pending-state visual treatment for the per-mod rows. Stage adds get + a yellow accent stripe + "to install" badge; stage removes get a + red strikethrough + "will remove" badge. Operator can cancel any + pending action before clicking Apply. */ + .arkm-pending-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 8px; border-radius: var(--radius-pill); + font-size: 9.5px; font-weight: 700; letter-spacing: .4px; + text-transform: uppercase; + border: 1px solid; + vertical-align: middle; + transition: all 120ms var(--ease-out); + } + .arkm-pending-badge.add { + background: linear-gradient(120deg, rgba(255,177,59,.14), rgba(255,177,59,.06)); + border-color: var(--amber); + color: var(--amber); + box-shadow: 0 0 8px rgba(255,177,59,.15); + } + .arkm-pending-badge.remove { + background: linear-gradient(120deg, rgba(255,66,66,.14), rgba(255,66,66,.06)); + border-color: var(--err); + color: var(--err); + box-shadow: 0 0 8px rgba(255,66,66,.15); + } + .dzm-mod-row.arkm-pending-add { + border-left: 3px solid var(--amber); + background: linear-gradient(90deg, rgba(255,177,59,.08), transparent 60%); + box-shadow: inset 0 0 12px rgba(255,177,59,.05); + } + .dzm-mod-row.arkm-pending-remove { + border-left: 3px solid var(--err); + background: linear-gradient(90deg, rgba(255,66,66,.07), transparent 60%); + box-shadow: inset 0 0 12px rgba(255,66,66,.05); + opacity: .65; + } + .dzm-mod-row.arkm-pending-remove .dzm-mod-row-title { + text-decoration: line-through; + text-decoration-color: rgba(255,66,66,.5); + } + .arkm-pending-footer { + display: flex; align-items: center; gap: 10px; + padding: 12px 16px; + background: linear-gradient(180deg, rgba(255,177,59,.08), rgba(255,177,59,.03)); + border: 1px solid var(--amber); + border-radius: var(--radius); + box-shadow: 0 0 0 1px rgba(255,177,59,.15), 0 8px 24px rgba(255,177,59,.1); + position: sticky; bottom: 0; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + } + .arkm-pending-footer .arkm-pending-pip { + width: 8px; height: 8px; border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 8px rgba(255,177,59,.8), inset 0 0 4px rgba(255,255,255,.3); + animation: pulseDot 2s ease-in-out infinite; + flex: 0 0 auto; + } + .arkm-pending-footer .arkm-pending-status { + font-size: 12.5px; color: var(--text); + font-weight: 600; + } + + /* =============== SETTINGS MODAL ===============*/ + .settings-section h4 { + margin: 0 0 10px; + font-size: 11px; font-weight: 700; + text-transform: uppercase; letter-spacing: .9px; + color: var(--accent); + padding-bottom: 8px; + border-bottom: 2px solid var(--accent); + display: flex; align-items: center; gap: 8px; + opacity: 1; + } + .settings-section h4::before { + content: ""; width: 3px; height: 11px; + background: var(--grad-accent); + border-radius: 2px; + flex: 0 0 auto; + } + .settings-row { + display: flex; align-items: center; gap: 14px; + padding: 11px 2px; + border-bottom: 1px dashed rgba(255,107,26,.04); + transition: background 120ms var(--ease-out); + } + .settings-row:hover { background: rgba(255,107,26,.03); } + .settings-row:last-child { border-bottom: none; } + .settings-row-info { flex: 1; min-width: 0; } + .settings-row-info .lbl { font-size: 13px; font-weight: 600; color: var(--text); line-height: 1.3; } + .settings-row-info .hint { font-size: 11.5px; color: var(--text-dim); margin-top: 3px; line-height: 1.45; } + .settings-row-ctrl { flex: 0 0 auto; display: flex; align-items: center; } + .settings-row-ctrl select { + min-width: 170px; + padding: 6px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: var(--text); + font-family: var(--sans); + font-size: 13px; + cursor: pointer; + transition: all 140ms var(--ease-out); + } + .settings-row-ctrl select:hover { + border-color: var(--accent); + } + .settings-row-ctrl select:focus { + outline: none; + border-color: var(--accent); + } + + /* Density toggle hooks: tiles use --svr-grid-min, bars hide via class. */ + .hide-svr-bars .svr-tile-bars { display: none; } + .hide-svr-bars .svr-tile-body { padding-bottom: 13px; } + + /* RGSP brand block in About (settings modal). */ + .about-card { + display: flex; gap: 14px; align-items: center; + padding: 14px 16px; + background: linear-gradient(135deg, rgba(255,107,26,.08), rgba(95,178,255,.06)); + border: 1px solid rgba(255,107,26,.25); + border-radius: var(--radius); + } + .about-card .logo-big { + width: 52px; height: 52px; flex: 0 0 52px; + border-radius: var(--radius-sm); + background: var(--grad-accent); + display: flex; align-items: center; justify-content: center; + color: #0a0e17; font-weight: 800; font-size: 18px; + letter-spacing: -.5px; + position: relative; overflow: hidden; + } + .about-card .logo-big::after { + content: ""; position: absolute; inset: 0; + background: radial-gradient(circle at 30% 25%, rgba(255,255,255,.4), transparent 55%); + } + .about-card .info .name { + font-size: 15px; font-weight: 700; color: var(--text); + display: flex; align-items: baseline; gap: 8px; + } + .about-card .info .name .ver { font-size: 11.5px; font-weight: 400; color: var(--text-dim); font-family: var(--mono); } + .about-card .info .desc { font-size: 12px; color: var(--text-dim); margin-top: 4px; line-height: 1.5; } + .about-card .info .links { margin-top: 8px; display: flex; gap: 12px; font-size: 11.5px; } + .about-card .info .links a { color: var(--accent); text-decoration: none; transition: all 120ms var(--ease-out); } + .about-card .info .links a:hover { color: #fff; text-decoration: underline; text-shadow: 0 0 8px rgba(255,107,26,.4); } + +/* ===================== REGION: toastutil ===================== */ + /* =============== TOASTS =============== */ + #toasts { + position: fixed; bottom: 24px; right: 24px; z-index: 9999; + display: flex; flex-direction: column; gap: 12px; max-width: 440px; + pointer-events: none; + } + #toasts .toast { pointer-events: auto; } + .toast { + background: linear-gradient(180deg, rgba(15,22,34,.88), rgba(7,10,16,.91)); + backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); + border: 1px solid var(--border-strong); + border-radius: var(--radius-lg); + padding: 16px 20px; + font-size: 13px; + color: var(--text); + animation: toastIn .4s var(--ease-out); + position: relative; overflow: hidden; + } + .toast::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; + background: var(--grad-accent); + } + .toast.ok::before { + background: var(--grad-ok); + box-shadow: 0 0 16px rgba(46,224,107,.6), inset 0 0 12px rgba(46,224,107,.3); + } + .toast.err::before { + background: var(--grad-danger); + box-shadow: 0 0 16px rgba(255,66,66,.6), inset 0 0 12px rgba(255,66,66,.3); + } + .toast .close { + float: right; cursor: pointer; color: var(--text-dim); + margin-left: 12px; font-size: 18px; line-height: 1; + /* real ${esc(msg)}`; + host.appendChild(el); + // Fade out via .leaving, remove on transitionend (fallback timer in case + // transitions are disabled, e.g. prefers-reduced-motion). + const leave = () => { + if (el.classList.contains("leaving")) return; + el.classList.add("leaving"); + el.addEventListener("transitionend", () => el.remove(), { once: true }); + setTimeout(() => el.remove(), 400); + }; + el.querySelector(".close").addEventListener("click", leave); + // Default 5s; longer for sticky info ("warn" / explicit ms override). + // TTL pauses while hovered so the operator can finish reading. + const ttl = (typeof ms === "number" && ms > 0) ? ms : 5000; + let timer = setTimeout(leave, ttl); + el.addEventListener("mouseenter", () => { clearTimeout(timer); timer = null; }); + el.addEventListener("mouseleave", () => { if (!timer) timer = setTimeout(leave, ttl); }); +} +function setStatus(t, cls) { + $("#status").textContent = t; + const d = $("#status-dot"); + d.className = "dot " + (cls === "ok" ? "ok" : cls === "err" ? "err" : ""); +} +async function fetchJSON(url, opts) { + const r = await fetch(url, opts); + if (r.status === 401) { location.href = "/login?next=" + encodeURIComponent(location.pathname); throw new Error("unauthenticated"); } + if (!r.ok) { + // Read the body ONCE as text, then try to parse it as JSON. Calling + // r.json() and THEN r.text() on the same Response throws "body stream + // already read" — which masked the real error on the ARK Files tab + // (Ragnarok's big save dir returns a non-JSON / empty error body, so + // r.json() threw, and the catch's r.text() hit the locked stream). + const raw = await r.text().catch(() => ""); + let body = { message: raw }; + try { body = JSON.parse(raw); } catch {} + // Attach status + machine-readable code so callers can branch on a specific + // failure (e.g. the 7DTD 409 "version_locked") instead of matching on the + // human message text. writeError() emits {code, message}; older endpoints + // emit {error}. Backward-compatible: existing callers still read .message. + const e = new Error(body.message || body.error || raw || `${r.status}`); + e.status = r.status; + e.code = body.code || (typeof body.error === "string" ? body.error : "") || ""; + throw e; + } + return r.json(); +} + +// ================ Build-tag capabilities ================ +// The controller advertises optional (build-tag-gated) features via +// GET /api/capabilities. Default is everything OFF: a public build +// shows no RefugeBot install UI and no SaaS cross-sell link. +window.PANEL_CAPS = {}; +document.addEventListener("DOMContentLoaded", async () => { + try { + window.PANEL_CAPS = await fetchJSON("/api/capabilities"); + } catch { return; } // caps stay {} → all gated UI hidden + if (window.PANEL_CAPS.refugebot) { + // Inject the RefugeBot Server sidebar link (Tools section, above Steam). + const steam = document.querySelector('.side-link[data-scroll="steam"]'); + if (steam && !document.getElementById("side-refugebot")) { + const a = document.createElement("a"); + a.id = "side-refugebot"; + a.className = "side-link"; + a.href = "https://app.refugegaming.org"; + a.target = "_blank"; + a.rel = "noopener"; + a.title = "Open RefugeBot Server (app.refugegaming.org)"; + a.innerHTML = '🤖RefugeBot Server'; + steam.parentNode.insertBefore(a, steam); + } + } +}); + +// ================ Global action indicator ================ +// +// runAction(buttonEl, asyncFn, opts?) wraps any async handler so the +// button shows live feedback — spinner + bottom progress bar during +// the action, a green ✓ with success label for 1.5 s on resolve, a +// red ✕ with the error tooltip for 2.5 s on reject. The button's +// original content is preserved and restored. +// +// A top-of-page 2px progress strip shows when ANY action is in flight, +// giving the whole UI a "something is happening" signal. +// +// Apply to any button that kicks off an async operation. Zero-cost if +// a button doesn't use it — legacy buttons keep working unchanged. + +let _actInFlight = 0; +function _actBumpGlobal(delta) { + _actInFlight = Math.max(0, _actInFlight + delta); + const bar = $("#act-progress-global"); + if (bar) bar.classList.toggle("on", _actInFlight > 0); +} + +async function runAction(btn, fn, opts) { + opts = opts || {}; + if (!btn) return fn(); + // Overlay approach — no more clobbering the button's label. The button's + // underlying innerHTML (■ Stop / ▶ Start / ↻ Restart / etc.) stays 100% + // owned by updateCard. We attach an absolutely-positioned overlay span + // with the loading / success / error indicator; updateCard can repaint + // the button label any time it wants without fighting us, and we just + // remove our overlay when done. Eliminates the "button briefly reverts + // from Start back to Stop after the 1.5s success hold" class of bugs. + const origDisabled = btn.disabled; + const origPosition = btn.style.position; + if (!btn.style.position) btn.style.position = "relative"; + const overlay = document.createElement("span"); + overlay.className = "act-overlay is-loading"; + overlay.innerHTML = `${esc(opts.loadingLabel || labelFromNode(btn) + "…")}`; + btn.appendChild(overlay); + btn.disabled = true; + btn.dataset._actRestore = "1"; + _actBumpGlobal(+1); + const removeOverlay = () => { + if (overlay.parentNode === btn) btn.removeChild(overlay); + if (!origPosition) btn.style.removeProperty("position"); + btn.disabled = origDisabled; + delete btn.dataset._actRestore; + btn.title = ""; + }; + try { + const result = await fn(); + overlay.classList.remove("is-loading"); + overlay.classList.add("is-success"); + overlay.innerHTML = `${esc(opts.successLabel || "Success")}`; + setTimeout(removeOverlay, opts.successHold || 1200); + return result; + } catch (err) { + overlay.classList.remove("is-loading"); + overlay.classList.add("is-error"); + overlay.innerHTML = `${esc(opts.errorLabel || "Failed")}`; + btn.title = (err && err.message) || String(err); + setTimeout(removeOverlay, opts.errorHold || 2200); + throw err; + } finally { + _actBumpGlobal(-1); + } +} + +// labelFromNode strips existing icon children and returns plain text. +function labelFromNode(el) { + const clone = el.cloneNode(true); + clone.querySelectorAll(".act-spin, .act-check, .act-x").forEach(n => n.remove()); + return clone.textContent.trim(); +} +let _modalZ = 100; + +// ---- modal close stack + focus trap -------------------------------------- +// Every open modal (static #id modals AND dynamically-built wrappers) pushes +// one close function here, in open order. Escape pops ONLY the top entry, so +// stacked modals close one per keypress and _modalZ decrements exactly once +// per close (fixes the old per-modal-keydown double-decrement drift). +const _modalCloseStack = []; +function _modalStackPush(fn) { _modalCloseStack.push(fn); return fn; } +function _modalStackRemove(fn) { + const i = _modalCloseStack.indexOf(fn); + if (i >= 0) _modalCloseStack.splice(i, 1); +} +function closeTopModal() { + const fn = _modalCloseStack[_modalCloseStack.length - 1]; + if (fn) fn(); + return !!fn; +} +// trapFocus: wrap Tab / Shift+Tab at the first/last focusable inside the +// modal wrapper so keyboard focus never escapes to the page behind it. +function trapFocus(bg) { + if (bg._focusTrapped) return; + bg._focusTrapped = true; + const SEL = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + bg.addEventListener("keydown", e => { + if (e.key !== "Tab") return; + const items = [...bg.querySelectorAll(SEL)].filter(el => el.offsetParent !== null); + if (!items.length) { e.preventDefault(); return; } + const first = items[0], last = items[items.length - 1]; + const inside = bg.contains(document.activeElement); + if (e.shiftKey && (!inside || document.activeElement === first)) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && (!inside || document.activeElement === last)) { e.preventDefault(); first.focus(); } + }); +} +// initDynamicModal: shared chrome for dynamically-built modal wrappers — +// z-bump, aria, focus trap, backdrop click, and stack registration. Returns +// the close() the builder should use. Escape is handled centrally. +function initDynamicModal(bg, onClose) { + bg.setAttribute("role", "dialog"); + bg.setAttribute("aria-modal", "true"); + _modalZ += 2; bg.style.zIndex = _modalZ; + trapFocus(bg); + const close = () => { + _modalStackRemove(close); + bg.remove(); + _modalZ = Math.max(100, _modalZ - 2); + if (onClose) onClose(); + }; + _modalStackPush(close); + bg._stackClose = close; // lets the shared [data-close] handler close us too + bg.addEventListener("click", e => { if (e.target === bg) close(); }); + return close; +} +function showModal(id) { + const m = $(id); if (!m) return; + m.classList.remove("closing"); + m.setAttribute("role", "dialog"); + m.setAttribute("aria-modal", "true"); + _modalZ += 2; + m.style.zIndex = _modalZ; + m.style.display = "flex"; + trapFocus(m); + if (m._stackClose) _modalStackRemove(m._stackClose); // re-show → move to top + m._stackClose = _modalStackPush(() => hideModal(id)); +} +function hideModal(id) { + const m = $(id); if (!m || m.style.display === "none") return; + if (m._stackClose) { _modalStackRemove(m._stackClose); m._stackClose = null; } + m.classList.add("closing"); + // Match the CSS exit animation duration (modalOut + bgFadeOut = .14s). + // Reduced-motion shortens to .10s; pick whichever the media query is + // currently in via matchMedia. + const reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const closeMs = reduced ? 110 : 150; + setTimeout(() => { + m.style.display = "none"; m.classList.remove("closing"); + m.style.zIndex = ""; // let CSS default take over again + const anyOpen = [...$$(".modal-bg")].some(x => x.style.display === "flex"); + if (!anyOpen) _modalZ = 100; + }, closeMs); + if (id === "#instance-modal") onInstanceModalClose(); +} +document.addEventListener("click", e => { + if (e.target.matches("[data-close]") || (e.target.closest && e.target.closest("[data-close]"))) { + const m = (e.target.closest("[data-close]") || e.target).closest(".modal-bg"); + // Dynamic (id-less) wrappers close via their registered stack fn — + // hideModal("#") on an empty id would throw in querySelector. + if (m) { if (m.id) hideModal("#" + m.id); else if (m._stackClose) m._stackClose(); } + } + if (e.target.classList.contains("modal-bg")) { + if (e.target.id) hideModal("#" + e.target.id); + else if (e.target._stackClose) e.target._stackClose(); + } +}); +document.addEventListener("keydown", e => { + if (e.key === "Escape") { + // Pop only the top of the shared close stack (static + dynamic modals). + if (closeTopModal()) return; + // Fallback for anything shown without registering (legacy paths). + const open = [...$$(".modal-bg")].filter(m => m.style.display !== "none" && !m.classList.contains("closing")); + if (open.length) hideModal("#" + open[open.length - 1].id); + } +}); + +// ------------------ Steam login gate ------------------ +// +// Some SteamCMD apps (DayZ, Arma, a few others) refuse +login anonymous. +// When the operator triggers an update on such a module and the panel has +// no cached credentials, /api/instances//update returns 200 with +// {steam_login_required: true} rather than forwarding to the agent. We +// detect that here, pop the Steam-login modal, and retry once the login +// ceremony completes. +// confirmVersionUnlock gates a deliberate 7DTD game-version SWITCH on a LOCKED +// (frozen-branch) server behind a typed-slug confirm — per the destructive- +// action rule, since moving a live world between game versions (e.g. 2.6 → 3.0) +// can corrupt the save. Returns true only if the operator typed the exact +// instance id. `message` is the controller's version_locked explanation. +function confirmVersionUnlock(instanceID, message) { + const typed = window.prompt( + (message || "This server is locked to its current game version.") + + "\n\nSwitching game versions can CORRUPT an existing world and is not reversible.\n\n" + + "Type the server id to confirm the switch:\n" + instanceID, ""); + return typed !== null && typed.trim() === instanceID; +} + +async function triggerUpdateWithSteamGate(instanceID, providerID, allowVersionChange) { + const payload = {}; + if (providerID) payload.provider_id = providerID; + if (allowVersionChange) payload.allow_version_change = true; + const body = JSON.stringify(payload); + let r; + try { + r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/update`, + { method: "POST", headers: {"Content-Type":"application/json"}, body }); + } catch (err) { + // 7DTD version lock: the controller refuses to switch a LOCKED server to a + // different game version with 409 version_locked, unless we resend with + // allow_version_change. Confirm with a typed slug, then retry exactly once. + if (err && err.code === "version_locked" && !allowVersionChange) { + if (!confirmVersionUnlock(instanceID, err.message)) return null; // cancelled + return triggerUpdateWithSteamGate(instanceID, providerID, true); + } + throw err; + } + if (r && r.steam_login_required) { + const ok = await openSteamLoginModal(r.reason || "This module needs a Steam account that owns it."); + if (!ok) return null; // user cancelled + // Retry — now credentials are stored, controller will forward. + r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/update`, + { method: "POST", headers: {"Content-Type":"application/json"}, body }); + if (r && r.steam_login_required) { + throw new Error("Steam login did not take; try again or re-enter credentials"); + } + } + return r; +} + +// openSteamLoginModal shows the modal and returns a Promise that +// resolves to true when login succeeds, false when the user cancels. +function openSteamLoginModal(introText) { + return new Promise((resolve) => { + const reset = () => { + $("#sl-user").value = ""; + $("#sl-pass").value = ""; + $("#sl-guard").value = ""; + $("#sl-error").style.display = "none"; + $("#sl-error").textContent = ""; + // Reset error styling — the submit handler temporarily repurposes + // this div as a yellow "waiting on Steam push" banner. + $("#sl-error").style.color = "var(--err)"; + $("#sl-error").style.background = "rgba(239,68,68,0.08)"; + $("#sl-error").style.borderLeftColor = "var(--err)"; + $("#sl-submit").disabled = false; + $("#sl-submit").textContent = "Sign in to Steam"; + }; + if (introText) $("#sl-intro").textContent = introText; + reset(); + showModal("#steam-login-modal"); + setTimeout(() => $("#sl-user").focus(), 80); + + const cancelBtn = $("#sl-cancel"); + const submitBtn = $("#sl-submit"); + const closeBtn = $("#steam-login-modal [data-close]"); + + const cleanup = () => { + cancelBtn.onclick = null; + submitBtn.onclick = null; + if (closeBtn) closeBtn.onclick = null; + }; + const done = (result) => { cleanup(); hideModal("#steam-login-modal"); resolve(result); }; + + cancelBtn.onclick = () => done(false); + if (closeBtn) closeBtn.onclick = () => done(false); + + submitBtn.onclick = async () => { + const username = $("#sl-user").value.trim(); + const password = $("#sl-pass").value; + const guardCode = $("#sl-guard").value.trim().toUpperCase(); + if (!username || !password) { + $("#sl-error").textContent = "username and password are required"; + $("#sl-error").style.display = "block"; + return; + } + submitBtn.disabled = true; + submitBtn.textContent = "📱 Waiting for Steam Guard approval (up to 90s)…"; + $("#sl-error").style.display = "none"; + // Also surface a yellow banner so the wait state is impossible to + // miss — the button text alone is too easy to overlook on a long + // press, which is why the operator wonders if the modal is hung. + const errEl = $("#sl-error"); + errEl.style.color = "#d97706"; + errEl.style.background = "rgba(217,119,6,0.08)"; + errEl.style.borderLeftColor = "#d97706"; + errEl.textContent = "📱 Check your Steam Mobile app for a push notification — tap Approve. The panel will wait up to 90 seconds."; + errEl.style.display = "block"; + try { + const r = await fetchJSON("/api/steam/login", { + method: "POST", + headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ username, password, guard_code: guardCode }), + }); + if (r && r.ok) { + toast(`Steam login saved for ${r.username}`, "ok"); + done(true); + return; + } + if (r && r.need_guard) { + $("#sl-intro").textContent = "Steam Guard code required. Check your email or the Steam Mobile app."; + $("#sl-guard").focus(); + $("#sl-error").textContent = "Enter the Steam Guard code above and resubmit."; + $("#sl-error").style.display = "block"; + submitBtn.disabled = false; + submitBtn.textContent = "Retry with code"; + return; + } + $("#sl-error").textContent = (r && r.error) || "Login failed — check your credentials."; + $("#sl-error").style.display = "block"; + submitBtn.disabled = false; + submitBtn.textContent = "Sign in to Steam"; + } catch (err) { + $("#sl-error").textContent = "Request failed: " + err.message; + $("#sl-error").style.display = "block"; + submitBtn.disabled = false; + submitBtn.textContent = "Sign in to Steam"; + } + }; + }); +} + +// Keep toggle label text in sync with the underlying checkbox state. +document.addEventListener("change", e => { + if (e.target.matches(".toggle input[type='checkbox']")) { + const lbl = e.target.closest(".toggle").querySelector("[data-toggle-label]"); + if (lbl) { lbl.textContent = e.target.checked ? "Enabled" : "Disabled"; lbl.classList.toggle("on", e.target.checked); } + } +}); + +function fmtBytes(n) { + if (!n) return "0 B"; + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + " MB"; + return (n / 1024 / 1024 / 1024).toFixed(2) + " GB"; +} +function fmtTime(iso) { + if (!iso) return "—"; + const d = new Date(iso); if (isNaN(d)) return iso; + const diff = (Date.now() - d) / 1000; + if (diff < 60) return Math.floor(diff) + "s ago"; + if (diff < 3600) return Math.floor(diff / 60) + "m ago"; + if (diff < 86400) return Math.floor(diff / 3600) + "h ago"; + return d.toLocaleString(); +} + +// ================ game appearance (emoji + gradient + artwork) ================ +// Each entry can carry three art sources (first non-empty wins): +// art — panel-hosted image in /game-art/.{jpg,png} (best — +// no external dependency, loads instantly, survives Steam CDN +// renames). +// steam — Steam app id → hotlink to cdn.cloudflare.steamstatic.com. +// Fallback for modules where we haven't packaged art yet. +// grad — gradient used for the flat tile on non-Steam games + as a +// background-color behind the image while it loads. +const moduleAppearance = { + "minecraft-java": { emoji: "🧱", grad: "linear-gradient(135deg,#4c8b32,#84ce5c)", art: "/game-art/minecraft-java.png" }, + "minecraft-bedrock": { emoji: "🧱", grad: "linear-gradient(135deg,#2d6929,#5a9d56)" }, + "7dtd": { emoji: "🧟", grad: "linear-gradient(135deg,#7b3b12,#d97706)", art: "/game-art/7dtd.jpg", steam: "251570" }, + "valheim": { emoji: "⚔️", grad: "linear-gradient(135deg,#3b5998,#8da5d4)", steam: "896660" }, + "ark": { emoji: "🦖", grad: "linear-gradient(135deg,#1f6f47,#45a877)", steam: "346110" }, + "ark-sa": { emoji: "🦖", grad: "linear-gradient(135deg,#3a2e1e,#d97706)", art: "/game-art/ark-sa.jpg", steam: "2430930" }, + "terraria": { emoji: "🌳", grad: "linear-gradient(135deg,#2d7141,#78c87a)", steam: "105600" }, + "rust": { emoji: "🔧", grad: "linear-gradient(135deg,#8b4a2b,#d97706)", steam: "252490" }, + "cs2": { emoji: "🔫", grad: "linear-gradient(135deg,#2a3a4a,#4a5e74)", steam: "730" }, + "tf2": { emoji: "🔨", grad: "linear-gradient(135deg,#a0443a,#e07960)", steam: "440" }, + "satisfactory": { emoji: "🏭", grad: "linear-gradient(135deg,#d97706,#fbbf24)", steam: "526870" }, + "factorio": { emoji: "🏭", grad: "linear-gradient(135deg,#8c6239,#d4a373)", steam: "427520" }, + "palworld": { emoji: "🐾", grad: "linear-gradient(135deg,#006d77,#83c5be)", steam: "1623730" }, + "empyrion": { emoji: "🚀", grad: "linear-gradient(135deg,#0b2545,#5c7cfa)", art: "/game-art/empyrion.jpg", steam: "383120" }, + "valheim": { emoji: "⚔️", grad: "linear-gradient(135deg,#3b5998,#8da5d4)", art: "/game-art/valheim.jpg", steam: "892970" }, + "palworld": { emoji: "🐾", grad: "linear-gradient(135deg,#006d77,#83c5be)", art: "/game-art/palworld.jpg", steam: "1623730" }, + "factorio": { emoji: "🏭", grad: "linear-gradient(135deg,#8c6239,#d4a373)", art: "/game-art/factorio.jpg", steam: "427520" }, + "satisfactory": { emoji: "🏭", grad: "linear-gradient(135deg,#d97706,#fbbf24)", art: "/game-art/satisfactory.jpg", steam: "526870" }, + "barotrauma": { emoji: "🚢", grad: "linear-gradient(135deg,#1e3a5f,#3b6e8c)", art: "/game-art/barotrauma.jpg", steam: "602960" }, + "enshrouded": { emoji: "🌫️", grad: "linear-gradient(135deg,#4a2c2a,#a45e4a)", art: "/game-art/enshrouded.jpg", steam: "1203620" }, + "terraria": { emoji: "🌳", grad: "linear-gradient(135deg,#2d7141,#78c87a)", art: "/game-art/terraria.jpg", steam: "105600" }, + "project-zomboid": { emoji: "🧟", grad: "linear-gradient(135deg,#6a4c1e,#b89040)", art: "/game-art/project-zomboid.jpg", steam: "108600" }, + "core-keeper": { emoji: "⛏️", grad: "linear-gradient(135deg,#2e1a47,#6b3fa0)", art: "/game-art/core-keeper.jpg", steam: "1621690" }, + "soulmask": { emoji: "🗿", grad: "linear-gradient(135deg,#3a2416,#7a5230)", art: "/game-art/soulmask.jpg", steam: "2646460" }, + "v-rising": { emoji: "🧛", grad: "linear-gradient(135deg,#3b0a0a,#991b1b)", art: "/game-art/v-rising.jpg", steam: "1604030" }, + "sons-of-the-forest": { emoji: "🌲", grad: "linear-gradient(135deg,#14532d,#4d7c0f)", art: "/game-art/sons-of-the-forest.jpg", steam: "1326470" }, + "windrose": { emoji: "🧭", grad: "linear-gradient(135deg,#1e3a5f,#60a5fa)", art: "/game-art/windrose.jpg", steam: "3041230" }, + "conan-exiles": { emoji: "⚔️", grad: "linear-gradient(135deg,#5a1a00,#c2410c)", art: "/game-art/conan-exiles.jpg", steam: "440900" }, + "rust": { emoji: "🔧", grad: "linear-gradient(135deg,#8b4a2b,#d97706)", art: "/game-art/rust.jpg", steam: "252490" }, + "dragonwilds": { emoji: "🐉", grad: "linear-gradient(135deg,#4c1d95,#f59e0b)", art: "/game-art/dragonwilds.jpg", steam: "1374490" }, + "dayz": { emoji: "🧟", grad: "linear-gradient(135deg,#2d3e2e,#6b7f56)", art: "/game-art/dayz.jpg", steam: "221100" }, + "vein": { emoji: "🩸", grad: "linear-gradient(135deg,#2a0a0a,#7a1010)", art: "/game-art/vein.jpg", steam: "1857950" }, + "project-zomboid": { emoji: "🧟", grad: "linear-gradient(135deg,#6a4c1e,#b89040)", steam: "108600" }, + "gmod": { emoji: "🔧", grad: "linear-gradient(135deg,#334155,#64748b)", steam: "4020" }, + "demo": { emoji: "🧪", grad: "linear-gradient(135deg,#4b5563,#9ca3af)" }, + "steamcmd-test": { emoji: "⚙️", grad: "linear-gradient(135deg,#1e3a8a,#3b82f6)" }, +}; + +// Steam CDN URL builder. header.jpg is the 460x215 banner that Steam shows +// at the top of the store page; it's the canonical "game art" asset. +function steamHeaderURL(appid) { + return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/header.jpg`; +} +function steamCapsuleURL(appid) { + // 467x181 tall capsule — nice for the square-ish tile on cards. + return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appid}/library_600x900.jpg`; +} +// Art URL resolver — local panel-hosted art wins over Steam CDN so packaged +// games render instantly offline and don't break when Steam renames things. +function artURLFor(app) { + if (!app) return ""; + if (app.art) return app.art; + if (app.steam) return steamHeaderURL(app.steam); + return ""; +} + +// Square game-icon resolver. Looks for a bundled file under +// /game-art/icons/.jpg (every module with a Steam library +// poster has one — see static/game-art/icons/). Returns "" when the +// module has no bundled icon, in which case the caller should fall +// back to the emoji. +// +// Bundled — never reaches over the network at runtime. Adding a new +// module means dropping a new .jpg into the icons/ directory and +// listing the id below; that keeps the offline guarantee even when +// Steam renames or moves their asset paths. +const _GAME_ICON_IDS = new Set([ + "7dtd","ark-sa","barotrauma","conan-exiles","core-keeper","dayz", + "dragonwilds","empyrion","enshrouded","factorio","palworld", + "project-zomboid","rust","satisfactory","sons-of-the-forest", + "soulmask","terraria","v-rising","valheim","vein","windrose", +]); +function gameIconURLFor(moduleID) { + if (!moduleID) return ""; + if (_GAME_ICON_IDS.has(moduleID)) return `/game-art/icons/${moduleID}.jpg`; + return ""; +} + +// Module-id → short label for the tile's top-left chip. Hand-curated +// for known games (so "ark-sa" reads as "ARKASA" not "Ark-sa") with a +// reasonable fallback for unknown modules. +const MODULE_ABBR = { + "7dtd": "7DTD", + "ark": "ARK", + "ark-sa": "ARKASA", + "minecraft-java": "MC", + "valheim": "Valheim", + "v-rising": "VRising", + "empyrion": "Empyrion", + "palworld": "Palworld", + "rust": "Rust", + "factorio": "Factorio", + "satisfactory": "Satisfactory", + "barotrauma": "Barotrauma", + "enshrouded": "Enshrouded", + "terraria": "Terraria", + "project-zomboid": "PZ", + "core-keeper": "CoreKeeper", + "soulmask": "Soulmask", + "sons-of-the-forest":"SOTF", + "windrose": "Windrose", + "conan-exiles": "Conan", + "dayz": "DayZ", + "vein": "VEIN", + "dragonwilds": "DragonWilds", + "cs2": "CS2", + "tf2": "TF2", + "gmod": "GMod", + "demo": "Demo", + "steamcmd-test": "SteamCMD", +}; +function _moduleAbbr(moduleID) { + if (!moduleID) return "—"; + if (MODULE_ABBR[moduleID]) return MODULE_ABBR[moduleID]; + // Fallback: drop common suffixes, then PascalCase the segments. + let id = String(moduleID) + .replace(/-server$/i, "") + .replace(/-galactic-survival$/i, "") + .replace(/-edition$/i, ""); + return id.split("-").map(p => p ? p[0].toUpperCase() + p.slice(1) : "").join(""); +} + +function appearanceFor(moduleID) { + // WI-07: manifest appearance (from /api/modules) wins; inline map is + // the fallback for modules without a manifest dir (ark, cs2, tf2, gmod) + // or before the first modules poll lands. + const meta = _modMeta(moduleID); + if (meta && meta.appearance) return meta.appearance; + const base = moduleAppearance[moduleID]; + if (base) return base; + // Deterministic fallback: hash to a hue. + let h = 0; for (const c of moduleID) h = (h * 31 + c.charCodeAt(0)) >>> 0; + const hue = h % 360; + return { emoji: "🎮", grad: `linear-gradient(135deg, hsl(${hue} 55% 30%), hsl(${(hue+30)%360} 65% 55%))` }; +} + +// ================ auth header ================ +async function loadMe() { + try { + const me = await fetchJSON("/api/me"); + $("#whoami").textContent = me.email; + } catch {} +} +$("#logout").addEventListener("click", async () => { + try { await fetchJSON("/api/logout", { method: "POST" }); } catch {} + location.href = "/login"; +}); + +// ================ data ================ +const latestStats = {}; +const latestAppState = {}; +let allInstances = []; +let allAgents = []; +let allModules = []; +// agentsLoaded flips true after the first /api/agents response lands +// (success OR empty). Used by renderAgents to avoid flashing the +// "No agents yet" empty state on hard refreshes when the render races +// the API call. +let agentsLoaded = false; +let instancesLoaded = false; + +// ================ polling ================ + +// Controller-liveness tracker. Polls fail in two distinct ways: +// +// 1. one-off network blip — controller is up, packet got dropped. Next +// tick recovers cleanly. Don't bother the operator. +// 2. controller restart — sustained failures, then recovery. Open +// browser tabs at this point have stale JS state (in-flight fetches +// that pre-date the restart, polling timers tied to the old session). +// The new page load works; the open tab is lying half-frozen. We +// surface a sticky reload prompt so the operator doesn't sit +// wondering why a tab is stuck (see panel/memory/gotchas.md +// "Dashboard tab gets stuck on 'loading…' after a controller +// restart"). +// +// Threshold: 3 consecutive poll failures before we count the controller +// as down. Lower would flap on transient blips; higher would keep the +// operator confused longer. +const controllerLiveness = { + consecutiveFailures: 0, + wasDown: false, // true once we've crossed the down threshold + bannerShown: false, // dedupe so we don't keep stacking banners + downAt: 0, // timestamp first failure (for the banner copy) + downThreshold: 3, +}; + +function notePollFailure(err) { + controllerLiveness.consecutiveFailures++; + if (!controllerLiveness.wasDown && controllerLiveness.consecutiveFailures === 1) { + controllerLiveness.downAt = Date.now(); + } + if (!controllerLiveness.wasDown && controllerLiveness.consecutiveFailures >= controllerLiveness.downThreshold) { + controllerLiveness.wasDown = true; + } + setStatus("api: " + (err && err.message ? err.message : err), "err"); +} + +function notePollSuccess() { + // Recovery after a confirmed down period → controller almost certainly + // restarted. Show the reload prompt once per recovery (bannerShown + // resets on dismiss + on next down→up cycle). + if (controllerLiveness.wasDown && !controllerLiveness.bannerShown) { + showControllerRestartBanner(Date.now() - controllerLiveness.downAt); + controllerLiveness.bannerShown = true; + } + if (controllerLiveness.consecutiveFailures > 0 || controllerLiveness.wasDown) { + controllerLiveness.consecutiveFailures = 0; + controllerLiveness.wasDown = false; + } +} + +// showControllerRestartBanner — sticky banner top-of-page urging a reload +// after a detected controller restart. Auto-reloads after 30s if the +// operator doesn't dismiss; that grace gives them time to look at any +// in-progress UI before the JS state resets. +function showControllerRestartBanner(downForMs) { + let bar = document.getElementById("controller-restart-bar"); + if (bar) return; // already showing + bar = document.createElement("div"); + bar.id = "controller-restart-bar"; + bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:9999;background:#0b1220;border-bottom:1px solid rgba(96,165,250,.4);color:#e2e8f0;padding:9px 16px;font-size:13px;display:flex;align-items:center;gap:14px;box-shadow:0 4px 12px rgba(0,0,0,.4)"; + const downSec = Math.max(1, Math.round(downForMs / 1000)); + bar.innerHTML = ` + 🔄 +
+ Controller reconnected after ${downSec}s offline. + Reload the page so this tab's state matches the new server. (Auto-reloads in 30s.) +
+ + + `; + document.body.appendChild(bar); + document.getElementById("ctrl-restart-reload").addEventListener("click", () => location.reload()); + document.getElementById("ctrl-restart-dismiss").addEventListener("click", () => { + if (bar.parentNode) bar.parentNode.removeChild(bar); + if (window._ctrlRestartTimer) { clearInterval(window._ctrlRestartTimer); window._ctrlRestartTimer = null; } + // Don't clear bannerShown — operator dismissed; respect that until + // the next down→up transition. + }); + let countdown = 30; + if (window._ctrlRestartTimer) clearInterval(window._ctrlRestartTimer); + window._ctrlRestartTimer = setInterval(() => { + countdown--; + const el = document.getElementById("ctrl-restart-counter"); + if (el) el.textContent = String(countdown); + if (countdown <= 0) { + clearInterval(window._ctrlRestartTimer); + window._ctrlRestartTimer = null; + location.reload(); + } + }, 1000); +} + +async function pollAgents() { + if (document.hidden) return; // hidden tab: skip — visibilitychange refetches on return + try { + const { agents } = await fetchJSON("/api/agents"); + applyAgents(agents); + } catch (e) { notePollFailure(e); } +} +// applyAgents — shared sink for the /api/agents poll AND the SSE connect +// snapshot (same DTO shape on both paths). +function applyAgents(agents) { + allAgents = agents || []; + agentsLoaded = true; + renderAgents(); + syncAgentPicker(); + refreshHeroStats(); + notePollSuccess(); +} +async function pollModules() { + if (document.hidden) return; // hidden tab: skip — visibilitychange refetches on return + try { + const { modules } = await fetchJSON("/api/modules"); + allModules = modules || []; + syncModulePicker(); + } catch {} +} +// Previous poll snapshot — used to detect status transitions (stopped → +// running, etc.) across poll ticks so we can arm client-side overlays +// even when the operator didn't click the button themselves. +const _instancePrevStatus = new Map(); + +let _lastInstancesJSON = ""; +let _clusterTick = 0; +async function pollInstances() { + // Hidden tab: skip entirely — SSE keeps state warm and the next visible + // poll resyncs. Saves a render pass every 2s in background tabs. + if (document.hidden) return; + try { + const { instances } = await fetchJSON("/api/instances"); + await applyInstances(instances); + } catch (e) { notePollFailure(e); } +} +// applyInstances — shared sink for the /api/instances poll AND the SSE +// connect snapshot; everything below is the historical pollInstances body. +async function applyInstances(instances) { + try { + // PERF: most 2-second ticks change NOTHING — but the old path still + // rebuilt toolbar HTML, diffed every card, and re-ran hero stats each + // tick, costing a style/layout pass that competed with hover + console + // rendering. If the payload is byte-identical to the previous tick, + // skip all render work (overlay timers/SSE handle their own repaints). + const rawJSON = JSON.stringify(instances || []); + if (rawJSON === _lastInstancesJSON) { + scanReadyForUnconfirmedRunning(); + notePollSuccess(); + // Cluster membership can change while the instances payload is static + // (a join/leave doesn't alter /api/instances). Re-check on a slow cadence + // and repaint cards only if the cluster map actually changed. + if ((_clusterTick = (_clusterTick + 1) % 5) === 0) { + if (await refreshClusterMap() && typeof renderServerCards === "function") renderServerCards(); + } + return; + } + _lastInstancesJSON = rawJSON; + // Keep the cluster→color map fresh for the card badges (cheap; cached). + await refreshClusterMap(); + // Filter out ids the operator just deleted client-side. The backend + // delete is async (30-90s when sidecars need cleanup); without this + // the card would visibly pop back into the dashboard for the next + // 2-second poll cycle, looking like the delete failed. + allInstances = (instances || []).filter(i => !instanceLocallyDeleted.has(i.instance_id)); + instancesLoaded = true; + // Reflect server-driven transient states for operators who didn't + // trigger the Create themselves (refreshed mid-install, or watching + // someone else's instance come up). Agent emits `detail=installing` + // during first-time SteamCMD download via our sidecar; mark when we + // see it so the card pulses blue. We deliberately DO NOT clear on + // status transitions away from stopped — for image-managed modules + // like ark-sa, the container's entrypoint runs its own SteamCMD and + // the agent reports status=running the moment docker-start returns, + // long before the game is actually up. Clearing on that transition + // made the pill lie. Installing clears on: the markInstalling SSE + // listener matching "update complete" / ready-regex / crash status, + // or the 30-min safety timer. + for (const i of allInstances) { + if (i.detail === "installing" && !instanceInstalling.has(i.instance_id)) { + markInstalling(i.instance_id); + } + // Auto-clear stuck "installing" overlay: agent reports detail=running + // (or anything non-"installing") once the steamcmd sidecar is done. + // If the markInstalling SSE listener missed the "update complete" log + // line — happens on a refresh after install finished, or when the + // sidecar exited cleanly with no terminal log line — without this + // check the overlay would persist for the full 30-min safety timer. + else if (instanceInstalling.has(i.instance_id) && i.detail !== "installing" && i.status !== "creating") { + clearInstalling(i.instance_id, false); + } + // Auto-start path: the agent fires docker-start and emits RUNNING + // without the client ever calling markStarting. Without a transient + // overlay, the pill flips straight to "RUNNING" even though the + // game itself may still be minutes away from being joinable (ARK: + // "Server has successfully started" comes ~2 min before the actual + // "advertising for join" line). Detect the stopped/starting → + // running transition and arm a starting overlay so the pill stays + // yellow until the module's ready-regex fires for real. If the + // overlay is already armed (user clicked Start, or we're mid- + // install), respect that. + // + // IMPORTANT — only fire when we've actually OBSERVED a prior + // non-running state. If `prev` is undefined (first time we've seen + // this instance this session) we have no way to know whether the + // server just started or has been running for days; treat it as + // "already settled" and don't arm the overlay. That first-poll + // over-arming was the cause of every card on a fresh page-load + // showing STARTING forever. + const prev = _instancePrevStatus.get(i.instance_id); + if (i.status === "running" + && prev !== undefined && prev !== "running" + && !instanceStarting.has(i.instance_id) + && !instanceInstalling.has(i.instance_id) + && !instanceReady.has(i.instance_id)) { + markStarting(i.instance_id); + } + _instancePrevStatus.set(i.instance_id, i.status); + } + // Evict entries for instances that no longer exist so the map doesn't + // grow unbounded over a long-running dashboard session. + const currentIds = new Set(allInstances.map(i => i.instance_id)); + for (const id of _instancePrevStatus.keys()) { + if (!currentIds.has(id)) _instancePrevStatus.delete(id); + } + // Self-heal stale "deleting" / "installing" / "starting" overlays for + // ids that have completely disappeared from the instance list — the + // backend already removed the row, so the card will unmount on the + // next render anyway. Without this, a slow background watcher could + // hold the Set populated AFTER the card's gone, which means the next + // instance with the same id (rare but possible) would inherit the + // ghost state. + for (const id of instanceDeleting) if (!currentIds.has(id)) instanceDeleting.delete(id); + for (const id of instanceInstalling.keys()) if (!currentIds.has(id)) clearInstalling(id, false); + for (const id of instanceStarting.keys()) if (!currentIds.has(id)) clearStarting(id); + for (const id of instanceStopping.keys()) if (!currentIds.has(id)) clearStopping(id); + renderServerCards(); + syncInstancePickers(); + refreshHeroStats(); + // Background ready-scan for log-only modules whose status is running + // but whose ready-regex hasn't fired this session. Without this, a + // page refresh on an already-running ARK leaves the card stuck on + // "starting" until the operator opens the console (which does the + // same scan). Mirrors ensureConsoleStream's history scan. + scanReadyForUnconfirmedRunning(); + notePollSuccess(); + } catch (e) { notePollFailure(e); } +} + +// scanReadyForUnconfirmedRunning — on every poll, kick off a one-shot +// log-history fetch for any log-only running instance whose ready signal +// hasn't been confirmed yet this session. The fetch is the same one the +// console pane does on open; we just run it eagerly from the dashboard. +// _readyScanInflight prevents the same instance being scanned twice +// concurrently across overlapping polls (2s tick + ~200ms fetch latency). +// +// CRITICAL: filter matches by log timestamp > instance.updated_at. The +// docker log buffer carries lines across container restarts, so a freshly +// started ARK has the PREVIOUS run's "Server has completed startup" line +// sitting in /logs/recent. Without this filter the scanner instantly marks +// ready and the pill flips to RUNNING the moment the page polls — exactly +// the bug READY_LOG_ONLY exists to prevent. updated_at is only bumped on +// status changes (NULL-distinct check in db.UpdateInstanceStatus), so it +// doubles as a reliable "current-run started at" boundary. +const _readyScanInflight = new Set(); +const _readyScanLastAt = new Map(); +async function scanReadyForUnconfirmedRunning() { + for (const i of allInstances) { + if (i.status !== "running") continue; + if (!READY_LOG_ONLY.has(i.module_id)) continue; + if (instanceConfirmedReady.has(i.instance_id)) continue; + if (_readyScanInflight.has(i.instance_id)) continue; + // Throttle: this used to refetch 400 log lines EVERY 2s poll for the + // whole boot of an unconfirmed server (minutes of redundant fetches + // through the proxy chain). 10s granularity is plenty — the live + // per-instance SSE catches the ready line between scans anyway. + const lastAt = _readyScanLastAt.get(i.instance_id) || 0; + if (Date.now() - lastAt < 10000) continue; + _readyScanLastAt.set(i.instance_id, Date.now()); + const pat = readyPatternFor(i.module_id); + if (!pat) continue; + // Boundary for filtering stale ready lines from previous container + // runs. 30s grace for clock skew between docker host + log shipper. + let sinceMs = 0; + if (i.updated_at) { + const t = Date.parse(i.updated_at); + if (!isNaN(t)) sinceMs = t - 30 * 1000; + } + _readyScanInflight.add(i.instance_id); + (async (id, pattern, since) => { + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/logs/recent?limit=400`); + for (const ev of (r.events || [])) { + if (!ev.log || !ev.log.line) continue; + if (since > 0 && ev.log.at) { + const lineAt = Date.parse(ev.log.at); + if (!isNaN(lineAt) && lineAt < since) continue; // stale, previous run + } + if (pattern.test(ev.log.line)) { + instanceConfirmedReady.add(id); + paintStartingUI(); + renderServerCards(); + return; + } + } + } catch { /* silent — next poll will retry */ } + finally { _readyScanInflight.delete(id); } + })(i.instance_id, pat, sinceMs); + } +} + +// Refresh the 4 hero stat cards + sidebar counts. Recomputed cheaply on +// every data poll so the dashboard stays in sync without an extra fetch. +function refreshHeroStats() { + const total = allInstances.length; + const running = allInstances.filter(i => i.status === "running").length; + const agentsConn = allAgents.filter(a => a.connected).length; + const agentsTotal = allAgents.length; + // Players online: sum across all latestAppState entries — but only for + // instances that are actually running, so a stale sample from a server + // that just stopped doesn't keep contributing to the lobby count. + const aliveIds = new Set(allInstances.filter(i => i.status === "running").map(i => i.instance_id)); + let players = 0, playersCap = 0; + for (const id in latestAppState) { + if (!aliveIds.has(id)) continue; + const s = latestAppState[id]; + if (typeof s.players_online === "number") players += s.players_online; + if (typeof s.players_max === "number") playersCap += s.players_max; + } + // CPU: sum of latest stats entries for running servers. + let cpuSum = 0, cpuCount = 0; + for (const id in latestStats) { + if (!aliveIds.has(id)) continue; + const s = latestStats[id]; + if (typeof s.cpu === "number") { cpuSum += s.cpu; cpuCount++; } + } + const setText = (id, v) => { const el = $(id); if (el) el.textContent = v; }; + // Count-up: when a stat number changes, briefly animate to the new + // value rather than snapping. Makes the dashboard feel "alive" without + // being noisy. Stays a no-op for first paint (no `data-prev` set). + const animateNumber = (selector, newVal) => { + const el = $(selector); + if (!el) return; + const cur = parseFloat(el.dataset.prev || ""); + const target = (typeof newVal === "number") ? newVal : parseFloat(newVal); + if (!isNaN(target)) el.dataset.prev = String(target); + if (isNaN(cur) || isNaN(target) || cur === target) { + el.textContent = (typeof newVal === "number") ? String(newVal) : newVal; + return; + } + const start = performance.now(); + const dur = 450; + const step = (now) => { + const t = Math.min(1, (now - start) / dur); + // ease-out + const ease = 1 - Math.pow(1 - t, 3); + const v = cur + (target - cur) * ease; + el.textContent = Number.isInteger(target) ? Math.round(v).toString() : v.toFixed(1); + if (t < 1) requestAnimationFrame(step); + else el.textContent = (typeof newVal === "number") ? String(newVal) : newVal; + }; + requestAnimationFrame(step); + }; + animateNumber("#stat-running", running); + setText("#stat-running-hint", `of ${total}`); + animateNumber("#stat-players", players); + setText("#stat-players-hint", playersCap > 0 ? `/ ${playersCap} cap` : ""); + animateNumber("#stat-agents", agentsConn); + setText("#stat-agents-hint", agentsTotal === agentsConn ? `online` : `of ${agentsTotal}`); + // Fleet-wide CPU: sum of docker-style percents across hosts has no single + // "% of host" meaning, so the hero shows total cores in use instead. + setText("#stat-cpu", cpuCount ? `${(cpuSum / 100).toFixed(1)}` : "—"); + setText("#stat-cpu-hint", cpuCount ? `cores · ${cpuCount} live` : "idle"); + setText("#side-server-count", total); + setText("#side-agent-count", agentsConn); + setText("#page-sub", total > 0 ? `— ${running} / ${total} running` : ""); + // Topbar live health pill — running/total + agents online. + setText("#topbar-running", `${running}/${total}`); + setText("#topbar-agents", `${agentsConn}/${agentsTotal}`); + const agentSeg = $("#topbar-agents-seg"); + if (agentSeg) agentSeg.classList.toggle("warn", agentsTotal > agentsConn); + + // Cluster-health sparklines: push this tick's totals into a rolling + // ring buffer and redraw the trend lines under the Players + CPU cards. + // Seeded on load from /api/metrics/history (controller-side sampler) so + // the trend survives a page reload, then extended live each poll tick. + _pushHeroHistory(players, cpuCount ? cpuSum : 0); + _drawSpark("#spark-players", _heroHistory.players, "rgba(95,178,255"); + _drawSpark("#spark-cpu", _heroHistory.cpu, "rgba(255,177,59"); +} + +// Rolling history for the overview sparklines. Capped at SPARK_MAX points. +// Seeded from the controller's /api/metrics/history on boot (last hour at +// 30s resolution) then extended live; keyed series so adding another later +// is trivial. +const SPARK_MAX = 120; +const _heroHistory = { players: [], cpu: [] }; +function _pushHeroHistory(players, cpu) { + _heroHistory.players.push(players); + _heroHistory.cpu.push(cpu); + if (_heroHistory.players.length > SPARK_MAX) _heroHistory.players.shift(); + if (_heroHistory.cpu.length > SPARK_MAX) _heroHistory.cpu.shift(); +} +// Seed the sparkline buffers from the controller's rolling metrics history +// so trends are visible immediately on load instead of building from zero. +// Best-effort: a missing endpoint (old controller) or empty history just +// leaves the buffers to fill live. Called once on boot. +let _sparkSeeded = false; +async function seedSparkHistory() { + if (_sparkSeeded) return; + _sparkSeeded = true; + try { + const { samples } = await fetchJSON("/api/metrics/history"); + if (!Array.isArray(samples) || !samples.length) return; + // Keep the most recent SPARK_MAX points; prepend before live ticks. + const recent = samples.slice(-SPARK_MAX); + _heroHistory.players = recent.map(s => s.players | 0).concat(_heroHistory.players).slice(-SPARK_MAX); + _heroHistory.cpu = recent.map(s => s.cpu | 0).concat(_heroHistory.cpu).slice(-SPARK_MAX); + _drawSpark("#spark-players", _heroHistory.players, "rgba(95,178,255"); + _drawSpark("#spark-cpu", _heroHistory.cpu, "rgba(255,177,59"); + } catch { /* old controller / no history — fill live */ } +} +// Render a series as an SVG area+line sparkline into the given . The +// viewBox is 100x28; we map points across the width and scale to the +// series' own max (with a small headroom) so the trend is always visible +// regardless of absolute magnitude. rgbPrefix is like "rgba(95,178,255". +function _drawSpark(sel, series, rgbPrefix) { + const svg = $(sel); if (!svg) return; + const lineEl = svg.querySelector(".spark-line"); + const areaEl = svg.querySelector(".spark-area"); + if (!lineEl || !areaEl) return; + // Need at least 2 points to draw a trend; before that, stay blank. + if (series.length < 2) { lineEl.setAttribute("d", ""); areaEl.setAttribute("d", ""); return; } + const W = 100, H = 28, pad = 3; + const max = Math.max(1, ...series) * 1.15; // headroom so the peak isn't flush to the top + const n = series.length; + const x = (i) => (n === 1 ? 0 : (i / (n - 1)) * W); + const y = (v) => H - pad - (v / max) * (H - pad * 2); + let d = ""; + series.forEach((v, i) => { d += (i === 0 ? "M" : "L") + x(i).toFixed(1) + " " + y(v).toFixed(1) + " "; }); + lineEl.setAttribute("d", d.trim()); + // Area path: line, then drop to the baseline and close. + areaEl.setAttribute("d", `${d.trim()} L${W} ${H} L0 ${H} Z`); + // Tint via inline style so each card keeps its accent color. + lineEl.style.stroke = rgbPrefix + ",.9)"; + areaEl.style.fill = rgbPrefix + ",.14)"; +} +async function pollSchedules() { + try { + const { schedules } = await fetchJSON("/api/schedules"); + renderSchedules(schedules || []); + } catch {} +} + +// ================ agent panes + nested server cards (diff-render, no flash) ================ +// +// Layout inspired by AMP's Instance Manager: each agent is a collapsible +// panel; its game server cards nest inside. We diff-render at both levels +// (per-agent and per-card) so polling updates never flash the UI. + +// ---- FRESH SERVERS LAYOUT — flat grid + filter strip + agents drawer ---- +// Replaces the older AMP-style "one collapsible pane per agent". All servers +// flow into a single dense grid; agent identity rides on each card as a +// chip; agent CRUD lives in a collapsible drawer at the bottom; status + +// agent + sort + search filters live in a toolbar above the grid. +const serverFilter = { status: "all", agent: "all", module: "all", sort: "name", search: "", group: (localStorage.getItem("rcc.group.v1") || "game") }; +let _flatShellRendered = false; + +function renderAgentsAndServers() { + const wrap = $("#agents-wrap"); + const total = allInstances.length; + const running = allInstances.filter(i => i.status === "running").length; + const connected = allAgents.filter(a => a.connected).length; + // Legacy element guard — moved into sidebar/hero on shell restructure. + const legacyCount = $("#agent-count"); + if (legacyCount) legacyCount.textContent = `${allAgents.length} agent${allAgents.length === 1 ? "" : "s"} (${connected} online) · ${total} server${total === 1 ? "" : "s"} · ${running} running`; + + if (!allAgents.length) { + // First page-load race: the render can fire before the first + // /api/agents response lands. Show a neutral "loading" state then, + // not the "no agents" CTA — otherwise every hard refresh flashes + // the empty state for a beat before the real data arrives. + if (!agentsLoaded) { + wrap.innerHTML = loadingState("Loading agents…"); + } else { + wrap.innerHTML = `
${EMPTY_ICONS.server}
No agents yetClick "Pair new agent" to add one
`; + } + return; + } + + // Build the shell once — toolbar, batch-actions row, grid, agents drawer. + if (!_flatShellRendered) { + wrap.innerHTML = ` +
+ +
+
+ ⚙️ Manage agents +
+
+ `; + _flatShellRendered = true; + // Toolbar click delegation: status pills, agent chips, module chips + $("#svr-toolbar").addEventListener("click", e => { + const pill = e.target.closest("[data-svr-pill]"); + if (!pill) return; + const [k, v] = pill.dataset.svrPill.split(":"); + if (k === "status") serverFilter.status = v; + else if (k === "agent") serverFilter.agent = v; + else if (k === "module") serverFilter.module = v; + _refreshToolbarActive(); + _applyServerFilter(); + _renderBatchBar(); + }); + // Batch action click delegation + $("#svr-batchbar").addEventListener("click", e => { + const btn = e.target.closest("[data-batch]"); + if (!btn) return; + runBatchAction(btn.dataset.batch, btn); + }); + } + + _renderToolbar(); + _renderGrid(); + _renderBatchBar(); + _renderAgentsDrawer(); +} + +function _renderToolbar() { + const tb = $("#svr-toolbar"); if (!tb) return; + const total = allInstances.length; + const running = allInstances.filter(i => i.status === "running").length; + const stopped = allInstances.filter(i => i.status === "stopped").length; + const issues = allInstances.filter(i => /^(crashed|error|delete_failed|install_failed)/i.test(i.status || "")).length; + // Per-agent server counts so the chip shows "agent (12)" instead of guessing. + const byAgent = {}; + for (const a of allAgents) byAgent[a.agent_id] = 0; + for (const i of allInstances) if (byAgent[i.agent_id] !== undefined) byAgent[i.agent_id]++; + + const statusPills = [ + { v: "all", label: "All", count: total }, + { v: "running", label: "Running", count: running, status: "running" }, + { v: "stopped", label: "Stopped", count: stopped, status: "stopped" }, + ]; + if (issues > 0) statusPills.push({ v: "issues", label: "Issues", count: issues, status: "issues" }); + + const html = ` +
+ Show + ${statusPills.map(p => ` + + `).join("")} +
+ ${(() => { + // Per-game chips. Only render the group when there's more than one + // distinct module in play — single-game panels don't need a "Game" + // filter, the agent + status chips already disambiguate. Counts + // refresh on every toolbar render. + const byModule = {}; + for (const i of allInstances) byModule[i.module_id] = (byModule[i.module_id] || 0) + 1; + const modIDs = Object.keys(byModule); + if (modIDs.length < 2) return ""; + modIDs.sort((a, b) => (byModule[b] - byModule[a]) || a.localeCompare(b)); + return ` +
+
+ Game + + ${modIDs.map(m => { + const mod = (allModules.find(x => x.id === m) || {}); + const lab = _moduleAbbr(m) || mod.name || m; + return ``; + }).join("")} +
+ `; + })()} +
+
+
+ Group + + + +
+ + + +
+ `; + // Dirty-check: most poll ticks change nothing here. Skipping the innerHTML + // assignment when the markup is byte-identical avoids tearing down + re- + // attaching listeners and saves a style/layout pass. The old DOM (and its + // listeners) stays live, so no re-wiring is needed on the skip path. + if (html === _renderToolbar._lastHTML) return; + _renderToolbar._lastHTML = html; + tb.innerHTML = html; + const sortSel = $("#svr-sort"); + if (sortSel) sortSel.addEventListener("change", e => { + serverFilter.sort = e.target.value; + _renderGrid(); + }); + // Group-by toggle: game / host / flat. Persisted per-device. Re-renders + // the grid (which rebuilds sections from scratch when the mode changes). + tb.querySelectorAll("[data-svr-group]").forEach(btn => { + btn.addEventListener("click", () => { + const mode = btn.dataset.svrGroup; + if (serverFilter.group === mode) return; + serverFilter.group = mode; + try { localStorage.setItem("rcc.group.v1", mode); } catch {} + tb.querySelectorAll("[data-svr-group]").forEach(b => b.classList.toggle("active", b.dataset.svrGroup === mode)); + _renderGrid(); + }); + }); +} + +function _refreshToolbarActive() { + document.querySelectorAll("#svr-toolbar [data-svr-pill]").forEach(p => { + const [k, v] = p.dataset.svrPill.split(":"); + const isActive = + (k === "status" && serverFilter.status === v) || + (k === "agent" && serverFilter.agent === v) || + (k === "module" && serverFilter.module === v); + p.classList.toggle("active", isActive); + }); +} + +function _serverSortCmp(a, b) { + if (serverFilter.sort === "agent") { + const ax = (a.agent_id || ""); const bx = (b.agent_id || ""); + if (ax !== bx) return ax.localeCompare(bx); + return a.instance_id.localeCompare(b.instance_id); + } + if (serverFilter.sort === "status") { + const order = { running: 0, starting: 1, stopping: 2, ready: 3, stopped: 4, crashed: 5, deleting: 6 }; + const ai = order[a.status] != null ? order[a.status] : 9; + const bi = order[b.status] != null ? order[b.status] : 9; + if (ai !== bi) return ai - bi; + return a.instance_id.localeCompare(b.instance_id); + } + if (serverFilter.sort === "recent") { + const at = a.updated_at ? Date.parse(a.updated_at) : 0; + const bt = b.updated_at ? Date.parse(b.updated_at) : 0; + return bt - at; + } + return a.instance_id.localeCompare(b.instance_id); +} + +function _renderGrid() { + const grid = $("#svr-grid"); if (!grid) return; + // Always render every instance; filter visibility is layered on top. + // Orphan instances (whose agent_id no longer exists) still appear here — + // their agent chip just shows the unknown id with the offline marker so + // the operator can find + delete them. + const list = allInstances.slice().sort(_serverSortCmp); + + // Harvest every existing card element (from flat mode OR previous + // per-agent sections) so a group-mode toggle reuses the same DOM nodes — + // animations don't restart, click state doesn't reset, etc. + const existing = new Map(); + grid.querySelectorAll(":scope [data-card-id]").forEach(el => { + if (el.dataset.cardId) existing.set(el.dataset.cardId, el); + }); + + // Grouping is operator-selectable (Group toggle in the toolbar): + // game → bucket by module_id, header = game name + icon + // host → bucket by agent_id, header = agent name + online dot + // flat → no grouping, one responsive grid + // "flat" also covers the trivial single-bucket case implicitly. + const groupMode = serverFilter.group || "game"; + const grouped = groupMode !== "flat"; + grid.classList.toggle("grouped", grouped); + grid.dataset.groupMode = groupMode; + + if (!grouped) { + // Flat mode: clear non-card children, then diff-render into the grid. + for (const el of Array.from(grid.children)) { + if (!(el.dataset && el.dataset.cardId)) el.remove(); + } + const seen = new Set(); + list.forEach((i, idx) => { + seen.add(i.instance_id); + let el = existing.get(i.instance_id); + if (!el) { el = buildCard(i); grid.appendChild(el); } + else if (el.parentElement !== grid) grid.appendChild(el); + el.style.setProperty("--i", Math.min(idx, 24)); + updateCard(el, i); + }); + list.forEach((i, idx) => { + const el = existing.get(i.instance_id) || grid.querySelector(`[data-card-id="${CSS.escape(i.instance_id)}"]`); + if (!el) return; + const ref = grid.children[idx]; + if (ref && ref !== el) grid.insertBefore(el, ref); + else if (!ref) grid.appendChild(el); + }); + for (const [id, el] of existing) if (!seen.has(id)) el.remove(); + } else { + // Grouped mode (game | host): diff render. Reuse existing
+ // nodes keyed by bucket id so a refresh tick only updates header text + + // the cards inside, never destroys the layout (which would flash/jitter + // the page). Strip any flat-mode leftovers (cards that ended up directly + // on #svr-grid before the mode switch) — re-homed below. When the mode + // CHANGES (game<->host), the old sections key on a different namespace + // and won't be reused, so we also drop stale sections at the end. + for (const el of Array.from(grid.children)) { + if (el.classList.contains("svr-agent-section")) continue; + if (el.dataset && el.dataset.cardId) continue; + el.remove(); + } + + // bucketKeyFor + bucketMetaFor abstract over the two grouping modes so + // the diff-render loop below is identical for game and host. + const byHost = groupMode === "host"; + const bucketKeyFor = (i) => byHost ? (i.agent_id || "(unknown)") : (i.module_id || "(unknown)"); + + // Bucket the globally-sorted list so each section's internal order + // matches the chosen sort within that bucket's slice. + const buckets = new Map(); + for (const i of list) { + const k = bucketKeyFor(i); + if (!buckets.has(k)) buckets.set(k, []); + buckets.get(k).push(i); + } + + // Section order: + // host → known agents first (by label), then orphan buckets + // game → by running-count desc, then total desc, then label + let orderedKeys = []; + if (byHost) { + const knownAgents = allAgents.slice().sort((a, b) => (a.label || a.agent_id).localeCompare(b.label || b.agent_id)); + const knownIds = new Set(); + for (const a of knownAgents) { + knownIds.add(a.agent_id); + if (buckets.has(a.agent_id)) orderedKeys.push(a.agent_id); + } + for (const k of buckets.keys()) if (!knownIds.has(k)) orderedKeys.push(k); + } else { + orderedKeys = Array.from(buckets.keys()).sort((a, b) => { + const ba = buckets.get(a), bb = buckets.get(b); + const ra = ba.filter(x => x.status === "running").length; + const rb = bb.filter(x => x.status === "running").length; + if (rb !== ra) return rb - ra; + if (bb.length !== ba.length) return bb.length - ba.length; + const la = (allModules.find(m => m.id === a) || {}).name || a; + const lb = (allModules.find(m => m.id === b) || {}).name || b; + return la.localeCompare(lb); + }); + } + + // Index existing sections so we can reuse them in place. The data-group + // attr is namespaced by mode so a game-section is never mistaken for a + // host-section of the same id. + const existingSections = new Map(); + grid.querySelectorAll(":scope > .svr-agent-section").forEach(sec => { + if (sec.dataset.groupKey) existingSections.set(sec.dataset.groupKey, sec); + }); + + const seen = new Set(); + const seenKeys = new Set(); + let cardIdx = 0; + orderedKeys.forEach((key, secIdx) => { + const nsKey = groupMode + ":" + key; + seenKeys.add(nsKey); + const bucket = buckets.get(key) || []; + const total = bucket.length; + const running = bucket.filter(x => x.status === "running").length; + + // Resolve header presentation per mode. + let lab, icon = "", dotOn = false, dotOff = false, badgeText = "", badgeCls = ""; + if (byHost) { + const agent = allAgents.find(a => a.agent_id === key); + const isKnown = !!agent; + const offline = !isKnown || !agent.connected; + lab = agent ? (agent.label || agent.agent_id) : key; + dotOn = !offline; dotOff = offline; + if (!isKnown) { badgeText = "orphaned"; badgeCls = "orphan"; } + else if (offline) { badgeText = "offline"; badgeCls = "offline"; } + } else { + const mod = (allModules.find(m => m.id === key) || {}); + lab = mod.name || key; + const app = appearanceFor(key); + icon = (app && app.emoji) ? app.emoji : "🎮"; + } + + let section = existingSections.get(nsKey); + if (!section) { + section = document.createElement("section"); + section.className = "svr-agent-section"; + section.dataset.groupKey = nsKey; + section.innerHTML = ` +
+ + + + + +
+
+ `; + grid.appendChild(section); + } + // Update header in place (text-only changes don't reflow the cards). + const iconEl = section.querySelector("[data-grp-icon]"); + const dot = section.querySelector("[data-grp-dot]"); + const labEl = section.querySelector("[data-grp-lab]"); + const badge = section.querySelector("[data-grp-badge]"); + const meta = section.querySelector("[data-grp-meta]"); + if (icon) { + iconEl.style.display = ""; + if (iconEl.textContent !== icon) iconEl.textContent = icon; + dot.style.display = "none"; + } else { + iconEl.style.display = "none"; + dot.style.display = ""; + dot.classList.toggle("on", dotOn); + dot.classList.toggle("off", dotOff); + } + if (labEl.textContent !== lab) labEl.textContent = lab; + if (badgeText) { + badge.style.display = ""; + badge.className = "svr-agent-badge " + badgeCls; + if (badge.textContent !== badgeText) badge.textContent = badgeText; + } else { + badge.style.display = "none"; + badge.textContent = ""; + } + const metaText = `${total} server${total === 1 ? "" : "s"} · ${running} running`; + if (meta.textContent !== metaText) meta.textContent = metaText; + + // Reorder section to match desired position (cheap if already correct). + const refSec = grid.children[secIdx]; + if (refSec && refSec !== section) grid.insertBefore(section, refSec); + else if (!refSec) grid.appendChild(section); + + // Diff the inner cards. + const inner = section.querySelector(".svr-group-grid"); + bucket.forEach((i, idx) => { + seen.add(i.instance_id); + let el = existing.get(i.instance_id); + if (!el) el = buildCard(i); + if (el.parentElement !== inner) inner.appendChild(el); + const ref = inner.children[idx]; + if (ref && ref !== el) inner.insertBefore(el, ref); + else if (!ref) inner.appendChild(el); + el.style.setProperty("--i", Math.min(cardIdx++, 24)); + updateCard(el, i); + }); + // Trim any cards in this section that no longer belong. + Array.from(inner.children).forEach(el => { + if (el.dataset && el.dataset.cardId && !bucket.find(x => x.instance_id === el.dataset.cardId)) { + el.remove(); + } + }); + }); + // Remove sections whose bucket disappeared (incl. all sections from the + // previous group mode, which carry a different namespace prefix). + existingSections.forEach((sec, nsKey) => { + if (!seenKeys.has(nsKey)) sec.remove(); + }); + for (const [id, el] of existing) if (!seen.has(id)) el.remove(); + } + _applyServerFilter(); +} + +// In-app confirm/prompt for bulk operations. Returns: +// { confirmed: true, value: } on OK +// { confirmed: false, value: null } on Cancel/Esc +// +// Replaces window.confirm()/prompt() for batch actions because those +// browser-native dialogs are blocky, breaking, sometimes blocked by +// extensions, and don't match the rest of the panel. opts shape: +// title — modal heading (string) +// message — primary copy (string or HTML; use safeHTML to +// pre-escape if interpolating user content) +// list — optional [{label}] for the "affected servers" UL +// listHeader — optional header above the list +// grace — optional informational hint ("Sequential — 10s …") +// input — optional { label, placeholder, default } to show +// a single-line freeform input. value lands in +// resolve's `value` field. +// okLabel — Confirm button label (default "Confirm") +// okStyle — "primary" (default) or "danger" tinting +function bulkConfirm(opts) { + return new Promise((resolve) => { + const modal = $("#bulk-confirm-modal"); + const titleEl = $("#bcm-title"); + const msgEl = $("#bcm-message"); + const inputWrap = $("#bcm-input-wrap"); + const inputLab = $("#bcm-input-label"); + const inputEl = $("#bcm-input"); + const listWrap = $("#bcm-list-wrap"); + const listHead = $("#bcm-list-head"); + const listEl = $("#bcm-list"); + const graceEl = $("#bcm-grace"); + const okBtn = $("#bcm-ok"); + const cancelBtn = $("#bcm-cancel"); + + titleEl.textContent = opts.title || "Confirm"; + if (opts.html) msgEl.innerHTML = opts.message || ""; + else msgEl.textContent = opts.message || ""; + + if (opts.input) { + inputWrap.style.display = ""; + inputLab.textContent = opts.input.label || ""; + inputEl.placeholder = opts.input.placeholder || ""; + inputEl.value = opts.input.default || ""; + } else { + inputWrap.style.display = "none"; + inputEl.value = ""; + } + + if (opts.list && opts.list.length) { + listWrap.style.display = ""; + listHead.textContent = opts.listHeader || `Affects ${opts.list.length} server${opts.list.length === 1 ? "" : "s"}:`; + listEl.innerHTML = opts.list.slice(0, 12).map(s => `
  • ${esc(s.label || s)}
  • `).join("") + + (opts.list.length > 12 ? `
  • … and ${opts.list.length - 12} more
  • ` : ""); + } else { + listWrap.style.display = "none"; + listEl.innerHTML = ""; + } + + if (opts.grace) { + graceEl.style.display = ""; + graceEl.textContent = opts.grace; + } else { + graceEl.style.display = "none"; + } + + okBtn.textContent = opts.okLabel || "Confirm"; + okBtn.classList.toggle("danger", opts.okStyle === "danger"); + + let cleaned = false; + function cleanup(result) { + if (cleaned) return; + cleaned = true; + okBtn.removeEventListener("click", onOk); + cancelBtn.removeEventListener("click", onCancel); + modal.removeEventListener("click", onBgClick); + inputEl.removeEventListener("keydown", onKey); + document.removeEventListener("keydown", onEsc); + hideModal("#bulk-confirm-modal"); + resolve(result); + } + function onOk() { + const value = opts.input ? inputEl.value : null; + if (opts.input && opts.input.required !== false && (!value || !value.trim())) { + inputEl.focus(); + inputEl.style.borderColor = "rgba(248,81,73,.6)"; + return; + } + cleanup({ confirmed: true, value }); + } + function onCancel() { cleanup({ confirmed: false, value: null }); } + function onBgClick(e) { + if (e.target === modal) cleanup({ confirmed: false, value: null }); + const closeBtn = e.target.closest("[data-close]"); + if (closeBtn && modal.contains(closeBtn)) cleanup({ confirmed: false, value: null }); + } + function onKey(e) { + if (e.key === "Enter") { e.preventDefault(); onOk(); } + } + function onEsc(e) { if (e.key === "Escape") onCancel(); } + + okBtn.addEventListener("click", onOk); + cancelBtn.addEventListener("click", onCancel); + modal.addEventListener("click", onBgClick); + inputEl.addEventListener("keydown", onKey); + document.addEventListener("keydown", onEsc); + + showModal("#bulk-confirm-modal"); + setTimeout(() => { + if (opts.input) inputEl.focus(); + else okBtn.focus(); + }, 50); + }); +} + +// Game-specific batch commands. Keyed by module-id; each entry is an +// optional list of "extra" batch actions surfaced when the dashboard is +// filtered to that one module. `rcon` is the literal command string; +// `confirmIf` is an optional predicate against the RCON output that +// decides whether the per-server confirmation should glow green ("got +// the response we expected") instead of just neutral ack. +// +// Keep the list focused — one or two signature commands per game. The +// modal's full RCON console is the right place for one-off ops. +// `confirmIf` is intentionally absent on save-style commands — RCON +// "no error" is the canonical success signal. ARK's SaveWorld in +// particular sometimes returns no body at all but still flushes the +// world to disk; if confirmIf returned false on empty output the +// operator saw "unexpected response" while the save was actually +// landing. Output text is shown in the toast for informational value. +const BATCH_GAME_ACTIONS = { + "ark-sa": [ + { id: "save", label: "💾 Save World", title: "Sends SaveWorld over RCON to every visible server", rcon: "SaveWorld" }, + { id: "broadcast", label: "📢 Broadcast", title: "Send a broadcast message to every visible server", + rconPrompt: { prompt: "Broadcast message to all visible ARK servers:", template: 'Broadcast "{msg}"' } }, + ], + "7dtd": [ + { id: "save", label: "💾 Save World", title: "Sends saveworld over telnet RCON to every visible server", rcon: "saveworld" }, + { id: "say", label: "📢 Say", title: "Send a chat message to every visible server", + rconPrompt: { prompt: "Chat message to broadcast:", template: 'say "{msg}"' } }, + ], + "minecraft-java": [ + { id: "save", label: "💾 Save All", title: "Runs save-all on every visible server", rcon: "save-all" }, + ], + "valheim": [], + "v-rising": [], + "factorio": [ + { id: "save", label: "💾 Save", title: "Runs /save on every visible server", rcon: "/save" }, + ], + "palworld": [ + { id: "save", label: "💾 Save", title: "Runs Save on every visible server", rcon: "Save" }, + ], +}; + +// Tracks the currently-running batch so we can show a "Cancel" button +// + ignore overlapping triggers. One queue at a time globally. +let _batchInFlight = null; + +function _visibleInstances() { + // The grid renders all instances and toggles display:none for hidden + // ones; query that instead of recomputing the filter so we always + // act on what the operator can see (and can verify before clicking). + const grid = $("#svr-grid"); if (!grid) return []; + const visible = []; + for (const el of grid.children) { + if (!el.dataset || !el.dataset.cardId) continue; + if (el.style.display === "none") continue; + const i = allInstances.find(x => x.instance_id === el.dataset.cardId); + if (i) visible.push(i); + } + return visible; +} + +function _renderBatchBar() { + const bar = $("#svr-batchbar"); if (!bar) return; + const list = _visibleInstances(); + // Hide the bar only when nothing's visible. Even a single visible + // server gets the bar so game-specific commands (Save World, Say, + // Broadcast) surface as soon as the filter narrows to one game — + // operators kept asking "where did Save World go" when scoping to + // a single 7DTD or V Rising server. + if (list.length === 0) { + if (_renderBatchBar._lastHTML !== "") { + _renderBatchBar._lastHTML = ""; + bar.style.display = "none"; bar.innerHTML = ""; + } + return; + } + bar.style.display = ""; + + const runningCount = list.filter(i => i.status === "running").length; + const stoppedCount = list.filter(i => i.status === "stopped" || /^crashed/i.test(i.status || "")).length; + const startable = stoppedCount > 0; + const stoppable = runningCount > 0; + const restartable = runningCount > 0; + + // Game-specific extras only appear when the filter narrows to ONE + // module (the request was "if I filter the list by game, game- + // specific actions should pop up"). + let extrasHTML = ""; + if (serverFilter.module !== "all") { + const extras = BATCH_GAME_ACTIONS[serverFilter.module] || []; + if (extras.length) { + const mod = (allModules.find(m => m.id === serverFilter.module) || {}); + extrasHTML = ` + + ${esc(mod.name || serverFilter.module)} + ${extras.map(a => ``).join("")} + `; + } + } + + const inFlight = _batchInFlight ? ` +
    + + ${esc(_batchInFlight.statusLine || "running…")} + +
    ` : ""; + + const html = ` + Bulk (${list.length}) + + + + ${extrasHTML} + + ${inFlight} + `; + // Dirty-check — skip the innerHTML write when nothing changed (click + // handling is delegated on the container, so the skip path loses nothing). + if (html === _renderBatchBar._lastHTML) return; + _renderBatchBar._lastHTML = html; + bar.innerHTML = html; +} + +// Sequential batch runner. Clicks queue every visible server, then +// fire one at a time so we never slam an agent or the host with +// concurrent starts (cold-start ARK pulls dozens of GB into RAM, +// concurrent starts swap-thrash the box). Per-server result lands +// as a toast + the line in the batch progress strip. +// +// Grace periods: +// start — 10s between (allows ports to fully bind, ASA to claim +// SteamCMD, DB writes to settle). +// stop — 2s between (just enough that the agent's stop reply + +// container teardown unwind in order). +// restart — 12s between (stop + start handshake takes ~10s on ARK). +// rcon — 1s between (agent dispatcher serializes anyway, but the +// grace gives SaveWorld in particular a moment to land its +// flush before the next call). +async function runBatchAction(actionID, btn) { + if (actionID === "cancel") { + if (_batchInFlight) _batchInFlight.cancel = true; + return; + } + if (_batchInFlight) { + toast("Another batch is still running — Cancel it first.", "err"); + return; + } + const targets = _visibleInstances(); + if (!targets.length) { toast("Nothing visible to batch.", "err"); return; } + + // Map action → per-target work function + in-flight grace period. + // Kept here so the queue runner stays generic. + let workFn, label, graceMs; + // Game-specific action lookup. + let gameAction = null; + if (serverFilter.module !== "all") { + gameAction = (BATCH_GAME_ACTIONS[serverFilter.module] || []).find(a => a.id === actionID) || null; + } + // Game-specific RCON prompts (e.g. "Broadcast message:") are shown + // up-front in the same in-app modal as the confirm step below — but + // we capture the value here so the confirm dialog can show what's + // about to be sent. Native window.prompt is intentionally avoided. + // promptValue stays null when the action doesn't need an input; + // the confirm modal will then render without the input field. + let promptValue = null; + // Resolved at confirm-time below so cancel cleanly aborts. + + switch (actionID) { + case "start": + label = "Start"; + graceMs = 10000; + workFn = async (i) => { + // Optimistic flip — same path the per-tile Start button takes + // (markStarting opens an SSE so the yellow pulse rides the card + // until the ready-regex / RCON / instance_state confirms running). + markStarting(i.instance_id); + try { + return await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/start`, { method: "POST" }); + } catch (err) { + clearStarting(i.instance_id); + throw err; + } + }; + break; + case "stop": + label = "Stop"; + graceMs = 2000; + workFn = async (i) => { + clearStarting(i.instance_id); + markStopping(i.instance_id); + try { + return await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/stop`, { method: "POST" }); + } catch (err) { + clearStopping(i.instance_id); + throw err; + } + }; + break; + case "restart": + label = "Restart"; + graceMs = 12000; + workFn = async (i) => { + // Mirror doInstanceRestart's Phase-1/Phase-2 trick — keep the + // amber overlay on via the lightweight class while we land the + // Stop, THEN arm a fresh markStarting so its SSE only sees the + // post-Start events (otherwise the replay-tail of the previous + // run would clear the pulse instantly). + _paintRestartingCard(i.instance_id, true); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/stop?grace=30`, { method: "POST" }); + } catch (err) { + _paintRestartingCard(i.instance_id, false); + throw err; + } + // Wait for the container to actually report stopped before Start. + const deadline = Date.now() + 45000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 1000)); + try { + const { instances } = await fetchJSON("/api/instances"); + const cur = instances.find(x => x.instance_id === i.instance_id); + if (!cur || cur.status === "stopped" || cur.status === "crashed") break; + } catch {} + } + _paintRestartingCard(i.instance_id, false); + markStarting(i.instance_id); + try { + return await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/start`, { method: "POST" }); + } catch (err) { + clearStarting(i.instance_id); + throw err; + } + }; + break; + default: + if (!gameAction) { toast(`Unknown batch action: ${actionID}`, "err"); return; } + label = gameAction.label.replace(/^[^\w]+\s*/, ""); + graceMs = 1000; + const cmdTemplate = gameAction.rcon || (gameAction.rconPrompt && gameAction.rconPrompt.template) || ""; + const cmd = promptValue ? cmdTemplate.replace("{msg}", promptValue.replace(/"/g, '\\"')) : cmdTemplate; + workFn = async (i) => { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/rcon`, { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ command: cmd, timeout_ms: 8000 }), + }); + // Treat empty string as success too — many games (ARK SaveWorld) + // reply with no body but exit cleanly. + const out = r.output || ""; + const ok = !r.error && (gameAction.confirmIf ? gameAction.confirmIf(out) : true); + return { ok, output: out, error: r.error }; + }; + break; + } + + // Filter targets sensibly: don't try to start a running server, etc. + const skip = new Set(); + if (actionID === "start") for (const i of targets) if (i.status === "running") skip.add(i.instance_id); + if (actionID === "stop") for (const i of targets) if (i.status !== "running" && i.status !== "starting") skip.add(i.instance_id); + if (actionID === "restart") for (const i of targets) if (i.status !== "running") skip.add(i.instance_id); + if (gameAction) for (const i of targets) if (i.status !== "running") skip.add(i.instance_id); + const queue = targets.filter(i => !skip.has(i.instance_id)); + if (!queue.length) { toast(`Nothing to ${label.toLowerCase()} — every visible server is already in the right state.`, "err"); return; } + + // In-app confirm dialog. Combined input + list + grace hint. + const messageVerb = label.toLowerCase(); + const confirmTitle = gameAction + ? `${label} on ${queue.length} server${queue.length === 1 ? "" : "s"}` + : `${label} ${queue.length} server${queue.length === 1 ? "" : "s"}`; + const message = gameAction + ? `Send ${esc(label)} to ${queue.length} ${queue.length === 1 ? "server" : "servers"} below.` + : `About to ${esc(messageVerb)} ${queue.length} ${queue.length === 1 ? "server" : "servers"}.`; + const confirmRes = await bulkConfirm({ + title: confirmTitle, + message, + html: true, + list: queue.map(i => ({ label: i.instance_id })), + listHeader: `Affects ${queue.length} server${queue.length === 1 ? "" : "s"}:`, + grace: queue.length > 1 ? `Sequential — ${(graceMs/1000).toFixed(0)}s grace between each. Use Cancel in the bulk bar to stop mid-queue.` : null, + okLabel: gameAction ? `Send ${label}` : `${label} all`, + okStyle: actionID === "stop" ? "danger" : "primary", + input: (gameAction && gameAction.rconPrompt) ? { + label: gameAction.rconPrompt.prompt, + placeholder: "Type your message…", + default: "", + } : null, + }); + if (!confirmRes.confirmed) return; + if (gameAction && gameAction.rconPrompt) { + promptValue = (confirmRes.value || "").trim(); + if (!promptValue) { toast("Cancelled — empty input.", "err"); return; } + // Re-resolve workFn now that we have the prompt value (the earlier + // assignment captured the empty string template). + const cmdTemplate = gameAction.rconPrompt.template; + const cmd = cmdTemplate.replace("{msg}", promptValue.replace(/"/g, '\\"')); + workFn = async (i) => { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(i.instance_id)}/rcon`, { + method: "POST", headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ command: cmd, timeout_ms: 8000 }), + }); + const out = r.output || ""; + const ok = !r.error && (gameAction.confirmIf ? gameAction.confirmIf(out) : true); + return { ok, output: out, error: r.error }; + }; + } + + // Immediate visible ack so the operator knows the click registered — + // the per-server toasts come in over the next several seconds, but + // this fires the moment we leave the confirm dialog. + toast(`${label} batch queued — ${queue.length} server${queue.length === 1 ? "" : "s"}, ${(graceMs/1000).toFixed(0)}s grace between each.`, "ok"); + + _batchInFlight = { action: actionID, label, total: queue.length, done: 0, ok: 0, fail: 0, cancel: false, statusLine: `${label} 0/${queue.length}…` }; + _renderBatchBar(); + + for (let idx = 0; idx < queue.length; idx++) { + if (_batchInFlight.cancel) { toast(`${label} cancelled — ${_batchInFlight.done}/${queue.length} done.`, "err"); break; } + const i = queue[idx]; + _batchInFlight.statusLine = `${label} ${idx + 1}/${queue.length}: ${i.instance_id}…`; + _renderBatchBar(); + try { + const result = await workFn(i); + const ok = (result && typeof result === "object" && "ok" in result) ? result.ok : true; + const out = (result && result.output) ? String(result.output).trim() : ""; + if (ok) { + _batchInFlight.ok++; + toast(`${label} ✓ ${i.instance_id}${out ? " — " + (out.length > 80 ? out.slice(0, 77) + "…" : out) : ""}`, "ok"); + } else { + _batchInFlight.fail++; + toast(`${label} ⚠ ${i.instance_id} — ${result.error || "unexpected response"}`, "err"); + } + } catch (err) { + _batchInFlight.fail++; + toast(`${label} ✗ ${i.instance_id} — ${err.message}`, "err"); + } + _batchInFlight.done++; + if (idx < queue.length - 1 && !_batchInFlight.cancel && graceMs > 0) { + _batchInFlight.statusLine = `Waiting ${(graceMs/1000).toFixed(0)}s before next…`; + _renderBatchBar(); + await new Promise(r => setTimeout(r, graceMs)); + } + } + + const done = _batchInFlight.done, ok = _batchInFlight.ok, fail = _batchInFlight.fail; + _batchInFlight = null; + _renderBatchBar(); + toast(`${label} batch finished — ${ok}/${done} ok${fail ? `, ${fail} failed` : ""}`, fail ? "err" : "ok"); +} + +function _applyServerFilter() { + const grid = $("#svr-grid"); if (!grid) return; + const q = (serverFilter.search || "").trim().toLowerCase(); + let visible = 0; + // Card elements may live directly in #svr-grid (flat mode) OR inside + // per-section .svr-group-grid containers (grouped mode). The querySelector + // covers both. + grid.querySelectorAll("[data-card-id]").forEach(el => { + const id = el.dataset.cardId; + const i = allInstances.find(x => x.instance_id === id); + if (!i) { el.style.display = "none"; return; } + let show = true; + if (serverFilter.status === "running") show = i.status === "running"; + else if (serverFilter.status === "stopped") show = i.status === "stopped"; + else if (serverFilter.status === "issues") show = /^(crashed|error|delete_failed|install_failed)/i.test(i.status || ""); + if (show && serverFilter.agent !== "all" && i.agent_id !== serverFilter.agent) show = false; + if (show && serverFilter.module !== "all" && i.module_id !== serverFilter.module) show = false; + if (show && q) { + const hay = ((i.instance_id || "") + " " + (i.module_id || "") + " " + (i.agent_id || "")).toLowerCase(); + if (!hay.includes(q)) show = false; + } + el.style.display = show ? "" : "none"; + if (show) visible++; + }); + // Hide any per-agent section that has zero visible cards so the layout + // doesn't show empty headers. + grid.querySelectorAll(".svr-agent-section").forEach(sec => { + const anyVisible = !!sec.querySelector("[data-card-id]:not([style*='display: none'])"); + sec.style.display = anyVisible ? "" : "none"; + }); + // Empty state under the grid (sibling node so it doesn't fight the + // section layout in grouped mode). + let empty = grid.parentElement.querySelector(":scope > .svr-empty"); + if (visible === 0) { + if (!empty) { + empty = document.createElement("div"); + empty.className = "svr-empty"; + grid.insertAdjacentElement("afterend", empty); + } + if (allInstances.length === 0) { + empty.innerHTML = `
    ${EMPTY_ICONS.server}
    No servers yetClick + New server in the sidebar to spin one up.`; + } else { + empty.innerHTML = `
    ${EMPTY_ICONS.search}
    No servers match these filtersTry clicking All above, or clearing the search.`; + } + } else if (empty) { + empty.remove(); + } + // Visible-set may have shrunk/grown — refresh the bulk action bar so + // the per-button counts and game-specific actions match what's on screen. + _renderBatchBar(); +} + +function _renderAgentsDrawer() { + const body = $("#agents-drawer-body"); + const sum = $("#agents-drawer-summary"); + if (!body || !sum) return; + const offline = allAgents.filter(a => !a.connected).length; + // Orphan instances — referenced agent_ids that aren't registered anymore. + const knownAgentIds = new Set(allAgents.map(a => a.agent_id)); + const orphanIds = new Set(); + for (const i of allInstances) if (!knownAgentIds.has(i.agent_id)) orphanIds.add(i.agent_id || "(unknown)"); + sum.textContent = `${allAgents.length} agent${allAgents.length === 1 ? "" : "s"}${offline ? ` · ${offline} offline` : ""}${orphanIds.size ? ` · ${orphanIds.size} orphan` : ""}`; + const realAgents = allAgents.map(a => ({ + agent_id: a.agent_id, label: a.label, hostname: a.hostname, + os: a.os, arch: a.arch, version: a.version, last_seen: a.last_seen, + connected: a.connected, _orphan: false, + })); + const orphanAgents = Array.from(orphanIds).map(aid => ({ + agent_id: aid, label: aid + " (orphaned)", hostname: "—", + os: "?", arch: "?", version: "?", last_seen: null, connected: false, _orphan: true, + })); + const combined = realAgents.concat(orphanAgents); + const html = combined.map(a => { + const lab = a.label || a.agent_id; + const showId = a.label && a.label !== a.agent_id && !a._orphan; + const meta = `${esc(a.hostname || "—")} · ${esc(a.os || "")}/${esc(a.arch || "")} · v${esc(a.version || "?")}${a.connected ? "" : (a._orphan ? " · orphaned" : " · offline")}${a.last_seen ? ` · last seen ${esc(fmtTime(a.last_seen))}` : ""}`; + const actions = a._orphan ? "" : ` + + + + `; + return `
    +
    ${a.connected ? "🖥️" : "⚠"}
    +
    +
    ${esc(lab)}${showId ? ` ${esc(a.agent_id)}` : ""}
    +
    ${meta}
    +
    +
    ${actions}
    +
    `; + }).join(""); + // Dirty-check — agents change rarely; skip the rebuild when identical. + if (html === _renderAgentsDrawer._lastHTML) return; + _renderAgentsDrawer._lastHTML = html; + body.innerHTML = html; +} + +// Backwards-compat alias — SSE live-update path + topbar search use this. +function renderServerCards() { renderAgentsAndServers(); } + +// New "glass-tile" card. Same data-* attributes as before so all the +// click delegation, SSE patches, and starting/stopping overlays keep +// working untouched. CSS is fresh — full-bleed art layer, frosted-glass +// info panel at the bottom, status badge top-right, agent chip top-left, +// quick actions revealed on hover. +// ── 7DTD cluster membership → color-coded card badge ─────────────────────── +// clusterMap maps instance_id → {id, name, master}. Refreshed by pollInstances +// from /api/7dtd/clusters (cheap; only re-renders cards when it actually +// changes). The badge color is derived from the cluster id so each cluster is +// visually distinct without any hardcoded palette. +let clusterMap = {}; +let _clustersJSON = ""; + +// Stable, well-distributed color from a cluster id → an HSL string. Same id +// always yields the same color. A raw `hash % 360` clusters similar hashes into +// the same hue band (three near-identical greens — useless for telling clusters +// apart), so we map the hash through the golden-angle (137.5°): successive +// values land far apart on the wheel. Lightness/saturation also wobble slightly +// by hash so two hues that happen to land close still read distinct. +function clusterColor(clusterId) { + let h = 0; + for (let k = 0; k < clusterId.length; k++) h = (h * 31 + clusterId.charCodeAt(k)) >>> 0; + const hue = Math.round((h * 137.508) % 360); + const sat = 68 + (h % 18); // 68–85% + const light = 58 + ((h >> 3) % 12); // 58–69% + return `hsl(${hue} ${sat}% ${light}%)`; +} + +// Short, human label for a cluster (its name, else a trimmed id). +function clusterLabel(c) { + const n = (c && c.name || "").trim(); + if (n) return n; + return c && c.id ? c.id.replace(/^cl_/, "").slice(0, 6) : "cluster"; +} + +async function refreshClusterMap() { + try { + const r = await fetchJSON("/api/7dtd/clusters"); + const clusters = r.clusters || []; + const j = JSON.stringify(clusters); + if (j === _clustersJSON) return false; // unchanged — skip rebuild + _clustersJSON = j; + const map = {}; + for (const c of clusters) { + for (const inst of (c.members || [])) { + map[inst] = { id: c.id, name: c.name, master: c.master === inst }; + } + } + clusterMap = map; + return true; // changed — caller re-renders + } catch { return false; } +} + +// ── WI-14: "Update available" badges ──────────────────────────────────────── +// updateCheckMap maps instance_id → last stored update-check row from +// /api/update-checks. Refreshed once at boot and after every manual +// "Check for updates" — checks are operator-initiated, so there's nothing +// to poll for in between. CHECK-ONLY: the badge never triggers an update. +let updateCheckMap = {}; +async function refreshUpdateCheckMap() { + try { + const r = await fetchJSON("/api/update-checks"); + const map = {}; + for (const c of (r.checks || [])) map[c.instance_id] = c; + updateCheckMap = map; + } catch {} +} + +// Card title: prefer the operator-set server name (what shows in the game +// browser) over the raw instance_id slug, so a card reads "Refuge Gaming 3.0 +// PVE" instead of "v3-test". Modules key the name differently, so check the +// common ones in priority order; fall back to the slug when nothing's set. +function instanceDisplayName(i) { + const cv = i.config_values || {}; + const name = cv.server_name || cv.SERVER_NAME || cv.ServerName || + cv.SESSION_NAME || cv.session_name || ""; + const trimmed = (name || "").trim(); + return trimmed || i.instance_id; +} + +function buildCard(i) { + const el = document.createElement("div"); + el.className = "card svr-tile"; // keep .card so legacy queries still hit + el.dataset.cardId = i.instance_id; + el.dataset.inst = i.instance_id; + el.dataset.mod = i.module_id; + el.dataset.agent = i.agent_id; + // Overhauled card: NO click-to-flip strip. The art is a compact banner; + // info (status/name/meta/bars) is ALWAYS visible; a clear action row sits + // at the bottom at all times — a labeled primary button (Start/Stop) plus + // secondary icon buttons (Restart / Console / Files / Manage) and a "More" + // menu for the rare/dangerous actions (Force-kill / Backup / Delete). + // Every data-* hook the action delegation relies on is preserved, just + // relocated, so Start/Stop/Restart/Force-kill/Backup/Manage all keep + // firing through the same handlers. + const idA = esc(i.instance_id); + el.innerHTML = ` +
    +
    + + + + + ⬆ Update available +
    +
    +
    + +
    +
    + + · + + · + + +
    +
    +
    + CPU +
    + +
    +
    + RAM +
    + +
    +
    +
    +
    + + + +
    + + +
    +
    + `; + return el; +} + +function updateCard(el, i) { + const app = appearanceFor(i.module_id); + // When the instance is stopped/crashed, ignore any stats sample left + // over from before — the container is gone and the bars should read + // empty. Catches the polling path (state arrives via /api/instances) + // even if the SSE clear at instance_state time missed. + const isDead = i.status === "stopped" || i.status === "crashed"; + const stats = isDead ? {} : (latestStats[i.instance_id] || {}); + const appSt = isDead ? {} : (latestAppState[i.instance_id] || {}); + const cpuF = fmtCpu(stats); + const cpuPct = cpuF.barPct; + // RAM bar scaled to 20 GB so a 12 GB ARK reads as a meaningful ~60% + // instead of the host-wide 62 GB making it look near-idle. + const CARD_RAM_MAX_MB = 20480; + const memPct = stats.memMB > 0 ? Math.min(100, (stats.memMB / CARD_RAM_MAX_MB) * 100) : 0; + const isRunning = i.status === "running"; + const mod = (allModules.find(m => m.id === i.module_id) || {}); + const moduleName = mod.name || i.module_id; + + // Art layer — set background-image (URL or gradient as fallback). + // We only touch backgroundImage; setting style.background = "" would + // wipe the entire shorthand including the image we just assigned — + // that was the "no game pictures" bug. + const artEl = el.querySelector("[data-tile-art]"); + const fbEl = el.querySelector("[data-tile-fallback]"); + const url = artURLFor(app); + if (url) { + if (artEl.dataset.artUrl !== url) { + // Layer the URL over the gradient so a 404'd image still shows + // something coherent rather than the raw bg-color. + artEl.style.backgroundImage = `url("${url}"), ${app.grad || ""}`.replace(/, $/, ""); + artEl.dataset.artUrl = url; + } + if (fbEl) fbEl.style.display = "none"; + } else { + if (artEl.dataset.artUrl !== "_grad") { + artEl.style.backgroundImage = app.grad || ""; + artEl.dataset.artUrl = "_grad"; + } + if (fbEl) { + const e = app.emoji || "🎮"; + if (fbEl.dataset.emoji !== e) { fbEl.textContent = e; fbEl.dataset.emoji = e; } + fbEl.style.display = ""; + } + } + + setText(el, "[data-title]", instanceDisplayName(i)); + // Hover always reveals the full name even when the 2-line clamp cuts it. + const fullTitle = instanceDisplayName(i); + if (el.title !== fullTitle) el.title = fullTitle; + setText(el, "[data-sub]", moduleName); + + // Top-left chip — game abbreviation (e.g. "7DTD", "ARKASA", "VRising"). + // The agent identity used to live here but it's redundant: the agents + // drawer + filter pills already cover that, and operators care more + // about "what game is this" at a glance than "which host runs it". + const agEl = el.querySelector("[data-tile-agent]"); + if (agEl) { + const abbr = _moduleAbbr(i.module_id); + if (agEl.textContent !== abbr) agEl.textContent = abbr; + agEl.classList.remove("offline"); // no longer an agent indicator + agEl.title = `${moduleName} · ${i.agent_id || ""}`; + } + + // Cluster badge — color-coded chip showing which cluster this server is in. + // Only members of a 7DTD cluster show it; everything else stays hidden. + const clEl = el.querySelector("[data-cluster-badge]"); + if (clEl) { + const c = clusterMap[i.instance_id]; + if (c) { + const color = clusterColor(c.id); + const label = clusterLabel(c); + clEl.style.setProperty("--cluster-color", color); + clEl.classList.add("is-member"); + clEl.title = `Cluster: ${label}${c.master ? " (master)" : ""}`; + const html = `${c.master ? '' : ""}${esc(label)}`; + if (clEl.dataset.cl !== c.id + "|" + (c.master ? "m" : "")) { + clEl.innerHTML = html; + clEl.dataset.cl = c.id + "|" + (c.master ? "m" : ""); + } + } else if (clEl.classList.contains("is-member")) { + clEl.classList.remove("is-member"); + clEl.removeAttribute("data-cl"); + clEl.innerHTML = ""; + } + } + + // "Update available" badge — amber pill lit when the last manual check + // (Updates tab → Check for updates) found installed != latest buildid. + const updEl = el.querySelector("[data-update-badge]"); + if (updEl) { + const uc = updateCheckMap[i.instance_id]; + const show = !!(uc && uc.update_available); + updEl.classList.toggle("is-stale", show); + if (show) updEl.title = `Installed build ${uc.installed_buildid} → latest ${uc.latest_buildid} (${uc.branch})`; + } + + // Port — main game port (override > module default). + const portTxt = primaryPortFor(i); + const portEl = el.querySelector("[data-port]"); + const portSep = el.querySelector("[data-port-sep]"); + if (portEl) { + if (portTxt) { + portEl.textContent = portTxt; + portEl.style.display = ""; + if (portSep) portSep.style.display = ""; + } else { + portEl.style.display = "none"; + if (portSep) portSep.style.display = "none"; + } + } + + // Status badge — precedence: deleting > installing/updating > ready + // > starting > stopping > log-confirm > backend status. + // Backend "updating" maps to either "installing" (first-time download + // when the instance has never run) or "updating" (subsequent fetches); + // the only difference is the label, both use the same pulsing yellow + // pill style. + const pill = el.querySelector("[data-pill]"); + let effectiveStatus = i.status; + let pillLabel = null; // optional pill label override (e.g. cluster "setting up") + const needLogConfirm = READY_LOG_ONLY.has(i.module_id) && i.status === "running" && !isConfirmedReady(i); + if (instanceDeleting.has(i.instance_id)) effectiveStatus = "deleting"; + else if (instanceInstalling.has(i.instance_id)) effectiveStatus = "installing"; + else if (i.status === "updating") effectiveStatus = (i.detail && /install/i.test(i.detail)) ? "installing" : "updating"; + else if (instanceReady.has(i.instance_id)) effectiveStatus = "ready"; + else if (instanceStarting.has(i.instance_id) && (i.status === "running" || i.status === "stopped")) effectiveStatus = "starting"; + else if (instanceStopping.has(i.instance_id)) effectiveStatus = "stopping"; + else if (needLogConfirm) effectiveStatus = "starting"; + // 7DTD cluster auto-provision: a fresh world is still aligning to the cluster + // canonical (the agent holds this detail until the .canon-provisioned marker + // exists). Show an amber "setting up" pill instead of green "running" so a + // refresh makes clear the server isn't ready yet — don't restart it. + else if (i.status === "running" && i.detail && /finishing cluster setup|aligning decorations to (the )?cluster/i.test(i.detail)) { effectiveStatus = "starting"; pillLabel = "setting up"; } + if (pillLabel === null) pillLabel = effectiveStatus; + if (pill && pill.dataset.status !== pillLabel) { + pill.className = "svr-tile-status " + effectiveStatus; + pill.innerHTML = `${esc(pillLabel)}`; + pill.dataset.status = pillLabel; + } + + // CPU + RAM bars + const fcpu = el.querySelector("[data-fill-cpu]"); + if (fcpu) { fcpu.style.width = cpuPct + "%"; fcpu.style.background = barColor(cpuPct); } + setText(el, "[data-val-cpu]", stats.cpu !== undefined ? cpuF.text : "—"); + const cpuValEl = el.querySelector("[data-val-cpu]"); + if (cpuValEl) cpuValEl.title = stats.cpu !== undefined ? cpuF.title : ""; + const fram = el.querySelector("[data-fill-ram]"); + if (fram) { fram.style.width = memPct + "%"; fram.style.background = barColor(memPct); } + setText(el, "[data-val-ram]", stats.memMB !== undefined ? (stats.memMB >= 1024 ? (stats.memMB / 1024).toFixed(1) + "G" : stats.memMB.toFixed(0) + "M") : "—"); + + // Players + const hp = el.querySelector("[data-hero-players]"); + if (hp) { + const online = (appSt.players_online !== undefined) ? appSt.players_online : null; + const max = (appSt.players_max !== undefined) ? appSt.players_max : null; + if (online === null || max === null) { + hp.textContent = "—"; + hp.classList.add("empty"); + } else { + hp.textContent = `${online} / ${max}`; + hp.classList.remove("empty"); // show even when 0/N — the count is informative + } + } + // Hide the players-sep "·" if players is empty so the meta line never + // shows "module · :7777 · · " with a stranded dot. (Port-sep is + // already toggled inline above where portTxt is resolved.) + const playersShown = hp && !hp.classList.contains("empty"); + const playersSep = el.querySelector("[data-players-sep]"); + if (playersSep) playersSep.style.display = playersShown ? "" : "none"; + + // Uptime — computed from row.updated_at when running. + const up = el.querySelector("[data-uptime]"); + if (up) { + if (isRunning && i.updated_at) { + const updatedAt = Date.parse(i.updated_at); + if (!isNaN(updatedAt)) { + up.textContent = _fmtCompactDuration(Date.now() - updatedAt); + up.classList.remove("empty"); + } else { up.textContent = "—"; up.classList.add("empty"); } + } else { up.textContent = "—"; up.classList.add("empty"); } + } + + // Primary action — Start (▶) when stopped, Stop (■) when running. + // Inline SVG so the glyph renders identically on every OS instead of + // depending on emoji-font availability. We swap only the (via + // outerHTML) + the label span, never the whole button, so the + // `svr-act-primary` class and the data-* hooks survive the mode flip. + // Include fill="currentColor" inline as a belt-and-braces guard + // so the SVG always picks up the button's text color even if a class + // ever drops. + const pbtn = el.querySelector("[data-primary-btn]"); + if (pbtn) { + const setLabel = (txt) => { const s = pbtn.querySelector("[data-primary-label]"); if (s && s.textContent !== txt) s.textContent = txt; }; + if (isRunning) { + if (pbtn.dataset.mode !== "stop") { + pbtn.className = "svr-act-primary act-stop"; + pbtn.querySelector("svg").outerHTML = ``; + setLabel("Stop"); + pbtn.title = "Stop"; + pbtn.dataset.stop = i.instance_id; delete pbtn.dataset.start; + pbtn.dataset.mode = "stop"; + } + } else { + if (pbtn.dataset.mode !== "start") { + pbtn.className = "svr-act-primary act-start"; + pbtn.querySelector("svg").outerHTML = ``; + setLabel("Start"); + pbtn.title = "Start"; + pbtn.dataset.start = i.instance_id; delete pbtn.dataset.stop; + pbtn.dataset.mode = "start"; + } + } + pbtn.disabled = false; + } + // Restart only when running. + const rbtn = el.querySelector("[data-restart-btn]"); + if (rbtn) { + rbtn.style.display = isRunning ? "" : "none"; + if (isRunning) rbtn.dataset.restart = i.instance_id; else delete rbtn.dataset.restart; + rbtn.disabled = false; + } + // Force-kill button: shown whenever the engine is in any non-stopped + // state — including "crashed", "starting", "updating", "running". This + // is intentionally MORE permissive than the Stop button, because the + // exact case this button exists for is "engine reports crashed/dead but + // the container's still hogging RAM" (acekorneya watchdog wedged). It + // hits docker stop with grace=0 → near-instant SIGKILL. + const fbtn = el.querySelector("[data-force-kill-btn]"); + if (fbtn) { + const showForce = i.status !== "stopped"; + fbtn.style.display = showForce ? "" : "none"; + if (showForce) fbtn.dataset.forceKill = i.instance_id; else delete fbtn.dataset.forceKill; + fbtn.disabled = false; + } + const delBtn = el.querySelector("[data-del]"); if (delBtn) delBtn.disabled = false; +} + +function _fmtCompactDuration(ms) { + if (ms < 60_000) return Math.floor(ms / 1000) + "s"; + if (ms < 3_600_000) return Math.floor(ms / 60_000) + "m"; + if (ms < 86_400_000) { + const h = Math.floor(ms / 3_600_000); + const m = Math.floor((ms % 3_600_000) / 60_000); + return m === 0 ? h + "h" : h + "h " + m + "m"; + } + const d = Math.floor(ms / 86_400_000); + const h = Math.floor((ms % 86_400_000) / 3_600_000); + return h === 0 ? d + "d" : d + "d " + h + "h"; +} + +function setText(root, sel, v) { + const el = root.querySelector(sel); + const next = v == null ? "" : String(v); + if (el.textContent !== next) el.textContent = next; +} + +function barColor(pct) { + if (pct > 85) return "linear-gradient(90deg,#ff4f44,#f85149)"; + if (pct > 60) return "linear-gradient(90deg,#f4c13d,#e3b341)"; + return "linear-gradient(90deg,#3fb950,#4fcf63)"; +} + +// Called from SSE when per-instance stats/app_state arrive — keeps the bars +// breathing between polling ticks without touching the whole card's DOM. +function updateCardInPlace(instanceID) { + // The grid lives at #svr-grid in the new layout; older code referenced + // #cards-wrap. Try the document-level lookup so we don't depend on + // either container id. + const el = document.querySelector(`[data-card-id="${CSS.escape(instanceID)}"]`); + const i = allInstances.find(x => x.instance_id === instanceID); + if (el && i) updateCard(el, i); +} + +function syncInstancePickers() { + const ids = allInstances.map(i => i.instance_id); + syncPicker("#s-instance", ids, "— pick a server —"); +} +function syncAgentPicker() { + syncPicker("#f-agent", allAgents.map(a => a.agent_id), "— pick an agent —"); +} +function syncModulePicker() { + // Hidden input keeps the rest of the form logic unchanged; the visible + // UI is a grid of clickable tiles with game art. Name only — no version + // suffix, tiles are for humans picking a game, not inspecting releases. + // + // `demo` and `steamcmd-test` are dev-only fixtures — hide from the + // picker so end users only see real games. + const HIDDEN_FROM_PICKER = new Set(["demo", "steamcmd-test"]); + const hidden = $("#f-module"); + const grid = $("#f-module-grid"); + if (!grid) return; + const prev = hidden.value; + const visibleModules = allModules.filter(m => !HIDDEN_FROM_PICKER.has(m.id)); + const tiles = visibleModules.map(m => { + const app = m.appearance || moduleAppearance[m.id]; // WI-07: manifest appearance wins + const art = artURLFor(app); + const bg = art ? `background-image:url(${art});` : ''; + const grad = (app && app.grad) ? `background-color: transparent; background-image: ${art ? `url(${art})` : app.grad};` : bg; + const name = esc(m.name || m.id); + return `
    +
    +
    ${name}
    +
    `; + }).join(""); + grid.innerHTML = tiles || `
    No modules available yet.
    `; + // Re-apply previous selection if the module is still there. + if (prev && visibleModules.some(m => m.id === prev)) { + const t = grid.querySelector(`.module-tile[data-module-id="${prev}"]`); + if (t) t.classList.add("selected"); + } else { + hidden.value = ""; + } +} +// Delegate tile click/keyboard selection to the grid (idempotent — bind once). +document.addEventListener("click", e => { + const tile = e.target.closest("#f-module-grid .module-tile"); + if (!tile) return; + const grid = tile.parentElement; + grid.querySelectorAll(".module-tile.selected").forEach(t => t.classList.remove("selected")); + tile.classList.add("selected"); + $("#f-module").value = tile.dataset.moduleId || ""; + renderModuleCreateOptions(tile.dataset.moduleId || ""); +}); + +// MODULE_CREATE_OPTIONS: per-module fields shown on the new-server form +// AFTER the user picks a game. Populates a config_values map that the +// create POST forwards to the agent. Each entry is rendered into the +// `#f-extras` row. +// +// To extend: add a key (module id) → array of fields. Each field: +// - prop: env var / config_values key +// - label: human-readable label +// - type: "select" | "string" | "number" | "bool" +// - options (for select): [[value, label], …] OR plain ["v","v2"] +// - default: initial value +// - hint: small text under the input +const MODULE_CREATE_OPTIONS = { + // Canonical ARK: Survival Ascended map list. Source: ARK wiki + + // Wildcard's official map IDs. Community maps that don't ship with + // the dedicated server (Ragnarok / Astraeos in their fork form) are + // marked so operators know they need extra mod IDs after create. + "ark-sa": [{ + prop: "MAP_NAME", + label: "Map", + type: "select", + default: "TheIsland_WP", + hint: "Vanilla maps need no mods. Community maps (★) require mod IDs in the Mods tab AFTER first boot.", + options: [ + ["TheIsland_WP", "The Island"], + ["TheCenter_WP", "The Center"], + ["ScorchedEarth_WP", "Scorched Earth"], + ["Aberration_WP", "Aberration"], + ["Extinction_WP", "Extinction"], + ["LostColony_WP", "Lost Colony"], + ["Astraeos_WP", "Astraeos ★"], + ["Ragnarok_WP", "Ragnarok ★"], + ["Valguero_WP", "Valguero ★"], + ["BobsMissions_WP", "Club ARK (BobsMissions)"], + ], + }], + // 7DTD: bundled-mod install option. Single checkbox today (RefugeBot + // is the only mod the panel ships embedded), but the create-form + // already has the bool-checkbox machinery so adding more is just + // another entry here. Mod install runs AFTER the agent has created + // the container — frontend kicks the /install-mod endpoint once + // status settles. + "7dtd": [ + { + // Game VERSION / Steam branch → module.yaml update_provider (agent's + // pickProviderFromConfigValues reads config_values.provider_id). v2_6 is + // the DEFAULT and a FROZEN branch (buildid 22422094) so a server can + // never auto-drift onto the moving public branch when it flips to 3.0 on + // 2026-06-29 — this is the version lock. Branch list confirmed live via + // app_info_print 294420 on 2026-06-14. See module.yaml + memory plan doc. + prop: "provider_id", + label: "Game version", + type: "select", + default: "v2_6", + hint: "Locked branches (v2.6, v2.5…) pin the server to a frozen build so a Steam update can never corrupt your world. 2.6 is current. Pick 3.0 Experimental only to test the new build on a throwaway world — it auto-updates and is NOT save-compatible with 2.6. 'Public (auto-updates)' is unlocked and WILL move to 3.0 on June 29.", + options: [ + ["v2_6", "2.6 Stable — current · 🔒 locked (recommended)"], + ["latest_experimental", "3.0 Experimental — Dead Hot Summer · ⚠ beta, auto-updates"], + ["current", "Public / latest stable · ⚠ unlocked (moves to 3.0 on Jun 29)"], + ["v2_5", "2.5 Stable · 🔒 locked"], + ["v2_4", "2.4 Stable · 🔒 locked"], + ["v2_3", "2.3 Stable · 🔒 locked"], + ["v2_0", "2.0 Stable · 🔒 locked"], + ["v1_4", "1.4 Stable · 🔒 locked"], + ], + }, + { + prop: "server_name", + label: "Server name", + type: "text", + default: "Refuge 7DTD", + hint: "Shown in the in-game server browser (serverconfig ServerName).", + }, + { + prop: "world_name", + label: "World", + type: "select", + default: "RWG", + hint: "RWG = randomly-generated world (uses the seed + map size below). Navezgane is the handcrafted map. PREGEN* are TFP's prebuilt random maps.", + options: [ + ["RWG", "Random Gen (RWG)"], + ["Navezgane", "Navezgane (handcrafted)"], + ["PREGEN01", "PREGEN01"], + ["PREGEN02", "PREGEN02"], + ["PREGEN03", "PREGEN03"], + ["PREGEN4k_01", "PREGEN4k_01"], + ["PREGEN4k_02", "PREGEN4k_02"], + ["PREGEN4k_03", "PREGEN4k_03"], + ], + }, + { + prop: "world_seed", + label: "World seed (RWG only)", + type: "text", + default: "", + hint: "Any text — seeds the random world generation. Leave blank for the panel default. Ignored for Navezgane / PREGEN maps.", + }, + { + prop: "world_size", + label: "Map size (RWG only)", + type: "select", + default: "8192", + hint: "RWG world width/height. Bigger = more to explore but slower first-gen and more RAM. Ignored for Navezgane / PREGEN.", + options: [ + ["6144", "6144 (small)"], + ["8192", "8192 (default)"], + ["10240", "10240 (large)"], + ], + }, + { + prop: "_install_refugebot", + label: "Install RefugeBot mod", + type: "bool", + default: false, + hint: "Drops the bundled RefugeBot DLL + assets into Mods/RefugeBot/. Runs once after create. Loads on first server start.", + cap: "refugebot", // only shown when the controller was built with -tags refugebot + }, + ], + // Conan Exiles: pick UE5 (Enhanced) or UE4 (Legacy beta branch) at + // create time. Drives BOTH the initial Steam download (agent's + // pickProviderFromConfigValues maps EDITION → matching update_provider) + // AND the Mods tab's Workshop search filter (only Enhanced-tagged + // mods load on UE5 servers; Legacy-tagged mods on UE4). See + // modules/conan-exiles/module.yaml config_values block for the canonical + // copy and panel/memory/changelog_2026-05-08.md for the rationale. + "conan-exiles": [{ + prop: "EDITION", + label: "Edition", + type: "select", + default: "enhanced", + hint: "Enhanced (UE5) is Funcom's current build — pick this unless you specifically need the older UE4 mods. Switching after creation is supported but UE5→UE4 may not load Enhanced-era saves cleanly.", + options: [ + ["enhanced", "Enhanced (UE5, default)"], + ["legacy", "Legacy (UE4 — Steam beta branch)"], + ], + }], + // Minecraft + Valheim etc. ready when wanted: + // "minecraft-java": [{prop:"DIFFICULTY", label:"Difficulty", type:"select", default:"normal", options:["peaceful","easy","normal","hard"]}], +}; + +async function renderModuleCreateOptions(moduleID) { + const wrap = $("#f-extras"); + const labelEl = $("#f-extras-label"); + if (!wrap || !labelEl) return; + // Start from the static options for this module, then layer dynamic + // extras (like the ARK cluster dropdown that depends on the current + // cluster list). Returning [] hides the section entirely. + let fields = MODULE_CREATE_OPTIONS[moduleID] ? [...MODULE_CREATE_OPTIONS[moduleID]] : []; + // Drop fields gated on a build-tag capability the controller wasn't + // compiled with (GET /api/capabilities, loaded at boot into PANEL_CAPS). + fields = fields.filter(f => !f.cap || (window.PANEL_CAPS || {})[f.cap]); + // ARK cluster picker — only show if there's at least one cluster + // already set up. The first ARK on a fresh agent has nothing to join. + // Always fetch fresh: a cached value from the modal-open path can + // be stale if the operator just set up a cluster on another server + // and hit Add immediately. No-op if the API errors (network blip). + if (moduleID === "ark-sa") { + let clusters = []; + try { + const r = await fetchJSON("/api/ark/clusters"); + clusters = r.clusters || []; + } catch {} + window._arkClustersForCreate = clusters; + if (clusters.length > 0) { + fields.push({ + prop: "_ark_cluster_id", // underscore prefix → stripped before going into config_values + label: "Cluster", + type: "select", + default: "", + hint: "Joining a cluster mounts its shared transfer dir at create time. Pick standalone if this server runs solo.", + options: [["", "Standalone (no cluster)"]].concat( + clusters.map(c => [c.id, `${c.name || c.id} · ${(c.members||[]).length} member${(c.members||[]).length === 1 ? "" : "s"}`]) + ), + }); + } + } + // 7DTD cluster picker — only show if there's at least one cluster + // already set up. The first 7DTD on a fresh agent has nothing to join. + // Always fetch fresh: a cached value from the modal-open path can + // be stale if the operator just set up a cluster on another server + // and hit Add immediately. No-op if the API errors (network blip). + if (moduleID === "7dtd") { + let clusters = []; + try { + const r = await fetchJSON("/api/7dtd/clusters"); + clusters = r.clusters || []; + } catch {} + window._sdtdClustersForCreate = clusters; + if (clusters.length > 0) { + fields.push({ + prop: "_seven_dtd_cluster_id", // underscore prefix → stripped before going into config_values + label: "Cluster", + type: "select", + default: "", + hint: "Join a 7DTD cluster to share player progression with its other servers. Standalone runs solo. A new cluster world auto-aligns its decorations to the cluster on its first start (a one-time auto-restart) — watch the Console for “aligning decorations to cluster…”.", + options: [["", "Standalone (no cluster)"]].concat( + clusters.map(c => [c.id, `${c.name || c.id} · ${(c.members||[]).length} member${(c.members||[]).length === 1 ? "" : "s"}`]) + ), + }); + fields.push({ + prop: "_seven_dtd_seed_mods", // underscore prefix → dedicated request field, not config_values + label: "Seed mods from the cluster master", + type: "bool", + default: true, + hint: "Only applies if you pick a cluster above: clones the master server's exact mod set onto this new server so it plays identically (your own teleport homes stay separate). Ignored for standalone.", + }); + } + } + if (!fields.length) { + wrap.innerHTML = ""; + wrap.style.display = "none"; + labelEl.style.display = "none"; + return; + } + labelEl.style.display = ""; + wrap.style.display = ""; + wrap.innerHTML = fields.map(f => { + const id = "f-extra-" + f.prop; + // Bool fields render as a clear native checkbox + label inline, + // NOT wrapped in another label heading. Without the explicit + // checkbox the field looked like a static description block — + // operators reported they couldn't see how to opt in/out. + if (f.type === "bool") { + const checked = f.default ? " checked" : ""; + return `
    + +
    `; + } + let ctrl = ""; + if (f.type === "select") { + const opts = (f.options || []).map(o => { + const [val, lab] = Array.isArray(o) ? o : [o, o]; + const sel = (val === f.default) ? " selected" : ""; + return ``; + }).join(""); + ctrl = ``; + } else { + ctrl = ``; + } + return `
    + + ${ctrl} + ${f.hint ? `
    ${f.hint}
    ` : ""} +
    `; + }).join(""); +} + +// Snapshot the current extras into a config_values map at submit time. +function readModuleCreateOptions() { + const out = {}; + document.querySelectorAll("#f-extras [data-extra-prop]").forEach(el => { + const key = el.dataset.extraProp; + if (!key) return; + let v; + if (el.type === "checkbox") v = el.checked ? "true" : "false"; + else v = (el.value || "").trim(); + if (v !== "") out[key] = v; + }); + return out; +} +document.addEventListener("keydown", e => { + if (e.key !== "Enter" && e.key !== " ") return; + const tile = e.target.closest && e.target.closest("#f-module-grid .module-tile"); + if (!tile) return; + e.preventDefault(); + tile.click(); +}); +function syncPicker(sel, ids, placeholder) { + const el = $(sel); if (!el) return; + const prev = el.value; + el.innerHTML = `` + ids.map(id => ``).join(""); + if (prev && ids.includes(prev)) el.value = prev; + else if (ids.length === 1) el.value = ids[0]; +} + +// renderAgents() used to render a flat agent list below the servers grid. +// That section was collapsed into Agents-wrap-servers, so this is just an +// alias that re-runs the combined renderer. +function renderAgents() { renderAgentsAndServers(); } + +// ================ schedules ================ +function renderSchedules(schedules) { + const wrap = $("#schedules-wrap"); + // Main-page Schedules section was removed; renderSchedules survives only + // to avoid touching every call site. When its DOM target is absent, bail + // out — the per-instance modal has its own renderer (loadInstanceSchedules). + if (!wrap) return; + const countEl = $("#sched-count"); + if (countEl) countEl.textContent = `${schedules.length} total`; + if (!schedules.length) { + wrap.innerHTML = `
    ${EMPTY_ICONS.clock}
    No schedulesClick "Add schedule" to automate things (auto-backup, idle shutdown, crash alerts)
    `; + return; + } + wrap.innerHTML = `
    + + ${schedules.map(s => { + let act; try { act = typeof s.action === "string" ? JSON.parse(s.action) : s.action; } catch { act = s.action; } + const when = s.trigger_kind === "event" + ? humanizeEvent(s.event_spec) + (s.event_sustained_seconds ? ` for ${Math.round(s.event_sustained_seconds/60)}m` : "") + : humanizeCron(s.cron_spec); + const fired = s.last_fired_at ? fmtTime(s.last_fired_at) : "—"; + const statusDot = s.last_status === "ok" ? '' + : s.last_status === "error" ? '' + : ''; + return ` + + + + + + + `; + }).join("")} +
    ServerWhenActionLast firedOn
    ${esc(s.instance_id)}${s.description ? `
    ${esc(s.description)}` : ""}
    ${esc(when)}${esc(humanizeAction(act))}${statusDot}${esc(fired)}${s.last_detail ? `
    ${esc(String(s.last_detail).slice(0,60))}` : ""}
    `; +} +function humanizeCron(spec) { + const known = { "0 */15 * * * *":"Every 15 minutes","0 0 * * * *":"Every hour","0 0 */6 * * *":"Every 6 hours", + "0 0 */12 * * *":"Every 12 hours","0 0 3 * * *":"Daily at 3am","0 0 4 * * 0":"Weekly (Sun 4am)" }; + return known[spec] || "Cron: " + spec; +} +function humanizeEvent(cel) { + if (!cel) return "—"; + if (/players_online == 0/.test(cel)) return "When no players"; + if (/instance_status == "crashed"/.test(cel)) return "When server crashes"; + if (/player_kind == "KIND_JOIN"/.test(cel)) return "When a player joins"; + if (/player_kind == "KIND_LEAVE"/.test(cel)) return "When a player leaves"; + if (/OutOfMemoryError/.test(cel)) return "When OOM in logs"; + return "Custom: " + cel.slice(0, 40); +} +function humanizeAction(a) { + if (!a || !a.type) return "—"; + if (a.type === "rcon") return a.command === "save-all" ? "Save the world" : `RCON: ${a.command}`; + if (a.type === "instance") { + const base = `${a.op} server${a.grace_seconds ? ` (grace ${a.grace_seconds}s)` : ""}`; + const preSave = a.pre_save_rcon ? ` (after ${esc(a.pre_save_rcon)} +${a.pre_save_delay_sec || 10}s)` : ""; + const warn = a.warn_lead_minutes ? ` · ⚠ warn ${a.warn_lead_minutes}m` : ""; + return (a.backup_before_start ? `${base}${preSave} + 💾 clean backup` : `${base}${preSave}`) + warn; + } + if (a.type === "backup") return `💾 Backup${a.description ? ` (${a.description})` : ""}`; + if (a.type === "webhook") return `POST → ${(a.url || "").slice(0, 40)}…`; + return a.type; +} + +// ================ card actions ================ +document.addEventListener("click", async e => { + // Button actions win over card-open. Find the closest element with any data-*. + const btn = e.target.closest("[data-start],[data-stop],[data-restart],[data-force-kill],[data-del],[data-open],[data-open-tab],[data-schdel],[data-schedit],[data-agent-edit],[data-agent-del],[data-agent-new-server],[data-card-backup],[data-card-more]"); + if (!btn) return; + const ds = btn.dataset; + // Prevent card-open from firing when an action button inside the card was clicked. + if ((ds.start || ds.stop || ds.restart || ds.forceKill || ds.del || ds.cardBackup) && btn !== e.target && btn.matches("[data-open]") === false) { + e.stopPropagation(); + } + if (ds.start) { _closeCardMenus(); return doInstanceAction(ds.start, "start", btn); } + if (ds.stop) { _closeCardMenus(); return doInstanceAction(ds.stop, "stop", btn); } + if (ds.forceKill) { _closeCardMenus(); return doForceKill(ds.forceKill, btn); } + if (ds.restart) { _closeCardMenus(); return doInstanceRestart(ds.restart, btn); } + if (ds.del) { _closeCardMenus(); return doDelete(ds.del, btn); } + if (ds.cardBackup) { e.stopPropagation(); _closeCardMenus(); return openCardBackupModal(ds.cardBackup); } + if (ds.openTab) { e.stopPropagation(); _closeCardMenus(); const [oid, otab] = ds.openTab.split("|"); return openInstanceModal(oid, otab || "console"); } + if (ds.cardMore) { + e.stopPropagation(); + const wrap = btn.closest(".svr-act-more-wrap"); + const menu = wrap && wrap.querySelector("[data-card-menu]"); + const willOpen = menu && menu.hasAttribute("hidden"); + _closeCardMenus(); + if (willOpen) { + menu.removeAttribute("hidden"); + btn.setAttribute("aria-expanded", "true"); + // Flip the menu above the button if it would overflow the viewport bottom. + const r = btn.getBoundingClientRect(); + menu.classList.toggle("drop-up", r.bottom + 160 > window.innerHeight); + } + return; + } + if (ds.open) { e.stopPropagation(); _closeCardMenus(); return openInstanceModal(ds.open); } + if (ds.agentNewServer) { + e.stopPropagation(); + showModal("#new-server-modal"); + // Pre-select the agent in the create form. + const sel = $("#f-agent"); if (sel) sel.value = ds.agentNewServer; + return; + } + if (ds.schdel) { + if (!confirm("Remove this schedule?")) return; + try { await fetchJSON(`/api/schedules/${encodeURIComponent(ds.schdel)}`, { method: "DELETE" }); toast("Schedule removed", "ok"); pollSchedules(); if (instanceModalState.id) loadInstanceSchedules(); } + catch (err) { toast("Remove failed: " + err.message, "err"); } + } + if (ds.schedit) { + // Edit a schedule in place. Server-side scheduler unregisters + re-registers + // the cron entry so a new spec takes effect immediately — no controller restart. + try { + const all = await fetchJSON("/api/schedules"); + const cur = (all.schedules || []).find(x => x.id === ds.schedit); + if (!cur) { toast("Schedule not found", "err"); return; } + const newCron = prompt( + `Cron spec for "${cur.description || cur.id}":\n` + + `(6-field cron with seconds — e.g. "0 0 */6 * * *" = every 6 h)`, + cur.cron_spec || "" + ); + if (newCron === null) return; + const r = await fetch(`/api/schedules/${encodeURIComponent(ds.schedit)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cron_spec: newCron }) + }); + if (!r.ok) throw new Error(await r.text()); + toast("Schedule updated — re-registered live", "ok"); + pollSchedules(); + if (instanceModalState && instanceModalState.id) loadInstanceSchedules(); + } catch (err) { toast("Edit failed: " + err.message, "err"); } + } + if (ds.agentEdit) openAgentEdit(ds.agentEdit); + if (ds.agentDel) { + if (!confirm(`Remove agent "${ds.agentDel}" from the panel? It'll re-register on next connect if still running.`)) return; + try { await fetchJSON(`/api/agents/${encodeURIComponent(ds.agentDel)}`, { method: "DELETE" }); toast("Agent removed", "ok"); pollAgents(); } + catch (err) { toast("Remove failed: " + err.message, "err"); } + } +}); +// Force-kill — SIGKILL the container almost immediately. For the case +// where the game engine has wedged but the supervisor inside the +// container is keeping it alive (acekorneya ARK watchdog respawning a +// runaway engine, RAM climbing past sane limits, panel's normal Stop +// politely waiting 30s for a graceful shutdown that's never coming). +// Goes through the existing /stop endpoint with grace=0 — the agent's +// runtime.Stop becomes "SIGTERM, then immediate SIGKILL". +async function doForceKill(id, btn) { + if (!confirm(`Force kill ${id}?\n\nThis sends SIGKILL to the container almost immediately (grace=0). Use ONLY when the server is wedged or its RAM is runaway and Stop didn't take. Any unsaved game state since the last auto-save will be lost.`)) return; + if (!_lifecycleAcquire(id, "force-kill")) return; + markStopping(id); + try { + await runAction(btn, async () => { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/stop?grace=0`, { method: "POST" }); + pollInstances(); + }, { loadingLabel: "Force-killing", successLabel: "Killed", errorLabel: "Force kill failed" }).catch(() => { + clearStopping(id); + }); + } finally { _lifecycleRelease(id); } +} + +// Per-instance lifecycle lock. runAction disables only the button that +// was clicked, but operators can fire multiple lifecycle commands for +// the same instance from different surfaces (card button, modal, +// keyboard, batch dialog) — without this gate, a Restart while a Stop +// is in flight queues a second stop+start on the agent, which doubles +// the wait. Deduping at the instance level keeps the agent from +// processing redundant lifecycle commands and gives the operator +// immediate feedback that the click was redundant. +const _lifecycleInFlight = new Set(); +function _lifecycleAcquire(id, label) { + if (_lifecycleInFlight.has(id)) { + if (typeof toast === "function") toast(`${id}: a lifecycle command is already in flight — give it a few seconds`, "info"); + return false; + } + _lifecycleInFlight.add(id); + return true; +} +function _lifecycleRelease(id) { _lifecycleInFlight.delete(id); } + +async function doInstanceAction(id, action, btn) { + if (!_lifecycleAcquire(id, action)) return; + const labels = { + start: { loading: "Starting", success: "Started" }, + stop: { loading: "Stopping", success: "Stopped" }, + }[action] || { loading: action, success: "Done" }; + // Optimistic UI: flip the pill BEFORE the RPC fires. The previous + // version only updated the pill when the agent's InstanceState event + // landed, leaving the card in a stale "running" pill for 1-3s after + // the click — operators read this as "did my click register?" + if (action === "start") markStarting(id); + else if (action === "stop") { clearStarting(id); markStopping(id); } + try { + await runAction(btn, async () => { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/${action}`, { method: "POST" }); + pollInstances(); + }, { loadingLabel: labels.loading, successLabel: labels.success, errorLabel: `${action[0].toUpperCase()+action.slice(1)} failed` }).catch(() => { + // RPC failed — drop the optimistic overlay so the real status shows. + if (action === "start") clearStarting(id); + else if (action === "stop") clearStopping(id); + }); + } finally { _lifecycleRelease(id); } +} + +// ── Shared per-instance SSE hub ────────────────────────────────────────── +// One EventSource per instance id, shared by every consumer (start/stop/ +// install overlays, the console pane, backup progress). Before this hub each +// consumer opened its OWN /api/events?instance= connection — up to five +// concurrent streams for a single instance, each separately parsing the same +// event payloads. Subscribers receive the already-parsed event object; the +// connection closes when the last subscriber unsubscribes. EventSource's +// built-in auto-reconnect is preserved (none of the old sites had custom +// onerror logic beyond a silent no-op). +const _instSSEHub = new Map(); // instance_id -> { es, subs: Set } +function sseSubscribeInstance(id, fn) { + let hub = _instSSEHub.get(id); + if (!hub) { + hub = { es: new EventSource(`/api/events?instance=${encodeURIComponent(id)}`), subs: new Set() }; + hub.es.onmessage = e => { + let ev; + try { ev = JSON.parse(e.data); } catch { return; } + for (const sub of hub.subs) { try { sub(ev); } catch {} } + }; + _instSSEHub.set(id, hub); + } + hub.subs.add(fn); + let done = false; + return () => { + if (done) return; + done = true; + hub.subs.delete(fn); + if (hub.subs.size === 0 && _instSSEHub.get(id) === hub) { + _instSSEHub.delete(id); + try { hub.es.close(); } catch {} + } + }; +} + +// Same shape as markStarting/clearStarting but watches for the stop to +// settle. Without this, clicking Stop leaves the pill on "running" until +// the next 2s poll lands the new agent-side status. +function markStopping(id) { + if (instanceStopping.has(id)) return; + const armedAt = Date.now(); + const unsub = sseSubscribeInstance(id, ev => { + try { + if (!ev.instance_state) return; + const s = String(ev.instance_state.status).replace("INSTANCE_STATUS_", "").toLowerCase(); + if (s === "stopped" || s === "crashed") { + if (Date.now() - armedAt < 800) return; // ignore replay echo + clearStopping(id); + } + } catch {} + }); + // 90s grace — graceful stop is configurable but defaults to 30s. If + // the agent doesn't ack within 90s the operator probably wants to know + // something's wrong; let the pill drop and the actual status (still + // running) reasserts on the next poll. + const timer = setTimeout(() => clearStopping(id), 90000); + instanceStopping.set(id, { unsub, timer }); + paintStartingUI(); +} +function clearStopping(id) { + const rec = instanceStopping.get(id); + if (!rec) return; + try { rec.unsub(); } catch {} + clearTimeout(rec.timer); + instanceStopping.delete(id); + paintStartingUI(); + if (typeof renderServerCards === "function") renderServerCards(); +} + +// Client-side restart: stop → poll until stopped → start. No backend +// endpoint needed; just chains the two existing ones. +// +// Subtle: markStarting() sets up an SSE listener that auto-clears the +// "starting" overlay on any `instance_state=stopped` event — including the +// one the agent emits during OUR stop phase. If we armed it before calling +// Stop, the overlay would evaporate between Stop and Start and the card +// would snap to "running" instantly when Start landed (the bug the +// operator just observed). Instead we keep the yellow pulse visible via a +// lightweight class-only override during Stop, then call markStarting +// AFTER the stop has actually landed so its SSE listener only sees the +// events from the new process. +async function doInstanceRestart(id, btn) { + if (!_lifecycleAcquire(id, "restart")) return; + try { + await runAction(btn, async () => { + // Phase 1 — keep the yellow pulse on via CSS class only; no SSE yet. + _paintRestartingCard(id, true); + try { + // Server-side restart: the controller awaits the agent's stop ack + // (sent only after the container actually exited) before dispatching + // Start. The old client-side stop→poll→start chain raced — the poll + // could fire Start while the graceful stop was still draining, and + // the stop then killed the freshly-started server. It also stranded + // the server stopped if the page was refreshed mid-chain. The POST + // blocks for the stop duration (seconds to ~grace) and returns once + // Start is dispatched. + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/restart?grace=30`, { method: "POST" }); + // Phase 2 — stop landed, start dispatched. Arm the proper starting + // overlay; its SSE subscription only sees events from the new run. + _paintRestartingCard(id, false); + markStarting(id); + } finally { + // Defensive — if the POST threw before we got to markStarting, + // we'd leave the pulse class stuck on the card. Make sure it's gone. + if (!instanceStarting.has(id)) _paintRestartingCard(id, false); + } + pollInstances(); + }, { loadingLabel: "Restarting", successLabel: "Restarted", errorLabel: "Restart failed" }).catch(() => {}); + } finally { _lifecycleRelease(id); } +} + +// Paint/unpaint the yellow pulse class on a specific card without going +// through the full Starting-overlay SSE machinery. Used during the Stop +// phase of Restart so the transient `instance_state=stopped` event the +// agent emits doesn't prematurely clear the overlay. +function _paintRestartingCard(id, on) { + document.querySelectorAll(`.card[data-card-id="${CSS.escape(id)}"]`).forEach(card => { + card.classList.toggle("is-starting", !!on); + const pill = card.querySelector("[data-pill]"); + if (pill && on) { + pill.className = "status-pill starting"; + pill.textContent = "restarting"; + pill.dataset.status = "restarting"; + } + }); +} + +// ============ Starting state (client-side "starting" pill) ============ +// +// Backend state machine is creating → stopped → running (docker-level). +// But "docker says running" ≠ "game server is joinable" — MC needs ~30s, +// 7DTD 1-2 min, ARK multiple minutes, Empyrion (Wine + Unity + world gen) +// 1-2 min. So we overlay a client-only "starting" pill from the moment +// the user clicks Start until we see a per-game "ready" signal: +// +// • app_state events (agent emits these when RCON poll succeeds — best +// signal for RCON-enabled modules). +// • a module-specific "ready" log-line regex (the only path for Empyrion +// since it has no RCON adapter yet). +// • backend status flips to stopped/crashed (something went wrong). +// • 15-minute failsafe in case a game never emits a recognisable signal. +const instanceStarting = new Map(); // id → { sse, timer } +const instanceInstalling = new Map(); // id → { sse, timer } — create auto-kicks SteamCMD update; hold "installing" pill until "update complete" +const instanceReady = new Map(); // id → timer — short flash after install completes, before normal "stopped" pill takes over +const instanceStopping = new Map(); // id → { sse, timer } — pill flips immediately on Stop click; cleared when agent confirms stopped/crashed +const instanceDeleting = new Set(); // id — fade-out class on card while delete is in flight +// instanceLocallyDeleted: ids whose delete the user JUST initiated. The +// card was optimistically pulled from the dashboard, and pollInstances +// must NOT reintroduce them until the backend's delete actually +// completes (often 30-90s when a steamcmd sidecar needs cleanup). +// pollInstances filters these out; the create-server form refuses to +// reuse the id until it's gone. Cleared by the delete background +// watcher when the DB row truly disappears. +const instanceLocallyDeleted = new Set(); +// Confirmed-joinable: ids whose READY_PATTERNS match has been seen this +// session. Source of truth for log-only modules where the backend's +// STATUS_RUNNING fires too early. Persisted to localStorage keyed by the +// instance's current updated_at — so a page refresh remembers what we've +// already seen, and a server restart (which bumps updated_at) properly +// invalidates the stored flag. Cleared on stop/crash/delete via +// resetReadyOnStop(). +const _READY_STORAGE_KEY = "panel.ready.v1"; +function _loadConfirmedReady() { + try { + const raw = sessionStorage.getItem(_READY_STORAGE_KEY) || localStorage.getItem(_READY_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { return {}; } +} +function _saveConfirmedReady(map) { + try { localStorage.setItem(_READY_STORAGE_KEY, JSON.stringify(map)); } catch {} +} +// In-memory map: instance_id -> updated_at timestamp at the moment ready +// was confirmed. Hydrated from localStorage on script load. +const _instanceReadyMap = _loadConfirmedReady(); +// Set-like API kept for the rest of the codebase. +const instanceConfirmedReady = { + has(id) { + if (!_instanceReadyMap[id]) return false; + // If the instance's current updated_at differs from what we stored, + // it's a different run — drop the stale entry. + const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null; + if (inst && inst.updated_at && inst.updated_at !== _instanceReadyMap[id]) { + delete _instanceReadyMap[id]; + _saveConfirmedReady(_instanceReadyMap); + return false; + } + return true; + }, + add(id) { + const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null; + _instanceReadyMap[id] = (inst && inst.updated_at) || String(Date.now()); + _saveConfirmedReady(_instanceReadyMap); + }, + delete(id) { + delete _instanceReadyMap[id]; + _saveConfirmedReady(_instanceReadyMap); + }, +}; +// True when this instance's pill should be allowed to read inst.status +// directly. For non-log-only modules, always. For log-only modules, +// only after we've seen the joinable log line OR the instance has been +// up for >5 minutes (boot lines are long gone from the live stream). +// 5 min covers ARK's 3-6 minute boot; sub-1-minute games already get +// confirmed via live SSE inside that window so the fallback only kicks +// in for "I just opened the dashboard, server has been up for ages". +function isConfirmedReady(i) { + if (!i) return false; + if (!READY_LOG_ONLY.has(i.module_id)) return true; + if (instanceConfirmedReady.has(i.instance_id)) return true; + if (i.status === "running" && i.updated_at) { + const updatedAt = Date.parse(i.updated_at); + if (!isNaN(updatedAt) && (Date.now() - updatedAt) > 5 * 60 * 1000) return true; + } + return false; +} +// On stop / crash / delete, drop the confirmed flag so the next start +// has to re-prove joinability via the log line. +function resetReadyOnStop(id) { instanceConfirmedReady.delete(id); } +// ---- WI-07: manifest-driven module metadata ----------------------------- +// GET /api/modules now carries ready_pattern / appearance / ports / +// browseable_roots straight from each module.yaml (the controller's own +// ./modules copy — no agent round-trip). The helpers below read the API +// payload first (cached in allModules by pollModules) and fall back to +// the legacy inline maps for any module missing the new fields, so +// behavior is unchanged for modules that haven't been migrated. +function _modMeta(moduleID) { + if (!moduleID) return null; + return (allModules || []).find(m => m.id === moduleID) || null; +} +// Compile a manifest ready_pattern string. Two forms, matching how the +// values were moved out of READY_PATTERNS verbatim: +// "/source/flags" — JS regex literal form, used when flags are needed +// "source" — bare regex source, no flags +const _readyReCache = new Map(); +function readyPatternFor(moduleID) { + const meta = _modMeta(moduleID); + const s = meta && meta.ready_pattern; + if (s) { + let re = _readyReCache.get(s); + if (re === undefined) { + const m = /^\/([\s\S]+)\/([a-z]*)$/.exec(s); + try { re = m ? new RegExp(m[1], m[2]) : new RegExp(s); } + catch { re = null; } // malformed manifest regex → inline fallback + _readyReCache.set(s, re); + } + if (re) return re; + } + return READY_PATTERNS[moduleID] || null; +} +// Legacy inline map — FALLBACK ONLY. The source of truth for every +// packaged module moved to its module.yaml `ready_pattern` (WI-07); +// entries here cover modules without a manifest dir (ark, cs2, tf2, +// gmod) and any manifest missing the field. Read via readyPatternFor(). +const READY_PATTERNS = { + "7dtd": /StartGame done|"StartGame" finished|GameStartDone|by Telnet from/i, + "minecraft-java": /Done \([\d.]+s\)! For help/, + // ARK: Survival Ascended — the actually-joinable log line is + // "Server has completed startup and is now advertising for join." + // emitted ~20-60s after the earlier "Server: has successfully + // started!" marker. That earlier line is misleading: the server process + // is up, but BattlEye / Steam auth / world streaming isn't done, and + // RCON won't accept AUTH until this "advertising for join" line lands. + // We dropped the "successfully started" fallback — it was flipping + // cards to RUNNING minutes before the server was actually joinable. + "ark-sa": /Server has completed startup and is now advertising/i, + "empyrion": /Server started listening on port|Dedi is ready|Ready for connections/i, + // Native-Linux bucket. Patterns taken from AMPTemplates' Console.AppReadyRegex + // where available, plus boot-sequence tail lines when no explicit "ready" + // string exists. Verify + tighten on first real boot. + "valheim": /Game server connected|Dedicated server started/i, + "palworld": /Running Palworld dedicated server|Setting breakpad minidump AppID/i, + "factorio": /Hosting game at IP ADDR|changing state from\(CreatingGame\) to\(InGame\)/i, + "satisfactory": /IpNetDriver listening on port \d+|Server startup time elapsed/i, + "barotrauma": /Server started|Listening to \w+ connections/i, + "enshrouded": /\[Session\] 'HostOnline' \(up\)!/i, + "terraria": /Server started|Listening on port \d+/i, + "project-zomboid":/\*+ SERVER STARTED \*+/, + "core-keeper": /Started session with info/i, // verified live 2026-07-14 + "soulmask": /Starting up WSServer|Network connection saved/i, + // V Rising: verified from a live server log on 2026-04-29. The actual + // ready line is "[Server] Startup Completed - Disabling Scene Loading + // Systems" — fires after world streaming + persistence loading finish + // and the server starts accepting clients. Earlier markers (chunk + // streams, PersistenceV2 - Finished Loading) fire mid-boot. + "v-rising": /\[Server\]\s*Startup Completed/i, + "sons-of-the-forest":/Dedicated server loaded|\[Self-Tests\] Please restart the server/i, + // Windrose: UE5 server. Needs to fire when the world is joinable, NOT on + // early boot markers. Two traps this regex explicitly dodges: + // 1. `"InviteCode": "…"` is printed verbatim out of ServerDescription.json + // at tick [0] — before anything is loaded. Previous version matched + // this and flipped "ready" within 1s of click. + // 2. `LogGlobalStatus: UEngine::LoadMap Load map complete /Game/Maps/Lobby/ + // R5ServerLobby` fires at tick [0] too (the lobby map loads instantly + // on a warm start). We need the Genlandia / main-map complete line + // that fires after actual world streaming. + // Preferred signal: "Host server is ready for owner to connect" — the + // CoopProxy server's explicit ready-to-join log line, fires after world + // init. Secondary: a Load-map-complete for any path EXCEPT /Lobby/. + "windrose": /Host server is ready for owner to connect|LogGlobalStatus:\s*UEngine::LoadMap\s+Load\s+map\s+complete\s+\/Game\/Maps\/(?!Lobby)/i, + // Conan Exiles: AMP's AppReadyRegex — fires when match state flips to + // InProgress, meaning players can actually join. + "conan-exiles": /LogGameState: Match State Changed from WaitingToStart to InProgress/i, + // Rust: Facepunch's well-known "Server startup complete" after world gen. + "rust": /Server startup complete|Listening on 0\.0\.0\.0:28015/i, + // Dragonwilds: UE5 server, no canonical ready string documented. Match + // broad UE5 startup markers + map-complete; tighten after first real boot. + "dragonwilds": /LogInit: Display: Engine is initialized|LogGlobalStatus:\s*UEngine::LoadMap\s+Load\s+map\s+complete|LogLoad: Game Engine Initialized|LogNet: GameNetDriver/i, + // DayZ: Bohemia's BattlEye-protected server. Common markers at end of + // mission init + when the persistence DB finishes loading. + "dayz": /Mission read from bank|BattlEye Server: Initialized|Server World: Initialized|NetServer: (:\d+)?started|Creating a new world/i, + // VEIN: the dedicated server has registered with Steam and is advertising. + // Verified against the live 2026-06 build on Ivy: the canonical line is + // "LogRamjetNetworking: Steamworks server initialized with SteamID " + // (AMP's template regex never fires on this build). Fallback: the + // "Heartbeating with IP :" line that confirms reachability. + "vein": /LogRamjetNetworking: Steamworks server initialized with SteamID \d+|LogRamjetNetworking: Heartbeating with IP /i, +}; +// Modules where the agent's `app_state` event (= RCON poll succeeded) +// is NOT a sufficient ready signal — the RCON port can be open and +// answering queries minutes before the world is actually streamable +// or accepting joins. For these modules, only the log-line regex in +// READY_PATTERNS clears the "starting" overlay. ARK-SA is the +// canonical case: the acekorneya image's RCON binds during early +// boot (often <30s) but the "Server has completed startup and is +// now advertising for join." line lands ~3-6 minutes later. Without +// this gate the card flipped to RUNNING far too soon — every time +// the operator complained. +// +// To extend: add a module id here AND make sure READY_PATTERNS[modId] +// is set to a verified joinable-state log line. +const READY_LOG_ONLY = new Set([ + "ark-sa", + "windrose", // RCON-less; same flag applies + "empyrion", // RCON binds during world gen, well before joinable + "conan-exiles", // match-state flip is the canonical signal + "rust", // server "online" but EAC handshake still pending + // 7DTD: container reports running as soon as Unity boots, but the + // game isn't joinable until 30-60s later when "StartGame finished" + // hits stdout. Without this gate the card flipped to RUNNING the + // instant docker said the process was up — operators tried to join + // immediately and got refused. READY_PATTERNS["7dtd"] matches the + // joinable line; the pill rides amber until that fires. + "7dtd", + // V Rising: BootStrap_Server logs "CharacterManager.StartServer" early + // in boot, then a few seconds later RCON answers — but the world isn't + // accepting clients until "Server started" hits stdout. Without this + // gate the pill flipped to RUNNING within ~5s of clicking Start, and + // operators connecting that quickly got "server not responding" timeouts. + "v-rising", +]); +function markStarting(id) { + if (instanceStarting.has(id)) return; + const inst = allInstances.find(x => x.instance_id === id); + const modId = inst && inst.module_id; + const pattern = readyPatternFor(modId); + const logOnly = READY_LOG_ONLY.has(modId); + // Armed-at timestamp — the agent's StreamLogs opens with Tail: "400" + // so a Start always replays the previous run's last ~5s of chatter, + // which typically INCLUDES the ready-regex match from the earlier run + // (e.g. Windrose's "LogGlobalStatus: Load map complete"). Without a + // guard the replay would clear the overlay instantly on restart and + // the card would snap to "running" before the server has actually + // booted. We reject: + // • any log/app_state event whose `at` timestamp predates arming + // • any event received within the first 4s (safety net for events + // that arrive with no timestamp or with client/server clock skew) + const armedAt = Date.now(); + const isStale = (ev) => { + if (Date.now() - armedAt < 4000) return true; + const t = ev && ev.log && ev.log.at; + if (t) { + const evAt = Date.parse(t); + if (!isNaN(evAt) && evAt < armedAt) return true; + } + return false; + }; + const unsub = sseSubscribeInstance(id, ev => { + try { + // app_state — RCON poll succeeded. For most modules this means + // "joinable enough" (player count is reachable, server is up). + // For READY_LOG_ONLY modules, RCON answering does NOT mean the + // world is ready, so we ignore this and wait for the log line. + if (ev.app_state && !isStale(ev) && !logOnly) { clearStarting(id); return; } + if (ev.log && pattern && pattern.test(ev.log.line || "") && !isStale(ev)) { + // Record confirmed-ready here too. This used to be the GLOBAL SSE + // handler's job, but the dashboard stream now excludes log lines — + // this per-instance stream is the only live scanner left. Without + // the add, READY_LOG_ONLY cards would keep pulsing "starting" until + // the 5-minute uptime fallback. + if (logOnly) instanceConfirmedReady.add(id); + clearStarting(id); + return; + } + if (ev.instance_state) { + const s = String(ev.instance_state.status).replace("INSTANCE_STATUS_", "").toLowerCase(); + // Don't clear on the intermediate "stopping" or early "stopped" + // events the agent fires during the brief lifecycle gap of a + // restart — only an actual crash or a settled stop should kill + // the overlay. The 4s stale window above catches those too. + if ((s === "stopped" || s === "crashed") && !isStale(ev)) clearStarting(id); + } + } catch {} + }); + const timer = setTimeout(() => clearStarting(id), 15 * 60 * 1000); + instanceStarting.set(id, { unsub, timer, armedAt, logOnly }); + paintStartingUI(); +} +function clearStarting(id) { + const rec = instanceStarting.get(id); + if (!rec) return; + try { rec.unsub(); } catch {} + clearTimeout(rec.timer); + instanceStarting.delete(id); + paintStartingUI(); + // Full card repaint so the "running" pill can show up without waiting + // for the next poll tick. + if (typeof renderServerCards === "function") renderServerCards(); +} +function paintStartingUI() { + document.querySelectorAll(".card[data-card-id]").forEach(card => { + const id = card.dataset.cardId; + if (!id) return; + const installing = instanceInstalling.has(id); + const starting = instanceStarting.has(id); + const ready = instanceReady.has(id); + const stopping = instanceStopping.has(id); + const deleting = instanceDeleting.has(id); + // For log-only modules: even if the backend says "running", show + // "starting" pulse until we've actually seen the joinable log + // line (or the instance has been up for >10 min). Console is + // the source of truth — ignore docker-running-says-RCON-up. + const inst = allInstances.find(x => x.instance_id === id); + const backendSaysRunning = inst && inst.status === "running"; + const needLogConfirm = inst && READY_LOG_ONLY.has(inst.module_id) && backendSaysRunning && !isConfirmedReady(inst); + // Border pulse — gives clear feedback that the card is in a transient + // state even if the user hasn't looked at the pill. + card.classList.toggle("is-installing", installing); + card.classList.toggle("is-starting", (starting || needLogConfirm) && !installing); + // is-ready is a brief flash when the server first reports joinable — the + // animation itself is ~1.6s and plays once, but we keep the class on for + // the ready-timer window (~4s) so the green glow is visible before the + // normal "running" pill takes over. + card.classList.toggle("is-ready", ready && !installing && !starting); + card.classList.toggle("is-stopping", stopping && !installing && !starting); + card.classList.toggle("is-deleting", deleting); + const pill = card.querySelector("[data-pill]"); + if (!pill) return; + // Priority: deleting > installing > ready > starting > stopping > + // log-confirm-pending. The last beats backend-supplied "running" so + // a click feels immediate even before the agent's event lands AND + // ARK's premature "running" doesn't lie to the operator. + let effective = null; + if (deleting) effective = "deleting"; + else if (installing) effective = "installing"; + else if (ready) effective = "ready"; + else if (starting) effective = "starting"; + else if (stopping) effective = "stopping"; + else if (needLogConfirm) effective = "starting"; + if (effective && pill.dataset.status !== effective) { + // Match updateCard's pill format: .svr-tile-status with a dot + // inside, NOT the old .status-pill class. Mismatched + // class meant the pill was effectively unstyled after click — + // why "starting" only showed up after a full page refresh. + pill.className = "svr-tile-status " + effective; + pill.innerHTML = `${esc(effective)}`; + pill.dataset.status = effective; + } + }); + if (instanceModalState.id) { + const id = instanceModalState.id; + const pill = $("#im-status"); + const inst = allInstances.find(x => x.instance_id === id); + const needLogConfirm = inst && READY_LOG_ONLY.has(inst.module_id) && inst.status === "running" && !isConfirmedReady(inst); + let effective = null; + if (instanceDeleting.has(id)) effective = "deleting"; + else if (instanceInstalling.has(id)) effective = "installing"; + else if (instanceReady.has(id)) effective = "ready"; + else if (instanceStarting.has(id)) effective = "starting"; + else if (instanceStopping.has(id)) effective = "stopping"; + else if (needLogConfirm) effective = "starting"; + if (pill && effective) { pill.className = "status-pill " + effective; pill.textContent = effective; } + const sb = $("#im-start"); if (sb) sb.disabled = !!effective; + // Install banner above the console — visible while installing. + const banner = document.getElementById("im-c-install-banner"); + if (banner) { + const isInstalling = instanceInstalling.has(id); + banner.style.display = isInstalling ? "" : "none"; + if (isInstalling) { + const sub = document.getElementById("im-c-install-step"); + if (sub && (!sub.textContent || sub.textContent === "Preparing…")) { + // Initial copy until the first log line lands. + const detail = (inst && inst.detail) || ""; + sub.textContent = detail && detail !== "installing" ? detail : "Setting up — first log line will appear here as soon as the agent starts the work…"; + } + } + } + const modeEl = $("#im-c-mode"); + if (modeEl) { + // The "im-c-mode" line under the console is the RCON-readiness + // chip. Game-aware copy: log-only modules need a particular log + // line to flip joinable; everything else is ready as soon as + // RCON answers. The class triggers a yellow accent + soft pulse. + const inst = allInstances.find(x => x.instance_id === id); + const modId = inst && inst.module_id; + const logOnly = READY_LOG_ONLY.has(modId); + modeEl.classList.remove("rcon-warn"); + if (instanceInstalling.has(id)) { + modeEl.textContent = "⏳ Installing — downloading game files. ARK / Empyrion / Conan can take 15-30 min on first boot."; + modeEl.classList.add("rcon-warn"); + } else if (instanceStarting.has(id)) { + if (inst && inst.detail && /finishing cluster setup|aligning decorations to (the )?cluster/i.test(inst.detail)) { + // 7DTD cluster auto-provision: a fresh world is being remapped to the + // cluster canonical (one-time auto-restart). Surface it so the operator + // sees what's happening instead of a generic "starting". + modeEl.textContent = "⏳ Finishing cluster setup — aligning this new world's decorations to the cluster. It restarts itself once and goes green when ready; don't restart it."; + } else if (logOnly) { + modeEl.textContent = "⏳ Starting — RCON not ready. Waiting for the joinable log line before flipping to running."; + } else { + modeEl.textContent = "⏳ Starting — RCON not ready. Waiting for the server to answer."; + } + modeEl.classList.add("rcon-warn"); + } else if (inst && inst.status === "running") { + modeEl.textContent = "● Live server output — RCON ready."; + } else if (inst && (inst.status === "stopped" || inst.status === "crashed")) { + modeEl.textContent = "Server is stopped."; + } else { + modeEl.textContent = "Live server output"; + } + } + } +} + +// Kicks off the "installing" overlay. Called right after Create succeeds +// (and by pollInstances when the agent emits detail=installing for operators +// who didn't click Create themselves). +// +// Clear triggers: +// • sidecar updater log says "update complete" (panel-managed install) +// • sidecar updater log says "update failed" / "steamcmd exited N" +// • module's READY_PATTERNS regex matches the live log — this is what +// ends the overlay for image-managed modules (ark-sa, etc.) where +// the container's bundled entrypoint handles both download AND start +// internally; docker-level status=running lands way before the game +// actually binds, so we can only know "really done" from its log. +// • instance_state transitions to crashed — install/start failed +// • 30-min safety timer (large SteamCMD runs like ARK ~17GB take 20+ min). +function markInstalling(id, opts) { + if (instanceInstalling.has(id)) return; + const inst = allInstances.find(x => x.instance_id === id); + const modId = inst && inst.module_id; + const readyPattern = readyPatternFor(modId); + const armedAt = Date.now(); + const autoStart = !!(opts && opts.autoStart); + const unsub = sseSubscribeInstance(id, ev => { + try { + // Instance-state events (lifecycle changes from the agent) + if (ev.instance_state) { + const s = String(ev.instance_state.status).replace("INSTANCE_STATUS_", "").toLowerCase(); + if (s === "crashed") { clearInstalling(id, /*flashReady*/false); return; } + } + if (!ev.log) return; + const line = ev.log.line || ""; + // Updater sidecar signals (panel-managed install path). + if (/update complete/i.test(line)) { clearInstalling(id, /*flashReady*/true); return; } + if (/update failed|steamcmd exited [1-9]|steamcmd: exhausted/i.test(line)) { clearInstalling(id, /*flashReady*/false); return; } + // Module ready-regex (image-managed install path). Same stale-replay + // guard as markStarting: the agent tails the last 400 log lines on + // every stream open, so old "ready" matches would otherwise instantly + // clear this overlay on an agent restart / reload. + if (readyPattern && readyPattern.test(line)) { + if (Date.now() - armedAt < 4000) return; + const t = ev.log.at ? Date.parse(ev.log.at) : NaN; + if (!isNaN(t) && t < armedAt) return; + clearInstalling(id, /*flashReady*/true); + } + } catch {} + }); + // Failsafe: 30 minutes. Large SteamCMD downloads (ARK/Empyrion ~17 GB) + // can run 20+ min on slow links; cap at 30. + const timer = setTimeout(() => clearInstalling(id, false), 30 * 60 * 1000); + instanceInstalling.set(id, { unsub, timer, autoStart }); + paintStartingUI(); +} +function clearInstalling(id, flashReady) { + const rec = instanceInstalling.get(id); + if (!rec) return; + try { rec.unsub(); } catch {} + clearTimeout(rec.timer); + instanceInstalling.delete(id); + if (flashReady) { + const t = setTimeout(() => { instanceReady.delete(id); paintStartingUI(); if (typeof renderServerCards === "function") renderServerCards(); }, 4000); + instanceReady.set(id, t); + // Auto-start handoff: when create-flow armed `autoStart`, the + // moment install completes (and the readyFlash starts) we kick a + // start RPC. Image-managed modules (ARK acekorneya) often start + // themselves from inside the entrypoint, so we tolerate the start + // returning "already running" — backend will respond, we just don't + // surface a redundant error. + if (rec.autoStart) { + // Wait until the agent's instance_state has actually settled to + // "stopped" (= install completed and the container exited the + // updater leg). Some modules go installing → stopped → start + // already on their own; for those we skip our auto-start so we + // don't double-start and trip "container already running" errors. + setTimeout(async () => { + try { + const { instances } = await fetchJSON("/api/instances"); + const cur = instances.find(x => x.instance_id === id); + if (!cur) return; // deleted in the meantime + if (cur.status === "running" || cur.status === "starting") return; + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/start`, { method: "POST" }); + markStarting(id); + pollInstances(); + } catch (err) { + // Don't toast — the user can retry from the modal. The card + // will reflect the actual state on the next poll. + } + }, 1500); + } + } + paintStartingUI(); + if (typeof renderServerCards === "function") renderServerCards(); +} +// doDelete shows a rich confirmation modal that ALWAYS reads the current +// instance from allInstances (via id) — so even if the button's data +// attribute somehow drifted, the modal shows the truth (name, module, +// agent). The type-to-confirm gate prevents muscle-memory mis-deletes. +const _delState = { id: null, btn: null }; +// ================ card-level backup modal ================ +// +// Opened from the 💾 button on each card. Shows a focused list of +// recent backups + "Back up now" + restore actions without the user +// having to open the full instance modal and switch to the Backups +// tab. The full tab still exists for power users. +const _cardBackupState = { id: null }; +async function openCardBackupModal(id) { + const inst = allInstances.find(x => x.instance_id === id); + if (!inst) { toast("Instance not found", "err"); return; } + _cardBackupState.id = id; + $("#cb-instance").textContent = id; + // Per-game backup notes — pulled from the manifest later. For now + // hard-coded copy that mirrors what's in the module manifest's + // `backup.notes` field. TODO: expose `backup.notes` via /api/modules. + const notesByMod = { + "ark-sa": "Saves SavedArks/ + Config/ + Logs/ (~250 MB typical). Game install + Proton runtime are preserved by SteamCMD on next start.", + "minecraft-java": "world + plugins + mods + configs (~50-500 MB typical). Server jar + libraries are fetched fresh on next start.", + }; + $("#cb-notes").textContent = notesByMod[inst.module_id] || "Whole-instance backup. The agent tarballs the entire data directory — could be large depending on the game."; + await refreshCardBackupList(); + showModal("#card-backup-modal"); +} +async function refreshCardBackupList() { + const id = _cardBackupState.id; + const wrap = $("#cb-list"); + if (!id || !wrap) return; + wrap.innerHTML = loadingState("Loading…"); + try { + const { backups } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`); + $("#cb-count").textContent = backups && backups.length ? `${backups.length} backup${backups.length === 1 ? "" : "s"}` : ""; + if (!backups || !backups.length) { + wrap.innerHTML = `
    ${EMPTY_ICONS.disk}
    No backups yetClick "Back up now" to create one
    `; + return; + } + wrap.innerHTML = backups.map(b => ` +
    +
    +
    ${esc(fmtTime(b.created_at))}
    +
    ${esc(b.id)} · ${esc(fmtBytes(b.size_bytes))}
    + ${b.description ? `
    ${esc(b.description)}
    ` : ""} +
    + + +
    `).join(""); + } catch (err) { + wrap.innerHTML = errorState(`Couldn't load backups: ${err.message}`); + } +} +document.addEventListener("DOMContentLoaded", () => { + const cbNow = $("#cb-now"); + const cbRefresh = $("#cb-refresh"); + const cbList = $("#cb-list"); + if (!cbNow) return; + cbNow.addEventListener("click", async () => { + const id = _cardBackupState.id; + if (!id) return; + cbNow.disabled = true; + cbNow.textContent = "Backing up…"; + toast(`Backing up ${id}…`, "ok"); + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ description: "" }), + }); + toast(`Backup complete — ${fmtBytes(r.size_bytes)}`, "ok"); + await refreshCardBackupList(); + } catch (err) { + toast(`Backup failed: ${err.message}`, "err"); + } finally { + cbNow.disabled = false; + cbNow.textContent = "⏺ Backup now"; + } + }); + if (cbRefresh) cbRefresh.addEventListener("click", () => refreshCardBackupList()); + if (cbList) cbList.addEventListener("click", async e => { + const id = _cardBackupState.id; + if (!id) return; + const restoreID = e.target.dataset.cbRestore; + const delID = e.target.dataset.cbDel; + if (restoreID) { + if (!confirm(`Restore "${id}" from backup ${restoreID}?\n\nThe server will be stopped (if running) and its save data will be overwritten with the backup. Game install files are preserved.`)) return; + e.target.disabled = true; + toast(`Restoring ${id}…`, "ok"); + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(restoreID)}/restore`, { method: "POST" }); + toast(`Restored ${fmtBytes(r.bytes_restored)}`, "ok"); + await refreshCardBackupList(); + } catch (err) { + toast(`Restore failed: ${err.message}`, "err"); + } finally { + e.target.disabled = false; + } + } + if (delID) { + if (!confirm("Remove this backup from the list?\nThe tarball file stays on disk; only the panel record is deleted.")) return; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(delID)}`, { method: "DELETE" }); + await refreshCardBackupList(); + } catch (err) { + toast(`Remove failed: ${err.message}`, "err"); + } + } + }); +}); + +async function doDelete(id, btn) { + // Look up live instance from the canonical array. If it isn't there + // (race with poll), abort with a toast — never let the user confirm a + // delete against a missing record. + const inst = allInstances.find(x => x.instance_id === id); + if (!inst) { + toast("Instance not found in list — refresh and try again.", "err"); + return; + } + _delState.id = id; + _delState.btn = btn || null; + // Tile preview (re-uses the same appearance map as cards). + const app = appearanceFor(inst.module_id) || {}; + const tile = $("#del-tile"); + if (tile) { + tile.style.background = app.grad || "var(--card-bg-2)"; + const url = artURLFor(app); + tile.style.backgroundSize = "cover"; + tile.style.backgroundPosition = "center"; + if (url) { + tile.style.backgroundImage = `${app.grad ? app.grad + ", " : ""}url("${url}")`; + tile.textContent = ""; + } else { + tile.style.backgroundImage = app.grad || ""; + tile.textContent = app.emoji || "🎮"; + } + } + // Module display name from allModules if available, else fallback to id. + const mod = allModules.find(m => m.id === inst.module_id); + const moduleLabel = (mod && mod.name) || inst.module_id; + $("#del-name").textContent = id; + $("#del-meta").innerHTML = + `${esc(inst.module_id)} · ${esc(moduleLabel)} · agent ${esc(inst.agent_id)}`; + $("#del-id-hint").textContent = id; + const conf = $("#del-confirm"); + conf.value = ""; + conf.classList.remove("ok"); + // Reset the button's label too — without this, opening the modal AFTER + // a previous confirm leaves "Deleting…" text on the button (which is + // a shared DOM element across modal opens). User reported this exact + // symptom: opened a fresh server's delete modal and saw "Deleting…" + // before they'd confirmed. + const goBtn = $("#del-go"); + goBtn.disabled = true; + goBtn.textContent = "Delete this server"; + $("#del-purge").checked = true; + showModal("#delete-instance-modal"); + setTimeout(() => conf.focus(), 80); +} +// Wire confirm field + go button once on page load. The handler reads +// from _delState which is set fresh every doDelete() call. +document.addEventListener("DOMContentLoaded", () => { + const conf = $("#del-confirm"); + const go = $("#del-go"); + if (!conf || !go) return; // older builds — defensive + conf.addEventListener("input", () => { + const ok = conf.value === _delState.id; + go.disabled = !ok; + conf.classList.toggle("ok", ok); + }); + conf.addEventListener("keydown", e => { + if (e.key === "Enter" && !go.disabled) { e.preventDefault(); go.click(); } + }); + go.addEventListener("click", async () => { + const id = _delState.id; + if (!id || conf.value !== id) return; + const purge = !!$("#del-purge").checked; + go.disabled = true; + go.textContent = "Deleting…"; + instanceDeleting.add(id); + paintStartingUI(); + // Auto-promote orphans to force-delete: if this instance's agent + // isn't in allAgents, the normal delete would 404 with "agent_offline". + // We already showed the user the orphaned pane explaining the agent + // is gone, so the second confirmation isn't needed — go straight to + // ?force=true to scrub the DB row. + const inst = allInstances.find(x => x.instance_id === id); + const isOrphan = inst && !allAgents.some(a => a.agent_id === inst.agent_id); + const qs = isOrphan ? `?force=true&purge=${purge}` : `?purge=${purge}`; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}${qs}`, { method: "DELETE" }); + toast(`Deleting ${id}…`, "ok"); + hideModal("#delete-instance-modal"); + go.disabled = false; + go.textContent = "Delete this server"; + // Optimistic remove: drop the row from allInstances NOW so the + // card visually disappears within a second instead of sitting + // on "Deleting…" while the backend's stop+rm+volume-rm chain + // runs (30-90s for a SteamCMD-mid-download delete). The id + // joins instanceLocallyDeleted — the next pollInstances filters + // it out so the row can't pop back, and the create-server form + // refuses to reuse it until the backend confirms it's gone. + instanceLocallyDeleted.add(id); + const idx = allInstances.findIndex(x => x.instance_id === id); + if (idx >= 0) { + allInstances.splice(idx, 1); + instanceDeleting.delete(id); + instanceStarting.delete(id); + instanceStopping.delete(id); + instanceInstalling.delete(id); + renderServerCards(); + refreshHeroStats(); + } + // Background watcher: confirms the DB row really does disappear. + (async () => { + const deadline = Date.now() + 120000; + let confirmed = false; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 2500)); + try { + const { instances } = await fetchJSON("/api/instances"); + if (!instances.find(x => x.instance_id === id)) { confirmed = true; break; } + } catch {} + } + instanceLocallyDeleted.delete(id); // cleared either way + if (confirmed) toast(`Deleted ${id}`, "ok"); + else toast(`${id} delete still in progress on the agent — recreate blocked until it finishes`, "err", 9000); + pollInstances(); + })(); + } catch (err) { + toast(`Delete failed: ${err.message}`, "err"); + instanceDeleting.delete(id); + paintStartingUI(); + go.disabled = false; + go.textContent = "Delete this server"; + } + }); +}); + +// ================ create server ================ +const INSTANCE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$/; +function slugifyInstanceID(s) { + // Replace anything that isn't a valid char with "-", collapse runs of "-", + // strip a leading non-alphanumeric, and truncate to 48 chars. + return s.trim().toLowerCase() + .replace(/[^a-z0-9_.\-]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^[^a-z0-9]+/, "") + .slice(0, 48); +} +$("#new-server").addEventListener("click", async () => { + // Reset the form fields each time so it feels fresh. + $("#f-display").value = ""; + $("#f-instance").value = ""; + $("#f-id-preview").textContent = "—"; + $("#f-id-edit-row").style.display = "none"; + // Refresh ARK cluster list so the dropdown shows current state — and + // appears at all. Cached on window so successive opens don't re-fetch. + try { + const r = await fetchJSON("/api/ark/clusters"); + window._arkClustersForCreate = r.clusters || []; + } catch { window._arkClustersForCreate = []; } + try { + const r = await fetchJSON("/api/7dtd/clusters"); + window._sdtdClustersForCreate = r.clusters || []; + } catch { window._sdtdClustersForCreate = []; } + // Re-render extras for whatever module is currently selected (if any). + // Default state with no module = no extras. + renderModuleCreateOptions($("#f-module").value || ""); + showModal("#new-server-modal"); + setTimeout(() => $("#f-display").focus(), 80); +}); + +// The friendly "Display name" field — user types naturally (spaces, +// caps, whatever), we auto-mirror a valid server ID into the preview + +// the hidden #f-instance field. They can click "edit manually" to +// override the id if they really want something specific. +const manualIDState = { overridden: false }; +$("#f-display").addEventListener("input", e => { + if (manualIDState.overridden) return; + const slug = slugifyInstanceID(e.target.value); + $("#f-instance").value = slug; + const prev = $("#f-id-preview"); + prev.textContent = slug || "—"; + prev.style.color = slug ? "" : "var(--muted)"; +}); +$("#f-edit-id").addEventListener("click", () => { + manualIDState.overridden = true; + $("#f-id-edit-row").style.display = ""; + $("#f-edit-id").textContent = "auto from name"; + $("#f-edit-id").onclick = () => { + manualIDState.overridden = false; + $("#f-id-edit-row").style.display = "none"; + $("#f-edit-id").textContent = "edit manually"; + // Re-mirror from display name. + $("#f-display").dispatchEvent(new Event("input")); + }; + setTimeout(() => $("#f-instance").focus(), 50); +}); +$("#f-instance").addEventListener("input", e => { + // When in manual-override mode, still slug the manual input so the + // preview is always what gets sent. + const slug = slugifyInstanceID(e.target.value); + $("#f-id-preview").textContent = slug || "—"; +}); +$("#f-instance").addEventListener("blur", e => { + e.target.value = slugifyInstanceID(e.target.value); + $("#f-id-preview").textContent = e.target.value || "—"; +}); + +// Game ids whose first update_provider requires_steam_login. The +// canonical source is the module manifest (`update_providers[0].requires_steam_login`), +// but we mirror it here for the create-flow toast — checking the +// manifest server-side would add a round-trip and the list is small +// + slow-changing. Defined BEFORE the create handler so the `const` +// is in scope when the handler executes. +const STEAM_LOGIN_REQUIRED = new Set(["dayz"]); + +$("#create-btn").addEventListener("click", async (ev) => { + const btn = ev.currentTarget; + const agent = $("#f-agent").value, module = $("#f-module").value; + let inst = slugifyInstanceID($("#f-instance").value || $("#f-display").value); + $("#f-instance").value = inst; + $("#f-id-preview").textContent = inst || "—"; + if (!agent) { toast("Pick an agent first", "err"); return; } + if (!module) { toast("Pick a game", "err"); return; } + if (!inst) { toast("Give the server a name", "err"); $("#f-display").focus(); return; } + if (!INSTANCE_ID_RE.test(inst)) { toast("Server ID has invalid characters", "err"); return; } + // Block reuse of an id whose delete the operator just kicked off but + // the agent hasn't finished tearing down yet. The container/volumes + // still exist — recreating now would race and crash. Tell the user + // to wait or pick a different name. + if (instanceLocallyDeleted.has(inst)) { + toast(`"${inst}" is still being deleted — pick a different name or wait ~30s`, "err", 8000); + return; + } + // Defensive: if a previous per-instance modal is somehow still open + // (user left it underneath the new-server-modal), force-close it + // BEFORE the create. Without this, if our auto-open later returns + // early (e.g. allInstances hasn't refreshed yet), the user is left + // staring at the OLD modal's console and reasonably assumes the + // create dropped them into the wrong instance. + if (instanceModalState.id) { + try { hideModal("#instance-modal"); } catch {} + } + + // Snapshot module-specific create options (e.g. ARK map). Map values + // become initial config_values that the controller persists + forwards + // to the agent. Empty object on modules with no extras — the field + // omits via omitempty on the backend. + const extraConfig = readModuleCreateOptions(); + // Underscore-prefixed keys are sentinel fields the controller knows + // about as DEDICATED request fields, not as config_values. Strip + // them from the env-bound map and shape the payload accordingly. + const arkClusterID = extraConfig["_ark_cluster_id"] || ""; + delete extraConfig["_ark_cluster_id"]; + const sdtdClusterID = extraConfig["_seven_dtd_cluster_id"] || ""; + delete extraConfig["_seven_dtd_cluster_id"]; + const sdtdSeedMods = (extraConfig["_seven_dtd_seed_mods"] === "true"); + delete extraConfig["_seven_dtd_seed_mods"]; + const installRefugebot = (extraConfig["_install_refugebot"] === "true"); + delete extraConfig["_install_refugebot"]; + + await runAction(btn, async () => { + await fetchJSON("/api/instances", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ + agent_id: agent, + instance_id: inst, + module_id: module, + config_values: extraConfig, + ark_cluster_id: arkClusterID, + seven_dtd_cluster_id: sdtdClusterID, + seven_dtd_seed_mods: sdtdSeedMods, + }), + }); + // For Steam-required modules, the agent's auto-update is skipped + // (no creds yet) and auto-start is suppressed too. There's no real + // install in flight, so showing the "installing" pulse is a lie. + // Instead jump straight to opening the modal with the Steam banner. + // For all other modules, kick the installing pill + chain auto-start + // so the create flow lands on a running server in one gesture. + if (!STEAM_LOGIN_REQUIRED.has(module)) { + // 7DTD is created stopped (auto_start_on_create=false) so the operator can + // finish world / cluster setup before booting it — don't chain a frontend + // auto-start for it (the agent suppresses its own auto-start too). + markInstalling(inst, { autoStart: module !== "7dtd" }); + } + // Await pollInstances so allInstances actually has the new row + // before we try to open it. Without the await, the auto-open ran + // at +800ms while pollInstances was still in flight, openInstanceModal + // returned early because the instance wasn't in the array, AND the + // previous instance modal sometimes flickered with stale state. + await pollInstances(); + setTimeout(() => hideModal("#new-server-modal"), 400); + // Then open with up-to-3s wait for the new row to actually be in + // allInstances. Belt-and-suspenders: even if pollInstances above + // finished before the controller's UpsertInstance lit up, we'll + // catch it on the second tick. + (async () => { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + if (allInstances.find(x => x.instance_id === inst)) break; + await new Promise(r => setTimeout(r, 200)); + // Re-fetch in case the periodic 2s poll hasn't run yet. + try { await pollInstances(); } catch {} + } + try { openInstanceModal(inst); } catch {} + try { maybePromptSteamLoginAfterCreate(inst, module); } catch {} + // Post-create mod install. The instance's volumes have to exist + // before FsWrite lands files in them, so wait until status is + // past "creating" first. installModWhenReady polls + retries. + if (installRefugebot) installModWhenReady(inst, "refugebot"); + })(); + }, { loadingLabel: "Creating", successLabel: "Created ✓", errorLabel: "Create failed" }); +}); + +// installModWhenReady — polls the instance until status leaves +// "creating", then POSTs /install-mod. Used both by the create-form +// "install RefugeBot" checkbox path and by the manual install button +// in the Settings tab. Surfaces toasts so the operator sees progress. +async function installModWhenReady(instanceID, modID) { + toast(`${modID} install queued…`, "ok"); + const deadline = Date.now() + 60000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 1500)); + try { + const { instances } = await fetchJSON("/api/instances"); + const cur = (instances || []).find(x => x.instance_id === instanceID); + if (cur && cur.status !== "creating") break; + } catch {} + } + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/install-mod`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ mod_id: modID }), + }); + toast(`${modID} installed: ${r.files} files (${fmtBytes(r.bytes)}). ${r.restart_after ? "Restart the server to load." : ""}`, "ok", 9000); + } catch (err) { + toast(`${modID} install failed: ${err.message}`, "err", 9000); + } +} + +async function maybePromptSteamLoginAfterCreate(instanceID, moduleID) { + if (!STEAM_LOGIN_REQUIRED.has(moduleID)) return; + // Cheap probe — /api/steam/accounts returns a usernames array. Empty + // means no cached creds → operator must sign in before the install + // can fetch from Workshop. + let cached = false; + try { + const r = await fetchJSON("/api/steam/accounts"); + cached = !!(r && Array.isArray(r.usernames) && r.usernames.length > 0); + } catch {} + if (cached) return; + // Big visible toast that nudges the user to Updates → Sign in. 10s + // (longer than default) so the user can read it before it fades. + toast(`${moduleID.toUpperCase()} needs a Steam login to download. Open the Updates tab to sign in — install resumes automatically.`, "warn", 10000); +} + +// ================ schedules ================ +// #new-sched was the top-level "Add schedule" button on the main page. +// Schedules moved entirely into the per-instance modal so the button is +// gone; guard the listener so the module still loads when the element is +// missing. The modal's own "+ Add schedule for this server" button uses +// #im-s-add and is wired elsewhere. +const newSchedBtn = $("#new-sched"); +if (newSchedBtn) newSchedBtn.addEventListener("click", () => showModal("#sched-modal")); +$("#s-when").addEventListener("change", () => { + const v = $("#s-when").value, wrap = $("#s-custom-wrap"); + $("#s-custom-cron").style.display = v === "cron-custom" ? "" : "none"; + $("#s-custom-event").style.display = v === "event-custom" ? "" : "none"; + $("#s-custom-sustain").style.display = v === "event-custom" ? "" : "none"; + wrap.style.display = (v === "cron-custom" || v === "event-custom") ? "block" : "none"; +}); +$("#s-action").addEventListener("change", () => { + const v = $("#s-action").value, wrap = $("#s-action-custom-wrap"); + $("#s-action-rcon").style.display = v === "rcon-custom" ? "" : "none"; + $("#s-action-webhook-url").style.display = v === "webhook-custom" ? "" : "none"; + $("#s-action-webhook-body").style.display = v === "webhook-custom" ? "" : "none"; + // Backup-before option only makes sense when the server is going + // offline as part of the action (stop or restart). The combined + // "instance-maintenance" preset always backs up so we hide the + // explicit toggle for it. + const backupApplies = (v === "instance-restart" || v === "instance-stop"); + $("#s-backup-wrap").style.display = backupApplies ? "" : "none"; + $("#s-backup-label").style.display = backupApplies ? "" : "none"; + if (!backupApplies) { + const cb = $("#s-backup-before"); + if (cb) cb.checked = false; + } + // Warning cascade only meaningful when the server is going offline AND the + // module has RCON (we don't gate on RCON here — the dispatch logs failures + // and still does the stop, which is the right fallback). + const warnApplies = (v === "instance-restart" || v === "instance-stop" || v === "instance-maintenance"); + $("#s-warn-wrap").style.display = warnApplies ? "flex" : "none"; + $("#s-warn-label").style.display = warnApplies ? "" : "none"; + if (!warnApplies) { + const wb = $("#s-warn-enable"); + if (wb) { wb.checked = false; $("#s-warn-detail").style.display = "none"; } + } + // Show a small note for the maintenance preset describing the full + // sequence and (when the picked module has one) the pre-save command. + let hint = $("#s-maintenance-hint"); + if (v === "instance-maintenance") { + if (!hint) { + hint = document.createElement("div"); + hint.id = "s-maintenance-hint"; + hint.className = "row"; + hint.style.cssText = "grid-column: 1 / -1; padding: 10px 14px; border-radius:var(--radius); background: rgba(95,178,255,.08); border: 1px solid rgba(95,178,255,.28); font-size: 12px; color: var(--text); line-height: 1.55;"; + $("#s-action").parentElement.parentElement.parentElement.insertBefore(hint, $("#s-action-custom-wrap").nextSibling); + } + const inst = allInstances.find(x => x.instance_id === $("#s-instance").value); + const modID = inst && inst.module_id; + const saveAction = modID && (BATCH_GAME_ACTIONS[modID] || []).find(a => a.id === "save"); + const sequence = [ + saveAction ? `${esc(saveAction.rcon)} over RCON (10s for the world to flush)` : null, + `Graceful stop (waits for the agent to confirm the container actually exited — no naive sleep)`, + `Clean backup (saves + configs only, per the module manifest)`, + `Start the server back up`, + ].filter(Boolean).map((s, i) => `
  • ${s}
  • `).join(""); + hint.innerHTML = `🛠 Sequence:
      ${sequence}
    `; + hint.style.display = ""; + } else if (hint) { + hint.style.display = "none"; + } + // Bloodmoon-aware: only for 7dtd stop/restart/maintenance actions. + const inst7 = allInstances.find(x => x.instance_id === $("#s-instance").value); + const is7dtd = inst7 && inst7.module_id === "7dtd"; + const bmApplies = is7dtd && (v === "instance-restart" || v === "instance-stop" || v === "instance-maintenance"); + $("#s-bmaware-wrap").style.display = bmApplies ? "flex" : "none"; + $("#s-bmaware-label").style.display = bmApplies ? "" : "none"; + wrap.style.display = (v === "rcon-custom" || v === "webhook-custom") ? "block" : "none"; +}); +// When the operator changes the target instance, re-evaluate the +// bloodmoon-aware visibility against the new module_id. +$("#s-instance") && $("#s-instance").addEventListener("change", () => { + const v = $("#s-action").value; + const inst7 = allInstances.find(x => x.instance_id === $("#s-instance").value); + const is7dtd = inst7 && inst7.module_id === "7dtd"; + const bmApplies = is7dtd && (v === "instance-restart" || v === "instance-stop" || v === "instance-maintenance"); + $("#s-bmaware-wrap").style.display = bmApplies ? "flex" : "none"; + $("#s-bmaware-label").style.display = bmApplies ? "" : "none"; +}); +// Reveal the warning detail block when the toggle flips. +const _warnToggle = $("#s-warn-enable"); +if (_warnToggle) { + _warnToggle.addEventListener("change", () => { + $("#s-warn-detail").style.display = _warnToggle.checked ? "" : "none"; + }); +} + +// shiftCronEarlier subtracts `mins` minutes from a 6-field robfig cron spec. +// We only touch the minute field; if it's a literal int (the common case for +// our preset "Every 6 hours" = "0 0 */6 * * *") we wrap-around mod 60 and +// also shift the hour by -1 when we wrap. For step / list / range minute +// fields we wrap the whole expression in a "(orig)-N min" approach: pre-pend +// an "@every"-style note in the description but leave the cron as-is. The +// dispatch's warning loop is independent of how we got there; if the operator +// uses a custom cron the warnings just won't span the gap and that's their +// choice. +function shiftCronEarlier(spec, mins) { + if (!spec || !mins || mins <= 0) return spec; + const parts = spec.trim().split(/\s+/); + if (parts.length !== 6) return spec; // 5-field or unknown — leave alone + const minTok = parts[1], hrTok = parts[2]; + const minNum = parseInt(minTok, 10); + if (isNaN(minNum) || String(minNum) !== minTok) return spec; // not a literal + let m = minNum - mins; + let hrDelta = 0; + while (m < 0) { m += 60; hrDelta += 1; } + parts[1] = String(m); + if (hrDelta > 0) { + const hrNum = parseInt(hrTok, 10); + if (!isNaN(hrNum) && String(hrNum) === hrTok) { + let h = hrNum - hrDelta; + while (h < 0) h += 24; + parts[2] = String(h); + } + // If hour is */6 etc., we leave it alone — the schedule will fire + // mins-minutes-early on every interval, which is what we want. + } + return parts.join(" "); +} + +$("#s-add").addEventListener("click", async () => { + const instance = $("#s-instance").value; + if (!instance) { toast("Pick a server", "err"); return; } + const when = $("#s-when").value, actionSel = $("#s-action").value, grace = parseInt($("#s-grace").value || "30", 10); + let trigger_kind, cron_spec, event_spec, event_sustained_seconds = 0; + if (when === "cron-custom") { trigger_kind = "cron"; cron_spec = $("#s-custom-cron").value.trim(); if (!cron_spec) { toast("Cron spec required", "err"); return; } } + else if (when === "event-custom") { trigger_kind = "event"; event_spec = $("#s-custom-event").value.trim(); event_sustained_seconds = parseInt($("#s-custom-sustain").value||"0",10); if (!event_spec) { toast("CEL expression required","err"); return; } } + else if (when.startsWith("cron:")) { trigger_kind = "cron"; cron_spec = when.slice(5); } + else if (when.startsWith("event:")) { trigger_kind = "event"; const rest = when.slice(6); const i = rest.lastIndexOf(":"); event_spec = rest.slice(0,i); event_sustained_seconds = parseInt(rest.slice(i+1),10)||0; } + let action; + if (actionSel === "rcon-custom") { const cmd = $("#s-action-rcon").value.trim(); if (!cmd) { toast("RCON command required","err"); return; } action = { type:"rcon", command: cmd }; } + else if (actionSel === "webhook-custom") { const url = $("#s-action-webhook-url").value.trim(); if (!url.startsWith("http")) { toast("Enter a valid URL","err"); return; } action = { type:"webhook", url, method:"POST", body: $("#s-action-webhook-body").value.trim() || '{"event":"panel"}' }; } + else if (actionSel.startsWith("rcon:")) action = { type:"rcon", command: actionSel.slice(5) }; + else if (actionSel === "instance-restart"||actionSel === "instance-stop"||actionSel === "instance-start") { + action = { type:"instance", op: actionSel.split("-")[1], grace_seconds: grace }; + // "Clean backup" only applies to stop/restart (server actually goes + // offline). Set when the operator ticked the checkbox. + if ((actionSel === "instance-restart" || actionSel === "instance-stop") && $("#s-backup-before") && $("#s-backup-before").checked) { + action.backup_before_start = true; + } + } + else if (actionSel === "instance-maintenance") { + // Combined "save + stop + backup + start" preset. Always restart-with-backup; + // the only thing that varies per game is whether we have a save command to + // run first. Pulled from BATCH_GAME_ACTIONS so the same source-of-truth + // drives both the dashboard's bulk Save World button and the scheduler. + action = { type:"instance", op:"restart", grace_seconds: grace, backup_before_start: true }; + const inst = allInstances.find(x => x.instance_id === instance); + const modID = inst && inst.module_id; + const saveAction = modID && (BATCH_GAME_ACTIONS[modID] || []).find(a => a.id === "save"); + if (saveAction && saveAction.rcon) { + action.pre_save_rcon = saveAction.rcon; + action.pre_save_delay_sec = 10; + } + } + // Warning cascade: applies to any instance-* action. Shifts cron earlier + // so the actual restart still lands on the displayed time. + const warnEnable = $("#s-warn-enable") && $("#s-warn-enable").checked; + const isInstanceAction = (actionSel === "instance-restart" || actionSel === "instance-stop" || actionSel === "instance-maintenance"); + if (warnEnable && isInstanceAction && action && action.type === "instance") { + const lead = Math.max(1, Math.min(60, parseInt($("#s-warn-lead").value || "15", 10))); + action.warn_lead_minutes = lead; + const tmpl = $("#s-warn-template").value.trim(); + if (tmpl) action.warn_template = tmpl; + const extra = $("#s-warn-extra-template").value.trim(); + if (extra) action.warn_extra_template = extra; + action.warn_extra_minutes = [2, 1]; + if (trigger_kind === "cron") cron_spec = shiftCronEarlier(cron_spec, lead); + } + // Bloodmoon-aware flag: only attach for 7dtd stop/restart/maintenance. + if (isInstanceAction && action && action.type === "instance") { + const inst = allInstances.find(x => x.instance_id === instance); + if (inst && inst.module_id === "7dtd" && $("#s-bmaware") && $("#s-bmaware").checked) { + action.bloodmoon_aware = true; + } + } + const payload = { instance_id: instance, trigger_kind, description: $("#s-desc").value.trim(), action }; + if (trigger_kind === "cron") payload.cron_spec = cron_spec; else { payload.event_spec = event_spec; payload.event_sustained_seconds = event_sustained_seconds; } + try { await fetchJSON("/api/schedules", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(payload) }); toast("Schedule added", "ok"); $("#s-desc").value = ""; hideModal("#sched-modal"); pollSchedules(); } + catch (err) { toast(`Add failed: ${err.message}`, "err"); } +}); + +document.addEventListener("change", async e => { + const t = e.target.dataset.toggle; + if (t) { + const v = e.target.checked ? "enable" : "disable"; + try { await fetchJSON(`/api/schedules/${encodeURIComponent(t)}/${v}`, { method: "POST" }); pollSchedules(); } + catch (err) { toast(v + " failed: " + err.message, "err"); pollSchedules(); } + } +}); + +// ================ pair agent ================ +$("#pair-agent").addEventListener("click", () => { + $("#pair-tok-form").style.display = "block"; + $("#pair-tok-result").style.display = "none"; + showModal("#pair-modal"); +}); +$("#pair-gen").addEventListener("click", async () => { + const desc = $("#pair-desc").value.trim(); + const mins = parseInt($("#pair-expires").value, 10); + try { + const r = await fetchJSON("/api/admin/pair-tokens", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ description: desc, expires_minutes: mins }), + }); + $("#pair-tok-val").textContent = r.token; + $("#pair-tok-exp").textContent = `Expires: ${new Date(r.expires_at).toLocaleString()} (${mins} min)`; + const host = location.hostname + ":8080"; + $("#pair-cmd").textContent = + `# on the new agent host:\n` + + `./agent \\\n` + + ` --pair-token ${r.token} \\\n` + + ` --pair-url http://${host} \\\n` + + ` --agent-id \\\n` + + ` --cert-dir ./data/certs\n\n` + + `# then start it normally (no --insecure, no --pair-token):\n` + + `./agent --agent-id --modules-dir ./modules`; + $("#pair-tok-form").style.display = "none"; + $("#pair-tok-result").style.display = "block"; + } catch (err) { toast("Token failed: " + err.message, "err"); } +}); +$("#pair-close").addEventListener("click", () => hideModal("#pair-modal")); + +// ================ edit agent ================ +function openAgentEdit(id) { + const a = allAgents.find(x => x.agent_id === id); if (!a) return; + $("#ae-id").textContent = id; + $("#ae-label").value = a.label || ""; + $("#ae-notes").value = a.notes || ""; + // Port range — empty inputs = no override, allocator falls back to default. + $("#ae-port-start").value = a.port_range_start ? String(a.port_range_start) : ""; + $("#ae-port-end").value = a.port_range_end ? String(a.port_range_end) : ""; + // LAN IP — used by opnfwd integration as the forward target. The /api/agents + // endpoint surfaces this as `lan_ip`; absent on older controllers, treat as "". + $("#ae-lan-ip").value = a.lan_ip || ""; + $("#ae-save").dataset.id = id; + showModal("#agent-edit-modal"); +} +$("#ae-port-clear").addEventListener("click", () => { + $("#ae-port-start").value = ""; + $("#ae-port-end").value = ""; +}); +$("#ae-save").addEventListener("click", async () => { + const id = $("#ae-save").dataset.id; + // Validate port range — both empty = clear, or both set with start<=end. + const sRaw = $("#ae-port-start").value.trim(); + const eRaw = $("#ae-port-end").value.trim(); + let portStart = 0, portEnd = 0; + if (sRaw || eRaw) { + portStart = parseInt(sRaw || "0", 10) | 0; + portEnd = parseInt(eRaw || "0", 10) | 0; + if (!portStart || !portEnd || portStart < 1 || portEnd > 65535 || portStart > portEnd) { + toast("Port range must be 1-65535 with start ≤ end (or both empty for default)", "err"); + return; + } + } + try { + await fetchJSON(`/api/agents/${encodeURIComponent(id)}`, { + method: "PATCH", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ + label: $("#ae-label").value, + notes: $("#ae-notes").value, + port_range_start: portStart, + port_range_end: portEnd, + }), + }); + // LAN IP is a separate endpoint (its own validator). Fire only if changed + // or being cleared — saves a no-op DB write on every Save click. + const lanIP = $("#ae-lan-ip").value.trim(); + const a = allAgents.find(x => x.agent_id === id); + const priorLAN = (a && a.lan_ip) || ""; + if (lanIP !== priorLAN) { + try { + await fetchJSON(`/api/agents/${encodeURIComponent(id)}/lan-ip`, { + method: "PATCH", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ lan_ip: lanIP }), + }); + } catch (lanErr) { + toast("LAN IP save failed: " + lanErr.message, "err"); + return; + } + } + toast("Agent updated", "ok"); + hideModal("#agent-edit-modal"); + pollAgents(); + } catch (err) { toast("Save failed: " + err.message, "err"); } +}); + +// ================ file helpers ================ +function iconForFile(name) { + const l = name.toLowerCase(); + if (/\.(jar|class)$/.test(l)) return "☕"; + if (/\.(yml|yaml|toml|ini|conf|cfg|properties)$/.test(l)) return "⚙️"; + if (/\.(json|xml)$/.test(l)) return "📋"; + if (/\.(log|txt|md)$/.test(l)) return "📄"; + if (/\.(sh|bat|cmd|py|js|ts)$/.test(l)) return "📜"; + if (/\.(zip|gz|tar|bz2|7z|rar)$/.test(l)) return "📦"; + if (/\.(png|jpg|jpeg|gif|webp|bmp)$/.test(l)) return "🖼️"; + if (/\.(dat|db|sqlite)$/.test(l)) return "💾"; + return "📄"; +} +function looksBinary(s) { + const sample = s.slice(0, 2048); let n = 0; + for (let i = 0; i < sample.length; i++) { const c = sample.charCodeAt(i); if (c === 0) return true; if (c < 9 || (c > 13 && c < 32)) n++; } + return n > sample.length * 0.1; +} +function downloadFile(path, name, content) { + const blob = new Blob([content], { type: "application/octet-stream" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); a.href = url; a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + toast(`Downloaded ${name}`, "ok"); +} + +// ================ editor (file) ================ +async function openFileInEditor(sub, name) { + const id = instanceModalState.id; if (!id) return; + const wirePath = buildFilePath(sub); + try { + const q = new URLSearchParams({ path: wirePath }); + const { content, size } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/read?${q}`); + if (looksBinary(content)) { + if (confirm(`"${name || sub}" looks like a binary file. Download instead of editing?`)) { + downloadFile(sub, name || sub.split("/").pop(), content); return; + } + } + $("#e-path").textContent = wirePath; // show full container path in editor header + $("#e-path").dataset.sub = sub; // remember subpath for save + $("#e-size").textContent = fmtBytes(size) + " · " + content.split("\n").length + " lines"; + $("#e-body").value = content; + // Reset the in-editor search so a stale query/highlight from a prior + // file doesn't carry over into the new one. + const _es = $("#e-search"); if (_es) _es.value = ""; + if (typeof _editorFindCompute === "function") _editorFindCompute(); + if (typeof _editorHideFindHighlight === "function") _editorHideFindHighlight(); + showModal("#editor-modal"); setTimeout(() => $("#e-body").focus(), 50); + } catch (err) { toast(`Open failed: ${err.message}`, "err"); } +} +$("#e-close").addEventListener("click", () => hideModal("#editor-modal")); +$("#e-save").addEventListener("click", async () => { + const id = instanceModalState.id; if (!id) return; + const wirePath = $("#e-path").textContent; + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ path: wirePath, content: $("#e-body").value }) }); + $("#e-size").textContent = fmtBytes(r.bytes) + " · saved"; toast(`Saved ${wirePath.split("/").pop()}`, "ok"); loadInstanceFiles(); + } catch (err) { toast("Save failed: " + err.message, "err"); } +}); +$("#e-download").addEventListener("click", () => { const path = $("#e-path").textContent; downloadFile(path, path.split("/").pop(), $("#e-body").value); }); +document.addEventListener("keydown", e => { + if ((e.ctrlKey || e.metaKey) && e.key === "s" && $("#editor-modal").style.display === "flex") { e.preventDefault(); $("#e-save").click(); } + // Ctrl/Cmd+F focuses the in-editor search when the modal's open. We + // suppress the browser's native find dialog because it doesn't know + // about the textarea's content layout — Ctrl+G / F3 also route through. + if ((e.ctrlKey || e.metaKey) && (e.key === "f" || e.key === "F") && $("#editor-modal").style.display === "flex") { + e.preventDefault(); + const sb = $("#e-search"); if (sb) { sb.focus(); sb.select(); } + } +}); + +// =============== EDITOR FIND (in-textarea search) =============== +// Scans for occurrences of the query and on prev/next does: +// 1. Smooth-scroll the textarea so the matched line lands ~30% from the top. +// 2. Drop an animated highlight overlay div on the matched line — +// RIDES the scroll via a scroll listener so the highlight stays glued +// to the line whether the user navigates or scrolls manually. +// 3. Keep focus in the search input the whole time. We deliberately do +// NOT call ta.focus() / setSelectionRange — those caused stray +// keystrokes to overwrite selected matches in the textarea. +const _editorFind = { matches: [], idx: -1, q: "", currentLine: -1, scrollHooked: false }; + +function _editorFindCompute() { + const ta = $("#e-body"); + const inp = $("#e-search"); + const cnt = $("#e-search-count"); + const prev = $("#e-search-prev"); + const next = $("#e-search-next"); + if (!ta || !inp || !cnt) return; + const q = inp.value; + _editorFind.q = q; + if (!q) { + _editorFind.matches = []; _editorFind.idx = -1; + cnt.textContent = "—"; cnt.className = "es-count"; + prev.disabled = true; next.disabled = true; + _editorHideFindHighlight(); + return; + } + const hay = ta.value.toLowerCase(); + const needle = q.toLowerCase(); + const matches = []; + let from = 0; + while (from <= hay.length - needle.length) { + const at = hay.indexOf(needle, from); + if (at < 0) break; + matches.push({ start: at, end: at + needle.length }); + from = at + Math.max(1, needle.length); + } + _editorFind.matches = matches; + if (!matches.length) { + _editorFind.idx = -1; + cnt.textContent = "0/0"; cnt.className = "es-count no-match"; + prev.disabled = true; next.disabled = true; + _editorHideFindHighlight(); + return; + } + // First match — don't auto-jump (and don't pretend cursor matters; we + // never moved the cursor). User pages with Enter / ↓. + _editorFind.idx = -1; + cnt.textContent = `0/${matches.length}`; + cnt.className = "es-count has-match"; + prev.disabled = false; next.disabled = false; +} + +function _editorFindGoto(delta) { + const ta = $("#e-body"); + const cnt = $("#e-search-count"); + if (!_editorFind.matches.length) return; + let idx = _editorFind.idx + delta; + if (idx < 0) idx = _editorFind.matches.length - 1; + if (idx >= _editorFind.matches.length) idx = 0; + _editorFind.idx = idx; + const m = _editorFind.matches[idx]; + const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 21; + const padTop = parseFloat(getComputedStyle(ta).paddingTop) || 0; + const linesBefore = ta.value.slice(0, m.start).split("\n").length - 1; + // Place the match ~30% from the top of the visible area for context. + const targetLineY = padTop + linesBefore * lineHeight; + const margin = ta.clientHeight * 0.30; + const desiredScroll = Math.max(0, targetLineY - margin); + // Smooth scroll. The highlight tracks scrollTop via a listener attached + // below so it stays on the right line through the animation. + ta.scrollTo({ top: desiredScroll, behavior: "smooth" }); + _editorShowFindHighlight(linesBefore); + cnt.textContent = `${idx + 1}/${_editorFind.matches.length}`; + cnt.className = "es-count has-match"; +} + +function _editorShowFindHighlight(lineNum) { + const ta = $("#e-body"); + if (!ta) return; + const wrap = ta.parentElement; + let hl = wrap.querySelector(".editor-find-hl"); + if (!hl) { + hl = document.createElement("div"); + hl.className = "editor-find-hl"; + wrap.appendChild(hl); + } + _editorFind.currentLine = lineNum; + // Attach scroll listener once — the highlight follows the textarea's + // scrollTop so it visually rides the matched line during smooth scroll + // AND when the user scrolls the textarea by hand later. + if (!_editorFind.scrollHooked) { + ta.addEventListener("scroll", _editorPositionFindHighlight); + _editorFind.scrollHooked = true; + } + _editorPositionFindHighlight(); + // Restart the entrance pulse on each navigation. + hl.classList.remove("pulse"); + void hl.offsetWidth; // force reflow so the animation can re-trigger + hl.classList.add("pulse"); + hl.style.display = "block"; +} + +function _editorPositionFindHighlight() { + const ta = $("#e-body"); + if (!ta || _editorFind.currentLine < 0) return; + const wrap = ta.parentElement; + const hl = wrap && wrap.querySelector(".editor-find-hl"); + if (!hl) return; + const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 21; + const padTop = parseFloat(getComputedStyle(ta).paddingTop) || 0; + const taTop = ta.offsetTop; + const taLeft = ta.offsetLeft; + const lineY = taTop + padTop + _editorFind.currentLine * lineHeight - ta.scrollTop; + // Hide when the matched line scrolls off-screen. + if (lineY + lineHeight < taTop || lineY > taTop + ta.clientHeight) { + hl.style.display = "none"; + return; + } + hl.style.display = "block"; + hl.style.top = lineY + "px"; + hl.style.left = taLeft + "px"; + hl.style.width = ta.clientWidth + "px"; + hl.style.height = lineHeight + "px"; +} + +function _editorHideFindHighlight() { + const ta = $("#e-body"); + if (!ta) return; + const wrap = ta.parentElement; + const hl = wrap && wrap.querySelector(".editor-find-hl"); + if (hl) { hl.style.display = "none"; hl.classList.remove("pulse"); } + _editorFind.currentLine = -1; +} + +(() => { + const inp = $("#e-search"); + if (!inp) return; + inp.addEventListener("input", () => { + _editorFindCompute(); + }); + inp.addEventListener("keydown", e => { + // Always preventDefault on Enter so the textarea (or any parent form) + // never receives the keystroke. Stay focused in the search box — + // earlier we focused the textarea, which let stray typing replace + // the selected match and corrupt the file. + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + _editorFindGoto(e.shiftKey ? -1 : +1); + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + inp.value = ""; _editorFindCompute(); + } + }); + $("#e-search-prev").addEventListener("click", () => { _editorFindGoto(-1); inp.focus(); }); + $("#e-search-next").addEventListener("click", () => { _editorFindGoto(+1); inp.focus(); }); +})(); + +// ================ INSTANCE MODAL ================ +const instanceModalState = { + id: "", + tab: "console", + path: "", + sseUnsub: null, // unsubscribe fn for the console's shared per-instance SSE + consoleAutoscroll: true, + consoleLines: [], // cache of rendered lines so we can re-render on "Clear" +}; + +function onInstanceModalClose() { + // Tear down the per-instance SSE subscription if still open. + if (instanceModalState.sseUnsub) { try { instanceModalState.sseUnsub(); } catch {} instanceModalState.sseUnsub = null; } + if (instanceModalState.playersTimer) { clearInterval(instanceModalState.playersTimer); instanceModalState.playersTimer = null; } + if (typeof stopDayzModJobPolling === "function") stopDayzModJobPolling(); + if (typeof stopConanModJobPolling === "function") stopConanModJobPolling(); + // EAH pane leftovers: the ban-list countdown interval + the live SSE + // stream must not outlive the modal. + if (window._banTimer) { clearInterval(window._banTimer); window._banTimer = null; } + if (typeof _eahLiveStop === "function") _eahLiveStop(); + // Close the console command drawer + autocomplete. + const drawer = $("#im-c-helpdrawer"); if (drawer) { drawer.classList.remove("on"); drawer.innerHTML = ""; } + if (typeof hideConsoleAutocomplete === "function") hideConsoleAutocomplete(); + // Clear the fatal banner + reset its seen set so a different instance's + // modal doesn't inherit the previous one's banner state. + const fb = $("#im-c-fatal"); if (fb) fb.style.display = "none"; + if (instanceModalState.fatalSeen) instanceModalState.fatalSeen.clear(); + instanceModalState.id = ""; +} + +function openInstanceModal(id, tab) { + const i = allInstances.find(x => x.instance_id === id); if (!i) return; + instanceModalState.id = id; + instanceModalState.path = ""; + instanceModalState.rootPath = ""; // reset so a different module's roots get re-picked + instanceModalState.consoleLines = []; + refreshInstanceModalHead(); + // Deep-link to a specific tab (Console/Files/Settings/…) when the caller + // asks for one — the card's Console/Files buttons use this so they land + // straight on the right surface instead of always opening Console. + switchTab(tab || "console"); + showModal("#instance-modal"); +} + +function refreshInstanceModalHead() { + const id = instanceModalState.id; + const i = allInstances.find(x => x.instance_id === id); if (!i) return; + const app = appearanceFor(i.module_id); + const mod = (allModules.find(m => m.id === i.module_id) || {}); + const agent = i.agent_id || "—"; + // Prefer the bundled square game icon; fall back to the emoji for + // modules that don't have one packaged. The image inherits the + // tile's rounded corners via overflow:hidden on .game-tile. + const tile = $("#im-tile"); + const iconURL = gameIconURLFor(i.module_id); + if (iconURL) { + tile.textContent = ""; + tile.style.background = app.grad; // fallback color while img decodes + tile.dataset.hasIcon = "1"; + let img = tile.querySelector("img"); + if (!img) { + img = document.createElement("img"); + img.alt = ""; + img.draggable = false; + tile.appendChild(img); + } + if (img.getAttribute("src") !== iconURL) img.src = iconURL; + } else { + const old = tile.querySelector("img"); if (old) old.remove(); + tile.textContent = app.emoji; + tile.style.background = app.grad; + tile.dataset.hasIcon = "0"; + } + const head = $("#im-head"), banner = $("#im-banner"); + const bannerURL = artURLFor(app); + if (bannerURL) { + banner.style.backgroundImage = `url("${bannerURL}")`; + head.classList.add("has-banner"); + } else { + banner.style.backgroundImage = ""; + head.classList.remove("has-banner"); + } + $("#im-title").textContent = id; + $("#im-sub").textContent = `${mod.name || i.module_id} · on agent ${agent}`; + // Live header stats — players / CPU / RAM / uptime. Each chip hides + // itself when its datum is unavailable (e.g. stopped server, no sample + // yet) so we never show "— players". Reuses the same latestStats / + // latestAppState feeds the cards use. + { + const dead = i.status === "stopped" || i.status === "crashed"; + const st = dead ? {} : (latestStats[i.instance_id] || {}); + const as = dead ? {} : (latestAppState[i.instance_id] || {}); + const setHStat = (key, valEl, val) => { + const chip = document.querySelector(`#im-head-stats [data-hstat="${key}"]`); + const ve = $(valEl); + if (val == null) { if (chip) chip.hidden = true; return; } + if (chip) chip.hidden = false; + if (ve && ve.textContent !== String(val)) ve.textContent = String(val); + }; + const players = (as.players_online != null && as.players_max != null) ? `${as.players_online}/${as.players_max}` : null; + setHStat("players", "#im-hs-players", players); + setHStat("cpu", "#im-hs-cpu", st.cpu != null ? fmtCpu(st).long : null); + setHStat("ram", "#im-hs-ram", st.memMB != null ? (st.memMB >= 1024 ? `${(st.memMB/1024).toFixed(1)}G` : `${st.memMB.toFixed(0)}M`) : null); + let upTxt = null; + if (i.status === "running" && i.updated_at) { + const t = Date.parse(i.updated_at); + if (!isNaN(t)) upTxt = _fmtCompactDuration(Date.now() - t); + } + setHStat("uptime", "#im-hs-uptime", upTxt); + } + const pill = $("#im-status"); + const isInstalling = instanceInstalling.has(i.instance_id); + const isReadyFlash = instanceReady.has(i.instance_id); + const isStarting = instanceStarting.has(i.instance_id); + const isStopping = instanceStopping.has(i.instance_id); + const isDeleting = instanceDeleting.has(i.instance_id); + // Same log-confirm override as the cards: docker-running is not + // joinable for ARK / Empyrion / Conan / windrose / rust. Wait for + // the actual ready log line (or the long-uptime fallback) before + // letting the modal pill go green. + const needLogConfirm = READY_LOG_ONLY.has(i.module_id) && i.status === "running" && !isConfirmedReady(i); + let effectiveStatus = i.status || ""; + if (isDeleting) effectiveStatus = "deleting"; + else if (isInstalling) effectiveStatus = "installing"; + else if (isReadyFlash) effectiveStatus = "ready"; + else if (isStarting) effectiveStatus = "starting"; + else if (isStopping) effectiveStatus = "stopping"; + else if (needLogConfirm) effectiveStatus = "starting"; + pill.className = "status-pill " + effectiveStatus; + pill.textContent = effectiveStatus || "—"; + const isRunning = i.status === "running"; + const transient = isInstalling || isStarting || isStopping || isDeleting; + const needsSteam = (i.detail === "needs_steam_login"); + // While "starting" we intentionally leave the Stop button visible so + // the user can cancel a misbehaving boot even before it's fully up. + // While "installing" we hide Start (you can't launch a half-downloaded + // install) but leave Stop so operators can cancel the install by + // stopping the container. needs_steam_login: Start is hidden — it + // would just bounce the empty container; user must Update first. + $("#im-start").style.display = (isRunning || transient || needsSteam) ? "none" : ""; + $("#im-stop").style.display = (isRunning || transient) ? "" : "none"; + $("#im-start").disabled = transient || needsSteam; + $("#im-stop").disabled = isStopping || isDeleting; + $("#im-restart").disabled = !isRunning || transient; + $("#im-c-cmd").disabled = !isRunning; + $("#im-c-send").disabled = !isRunning; + // Steam-login banner — surface on top of the modal head when the + // instance was created with a Steam-required update_provider but + // creds aren't cached yet. Click the banner to jump to the Updates + // tab where the sign-in lives. Operators reported DayZ creates "did + // nothing"; this makes it explicit. + let steamBanner = $("#im-steam-banner"); + if (needsSteam) { + if (!steamBanner) { + steamBanner = document.createElement("div"); + steamBanner.id = "im-steam-banner"; + steamBanner.className = "im-steam-banner"; + steamBanner.innerHTML = `🔒Sign in to Steam to install — this game's downloads need a Steam account that owns the title. `; + const body = document.querySelector(".im-body"); + if (body) body.parentNode.insertBefore(steamBanner, body); + const go = $("#im-steam-banner-go"); + if (go) go.addEventListener("click", e => { e.stopPropagation(); switchTab("updates"); }); + } + steamBanner.style.display = ""; + } else if (steamBanner) { + steamBanner.style.display = "none"; + } + if (isDeleting) { + $("#im-c-mode").textContent = "Deleting — container removal in flight."; + } else if (isInstalling) { + $("#im-c-mode").textContent = "Downloading game files — this can take a while on first boot."; + } else if (isStarting) { + $("#im-c-mode").textContent = "Starting the game server — waiting for it to report ready…"; + } else if (isStopping) { + $("#im-c-mode").textContent = "Stopping the server — graceful shutdown in flight."; + } else if (isRunning) { + $("#im-c-mode").textContent = "● Live server output — RCON ready."; + } else if (needsSteam) { + $("#im-c-mode").textContent = "Server is stopped — sign in to Steam + click Update to install."; + } else { + $("#im-c-mode").textContent = "Server is stopped."; + } + // DayZ-only "XML Integration" tab visibility. + const dayzMod = i.module_id === "dayz"; + $$(".im-tab-dayz").forEach(b => b.style.display = dayzMod ? "" : "none"); + // ARK-only "Cluster" tab visibility. + const arkMod = i.module_id === "ark-sa"; + $$(".im-tab-ark").forEach(b => b.style.display = arkMod ? "" : "none"); + // 7DTD-only "Mods" tab visibility. + const sdtdMod = i.module_id === "7dtd"; + $$(".im-tab-7dtd").forEach(b => b.style.display = sdtdMod ? "" : "none"); + // Conan-only "Mods" tab visibility. + const conanMod = i.module_id === "conan-exiles"; + $$(".im-tab-conan").forEach(b => b.style.display = conanMod ? "" : "none"); + // Empyrion-only "EAH" tab visibility. + const empyrionMod = i.module_id === "empyrion"; + $$(".im-tab-empyrion").forEach(b => b.style.display = empyrionMod ? "" : "none"); + // Divider before the game-specific tab group — show only when this + // module actually contributes any game tabs. + const hasGameTabs = dayzMod || arkMod || sdtdMod || conanMod || empyrionMod; + $$(".im-tab-divider.im-tab-game").forEach(d => d.style.display = hasGameTabs ? "" : "none"); + // 7DTD bloodmoon indicator: poll the PanelBloodMoonAgent status file + // when the modal is open on a 7dtd instance. Status file is written + // every 5s by the mod; we re-fetch every 15s while the modal is up. + refreshBloodMoonPill(i); +} + +// refreshBloodMoonPill reads /game-saves/.panel-bloodmoon-status.json +// via the existing files-read API and shows a small "BLOOD MOON" pill +// next to the status pill when active:true. Silent no-op for non-7DTD +// instances or when the mod hasn't written a file yet (older builds, +// instance stopped, etc.). +async function refreshBloodMoonPill(inst) { + const pill = $("#im-bloodmoon"); + if (!pill) return; + if (!inst || inst.module_id !== "7dtd" || inst.status !== "running") { + pill.style.display = "none"; + return; + } + try { + // Absolute path → safeJoinAny matches it to the /game-saves browseable + // root for 7dtd module. Endpoint returns {path, content (string), size}. + const url = `/api/instances/${encodeURIComponent(inst.instance_id)}/files/read?path=` + + encodeURIComponent("/game-saves/.panel-bloodmoon-status.json"); + const r = await fetch(url, { credentials: "same-origin" }); + if (!r.ok) { pill.style.display = "none"; return; } + const body = await r.json(); + if (!body || !body.content) { pill.style.display = "none"; return; } + const status = JSON.parse(body.content); + if (status && status.active === true) { + const day = status.in_game_day || "?"; + const hr = status.in_game_hour || 0; + const mn = String(status.in_game_minute || 0).padStart(2, "0"); + pill.title = `Blood moon active · day ${day} ${hr}:${mn} · source=${status.source || "?"} · ends_unix=${status.ends_at_unix || 0}`; + pill.style.display = "inline-block"; + } else { + pill.style.display = "none"; + if (status && status.next_horde_day) { + // Optional: leave a faint tooltip on the status pill for next-horde info. + const sp = $("#im-status"); + if (sp) sp.title = `Next blood moon: day ${status.next_horde_day}`; + } + } + } catch { + pill.style.display = "none"; + } +} + +// Re-render modal head when polling refreshes allInstances. +const _renderCards = renderServerCards; +renderServerCards = function() { + _renderCards.apply(this, arguments); + if (instanceModalState.id) refreshInstanceModalHead(); +}; + +function switchTab(tab) { + instanceModalState.tab = tab; + $$(".im-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab)); + $$(".im-panel").forEach(p => p.classList.toggle("active", p.dataset.panel === tab)); + // Lazy-load tab content. + if (tab === "console") ensureConsoleStream(); + else if (tab === "players") loadInstancePlayers(); + else if (tab === "config") loadInstanceConfig(); + else if (tab === "files") loadInstanceFiles(); + else if (tab === "backups") loadInstanceBackups(); + else if (tab === "schedules") loadInstanceSchedules(); + else if (tab === "updates") loadInstanceUpdates(); + else if (tab === "dayzmods") loadDayzModsTab(); + else if (tab === "dayzxml") loadDayzXmlTab(); + else if (tab === "conanmods") loadConanModsTab(); + else if (tab === "arkmods") loadArkModsTab(); + else if (tab === "arkcluster") loadArkClusterTab(); + else if (tab === "arkrestore") loadArkRestoreTab(); + else if (tab === "sdtdmods") loadSdtdModsTab(); + else if (tab === "sdtdcluster") loadSdtdClusterTab(); + else if (tab === "regionhealth") loadRegionHealth(); + else if (tab === "empyrioneah") loadEmpyrionEAHTab(); + else if (tab === "settings") renderInstanceSettings(); + + // Stop the players polling cycle when switching away. + if (tab !== "players" && instanceModalState.playersTimer) { + clearInterval(instanceModalState.playersTimer); + instanceModalState.playersTimer = null; + } +} +$("#im-tabs").addEventListener("click", e => { + const t = e.target.closest(".im-tab"); if (!t) return; + switchTab(t.dataset.tab); +}); + +// --- quick buttons in header +$("#im-start").addEventListener("click", e => { const id = instanceModalState.id; if (id) doInstanceAction(id, "start", e.target); }); +$("#im-stop").addEventListener("click", e => { const id = instanceModalState.id; if (id) doInstanceAction(id, "stop", e.target); }); +$("#im-restart").addEventListener("click", async e => { + const id = instanceModalState.id; if (!id) return; + e.target.disabled = true; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/stop?grace=10`, { method: "POST" }); + toast(`Stopping ${id}…`); + setTimeout(async () => { + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/start`, { method:"POST" }); + toast(`Starting ${id}…`, "ok"); + markStarting(id); + } catch(err) { toast("Restart failed: " + err.message, "err"); } + }, 2500); + } + catch (err) { toast("Restart failed: " + err.message, "err"); } + finally { setTimeout(() => e.target.disabled = false, 1500); } +}); + +// ---------------- CONSOLE tab ---------------- +// Patterns that look like repeating progress updates — we collapse runs of +// these into a single line that updates in place, like a real terminal would. +// Key = the line's "identity" (stable across updates). +function progressKey(text) { + // "Update state (0x61) downloading, progress: 47.95 (...)" → "Update state downloading" + const m1 = text.match(/^\s*Update state \([^)]+\) (\w+), progress:/); + if (m1) return "steamcmd:" + m1[1]; + // "[ 64%] Installing ..." / "Progress: 64%" + if (/^\s*\[\s*\d+%\s*\]/.test(text) || /^\s*Progress:\s*\d/.test(text)) return "progress"; + // LGSM "Starting sdtdserver: " (vinanrra emits many of these per step) + const m2 = text.match(/^\s*Starting sdtdserver: (.+?)$/); + if (m2) return "lgsm-start"; + return null; +} + +// PERF: this used to append + autoscroll PER LINE — and `scrollTop = +// scrollHeight` reads scrollHeight, which forces a synchronous layout. A +// booting server bursts 50-200 lines/sec → that many forced layouts per +// second inside a blurred glass panel = the "2fps console". Lines now +// queue and flush once per animation frame: one fragment append, one +// trim, ONE autoscroll, regardless of burst size. +const _consoleQueue = []; +let _consoleFlushArmed = false; +// ---- Scroll pinning ---- +// The console autoscrolls ONLY while the user is "pinned" to the bottom. +// Scrolling up into history unpins (so streaming stops yanking the view); +// returning to the bottom re-pins automatically. The auto-scroll checkbox +// stays a hard override on top of this. While unpinned, new lines are +// counted into a "N new lines ↓" chip that jumps back to the bottom. +let _consolePinned = true; +let _consoleScrollSquelchUntil = 0; +let _consoleUnseen = 0; +const CONSOLE_MAX_LINES = 2000; +function _consoleTrimTop(box, excess) { + // Bulk trims go through one Range.deleteContents() — a single mutation + // instead of N removeChild layout invalidations. + if (excess > 50) { + const range = document.createRange(); + range.setStartBefore(box.children[0]); + range.setEndAfter(box.children[excess - 1]); + range.deleteContents(); + return; + } + for (let k = 0; k < excess; k++) box.removeChild(box.firstChild); +} +// Timestamp memo: toLocaleTimeString costs ~40µs a call, which adds up to +// ~8ms on a 200-line flush. Log bursts share the same wall-clock second, +// so cache the formatted string per second. +let _tsMemoSec = -1, _tsMemoStr = ""; +function _fmtConsoleTs(ts) { + const ms = ts ? new Date(ts).getTime() : Date.now(); + const sec = Math.floor(ms / 1000); + if (sec !== _tsMemoSec) { + _tsMemoSec = sec; + _tsMemoStr = new Date(sec * 1000).toLocaleTimeString([], { hour12: false }); + } + return _tsMemoStr; +} +// Line skeleton cloned per line — cloneNode(true) + textContent is ~3x +// cheaper than innerHTML parse + esc(), and textContent needs no escaping. +const _consoleLnTpl = (() => { + const d = document.createElement("div"); + d.className = "ln"; + const ts = document.createElement("span"); ts.className = "ts"; + const tx = document.createElement("span"); tx.className = "txt"; + d.appendChild(ts); d.appendChild(tx); + return d; +})(); +// Per-rAF-pass build limits: cap lines AND wall time so a burst never +// stalls a frame; the remainder re-arms rAF and lands next frame(s). +const FLUSH_LINE_CAP = 250; +const FLUSH_MS_BUDGET = 6; +function _updateConsoleNewChip(box) { + let chip = document.getElementById("im-c-newchip"); + if (!chip) { + const wrap = box.closest(".console-wrap"); + if (!wrap) return; + chip = document.createElement("button"); + chip.id = "im-c-newchip"; + chip.type = "button"; + chip.className = "console-newchip"; + chip.addEventListener("click", () => { + const b = $("#im-c-out"); + if (b) _consoleRepin(b); + }); + wrap.appendChild(chip); + } + chip.textContent = `${_consoleUnseen} new line${_consoleUnseen === 1 ? "" : "s"} ↓`; + chip.classList.add("on"); +} +function _hideConsoleNewChip() { + const chip = document.getElementById("im-c-newchip"); + if (chip) chip.classList.remove("on"); +} +// Re-pin: run the trim we deferred while the user was reading scrollback, +// jump to the bottom, clear the chip. overflow-anchor goes back to "none" +// while pinned (we control the scroll position ourselves) and "auto" while +// unpinned (browser scroll anchoring keeps the reading position stable if +// anything above the viewport changes). +function _consoleRepin(box) { + _consolePinned = true; + _consoleUnseen = 0; + box.style.overflowAnchor = "none"; + const excess = box.children.length - CONSOLE_MAX_LINES; + if (excess > 0) _consoleTrimTop(box, excess); + _hideConsoleNewChip(); + _consoleScrollSquelchUntil = performance.now() + 200; + box.scrollTop = box.scrollHeight; + // Belt-and-braces: land the bottom again after layout settles (wrapped + // lines / late paints can grow scrollHeight after the first set). The + // scroll handler treats a not-quite-bottom programmatic scroll as a user + // unpin, so this second pass keeps the chip from flickering back. + requestAnimationFrame(() => { + if (_consolePinned) box.scrollTop = box.scrollHeight; + }); +} +function _resetConsolePinState() { + _consolePinned = true; + _consoleUnseen = 0; + const box = $("#im-c-out"); + if (box) box.style.overflowAnchor = "none"; + _hideConsoleNewChip(); +} +function appendConsoleLine(entry) { + _consoleQueue.push(entry); + // Backstop for hidden tabs (rAF doesn't fire there): keep the queue from + // growing unbounded — the DOM cap would discard the overflow anyway. + if (_consoleQueue.length > 2500) _consoleQueue.splice(0, _consoleQueue.length - 2500); + if (_consoleFlushArmed) return; + _consoleFlushArmed = true; + requestAnimationFrame(_flushConsoleQueue); +} +function _flushConsoleQueue() { + _consoleFlushArmed = false; + const box = $("#im-c-out"); + if (!box) { _consoleQueue.length = 0; return; } + if (!_consoleQueue.length) return; + // Pre-trim: a queue at/above the DOM cap means every existing line will + // be trimmed anyway — drop the queue's own overflow and wipe the box in + // one replaceChildren() instead of building nodes destined for removal. + if (_consoleQueue.length >= CONSOLE_MAX_LINES) { + _consoleQueue.splice(0, _consoleQueue.length - CONSOLE_MAX_LINES); + box.replaceChildren(); + } + const t0 = performance.now(); + const frag = document.createDocumentFragment(); + // Track the effective "last line" across the batch so consecutive + // progress lines coalesce within the queue too, not just across flushes. + let lastEl = box.lastElementChild; + let built = 0, taken = 0; + for (const { kind, text, ts } of _consoleQueue) { + taken++; + const t = _fmtConsoleTs(ts); + const key = kind === "log" ? progressKey(text) : null; + if (key && lastEl && lastEl.dataset.progKey === key) { + lastEl.querySelector(".ts").textContent = t; + lastEl.querySelector(".txt").textContent = text; + continue; + } + const line = _consoleLnTpl.cloneNode(true); + line.className = "ln " + (kind || ""); + if (key) line.dataset.progKey = key; + const prefix = kind === "log" ? "" : ({ cmd: "> ", reply: "", err: "! ", sys: "· " }[kind] || ""); + line.firstChild.textContent = t; + line.lastChild.textContent = prefix + text; + frag.appendChild(line); + lastEl = line; + built++; + // Chunk: stop this pass at the line cap or the frame-time budget; + // the leftover queue re-arms rAF below and continues next frame. + if (built >= FLUSH_LINE_CAP || performance.now() - t0 > FLUSH_MS_BUDGET) break; + } + const appended = taken; + _consoleQueue.splice(0, taken); + if (_consoleQueue.length && !_consoleFlushArmed) { + _consoleFlushArmed = true; + requestAnimationFrame(_flushConsoleQueue); + } + if (frag.childNodes.length) box.appendChild(frag); + if (_consolePinned && instanceModalState.consoleAutoscroll) { + const excess = box.children.length - CONSOLE_MAX_LINES; + if (excess > 0) _consoleTrimTop(box, excess); + _consoleScrollSquelchUntil = performance.now() + 200; + box.scrollTop = box.scrollHeight; + } else { + // Unpinned: the operator is reading scrollback. No autoscroll, and no + // top-trim either — removing nodes above the viewport shifts the text + // under their eyes. The trim is deferred to re-pin. A hard safety cap + // keeps a very long unpinned stream from growing the DOM unbounded; + // overflow-anchor:auto (set on unpin) lets the browser keep the + // reading position stable when that cap trims from the top. + _consoleUnseen += appended; + const hardExcess = box.children.length - CONSOLE_MAX_LINES * 2.5; + if (hardExcess > 0) _consoleTrimTop(box, hardExcess); + _updateConsoleNewChip(box); + } +} +// Wire the pin state to real user scrolling. Programmatic scrollTop sets +// from the flush also fire "scroll", but they land at the bottom, where +// atBottom === _consolePinned and the handler no-ops. +(function () { + const box = $("#im-c-out"); + if (!box) return; + box.style.overflowAnchor = "none"; + box.addEventListener("scroll", () => { + const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 8; + if (atBottom === _consolePinned) return; + if (atBottom) { + _consoleRepin(box); + } else { + // Ignore not-at-bottom events briefly after our own programmatic + // scrolls — mid-layout they can read a pixel short of the bottom + // and would spuriously unpin (the chip-flicker glitch). + if (performance.now() < _consoleScrollSquelchUntil) return; + _consolePinned = false; + box.style.overflowAnchor = "auto"; + } + }); +})(); +// Render one wire event into the console. Log-only: state transitions show +// in the status pill, player events live in the Players tab — the console +// stays a pure server-side log stream with no sys/status chatter. +// FATAL_LOG_PATTERNS catches log lines that explain WHY a server died. +// Pattern → { name (short label), guidance (what the operator should do). +// Surfaced as a sticky red banner above the console + in a toast so the +// crash isn't silent. Each is evaluated against every incoming log line; +// a match arms the banner with that line as evidence. +const FATAL_LOG_PATTERNS = [ + { + re: /FAtlasSaveManager::LoadOperationSql: SQL database is corrupt/i, + label: "ARK save data corrupted", + guidance: "ARK's save SQLite database is corrupt — usually from a SIGKILL during a save write. Recovery: open Settings → Maintenance → Delete (with 'Wipe Docker volumes' checked) → recreate. Any unsaved progress is lost. Backups taken before the crash should restore cleanly.", + }, + { + re: /BattlEye Server: Failed to load|BattlEye Initialization Failed/i, + label: "BattlEye init failed", + guidance: "BattlEye couldn't start under Wine/Proton on this Linux container. ARK SA's BattlEye builds aren't reliable on Linux yet — set BATTLEEYE=FALSE in Config and restart. Players join via direct connect; not visible in BE-protected browser.", + }, + { + re: /AssertionFailed:.*Cluster|CreateASAClustersFolderMutex.*FAILED/i, + label: "ARK cluster mount inaccessible", + guidance: "The cluster folder mount didn't materialize correctly. Open Cluster tab → Leave cluster → re-Join (or Setup Cluster fresh). The shared volume bind needs to recreate.", + }, + { + re: /no space left on device|disk full/i, + label: "Host disk full", + guidance: "The host running this agent is out of disk space. Old container layers, ARK saves, or backup tarballs may be the culprit. SSH in and run `docker system df` to find the biggest consumers.", + }, + { + re: /steamcmd: exhausted|SteamCMD: install failed.*after/i, + label: "SteamCMD install failed", + guidance: "SteamCMD couldn't download game files after multiple retries. Likely a Steam CDN hiccup. Wait 5 min and click Update again — transient errors are auto-retried, but a hard exhaustion needs an operator nudge.", + }, +]; + +function renderConsoleEvent(ev) { + if (ev.log) { + const line = ev.log.line || ""; + appendConsoleLine({ kind: "log", text: line, ts: ev.at }); + // While the install banner is up, mirror the latest pre-container + // log line into the banner's sub-line so the operator can see what + // the agent is doing right now (image build step, SteamCMD progress). + const banner = document.getElementById("im-c-install-banner"); + if (banner && banner.style.display !== "none" && line) { + const sub = document.getElementById("im-c-install-step"); + if (sub) sub.textContent = line.length > 120 ? line.slice(0, 120) + "…" : line; + } + // Fatal-pattern scan — fire ONCE per (instance, pattern) per session. + // Sets a banner inside the console pane and pops a sticky toast so + // the operator doesn't have to scroll the log to find why the server + // is dead. Stored on the modal state so re-opens stay quiet for + // patterns we've already shown. + if (instanceModalState.id === ev.log.instance_id) { + if (!instanceModalState.fatalSeen) instanceModalState.fatalSeen = new Set(); + for (const f of FATAL_LOG_PATTERNS) { + if (f.re.test(line) && !instanceModalState.fatalSeen.has(f.label)) { + instanceModalState.fatalSeen.add(f.label); + showFatalLogBanner(f.label, f.guidance, line); + toast(`${f.label} — see banner above console`, "err", 12000); + } + } + } + return true; + } + return false; +} +// Renders a sticky banner above the console explaining a known-fatal +// log line. Click X to dismiss; auto-clears when console is cleared +// or modal closes (via #im-c-out innerHTML reset). +function showFatalLogBanner(label, guidance, line) { + let banner = $("#im-c-fatal"); + if (!banner) { + banner = document.createElement("div"); + banner.id = "im-c-fatal"; + banner.className = "im-fatal-banner"; + const consoleWrap = document.querySelector(".im-panel[data-panel='console'] .console-wrap"); + const out = $("#im-c-out"); + if (consoleWrap && out) consoleWrap.insertBefore(banner, out); + } + banner.innerHTML = ` + +
    ${esc(label)}
    +
    ${esc(guidance)}
    +
    ${esc(line.length > 200 ? line.slice(0,200)+"…" : line)}
    `; + banner.style.display = ""; + const closeBtn = banner.querySelector(".im-fatal-close"); + if (closeBtn) closeBtn.onclick = () => { banner.style.display = "none"; }; +} + +async function ensureConsoleStream() { + const id = instanceModalState.id; if (!id) return; + // Reset the per-instance stream so we only see this instance's events. + if (instanceModalState.sseUnsub) { try { instanceModalState.sseUnsub(); } catch {} instanceModalState.sseUnsub = null; } + // Reset the rendered buffer so history isn't mixed with the previous + // instance's content. + const box = $("#im-c-out"); if (box) box.innerHTML = ""; + _resetConsolePinState(); + + // 1. Fetch recent ring-buffer history so the pane isn't blank on open. + // Pure log stream only — no sys-banner separators, no state/player noise. + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/logs/recent?limit=400`); + // Also scan history for the ready-signal log line. The live SSE only + // replays the last ~5s, so if the user opens the console minutes + // after boot completes, the joinable line is already past — without + // this scan, the pill would stay yellow until the 10-min uptime + // fallback. The history scan picks it up immediately. + const inst = allInstances.find(x => x.instance_id === id); + const pat = inst && READY_LOG_ONLY.has(inst.module_id) && readyPatternFor(inst.module_id); + let foundReady = false; + for (const ev of (r.events || [])) { + renderConsoleEvent(ev); + if (pat && !foundReady && ev.log && ev.log.line && pat.test(ev.log.line)) { + instanceConfirmedReady.add(id); + foundReady = true; + } + } + if (foundReady) { + paintStartingUI(); + if (typeof renderServerCards === "function") renderServerCards(); + } + } catch { /* silent — console just stays blank on history failure */ } + + // 2. Subscribe to the live stream for new events. Silent retry on disconnect + // (EventSource already auto-reconnects; no user-facing status line). + instanceModalState.sseUnsub = sseSubscribeInstance(id, ev => { + // Head refresh only on non-log events (state/stats/etc.) — pure log + // lines can arrive 100+/sec and never change the header. + try { renderConsoleEvent(ev); if (!ev.log) refreshInstanceModalHead(); } catch {} + }); +} +$("#im-c-send").addEventListener("click", sendInstanceRCON); +$("#im-c-cmd").addEventListener("keydown", onConsoleInputKey); +$("#im-c-cmd").addEventListener("input", onConsoleInputChange); +$("#im-c-clear").addEventListener("click", () => { + $("#im-c-out").innerHTML = ""; + _resetConsolePinState(); + // Wipe the fatal banner + re-arm pattern detection so the next round + // of crash signatures (e.g. operator restarted after the fix) shows + // again instead of being dedup'd by the original session's seen set. + const fb = $("#im-c-fatal"); if (fb) fb.style.display = "none"; + if (instanceModalState.fatalSeen) instanceModalState.fatalSeen.clear(); +}); +$("#im-c-autoscroll").addEventListener("change", e => { + instanceModalState.consoleAutoscroll = e.target.checked; + // Re-checking the box is an explicit "take me back to live" — re-pin. + if (e.target.checked) { const b = $("#im-c-out"); if (b) _consoleRepin(b); } +}); +$("#im-c-help").addEventListener("click", toggleConsoleHelpDrawer); +// Click outside the autocomplete closes it. +document.addEventListener("click", e => { + if (!e.target.closest("#im-c-cmd") && !e.target.closest("#im-c-autocomplete")) hideConsoleAutocomplete(); +}); + +// ================ Per-game console command reference ================ +// +// Each entry describes a module's RCON/console surface — grouped into +// categories for the drawer, flattened for autocomplete matching. Keep +// descriptions terse (one sentence) and args syntax real (angle = required, +// square = optional). Examples render verbatim, copy-ready on click. +// +// To add a new module: mirror an existing entry's shape. Everything else +// (autocomplete, drawer rendering, click-to-insert) picks it up +// automatically. + +const consoleCommandRegistry = { + "vein": { + protocol: "Stdio (server console)", + tagline: "VEIN has no network RCON — the panel writes commands to the server process's stdin and the reply appears in the live log tail (not the Send box). Most admin actions happen in-game (admins set by Steam ID in Game.ini); the documented server-console command set is small. Settings live in Game.ini (edit via the Config tab).", + categories: [ + { title: "Server", icon: "🖥️", commands: [ + { cmd: "PrintAllVeinSettings", desc: "Dump every available VEIN server setting + its current value to the log (useful to discover keys for Game.ini)." }, + ]}, + ], + }, + "dayz": { + protocol: "BattlEye RCON", + tagline: "DayZ uses BattlEye RCON. `#` commands are admin surface; `say -1 ` broadcasts. DayZ has no vanilla admin cheat commands — most server-side tooling comes from the Community-Online-Tools mod (in-game menu, not RCON).", + categories: [ + { title: "Players — list & moderation", icon: "👥", commands: [ + { cmd: "#players", desc: "List every connected player with ID, name, IP, ping, GUID." }, + { cmd: "#kick", args: " [reason]", desc: "Boot a player by their numeric ID from #players.", ex: "#kick 4 griefing" }, + { cmd: "#ban", args: " [reason]", desc: "Ban a connected player. 0 = permanent.", ex: "#ban 4 0 blatant cheating" }, + { cmd: "#addBan", args: " [reason]", desc: "Ban an OFFLINE player by GUID." }, + { cmd: "#removeBan", args: "", desc: "Lift a ban by its entry number in #bans." }, + { cmd: "#bans", desc: "Show the current ban list with IDs." }, + { cmd: "#loadBans", desc: "Reload bans.txt from disk (after editing)." }, + { cmd: "#writeBans", desc: "Write the in-memory ban list to bans.txt." }, + { cmd: "#admins", desc: "Show connected RCON admins." }, + ]}, + { title: "Chat", icon: "💬", commands: [ + { cmd: "say -1", args: "", desc: "Broadcast to every connected player.", ex: "say -1 Server restart in 5 minutes" }, + { cmd: "say", args: " ", desc: "Whisper one player by ID.", ex: "say 4 Private message here" }, + ]}, + { title: "Server — lifecycle", icon: "⚙️", commands: [ + { cmd: "#shutdown", desc: "Graceful shutdown — mission save then exit." }, + { cmd: "#restart", desc: "Restart the server process." }, + { cmd: "#init", desc: "Reload BE config (server.cfg + BE) without a full process restart." }, + { cmd: "#lock", desc: "Lock the server to new connections." }, + { cmd: "#unlock", desc: "Unlock the server." }, + { cmd: "#monitor", args: "", desc: "Enable performance monitoring for N seconds (BE log)." }, + { cmd: "#debug", args: "<0|1|2|3>", desc: "Set BE RCON debug verbosity." }, + ]}, + { title: "Mission", icon: "🗺️", commands: [ + { cmd: "#mission", args: "", desc: "Load a different mission (restarts world)." }, + { cmd: "#missions", desc: "List available mission files on disk." }, + { cmd: "#missionname", desc: "Show the currently loaded mission." }, + { cmd: "#reassign", desc: "Re-assign player slots after mission change." }, + ]}, + ], + }, + + "ark-sa": { + protocol: "Source RCON", + tagline: "ASA admin console. Commands are case-insensitive. `cheat` commands need admin-auth (RCON sessions are auto-admin). This is a curated subset — ASA has hundreds of GM commands; check the ARK wiki for the exhaustive list.", + categories: [ + { title: "Players — list", icon: "👥", commands: [ + { cmd: "ListPlayers", desc: "Online players with indexes, names, and Steam IDs." }, + { cmd: "GetPlayerIDForSteamID", args: "", desc: "Resolve an ARK player-index from a Steam ID." }, + { cmd: "GetSteamIDForPlayerID", args: "", desc: "Reverse lookup." }, + { cmd: "GetTribeIDPlayerList", args: "", desc: "List every player in a tribe." }, + { cmd: "ShowMyAdminManager", desc: "Open the in-game admin manager UI (client-side)." }, + ]}, + { title: "Players — moderation", icon: "🛡️", commands: [ + { cmd: "KickPlayer", args: "", desc: "Kick a player by Steam ID.", ex: "KickPlayer 76561197960287930" }, + { cmd: "BanPlayer", args: "", desc: "Permanently ban a Steam ID." }, + { cmd: "UnbanPlayer", args: "", desc: "Lift a ban." }, + { cmd: "RenamePlayer", args: "\"\" \"\"", desc: "Rename a player." }, + { cmd: "RenameTribe", args: "\"\" \"\"", desc: "Rename a tribe." }, + { cmd: "cheat AllowPlayerToJoinNoCheck", args: "", desc: "Whitelist a Steam ID (requires whitelisting turned on)." }, + { cmd: "cheat DisallowPlayerToJoinNoCheck", args: "", desc: "Remove from whitelist." }, + ]}, + { title: "Chat", icon: "💬", commands: [ + { cmd: "Broadcast", args: "", desc: "Big yellow screen-filling broadcast.", ex: "Broadcast Restart in 10 minutes" }, + { cmd: "ServerChat", args: "", desc: "Regular SERVER chat line." }, + { cmd: "ServerChatToPlayer", args: "\"\" ", desc: "Whisper by name." }, + { cmd: "ServerChatTo", args: "\"\" ", desc: "Whisper by Steam ID." }, + ]}, + { title: "World — save & lifecycle", icon: "💾", commands: [ + { cmd: "SaveWorld", desc: "Force a world save to disk." }, + { cmd: "DoExit", desc: "Graceful shutdown (SaveWorld first)." }, + { cmd: "ShutDown", desc: "Like DoExit." }, + { cmd: "DestroyWildDinos", desc: "Wipe + respawn all WILD dinos. Tames are safe." }, + { cmd: "DestroyAllEnemies", desc: "Clears most wild + neutral creatures." }, + { cmd: "DestroyStructures", desc: "Destroy every placed structure (!! — catastrophic)." }, + ]}, + { title: "Time & environment", icon: "🕒", commands: [ + { cmd: "cheat settimeofday", args: "HH:MM", desc: "Set the in-game clock.", ex: "cheat settimeofday 12:00" }, + { cmd: "cheat sloomo", args: "", desc: "Scale game speed. 1.0 = normal, 0 = pause.", ex: "cheat sloomo 0.5" }, + { cmd: "cheat cheat forcetame", desc: "(Typo-safe) alternative alias for ForceTame." }, + ]}, + { title: "Items & inventory", icon: "🎒", commands: [ + { cmd: "cheat GiveItem", args: "\"\" ", desc: "Give YOU an item by blueprint path.", ex: "cheat GiveItem \"Blueprint'/Game/...MetalIngot.MetalIngot'\" 50 0 0" }, + { cmd: "cheat GiveItemNum", args: " ", desc: "Give yourself an item by numeric ID." }, + { cmd: "cheat GiveItemToPlayer", args: " \"\" ", desc: "Give another player an item." }, + { cmd: "cheat GiveItemNumToPlayer", args: " ", desc: "Numeric ID version of GiveItemToPlayer." }, + { cmd: "cheat ClearPlayerInventory", args: " ", desc: "Wipe a player's inventory (three 0/1 flags)." }, + { cmd: "cheat GiveResources", desc: "Dump 50 of every base resource into your inventory." }, + { cmd: "cheat GiveEngrams", desc: "Unlock every engram for you." }, + { cmd: "cheat GiveEngramsTekOnly", desc: "Unlock just the Tek-tier engrams." }, + { cmd: "cheat UnlockEngram", args: "\"\"", desc: "Unlock a specific engram by blueprint path." }, + { cmd: "cheat GiveExpToPlayer", args: " ", desc: "Award player XP." }, + { cmd: "cheat AddExperience", args: " ", desc: "Award yourself XP." }, + ]}, + { title: "Self — god mode & stats", icon: "🩸", commands: [ + { cmd: "cheat God", desc: "Toggle god-mode on yourself (no damage)." }, + { cmd: "cheat InfiniteStats", desc: "Infinite health + food + water + stamina (no damage to stats)." }, + { cmd: "cheat Fly", desc: "Toggle flight on your character." }, + { cmd: "cheat Walk", desc: "Stop flying." }, + { cmd: "cheat Ghost", desc: "Pass through objects (no-clip)." }, + { cmd: "cheat ForceGhost", desc: "Like Ghost but more forceful." }, + { cmd: "cheat GodConsole", args: "true|false", desc: "SERVER-WIDE god-mode toggle — huge impact, use carefully." }, + { cmd: "cheat EnemyInvisible", args: "true|false", desc: "Creatures stop noticing you." }, + ]}, + { title: "Creatures — spawn & tame", icon: "🦖", commands: [ + { cmd: "cheat Summon", args: "", desc: "Spawn a creature right at you.", ex: "cheat Summon Rex_Character_BP_C" }, + { cmd: "cheat SummonTamed", args: "", desc: "Same but already tamed to you." }, + { cmd: "cheat GMSummon", args: "\"\" ", desc: "Summon at a specific level — preferred for powerful tames.", ex: "cheat GMSummon \"Giga_Character_BP_C\" 150" }, + { cmd: "cheat SpawnDino", args: "\"\" ", desc: "Controlled dino spawn with placement." }, + { cmd: "cheat ForceTame", desc: "Instantly tame whatever you're looking at." }, + { cmd: "cheat DoTame", desc: "Perfect-tame the target (simulates kibble taming)." }, + { cmd: "cheat ForceTameAOE", args: "", desc: "Tame everything within radius." }, + { cmd: "cheat DestroyMyTarget", desc: "Kill whatever's under your crosshair." }, + { cmd: "cheat KillAOE", args: " ", desc: "Kill all of a category (pawn/structure/wild) in range.", ex: "cheat KillAOE wild 5000" }, + ]}, + { title: "Teleport", icon: "📍", commands: [ + { cmd: "cheat TeleportPlayerIDToMe", args: "", desc: "Yank a player to your position." }, + { cmd: "cheat TeleportToPlayer", args: "", desc: "Jump to a player." }, + { cmd: "cheat TeleportToPlayerName", args: "\"\"", desc: "Jump to a player by name." }, + { cmd: "cheat SetPlayerPos", args: " ", desc: "Teleport yourself to absolute coords.", ex: "cheat SetPlayerPos 0 0 1000" }, + { cmd: "cheat Teleport", desc: "Teleport in the direction you're facing." }, + ]}, + { title: "Tribe admin", icon: "🏛️", commands: [ + { cmd: "cheat MakeTribeAdmin", desc: "Make yourself tribe admin of the tribe you're looking at." }, + { cmd: "cheat MakeTribeFounder", desc: "Make yourself tribe founder." }, + { cmd: "cheat ForceJoinTribe", desc: "Force your character into the tribe of the player you're looking at." }, + { cmd: "cheat MergeTribe", args: "", desc: "Merge the tribe you're looking at into another." }, + ]}, + ], + }, + + "7dtd": { + protocol: "Telnet", + tagline: "7DTD telnet console. Type `help` at any time for the server's live list (includes modlet commands like Prisma Core / CSMM / Starvation). Quote multi-word args.", + categories: [ + { title: "Introspection", icon: "❓", commands: [ + { cmd: "help", desc: "List every command the server recognises RIGHT NOW — includes any modlet-added commands (Prisma Core, CSMM helpers, etc.)." }, + { cmd: "help", args: "", desc: "Show detailed usage + aliases for one command.", ex: "help giveself" }, + { cmd: "version", desc: "Game + build version + compatibility version." }, + { cmd: "ver", desc: "Alias for version." }, + ]}, + { title: "Players — list", icon: "👥", commands: [ + { cmd: "lp", desc: "List connected players (name, id, steamid, ip, level, tokens)." }, + { cmd: "lpi", desc: "List all KNOWN player IDs ever connected (offline included). Same as listplayerids." }, + { cmd: "listknownplayers", desc: "List every known player (name, steamid, last seen). Alias: lkp." }, + { cmd: "listplayerids", desc: "Alias for lpi." }, + { cmd: "le", args: "[filter]", desc: "List entities (players + zombies + drops). Filter by type.", ex: "le alive" }, + { cmd: "listents", args: "[filter]", desc: "Detailed entity listing." }, + ]}, + { title: "Players — moderation", icon: "🛡️", commands: [ + { cmd: "kick", args: " [reason]", desc: "Kick one player.", ex: "kick \"GrieferBob\" \"breaking rules\"" }, + { cmd: "kickall", args: "[reason]", desc: "Kick every non-admin player at once." }, + { cmd: "ban add", args: " [reason]", desc: "Add a ban. Duration: 30 seconds, 5 minutes, 7 days, 1 year, or 0.", ex: "ban add 76561197960000000 30 days cheating" }, + { cmd: "ban remove", args: "", desc: "Unban." }, + { cmd: "ban list", desc: "Show all current bans." }, + { cmd: "admin add", args: " ", desc: "Grant admin. 0 = owner, 1000 = normal player.", ex: "admin add 76561197960000000 0" }, + { cmd: "admin remove", args: "", desc: "Revoke admin." }, + { cmd: "admin list", desc: "List all admins + their perm levels." }, + { cmd: "cp add", args: " ", desc: "Set the permission level required to run a command.", ex: "cp add say 1000" }, + { cmd: "cp remove", args: "", desc: "Clear a command's custom permission." }, + { cmd: "cp list", desc: "Show all command-permission overrides." }, + { cmd: "whitelist add", args: "", desc: "Add a player to the whitelist." }, + { cmd: "whitelist remove", args: "", desc: "Remove from whitelist." }, + { cmd: "whitelist list", desc: "Show whitelisted steam IDs." }, + ]}, + { title: "Chat & Communication", icon: "💬", commands: [ + { cmd: "say", args: "\"\"", desc: "Broadcast a chat message from SERVER.", ex: "say \"Restart in 5 min — save your bases\"" }, + { cmd: "pm", args: " \"\"", desc: "Whisper a single player (private message)." }, + ]}, + { title: "World — time & weather", icon: "🕒", commands: [ + { cmd: "gettime", desc: "Current game day + in-game clock." }, + { cmd: "settime", args: "day|night|", desc: "Jump to day, night, or a raw tick count.", ex: "settime day" }, + { cmd: "setworldtime", args: "", desc: "Set absolute world tick. 24000 = one full day." }, + { cmd: "shownexthordetime", desc: "Report when the next blood-moon horde fires." }, + { cmd: "bc-size", args: "", desc: "Set blood-moon horde size multiplier (default 1.0)." }, + { cmd: "bc-remove", args: "", desc: "Remove an entity from the blood-moon horde." }, + { cmd: "weather", args: " ", desc: "Force a weather variable (Rain, Wind, Temperature…).", ex: "weather Rain 1" }, + { cmd: "weathersurvival", args: "on|off", desc: "Toggle whether weather affects survival stats." }, + ]}, + { title: "World — chunks & prefabs", icon: "🌍", commands: [ + { cmd: "chunkcache", desc: "Show the chunk cache status. Alias: cc." }, + { cmd: "chunkreset", args: " ", desc: "Regenerate a chunk (loses player edits there)." }, + { cmd: "chunkobserver", args: " [time]", desc: "Force-load a chunk for observation." }, + { cmd: "repairchunkdensity", desc: "Rebuild collision data for damaged chunks." }, + { cmd: "prefab", args: "", desc: "Prefab tools — list, rotate, bounds, etc. Run bare for subcommand list." }, + { cmd: "removelandclaim", args: "", desc: "Remove ONE land-claim block (lookup via steamid)." }, + { cmd: "removelandclaims", desc: "Wipe ALL land claims on the server — destructive." }, + { cmd: "visitmap", args: " ", desc: "Mark a region as 'visited' on the player's map." }, + { cmd: "traderarea", args: "[options]", desc: "Inspect / manage trader protection zones." }, + ]}, + { title: "Spawns & Events", icon: "🧟", commands: [ + { cmd: "spawnentity", args: " ", desc: "Spawn an entity AT a player (by numeric entity class ID)." }, + { cmd: "spawnairdrop", desc: "Trigger an air-drop event now." }, + { cmd: "spawnsupplycrate", desc: "Drop a supply crate at the caller." }, + { cmd: "spawnscouts", desc: "Send a screamer-scout horde (detect a hot spot)." }, + { cmd: "spawnwanderinghorde", desc: "Spawn a wandering horde immediately." }, + { cmd: "ge", desc: "List currently-registered game events (useful for custom modlets)." }, + ]}, + { title: "Self — items & XP", icon: "🎒", commands: [ + { cmd: "giveself", args: " [quality] [count]", desc: "Give yourself an item.", ex: "giveself gunMR10 6" }, + { cmd: "giveselfxp", args: "", desc: "Award yourself player XP." }, + { cmd: "giveselfskillxp", args: " ", desc: "Skill-specific XP.", ex: "giveselfskillxp perkMachineGuns 5000" }, + { cmd: "buffplayer", args: " ", desc: "Apply a buff to a player.", ex: "buffplayer @s buffInfectionDeath" }, + { cmd: "debuffplayer", args: " ", desc: "Remove a buff from a player." }, + { cmd: "buff", args: "", desc: "Apply a buff to yourself." }, + { cmd: "debuff", args: "", desc: "Remove a buff from yourself." }, + { cmd: "showinventory", args: "", desc: "Print a player's full inventory to the console." }, + ]}, + { title: "Self — state", icon: "🩸", commands: [ + { cmd: "god", desc: "Toggle god-mode on the caller." }, + { cmd: "kill", args: "[name]", desc: "Instantly kill yourself or a named player." }, + { cmd: "killall", desc: "Kill every non-player entity (zombies, animals, bandits) — alive entities only." }, + { cmd: "starve", args: "[name]", desc: "Drain a player's food stat to zero." }, + { cmd: "thirsty", args: "[name]", desc: "Drain a player's water stat to zero." }, + { cmd: "exhausted", args: "[name]", desc: "Drain a player's stamina to zero." }, + { cmd: "resetplayer", args: "", desc: "Wipe a player's saved data (forces fresh start)." }, + { cmd: "teleport", args: "", desc: "Teleport yourself. Dest = player name, \"x y z\", or offset. Alias: tp / tele.", ex: "teleport 0 100 0" }, + { cmd: "teleportplayer", args: " ", desc: "Teleport someone else.", ex: "teleportplayer GrieferBob 0 -70 0" }, + { cmd: "loc", desc: "Print your current world coords." }, + ]}, + { title: "Server — admin / ops", icon: "⚙️", commands: [ + { cmd: "saveworld", desc: "Force a full world save." }, + { cmd: "shutdown", desc: "Graceful shutdown — saves first." }, + { cmd: "settargetfps", args: "", desc: "Cap server FPS (default 30, modders sometimes push higher)." }, + { cmd: "getlogpath", desc: "Show the path of the current server log file." }, + { cmd: "showdlc", desc: "List DLCs recognised by the server." }, + { cmd: "settempunit", args: "c|f", desc: "Switch temperature display between Celsius / Fahrenheit." }, + { cmd: "mem", desc: "Memory usage." }, + { cmd: "memcl", desc: "Memory on the client side (if co-hosted)." }, + { cmd: "memgcall", desc: "Force a full garbage-collection pass." }, + { cmd: "netstats", desc: "Network statistics (bandwidth + packets)." }, + { cmd: "listthreads", desc: "Dump all running server threads." }, + ]}, + { title: "Dev / debug", icon: "🐛", commands: [ + { cmd: "debugmenu", args: "on|off", desc: "Toggle the in-game F1 dev console." }, + { cmd: "creativemenu", args: "on|off", desc: "Toggle the creative menu (U key)." }, + { cmd: "spectator", args: "on|off", desc: "Spectator camera toggle." }, + { cmd: "spectrum", args: "", desc: "Force a rendering spectrum (lighting profile).", ex: "spectrum Blood Moon" }, + { cmd: "showclouds", args: "on|off", desc: "Toggle cloud rendering." }, + { cmd: "showweather", args: "on|off", desc: "Toggle weather particle rendering." }, + { cmd: "enablerendering", args: "on|off", desc: "Toggle world rendering entirely (benchmark use)." }, + { cmd: "fov", args: "", desc: "Set field-of-view in degrees." }, + { cmd: "staticmeshinfo", desc: "Dump info about loaded static meshes." }, + { cmd: "xuireload", desc: "Reload the XUI (HUD / UI) definitions." }, + ]}, + ], + }, + + "minecraft-java": { + protocol: "Source RCON", + tagline: "Vanilla Minecraft Java Edition commands. Selectors (@a/@p/@r/@e/@s) + target-selector arguments ([name=…, type=…, distance=..5]) work. Plugins (Paper / Spigot / Fabric-API) add many more — check your server's plugin docs.", + categories: [ + { title: "Players — list & moderation", icon: "👥", commands: [ + { cmd: "list", desc: "Show connected players." }, + { cmd: "list uuids", desc: "Same, with UUIDs." }, + { cmd: "kick", args: " [reason]", desc: "Boot a player." }, + { cmd: "ban", args: " [reason]", desc: "Ban by name." }, + { cmd: "ban-ip", args: " [reason]", desc: "Ban by IP." }, + { cmd: "banlist", args: "[ips|players]", desc: "Show the ban list (type optional)." }, + { cmd: "pardon", args: "", desc: "Unban a player." }, + { cmd: "pardon-ip", args: "", desc: "Unban an IP." }, + { cmd: "whitelist add", args: "", desc: "Add to whitelist." }, + { cmd: "whitelist remove", args: "", desc: "Remove from whitelist." }, + { cmd: "whitelist on", desc: "Enforce whitelist." }, + { cmd: "whitelist off", desc: "Disable whitelist." }, + { cmd: "whitelist list", desc: "Show the whitelist." }, + { cmd: "whitelist reload", desc: "Reload whitelist.json from disk." }, + { cmd: "op", args: "", desc: "Grant operator." }, + { cmd: "deop", args: "", desc: "Revoke operator." }, + ]}, + { title: "Chat", icon: "💬", commands: [ + { cmd: "say", args: "", desc: "Server broadcast (every player sees it, prefixed [Server])." }, + { cmd: "tell", args: " ", desc: "Whisper. Alias: msg, w." }, + { cmd: "me", args: "", desc: "Server /me action line." }, + { cmd: "tellraw", args: " ", desc: "Styled message via raw JSON text.", ex: "tellraw @a {\"text\":\"Hi\",\"color\":\"gold\"}" }, + { cmd: "title", args: " [json]", desc: "Show a big on-screen title.", ex: "title @a title {\"text\":\"Welcome!\"}" }, + ]}, + { title: "Save & lifecycle", icon: "💾", commands: [ + { cmd: "save-all", args: "[flush]", desc: "Force save. `flush` waits for disk sync." }, + { cmd: "save-on", desc: "Re-enable auto-saving." }, + { cmd: "save-off", desc: "Pause auto-saving (for cold backups)." }, + { cmd: "stop", desc: "Shut down the server." }, + { cmd: "reload", desc: "Reload datapacks + functions (does NOT reload plugins)." }, + { cmd: "seed", desc: "Show the world seed." }, + ]}, + { title: "World & rules", icon: "🌍", commands: [ + { cmd: "difficulty", args: "peaceful|easy|normal|hard", desc: "Change world difficulty." }, + { cmd: "gamerule", args: " [value]", desc: "Read or set a gamerule.", ex: "gamerule keepInventory true" }, + { cmd: "time set", args: "day|night|noon|midnight|", desc: "Set in-world time.", ex: "time set day" }, + { cmd: "time add", args: "", desc: "Advance world time by ticks." }, + { cmd: "time query", args: "daytime|gametime|day", desc: "Query time in various formats." }, + { cmd: "weather", args: "clear|rain|thunder [seconds]", desc: "Change weather." }, + { cmd: "gamemode", args: " [target]", desc: "Switch gamemode.", ex: "gamemode creative @a" }, + { cmd: "defaultgamemode", args: "", desc: "Set the default gamemode for new players." }, + { cmd: "setworldspawn", args: "[x y z]", desc: "Set world spawn point." }, + { cmd: "spawnpoint", args: "[target] [x y z]", desc: "Set a PLAYER's spawn point." }, + { cmd: "worldborder", args: "", desc: "World border — set/get/add/center/damage/warning.", ex: "worldborder set 5000" }, + { cmd: "forceload add", args: " [x2 z2]", desc: "Keep chunks loaded even with no player." }, + { cmd: "forceload remove", args: " [x2 z2]", desc: "Stop force-loading chunks." }, + ]}, + { title: "Items & entities", icon: "🎒", commands: [ + { cmd: "give", args: " [count] [nbt]", desc: "Give items to a target.", ex: "give @p minecraft:diamond 64" }, + { cmd: "clear", args: "[target] [item] [count]", desc: "Remove items from a target's inventory." }, + { cmd: "replaceitem", args: " [count]", desc: "Replace an item in a specific slot." }, + { cmd: "summon", args: " [x y z] [nbt]", desc: "Spawn an entity.", ex: "summon minecraft:lightning_bolt" }, + { cmd: "kill", args: "[target]", desc: "Kill target entities.", ex: "kill @e[type=zombie,distance=..50]" }, + { cmd: "enchant", args: " [level]", desc: "Enchant held item." }, + { cmd: "effect give", args: " [seconds] [amp] [hideParticles]", desc: "Apply a potion effect." }, + { cmd: "effect clear", args: "[target] [effect]", desc: "Remove effects." }, + { cmd: "xp add", args: " [points|levels]", desc: "Give experience." }, + { cmd: "xp query", args: " [points|levels]", desc: "Check XP." }, + { cmd: "xp set", args: " [points|levels]", desc: "Set absolute XP." }, + ]}, + { title: "Teleport & positioning", icon: "📍", commands: [ + { cmd: "tp", args: " ", desc: "Teleport. Alias for teleport." }, + { cmd: "teleport", args: " [facing]", desc: "Full teleport with optional facing." }, + { cmd: "spreadplayers", args: " ", desc: "Scatter players randomly." }, + { cmd: "locate", args: "", desc: "Find nearest biome/structure/poi.", ex: "locate structure minecraft:village" }, + ]}, + { title: "Scripting & data", icon: "📜", commands: [ + { cmd: "execute", args: " run ", desc: "The universal conditional runner. See wiki for the full subcommand tree." }, + { cmd: "function", args: "", desc: "Run a datapack function." }, + { cmd: "datapack list", desc: "Show all datapacks (enabled + disabled)." }, + { cmd: "datapack enable", args: "", desc: "Enable a datapack." }, + { cmd: "datapack disable", args: "", desc: "Disable a datapack." }, + { cmd: "data get", args: " [path]", desc: "Read NBT data from an entity or block." }, + { cmd: "data modify", args: " ...", desc: "Edit NBT." }, + { cmd: "scoreboard objectives add", args: " ", desc: "Create a scoreboard objective." }, + { cmd: "scoreboard players set", args: " ", desc: "Set a score." }, + { cmd: "tag add", args: " ", desc: "Add a scoreboard tag to an entity." }, + { cmd: "team add", args: "", desc: "Create a team." }, + { cmd: "team join", args: " [targets]", desc: "Add targets to a team." }, + ]}, + { title: "World edit (vanilla)", icon: "🧱", commands: [ + { cmd: "setblock", args: " [mode]", desc: "Place a block.", ex: "setblock ~ ~ ~ minecraft:diamond_block" }, + { cmd: "fill", args: " [mode]", desc: "Fill a region with blocks." }, + { cmd: "clone", args: " [mode]", desc: "Clone a region." }, + ]}, + ], + }, + + "palworld": { + protocol: "Source RCON", + tagline: "Palworld admin RCON. CamelCase commands.", + categories: [ + { title: "Players", icon: "👥", commands: [ + { cmd: "ShowPlayers", desc: "List online players (CSV: name,uid,steamid)." }, + { cmd: "Info", desc: "Server info (version + player count)." }, + { cmd: "KickPlayer", args: "", desc: "Kick a player." }, + { cmd: "BanPlayer", args: "", desc: "Ban a player." }, + { cmd: "TeleportToPlayer", args: "", desc: "Yank you to a player." }, + { cmd: "TeleportToMe", args: "", desc: "Yank a player to you." }, + ]}, + { title: "Chat & Server", icon: "💬", commands: [ + { cmd: "Broadcast", args: "", desc: "Broadcast a message to all players." }, + { cmd: "Save", desc: "Force a world save." }, + { cmd: "DoExit", desc: "Graceful shutdown." }, + { cmd: "Shutdown", args: " ", desc: "Countdown shutdown with a warning.", ex: "Shutdown 60 Server restarting" }, + ]}, + ], + }, + + "rust": { + protocol: "WebSocket RCON", + tagline: "Rust uses a dotted-namespace convar tree: server.*, global.*, env.*, weather.*, user.*, ai.*, craft.*. Type `find ` to discover convars. Facepunch's list is enormous — this is the curated admin + env subset.", + categories: [ + { title: "Introspection", icon: "❓", commands: [ + { cmd: "find", args: "", desc: "Search ALL convars matching a keyword.", ex: "find server." }, + { cmd: "server.writecfg", desc: "Persist all current convar values to server.cfg." }, + { cmd: "server.version", desc: "Print server build + protocol." }, + { cmd: "sleep 0.5", args: "", desc: "(Built-in delay command, can chain with ;)" }, + ]}, + { title: "Players — list & info", icon: "👥", commands: [ + { cmd: "playerlist", desc: "List connected players (JSON)." }, + { cmd: "users", desc: "Alias for playerlist." }, + { cmd: "status", desc: "Short connected-player summary." }, + { cmd: "sleepers", desc: "List sleeping (logged-off) players still in the world." }, + { cmd: "connections", desc: "Raw network connection list." }, + ]}, + { title: "Players — moderation", icon: "🛡️", commands: [ + { cmd: "kick", args: "\"\" \"\"", desc: "Boot a player.", ex: "kick \"SomePlayer\" \"griefing\"" }, + { cmd: "kickall", args: "\"\"", desc: "Kick every connected player." }, + { cmd: "ban", args: "\"\" \"\"", desc: "Ban permanently." }, + { cmd: "banid", args: " \"\" \"\"", desc: "Ban by Steam ID explicitly." }, + { cmd: "unban", args: "", desc: "Lift a ban." }, + { cmd: "banlist", desc: "Show all current bans." }, + { cmd: "banlistex", desc: "Extended banlist with reasons + dates." }, + { cmd: "moderatorid", args: " \"\" \"\"", desc: "Grant moderator (kick/ban, not config).", ex: "moderatorid 76561197960287930 \"\" \"\"" }, + { cmd: "ownerid", args: " \"\" \"\"", desc: "Grant owner (full RCON + convars)." }, + { cmd: "removeowner", args: "", desc: "Revoke owner." }, + { cmd: "removemoderator", args: "", desc: "Revoke moderator." }, + { cmd: "mute", args: " [seconds]", desc: "Mute chat." }, + { cmd: "unmute", args: "", desc: "Unmute." }, + ]}, + { title: "Chat & server", icon: "💬", commands: [ + { cmd: "say", args: "", desc: "Broadcast a SERVER chat message." }, + { cmd: "notice.popupall", args: "\"\" ", desc: "Big popup notice for every player." }, + { cmd: "server.save", desc: "Force world save." }, + { cmd: "server.fps", desc: "Show server FPS." }, + { cmd: "server.seed", desc: "Show map seed." }, + { cmd: "server.worldsize", desc: "Show map size." }, + { cmd: "server.hostname", args: "\"\"", desc: "Change server name in the browser." }, + { cmd: "server.description", args: "\"\"", desc: "Change server description." }, + { cmd: "server.url", args: "", desc: "Server website URL." }, + { cmd: "serverrestart", args: " \"\"", desc: "Countdown restart.", ex: "serverrestart 300 \"Weekly restart in 5 minutes\"" }, + { cmd: "restart", args: "", desc: "Shorter restart command." }, + { cmd: "quit", desc: "IMMEDIATE shutdown (no countdown, no save)." }, + ]}, + { title: "World — env & weather", icon: "🌍", commands: [ + { cmd: "env.time", args: "", desc: "Set time of day (0-24 decimal).", ex: "env.time 12" }, + { cmd: "env.progresstime", args: "true|false", desc: "Whether time advances naturally." }, + { cmd: "weather.clouds", args: "<0.0-1.0>", desc: "Override cloud cover." }, + { cmd: "weather.rain", args: "<0.0-1.0>", desc: "Override rain intensity." }, + { cmd: "weather.fog", args: "<0.0-1.0>", desc: "Override fog." }, + { cmd: "weather.wind", args: "<0.0-1.0>", desc: "Override wind." }, + { cmd: "weather.lightningchance", args: "<0.0-1.0>", desc: "Thunderstorm lightning frequency." }, + { cmd: "ai.sense_range", args: "", desc: "Tune NPC sense range (default 40)." }, + ]}, + { title: "Entities — cleanup & spawn", icon: "🧹", commands: [ + { cmd: "server.removeDecayingEntities", desc: "Immediately remove all decaying entities." }, + { cmd: "server.stability", desc: "Re-calculate structural stability." }, + { cmd: "entities", desc: "Dump entity counts by type." }, + { cmd: "find_entity", args: "", desc: "Search for specific entity prefab." }, + { cmd: "bradley.respawnDelayMinutes", args: "", desc: "Tune APC respawn timer." }, + { cmd: "heli.strafe", desc: "Call a patrol helicopter now." }, + ]}, + { title: "Config & persistence", icon: "💾", commands: [ + { cmd: "server.saveinterval", args: "", desc: "Autosave interval (default 600)." }, + { cmd: "server.secure", args: "true|false", desc: "VAC/Anti-cheat on/off." }, + { cmd: "server.maxplayers", args: "", desc: "Slot count." }, + { cmd: "server.pve", args: "true|false", desc: "Toggle PvE mode." }, + { cmd: "decay.scale", args: "", desc: "Structure decay multiplier (0 = off)." }, + { cmd: "craft.instant", args: "true|false", desc: "Instant-craft toggle." }, + { cmd: "event.run", desc: "Trigger a random event (crate drop, APC)." }, + ]}, + ], + }, + + "conan-exiles": { + protocol: "Source RCON", + tagline: "Conan Exiles admin RCON.", + categories: [ + { title: "Players", icon: "👥", commands: [ + { cmd: "listplayers", desc: "List online players." }, + { cmd: "kickplayer", args: "", desc: "Kick a player." }, + { cmd: "banplayer", args: "", desc: "Ban a player." }, + { cmd: "unbanplayer", args: "", desc: "Unban." }, + ]}, + { title: "Chat", icon: "💬", commands: [ + { cmd: "broadcast", args: "", desc: "Broadcast a chat message." }, + { cmd: "servermsg", args: "", desc: "Server system message (popup)." }, + ]}, + { title: "Server", icon: "⚙️", commands: [ + { cmd: "save", desc: "Force a world save." }, + { cmd: "shutdown", desc: "Graceful shutdown." }, + { cmd: "GetServerSetting", args: "", desc: "Read a config value.", ex: "GetServerSetting ServerName" }, + ]}, + ], + }, + + "valheim": { + protocol: "Stdio (write-only)", + tagline: "Valheim's dedicated server only accepts write-style commands via stdio. Responses go to the log.", + categories: [ + { title: "Players", icon: "👥", commands: [ + { cmd: "kick", args: "", desc: "Kick a player." }, + { cmd: "ban", args: "", desc: "Ban a player." }, + { cmd: "unban", args: "", desc: "Unban." }, + { cmd: "banned", desc: "List banned players (logged, not returned here)." }, + ]}, + { title: "Server", icon: "⚙️", commands: [ + { cmd: "save", desc: "Force a world save." }, + { cmd: "ping", desc: "Health ping (logged)." }, + { cmd: "shutdown", desc: "Graceful shutdown." }, + ]}, + ], + }, +}; + +// Flat-list view used by autocomplete. Rebuilt lazily per module. +let consoleCmdFlat = null; +let consoleCmdFlatModule = ""; +function getConsoleCommandFlat(moduleID) { + if (consoleCmdFlatModule === moduleID && consoleCmdFlat) return consoleCmdFlat; + consoleCmdFlatModule = moduleID; + const reg = consoleCommandRegistry[moduleID]; + if (!reg) { consoleCmdFlat = []; return consoleCmdFlat; } + consoleCmdFlat = []; + for (const cat of reg.categories || []) { + for (const c of cat.commands || []) consoleCmdFlat.push({ ...c, cat: cat.title }); + } + return consoleCmdFlat; +} + +let consoleAcIndex = -1; +let consoleAcCurrent = []; + +function onConsoleInputChange(e) { + const v = e.target.value; + const caret = e.target.selectionStart; + // Only autocomplete on the FIRST word (prefix-of-command). + const upToCaret = v.slice(0, caret); + if (/\s/.test(upToCaret)) { hideConsoleAutocomplete(); return; } + const prefix = upToCaret.toLowerCase(); + if (!prefix) { hideConsoleAutocomplete(); return; } + const i = allInstances.find(x => x.instance_id === instanceModalState.id); + const moduleID = i && i.module_id; + const all = getConsoleCommandFlat(moduleID); + const hits = all.filter(c => c.cmd.toLowerCase().startsWith(prefix)).slice(0, 8); + if (!hits.length) { hideConsoleAutocomplete(); return; } + consoleAcCurrent = hits; + consoleAcIndex = 0; + renderConsoleAutocomplete(); +} + +function onConsoleInputKey(e) { + const drop = $("#im-c-autocomplete"); + const open = drop && drop.classList.contains("on"); + if (open && (e.key === "ArrowDown" || e.key === "ArrowUp")) { + e.preventDefault(); + consoleAcIndex += (e.key === "ArrowDown" ? 1 : -1); + if (consoleAcIndex < 0) consoleAcIndex = consoleAcCurrent.length - 1; + if (consoleAcIndex >= consoleAcCurrent.length) consoleAcIndex = 0; + renderConsoleAutocomplete(); + return; + } + if (open && (e.key === "Tab" || (e.key === "Enter" && consoleAcIndex >= 0))) { + if (e.key === "Tab" || consoleAcCurrent[consoleAcIndex]) { + e.preventDefault(); + applyConsoleAutocomplete(consoleAcCurrent[consoleAcIndex]); + return; + } + } + if (open && e.key === "Escape") { hideConsoleAutocomplete(); return; } + if (e.key === "Enter" && !open) { sendInstanceRCON(); return; } +} + +function renderConsoleAutocomplete() { + const drop = $("#im-c-autocomplete"); if (!drop) return; + drop.innerHTML = consoleAcCurrent.map((c, i) => ` +
    +
    ${esc(c.cmd)}${c.args ? `${esc(c.args)}` : ''}
    +
    ${esc(c.desc || '')}
    +
    + `).join(''); + drop.classList.add("on"); + $$("#im-c-autocomplete [data-cch-idx]").forEach(el => + el.addEventListener("click", () => applyConsoleAutocomplete(consoleAcCurrent[Number(el.dataset.cchIdx)]))); +} + +function hideConsoleAutocomplete() { + const drop = $("#im-c-autocomplete"); if (drop) drop.classList.remove("on"); + consoleAcIndex = -1; consoleAcCurrent = []; +} + +function applyConsoleAutocomplete(c) { + if (!c) return; + const input = $("#im-c-cmd"); + // Replace the current first-token with the command, leave a trailing + // space if it takes args so the user just starts typing. + const current = input.value; + const firstSpace = current.indexOf(" "); + const rest = firstSpace >= 0 ? current.slice(firstSpace) : ""; + input.value = c.cmd + (c.args ? " " : "") + rest; + hideConsoleAutocomplete(); + input.focus(); + // Position caret after the command (before any existing args). + const pos = c.cmd.length + (c.args ? 1 : 0); + input.setSelectionRange(pos, pos); +} + +// ---- Commands drawer (📖 button) ---- + +function toggleConsoleHelpDrawer() { + const drawer = $("#im-c-helpdrawer"); if (!drawer) return; + if (drawer.classList.contains("on")) { + drawer.classList.remove("on"); + drawer.innerHTML = ""; + return; + } + const i = allInstances.find(x => x.instance_id === instanceModalState.id); + const moduleID = i && i.module_id; + const reg = consoleCommandRegistry[moduleID]; + if (!reg) { + drawer.innerHTML = `
    +
    📖
    + No command reference registered for ${esc(moduleID || "?")} yet. +
    The Console still works — this panel will populate when we add a registry for this module.
    +
    `; + drawer.classList.add("on"); + return; + } + renderConsoleHelpDrawer(reg); + drawer.classList.add("on"); +} + +function renderConsoleHelpDrawer(reg) { + const drawer = $("#im-c-helpdrawer"); + drawer.innerHTML = ` +
    + + ${esc(reg.protocol || '')} + +
    + ${reg.tagline ? `
    ${esc(reg.tagline)}
    ` : ''} +
    + `; + paintConsoleHelpBody(reg, ""); + $("#im-c-helpclose").addEventListener("click", toggleConsoleHelpDrawer); + $("#im-c-helpfilter").addEventListener("input", (e) => paintConsoleHelpBody(reg, e.target.value.toLowerCase().trim())); + setTimeout(() => { const f = $("#im-c-helpfilter"); if (f) f.focus(); }, 60); +} + +function paintConsoleHelpBody(reg, filter) { + const body = $("#im-c-helpbody"); if (!body) return; + const parts = []; + for (const cat of reg.categories || []) { + const matched = (cat.commands || []).filter(c => + !filter || + c.cmd.toLowerCase().includes(filter) || + (c.desc || '').toLowerCase().includes(filter) || + (c.args || '').toLowerCase().includes(filter) + ); + if (!matched.length) continue; + parts.push(`
    +
    ${esc(cat.icon || '')} ${esc(cat.title)}
    + ${matched.map(c => ` +
    +
    +
    ${esc(c.cmd)}
    + ${c.args ? `
    ${esc(c.args)}
    ` : ''} +
    +
    +
    ${esc(c.desc || '')}
    + ${c.ex ? `${esc(c.ex)}` : ''} +
    +
    `).join('')} +
    `); + } + body.innerHTML = parts.length + ? parts.join('') + : `
    No commands match "${esc(filter)}"
    `; + $$("#im-c-helpbody [data-cch-cmd]").forEach(row => + row.addEventListener("click", () => { + applyConsoleAutocomplete({ cmd: row.dataset.cchCmd, args: row.dataset.cchArgs || "" }); + // Close drawer after selection so the user sees their input. + const drawer = $("#im-c-helpdrawer"); if (drawer) drawer.classList.remove("on"); + })); +} +async function sendInstanceRCON() { + const id = instanceModalState.id; const cmd = $("#im-c-cmd").value.trim(); + if (!id || !cmd) return; + appendConsoleLine({ kind: "cmd", text: cmd }); + $("#im-c-send").disabled = true; + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/rcon`, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ command: cmd }) }); + const data = await r.json(); + if (!r.ok) appendConsoleLine({ kind: "err", text: `${data.message || r.status}` }); + else if (data.error) { appendConsoleLine({ kind: "err", text: data.error }); if (data.output) appendConsoleLine({ kind: "reply", text: data.output }); } + else appendConsoleLine({ kind: "reply", text: data.output || "(ok — no output)" }); + $("#im-c-cmd").value = ""; + } catch (err) { appendConsoleLine({ kind: "err", text: err.message }); } + finally { $("#im-c-send").disabled = false; $("#im-c-cmd").focus(); } +} + +// ---------------- FILES tab ---------------- +// +// Path model: +// - Modules with a single root (most of them): use relative paths against +// the default root. Same as before. +// - Modules with multiple roots (7DTD: saves + game): send CONTAINER- +// ABSOLUTE paths so the agent knows which root to apply. The user +// picks a root from the picker; `instanceModalState.rootPath` is that +// root's absolute path; `instanceModalState.path` is the subpath +// inside it. +function currentRootPath() { + const i = allInstances.find(x => x.instance_id === instanceModalState.id); + const roots = i ? rootsFor(i.module_id) : null; + if (!roots) return ""; // single-root mode — relative paths + return instanceModalState.rootPath || roots[0].path; +} +function buildFilePath(sub) { + const root = currentRootPath(); + if (!root) return sub; // single-root: relative + return root + (sub ? "/" + sub : ""); +} +function renderInstanceRootPicker() { + const wrap = $("#im-f-roots"); + const i = allInstances.find(x => x.instance_id === instanceModalState.id); + const roots = i ? rootsFor(i.module_id) : null; + if (!roots) { wrap.style.display = "none"; return; } + wrap.style.display = ""; + if (!instanceModalState.rootPath) instanceModalState.rootPath = roots[0].path; + wrap.innerHTML = `
    + View: + ${roots.map(r => ``).join("")} +
    `; +} +function renderInstanceCrumbs() { + const el = $("#im-f-crumbs"); const id = instanceModalState.id; + const parts = instanceModalState.path ? instanceModalState.path.split("/").filter(Boolean) : []; + let path = ""; + const rootName = (() => { + const i = allInstances.find(x => x.instance_id === id); + const roots = i ? rootsFor(i.module_id) : null; + if (!roots) return id; + const r = roots.find(x => x.path === instanceModalState.rootPath); + return r ? r.name : id; + })(); + const crumbs = [`
    ${esc(rootName)}`]; + for (const p of parts) { path = path ? path + "/" + p : p; crumbs.push(`${esc(p)}`); } + el.innerHTML = crumbs.join(`/`); +} +// --- File browser state (selection / current-listing cache / xhr handle). +// _fsEntries caches the last successful listing so the right-click menu can +// look up "is this a directory?" without re-fetching. _fsSelection tracks +// which subpaths are checked. _fsLastClicked enables shift-click-range. +let _fsEntries = []; +let _fsSelection = new Set(); +let _fsLastClicked = null; +let _fsActiveUploadXHR = null; +let _fsUploadCancelled = false; + +const _ARCHIVE_RE = /\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tbz|tar\.xz|txz|7z|rar)$/i; +function _isArchiveName(name) { return _ARCHIVE_RE.test(name); } + +async function loadInstanceFiles() { + const id = instanceModalState.id; if (!id) return; + // Show the "Install base mods" toolbar button only for 7DTD instances — + // it's the only module whose mods come from the panel's basemods sidecar + // (Allocs/PrismaCore). Hidden by default in the HTML. + try { + const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null; + const btn = document.getElementById("im-f-basemods"); + if (btn) btn.style.display = (inst && inst.module_id === "7dtd") ? "" : "none"; + } catch {} + renderInstanceRootPicker(); + renderInstanceCrumbs(); + const wrap = $("#im-f-wrap"); + wrap.innerHTML = loadingState("Loading…"); + // A folder change invalidates the current selection — paths belong to + // the previous directory. Keep selections tied to a single dir for + // sanity (matches Windows Explorer). + _fsSelection.clear(); + _fsLastClicked = null; + _fsRefreshSelBar(); + try { + const q = new URLSearchParams({ path: buildFilePath(instanceModalState.path) }); + const { entries } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`); + _fsEntries = entries || []; + if (!_fsEntries.length) { wrap.innerHTML = `
    (empty folder)
    `; return; } + _fsEntries.sort((a, b) => (a.is_dir !== b.is_dir ? (a.is_dir ? -1 : 1) : a.name.localeCompare(b.name))); + const root = currentRootPath(); + const subFor = e => root && e.path.startsWith(root + "/") ? e.path.slice(root.length + 1) + : root && e.path === root ? "" + : e.path; + wrap.innerHTML = ` + + + + + ${_fsEntries.map(e => { + const icon = e.is_dir ? "📁" : iconForFile(e.name); + const sub = subFor(e); + const isArc = !e.is_dir && _isArchiveName(e.name); + return ` + + + + + + `; + }).join("")} +
    namesizemodified
    ${icon} ${esc(e.name)}${e.is_dir ? "—" : esc(fmtBytes(e.size))}${esc(fmtTime(e.mod_time))} + ${e.is_dir + ? `` + : ``} + +
    `; + // Re-apply selection visuals if any survived (intentionally cleared on + // folder change above, but a re-render after action should re-mark). + _fsApplySelectionVisuals(); + } catch (err) { wrap.innerHTML = errorState(`Error: ${err.message}`); } +} +$("#im-f-refresh").addEventListener("click", loadInstanceFiles); +// "Install base mods" — POSTs to /api/instances/{id}/install-base-mods. +// Confirms first; warns if the instance is running (the agent's sidecar +// will fail because the running game holds the Mods DLLs open). Async on +// the agent side — this returns 202 + a correlation id, the operator +// follows progress via the standard "updating" pulse on the card and the +// agent journal. Only enabled for 7DTD per the show/hide gate above. +$("#im-f-basemods").addEventListener("click", async () => { + const id = instanceModalState.id; if (!id) return; + const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null; + if (inst && (inst.status === "running" || inst.status === "starting")) { + if (!confirm(`${id} is currently ${inst.status}.\n\nInstalling base mods while the server is running will fail — the game holds Mods DLLs open. Stop the server first and retry. Proceed anyway?`)) return; + } else { + if (!confirm(`Re-install base mods on ${id}?\n\nWill download:\n • Allocs Server Fixes (illy.bz)\n • PrismaCore latest release (GitHub)\n\nExisting copies of those mods get backed up to ".bak-" siblings — re-runs are reversible. TFP_* and xMarkers are shipped by SteamCMD and not touched. Custom mods (RefugeBot, AGF-VP, etc.) are not touched. ~30-60s.`)) return; + } + const btn = $("#im-f-basemods"); const orig = btn.textContent; + btn.disabled = true; btn.textContent = "⏳ Installing…"; + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/install-base-mods`, { method: "POST" }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { toast(d.message || `install-base-mods failed (${r.status})`, "err"); return; } + toast("Base-mods install kicked — agent will finish in ~30-60s; refresh Files to see new dirs.", "ok"); + } catch (e) { + toast("Error: " + (e.message || e), "err"); + } finally { + btn.disabled = false; btn.textContent = orig; + } +}); + +// ---- Selection helpers ---- +function _fsRowSubs() { + return Array.from(document.querySelectorAll("#im-f-wrap tr[data-entry]")).map(tr => tr.dataset.entry); +} +function _fsApplySelectionVisuals() { + document.querySelectorAll("#im-f-wrap tr[data-entry]").forEach(tr => { + const on = _fsSelection.has(tr.dataset.entry); + tr.classList.toggle("selected", on); + const cb = tr.querySelector(".fs-row-chk"); + if (cb) cb.checked = on; + }); + const all = $("#im-f-chk-all"); + if (all) { + const subs = _fsRowSubs(); + all.checked = subs.length > 0 && subs.every(s => _fsSelection.has(s)); + all.indeterminate = !all.checked && subs.some(s => _fsSelection.has(s)); + } + _fsRefreshSelBar(); +} +function _fsRefreshSelBar() { + const bar = $("#im-f-selbar"); + const lbl = $("#im-f-selcount"); + if (!bar) return; + const n = _fsSelection.size; + if (n === 0) { bar.style.display = "none"; return; } + bar.style.display = "inline-flex"; + lbl.textContent = n === 1 ? "1 selected" : `${n} selected`; +} +function _fsToggleSelection(sub, mode /* "single" | "toggle" | "range" */) { + const subs = _fsRowSubs(); + if (mode === "range" && _fsLastClicked) { + const a = subs.indexOf(_fsLastClicked); + const b = subs.indexOf(sub); + if (a >= 0 && b >= 0) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + for (let i = lo; i <= hi; i++) _fsSelection.add(subs[i]); + _fsApplySelectionVisuals(); + return; + } + } + if (mode === "single") { + _fsSelection.clear(); + _fsSelection.add(sub); + } else { + if (_fsSelection.has(sub)) _fsSelection.delete(sub); else _fsSelection.add(sub); + } + _fsLastClicked = sub; + _fsApplySelectionVisuals(); +} + +// ---- Upload (XHR for real progress) ---- +$("#im-f-upload").addEventListener("click", () => $("#im-f-upload-input").click()); +$("#im-f-upload-input").addEventListener("change", e => { + const files = Array.from(e.target.files || []); + e.target.value = ""; + if (files.length) _fsUploadFiles(files); +}); + +function _fsShowProgress(title, meta, pct) { + const w = $("#im-f-prog"); + if (!w) return; + w.style.display = "block"; + $("#im-f-prog-title").textContent = title; + $("#im-f-prog-meta").textContent = meta; + $("#im-f-prog-fill").style.width = (pct == null ? 0 : Math.max(0, Math.min(100, pct))) + "%"; +} +function _fsHideProgress() { + const w = $("#im-f-prog"); if (w) w.style.display = "none"; +} + +// Chunk size — must stay below any edge-proxy body cap on the panel's +// public hostname. Cloudflare free tier silently buffers/stalls bodies +// over ~100 MB, and even smaller-than-cap uploads can hang behind their +// proxy. 8 MiB chunks are well clear of every common limit and give +// good throughput across home WAN links. +const _FS_CHUNK_SIZE = 8 * 1024 * 1024; + +function _fsNewUploadId() { + // Browser UUIDs aren't always available (older Safari, some + // privacy-locked Firefox) — fall back to crypto.getRandomValues. + if (window.crypto && crypto.randomUUID) return crypto.randomUUID(); + const b = new Uint8Array(16); crypto.getRandomValues(b); + return Array.from(b, x => x.toString(16).padStart(2, "0")).join(""); +} + +async function _fsUploadFiles(files) { + const id = instanceModalState.id; if (!id) return; + const currentDir = instanceModalState.path || ""; + _fsUploadCancelled = false; + let totalBytes = 0; + for (const f of files) totalBytes += f.size; + let bytesDone = 0; + let i = 0; + for (const f of files) { + if (_fsUploadCancelled) break; + i++; + const sub = currentDir ? `${currentDir}/${f.name}` : f.name; + const fpath = buildFilePath(sub); + const uploadId = _fsNewUploadId(); + try { + await _fsUploadOneChunked(id, f, fpath, uploadId, i, files.length, bytesDone, totalBytes); + bytesDone += f.size; + toast(`Uploaded ${f.name}`, "ok"); + } catch (err) { + toast(`Upload failed (${f.name}): ${err.message}`, "err", 8000); + break; + } + } + _fsHideProgress(); + _fsActiveUploadXHR = null; + loadInstanceFiles(); +} + +async function _fsUploadOneChunked(instanceId, file, fpath, uploadId, fileIdx, fileCount, batchBytesDone, batchTotalBytes) { + const total = file.size; + let offset = 0; + // Empty file is legal — send a single zero-byte final chunk so the + // agent still creates the destination. + do { + if (_fsUploadCancelled) throw new Error("cancelled"); + const end = Math.min(offset + _FS_CHUNK_SIZE, total); + const chunk = file.slice(offset, end); + const isFinal = end >= total; + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + _fsActiveUploadXHR = xhr; + xhr.open("POST", `/api/instances/${encodeURIComponent(instanceId)}/files/upload-chunk?path=${encodeURIComponent(fpath)}`); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + xhr.setRequestHeader("X-Upload-Id", uploadId); + xhr.setRequestHeader("X-Chunk-Offset", String(offset)); + xhr.setRequestHeader("X-Total-Size", String(total)); + xhr.setRequestHeader("X-Is-Final", isFinal ? "1" : "0"); + xhr.upload.onprogress = ev => { + if (!ev.lengthComputable) return; + const sent = offset + ev.loaded; + const overall = batchBytesDone + sent; + const pct = (overall / Math.max(1, batchTotalBytes)) * 100; + _fsShowProgress( + `Uploading ${file.name} (${fileIdx}/${fileCount})`, + `${fmtBytes(sent)} / ${fmtBytes(total)} · ${fmtBytes(overall)} of ${fmtBytes(batchTotalBytes)} total`, + pct, + ); + }; + xhr.onload = () => { + _fsActiveUploadXHR = null; + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error(`HTTP ${xhr.status}: ${(xhr.responseText || "").slice(0, 400) || xhr.statusText}`)); + }; + xhr.onerror = () => { _fsActiveUploadXHR = null; reject(new Error("network error")); }; + xhr.onabort = () => { _fsActiveUploadXHR = null; reject(new Error("cancelled")); }; + xhr.send(chunk); + }); + offset = end; + } while (offset < total); +} +$("#im-f-prog-cancel").addEventListener("click", () => { + _fsUploadCancelled = true; + if (_fsActiveUploadXHR) try { _fsActiveUploadXHR.abort(); } catch {} +}); + +// ---- New file / mkdir ---- +$("#im-f-new").addEventListener("click", async () => { + const id = instanceModalState.id; if (!id) return; + const name = prompt("New file name (relative to current folder):", "notes.txt"); if (!name) return; + const subpath = instanceModalState.path ? instanceModalState.path + "/" + name : name; + const path = buildFilePath(subpath); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ path, content: "" }) }); + toast(`Created ${name}`, "ok"); loadInstanceFiles(); openFileInEditor(subpath, name); + } catch (err) { toast("Create failed: " + err.message, "err"); } +}); +$("#im-f-mkdir").addEventListener("click", async () => { + const id = instanceModalState.id; if (!id) return; + const name = prompt("New folder name:", "new-folder"); if (!name) return; + // We don't have a dedicated mkdir RPC, but FsWrite of a placeholder + // file inside the new path implicitly creates the parents. Then we + // remove the placeholder so the folder is empty. + const placeholderSub = (instanceModalState.path ? instanceModalState.path + "/" : "") + name + "/.panel-keep"; + const placeholderPath = buildFilePath(placeholderSub); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ path: placeholderPath, content: "" }), + }); + toast(`Created folder ${name}`, "ok"); + loadInstanceFiles(); + } catch (err) { toast("Mkdir failed: " + err.message, "err"); } +}); + +// ---- Selection toolbar ---- +$("#im-f-sel-clear").addEventListener("click", () => { _fsSelection.clear(); _fsLastClicked = null; _fsApplySelectionVisuals(); }); +$("#im-f-sel-del").addEventListener("click", () => _fsBulkDelete(Array.from(_fsSelection))); +$("#im-f-sel-zip").addEventListener("click", () => _fsCompressPrompt(Array.from(_fsSelection))); +$("#im-f-sel-move").addEventListener("click", () => _fsMovePrompt(Array.from(_fsSelection))); + +async function _fsBulkDelete(subs) { + const id = instanceModalState.id; if (!id || !subs.length) return; + const lookup = new Map(_fsEntries.map(e => { + const root = currentRootPath(); + const s = root && e.path.startsWith(root + "/") ? e.path.slice(root.length + 1) : e.path; + return [s, e]; + })); + if (!confirm(`Delete ${subs.length} item${subs.length === 1 ? "" : "s"}? This cannot be undone.`)) return; + let ok = 0, fail = 0; + for (const sub of subs) { + const e = lookup.get(sub); + const isDir = e ? e.is_dir : false; + const url = `/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(buildFilePath(sub))}${isDir ? "&recursive=true" : ""}`; + try { await fetchJSON(url, { method: "DELETE" }); ok++; } + catch (err) { fail++; toast(`Delete ${sub} failed: ${err.message}`, "err"); } + } + if (ok) toast(`Deleted ${ok} item${ok === 1 ? "" : "s"}`, fail ? "warn" : "ok"); + loadInstanceFiles(); +} + +async function _fsCompressPrompt(subs) { + const id = instanceModalState.id; if (!id || !subs.length) return; + const defaultName = subs.length === 1 + ? subs[0].split("/").pop().replace(/\.[^./]+$/, "") + ".zip" + : "archive.zip"; + const out = prompt(`Compress ${subs.length} item${subs.length === 1 ? "" : "s"} into a .zip — destination filename (in current folder, or relative path):`, defaultName); + if (!out) return; + const dest = buildFilePath(instanceModalState.path ? instanceModalState.path + "/" + out : out); + const sources = subs.map(s => buildFilePath(s)); + toast(`Compressing ${subs.length} item${subs.length === 1 ? "" : "s"}…`); + _fsShowProgress(`Compressing into ${out}`, "agent is zipping…", null); + try { + const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/compress`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ sources, dest_zip_path: dest, overwrite: true }), + }); + toast(`Zipped ${res.entries_written} entr${res.entries_written === 1 ? "y" : "ies"} into ${out}`, "ok"); + _fsSelection.clear(); + loadInstanceFiles(); + } catch (err) { toast("Compress failed: " + err.message, "err", 8000); } + _fsHideProgress(); +} + +async function _fsExtractHere(sub, name) { + await _fsExtractTo(sub, name, ""); +} +async function _fsExtractToPrompt(sub, name) { + const defaultDir = name.replace(/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tbz)$/i, ""); + const dest = prompt(`Extract "${name}" — destination folder (relative to current, blank = current):`, defaultDir); + if (dest == null) return; + await _fsExtractTo(sub, name, dest.trim()); +} +async function _fsExtractTo(sub, name, destRel) { + const id = instanceModalState.id; if (!id) return; + const archivePath = buildFilePath(sub); + const destDir = destRel ? buildFilePath(instanceModalState.path ? instanceModalState.path + "/" + destRel : destRel) : ""; + toast(`Extracting ${name}…`); + _fsShowProgress(`Extracting ${name}`, "agent is unpacking…", null); + try { + const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/extract`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ archive_path: archivePath, dest_dir: destDir, overwrite: true }), + }); + toast(`Extracted ${res.entries_written} entr${res.entries_written === 1 ? "y" : "ies"} (${fmtBytes(res.bytes_written)})`, "ok"); + loadInstanceFiles(); + } catch (err) { toast("Extract failed: " + err.message, "err", 8000); } + _fsHideProgress(); +} + +async function _fsRenamePrompt(sub, name) { + const id = instanceModalState.id; if (!id) return; + const next = prompt(`Rename "${name}" to:`, name); + if (!next || next === name) return; + const dir = sub.includes("/") ? sub.slice(0, sub.lastIndexOf("/")) : ""; + const toSub = dir ? dir + "/" + next : next; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/rename`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ from_path: buildFilePath(sub), to_path: buildFilePath(toSub) }), + }); + toast(`Renamed to ${next}`, "ok"); + loadInstanceFiles(); + } catch (err) { toast("Rename failed: " + err.message, "err"); } +} + +// _fsMovePrompt — prompt for a destination folder (relative to the +// current root, or `..` to climb out) and call the rename RPC for each +// selected entry. The agent's FsRename does an actual `mv` and mkdir's +// the parent, so moving to a not-yet-existing folder works. +// +// Common case the user hit: a zip extracts to `nested/ModName/`, and +// they want to flatten it by moving everything in `nested/ModName/` up +// to the current dir. Solution: navigate into `nested/ModName/`, select +// all, Move → `..`. +async function _fsMovePrompt(subs) { + const id = instanceModalState.id; if (!id || !subs.length) return; + const cur = instanceModalState.path || ""; + const hint = cur ? `(current folder: ${cur} — use ".." to move up one level)` : `(at root — destination is relative to root)`; + const destRel = prompt(`Move ${subs.length} item${subs.length === 1 ? "" : "s"} to which folder? ${hint}`, ".."); + if (destRel == null) return; + const trimmed = destRel.trim(); + // Resolve destination relative to the current folder. "" / "." = current. + // Leading "/" means relative-to-root. + let destBase; + if (trimmed === "" || trimmed === ".") destBase = cur; + else if (trimmed.startsWith("/")) destBase = trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); + else destBase = (cur ? cur + "/" : "") + trimmed.replace(/\/+$/, ""); + // Normalize "a/b/../c" → "a/c", and handle leading "../". + const parts = []; + for (const seg of destBase.split("/")) { + if (seg === "" || seg === ".") continue; + if (seg === "..") { parts.pop(); continue; } + parts.push(seg); + } + destBase = parts.join("/"); + + toast(`Moving ${subs.length} item${subs.length === 1 ? "" : "s"}…`); + let ok = 0, fail = 0; + for (const sub of subs) { + const name = sub.split("/").pop(); + const toSub = destBase ? destBase + "/" + name : name; + if (toSub === sub) { fail++; continue; } // would be a no-op + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/rename`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ from_path: buildFilePath(sub), to_path: buildFilePath(toSub) }), + }); + ok++; + } catch (err) { fail++; toast(`Move ${name} failed: ${err.message}`, "err"); } + } + if (ok) toast(`Moved ${ok} item${ok === 1 ? "" : "s"}${fail ? ` (${fail} failed)` : ""}`, fail ? "warn" : "ok"); + _fsSelection.clear(); + loadInstanceFiles(); +} + +// ---- Root picker / breadcrumb clicks ---- +$("#im-f-roots").addEventListener("click", e => { + const btn = e.target.closest("[data-fs-root]"); if (!btn) return; + instanceModalState.rootPath = btn.dataset.fsRoot; + instanceModalState.path = ""; + loadInstanceFiles(); +}); +$("#im-f-crumbs").addEventListener("click", e => { + const link = e.target.closest("a[data-fspath]"); if (link) { e.preventDefault(); instanceModalState.path = link.dataset.fspath; loadInstanceFiles(); } +}); + +// ---- Row interactions ---- +$("#im-f-wrap").addEventListener("click", async e => { + const id = instanceModalState.id; if (!id) return; + // Header "select all" toggle. + if (e.target.id === "im-f-chk-all") { + const all = e.target; + const subs = _fsRowSubs(); + if (all.checked) subs.forEach(s => _fsSelection.add(s)); + else _fsSelection.clear(); + _fsApplySelectionVisuals(); + return; + } + // Per-row checkbox. + const cb = e.target.closest(".fs-row-chk"); + if (cb) { + e.stopPropagation(); + _fsToggleSelection(cb.dataset.sub, e.shiftKey ? "range" : "toggle"); + return; + } + // Action buttons (download / delete). + const del = e.target.dataset.fsdel; + if (del) { + const isDir = e.target.dataset.fsdir === "1"; + const name = e.target.dataset.name || del; + const msg = isDir + ? `Delete folder "${name}" and EVERYTHING inside it?\n\nThis is a recursive rm -rf and cannot be undone.` + : `Delete "${name}"?`; + if (!confirm(msg)) return; + const url = `/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(buildFilePath(del))}${isDir ? "&recursive=true" : ""}`; + try { await fetchJSON(url, { method: "DELETE" }); toast(isDir ? `Deleted folder "${name}"` : "Deleted", "ok"); loadInstanceFiles(); } + catch (err) { toast("Delete failed: " + err.message, "err"); } + return; + } + const dl = e.target.dataset.fsdownload; + if (dl) { + const q = new URLSearchParams({ path: buildFilePath(dl) }); + const a = document.createElement("a"); + a.href = `/api/instances/${encodeURIComponent(id)}/files/download?${q}`; + a.download = e.target.dataset.name || dl.split("/").pop(); + document.body.appendChild(a); a.click(); a.remove(); + return; + } + // Row body click — navigate (dir) or open editor (file). Ctrl/Shift + + // click toggles selection without navigating, like Explorer. + const row = e.target.closest("tr[data-entry]"); + if (row) { + if (e.ctrlKey || e.metaKey) { _fsToggleSelection(row.dataset.entry, "toggle"); return; } + if (e.shiftKey) { _fsToggleSelection(row.dataset.entry, "range"); return; } + const sub = row.dataset.entry, isDir = row.dataset.dir === "1"; + if (isDir) { instanceModalState.path = sub; loadInstanceFiles(); } + else { openFileInEditor(sub, row.dataset.name); } + } +}); + +// ---- Right-click context menu ---- +$("#im-f-wrap").addEventListener("contextmenu", e => { + const row = e.target.closest("tr[data-entry]"); + if (!row) return; + e.preventDefault(); + const sub = row.dataset.entry, name = row.dataset.name; + const isDir = row.dataset.dir === "1"; + const isArchive = row.dataset.arc === "1"; + // Selecting with right-click feels natural — if the row isn't already + // in the selection, replace it. Multiple-selected items keep their set. + if (!_fsSelection.has(sub)) { + _fsSelection.clear(); + _fsSelection.add(sub); + _fsLastClicked = sub; + _fsApplySelectionVisuals(); + } + _fsOpenCtxMenu(e.clientX, e.clientY, { sub, name, isDir, isArchive }); +}); +function _fsOpenCtxMenu(x, y, ctx) { + const menu = $("#im-f-ctx"); if (!menu) return; + const multi = _fsSelection.size > 1; + const items = []; + if (multi) { + items.push({ k: "move-many", icon: "➡", label: `Move ${_fsSelection.size} items to…` }); + items.push({ k: "zip-many", icon: "📦", label: `Compress ${_fsSelection.size} items to .zip…` }); + items.push({ sep: true }); + items.push({ k: "del-many", icon: "🗑️", label: `Delete ${_fsSelection.size} items`, danger: true }); + } else { + items.push({ k: "rename", icon: "✎", label: "Rename…" }); + items.push({ k: "move", icon: "➡", label: "Move to…" }); + items.push({ sep: true }); + if (!ctx.isDir) { + items.push({ k: "open", icon: "📝", label: "Open in editor" }); + items.push({ k: "download", icon: "↓", label: "Download" }); + } + if (ctx.isArchive) { + items.push({ sep: true }); + items.push({ k: "extract-here", icon: "📂", label: "Extract here" }); + items.push({ k: "extract-to", icon: "📂", label: "Extract to…" }); + } + items.push({ sep: true }); + items.push({ k: "zip", icon: "📦", label: "Compress to .zip…" }); + items.push({ k: "del", icon: "🗑️", label: "Delete", danger: true }); + } + menu.innerHTML = items.map((it, i, arr) => { + if (it.sep) { + if (i === 0 || i === arr.length - 1) return ""; + if (arr[i - 1]?.sep) return ""; + return `
    `; + } + return `
    ${it.icon}${esc(it.label)}
    `; + }).join(""); + menu.style.display = "block"; + // Position, then nudge if it would overflow the viewport edges. + menu.style.left = x + "px"; menu.style.top = y + "px"; + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) menu.style.left = Math.max(0, window.innerWidth - rect.width - 8) + "px"; + if (rect.bottom > window.innerHeight) menu.style.top = Math.max(0, window.innerHeight - rect.height - 8) + "px"; + menu.dataset.ctx = JSON.stringify(ctx); +} +function _fsCloseCtxMenu() { const m = $("#im-f-ctx"); if (m) m.style.display = "none"; } +document.addEventListener("click", e => { if (!e.target.closest("#im-f-ctx")) _fsCloseCtxMenu(); }); +document.addEventListener("keydown", e => { if (e.key === "Escape") _fsCloseCtxMenu(); }); +$("#im-f-ctx").addEventListener("click", async e => { + const it = e.target.closest(".fs-ctx-item"); if (!it) return; + const act = it.dataset.act; + const menu = $("#im-f-ctx"); + const ctx = JSON.parse(menu.dataset.ctx || "{}"); + _fsCloseCtxMenu(); + switch (act) { + case "open": openFileInEditor(ctx.sub, ctx.name); break; + case "download": { + const q = new URLSearchParams({ path: buildFilePath(ctx.sub) }); + const a = document.createElement("a"); + a.href = `/api/instances/${encodeURIComponent(instanceModalState.id)}/files/download?${q}`; + a.download = ctx.name; + document.body.appendChild(a); a.click(); a.remove(); + break; + } + case "extract-here": _fsExtractHere(ctx.sub, ctx.name); break; + case "extract-to": _fsExtractToPrompt(ctx.sub, ctx.name); break; + case "zip": _fsCompressPrompt([ctx.sub]); break; + case "zip-many": _fsCompressPrompt(Array.from(_fsSelection)); break; + case "rename": _fsRenamePrompt(ctx.sub, ctx.name); break; + case "move": _fsMovePrompt([ctx.sub]); break; + case "move-many": _fsMovePrompt(Array.from(_fsSelection)); break; + case "del": _fsBulkDelete([ctx.sub]); break; + case "del-many": _fsBulkDelete(Array.from(_fsSelection)); break; + } +}); + +// ---- Drag & drop upload ---- +(() => { + const pane = $("#im-f-pane"); + const droppy = $("#im-f-droppy"); + if (!pane || !droppy) return; + let dragDepth = 0; + // Only react to drags that carry actual files — internal drags (text + // selections, dragged buttons) shouldn't trip the overlay. + const hasFiles = ev => Array.from(ev.dataTransfer?.types || []).includes("Files"); + pane.addEventListener("dragenter", ev => { + if (!hasFiles(ev)) return; + ev.preventDefault(); + dragDepth++; + droppy.style.display = "flex"; + }); + pane.addEventListener("dragover", ev => { + if (!hasFiles(ev)) return; + ev.preventDefault(); + ev.dataTransfer.dropEffect = "copy"; + }); + pane.addEventListener("dragleave", ev => { + if (!hasFiles(ev)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) droppy.style.display = "none"; + }); + pane.addEventListener("drop", ev => { + if (!hasFiles(ev)) return; + ev.preventDefault(); + dragDepth = 0; + droppy.style.display = "none"; + const files = Array.from(ev.dataTransfer.files || []); + if (files.length) _fsUploadFiles(files); + }); +})(); + +// ---------------- BACKUPS tab ---------------- +// =============== BACKUPS TAB =============== +// Card-based list with explicit Restore/Delete buttons, animated progress +// during backup (SSE-fed activity line + indeterminate bar), and an +// "Auto: every X" dropdown that creates a backup-action schedule under +// the hood (no separate UI required — it just appears in the Schedules +// tab as well). +const _bkAutoCronMap = { + "off": null, + "6h": "0 0 */6 * * *", + "12h": "0 0 */12 * * *", + "24h": "0 0 4 * * *", // 4am daily — least disruptive + "7d": "0 0 4 * * 0", // Sunday 4am +}; +const _bkCronToOption = { + "0 0 */6 * * *": "6h", + "0 0 */12 * * *": "12h", + "0 0 4 * * *": "24h", + "0 0 4 * * 0": "7d", +}; + +// Auto-save cron map (ARK SaveWorld over RCON). Sub-hour cadences for +// world snapshotting on busy servers. Cron is 6-field (sec min hr dom mon dow). +const _bkAutoSaveCronMap = { + "off": null, + "15m": "0 */15 * * * *", + "30m": "0 */30 * * * *", + "60m": "0 0 * * * *", +}; +const _bkAutoSaveCronToOption = { + "0 */15 * * * *": "15m", + "0 */30 * * * *": "30m", + "0 0 * * * *": "60m", +}; + +let _bkAutoSchedule = null; // cached schedule row (auto-backup) +let _bkAutoSaveSchedule = null; // cached schedule row (ARK SaveWorld) +let _bkProgressTimer = null; // elapsed-time updater +let _bkProgressUnsub = null; // unsubscribe fn for backup log lines (shared SSE hub) +let _bkProgressStart = 0; + +function fmtAgo(ts) { + if (!ts) return ""; + const t = Date.parse(ts); + if (isNaN(t)) return ""; + const diff = Date.now() - t; + const sec = Math.max(0, Math.floor(diff / 1000)); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${hr}h ago`; + const d = Math.floor(hr / 24); + return `${d}d ago`; +} + +// ---- Region Health (7DTD .7rg corruption detect + heal) ---- +async function loadRegionHealth() { + const id = instanceModalState.id; const wrap = $("#im-rh-wrap"); if (!id || !wrap) return; + wrap.innerHTML = loadingState("Scanning regions…"); + let data; + try { data = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/region-health`); } + catch (err) { wrap.innerHTML = `
    ⚠ Couldn't scan: ${esc(err.message||err)}
    `; return; } + const modeSel = $("#im-rh-mode"); if (modeSel && data.mode) modeSel.value = data.mode; + const regions = data.regions || []; + if (!regions.length) { wrap.innerHTML = `
    ${EMPTY_ICONS.health}
    All regions healthy
    No corruption detected in the active world.
    `; return; } + wrap.innerHTML = regions.map(rg => ` +
    +
    +
    ${esc(rg.name)} ${rg.file_corrupt ? '' : ''}
    +
    ${esc(rg.issue||"")}
    +
    + +
    `).join(""); +} +document.addEventListener("click", async (e) => { + const el = e.target && e.target.closest ? e.target.closest("[data-rhheal]") : null; if (!el) return; + const id = instanceModalState.id; if (!id) return; + const region = el.dataset.rhheal; + if (!confirm(`Heal region ${region}? This STOPS the server, swaps the region from the newest clean backup, then restarts it.`)) return; + el.disabled = true; const orig = el.textContent; el.textContent = "Healing…"; + try { + const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/region-health/${encodeURIComponent(region)}/heal`, {method:"POST"}); + toast(`Healed ${res.region} from ${res.source_backup}`, "ok"); loadRegionHealth(); + } catch (err) { toast("Heal failed: " + (err.message||err), "err"); el.disabled = false; el.textContent = orig; } +}); +document.addEventListener("change", async (e) => { + if (!e.target || e.target.id !== "im-rh-mode") return; + const id = instanceModalState.id; if (!id) return; + try { await fetchJSON(`/api/instances/${encodeURIComponent(id)}/region-health/mode`, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({mode: e.target.value})}); toast(e.target.value === "auto" ? "Auto-heal enabled" : "Alert-only mode", "ok"); } + catch (err) { toast("Couldn't set mode: " + (err.message||err), "err"); } +}); +document.addEventListener("click", (e) => { if (e.target && e.target.id === "im-rh-refresh") loadRegionHealth(); }); + +async function loadInstanceBackups() { + const id = instanceModalState.id; const wrap = $("#im-b-wrap"); if (!id) return; + wrap.innerHTML = loadingState("Loading backups…"); + // Fetch backups + the instance's schedules in parallel — schedules + // tell us if an auto-backup is configured so we can populate the + // dropdown. + let backups = [], schedules = []; + try { + const [b, s] = await Promise.all([ + fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`).catch(() => ({backups: []})), + fetchJSON(`/api/schedules?instance_id=${encodeURIComponent(id)}`).catch(() => ({schedules: []})), + ]); + backups = b.backups || []; + schedules = s.schedules || []; + } catch (err) { + wrap.innerHTML = `
    Couldn't load backups${esc(err.message)}
    `; + return; + } + + // Find the auto-backup schedule (action.type === "backup") and the + // auto-save schedule (action.type === "rcon" + command === "SaveWorld", + // tagged in description with the "Auto-save" prefix so we don't pick up + // hand-rolled SaveWorld schedules the operator made themselves). + _bkAutoSchedule = null; + _bkAutoSaveSchedule = null; + for (const s of schedules) { + let act; try { act = typeof s.action === "string" ? JSON.parse(s.action) : s.action; } catch { act = {}; } + if (!act || s.trigger_kind !== "cron") continue; + if (!_bkAutoSchedule && act.type === "backup") { _bkAutoSchedule = s; continue; } + if (!_bkAutoSaveSchedule + && act.type === "rcon" + && (act.command || "").toLowerCase() === "saveworld") { + const desc = (s.description || "").toLowerCase(); + // Accept either the current label ("auto saveworld …") or the + // older "auto-save …" prefix from the first cut, so dropdowns from + // a previous deploy still get re-detected correctly. + if (desc.startsWith("auto saveworld") || desc.startsWith("auto-save")) { + _bkAutoSaveSchedule = s; + continue; + } + } + } + const sel = $("#im-b-auto"); + if (sel) { + const opt = _bkAutoSchedule ? (_bkCronToOption[_bkAutoSchedule.cron_spec] || "off") : "off"; + sel.value = opt; + sel.parentElement.classList.toggle("active", opt !== "off"); + } + // ARK-only auto-save dropdown — visibility + selection state. + const inst = (typeof allInstances !== "undefined") ? allInstances.find(x => x.instance_id === id) : null; + const isArkSA = inst && inst.module_id === "ark-sa"; + const saveWrap = $("#im-b-autosave-wrap"); + if (saveWrap) saveWrap.style.display = isArkSA ? "" : "none"; + const saveSel = $("#im-b-autosave"); + if (saveSel) { + const sopt = _bkAutoSaveSchedule ? (_bkAutoSaveCronToOption[_bkAutoSaveSchedule.cron_spec] || "off") : "off"; + saveSel.value = sopt; + saveSel.parentElement.classList.toggle("active", sopt !== "off"); + } + + // Card list + if (!backups.length) { + wrap.innerHTML = ` + ${_bkSmartNoteHTML(id)} +
    ${EMPTY_ICONS.disk}
    No backups yetClick Back up now above to create your first snapshot.
    + `; + return; + } + // Sort newest first. + backups.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at)); + const cardsHTML = backups.map(b => { + const isAuto = (b.description || "").toLowerCase().startsWith("auto-backup") + || (b.description || "").toLowerCase() === "auto-backup"; + return `
    +
    +
    + ${esc(fmtTime(b.created_at))} + ${esc(fmtAgo(b.created_at))} +
    +
    + ${esc(fmtBytes(b.size_bytes))} + ${isAuto ? `Auto` : ""} + ${b.description && !isAuto ? `${esc(b.description)}` : ""} + ${esc(b.id)} +
    +
    +
    + + + +
    +
    `; + }).join(""); + wrap.innerHTML = `${_bkSmartNoteHTML(id)}
    ${cardsHTML}
    `; +} + +function _bkSmartNoteHTML(id) { + const inst = allInstances.find(x => x.instance_id === id); + const mod = inst && (allModules.find(m => m.id === inst.module_id) || {}); + const moduleName = mod ? mod.name || inst.module_id : "this server"; + return `
    + 🧠 +
    + Intelligent backups. Only saves, configs, and world data are archived for ${esc(moduleName)} — the bulky game install is excluded because it rebuilds from SteamCMD on next start. Tarballs typically stay in the MB to low-GB range; restore overwrites the same paths in place. +
    +
    `; +} + +$("#im-b-refresh").addEventListener("click", loadInstanceBackups); + +// Backup-now flow with animated progress. +$("#im-b-create").addEventListener("click", async (e) => { + const id = instanceModalState.id; if (!id) return; + // Optional description — small inline prompt; users who skip it get an + // empty desc which renders as "manual" in the card list. + const desc = prompt("Backup description (optional, e.g. 'before mod update'):", "") || ""; + _bkStartProgress(id, desc); + e.target.disabled = true; + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ description: desc }), + }); + _bkStopProgress(); + toast(`Backup complete — ${fmtBytes(r.size_bytes)}`, "ok"); + loadInstanceBackups(); + } catch (err) { + _bkStopProgress(); + toast(`Backup failed: ${err.message}`, "err"); + } finally { + e.target.disabled = false; + } +}); + +function _bkStartProgress(instanceID, desc) { + const wrap = $("#im-b-progress"); + const titleEl = $("#im-b-progress-title"); + const elapsed = $("#im-b-progress-elapsed"); + const line = $("#im-b-progress-line"); + if (!wrap) return; + wrap.style.display = ""; + titleEl.textContent = `Backing up ${instanceID}${desc ? ` — "${desc}"` : ""}…`; + line.textContent = "starting sidecar…"; + _bkProgressStart = Date.now(); + if (_bkProgressTimer) clearInterval(_bkProgressTimer); + _bkProgressTimer = setInterval(() => { + const sec = Math.floor((Date.now() - _bkProgressStart) / 1000); + elapsed.textContent = sec < 60 ? `${sec}s` : `${Math.floor(sec/60)}m ${sec%60}s`; + }, 1000); + // Subscribe to SSE; show the latest log line whose stream is "backup". + try { if (_bkProgressUnsub) _bkProgressUnsub(); } catch {} + _bkProgressUnsub = sseSubscribeInstance(instanceID, (e) => { + try { + if (e.log && e.log.stream === "backup" && e.log.line) { + const t = String(e.log.line).trim(); + if (t) line.textContent = t.length > 90 ? "…" + t.slice(-90) : t; + } + } catch {} + }); +} +function _bkStopProgress() { + const wrap = $("#im-b-progress"); + if (wrap) wrap.style.display = "none"; + if (_bkProgressTimer) { clearInterval(_bkProgressTimer); _bkProgressTimer = null; } + if (_bkProgressUnsub) { try { _bkProgressUnsub(); } catch {} _bkProgressUnsub = null; } +} + +// Restore + delete handlers — friendlier confirm dialogs. +$("#im-b-wrap").addEventListener("click", async (e) => { + const id = instanceModalState.id; if (!id) return; + const bEl = e.target.closest("[data-bkbrowse]"); + if (bEl) { + let meta = {}; try { meta = JSON.parse(bEl.dataset.bkmeta || "{}"); } catch {} + openBackupBrowser(id, bEl.dataset.bkbrowse, meta); + return; + } + const rEl = e.target.closest("[data-bkrestore]"); + const dEl = e.target.closest("[data-bkdel]"); + if (rEl) { + let meta = {}; + try { meta = JSON.parse(rEl.dataset.bkmeta || "{}"); } catch {} + const inst = allInstances.find(x => x.instance_id === id); + const isRunning = inst && inst.status === "running"; + const lines = [ + `Restore "${id}" from this backup?`, + "", + `📅 Captured: ${fmtTime(meta.created_at)} (${fmtAgo(meta.created_at)})`, + `💾 Size: ${fmtBytes(meta.size || 0)}`, + meta.desc ? `📝 Description: ${meta.desc}` : null, + "", + "⚠ This will OVERWRITE the current saves / configs / world data.", + isRunning ? "⚠ The server is running — STOP it first to avoid corrupting state." : null, + ].filter(Boolean); + if (!confirm(lines.join("\n"))) return; + rEl.disabled = true; toast(`Restoring "${id}"… (may take a minute)`); + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(rEl.dataset.bkrestore)}/restore`, { method: "POST" }); + toast(`Restored ${fmtBytes(r.bytes_restored)} — start the server to use it`, "ok"); + loadInstanceBackups(); + } catch (err) { + toast("Restore failed: " + err.message, "err"); + } finally { rEl.disabled = false; } + } else if (dEl) { + if (!confirm("Remove this backup from the panel's list?\n\nNote: the tarball file on the agent's disk is not deleted automatically — only the DB row is removed.")) return; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/backups/${encodeURIComponent(dEl.dataset.bkdel)}`, { method: "DELETE" }); + toast("Removed", "ok"); + loadInstanceBackups(); + } catch (err) { + toast("Remove failed: " + err.message, "err"); + } + } +}); + +// Auto-backup dropdown — creates / updates / deletes a "backup" action +// schedule for this instance. Only one auto-backup schedule per instance. +$("#im-b-auto").addEventListener("change", async (e) => { + const id = instanceModalState.id; if (!id) return; + const opt = e.target.value; + const cron = _bkAutoCronMap[opt]; + // Always delete any existing auto-backup schedule first. + if (_bkAutoSchedule) { + try { await fetchJSON(`/api/schedules/${encodeURIComponent(_bkAutoSchedule.id)}`, { method: "DELETE" }); } catch {} + _bkAutoSchedule = null; + } + if (!cron) { + toast("Auto-backup disabled", "ok"); + e.target.parentElement.classList.remove("active"); + return; + } + const labels = { "6h":"every 6 hours", "12h":"every 12 hours", "24h":"daily at 4am", "7d":"weekly (Sunday 4am)" }; + try { + await fetchJSON("/api/schedules", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ + instance_id: id, + description: `Auto-backup ${labels[opt] || opt}`, + trigger_kind: "cron", + cron_spec: cron, + action: { type: "backup", description: "Auto-backup" }, + enabled: true, + }), + }); + toast(`Auto-backup scheduled ${labels[opt] || opt}`, "ok"); + e.target.parentElement.classList.add("active"); + loadInstanceBackups(); + } catch (err) { + toast("Schedule failed: " + err.message, "err"); + e.target.value = "off"; + e.target.parentElement.classList.remove("active"); + } +}); + +// Auto-save dropdown — fires `SaveWorld` over RCON on a recurring cron. +// Mirrors the auto-backup dropdown's pattern: only one auto-save schedule +// per instance, identified by its description prefix + rcon SaveWorld +// command. ARK-only (the dropdown wrapper is hidden for non-ARK modules). +$("#im-b-autosave").addEventListener("change", async (e) => { + const id = instanceModalState.id; if (!id) return; + const opt = e.target.value; + const cron = _bkAutoSaveCronMap[opt]; + if (_bkAutoSaveSchedule) { + try { await fetchJSON(`/api/schedules/${encodeURIComponent(_bkAutoSaveSchedule.id)}`, { method: "DELETE" }); } catch {} + _bkAutoSaveSchedule = null; + } + if (!cron) { + toast("Auto SaveWorld disabled", "ok"); + e.target.parentElement.classList.remove("active"); + return; + } + const labels = { "15m":"every 15 min", "30m":"every 30 min", "60m":"hourly" }; + try { + await fetchJSON("/api/schedules", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ + instance_id: id, + description: `Auto SaveWorld ${labels[opt] || opt}`, + trigger_kind: "cron", + cron_spec: cron, + action: { type: "rcon", command: "SaveWorld" }, + enabled: true, + }), + }); + toast(`SaveWorld will fire ${labels[opt] || opt}`, "ok"); + e.target.parentElement.classList.add("active"); + loadInstanceBackups(); + } catch (err) { + toast("Schedule failed: " + err.message, "err"); + e.target.value = "off"; + e.target.parentElement.classList.remove("active"); + } +}); + +// =============== BACKUP BROWSER =============== +// Drill into a tarball without restoring it: tree on the left, content +// pane on the right. Tree comes from /entries (one shot — agent reads +// the tar header and ships back every path). Each file click hits +// /file?path=... which streams the entry's bytes; text files render +// inline, binaries get a Download button. +const _bkbState = { + id: null, + bkpId: null, + meta: null, + entries: [], // raw flat list from API + tree: null, // built tree: { name, path, isDir, size, children: [] } + expanded: new Set(), // dir paths the user has opened + selected: null, // currently-viewed file path + filter: "", +}; + +function openBackupBrowser(instanceID, bkpID, meta) { + _bkbState.id = instanceID; + _bkbState.bkpId = bkpID; + _bkbState.meta = meta || {}; + _bkbState.entries = []; + _bkbState.tree = null; + _bkbState.expanded = new Set(); + _bkbState.selected = null; + _bkbState.filter = ""; + $("#bkb-title").textContent = `Backup contents`; + const m = _bkbState.meta; + $("#bkb-meta").textContent = [ + m.created_at ? fmtTime(m.created_at) : "", + m.size ? fmtBytes(m.size) : "", + m.desc ? `"${m.desc}"` : "", + ].filter(Boolean).join(" · "); + $("#bkb-tree-rows").innerHTML = `
    Loading entries…
    `; + $("#bkb-content").innerHTML = `
    📂
    Click a file in the tree to view its contents.
    `; + $("#bkb-filter").value = ""; + showModal("#bk-browser-modal"); + fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/backups/${encodeURIComponent(bkpID)}/entries`) + .then(res => { + _bkbState.entries = res.entries || []; + _bkbState.tree = _bkbBuildTree(_bkbState.entries); + // Auto-expand the first level so the operator sees the structure + // immediately — most game backups have a single top-level folder + // (Saves/, world/, mods/) anyway. + if (_bkbState.tree.children.length === 1 && _bkbState.tree.children[0].isDir) { + _bkbState.expanded.add(_bkbState.tree.children[0].path); + } + _bkbRenderTree(); + }) + .catch(err => { + $("#bkb-tree-rows").innerHTML = `
    Couldn't load entries
    ${esc(err.message)}
    `; + }); +} + +function _bkbBuildTree(entries) { + const root = { name: "", path: "", isDir: true, size: 0, children: [] }; + // Index dirs by path so we can attach children even when the + // directory header itself isn't an explicit entry (some tarballs + // omit them). + const dirs = new Map(); + dirs.set("", root); + function ensureDir(path) { + if (dirs.has(path)) return dirs.get(path); + const slash = path.lastIndexOf("/"); + const parent = ensureDir(slash >= 0 ? path.slice(0, slash) : ""); + const node = { name: slash >= 0 ? path.slice(slash + 1) : path, path, isDir: true, size: 0, children: [] }; + parent.children.push(node); + dirs.set(path, node); + return node; + } + for (const e of entries) { + const p = (e.path || "").replace(/\/+$/, ""); + if (!p) continue; + if (e.is_dir) { ensureDir(p); continue; } + const slash = p.lastIndexOf("/"); + const parent = ensureDir(slash >= 0 ? p.slice(0, slash) : ""); + parent.children.push({ name: slash >= 0 ? p.slice(slash + 1) : p, path: p, isDir: false, size: e.size || 0 }); + } + // Sort: dirs first, then files, alphabetical within each group. + function sortRec(node) { + node.children.sort((a, b) => { + if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); + }); + for (const c of node.children) if (c.isDir) sortRec(c); + } + sortRec(root); + return root; +} + +function _bkbRenderTree() { + const rows = []; + const filter = (_bkbState.filter || "").toLowerCase(); + function walk(node, depth) { + for (const c of node.children) { + // When filtering, hide subtrees with no matching descendant — + // but keep ancestors of matches visible. Pre-compute per node. + if (filter && !_bkbNodeMatches(c, filter)) continue; + const isOpen = !c.isDir ? false : (filter ? true : _bkbState.expanded.has(c.path)); + const isSel = c.path === _bkbState.selected; + const sizeLabel = c.isDir ? "" : fmtBytes(c.size); + const icon = c.isDir ? "📁" : _bkbFileIcon(c.name); + rows.push(`
    + ${c.isDir ? "▸" : ""} + ${icon} + ${esc(c.name)} + ${sizeLabel} +
    `); + if (c.isDir && isOpen) walk(c, depth + 1); + } + } + walk(_bkbState.tree || { children: [] }, 0); + if (!rows.length) { + $("#bkb-tree-rows").innerHTML = `
    ${filter ? "No matches" : "Empty backup"}
    `; + return; + } + $("#bkb-tree-rows").innerHTML = rows.join(""); +} + +function _bkbNodeMatches(node, filter) { + if (node.path.toLowerCase().includes(filter)) return true; + if (node.isDir) { + for (const c of node.children) if (_bkbNodeMatches(c, filter)) return true; + } + return false; +} + +function _bkbFileIcon(name) { + const ext = (name.split(".").pop() || "").toLowerCase(); + if (["json","ini","cfg","conf","yml","yaml","toml","xml","txt","md","log"].includes(ext)) return "📄"; + if (["png","jpg","jpeg","gif","webp","bmp","ico"].includes(ext)) return "🖼"; + if (["zip","gz","tar","7z","rar","bz2","xz"].includes(ext)) return "📦"; + if (["dll","exe","so","bin"].includes(ext)) return "⚙️"; + if (["lua","js","ts","py","cs","go","sh","bat"].includes(ext)) return "📜"; + return "📄"; +} + +$("#bkb-tree-rows").addEventListener("click", (e) => { + const row = e.target.closest("[data-bkpath]"); + if (!row) return; + const path = row.dataset.bkpath; + const isDir = row.dataset.bkdir === "1"; + if (isDir) { + if (_bkbState.expanded.has(path)) _bkbState.expanded.delete(path); + else _bkbState.expanded.add(path); + _bkbRenderTree(); + } else { + _bkbState.selected = path; + _bkbRenderTree(); + _bkbLoadFile(path); + } +}); + +$("#bkb-filter").addEventListener("input", (e) => { + _bkbState.filter = e.target.value.trim(); + _bkbRenderTree(); +}); + +async function _bkbLoadFile(path) { + const wrap = $("#bkb-content"); + wrap.innerHTML = `
    Loading ${esc(path)}…
    `; + let res; + try { + res = await fetchJSON(`/api/instances/${encodeURIComponent(_bkbState.id)}/backups/${encodeURIComponent(_bkbState.bkpId)}/file?path=${encodeURIComponent(path)}`); + } catch (err) { + wrap.innerHTML = `
    Couldn't load file
    ${esc(err.message)}
    `; + return; + } + // Decode the base64 payload. For text we go via TextDecoder so utf-8 + // characters (mod descriptions in many languages) round-trip; for + // binaries we keep the raw bytes for the download path. + const raw = atob(res.content || ""); + const u8 = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) u8[i] = raw.charCodeAt(i); + const sizeLabel = `${fmtBytes(res.size || u8.length)}${res.truncated ? " (truncated)" : ""}`; + const fileName = path.split("/").pop() || "file"; + const head = `
    + ${esc(path)} + ${sizeLabel} + +
    `; + let body; + if (res.is_binary) { + body = `
    📦
    ${esc(fileName)} is a binary file.
    Click Download above to save it locally.
    `; + } else { + let text; + try { text = new TextDecoder("utf-8", { fatal: false }).decode(u8); } + catch { text = raw; } + const banner = res.truncated + ? `
    Only the first ${fmtBytes(u8.length)} of ${fmtBytes(res.size)} are shown. Download to view the full file.
    ` + : ""; + body = `${banner}
    ${esc(text)}
    `; + } + wrap.innerHTML = head + body; + $("#bkb-download").addEventListener("click", () => { + // Build a Blob URL from the bytes we already have. For truncated + // files, this download is also truncated — surface that in the + // filename so the operator knows. + const dlName = res.truncated ? fileName + ".truncated" : fileName; + const blob = new Blob([u8], { type: res.is_binary ? "application/octet-stream" : "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = dlName; + document.body.appendChild(a); a.click(); + setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); + }); +} + +$("#bkb-close").addEventListener("click", () => hideModal("#bk-browser-modal")); + +// ---------------- SCHEDULES tab ---------------- +async function loadInstanceSchedules() { + const id = instanceModalState.id; const wrap = $("#im-s-wrap"); if (!id) return; + wrap.innerHTML = loadingState("Loading…"); + try { + const { schedules } = await fetchJSON(`/api/schedules?instance_id=${encodeURIComponent(id)}`); + if (!schedules || !schedules.length) { wrap.innerHTML = `
    ${EMPTY_ICONS.clock}
    No schedules yetClick "Add schedule" above (auto-backup, idle shutdown, restarts)
    `; return; } + wrap.innerHTML = ` + + ${schedules.map(s => { + let act; try { act = typeof s.action === "string" ? JSON.parse(s.action) : s.action; } catch { act = s.action; } + const when = s.trigger_kind === "event" + ? humanizeEvent(s.event_spec) + (s.event_sustained_seconds ? ` for ${Math.round(s.event_sustained_seconds/60)}m` : "") + : humanizeCron(s.cron_spec); + const fired = s.last_fired_at ? fmtTime(s.last_fired_at) : "—"; + const statusDot = s.last_status === "ok" ? '' + : s.last_status === "error" ? '' + : ''; + return ` + + + + + + `; + }).join("")} +
    WhenActionLast firedOn
    ${esc(when)}${s.description ? `
    ${esc(s.description)}` : ""}
    ${esc(humanizeAction(act))}${statusDot}${esc(fired)}
    `; + } catch (err) { wrap.innerHTML = errorState(`Error: ${err.message}`); } +} +$("#im-s-add").addEventListener("click", () => { + // Pre-fill instance in the shared schedule modal. + $("#s-instance").value = instanceModalState.id; + showModal("#sched-modal"); +}); + +// ---------------- UPDATES tab ---------------- +async function loadInstanceUpdates() { + const id = instanceModalState.id; const wrap = $("#im-u-wrap"); if (!id) return; + const i = allInstances.find(x => x.instance_id === id); + const mod = (allModules.find(m => m.id === (i && i.module_id)) || {}); + const providers = mod.update_providers || []; + const is7dtd = (i && i.module_id) === "7dtd"; + + // 7DTD version lock surface. Frozen branches (v2.6, v2.5, …) pin a server to + // a fixed build so a Steam update can't drift it onto the moving public + // branch (which flips to 3.0 on 2026-06-29 and would corrupt a 2.6 world). + // The pinned branch is config_values.provider_id. MOVING ids auto-advance. + const VLABEL = pid => ({v2_6:"2.6 Stable",v2_5:"2.5 Stable",v2_4:"2.4 Stable",v2_3:"2.3 Stable",v2_0:"2.0 Stable",v1_4:"1.4 Stable",latest_experimental:"3.0 Experimental (Dead Hot Summer)",current:"Public / latest stable",stable:"Public / latest stable"}[String(pid||"").toLowerCase()] || pid || "—"); + const MOVING = ["current","stable","latest_experimental"]; + const pinned = (i && i.config_values && i.config_values.provider_id) || ""; + const pinnedLower = pinned.toLowerCase(); + const lockedNow = is7dtd && pinned && !MOVING.includes(pinnedLower); + + let lockBadge = ""; + if (is7dtd) { + if (lockedNow) { + lockBadge = `
    +
    🔒 Locked to ${esc(VLABEL(pinned))}
    +
    Pinned to a frozen Steam branch — a Steam update can't move it to a new game version (e.g. the public branch flipping to 3.0 on June 29). Your world is safe. Switching versions below needs a typed confirmation.
    +
    `; + } else if (pinned) { + lockBadge = `
    +
    ⚠ ${esc(VLABEL(pinned))} — unlocked (auto-updates)
    +
    This server tracks a MOVING branch and will advance when that branch does. Switch to a frozen version (e.g. 2.6) below to lock it.
    +
    `; + } else { + lockBadge = `
    +
    ⚠ Not explicitly pinned
    +
    No game version is recorded for this server. An update currently defaults to 2.6 (frozen), but lock it explicitly to be safe.
    + +
    `; + } + } + + const runLabel = (is7dtd && pinned) ? `⬆ Re-validate ${esc(VLABEL(pinned))}` : "⬆ Run update now"; + const runHint = is7dtd + ? "Re-downloads/validates the files for THIS server's pinned version. Safe — it never changes the game version. Stop the server first if needed." + : "Pulls the latest files from the configured provider. Stop the server first if the provider requires it."; + + const rows = providers.map(p => { + const isCur = is7dtd && (p.id.toLowerCase() === pinnedLower || (!pinned && p.id.toLowerCase() === "v2_6")); + const action = !is7dtd ? "" : (isCur + ? `current` + : ``); + return ` + ${esc(p.id)}${is7dtd ? `
    ${esc(VLABEL(p.id))}
    ` : ""} + ${esc(p.kind)} + ${esc([p.app_id && "app_id="+p.app_id, p.beta && "branch="+p.beta, p.repo && "repo="+p.repo, p.url && "url="+p.url].filter(Boolean).join(" · ") || "—")} + ${is7dtd ? `${action}` : ""} + `; + }).join(""); + + wrap.innerHTML = `
    + ${lockBadge} +
    +
    +
    +
    Update this server
    +
    ${runHint}
    +
    + +
    +
    +
    +
    +
    +
    Update available?
    +
    Compares the installed Steam build (from this server's appmanifest) against the latest build for its branch. Check only — never installs or restarts anything.
    +
    + +
    +
    +
    + ${providers.length ? `
    +
    ${is7dtd ? "Game versions" : "Available sources"}
    + ${is7dtd ? "" : ""} + ${rows}
    IDTypeDetails
    +
    ` : `
    This module has no configured update providers.
    `} +
    `; + + // WI-14: update-available check (manual, check-only). + const renderCheckResult = (d) => { + const box = $("#im-u-check-result"); if (!box) return; + if (!d || d.checked === false) { box.innerHTML = `Never checked.`; return; } + const when = d.checked_at ? fmtTime(d.checked_at) : "—"; + let pill; + if (d.error) pill = `⚠ check failed`; + else if (d.update_available) pill = `⬆ Update available`; + else pill = `✓ Up to date`; + box.innerHTML = `
    + ${pill} + branch=${esc(d.branch || "—")} + installed=${esc(d.installed_buildid || "?")} + latest=${esc(d.latest_buildid || "?")} + checked ${esc(when)}${d.cached ? " (cached)" : ""} +
    ${d.error ? `
    ${esc(d.error)}
    ` : ""}`; + }; + fetchJSON(`/api/instances/${encodeURIComponent(id)}/update-check`).then(renderCheckResult).catch(() => {}); + const checkBtn = $("#im-u-check"); + if (checkBtn) checkBtn.addEventListener("click", async () => { + checkBtn.disabled = true; + const box = $("#im-u-check-result"); + if (box) box.innerHTML = `Checking… (may take up to a minute on a cold cache)`; + try { + const d = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/update-check`, { method: "POST" }); + renderCheckResult(d); + await refreshUpdateCheckMap(); + _renderGrid(); // repaint card badges with the fresh result + } catch (err) { + if (box) box.innerHTML = `Check failed: ${esc(err.message)}`; + } finally { checkBtn.disabled = false; } + }); + + const btn = $("#im-u-run"); + if (btn) btn.addEventListener("click", async () => { + btn.disabled = true; + try { + const r = await triggerUpdateWithSteamGate(id); + if (r && r.accepted) toast(`Update started (${r.provider_id} · ${r.provider_kind})`, "ok"); + } catch (err) { toast(`Update failed: ${err.message}`, "err"); } + finally { btn.disabled = false; } + }); + // 7DTD: explicit "lock to 2.6" for servers with no recorded version. + const lockBtn = $("#im-u-lock26"); + if (lockBtn) lockBtn.addEventListener("click", async () => { + lockBtn.disabled = true; + try { + const r = await triggerUpdateWithSteamGate(id, "v2_6"); + if (r && r.accepted) { + if (i && i.config_values) i.config_values.provider_id = "v2_6"; + toast("Locked to 2.6 — re-validating files", "ok"); + loadInstanceUpdates(); + } + } catch (err) { toast(`Lock failed: ${err.message}`, "err"); } + finally { lockBtn.disabled = false; } + }); + // 7DTD: deliberate version switch (gated server-side + typed-slug confirm). + $$(".im-u-switch").forEach(b => b.addEventListener("click", async () => { + const pid = b.getAttribute("data-pid"); + b.disabled = true; + try { + const r = await triggerUpdateWithSteamGate(id, pid); + if (r && r.accepted) { + if (i && i.config_values) i.config_values.provider_id = r.provider_id || pid; + toast(`Switching to ${r.provider_id || pid} — downloading`, "ok"); + loadInstanceUpdates(); + } + } catch (err) { toast(`Switch failed: ${err.message}`, "err"); } + finally { b.disabled = false; } + })); +} + +// ---------------- ARK MODS tab ---------------- +// +// CurseForge-backed mod manager. Mirrors the DayZ mod tab (same .dzm-* +// CSS classes for design cohesion), but under the hood the flow is much +// simpler: ASA's dedicated server has its own built-in CurseForge +// downloader, so we never run steamcmd. Installing just mutates the +// instance's MOD_IDS (or PASSIVE_MODS) env var and recreates the +// container — ASA downloads the mod files on next start. +// +// Search + detail data come from api.curse.tools (free CurseForge proxy, +// no key). We only proxy it — the acekorneya image fetches the actual +// mod binaries directly from CurseForge CDN at server start. + +const arkModsState = { + mods: [], // [{id, title, logo_url, passive, ...}] from server + searchResults: [], // last CurseForge search hits + searchPreview: null, // single-mod preview when user pastes ID/URL + searchTerm: "", + searchDebounce: null, + searchController: null, + installing: false, + // Pending changes — none of these touch the backend until Apply. Each + // recreate is expensive (stops the server, deletes container, recreates, + // restarts), so we batch every UX action into one POST /ark/mods/batch. + pending: { + adds: new Map(), // modID(string) → {modID, passive, title, logo_url, author} + removes: new Set(), // modID(string) — staged for removal + activeOrder: null, // [modID,...] override or null = use server order + passiveOrder: null, // same + }, +}; + +function _arkModsResetPending() { + arkModsState.pending = { + adds: new Map(), removes: new Set(), + activeOrder: null, passiveOrder: null, + }; +} + +function _arkModsPendingCount() { + const p = arkModsState.pending; + let n = p.adds.size + p.removes.size; + if (p.activeOrder || p.passiveOrder) n += 1; // ordering counts as one logical change + return n; +} + +// Project current installed list + pending changes into the lists +// the user sees on screen. Adds get a `_pending: "add"` marker; +// removes get `_pending: "remove"` (kept in the list with strikethrough +// so the operator can still see what they're about to lose). +function _arkModsEffectiveLists() { + const p = arkModsState.pending; + const installed = arkModsState.mods.map(m => ({ ...m, _pending: p.removes.has(String(m.id)) ? "remove" : null })); + const fromInstalled = (passive) => installed.filter(m => !!m.passive === passive); + const addsList = (passive) => Array.from(p.adds.values()) + .filter(a => !!a.passive === passive) + .map(a => ({ id: a.modID, passive: a.passive, title: a.title || `Mod ${a.modID}`, logo_url: a.logo_url || "", author: a.author || "", _pending: "add" })); + let active = fromInstalled(false).concat(addsList(false)); + let passive = fromInstalled(true).concat(addsList(true)); + if (p.activeOrder) active = _arkModsApplyOrder(active, p.activeOrder); + if (p.passiveOrder) passive = _arkModsApplyOrder(passive, p.passiveOrder); + return { active, passive }; +} + +function _arkModsApplyOrder(list, ids) { + const idx = new Map(list.map(m => [String(m.id), m])); + const seen = new Set(); + const out = []; + for (const id of ids) { + const sid = String(id); + if (idx.has(sid) && !seen.has(sid)) { out.push(idx.get(sid)); seen.add(sid); } + } + for (const m of list) if (!seen.has(String(m.id))) out.push(m); + return out; +} + +async function loadArkModsTab() { + const id = instanceModalState.id; const wrap = $("#im-arkm-wrap"); if (!id) return; + // If we switched to a different instance, drop any pending staged + // changes — they belong to the previous server and shouldn't + // accidentally Apply against this one. + if (arkModsState._pendingForID && arkModsState._pendingForID !== id) { + _arkModsResetPending(); + } + arkModsState._pendingForID = id; + wrap.innerHTML = loadingState("Loading installed mods…"); + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods`); + arkModsState.mods = r.mods || []; + } catch (err) { + wrap.innerHTML = errorState(`Couldn't load mods: ${err.message}`); + return; + } + renderArkModsTab(); +} + +function renderArkModsTab() { + const wrap = $("#im-arkm-wrap"); + const eff = _arkModsEffectiveLists(); + const active = eff.active; + const passive = eff.passive; + const inst = allInstances.find(x => x.instance_id === instanceModalState.id); + const isRunning = inst && inst.status === "running"; + + const installedRow = (m, kind, idx, total) => { + const pendState = m._pending; // "add" | "remove" | null + const pendBadge = pendState === "add" ? `+ to install` + : pendState === "remove" ? `– will remove` + : ""; + const cardCls = pendState ? `dzm-mod-row ${kind} arkm-pending arkm-pending-${pendState}` : `dzm-mod-row ${kind}`; + // Action button: cancel-add OR cancel-remove OR stage-remove. + const actionBtn = pendState === "add" + ? `` + : pendState === "remove" + ? `` + : ``; + return `
    +
    + ${idx + 1} + /${total} +
    +
    + ${m.logo_url ? `` : '🧩'} +
    +
    +
    ${esc(m.title || 'Mod ' + m.id)} ${pendBadge}
    +
    + + ${m.author ? `by ${esc(m.author)}` : ''} + ${kind === 'client' ? '-mods=' : '-passivemods='} +
    +
    +
    + + +
    + ${actionBtn} +
    `; + }; + + wrap.innerHTML = ` +
    +
    +
    +
    +
    🧩 CurseForge mods
    +
    Search by name, or paste a CurseForge URL / project ID. The server downloads mods itself on start — just queue them here and restart.
    +
    +
    +
    + 🔎 + + + +
    +
    + +
    +
    +
    + +
    +
    +

    🎮 Active mods (${active.length})

    + clients must subscribe · loaded with -mods= +
    + ${active.length + ? `
    ${active.map((m, i) => installedRow(m, 'client', i, active.length)).join('')}
    ` + : `
    ${EMPTY_ICONS.server}
    No active mods yet. Search above to install one.
    `} + +
    +

    ⚙️ Passive / server-side mods (${passive.length})

    + server-only · loaded with -passivemods= +
    + ${passive.length + ? `
    ${passive.map((m, i) => installedRow(m, 'server', i, passive.length)).join('')}
    ` + : `
    Passive mods are balance or admin tools that shouldn't force clients to download anything — e.g. server-side cryofridge balance tweaks.
    `} +
    + +
    +
    + 💡 How ASA mod downloads work: When you install a mod here, the panel adds its ID to the server's launch args (-mods=id1,id2,id3). The next time the server starts, ASA's built-in CurseForge downloader pulls the mod files. First-boot downloads take a few minutes — watch the Console tab. +

    + 🔢 Load order matters. Mods load top-to-bottom in the order shown — whatever's at position 1 wins conflicts. Use ▲ / ▼ to reorder. +

    + 🛠 Stage, then Apply. Installs / removals / reorders only happen when you click Apply at the bottom — that runs ONE recreate instead of one per mod. Stop the server first if you want to add many mods without disrupting players. +
    +
    + + + +
    `; + + const input = $("#im-arkm-search-input"); + input.addEventListener("input", onArkModSearchInput); + input.addEventListener("keydown", e => { + if (e.key === "Enter") { + const v = input.value.trim(); + const m = v.match(/(\d{4,12})/); + if (m) { stageArkModAdd(m[1]); input.value = ""; renderArkSearchResults(); } + } else if (e.key === "Escape") { + input.value = ""; onArkModSearchInput({ target: input }); + } + }); + $("#im-arkm-search-clear").addEventListener("click", () => { + input.value = ""; input.focus(); onArkModSearchInput({ target: input }); + }); + // Stage buttons (replace previous immediate-POST handlers). + $$("#im-arkm-wrap [data-arkm-stage-remove]").forEach(b => b.addEventListener("click", () => stageArkModRemove(b.dataset.arkmStageRemove))); + $$("#im-arkm-wrap [data-arkm-cancel-remove]").forEach(b => b.addEventListener("click", () => unstageArkModRemove(b.dataset.arkmCancelRemove))); + $$("#im-arkm-wrap [data-arkm-cancel-add]").forEach(b => b.addEventListener("click", () => unstageArkModAdd(b.dataset.arkmCancelAdd))); + $$("#im-arkm-wrap [data-arkm-info]").forEach(b => b.addEventListener("click", () => openArkModInfoModal(b.dataset.arkmInfo))); + $$("#im-arkm-wrap [data-arkm-move]").forEach(b => b.addEventListener("click", () => stageArkModMove(b.dataset.arkmId, b.dataset.arkmKind, b.dataset.arkmMove))); + + // Footer wiring. + _arkModsRefreshFooter(); + $("#arkm-pending-reset")?.addEventListener("click", () => { + _arkModsResetPending(); + renderArkModsTab(); + }); + $("#arkm-pending-apply")?.addEventListener("click", applyArkModsPending); + + renderArkSearchResults(); + if (!arkModsState.searchTerm) setTimeout(() => input && input.focus(), 50); +} + +function _arkModsRefreshFooter() { + const foot = $("#arkm-pending-footer"); + if (!foot) return; + const p = arkModsState.pending; + const adds = p.adds.size; + const removes = p.removes.size; + const orders = (p.activeOrder ? 1 : 0) + (p.passiveOrder ? 1 : 0); + const total = adds + removes + orders; + const status = $("#arkm-pending-status"); + if (total === 0) { + foot.style.display = "none"; + return; + } + foot.style.display = "flex"; + const bits = []; + if (adds) bits.push(`${adds} to install`); + if (removes) bits.push(`${removes} to remove`); + if (orders) bits.push(`load order changed`); + status.textContent = `${bits.join(" · ")}`; +} + +// Stage a mod for installation (does NOT POST). Looks up metadata for +// the title/logo so the staged row in the active list reads cleanly. +async function stageArkModAdd(modID) { + const id = instanceModalState.id; if (!id) return; + const mid = String(modID); + // Skip if already installed or already staged. + if (arkModsState.mods.some(m => String(m.id) === mid)) { + // If currently staged for removal, treat the click as "cancel removal". + if (arkModsState.pending.removes.has(mid)) { + arkModsState.pending.removes.delete(mid); + renderArkModsTab(); + return; + } + toast(`${mid} is already installed`, "ok"); return; + } + if (arkModsState.pending.adds.has(mid)) { toast(`${mid} is already staged`, "ok"); return; } + const passive = !!($("#im-arkm-install-passive") && $("#im-arkm-install-passive").checked); + // Best-effort metadata fetch for the row title. + let title = `Mod ${mid}`, logo_url = "", author = ""; + try { + const d = await fetch(`/api/ark/mods/detail?id=${encodeURIComponent(mid)}`).then(r => r.ok ? r.json() : null); + if (d) { title = d.name || title; logo_url = d.logo_url || ""; author = d.author || ""; } + } catch {} + arkModsState.pending.adds.set(mid, { modID: mid, passive, title, logo_url, author }); + renderArkModsTab(); +} + +function unstageArkModAdd(modID) { + arkModsState.pending.adds.delete(String(modID)); + renderArkModsTab(); +} + +function stageArkModRemove(modID) { + arkModsState.pending.removes.add(String(modID)); + renderArkModsTab(); +} + +function unstageArkModRemove(modID) { + arkModsState.pending.removes.delete(String(modID)); + renderArkModsTab(); +} + +// Stage a one-position swap. Updates pending.activeOrder or +// pending.passiveOrder (the full list as currently displayed, after +// applying the swap). +function stageArkModMove(modID, kind, direction) { + const eff = _arkModsEffectiveLists(); + const list = kind === "server" ? eff.passive : eff.active; + const idx = list.findIndex(m => String(m.id) === String(modID)); + if (idx < 0) return; + const target = direction === "up" ? idx - 1 : idx + 1; + if (target < 0 || target >= list.length) return; + const newList = list.slice(); + [newList[idx], newList[target]] = [newList[target], newList[idx]]; + const ids = newList.map(m => String(m.id)); + if (kind === "server") arkModsState.pending.passiveOrder = ids; + else arkModsState.pending.activeOrder = ids; + renderArkModsTab(); +} + +async function applyArkModsPending() { + const id = instanceModalState.id; if (!id) return; + const p = arkModsState.pending; + if (_arkModsPendingCount() === 0) { toast("No pending changes", "ok"); return; } + const inst = allInstances.find(x => x.instance_id === id); + const isRunning = inst && inst.status === "running"; + if (isRunning) { + if (!confirm(`Apply ${_arkModsPendingCount()} mod change(s)?\n\nThe server will stop, recreate with the new launch args, and restart. Connected players drop briefly. First-boot mod downloads can take several minutes.`)) return; + } + const body = { + adds: Array.from(p.adds.values()).map(a => ({ mod_id: a.modID, passive: !!a.passive })), + removes: Array.from(p.removes), + }; + if (p.activeOrder) body.active_order = p.activeOrder; + if (p.passiveOrder) body.passive_order = p.passiveOrder; + const btn = $("#arkm-pending-apply"); const reset = $("#arkm-pending-reset"); + if (btn) { btn.disabled = true; btn.textContent = "Applying…"; } + if (reset) reset.disabled = true; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods/batch`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify(body), + }); + toast(isRunning ? "Mods applied — server restarting" : "Mods applied — start the server to download", "ok"); + _arkModsResetPending(); + await loadArkModsTab(); + pollInstances(); + } catch (err) { + toast(`Apply failed: ${err.message}`, "err"); + if (btn) { btn.disabled = false; } + if (reset) reset.disabled = false; + _arkModsRefreshFooter(); + } +} + +function onArkModSearchInput(e) { + const v = (e.target.value || "").trim(); + arkModsState.searchTerm = v; + const clear = $("#im-arkm-search-clear"); + if (clear) clear.style.display = v ? "block" : "none"; + clearTimeout(arkModsState.searchDebounce); + if (arkModsState.searchController) { try { arkModsState.searchController.abort(); } catch {} } + if (!v) { + arkModsState.searchResults = []; + arkModsState.searchPreview = null; + toggleArkSearchSpin(false); + renderArkSearchResults(); + return; + } + toggleArkSearchSpin(true); + arkModsState.searchDebounce = setTimeout(() => runArkLiveSearch(v), 300); +} + +function toggleArkSearchSpin(on) { + const s = $("#im-arkm-search-spin"); + if (s) s.classList.toggle("on", !!on); +} + +async function runArkLiveSearch(q) { + const ctrl = new AbortController(); + arkModsState.searchController = ctrl; + try { + const idMatch = q.match(/^\s*(?:https?:\/\/\S+?)?(?:[?&/])?(\d{4,12})\s*$/); + if (idMatch) { + const mid = idMatch[1]; + const d = await fetch(`/api/ark/mods/detail?id=${encodeURIComponent(mid)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + arkModsState.searchPreview = d; + arkModsState.searchResults = []; + } else { + const d = await fetch(`/api/ark/mods/search?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + arkModsState.searchResults = d.items || []; + arkModsState.searchPreview = null; + } + } catch (err) { + if (err.name === "AbortError") return; + console.warn("ark live search failed", err); + } finally { + if (arkModsState.searchController === ctrl) { + arkModsState.searchController = null; + toggleArkSearchSpin(false); + } + } + renderArkSearchResults(); +} + +function renderArkSearchResults() { + const host = $("#im-arkm-search-results"); if (!host) return; + const q = arkModsState.searchTerm || ""; + if (!q) { host.innerHTML = ""; return; } + if (arkModsState.searchPreview) { + host.innerHTML = renderArkSearchPreview(arkModsState.searchPreview); + const btn = $("#im-arkm-preview-install"); + if (btn) btn.addEventListener("click", () => { + const d = arkModsState.searchPreview; + stageArkModAdd(d.id); + }); + const info = $("#im-arkm-preview-info"); + if (info) info.addEventListener("click", () => openArkModInfoModal(arkModsState.searchPreview.id)); + return; + } + const items = arkModsState.searchResults || []; + if (!items.length) { + host.innerHTML = `
    ${EMPTY_ICONS.search}
    No results for "${esc(q)}". Try a shorter term, or paste a CurseForge URL.
    `; + return; + } + const installed = new Set(arkModsState.mods.map(m => String(m.id))); + host.innerHTML = `
    ${items.slice(0, 30).map(it => arkSearchCard(it, installed.has(String(it.id)))).join("")}
    `; + $$("#im-arkm-search-results [data-arkm-card]").forEach(c => + c.addEventListener("click", e => { + if (e.target.closest("[data-install-search]")) return; + openArkModInfoModal(c.dataset.arkmCard); + })); + $$("#im-arkm-search-results [data-install-search]").forEach(b => + b.addEventListener("click", e => { + e.stopPropagation(); + stageArkModAdd(b.dataset.installSearch); + })); +} + +function arkSearchCard(it, isInstalled) { + const fmtDownloads = (n) => { + n = Number(n) || 0; + if (n < 1000) return String(n); + if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k"; + return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M"; + }; + return `
    + ${it.logo_url ? `` : ''} +
    +
    ${esc(it.name || '(untitled)')}
    +
    + ${it.author ? `${esc(it.author)}` : ''} + ${it.download_count ? `· ⬇ ${fmtDownloads(it.download_count)}` : ''} +
    +
    + ${isInstalled + ? `✓ Installed` + : ``} +
    +
    +
    `; +} + +function renderArkSearchPreview(d) { + const fmtSize = (b) => { if (!b) return "?"; const mb = b/1048576; if (mb < 1) return `${Math.round(b/1024)} KB`; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb/1024).toFixed(2)} GB`; }; + const installed = arkModsState.mods.some(m => String(m.id) === String(d.id)); + return `
    + ${d.logo_url ? `` : `
    no preview
    `} +
    +
    ${esc(d.name || 'Mod #' + d.id)}
    +
    + 🆔 ${esc(d.id)} + ${d.author ? `👤 ${esc(d.author)}` : ''} + ${d.latest_size ? `📦 ${fmtSize(d.latest_size)}` : ''} + ${d.download_count ? `⬇ ${Number(d.download_count).toLocaleString()}` : ''} +
    +
    ${esc(d.summary || 'Paste a URL or ID — we\'ll fetch CurseForge metadata and stage the install.')}
    +
    +
    + ${installed + ? `✓ Installed` + : ``} + +
    +
    `; +} + +async function runArkModInstall(modID) { + if (arkModsState.installing) return; + const id = instanceModalState.id; if (!id) return; + const passive = !!($("#im-arkm-install-passive") && $("#im-arkm-install-passive").checked); + const body = JSON.stringify({ mod_id: String(modID), passive }); + arkModsState.installing = true; + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods`, + { method: "POST", headers: {"Content-Type":"application/json"}, body }); + if (r.duplicate) toast(`Already installed`, "ok"); + else toast(`Installed ${r.title || modID}${passive ? " (passive)" : ""} — start the server to download`, "ok"); + await loadArkModsTab(); + } catch (err) { + toast(`Install failed: ${err.message}`, "err"); + } finally { + arkModsState.installing = false; + } +} + +// runArkModMove shifts a mod one position up or down within its list +// (active or passive). Swaps with the neighbour, then calls the reorder +// endpoint with full replacement lists — that does the +// stop→delete-preserve→create-with-new-env→start dance. +async function runArkModMove(modID, kind, direction) { + const id = instanceModalState.id; if (!id) return; + const passive = kind === "server"; + const list = (arkModsState.mods || []).filter(m => !!m.passive === passive).map(m => String(m.id)); + const otherList = (arkModsState.mods || []).filter(m => !!m.passive !== passive).map(m => String(m.id)); + const idx = list.indexOf(String(modID)); + if (idx < 0) return; + const target = direction === "up" ? idx - 1 : idx + 1; + if (target < 0 || target >= list.length) return; + // Swap. + [list[idx], list[target]] = [list[target], list[idx]]; + const body = JSON.stringify(passive + ? { active: otherList, passive: list } + : { active: list, passive: otherList }); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods/order`, + { method: "PUT", headers: {"Content-Type":"application/json"}, body }); + await loadArkModsTab(); + } catch (err) { + toast(`Reorder failed: ${err.message}`, "err"); + } +} + +async function runArkModUninstall(modID) { + const id = instanceModalState.id; if (!id) return; + const m = arkModsState.mods.find(x => String(x.id) === String(modID)); + if (!confirm(`Uninstall ${m ? m.title || modID : modID}? The server will stop + recreate without this mod in the launch args.`)) return; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/mods/${encodeURIComponent(modID)}`, + { method: "DELETE" }); + toast(`Removed ${m ? m.title : modID}`, "ok"); + await loadArkModsTab(); + } catch (err) { + toast(`Uninstall failed: ${err.message}`, "err"); + } +} + +// ---- mod info modal (ARK / CurseForge) ---- +// Reuses the #mod-info-modal DOM that DayZ uses. Data source differs: +// we hit /api/ark/mods/detail which returns CurseForge-normalized JSON +// with a full HTML description. + +async function openArkModInfoModal(modID) { + const body = $("#mi-body"); + $("#mi-title").textContent = "Mod info"; + body.innerHTML = loadingState("Loading CurseForge details…"); + showModal("#mod-info-modal"); + let d; + try { + d = await fetchJSON(`/api/ark/mods/detail?id=${encodeURIComponent(modID)}`); + } catch (err) { + body.innerHTML = errorState(`Couldn't load details: ${err.message}`); + return; + } + $("#mi-title").textContent = d.name || "Mod info"; + const installed = arkModsState.mods.some(m => String(m.id) === String(d.id)); + const fmtSize = (b) => { if (!b) return "?"; const mb = b/1048576; if (mb < 1) return `${Math.round(b/1024)} KB`; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb/1024).toFixed(2)} GB`; }; + const fmtDate = (s) => { if (!s) return "—"; const t = new Date(s); return isNaN(t) ? "—" : t.toLocaleDateString(); }; + const fmtNum = (n) => (Number(n)||0).toLocaleString(); + // Sanitise the CurseForge description HTML. We allow links, b, i, u, + // br, p, h1-6, ul, ol, li, img, pre, code — strip everything else, + // strip inline event handlers / scripts. + const sanitiseHTML = (html) => { + if (!html) return "No description."; + const allowedTags = /^(a|b|strong|i|em|u|br|p|h[1-6]|ul|ol|li|img|pre|code|blockquote|hr|div|span|table|thead|tbody|tr|th|td)$/i; + const template = document.createElement("template"); + template.innerHTML = html; + const clean = (node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + if (!allowedTags.test(node.tagName)) { + const parent = node.parentNode; + while (node.firstChild) parent.insertBefore(node.firstChild, node); + parent.removeChild(node); + return; + } + for (const a of [...node.attributes]) { + if (/^on/i.test(a.name)) node.removeAttribute(a.name); + else if ((a.name === "href" || a.name === "src") && /^javascript:/i.test(a.value)) node.removeAttribute(a.name); + else if (!/^(href|src|alt|title|class|colspan|rowspan|target|rel)$/i.test(a.name)) node.removeAttribute(a.name); + } + if (node.tagName === "A") { node.setAttribute("target","_blank"); node.setAttribute("rel","noopener"); } + } + for (const child of [...node.childNodes]) clean(child); + }; + clean(template.content); + return template.innerHTML; + }; + + body.innerHTML = ` +
    +
    + ${d.logo_url ? `` : ''} +
    +
    Downloads${fmtNum(d.download_count)}
    +
    Latest file${fmtSize(d.latest_size)}
    +
    Updated${fmtDate(d.updated)}
    +
    Released${fmtDate(d.released)}
    + ${d.rank ? `
    Popularity#${d.rank}
    ` : ''} + ${d.rating ? `
    Rating${Number(d.rating).toFixed(1)}% (${fmtNum(d.ratings_total)})
    ` : ''} +
    +
    + ${d.website ? `↗ Open on CurseForge` : ''} +
    +
    +
    + ${(d.categories || []).length ? `
    + ${d.categories.map(tagChip).join("")} +
    ` : ''} +
    + ${d.summary ? `

    ${esc(d.summary)}

    ` : ''} + ${sanitiseHTML(d.description_html || "")} +
    +
    +
    +
    + + + ${installed + ? installedBadge() + : ``} +
    `; + const btn = $("#mi-arkm-install"); + if (btn) btn.addEventListener("click", async () => { + btn.disabled = true; btn.textContent = "⏳ Staging…"; + try { + const passive = !!($("#mi-arkm-passive") && $("#mi-arkm-passive").checked); + if ($("#im-arkm-install-passive")) $("#im-arkm-install-passive").checked = passive; + await stageArkModAdd(d.id); + hideModal("#mod-info-modal"); + toast(`Staged ${d.name || d.id} — review and Apply on the Mods tab`, "ok"); + } catch (err) { + toast(`Stage failed: ${err.message}`, "err"); + } finally { + btn.disabled = false; btn.textContent = "+ Stage install"; + } + }); +} + +// ---------------- ARK CLUSTER tab ---------------- +// +// One-click cluster setup for ARK: Survival Ascended. Two modes: +// - NOT clustered → "🔗 Setup New Cluster" button (generates id + makes +// the first member) + "➕ Join existing" dropdown when other clusters +// already exist on the node. +// - Clustered → shows the cluster's name, siblings list, and a "Leave" +// button. Explains that leaving recreates the container without the +// shared mount. +// +// All actions are synchronous from the UI's perspective: the backend does +// stop → delete-preserve → recreate → start internally. The UI disables +// buttons + shows a spinner during that (~20-60s) cycle. + +const arkClusterState = { + currentCluster: null, // {id, name, siblings} or null + allClusters: [], // all clusters on the node (for Join dropdown) + loading: false, +}; + +async function loadArkClusterTab() { + const id = instanceModalState.id; const wrap = $("#im-ark-wrap"); if (!id) return; + wrap.innerHTML = loadingState("Loading cluster info…"); + try { + const [mine, all] = await Promise.all([ + fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster`), + fetchJSON(`/api/ark/clusters`), + ]); + arkClusterState.currentCluster = mine.cluster || null; + arkClusterState.allClusters = all.clusters || []; + } catch (err) { + wrap.innerHTML = errorState(`Couldn't load cluster state: ${err.message}`); + return; + } + renderArkClusterTab(); +} + +function renderArkClusterTab() { + const wrap = $("#im-ark-wrap"); if (!wrap) return; + const id = instanceModalState.id; + const cluster = arkClusterState.currentCluster; + const others = arkClusterState.allClusters.filter(c => !cluster || c.id !== cluster.id); + + wrap.innerHTML = ` +
    + + +
    +
    + 🔗 +
    +
    ARK cluster manager
    +
    Share character + dino transfers across multiple ARK maps on this node.
    +
    +
    +
    + Setting up a cluster creates a shared Docker volume mounted into this server at + Saved/clusters. Any other ARK SA server on this node can then join the same cluster with one click — they'll all share character uploads, downloads, and dino transfers. The server auto-restarts to apply the mount change. +
    +
    + + ${cluster + ? renderArkClusterJoined(cluster) + : renderArkClusterStandalone(others)} + +
    `; + + // Wire handlers depending on current state. + if (cluster) { + const leaveBtn = $("#im-ark-leave"); + if (leaveBtn) leaveBtn.addEventListener("click", () => arkClusterLeave()); + const shareBtn = $("#im-ark-share"); + if (shareBtn) shareBtn.addEventListener("click", () => arkClusterShareModal()); + } else { + const setupBtn = $("#im-ark-setup"); + if (setupBtn) setupBtn.addEventListener("click", () => arkClusterSetup()); + const joinBtn = $("#im-ark-join"); + if (joinBtn) joinBtn.addEventListener("click", () => { + const sel = $("#im-ark-join-picker"); + if (sel && sel.value) arkClusterJoin(sel.value); + }); + } +} + +function renderArkClusterJoined(c) { + const siblings = c.siblings || []; + // Drift banner — set by the controller when the registry says this + // instance is in a cluster but the persisted CONFIG_VALUES.CLUSTER_ID + // doesn't match (= the recreate dance was rolled back or never + // actually applied). Operator must Leave + re-Join to repair the + // mount; otherwise transfers won't sync to siblings. + const driftBanner = c.mount_drift ? ` +
    + +
    +
    Cluster mount didn't take
    +
    + The cluster registry shows this server as a member, but its container is still using the per-instance volume — transfers won't sync to siblings. + Click Leave cluster, then re-join to recreate the container with the correct shared mount. +
    +
    +
    ` : ""; + return ` +
    + ${driftBanner} +
    +
    +
    ${esc(c.name || 'Cluster')}
    +
    ${esc(c.id)}
    +
    + ${c.mount_drift ? '⚠ Member (mount broken)' : '✓ Member of this cluster'} +
    +
    + ${siblings.length === 0 ? 'No siblings yet' : siblings.length + ' other server' + (siblings.length === 1 ? '' : 's') + ' in this cluster'} + ${siblings.length ? `
    ${siblings.map(esc).join(" · ")}
    ` : ''} +
    +
    + Transfers, dinos, and characters uploaded on this server appear on all siblings. Any new ARK SA server you create on this node can click Join Cluster and pick ${esc(c.name || c.id)} from the dropdown. +
    +
    + ${siblings.length ? `` : ""} + +
    +
    `; +} + +function renderArkClusterStandalone(others) { + return ` +
    +
    🆕 Set up a new cluster
    +
    + Creates a fresh shared volume for transfers and makes this server its first member. Other ARK servers on this node will then see a "Join Cluster" option. +
    +
    + +
    +
    + + ${others.length ? ` +
    +
    ➕ Join an existing cluster
    +
    + There ${others.length === 1 ? 'is 1 cluster' : 'are ' + others.length + ' clusters'} already set up on this node. Pick one to join — this server will be recreated with the shared cluster volume. +
    +
    + + +
    +
    ` : ` +
    🔗
    No other clusters on this node yet. Set one up to start sharing transfers across ARK maps.
    `}`; +} + +async function arkClusterSetup() { + const id = instanceModalState.id; if (!id || arkClusterState.loading) return; + if (!confirm("Setup a new ARK cluster for this server? The server will stop, recreate with a shared cluster volume, and restart if it was running. World data is preserved.")) return; + arkClusterState.loading = true; + setArkClusterBusy(true, "Creating cluster + recreating server…"); + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster`, + { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({}) }); + toast(`Cluster ${r.cluster_id} set up`, "ok"); + await loadArkClusterTab(); + } catch (err) { + toast(`Cluster setup failed: ${err.message}`, "err"); + } finally { + arkClusterState.loading = false; + } +} + +async function arkClusterJoin(clusterID) { + const id = instanceModalState.id; if (!id || arkClusterState.loading) return; + if (!confirm(`Join cluster ${clusterID}? The server will stop, recreate with the shared cluster volume, and restart if it was running.`)) return; + arkClusterState.loading = true; + setArkClusterBusy(true, "Joining cluster + recreating server…"); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster/join`, + { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ cluster_id: clusterID }) }); + toast(`Joined cluster ${clusterID}`, "ok"); + await loadArkClusterTab(); + } catch (err) { + toast(`Join failed: ${err.message}`, "err"); + } finally { + arkClusterState.loading = false; + } +} + +async function arkClusterLeave() { + const id = instanceModalState.id; if (!id || arkClusterState.loading) return; + if (!confirm("Leave the cluster? The server will recreate with its own per-instance cluster volume. Shared transfers will no longer reach siblings.")) return; + arkClusterState.loading = true; + setArkClusterBusy(true, "Leaving cluster + recreating server…"); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/cluster`, + { method: "DELETE" }); + toast(`Left cluster`, "ok"); + await loadArkClusterTab(); + } catch (err) { + toast(`Leave failed: ${err.message}`, "err"); + } finally { + arkClusterState.loading = false; + } +} + +// ==================================================================== +// "Copy settings to siblings" — broadcast this server's INI multipliers +// + selected env vars to every other ARK server in the same cluster. +// +// Source of truth for what's safe to copy is per-group, derived from the +// existing config schema. Defaults reflect cluster-shared norms: rates, +// modes, mods all match across cluster; identity (passwords, MOTD) and +// network (RCON port, slot count) stay per-server. Map and session name +// are NEVER copied — they're definitionally per-instance. +// +// Apply path: +// - INI changes write via /files (no recreate; effective on next +// server restart, just like a normal Apply). +// - Env changes write via /env-config (forces a recreate dance per +// sibling, ~30s each). +// ==================================================================== +// Groups that cluster-share never offers — Identity (passwords / MOTD) +// and Slots & Network (RCON port / reserved slots) are always per- +// server, so showing them as checkboxes invites a foot-gun. +const ARK_SHARE_INI_HIDE_GROUPS = new Set([ + "Identity", + "Slots & Network", +]); +const ARK_SHARE_ENV_GROUPS = [ + { id: "mods", label: "Mod IDs / Passive mods", + hint: "Cluster-wide mod parity is required — survivors transferring between maps lose mod-gated items if a target server isn't running the same mods.", + props: ["MOD_IDS", "PASSIVE_MODS"], defaultOn: true }, + { id: "battleeye", label: "BattlEye anti-cheat", + hint: "Cluster-wide for consistent player experience.", + props: ["BATTLEEYE"], defaultOn: true }, + { id: "auth", label: "Admin + server passwords", + hint: "Optional. Off by default — operators sometimes use per-map passwords.", + props: ["SERVER_ADMIN_PASSWORD", "SERVER_PASSWORD"], defaultOn: false }, + { id: "capacity", label: "Max players cap", + hint: "Off by default — many operators tune this per map for performance.", + props: ["MAX_PLAYERS"], defaultOn: false }, +]; + +async function arkClusterShareModal() { + const id = instanceModalState.id; if (!id) return; + const cluster = arkClusterState.currentCluster; + if (!cluster) return; + const siblings = cluster.siblings || []; + if (!siblings.length) { toast("No sibling servers to copy to.", "err"); return; } + + const schema = configSchemas["ark-sa"]; + if (!schema) { toast("ARK schema not loaded.", "err"); return; } + + const overlay = document.createElement("div"); + overlay.className = "ark-share-overlay"; + overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:1100;padding:20px"; + overlay.innerHTML = ` +
    +
    +
    📤 Copy settings from ${esc(id)} to siblings
    + +
    +
    +
    +
    + INI changes don't recreate the target containers — they take effect on each sibling's next restart. + Env changes (mods, BattlEye, passwords) do recreate their containers (~30s each). + Tick "Restart siblings sequentially" below to roll restarts on INI-only targets after the copy. +
    +
    + +
    +
    Targets (${siblings.length})
    +
    + ${siblings.map(s => ` + + `).join("")} +
    +
    + +
    +
    INI settings (effective on next restart of each sibling)
    +
    + ${(schema.groups || []).map(g => { + if (ARK_SHARE_INI_HIDE_GROUPS.has(g.title)) return ""; + const fieldCount = (g.fields || []).length; + if (!fieldCount) return ""; + return ` + `; + }).join("")} +
    +
    + +
    +
    Launch / env settings (recreates each sibling)
    +
    + ${ARK_SHARE_ENV_GROUPS.map(g => ` + + `).join("")} +
    +
    +
    +
    + + + +
    +
    `; + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.addEventListener("click", e => { + if (e.target === overlay || (e.target.dataset && e.target.dataset.close !== undefined)) close(); + }); + overlay.querySelector("#ark-share-apply").addEventListener("click", async () => { + const targets = Array.from(overlay.querySelectorAll(".ark-share-target:checked")).map(el => el.dataset.sib); + const iniGroups = new Set(Array.from(overlay.querySelectorAll(".ark-share-ini-grp:checked")).map(el => el.dataset.grp)); + const envGroups = new Set(Array.from(overlay.querySelectorAll(".ark-share-env-grp:checked")).map(el => el.dataset.grp)); + const restartAfter = !!overlay.querySelector("#ark-share-restart").checked; + if (!targets.length) { toast("Pick at least one target sibling.", "err"); return; } + if (!iniGroups.size && !envGroups.size) { toast("Pick at least one settings group.", "err"); return; } + const btn = overlay.querySelector("#ark-share-apply"); + btn.disabled = true; + try { + await arkClusterShareApply({ sourceID: id, targets, iniGroups, envGroups, restartAfter, statusEl: btn }); + close(); + } catch (err) { + toast("Cluster share failed: " + err.message, "err", 8000); + btn.disabled = false; + btn.textContent = "📤 Copy & apply"; + } + }); +} + +// Builds the {prop -> value} maps to push to siblings, fetches the +// source instance's INI, then writes per-sibling. Sequential to avoid +// dogpiling the agent + cluster recreate flow. +async function arkClusterShareApply({ sourceID, targets, iniGroups, envGroups, restartAfter, statusEl }) { + const schema = configSchemas["ark-sa"]; + + // Build the INI prop set from the chosen groups. + const iniProps = []; + for (const g of (schema.groups || [])) { + if (!iniGroups.has(g.title)) continue; + for (const f of (g.fields || [])) iniProps.push(f.prop); + } + + // Build the env prop set. + const envProps = []; + for (const g of ARK_SHARE_ENV_GROUPS) { + if (!envGroups.has(g.id)) continue; + for (const p of g.props) envProps.push(p); + } + + // Pull source values. INI: read the file, parse, pluck the selected + // keys. Env: read directly from the cached instance row. + const iniUpdates = {}; + if (iniProps.length) { + if (statusEl) statusEl.textContent = "Reading source config…"; + let srcContent = null; + for (const p of schema.filePaths) { + try { + const q = new URLSearchParams({ path: p }); + const r = await fetchJSON(`/api/instances/${encodeURIComponent(sourceID)}/files/read?${q}`); + srcContent = r.content; break; + } catch {} + } + if (srcContent == null) throw new Error("couldn't read source GameUserSettings.ini"); + const parsed = parseINI(srcContent); + for (const p of iniProps) { + if (parsed.values[p] !== undefined) iniUpdates[p] = parsed.values[p]; + } + } + + const envUpdates = {}; + if (envProps.length) { + const srcInst = allInstances.find(x => x.instance_id === sourceID); + const cv = (srcInst && srcInst.config_values) || {}; + for (const p of envProps) { + // Skip empty strings — server-side env-config treats "" as a + // delete signal, so propagating "" would clobber the target's + // existing value. That's how three ARK instances lost their + // MAP_NAME/SESSION_NAME/MOD_IDS via cluster-share. + if (cv[p] !== undefined && cv[p] !== "") envUpdates[p] = cv[p]; + } + } + + if (!Object.keys(iniUpdates).length && !Object.keys(envUpdates).length) { + throw new Error("source has no values set for the chosen groups"); + } + + // Apply per sibling. We do them sequentially so a recreate dance + // doesn't trigger 5 agents at once on big clusters. Errors collect; + // we keep going and show a summary toast at the end. + const results = []; + let i = 0; + const envApplied = Object.keys(envUpdates).length > 0; + for (const tgt of targets) { + i++; + if (statusEl) statusEl.textContent = `Apply (${i}/${targets.length}) ${tgt}…`; + let appliedINI = false, appliedEnv = false; + try { + // INI write — read sibling's existing file, splice in our updates, + // write back. This preserves their unrelated settings. + if (Object.keys(iniUpdates).length) { + let tgtContent = null, tgtPath = null; + for (const p of schema.filePaths) { + try { + const q = new URLSearchParams({ path: p }); + const r = await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/files/read?${q}`); + tgtContent = r.content; tgtPath = p; break; + } catch {} + } + if (tgtContent == null) throw new Error("can't read GameUserSettings.ini"); + const parsed = parseINI(tgtContent); + const newContent = serializeINI(parsed, iniUpdates); + await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/files`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ path: tgtPath, content: newContent }), + }); + appliedINI = true; + } + // Env write — single POST that does the recreate dance agent-side. + if (envApplied) { + await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/env-config`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ updates: envUpdates }), + }); + appliedEnv = true; + } + results.push({ tgt, ok: true, ini: appliedINI, env: appliedEnv }); + toast(`✓ ${tgt}`, "ok", 3500); + } catch (err) { + results.push({ tgt, ok: false, err: err.message }); + toast(`✗ ${tgt}: ${err.message}`, "err", 6000); + } + } + + const ok = results.filter(r => r.ok).length; + const fail = results.length - ok; + toast(`Cluster share: ${ok}/${results.length} ok${fail ? `, ${fail} failed` : ""}`, fail ? "warn" : "ok", 6000); + + // Refresh the live instance list so the cluster tab + main grid pick + // up new env values (if env was applied). + pollInstances(); + + // Optional sequential restart pass. Skips siblings whose env was + // applied — env-config already runs a recreate dance (stop → delete- + // preserve → create → start), so re-restarting them now would race + // that in-flight cycle. Only INI-only siblings need a manual bounce + // to reload GameUserSettings.ini. + if (restartAfter) { + const toRestart = results.filter(r => r.ok && r.ini && !r.env).map(r => r.tgt); + if (!toRestart.length) { + toast(envApplied ? "Restart skipped — env recreate already cycled the targets." : "Nothing to restart.", "ok", 4500); + return; + } + let j = 0; + for (const tgt of toRestart) { + j++; + if (statusEl) statusEl.textContent = `Restart (${j}/${toRestart.length}) ${tgt}…`; + try { + const inst = allInstances.find(x => x.instance_id === tgt); + const wasRunning = inst && inst.status === "running"; + if (wasRunning) { + await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/stop?grace=30`, { method: "POST" }); + } + // Always issue start — picks up newly-stopped instances cleanly, + // and a no-op start on an already-running instance returns 200. + await fetchJSON(`/api/instances/${encodeURIComponent(tgt)}/start`, { method: "POST" }); + toast(`↻ restarted ${tgt}`, "ok", 3500); + } catch (err) { + toast(`✗ restart ${tgt}: ${err.message}`, "err", 6000); + } + } + toast(`Restart pass: ${toRestart.length} sibling${toRestart.length === 1 ? "" : "s"} cycled`, "ok", 5000); + pollInstances(); + } +} + +function setArkClusterBusy(busy, msg) { + const wrap = $("#im-ark-wrap"); if (!wrap) return; + if (busy) { + wrap.innerHTML = `
    +
    +
    ${esc(msg || 'working…')}
    +
    This takes ~30-60 s (stop → delete → recreate → start)
    +
    `; + } +} + +// ---------------- 7DTD CLUSTER tab ---------------- +// +// Structural clone of the ARK cluster manager. Same JSON shapes, same +// recreate-dance semantics, but 7DTD shares PLAYER PROGRESSION instead of +// character/dino transfers: the world's Player/ save folder is symlinked +// into the cluster's shared /cluster/Player, so members share +// level/skills/inventory while world + bases stay per-server. No +// "copy settings to siblings" feature here — setup / join / leave only. +const sdtdClusterState = { + currentCluster: null, // {id, name, siblings} or null + allClusters: [], // all clusters on the node (for Join dropdown) + loading: false, +}; + +async function loadSdtdClusterTab() { + const id = instanceModalState.id; const wrap = $("#im-sdtd-cluster-wrap"); if (!id) return; + wrap.innerHTML = loadingState("Loading cluster info…"); + try { + const [mine, all] = await Promise.all([ + fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster`), + fetchJSON(`/api/7dtd/clusters`), + ]); + sdtdClusterState.currentCluster = mine.cluster || null; + sdtdClusterState.allClusters = all.clusters || []; + } catch (err) { + wrap.innerHTML = errorState(`Couldn't load cluster state: ${err.message}`); + return; + } + renderSdtdClusterTab(); +} + +function renderSdtdClusterTab() { + const wrap = $("#im-sdtd-cluster-wrap"); if (!wrap) return; + const id = instanceModalState.id; + const cluster = sdtdClusterState.currentCluster; + const others = sdtdClusterState.allClusters.filter(c => !cluster || c.id !== cluster.id); + + wrap.innerHTML = ` +
    + + +
    +
    + 🔗 +
    +
    7DTD cluster manager
    +
    Share player progression across multiple 7 Days to Die worlds on this node.
    +
    +
    +
    + Setting up a cluster creates a shared Docker volume and symlinks this world's + Player/ save folder into the cluster's shared /cluster/Player. Any other 7DTD server on this node can then join the same cluster with one click — members share level, skills, and inventory while each world and its bases stay per-server. The server auto-restarts to apply the mount change. +
    +
    + + ${cluster + ? renderSdtdClusterJoined(cluster) + : renderSdtdClusterStandalone(others)} + + ${renderMedicCard()} + +
    `; + + // Wire handlers depending on current state. + if (cluster) { + const leaveBtn = $("#im-sdtd-leave"); + if (leaveBtn) leaveBtn.addEventListener("click", () => sdtdClusterLeave()); + // Shared-player-world picker: populate from the server's actual worlds and + // pre-select the current pin (CLUSTER_PLAYER_SAVE in this instance's config). + const _inst = allInstances.find(x => x.instance_id === instanceModalState.id); + const _curWorld = (_inst && _inst.config_values && _inst.config_values.CLUSTER_PLAYER_SAVE) || ""; + populateSdtdPlayerWorlds(instanceModalState.id, _curWorld); + const applyWorldBtn = $("#im-sdtd-apply-world"); + if (applyWorldBtn) applyWorldBtn.addEventListener("click", () => sdtdClusterSetPlayerWorld()); + const syncModsBtn = $("#im-sdtd-sync-mods"); + if (syncModsBtn) syncModsBtn.addEventListener("click", () => sdtdClusterSyncMods()); + } else { + const setupBtn = $("#im-sdtd-setup"); + if (setupBtn) setupBtn.addEventListener("click", () => sdtdClusterSetup()); + const joinBtn = $("#im-sdtd-join"); + if (joinBtn) joinBtn.addEventListener("click", () => { + const sel = $("#im-sdtd-join-picker"); + const seed = $("#im-sdtd-seed-mods"); + if (sel && sel.value) sdtdClusterJoin(sel.value, seed ? seed.checked : false); + }); + } + + // Region Medic settings — shown for every 7DTD server (cluster or not). + populateMedicConfig(id); + const medicSaveBtn = $("#im-medic-save"); + if (medicSaveBtn) medicSaveBtn.addEventListener("click", () => saveMedicConfig()); +} + +// Region Medic & heal-alert config card. Settings persist to region-medic.json in +// the saves volume via the /files API (FsWrite) — NO container recreate; they +// apply on the server's next reboot. Read back + the Discord channel picker are +// populated by populateMedicConfig(). +function renderMedicCard() { + return ` +
    +
    + 🩺 +
    +
    Region Medic & heal alerts
    +
    On every reboot — while the server is down — validates the world's region files, heals any corrupt ones from a rolling snapshot (then backups), and keeps fast folder snapshots. Saving here does not restart the server.
    +
    +
    + +
    +
    Rolling region snapshots to keep
    + + full folder copies — instant restore, no unzip +
    +
    +
    🗺 World to monitor
    +
    Which save's regions get validated + healed. Auto-detect uses the active world (GameWorld/GameName) — pick explicitly if this server keeps multiple saves so the medic never touches the wrong one.
    + +
    +
    +
    📢 Discord heal-alert channel
    +
    When a reboot heals corrupt regions, RefugeBot posts here — the alert names this server and which snapshot/backup each region was pulled from. Leave on "No alerts" to stay silent.
    + +
    +
    + +
    +
    `; +} + +// populateMedicConfig loads the current region-medic.json from the saves volume +// and fills the form, then populates the Discord channel picker from the bot's +// channels (pre-selecting the saved one). All best-effort — an absent config or +// unreachable Discord just yields defaults. +async function populateMedicConfig(id) { + let cfg = { enabled: true, keep: 2, discord_channel: "" }; + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files/read?path=${encodeURIComponent("/game-saves/region-medic.json")}`); + if (r && r.content) { try { cfg = Object.assign(cfg, JSON.parse(r.content)); } catch {} } + } catch {} + const en = $("#im-medic-enabled"); if (en) en.checked = cfg.enabled !== false; + const kp = $("#im-medic-keep"); if (kp) kp.value = cfg.keep || 2; + // World picker — enumerate the server's saves (World/GameName holding a main.ttw). + const wsel = $("#im-medic-world"); + if (wsel) { + try { + const ROOT = "/game-saves/.local/share/7DaysToDie/Saves"; + const listDir = async (p) => { try { const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(p)}`); return r.entries || []; } catch { return []; } }; + const worldDirs = (await listDir(ROOT)).filter(e => e.is_dir).map(e => e.name); + const saves = []; + await Promise.all(worldDirs.map(async (world) => { + const games = (await listDir(`${ROOT}/${world}`)).filter(e => e.is_dir).map(e => e.name); + await Promise.all(games.map(async (game) => { + const files = await listDir(`${ROOT}/${world}/${game}`); + if (files.some(e => e.name === "main.ttw")) saves.push(`${world}/${game}`); + })); + })); + saves.sort(); + const cur = cfg.world || ""; + let html = ``; + html += saves.map(s => ``).join(""); + if (cur && !saves.includes(cur)) html += ``; + wsel.innerHTML = html; + } catch { wsel.innerHTML = ``; } + } + const sel = $("#im-medic-channel"); if (!sel) return; + try { + const r = await fetchJSON("/api/discord/channels"); + const chans = (r && r.channels) || []; + let html = `` + + chans.map(c => ``).join(""); + if (cfg.discord_channel && !chans.some(c => c.id === cfg.discord_channel)) { + html += ``; + } + sel.innerHTML = html; + } catch (e) { + sel.innerHTML = cfg.discord_channel + ? `` + : ``; + } +} + +// saveMedicConfig writes region-medic.json into the saves volume via FsWrite. +// No recreate — the medic reads it on the next reboot. +async function saveMedicConfig() { + const id = instanceModalState.id; if (!id) return; + const enabled = $("#im-medic-enabled") ? $("#im-medic-enabled").checked : true; + const keep = Math.max(1, parseInt(($("#im-medic-keep") || {}).value || "2", 10) || 2); + const channel = ($("#im-medic-channel") || {}).value || ""; + const world = ($("#im-medic-world") || {}).value || ""; + const cfg = { enabled, keep, world, discord_channel: channel }; + const btn = $("#im-medic-save"); if (btn) { btn.disabled = true; btn.textContent = "Saving…"; } + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: "/game-saves/region-medic.json", content: JSON.stringify(cfg, null, 2) }) + }); + toast("Region Medic settings saved — applies on next reboot, no restart needed", "ok"); + } catch (err) { + toast(`Save failed: ${err.message}`, "err"); + } finally { + if (btn) { btn.disabled = false; btn.textContent = "Save medic settings"; } + } +} + +function renderSdtdClusterJoined(c) { + const siblings = c.siblings || []; + // Drift banner — set by the controller when the registry says this + // instance is in a cluster but the persisted CONFIG_VALUES.CLUSTER_ID + // doesn't match (= the recreate dance was rolled back or never + // actually applied). Operator must Leave + re-Join to repair the + // mount; otherwise progression won't sync to siblings. + const driftBanner = c.mount_drift ? ` +
    + +
    +
    Cluster mount didn't take
    +
    + The cluster registry shows this server as a member, but its container is still using the per-instance volume — progression won't sync to siblings. + Click Leave cluster, then re-join to recreate the container with the correct shared mount. +
    +
    +
    ` : ""; + return ` +
    + ${driftBanner} +
    +
    +
    ${esc(c.name || 'Cluster')}
    +
    ${esc(c.id)}
    +
    + ${c.mount_drift ? '⚠ Member (mount broken)' : '✓ Member of this cluster'} +
    +
    + ${siblings.length === 0 ? 'No siblings yet' : siblings.length + ' other server' + (siblings.length === 1 ? '' : 's') + ' in this cluster'} + ${siblings.length ? `
    ${siblings.map(esc).join(" · ")}
    ` : ''} +
    +
    + Level, skills, and inventory earned on this server carry across to all siblings — only the world and its bases stay per-server. Any new 7DTD server you create on this node can click Join Cluster and pick ${esc(c.name || c.id)} from the dropdown. +
    +
    +
    🗺 Shared player world
    +
    + Which of this server's worlds shares its Player folder into the cluster. Pick the active map — required for RWG servers (7DTD names the save folder after the generated seed, not "RWG"). Changing this recreates the server (world data preserved). +
    +
    + + +
    +
    + ${c.is_master ? ` +
    +
    📦 Sync mods to members this server is the master
    +
    + Push this server's current Mods/ set to ${siblings.length === 0 ? 'the other members' : siblings.length + ' other member' + (siblings.length === 1 ? '' : 's')}. Upload or update mods here first, then sync. Each member's per-server data (RefugeBot teleport homes) is preserved, and no member is restarted — they load the new mods on their next reboot. +
    + +
    + +
    +
    ` : ''} +
    + +
    +
    `; +} + +function renderSdtdClusterStandalone(others) { + return ` +
    +
    🆕 Set up a new cluster
    +
    + Creates a fresh shared volume for player progression and makes this server its first member. Other 7DTD servers on this node will then see a "Join Cluster" option. +
    +
    + +
    +
    + + ${others.length ? ` +
    +
    ➕ Join an existing cluster
    +
    + There ${others.length === 1 ? 'is 1 cluster' : 'are ' + others.length + ' clusters'} already set up on this node. Pick one to join — this server will be recreated with the shared cluster volume. +
    + +
    + + +
    +
    ` : ` +
    🔗
    No other clusters on this node yet. Set one up to start sharing player progression across 7DTD worlds.
    `}`; +} + +async function sdtdClusterSetup() { + const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return; + if (!confirm("Setup a new 7DTD cluster for this server? The server will stop, recreate with a shared cluster volume, and restart if it was running. World data is preserved.")) return; + sdtdClusterState.loading = true; + setSdtdClusterBusy(true, "Creating cluster + recreating server…"); + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster`, + { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({}) }); + toast(`Cluster ${r.cluster_id} set up`, "ok"); + await loadSdtdClusterTab(); + } catch (err) { + toast(`Cluster setup failed: ${err.message}`, "err"); + } finally { + sdtdClusterState.loading = false; + } +} + +async function sdtdClusterJoin(clusterID, seedMods) { + const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return; + const seedNote = seedMods ? " Its mods will be REPLACED with the cluster master's set (your teleport homes are kept)." : ""; + if (!confirm(`Join cluster ${clusterID}? The server will stop, recreate with the shared cluster volume, and restart if it was running.${seedNote}`)) return; + sdtdClusterState.loading = true; + setSdtdClusterBusy(true, seedMods ? "Joining cluster + cloning master mods + recreating…" : "Joining cluster + recreating server…"); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster/join`, + { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ cluster_id: clusterID, seed_mods: !!seedMods }) }); + toast(`Joined cluster ${clusterID}${seedMods ? " + cloned master mods" : ""}`, "ok"); + await loadSdtdClusterTab(); + } catch (err) { + toast(`Join failed: ${err.message}`, "err"); + } finally { + sdtdClusterState.loading = false; + } +} + +async function sdtdClusterLeave() { + const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return; + if (!confirm("Leave the cluster? The server will recreate with its own per-instance cluster volume. Shared progression will no longer reach siblings.")) return; + sdtdClusterState.loading = true; + setSdtdClusterBusy(true, "Leaving cluster + recreating server…"); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster`, + { method: "DELETE" }); + toast(`Left cluster`, "ok"); + await loadSdtdClusterTab(); + } catch (err) { + toast(`Leave failed: ${err.message}`, "err"); + } finally { + sdtdClusterState.loading = false; + } +} + +// sdtdClusterSyncMods (master only): pushes this server's current Mods/ set to +// every other cluster member, live. Per-server homes.json is preserved and no +// member is restarted — they load the new mods on their next reboot. Renders a +// per-member result list (✓ N mods / ✕ error / ⊘ skipped cross-host). +async function sdtdClusterSyncMods() { + const id = instanceModalState.id; + const c = sdtdClusterState.currentCluster; + if (!id || !c || sdtdClusterState.loading) return; + const n = (c.siblings || []).length; + if (n === 0) { toast("No other members to sync", "warn"); return; } + if (!confirm(`Push this server's current mod set to ${n} other cluster member${n === 1 ? '' : 's'}?\n\nPer-server data (teleport homes) is preserved. Members are NOT restarted — they load the new mods on their next reboot.`)) return; + const btn = $("#im-sdtd-sync-mods"); + const out = $("#im-sdtd-sync-result"); + const orig = btn ? btn.textContent : ""; + sdtdClusterState.loading = true; + if (btn) { btn.disabled = true; btn.textContent = "Syncing…"; } + if (out) { out.style.display = "block"; out.innerHTML = `
    ⏳ Copying mods to ${n} member${n === 1 ? '' : 's'}… this can take a minute.
    `; } + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/cluster/sync-mods`, { method: "POST" }); + const rows = (r.results || []).map(res => { + if (res.skipped) return `
    ⊘ ${esc(res.instance)} — skipped (${esc(res.error || '')})
    `; + if (res.ok) return `
    ✓ ${esc(res.instance)} — ${res.mods} mods copied
    `; + return `
    ✕ ${esc(res.instance)} — ${esc(res.error || 'failed')}
    `; + }).join(""); + if (out) out.innerHTML = ` +
    +
    Synced ${r.synced}/${r.total} member${r.total === 1 ? '' : 's'} · new mods load on each member's next restart
    + ${rows || '
    No other members to sync.
    '} +
    `; + toast(`Mods synced to ${r.synced}/${r.total} member${r.total === 1 ? '' : 's'}`, r.synced === r.total ? "ok" : "warn"); + } catch (err) { + if (out) out.innerHTML = `
    Sync failed: ${esc(err.message)}
    `; + toast("Mod sync failed: " + err.message, "err"); + } finally { + sdtdClusterState.loading = false; + if (btn) { btn.disabled = false; btn.textContent = orig; } + } +} + +// Populate the "Shared player world" dropdown by enumerating this server's +// actual worlds via the existing Files API: Saves///, keeping +// only folders that hold real save data (a main.ttw file). The value written +// is "/" — exactly what the entrypoint's CLUSTER_PLAYER_SAVE +// expects. Pre-selects the current pin. Runs against the running container (or +// the agent's fs helper sidecar if stopped). +async function populateSdtdPlayerWorlds(id, currentValue) { + const sel = document.getElementById("im-sdtd-player-world"); + if (!sel) return; + const ROOT = "/game-saves/.local/share/7DaysToDie/Saves"; + const listDir = async (p) => { + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?path=${encodeURIComponent(p)}`); + return r.entries || []; + } catch { return []; } + }; + try { + const worldDirs = (await listDir(ROOT)).filter(e => e.is_dir).map(e => e.name); + const saves = []; + await Promise.all(worldDirs.map(async (world) => { + const games = (await listDir(`${ROOT}/${world}`)).filter(e => e.is_dir).map(e => e.name); + await Promise.all(games.map(async (game) => { + const files = await listDir(`${ROOT}/${world}/${game}`); + if (files.some(e => e.name === "main.ttw")) saves.push(`${world}/${game}`); + })); + })); + saves.sort(); + const cur = currentValue || ""; + let html = ``; + html += saves.map(s => ``).join(""); + if (cur && !saves.includes(cur)) { + html += ``; + } + if (!saves.length && !cur) html += ``; + sel.innerHTML = html; + } catch (e) { + sel.innerHTML = ``; + } +} + +async function sdtdClusterSetPlayerWorld() { + const id = instanceModalState.id; if (!id || sdtdClusterState.loading) return; + const sel = document.getElementById("im-sdtd-player-world"); if (!sel) return; + const val = sel.value; + const msg = val + ? `Share players from "${val}" for this server? It recreates (stop → recreate → start) so the cluster Player symlink re-points to that world. World data is preserved.` + : `Reset to auto (use GameWorld/GameName from config)? The server will recreate.`; + if (!confirm(msg)) return; + sdtdClusterState.loading = true; + setSdtdClusterBusy(true, "Setting shared player world + recreating…"); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/env-config`, + { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ updates: { CLUSTER_PLAYER_SAVE: val } }) }); + toast(val ? `Shared player world set to ${val}` : "Shared player world reset to auto", "ok"); + await loadSdtdClusterTab(); + } catch (err) { + toast(`Failed: ${err.message}`, "err"); + } finally { + sdtdClusterState.loading = false; + } +} + +function setSdtdClusterBusy(busy, msg) { + const wrap = $("#im-sdtd-cluster-wrap"); if (!wrap) return; + if (busy) { + wrap.innerHTML = `
    +
    +
    ${esc(msg || 'working…')}
    +
    This takes ~30-60 s (stop → delete → recreate → start)
    +
    `; + } +} + +// ---------------- ARK SAVE RESTORE tab ---------------- +// +// ARK SA writes rolling backups in ShooterGame/Saved/SavedArks/_WP/: +// _WP.ark — the ACTIVE save slot +// _WP__.ark — engine rolling backup +// _WP__.arkrbf — rolling backup (binary) +// _WP___replaced.ark — set aside by THIS feature +// *.bak — engine anti-corruption (skip) +// +// The user pattern that motivated this tab: when the active save is corrupt +// (engine pins on "Invalid readGuid for transform data" or similar), the +// fix is to swap a slightly-older rolling backup into the active slot. The +// engine boots cleanly on it. We preserve the previous active save under a +// "_replaced" name so even abandoned attempts are recoverable. +// +// Auto-discovery: we don't trust env vars to tell us the map directory — +// we list ShooterGame/Saved/SavedArks/ and pick subdirs ending in "_WP". +// Most ARK instances have exactly one; if multiple (cross-map clusters +// sharing a volume), the operator picks via a small selector. + +const arkRestoreState = { subdir: null, subdirs: [], entries: [] }; + +async function loadArkRestoreTab() { + const id = instanceModalState.id; + const wrap = $("#im-arkrestore-wrap"); + if (!id || !wrap) return; + wrap.innerHTML = loadingState("Scanning SavedArks…"); + try { + const q = new URLSearchParams({ path: "SavedArks" }); + const { entries } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`); + const subdirs = (entries || []).filter(e => e.is_dir && /_WP$/i.test(e.name)).map(e => e.name); + arkRestoreState.subdirs = subdirs; + if (subdirs.length === 0) { + wrap.innerHTML = `
    No <MapName>_WP/ directory found under SavedArks/. Has the server booted at least once?
    `; + return; + } + if (!arkRestoreState.subdir || !subdirs.includes(arkRestoreState.subdir)) { + arkRestoreState.subdir = subdirs[0]; + } + await loadArkRestoreEntries(); + } catch (err) { + wrap.innerHTML = errorState(`Error: ${err.message}`); + } +} + +async function loadArkRestoreEntries() { + const id = instanceModalState.id; + const wrap = $("#im-arkrestore-wrap"); + const subdir = arkRestoreState.subdir; + if (!id || !wrap || !subdir) return; + try { + const q = new URLSearchParams({ path: `SavedArks/${subdir}` }); + const { entries } = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`); + arkRestoreState.entries = (entries || []).filter(e => + !e.is_dir && (/\.ark$/i.test(e.name) || /\.arkrbf$/i.test(e.name))); + renderArkRestoreTab(); + } catch (err) { + wrap.innerHTML = errorState(`Error: ${err.message}`); + } +} + +// parseArkSaveName extracts the timestamp embedded in an ARK rolling-save +// filename. Format: __[_replaced]. +// Returns { stamp: Date, isActive: bool, isReplaced: bool, isRbf: bool }. +function parseArkSaveName(name, stem) { + const lower = name.toLowerCase(); + const isRbf = lower.endsWith(".arkrbf"); + const stemLower = stem.toLowerCase(); + // Active slot is exactly ".ark" (no timestamp). + if (lower === stemLower + ".ark") return { stamp: null, isActive: true, isReplaced: false, isRbf: false }; + // Strip stem prefix + leading underscore + extension. + const ext = isRbf ? ".arkrbf" : ".ark"; + if (!lower.startsWith(stemLower + "_") || !lower.endsWith(ext)) { + return { stamp: null, isActive: false, isReplaced: false, isRbf }; + } + let tail = name.slice(stem.length + 1, name.length - ext.length); + let isReplaced = false; + if (tail.endsWith("_replaced")) { tail = tail.slice(0, -"_replaced".length); isReplaced = true; } + // Expected: dd.mm.yyyy_HH.MM.SS + const m = tail.match(/^(\d{2})\.(\d{2})\.(\d{4})_(\d{2})\.(\d{2})\.(\d{2})$/); + if (!m) return { stamp: null, isActive: false, isReplaced, isRbf }; + const [_, dd, mm, yyyy, HH, MM, SS] = m; + // ARK encodes the rolling-save timestamp in UTC. Build the Date from + // UTC components so the "how long ago" arithmetic is correct regardless + // of the operator's local TZ. Without UTC parsing a player on PDT sees + // a 9-hour-old backup labeled "2 hours ago" because the local-time + // constructor reads the filename's "02.19.37" as 2 AM local. + const d = new Date(Date.UTC(+yyyy, +mm - 1, +dd, +HH, +MM, +SS)); + return { stamp: isNaN(d) ? null : d, isActive: false, isReplaced, isRbf }; +} + +function arkHowLongAgo(date) { + if (!date) return "—"; + const diff = (Date.now() - date.getTime()) / 1000; + if (diff < 0) return "just now"; + if (diff < 60) return `${Math.round(diff)}s ago`; + if (diff < 3600) return `${Math.round(diff/60)}m ago`; + if (diff < 86400) return `${Math.round(diff/3600)}h ago`; + if (diff < 86400*7) return `${Math.round(diff/86400)}d ago`; + if (diff < 86400*30) return `${Math.round(diff/(86400*7))}w ago`; + return `${Math.round(diff/(86400*30))}mo ago`; +} + +function renderArkRestoreTab() { + const wrap = $("#im-arkrestore-wrap"); + if (!wrap) return; + const subdir = arkRestoreState.subdir; + const subdirs = arkRestoreState.subdirs; + const inst = allInstances.find(x => x.instance_id === instanceModalState.id); + const isRunning = inst && inst.status === "running"; + + // Annotate + sort: active first, then by stamp desc. + const rows = arkRestoreState.entries.map(e => { + const meta = parseArkSaveName(e.name, subdir); + // Fallback to mod_time when the filename has no parsable stamp (engine + // sometimes drops .arkrbf rolling files with non-standard suffixes). + const stamp = meta.stamp || (e.mod_time ? new Date(e.mod_time) : null); + return { e, meta, stamp }; + }).sort((a, b) => { + if (a.meta.isActive !== b.meta.isActive) return a.meta.isActive ? -1 : 1; + const ta = a.stamp ? a.stamp.getTime() : 0; + const tb = b.stamp ? b.stamp.getTime() : 0; + return tb - ta; + }); + + const subdirSelect = subdirs.length > 1 + ? `` + : `${esc(subdir)}`; + + wrap.innerHTML = ` +
    +
    +
    + +
    +
    ARK auto-save restore
    +
    Roll back to a rolling backup when the active save is corrupt or you'd like to revert.
    +
    +
    +
    + ARK writes rolling backups every few minutes. Restoring sets one of them as the active save (${esc(subdir)}.ark) and moves the previous active save aside under a _replaced name — nothing is deleted, so you can revert again later. The server must be stopped first. +
    +
    + Map dir: + ${subdirSelect} + + ${isRunning ? `⚠ server running — stop it first to restore` : ""} +
    +
    + +
    + + + + + + + + + ${rows.length ? rows.map(({ e, meta, stamp }) => { + const tag = meta.isActive + ? `ACTIVE` + : meta.isReplaced + ? `REPLACED` + : meta.isRbf + ? `RBF` + : ""; + const typeText = meta.isActive ? "active" : meta.isReplaced ? "set aside" : meta.isRbf ? "rolling .arkrbf" : "rolling .ark"; + const ago = stamp ? arkHowLongAgo(stamp) : "—"; + const stampStr = stamp ? stamp.toLocaleString() : "(unknown)"; + return ` + + + + + + `; + }).join("") : ``} +
    savewhensizetype
    ${esc(e.name)}${tag}${esc(ago)}${esc(fmtBytes(e.size))}${esc(typeText)} + ${meta.isActive ? "" : ``} +
    (no .ark or .arkrbf files in this directory)
    +
    +
    `; + + if (subdirs.length > 1) { + $("#im-arkr-subdir").addEventListener("change", e => { + arkRestoreState.subdir = e.target.value; + loadArkRestoreEntries(); + }); + } + $("#im-arkr-refresh").addEventListener("click", loadArkRestoreTab); + $$("#im-arkrestore-wrap [data-arkr-restore]").forEach(b => { + b.addEventListener("click", () => arkRestoreApply(b.dataset.arkrRestore)); + }); +} + +async function arkRestoreApply(filename) { + const id = instanceModalState.id; + const subdir = arkRestoreState.subdir; + if (!id || !subdir || !filename) return; + if (!confirm(`Restore "${filename}" as the active save for ${subdir}?\n\nThe current active save will be moved aside under a "_replaced" name — nothing is deleted, you can revert again from this list.`)) return; + try { + const res = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/ark/save-restore`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ saved_arks_subdir: subdir, target_filename: filename }), + }); + const aside = res.aside_filename ? ` Previous active save preserved as ${res.aside_filename}.` : ""; + toast(`Restored ${filename}.${aside}`, "ok"); + await loadArkRestoreEntries(); + } catch (err) { + toast(`Restore failed: ${err.message}`, "err"); + } +} + +// ---------------- DAYZ WORKSHOP MODS tab ---------------- +// +// CFTools-inspired mod manager. Search the Workshop, install by ID or URL, +// manage install order, uninstall. Install pipeline: +// 1. Controller runs `steamcmd +workshop_download_item 221100 ` into +// the shared panel-dayz-workshop Docker volume. +// 2. Agent creates /game/@ModName -> /game/steamapps/workshop/content/221100/ +// and copies bikeys from the mod's keys/ dir into /game/keys/. +// 3. Controller appends the @ModName to /game-saves/panel-mods.txt (or +// panel-servermods.txt). The entrypoint reads those on next start. +// Steam credentials are cached encrypted on the controller (same row as the +// DayZ server install), so one login unlocks all future downloads. + +const dayzModsState = { + mods: [], // [{workshop_id, folder_name, server_mod, order, title?, ...}] + searchResults: [], // last Workshop search hits + searchTerm: "", + jobs: [], // active + recently-finished install jobs + jobPollTimer: null, // handle for the job-polling setInterval + // Cutoff timestamp set when the Mods tab opens. Terminal jobs with + // ended_at BEFORE this are hidden from the UI (stops stale errors + // from previous sessions cluttering a fresh visit). A 2-second grace + // catches jobs that literally just finished as we were opening. + sinceOpenAt: 0, +}; + +async function loadDayzModsTab() { + const id = instanceModalState.id; const wrap = $("#im-dzm-wrap"); if (!id) return; + // Reset the visibility cutoff on every tab-open: everything that was + // already terminal before now is hidden. 2s grace keeps jobs that + // literally just finished visible on first paint. + dayzModsState.sinceOpenAt = Date.now() - 2000; + wrap.innerHTML = loadingState("Loading mod list…"); + try { + const [mods, jobs] = await Promise.all([ + fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`), + fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/jobs`).catch(() => ({jobs:[]})), + ]); + dayzModsState.mods = mods.mods || []; + dayzModsState.jobs = jobs.jobs || []; + } catch (err) { + wrap.innerHTML = errorState(`Couldn't load mods: ${err.message}`); + return; + } + renderDayzModsTab(); + // Start polling if there are non-terminal jobs, or if the user might start one. + startDayzModJobPolling(); +} + +// visibleJobs applies the session cutoff + preserves any job the user +// started in this session (tracked by id against its start moment). +function visibleJobs() { + const cutoff = dayzModsState.sinceOpenAt || 0; + return (dayzModsState.jobs || []).filter(j => { + if (!j.terminal) return true; + if (!j.ended_at) return true; + return new Date(j.ended_at).getTime() >= cutoff; + }); +} + +// Poll the jobs endpoint while at least one job is non-terminal. Stops +// itself once everything settles. Called on tab open AND after starting +// an install (so we don't wait up to 1s for the next tick). +function startDayzModJobPolling() { + if (dayzModsState.jobPollTimer) return; + // Backoff: 800ms while the jobs payload is changing, 2s once it has been + // identical for 10 consecutive ticks (steamcmd downloads emit no progress + // deltas for long stretches). Any change snaps back to 800ms. + let idleTicks = 0, lastJobsJSON = "", curMs = 800; + const rearm = (ms) => { + if (curMs === ms || !dayzModsState.jobPollTimer) return; + curMs = ms; + clearInterval(dayzModsState.jobPollTimer); + dayzModsState.jobPollTimer = setInterval(tick, ms); + }; + const tick = async () => { + const id = instanceModalState.id; + if (!id || instanceModalState.tab !== "dayzmods") { stopDayzModJobPolling(); return; } + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/jobs`); + const prev = dayzModsState.jobs; + dayzModsState.jobs = r.jobs || []; + const jobsJSON = JSON.stringify(dayzModsState.jobs); + if (jobsJSON === lastJobsJSON) { + if (++idleTicks >= 10) rearm(2000); + } else { + lastJobsJSON = jobsJSON; idleTicks = 0; rearm(800); + } + // If any job just flipped to 'done', refresh the installed-mod list. + const wasActive = id => prev.find(p => p.id === id && !p.terminal); + for (const j of dayzModsState.jobs) { + if (j.terminal && j.phase === "done" && wasActive(j.id)) { + try { + const m = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`); + dayzModsState.mods = m.mods || []; + } catch {} + break; + } + if (j.terminal && j.phase === "login_needed" && wasActive(j.id)) { + // Pop login modal so the operator can re-auth without hunting the Update button. + const ok = await openSteamLoginModal(j.error || "Steam session expired."); + if (ok) { + // Retry the install automatically. + runDayzModInstallRaw(j.workshop_id, j.server_mod); + } + } + } + renderDayzModJobs(); + // Keep polling if anything is still in-flight. + if (!dayzModsState.jobs.some(j => !j.terminal)) { + // One more tick to let finished cards render their 'done' state, + // then stop. + setTimeout(() => { + if (!dayzModsState.jobs.some(j => !j.terminal)) stopDayzModJobPolling(); + }, 3000); + } + } catch (err) { + // Network blip; keep polling, surface via log only. + console.warn("job poll failed", err); + } + }; + tick(); + dayzModsState.jobPollTimer = setInterval(tick, 800); +} + +function stopDayzModJobPolling() { + if (dayzModsState.jobPollTimer) { + clearInterval(dayzModsState.jobPollTimer); + dayzModsState.jobPollTimer = null; + } +} + +function renderDayzModsTab() { + const wrap = $("#im-dzm-wrap"); + const mods = dayzModsState.mods; + const client = mods.filter(m => !m.server_mod); + const server = mods.filter(m => m.server_mod); + + const installedRow = (m, kind) => ` +
    +
    +
    ${esc(m.title || m.folder_name)}
    +
    + ${esc(m.folder_name)} + ${m.workshop_id ? `· ` : ''} + ${kind === 'client' ? '-mod=' : '-servermod='} +
    +
    + +
    `; + + wrap.innerHTML = ` +
    + + +
    +
    +
    +
    Steam Workshop
    +
    Search by name, or paste a Workshop ID / URL to install directly.
    +
    +
    +
    + 🔎 + + + +
    +
    + +
    +
    +
    + + +
    + + +
    +
    +

    🎮 Client-side mods (${client.length})

    + loaded with -mod= +
    + ${client.length + ? `
    ${client.map(m => installedRow(m, 'client')).join('')}
    ` + : `
    ${EMPTY_ICONS.server}
    No client-side mods yet. Search above to add one.
    `} + +
    +

    ⚙️ Server-side mods (${server.length})

    + loaded with -servermod= +
    + ${server.length + ? `
    ${server.map(m => installedRow(m, 'server')).join('')}
    ` + : `
    Server-side mods are less common — admin tools like VPPAdminTools belong here.
    `} +
    +
    `; + + const input = $("#im-dzm-search-input"); + input.addEventListener("input", onDayzSearchInput); + input.addEventListener("keydown", e => { + if (e.key === "Enter") { + // Pressing Enter on a raw ID/URL installs directly. + const v = input.value.trim(); + const m = v.match(/(\d{6,20})/); + if (m) { runDayzModInstallRaw(m[1], !!$("#im-dzm-install-server")?.checked); input.value = ""; renderDayzSearchResults(); } + } else if (e.key === "Escape") { + input.value = ""; onDayzSearchInput({ target: input }); + } + }); + $("#im-dzm-search-clear").addEventListener("click", () => { + input.value = ""; input.focus(); onDayzSearchInput({ target: input }); + }); + $$("#im-dzm-wrap [data-uninstall]").forEach(b => b.addEventListener("click", () => runDayzModUninstall(b.dataset.uninstall))); + $$("#im-dzm-wrap [data-mod-info]").forEach(b => b.addEventListener("click", () => openModInfoModal(b.dataset.modInfo))); + + renderDayzSearchResults(); + renderDayzModJobs(); + + // Auto-focus the search on first paint only — not on every re-render, + // otherwise typing would be interrupted whenever jobs poll triggers + // renderDayzModJobs and its side-effects. + if (!dayzModsState.searchTerm) setTimeout(() => input && input.focus(), 50); +} + +// onDayzSearchInput: debounced autocomplete. Runs on every keystroke. +// Two modes: +// - Input matches a Workshop ID / URL → fetch /workshop/detail, +// render a single big preview card with an Install button. +// - Otherwise → fetch /workshop/search?q=… , render grid of results. +// AbortController cancels in-flight requests so only the newest query lands. +function onDayzSearchInput(e) { + const v = (e.target.value || "").trim(); + dayzModsState.searchTerm = v; + const clear = $("#im-dzm-search-clear"); + if (clear) clear.style.display = v ? "block" : "none"; + clearTimeout(dayzModsState.searchDebounce); + if (dayzModsState.searchController) { try { dayzModsState.searchController.abort(); } catch {} } + if (!v) { + dayzModsState.searchResults = []; + dayzModsState.searchPreview = null; + toggleDayzSearchSpin(false); + renderDayzSearchResults(); + return; + } + toggleDayzSearchSpin(true); + dayzModsState.searchDebounce = setTimeout(() => runDayzLiveSearch(v), 300); +} + +function toggleDayzSearchSpin(on) { + const s = $("#im-dzm-search-spin"); + if (s) s.classList.toggle("on", !!on); +} + +async function runDayzLiveSearch(q) { + const id = instanceModalState.id; if (!id) return; + const ctrl = new AbortController(); + dayzModsState.searchController = ctrl; + try { + // Numeric-only or URL with an id → treat as direct lookup. + const idMatch = q.match(/^\s*(?:https?:\/\/\S*\?id=)?(\d{6,20})\s*$/); + if (idMatch) { + const wsID = idMatch[1]; + const d = await fetch(`/api/workshop/detail?id=${encodeURIComponent(wsID)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + dayzModsState.searchPreview = d; + dayzModsState.searchResults = []; + } else { + const d = await fetch(`/api/workshop/search?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + dayzModsState.searchResults = d.items || []; + dayzModsState.searchPreview = null; + } + } catch (err) { + if (err.name === "AbortError") return; + console.warn("live search failed", err); + } finally { + if (dayzModsState.searchController === ctrl) { + dayzModsState.searchController = null; + toggleDayzSearchSpin(false); + } + } + renderDayzSearchResults(); +} + +// renderDayzSearchResults paints the search-mode body: either a single +// detail preview card (for ID lookups) or the grid of search hits. +function renderDayzSearchResults() { + const host = $("#im-dzm-search-results"); if (!host) return; + const q = dayzModsState.searchTerm || ""; + if (!q) { host.innerHTML = ""; return; } + if (dayzModsState.searchPreview) { + host.innerHTML = renderDayzSearchPreview(dayzModsState.searchPreview); + const btn = $("#im-dzm-preview-install"); + if (btn) btn.addEventListener("click", () => { + const d = dayzModsState.searchPreview; + runDayzModInstallRaw(d.workshop_id, !!$("#im-dzm-install-server")?.checked); + }); + const info = $("#im-dzm-preview-info"); + if (info) info.addEventListener("click", () => openModInfoModal(dayzModsState.searchPreview.workshop_id)); + return; + } + const items = dayzModsState.searchResults || []; + if (!items.length) { + host.innerHTML = `
    ${EMPTY_ICONS.search}
    No results for "${esc(q)}". Try a shorter term, or paste a Workshop URL.
    `; + return; + } + const installed = new Set(dayzModsState.mods.map(m => String(m.workshop_id)).filter(Boolean)); + host.innerHTML = `
    ${items.slice(0, 30).map(it => dayzSearchCard(it, installed.has(String(it.id)))).join("")}
    `; + $$("#im-dzm-search-results [data-mod-card]").forEach(c => + c.addEventListener("click", e => { + if (e.target.closest("[data-install-search]")) return; // button handled below + openModInfoModal(c.dataset.modCard); + })); + $$("#im-dzm-search-results [data-install-search]").forEach(b => + b.addEventListener("click", e => { + e.stopPropagation(); + runDayzModInstallRaw(b.dataset.installSearch, !!$("#im-dzm-install-server")?.checked); + })); +} + +function dayzSearchCard(it, isInstalled) { + return `
    + ${it.preview_url ? `` : ''} +
    +
    ${esc(it.title || '(untitled)')}
    +
    + ${esc(it.id)} +
    +
    + ${isInstalled + ? `✓ Installed` + : ``} +
    +
    +
    `; +} + +function renderDayzSearchPreview(d) { + const fmtSize = (b) => { if (!b) return "?"; const mb = b/1048576; if (mb < 1) return `${Math.round(b/1024)} KB`; if (mb < 1024) return `${mb.toFixed(1)} MB`; return `${(mb/1024).toFixed(2)} GB`; }; + const installed = dayzModsState.mods.some(m => String(m.workshop_id) === String(d.workshop_id)); + return `
    + ${d.preview_url ? `` : `
    no preview
    `} +
    +
    ${esc(d.title || 'Workshop #' + d.workshop_id)}
    +
    + 📦 ${fmtSize(d.size)} + 🆔 ${esc(d.workshop_id)} + ${d.time_updated ? `🕒 updated ${new Date(d.time_updated*1000).toLocaleDateString()}` : ''} +
    +
    Paste a URL or ID — we'll grab the metadata and install on click.
    +
    +
    + ${installed + ? `✓ Installed` + : ``} + +
    +
    `; +} + +async function runDayzModInstall(overrideID) { + const workshopID = typeof overrideID === "string" + ? overrideID + : (($("#im-dzm-install-id") || {}).value || "").trim(); + if (!workshopID) { toast("Paste a Workshop ID or URL first", "err"); return; } + const serverMod = !!($("#im-dzm-install-server") && $("#im-dzm-install-server").checked); + await runDayzModInstallRaw(workshopID, serverMod); +} + +// runDayzModInstallRaw is the low-level version used both by the main +// Install button and by the mod-info modal's Install button. Returns once +// the job is QUEUED — the actual download runs in the background and the +// polling loop picks up progress. No blocking UI states here; the download +// card does all the feedback. +async function runDayzModInstallRaw(workshopID, serverMod) { + const id = instanceModalState.id; if (!id) return; + const body = JSON.stringify({ workshop_id: workshopID, server_mod: !!serverMod }); + try { + let r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`, + { method: "POST", headers: {"Content-Type":"application/json"}, body }); + if (r && r.steam_login_required) { + const ok = await openSteamLoginModal(r.reason || "DayZ Workshop downloads require a Steam account that owns the game."); + if (!ok) return; + r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods`, + { method: "POST", headers: {"Content-Type":"application/json"}, body }); + if (r && r.steam_login_required) throw new Error("Steam login didn't take; try again."); + } + if (r.duplicate) { + toast(`Already downloading this mod — see the download card`, "ok"); + } else { + toast(`Queued ${r.title || workshopID}`, "ok"); + } + // Immediately show a placeholder card so the user sees feedback without + // waiting 1s for the poll tick to bring it in. + dayzModsState.jobs = [{ + id: r.job_id, workshop_id: r.workshop_id, folder_name: r.folder_name, + title: r.title, phase: "queued", percent: 0, lines: ["queued"], terminal: false, + }, ...dayzModsState.jobs.filter(j => j.id !== r.job_id)]; + renderDayzModJobs(); + startDayzModJobPolling(); + } catch (err) { + toast(`Install failed: ${err.message}`, "err"); + } +} + +// renderDayzModJobs paints the active + recently-finished install jobs as +// a stack of cards with progress bars, phase pills, and a tail of recent +// steamcmd log lines. Called both on tab-open and from the polling loop. +function renderDayzModJobs() { + const host = $("#im-dzm-jobs"); + if (!host) return; + const jobs = visibleJobs(); + if (!jobs.length) { host.innerHTML = ""; return; } + const phaseMeta = { + queued: { label: "Queued", color: "var(--ink-3, #9ca3af)", spin: true }, + downloading: { label: "Downloading", color: "var(--accent-2)", spin: true }, + linking: { label: "Linking", color: "var(--accent-2)", spin: true }, + finalizing: { label: "Finalizing", color: "var(--accent-2)", spin: true }, + done: { label: "Done", color: "var(--ok)", spin: false }, + error: { label: "Failed", color: "var(--err)", spin: false }, + login_needed: { label: "Sign in to Steam", color: "var(--warn)", spin: false }, + }; + const fmtSize = (b) => { + if (!b) return ""; + const mb = b / 1048576; + if (mb < 1) return `${Math.round(b/1024)} KB`; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + return `${(mb/1024).toFixed(2)} GB`; + }; + const activeCount = jobs.filter(j=>!j.terminal).length; + const recentCount = jobs.filter(j=>j.terminal).length; + host.innerHTML = `
    +
    +
    📦 Downloads (${activeCount} active · ${recentCount} recent)
    + ${recentCount ? `` : ''} +
    +
    + ${jobs.map(j => { + const meta = phaseMeta[j.phase] || { label: j.phase || "?", color: "#9ca3af", spin: false }; + const titleText = j.title || `Workshop #${j.workshop_id}`; + const tail = (j.lines || []).slice(-3); + const pct = Math.max(0, Math.min(100, Math.round(j.percent || 0))); + const showBar = j.phase === "downloading" || j.phase === "linking" || j.phase === "finalizing" || j.phase === "done"; + const barInner = j.phase === "done" + ? `
    ` + : (pct > 0 + ? `
    ` + : `
    `); + // Button row: Dismiss is always available on terminal jobs. + // Retry is only shown when the server classified the error as + // recoverable (can_retry=true). Login-needed gets "Sign in" instead. + let actions = ""; + if (j.terminal) { + const parts = []; + if (j.phase === "error" && j.can_retry) parts.push(``); + if (j.phase === "login_needed") parts.push(``); + parts.push(``); + actions = `
    ${parts.join("")}
    `; + } + // Job-status class drives border + animation. + const jobCls = j.terminal ? (j.phase === 'done' ? 'done' : (j.phase === 'login_needed' ? 'login_needed' : 'error')) : 'active'; + return `
    +
    +
    + ${esc(titleText)} + ${esc(j.folder_name || '')} + ${j.size ? `· ${fmtSize(j.size)}` : ''} + ${j.attempt && j.attempt > 1 && !j.terminal ? `retry #${j.attempt}` : ''} + ${j.permanent ? `no retry` : ''} +
    + ${esc(meta.label)}${showBar && pct && j.phase!=="done" ? ` · ${pct}%` : ''} +
    + ${showBar ? `
    ${pct > 0 || j.phase === 'done' + ? `
    ` + : `
    `}
    ` : ''} + ${j.phase === 'error' ? `
    ${esc(j.error || 'Unknown error')}
    ` : ''} + ${j.phase === 'login_needed' ? `
    ${esc(j.error || 'Steam session expired')}
    ` : ''} + ${tail.length ? `
    ${esc(tail.join("\n"))}
    ` : ''} + ${actions} +
    `; + }).join('')} +
    +
    `; + $$("#im-dzm-jobs [data-job-retry]").forEach(b => b.addEventListener("click", () => { + const j = (dayzModsState.jobs || []).find(x => x.id === b.dataset.jobRetry); + if (j) { + // Retry spawns a FRESH job; dismiss the old card so the UI + // doesn't carry stale lines through the new run's render. + dismissDayzModJob(j.id, false); + runDayzModInstallRaw(j.workshop_id, j.server_mod); + } + })); + $$("#im-dzm-jobs [data-job-dismiss]").forEach(b => b.addEventListener("click", () => dismissDayzModJob(b.dataset.jobDismiss, true))); + $$("#im-dzm-jobs [data-job-login]").forEach(b => b.addEventListener("click", async () => { + const j = (dayzModsState.jobs || []).find(x => x.id === b.dataset.jobLogin); + if (!j) return; + const ok = await openSteamLoginModal(j.error || "Sign in to Steam to refresh the cached token."); + if (ok) { + dismissDayzModJob(j.id, false); + runDayzModInstallRaw(j.workshop_id, j.server_mod); + } + })); + const clearBtn = $("#im-dzm-clear-recent"); + if (clearBtn) clearBtn.addEventListener("click", async () => { + const terminal = jobs.filter(j => j.terminal); + for (const j of terminal) await dismissDayzModJob(j.id, false, /*quiet*/ true); + renderDayzModJobs(); + }); +} + +async function dismissDayzModJob(jobID, rerender = true, quiet = false) { + const id = instanceModalState.id; if (!id) return; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/jobs/${encodeURIComponent(jobID)}`, { method: "DELETE" }); + } catch (err) { + if (!quiet) toast(`Dismiss failed: ${err.message}`, "err"); + return; + } + dayzModsState.jobs = (dayzModsState.jobs || []).filter(j => j.id !== jobID); + if (rerender) renderDayzModJobs(); +} + +async function runDayzModUninstall(folder) { + const id = instanceModalState.id; if (!id) return; + if (!confirm(`Uninstall ${folder}? This removes the symlink, bikeys, and its entry from the mod list (the Workshop cache is kept — other instances may use it).`)) return; + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/mods/${encodeURIComponent(folder)}`, + { method: "DELETE" }); + const removed = (r.bikeys_removed || []).length; + toast(`Removed ${folder}${removed ? ` (−${removed} bikey${removed === 1 ? '' : 's'})` : ''}`, "ok"); + await loadDayzModsTab(); + } catch (err) { + toast(`Uninstall failed: ${err.message}`, "err"); + } +} + +// Legacy runDayzModSearch + renderDayzModSearchResults were superseded by +// onDayzSearchInput + renderDayzSearchResults above (live/debounced). The +// old implementations are intentionally removed — anything that called them +// now funnels through the new pipeline via the hero search input. + +// ---- mod info modal ---- +// +// Opened from a Workshop search result card. Fetches full details from +// /api/workshop/detail and renders title, preview, description (BBCode-ish, +// rendered as plain text with URLs autolinked), stats, tags, and an Install +// button right inside the modal. + +async function openModInfoModal(workshopID) { + const body = $("#mi-body"); + $("#mi-title").textContent = "Mod info"; + body.innerHTML = loadingState("Loading Steam Workshop details…"); + showModal("#mod-info-modal"); + let d; + try { + d = await fetchJSON(`/api/workshop/detail?id=${encodeURIComponent(workshopID)}`); + } catch (err) { + body.innerHTML = errorState(`Couldn't load details: ${err.message}`); + return; + } + $("#mi-title").textContent = d.title || "Mod info"; + const installed = dayzModsState.mods.some(m => String(m.workshop_id) === String(d.workshop_id)); + const fmtSize = (b) => { + if (!b) return "?"; + const mb = b / 1048576; + if (mb < 1) return `${Math.round(b/1024)} KB`; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + return `${(mb/1024).toFixed(2)} GB`; + }; + const fmtDate = (unix) => { + if (!unix) return "—"; + const dt = new Date(unix * 1000); + return dt.toLocaleDateString() + " " + dt.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}); + }; + const fmtNum = (n) => { + n = Number(n) || 0; + if (n < 1000) return String(n); + if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k"; + return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M"; + }; + // Workshop descriptions are BBCode-like with [b][i][url][img] tokens. + // Render as plain text with some safe transforms: + // [url=X]Y[/url] → Y (only https:// URLs honored) + // [h1]..[/h1] → .. + // [b]..[/b] → .. + // [i]..[/i] → .. + // newlines →
    + // Drop all other bbcode tags (no [img] — too easy to abuse). + const renderDesc = (s) => { + if (!s) return "No description."; + let t = esc(s); + t = t.replace(/\[url=(https?:\/\/[^\s\]]+)\]([^\[]*)\[\/url\]/gi, + (_, u, inner) => `${inner || u}`); + t = t.replace(/\[h[1-6]\](.*?)\[\/h[1-6]\]/gi, "$1"); + t = t.replace(/\[b\](.*?)\[\/b\]/gi, "$1"); + t = t.replace(/\[i\](.*?)\[\/i\]/gi, "$1"); + t = t.replace(/\[u\](.*?)\[\/u\]/gi, "$1"); + // Strip any remaining tags. + t = t.replace(/\[\/?[a-z][^\]]*\]/gi, ""); + t = t.replace(/\n/g, "
    "); + return t; + }; + + // Find the installed folder name (if any) so we can scan for bundled XML. + const installedMod = dayzModsState.mods.find(m => String(m.workshop_id) === String(d.workshop_id)); + + body.innerHTML = ` +
    +
    + ${d.preview_url ? `` : ''} +
    +
    Size${fmtSize(d.size)}
    +
    Updated${fmtDate(d.time_updated)}
    +
    Created${fmtDate(d.time_created)}
    +
    Subscribers${fmtNum(d.subscriptions)}
    +
    Favorites${fmtNum(d.favorited)}
    +
    Views${fmtNum(d.views)}
    +
    + +
    +
    + ${(d.tags || []).length ? `
    + ${d.tags.map(tagChip).join("")} +
    ` : ''} +
    + ${renderDesc(d.description)} +
    +
    +
    + ${installedMod ? ` +
    +
    +
    🧩 Bundled XML snippets
    + Central Economy drop-ins shipped inside this mod +
    +
    Scanning…
    +
    ` : ''} +
    + + + ${installed + ? installedBadge() + : ``} +
    `; + + if (installedMod) loadModXmlScan(instanceModalState.id, installedMod.folder_name); + const btn = $("#mi-install"); + if (btn) btn.addEventListener("click", async () => { + btn.disabled = true; btn.textContent = "⏳ Queuing…"; + try { + const serverMod = !!($("#mi-server-mod") && $("#mi-server-mod").checked); + await runDayzModInstallRaw(d.workshop_id, serverMod); + hideModal("#mod-info-modal"); + } catch (err) { + toast(`Install failed: ${err.message}`, "err"); + } finally { + btn.disabled = false; btn.textContent = "⬇ Install"; + } + }); +} + +// loadModXmlScan hits GET /dayz/mods/{folder}/xml and renders a row per +// discovered XML file with an "Integrate" button. Clicking the button reads +// the file from the container via the existing /files/read endpoint and +// funnels its content through the XML Integration tab's preview+commit flow. +async function loadModXmlScan(instanceID, folder) { + const body = $("#mi-xml-body"); + if (!body) return; + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/dayz/mods/${encodeURIComponent(folder)}/xml`); + const files = r.files || []; + if (!files.length) { + body.innerHTML = `
    No XML files in this mod — it doesn't ship Central Economy snippets (common for QoL / admin mods).
    `; + return; + } + body.innerHTML = ` +
    + ${files.map((f, i) => ` +
    +
    + ${esc(f.path)} + ${Math.round((f.size||0)/1024)} KB + ${f.kind ? `${esc(f.kind)}` : `kind auto-detect`} +
    +
    + + +
    +
    `).join("")} +
    +
    `; + const srcPath = (i) => `/game/${folder}/${files[i].path}`; + const act = async (i, dryRun) => { + const out = $("#mi-xml-result"); + out.innerHTML = `
    reading + ${dryRun ? "previewing" : "committing"} ${esc(files[i].path)}…
    `; + try { + const rd = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/files/read?path=${encodeURIComponent(srcPath(i))}`); + const snippet = rd.content || ""; + const body = JSON.stringify({ mission: "", kind: files[i].kind || "", snippet }); + const pr = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/dayz/xml/${dryRun ? "preview" : "commit"}`, + { method: "POST", headers: {"Content-Type":"application/json"}, body }); + const plan = pr.plan || {}; + const bucket = (label, arr, color) => (arr && arr.length) + ? `
    ${label} (${arr.length}) ${arr.slice(0,10).map(esc).join(", ")}${arr.length>10?` (+${arr.length-10} more)`:""}
    ` + : ""; + out.innerHTML = ` +
    +
    +
    ${dryRun ? "Preview" : "Committed"} · ${esc(plan.kind || files[i].kind || "?")} ${esc(pr.path || "")}
    +
    +${(plan.added||[]).length} ~${(plan.replaced||[]).length} =${(plan.unchanged||[]).length}
    +
    + ${bucket("Added", plan.added, "var(--ok)")} + ${bucket("Replaced", plan.replaced, "var(--warn)")} + ${(plan.warnings||[]).length ? `
    Warnings: ${plan.warnings.map(esc).join(" · ")}
    ` : ""} +
    `; + if (!dryRun) toast(`Integrated ${files[i].path} → ${pr.path} (backup: ${pr.backup_path})`, "ok"); + } catch (err) { + out.innerHTML = `
    Failed: ${esc(err.message)}
    `; + } + }; + $$("#mi-xml-body [data-mi-xml-preview]").forEach(b => b.addEventListener("click", () => act(Number(b.dataset.miXmlPreview), true))); + $$("#mi-xml-body [data-mi-xml-integrate]").forEach(b => b.addEventListener("click", () => act(Number(b.dataset.miXmlIntegrate), false))); + } catch (err) { + body.innerHTML = `
    Couldn't scan mod for XML: ${esc(err.message)}
    `; + } +} + +// ---------------- DAYZ XML MODS tab ---------------- +// +// Lets operators drop in a mod's types.xml / events.xml / cfgspawnabletypes.xml +// snippet and merge it into the running server's mission files. Shows a dry-run +// preview (added/replaced/unchanged counts + first N names) before commit. +// Commit auto-backs up the prior file as .bak.. + +const dayzXmlState = { + files: [], // from /dayz/xml/files + mission: "", + snippet: "", + pickedKind: "", // override kind (empty = infer from snippet root) + pickedPath: "", + plan: null, // last dry-run result +}; + +async function loadDayzXmlTab() { + const id = instanceModalState.id; const wrap = $("#im-dzx-wrap"); if (!id) return; + wrap.innerHTML = loadingState("Probing mission files…"); + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/dayz/xml/files`); + dayzXmlState.files = r.files || []; + dayzXmlState.mission = r.mission || ""; + } catch (err) { + wrap.innerHTML = `
    Couldn't list XML files: ${esc(err.message)}
    Make sure the server has started at least once so the mission folder exists.
    `; + return; + } + renderDayzXmlTab(); +} + +function renderDayzXmlTab() { + const wrap = $("#im-dzx-wrap"); + const files = dayzXmlState.files; + const existsCount = files.filter(f => f.exists).length; + wrap.innerHTML = ` +
    +
    +
    +
    +
    DayZ XML merger
    +
    + Drop a mod's snippet XML below. We'll preview the diff before touching your files, then commit with an auto-backup. + Target: ${esc(dayzXmlState.mission)} (${existsCount}/${files.length} supported files found) +
    +
    +
    +
    + +
    +
    + +
    + + Leave on auto-detect unless the snippet's root tag is ambiguous or wrong. +
    + + +
    + + + no file selected + Or paste below. +
    + + +
    + +
    +
    +
    + + +
    +
    + + +
    `; + + $("#im-dzx-target").addEventListener("change", e => { + dayzXmlState.pickedKind = e.target.value; + }); + $("#im-dzx-file-btn").addEventListener("click", () => $("#im-dzx-file").click()); + $("#im-dzx-file").addEventListener("change", async e => { + const f = e.target.files && e.target.files[0]; if (!f) return; + $("#im-dzx-file-name").textContent = f.name; + const text = await f.text(); + $("#im-dzx-snippet").value = text; + dayzXmlState.snippet = text; + }); + $("#im-dzx-snippet").addEventListener("input", e => { dayzXmlState.snippet = e.target.value; }); + $("#im-dzx-preview").addEventListener("click", () => runDayzXmlMerge(true)); + $("#im-dzx-commit").addEventListener("click", () => runDayzXmlMerge(false)); +} + +async function runDayzXmlMerge(dryRun) { + const id = instanceModalState.id; if (!id) return; + const snippet = ($("#im-dzx-snippet") || {}).value || dayzXmlState.snippet || ""; + if (!snippet.trim()) { toast("Paste or pick a snippet first", "err"); return; } + const kind = ($("#im-dzx-target") || {}).value || ""; + const url = `/api/instances/${encodeURIComponent(id)}/dayz/xml/${dryRun ? "preview" : "commit"}`; + const body = JSON.stringify({ mission: dayzXmlState.mission, kind, snippet }); + const btn = $(dryRun ? "#im-dzx-preview" : "#im-dzx-commit"); + btn.disabled = true; const old = btn.textContent; + btn.textContent = dryRun ? "Previewing…" : "Committing…"; + try { + const r = await fetchJSON(url, { method: "POST", headers: {"Content-Type":"application/json"}, body }); + dayzXmlState.plan = r.plan; + renderDayzXmlResult(r, dryRun); + if (!dryRun) { + toast(`Merged into ${r.path} — backup saved to ${r.backup_path}`, "ok"); + $("#im-dzx-commit").style.display = "none"; + } + } catch (err) { + toast(`Merge ${dryRun ? "preview" : "commit"} failed: ${err.message}`, "err"); + } finally { + btn.disabled = false; btn.textContent = old; + } +} + +function renderDayzXmlResult(r, wasDryRun) { + const plan = r.plan || {}; + const wrap = $("#im-dzx-result-wrap"); + wrap.style.display = ""; + const bucket = (label, arr, accent) => { + if (!arr || !arr.length) return ""; + const more = arr.length > 20 ? ` (+${arr.length - 20} more)` : ""; + return `
    +
    ${label} (${arr.length})
    +
    ${arr.slice(0,20).map(esc).join(", ")}${more}
    +
    `; + }; + const appChildren = plan.appended_children || {}; + const appChildrenHtml = Object.keys(appChildren).length ? `
    +
    Event children appended
    + ${Object.entries(appChildren).map(([k,v]) => `
    ${esc(k)}: ${esc(v.join(", "))}
    `).join("")} +
    ` : ""; + const warnHtml = (plan.warnings || []).length ? `
    + Warnings:
    ${plan.warnings.map(w => `• ${esc(w)}`).join("
    ")} +
    ` : ""; + const conflictsHtml = (plan.conflicts || []).length ? `
    + Conflicts (first wins in snippet): ${plan.conflicts.map(esc).join(", ")} +
    ` : ""; + + wrap.innerHTML = ` +
    +
    +
    ${wasDryRun ? "Preview" : "Committed"} · ${esc(plan.kind || "?")}
    +
    ${esc(r.path)}
    +
    +
    + +${(plan.added||[]).length} added · ~${(plan.replaced||[]).length} replaced · =${(plan.unchanged||[]).length} unchanged +
    +
    + ${bucket("Added", plan.added, "var(--ok)")} + ${bucket("Replaced", plan.replaced, "var(--warn)")} + ${bucket("Unchanged", plan.unchanged, "var(--text-dim)")} + ${appChildrenHtml} + ${conflictsHtml} + ${warnHtml} + `; + + if (wasDryRun) { + $("#im-dzx-commit").style.display = ""; + } +} + +// ---------------- SETTINGS tab ---------------- +// +// Schemas describe which fields from a game's config file we expose as +// a form. Unknown / un-listed fields in the file are preserved verbatim +// on save — we never rewrite the whole file, we patch only the keys the +// operator touched. +const configSchemas = { + // VEIN — UE4 Game.ini spans multiple [/Script/...] sections, so it uses the + // section-aware "ue_ini" format (parseUEIni/serializeUEIni). Each field + // declares its `section` so a brand-new key lands under the right header. + // The entrypoint also stamps the common knobs from env on boot. + "vein": { + file: "Vein/Saved/Config/LinuxServer/Game.ini", + filePaths: [ + "Vein/Saved/Config/LinuxServer/Game.ini", + "/game-saves/Vein/Saved/Config/LinuxServer/Game.ini", + ], + format: "ue_ini", + groups: [ + { title: "Identity & Access", icon: "🏷️", description: "Player-facing identity + the public-browser listing. ServerName shows in the in-game browser; an empty Password means an open server.", fields: [ + { prop: "ServerName", label: "Server name", type: "string", section: "[/Script/Vein.VeinGameSession]", hint: "Shown in the in-game server browser. Also stamped from the SERVER_NAME env on every boot." }, + { prop: "ServerDescription", label: "Description", type: "textarea", rows: 2, section: "[/Script/Vein.VeinGameSession]" }, + { prop: "Password", label: "Join password", type: "password", section: "[/Script/Vein.VeinGameSession]", hint: "Empty = open server." }, + { prop: "MaxPlayers", label: "Max players", type: "number", min: 1, max: 128, step: 1, section: "[/Script/Engine.GameSession]" }, + { prop: "bPublic", label: "List on public browser", type: "bool", section: "[/Script/Vein.VeinGameSession]" }, + { prop: "HeartbeatInterval", label: "Heartbeat interval (s)", type: "number", min: 0, step: 1, section: "[/Script/Vein.VeinGameSession]", hint: "How often the server pings the master server. Leave default unless you know you need it." }, + ]}, + { title: "Steam & Anti-Cheat", icon: "🛡️", description: "Steam-side options for the dedicated server.", fields: [ + { prop: "bVACEnabled", label: "VAC enabled", type: "bool", section: "[OnlineSubsystemSteam]", hint: "Valve Anti-Cheat. Disable only if you have a reason to." }, + ]}, + { title: "Server Display", icon: "🎯", description: "Cosmetic server-side toggles.", fields: [ + { prop: "GS_ShowScoreboardBadges", label: "Show scoreboard badges", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "PvP & Raiding", icon: "⚔️", fields: [ + { prop: "GS_PVP", label: "PvP enabled", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_BaseRaiding", label: "Allow base raiding", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_OfflineRaiding", label: "Allow offline raiding", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_BuildObjectPVP", label: "PvP damage to structures", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_BuildStructureDecay", label: "Structure decay", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_BaseDamage", label: "Base damage multiplier", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_HeadshotDamageMultiplier", label: "Headshot damage multiplier", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_VehicleOutgoingPlayerDamage", label: "Vehicle to player damage multiplier", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "Death & Survival", icon: "💀", fields: [ + { prop: "GS_Permadeath", label: "Permadeath", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_RetainItemsOnDeath", label: "Keep items on death", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_PersistentCorpses", label: "Persistent corpses", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_AlwaysBecomeZombie", label: "Always turn into zombie on death", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_NoSaves", label: "Disable saves", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_HungerMultiplier", label: "Hunger rate", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ThirstMultiplier", label: "Thirst rate", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_TiredMultiplier", label: "Tiredness rate", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ScarcityDifficulty", label: "Loot scarcity", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_MaxCharacters", label: "Max characters (total)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "Zombies", icon: "🧟", fields: [ + { prop: "GS_ZombiesHealth", label: "Zombie health", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieDamageMultiplier", label: "Zombie damage", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieSpeedMultiplier", label: "Zombie speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieRunSpeedMultiplier", label: "Zombie run speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieWalkSpeedMultiplier", label: "Zombie walk speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieCrawlSpeedMultiplier", label: "Zombie crawl speed", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieSightMultiplier", label: "Zombie sight range", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieHearingMultiplier", label: "Zombie hearing range", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieSpawnCountMultiplier", label: "Zombie spawn count", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieWalkerPercentage", label: "Walker (slow) zombie fraction", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombieInfectionChance", label: "Infection chance per hit", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombiesCanClimb", label: "Zombies can climb", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ZombiesHeadshotOnly", label: "Zombies killable by headshot only", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_Hordes", label: "Hordes enabled", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "World & Time", icon: "🌍", fields: [ + { prop: "GS_TimeMultiplier", label: "Day/night time scale", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_NightTimeMultiplier", label: "Night length scale", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_StartTime", label: "World start time (hour)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_TimeWithNoPlayers", label: "Time passes with no players", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ContainersRespawn", label: "Containers respawn loot", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_FurnitureRespawns", label: "Furniture respawns", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ItemActorSpawnersRespawn", label: "World item spawners respawn", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "Movement", icon: "🏃", fields: [ + { prop: "GS_RunSpeed", label: "Run speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_WalkSpeed", label: "Walk speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_CrouchWalkSpeed", label: "Crouch-walk speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_SwimSpeed", label: "Swim speed", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "Combat Effects", icon: "🥊", fields: [ + { prop: "GS_StaggerOdds", label: "Stagger chance", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_StunLockChance", label: "Stun-lock chance", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_StunLockDuration", label: "Stun-lock duration (s)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "Power & Wiring", icon: "🔌", fields: [ + { prop: "GS_PowerShutoffTime", label: "Power shutoff time (hour)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_WaterShutoffTime", label: "Water shutoff time (hour)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_MaxWireDistance", label: "Max wire distance", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_MaxTVWireDistance", label: "Max TV wire distance", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_MaxUtilityCabinets", label: "Max utility cabinets (0=unlimited)", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_UtilityCabinetInterval", label: "Utility cabinet interval", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_UtilityCabinetRadiusMultiplier", label: "Utility cabinet radius", type: "number", min: 0, step: 0.05, section: "[/Script/Vein.ServerSettings]" }, + ]}, + { title: "Misc", icon: "🎛️", fields: [ + { prop: "GS_AllowPickpocketing", label: "Allow pickpocketing", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_ClothingHideable", label: "Clothing hideable", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_AllowRemoteVideo", label: "Allow remote video (TVs)", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_SleepAdvancesTime", label: "Sleeping advances time", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_MaxThirdPersonDistance", label: "Max 3rd-person camera distance", type: "number", min: 0, step: 1, section: "[/Script/Vein.ServerSettings]" }, + { prop: "GS_IgnoreBuildObjectRestrictions", label: "Ignore build placement restrictions", type: "bool", section: "[/Script/Vein.ServerSettings]" }, + ]}, + ], + }, + // 7DTD is the DURABLE editor: the entrypoint re-stamps serverconfig.xml + // from the panel-rendered template (serverconfig.xml.rendered) on EVERY + // boot, so a live-file write alone silently reverts on the next restart. + // Each field carries a `tmplKey` — the config_values key the module + // template reads as `.Values.`. Apply pushes dirty fields through + // POST /config-render (updates config_values + re-renders the boot + // template on the agent, NO recreate) and then writes the live file so + // the editor stays consistent. Fields with `locked: true` are panel-owned + // (port allocator / RCON password) and render disabled. + "7dtd": { + file: "serverconfig.xml", + filePaths: [ + "/game-saves/serverconfig.xml", + "serverconfig.xml", + ], + format: "xml7dtd", + durable: true, + groups: [ + { title: "Identity", icon: "🏷️", description: "Server name, description, and visibility on the public server browser.", fields: [ + { prop: "ServerName", label: "Server name", type: "string", tmplKey: "server_name" }, + { prop: "ServerDescription", label: "Description", type: "string", tmplKey: "server_description" }, + { prop: "ServerWebsiteURL", label: "Website URL", type: "string", tmplKey: "server_website" }, + { prop: "ServerPassword", label: "Join password", type: "password", tmplKey: "server_password", hint: "Leave blank for an open server" }, + { prop: "ServerLoginConfirmationText", label: "Login confirmation text", type: "string", tmplKey: "login_confirmation_text", hint: "Shown to players on connect" }, + { prop: "Region", label: "Region", type: "enum", tmplKey: "region", + options: ["NorthAmericaEast","NorthAmericaWest","CentralAmerica","SouthAmerica","Europe","Russia","Asia","Oceania","Africa"] }, + { prop: "Language", label: "Language", type: "enum", tmplKey: "language", + options: ["English","German","Spanish","French","Italian","Japanese","Korean","Polish","Portuguese","Russian","Turkish","SimplifiedChinese","TraditionalChinese"] }, + ]}, + + { title: "Slots & Network", icon: "🔌", description: "Player cap, visibility, and crossplay. Ports are panel-allocated — change them in the Network ports pane, not here.", fields: [ + { prop: "ServerMaxPlayerCount", label: "Max players", type: "number", min: 1, max: 64, tmplKey: "max_players" }, + { prop: "ServerReservedSlots", label: "Reserved slots", type: "number", min: 0, max: 64, tmplKey: "reserved_slots" }, + { prop: "ServerReservedSlotsPermission", label: "Reserved-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "reserved_slots_permission" }, + { prop: "ServerAdminSlots", label: "Admin slots", type: "number", min: 0, max: 8, tmplKey: "admin_slots" }, + { prop: "ServerAdminSlotsPermission", label: "Admin-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "admin_slots_permission" }, + { prop: "ServerVisibility", label: "Visibility", type: "enum", tmplKey: "server_visibility", + options: [["0","Private (LAN / direct only)"],["1","Friends only"],["2","Public"]] }, + { prop: "ServerAllowCrossplay", label: "Allow crossplay", type: "bool", tmplKey: "allow_crossplay", hint: "Console + PC in the same game. Requires EAC on and vanilla-compatible mods only." }, + { prop: "ServerDisabledNetworkProtocols", label: "Disabled network protocols", type: "string", tmplKey: "disabled_network_protocols", hint: "Comma-separated: SteamNetworking, LiteNetLib. Default disables SteamNetworking." }, + { prop: "ServerMaxWorldTransferSpeedKiBs", label: "Max world transfer speed (KiB/s)", type: "number", min: 64, max: 8192, tmplKey: "max_world_transfer_speed" }, + { prop: "ServerPort", label: "Server port", type: "number", min: 1, max: 65535, locked: true, + hint: "🔒 Panel-owned: the port allocator assigns this and the entrypoint re-stamps it from env on every boot. Change it in the Network ports pane." }, + ]}, + + { title: "World", icon: "🌍", description: "⚠ Map, seed, size, and save folder. Changing any of these starts a new world save on the next restart (the old save folder stays on disk). On a clustered server, re-pick the shared player world in the Cluster tab afterwards.", fields: [ + { prop: "GameWorld", label: "World", type: "enum", tmplKey: "world_name", + options: ["Navezgane","PREGEN01","PREGEN02","PREGEN03","PREGEN4k_01","PREGEN4k_02","PREGEN4k_03","RWG"] }, + { prop: "WorldGenSeed", label: "World seed", type: "string", tmplKey: "world_seed", hint: "Anything — used for RWG worlds" }, + { prop: "WorldGenSize", label: "World size", type: "number", min: 4096, max: 16384, step: 1024, tmplKey: "world_size" }, + { prop: "GameName", label: "Save name", type: "string", tmplKey: "game_name", hint: "Folder name for this world" }, + { prop: "GameMode", label: "Game mode", type: "enum", tmplKey: "game_mode", options: ["GameModeSurvival"] }, + { prop: "PersistentPlayerProfiles", label: "Persistent player profiles", type: "bool", tmplKey: "persistent_player_profiles", hint: "Players always spawn as the same character profile" }, + { prop: "MaxChunkAge", label: "Max chunk age (days, -1 = off)", type: "number", min: -1, max: 365, tmplKey: "max_chunk_age", hint: "Resets unvisited chunks after this many in-game days" }, + { prop: "SaveDataLimit", label: "Save data limit (MB, -1 = off)", type: "number", min: -1, max: 65536, tmplKey: "save_data_limit" }, + { prop: "MaxUncoveredMapChunksPerPlayer", label: "Max uncovered map chunks / player", type: "number", min: 16384, max: 1048576, tmplKey: "max_uncovered_chunks" }, + ]}, + + { title: "Difficulty & Pacing", icon: "⚡", description: "How hard the game is, how fast time passes, and XP rates.", fields: [ + { prop: "GameDifficulty", label: "Difficulty", type: "enum", tmplKey: "difficulty", + options: [["0","Scavenger"],["1","Adventurer"],["2","Nomad"],["3","Warrior"],["4","Survivalist"],["5","Insane"]] }, + { prop: "XPMultiplier", label: "XP multiplier (%)", type: "number", min: 25, max: 500, step: 25, tmplKey: "xp_multiplier" }, + { prop: "BlockDamagePlayer",label: "Player block damage (%)", type: "number", min: 0, max: 1000, tmplKey: "block_damage_player" }, + { prop: "BlockDamageAI", label: "AI block damage (%)", type: "number", min: 0, max: 1000, tmplKey: "block_damage_ai" }, + { prop: "BlockDamageAIBM", label: "AI block damage during blood moon (%)", type: "number", min: 0, max: 1000, tmplKey: "block_damage_ai_bm" }, + { prop: "DayNightLength", label: "Day length (real minutes)", type: "number", min: 10, max: 240, tmplKey: "day_length_minutes" }, + { prop: "DayLightLength", label: "Daylight hours (per 24)", type: "number", min: 0, max: 24, tmplKey: "daylight_hours" }, + { prop: "BiomeProgression", label: "Biome progression", type: "bool", tmplKey: "biome_progression", hint: "V2.0 biome hazard/progression system" }, + { prop: "StormFreq", label: "Storm frequency (%)", type: "number", min: 0, max: 500, tmplKey: "storm_freq", hint: "100 = vanilla; 0 disables biome storms" }, + { prop: "PlayerSafeZoneLevel", label: "Player safe-zone level", type: "number", min: 0, max: 100, tmplKey: "player_safe_zone_level", hint: "Level required to leave the safe zone" }, + { prop: "PlayerSafeZoneHours", label: "Player safe-zone hours", type: "number", min: 0, max: 72, tmplKey: "player_safe_zone_hours" }, + { prop: "DeathPenalty", label: "Death penalty", type: "enum", tmplKey: "death_penalty", + options: [["0","None"],["1","XP loss"],["2","Full XP loss"]] }, + { prop: "DropOnDeath", label: "Death drop", type: "enum", tmplKey: "drop_on_death", + options: [["0","Nothing"],["1","Everything"],["2","Toolbelt only"],["3","Backpack only"],["4","Delete all"]] }, + { prop: "DropOnQuit", label: "Quit drop", type: "enum", tmplKey: "drop_on_quit", + options: [["0","Nothing"],["1","Everything"],["2","Toolbelt only"],["3","Backpack only"],["4","Delete all"]] }, + { prop: "PlayerKillingMode",label: "PvP mode", type: "enum", tmplKey: "pk_mode", + options: [["0","No killing"],["1","Allies only"],["2","Strangers only"],["3","Everyone"]] }, + { prop: "BuildCreate", label: "Creative mode (building)", type: "bool", tmplKey: "build_create" }, + { prop: "AllowSpawnNearFriend", label: "Spawn near friend", type: "number", min: 0, max: 2, tmplKey: "allow_spawn_near_friend", hint: "0 = off · 2 = vanilla default" }, + { prop: "CameraRestrictionMode", label: "Camera restriction mode", type: "number", min: 0, max: 2, tmplKey: "camera_restriction_mode", hint: "Limits 3rd-person camera abuse. 0 = off" }, + { prop: "JarRefund", label: "Jar refund (%)", type: "number", min: 0, max: 100, tmplKey: "jar_refund" }, + ]}, + + { title: "Zombies & Blood Moons", icon: "🧟", description: "Zombie counts, movement, and horde-night frequency.", fields: [ + { prop: "EnemySpawnMode", label: "Enemy spawning", type: "bool", tmplKey: "enemy_spawn_mode", hint: "Off = no zombies at all" }, + { prop: "EnemyDifficulty", label: "Enemy difficulty", type: "enum", tmplKey: "enemy_difficulty", + options: [["0","Normal"],["1","Feral"]] }, + { prop: "MaxSpawnedZombies", label: "Max zombies on server", type: "number", min: 8, max: 128, tmplKey: "max_spawned_zombies" }, + { prop: "MaxSpawnedAnimals", label: "Max animals on server", type: "number", min: 0, max: 128, tmplKey: "max_spawned_animals" }, + { prop: "ServerMaxAllowedViewDistance", label: "Max view distance (chunks)", type: "number", min: 6, max: 12, tmplKey: "max_view_distance" }, + { prop: "MaxQueuedMeshLayers", label: "Max queued mesh layers", type: "number", min: 100, max: 5000, tmplKey: "max_queued_mesh_layers", hint: "Chunk mesh generation queue — higher = faster loading, more RAM" }, + { prop: "ZombieFeralSense", label: "Feral sense", type: "enum", tmplKey: "zombie_feral_sense", + options: [["0","Off"],["1","Day only"],["2","Night only"],["3","Always"]] }, + { prop: "ZombieMove", label: "Zombie day speed", type: "enum", tmplKey: "zombie_move", + options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] }, + { prop: "ZombieMoveNight", label: "Zombie night speed", type: "enum", tmplKey: "zombie_move_night", + options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] }, + { prop: "ZombieFeralMove", label: "Feral zombie speed", type: "enum", tmplKey: "zombie_feral_move", + options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] }, + { prop: "ZombieBMMove", label: "Blood moon zombie speed", type: "enum", tmplKey: "zombie_bm_move", + options: [["0","Walk"],["1","Jog"],["2","Run"],["3","Sprint"],["4","Nightmare"]] }, + { prop: "AISmellMode", label: "AI smell mode", type: "number", min: 0, max: 3, tmplKey: "ai_smell_mode", hint: "Zombie smell sensing. 0 = off · 3 = vanilla default" }, + { prop: "BloodMoonFrequency", label: "Blood moon every (days)", type: "number", min: 0, max: 30, tmplKey: "blood_moon_frequency" }, + { prop: "BloodMoonRange", label: "± random days", type: "number", min: 0, max: 7, tmplKey: "blood_moon_range" }, + { prop: "BloodMoonWarning", label: "Blood moon warning at hour", type: "number", min: -1, max: 23, tmplKey: "blood_moon_warning" }, + { prop: "BloodMoonEnemyCount", label: "Zombies per player on blood moon", type: "number", min: 0, max: 128, tmplKey: "blood_moon_enemy_count" }, + ]}, + + { title: "Loot & Economy", icon: "💰", description: "Loot abundance, respawn time, and airdrops.", fields: [ + { prop: "LootAbundance", label: "Loot abundance (%)", type: "number", min: 25, max: 400, step: 25, tmplKey: "loot_abundance" }, + { prop: "LootRespawnDays", label: "Loot respawn (days)", type: "number", min: 0, max: 365, tmplKey: "loot_respawn_days" }, + { prop: "AirDropFrequency", label: "Airdrop every (hours, 0=off)", type: "number", min: 0, max: 168, tmplKey: "airdrop_frequency" }, + { prop: "AirDropMarker", label: "Show airdrop marker", type: "bool", tmplKey: "airdrop_marker" }, + { prop: "PartySharedKillRange", label: "Party-shared kill range", type: "number", min: 0, max: 1000, tmplKey: "party_shared_kill_range" }, + { prop: "QuestProgressionDailyLimit", label: "Quest progression daily limit", type: "number", min: 0, max: 100, tmplKey: "quest_daily_limit", hint: "Quests counting toward tier progression per day" }, + { prop: "TwitchServerPermission", label: "Twitch integration permission level", type: "number", min: 0, max: 1000, tmplKey: "twitch_server_permission" }, + { prop: "TwitchBloodMoonAllowed", label: "Twitch actions during blood moon", type: "bool", tmplKey: "twitch_blood_moon_allowed" }, + ]}, + + { title: "Land Claims", icon: "📍", description: "Land-claim block size, counts, decay rates, and dynamic mesh.", fields: [ + { prop: "LandClaimCount", label: "Land claims per player", type: "number", min: 1, max: 25, tmplKey: "land_claim_count" }, + { prop: "LandClaimSize", label: "Claim block size (blocks)", type: "number", min: 5, max: 151, tmplKey: "land_claim_size", hint: "Radius around the claim block; must be odd" }, + { prop: "LandClaimDeadZone", label: "Deadzone between claims (blocks)", type: "number", min: 5, max: 100, tmplKey: "land_claim_dead_zone" }, + { prop: "LandClaimExpiryTime", label: "Claim expiry (days offline)", type: "number", min: 1, max: 365, tmplKey: "land_claim_expiry_time" }, + { prop: "LandClaimDecayMode", label: "Claim decay mode", type: "enum", tmplKey: "land_claim_decay_mode", + options: [["0","Slow"],["1","Fast"],["2","None (always protected)"]] }, + { prop: "LandClaimOnlineDurabilityModifier", label: "Online durability multiplier", type: "number", min: 1, max: 128, tmplKey: "land_claim_online_durability_modifier" }, + { prop: "LandClaimOfflineDurabilityModifier",label: "Offline durability multiplier", type: "number", min: 1, max: 128, tmplKey: "land_claim_offline_durability_modifier" }, + { prop: "LandClaimOfflineDelay", label: "Offline delay (minutes)", type: "number", min: 0, max: 1440, tmplKey: "land_claim_offline_delay" }, + { prop: "BedrollDeadZoneSize", label: "Bedroll deadzone size", type: "number", min: 0, max: 100, tmplKey: "bedroll_dead_zone_size" }, + { prop: "BedrollExpiryTime", label: "Bedroll expiry (days offline)", type: "number", min: 1, max: 365, tmplKey: "bedroll_expiry_time" }, + { prop: "DynamicMeshEnabled", label: "Dynamic mesh", type: "bool", tmplKey: "dynamic_mesh_enabled", hint: "Renders player builds at long distance. RAM/CPU heavy." }, + { prop: "DynamicMeshLandClaimOnly", label: "Dynamic mesh in claims only", type: "bool", tmplKey: "dynamic_mesh_land_claim_only" }, + { prop: "DynamicMeshLandClaimBuffer", label: "Dynamic mesh claim buffer", type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_land_claim_buffer" }, + { prop: "DynamicMeshMaxItemCache", label: "Dynamic mesh max item cache", type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_max_item_cache" }, + ]}, + + { title: "Anti-Cheat & Telnet", icon: "🛡️", description: "EAC + telnet/RCON. The panel auto-generates a TelnetPassword on first boot and reads it for console access — the panel-owned fields are locked.", fields: [ + { prop: "EACEnabled", label: "EasyAntiCheat", type: "bool", tmplKey: "eac" }, + { prop: "IgnoreEOSSanctions", label: "Ignore EOS sanctions", type: "bool", tmplKey: "ignore_eos_sanctions", hint: "Let players sanctioned on Epic Online Services join anyway" }, + { prop: "HideCommandExecutionLog", label: "Hide command execution log", type: "enum", tmplKey: "hide_command_log", + options: [["0","Show everything"],["1","Hide only from Telnet/CP"],["2","Also hide from remote clients"],["3","Hide everything"]] }, + { prop: "TelnetEnabled", label: "Telnet enabled", type: "bool", tmplKey: "telnet_enabled", hint: "⚠ The panel's console + RCON ride telnet — disabling it breaks the Console tab and player lists." }, + { prop: "TelnetFailedLoginLimit", label: "Telnet failed-login limit", type: "number", min: 1, max: 100, tmplKey: "telnet_failed_login_limit" }, + { prop: "TelnetFailedLoginsBlocktime", label: "Telnet failed-login block (sec)", type: "number", min: 1, max: 86400, tmplKey: "telnet_failed_login_blocktime" }, + { prop: "TerminalWindowEnabled", label: "Terminal window enabled", type: "bool", tmplKey: "terminal_window_enabled" }, + { prop: "TelnetPort", label: "Telnet port", type: "number", min: 1, max: 65535, locked: true, + hint: "🔒 Panel-owned: allocated per instance and re-stamped from env on every boot." }, + { prop: "TelnetPassword", label: "Telnet password", type: "password", locked: true, + hint: "🔒 Panel-owned: auto-generated on first boot and persisted — the panel's RCON reads it. Changing it here would break console access." }, + { prop: "AdminFileName", label: "Admin file name", type: "string", locked: true, + hint: "🔒 Fixed to serveradmin.xml by the panel template." }, + ]}, + + { title: "Web Dashboard & Map", icon: "🗺️", description: "Built-in 7DTD web dashboard (replaces the old ControlPanel — removed in V2.6) and the map renderer it feeds. Map rendering must be on for the rendermap console command to work. The panel has its own dashboard; the in-game one is only needed if you want players to view the live map in a browser.", fields: [ + { prop: "WebDashboardEnabled", label: "Web dashboard enabled", type: "bool", tmplKey: "webdash_enabled", hint: "7DTD's built-in browser-based dashboard. Off by default." }, + { prop: "WebDashboardUrl", label: "Web dashboard external URL", type: "string", tmplKey: "webdash_url", hint: "Only set if behind a reverse proxy. Leave blank to use server's public IP." }, + { prop: "EnableMapRendering", label: "Enable map rendering", type: "bool", tmplKey: "enable_map_rendering", hint: "Required for the rendermap command and the dashboard's live map tile view." }, + { prop: "WebDashboardPort", label: "Web dashboard port", type: "number", min: 1, max: 65535, locked: true, + hint: "🔒 Panel-owned: allocated per instance and re-stamped from env on every boot." }, + ]}, + ], + }, + // 7DTD V3.0 "Dead Hot Summer" variant. V3.0 removed ~30 gameplay properties + // from serverconfig.xml and folded them into one encoded SandboxCode (the 150 + // in-game Sandbox Options). This schema keeps ONLY the properties that survive + // in the real 3.0 serverconfig.xml (verified against the shipped file), and + // adds a special "Sandbox Options" group (kind:"sandbox") rendered by a custom + // panel — paste-a-code + presets + per-option controls driven by + // /api/7dtd/sandbox/schema and round-tripped through the Go codec. Selected in + // loadInstanceConfig() when the instance's provider_id is a 3.0 branch. + "7dtd-v3": { + file: "serverconfig.xml", + filePaths: ["/game-saves/serverconfig.xml", "serverconfig.xml"], + format: "xml7dtd", + durable: true, + groups: [ + { title: "Identity", icon: "🏷️", description: "Server name, description, and visibility on the public server browser.", fields: [ + { prop: "ServerName", label: "Server name", type: "string", tmplKey: "server_name" }, + { prop: "ServerDescription", label: "Description", type: "string", tmplKey: "server_description" }, + { prop: "ServerWebsiteURL", label: "Website URL", type: "string", tmplKey: "server_website" }, + { prop: "ServerPassword", label: "Join password", type: "password", tmplKey: "server_password", hint: "Leave blank for an open server" }, + { prop: "ServerLoginConfirmationText", label: "Login confirmation text", type: "string", tmplKey: "login_confirmation_text" }, + { prop: "Region", label: "Region", type: "enum", tmplKey: "region", + options: ["NorthAmericaEast","NorthAmericaWest","CentralAmerica","SouthAmerica","Europe","Russia","Asia","MiddleEast","Africa","Oceania"] }, + { prop: "Language", label: "Language", type: "string", tmplKey: "language" }, + ]}, + { title: "Slots & Network", icon: "🔌", description: "Player cap, visibility, crossplay. Ports are panel-allocated — change them in the Network ports pane.", fields: [ + { prop: "ServerMaxPlayerCount", label: "Max players", type: "number", min: 1, max: 64, tmplKey: "max_players" }, + { prop: "ServerReservedSlots", label: "Reserved slots", type: "number", min: 0, max: 64, tmplKey: "reserved_slots" }, + { prop: "ServerReservedSlotsPermission", label: "Reserved-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "reserved_slots_permission" }, + { prop: "ServerAdminSlots", label: "Admin slots", type: "number", min: 0, max: 8, tmplKey: "admin_slots" }, + { prop: "ServerAdminSlotsPermission", label: "Admin-slot permission level", type: "number", min: 0, max: 1000, tmplKey: "admin_slots_permission" }, + { prop: "ServerVisibility", label: "Visibility", type: "enum", tmplKey: "server_visibility", + options: [["0","Private (LAN / direct only)"],["1","Friends only"],["2","Public"]] }, + { prop: "ServerAllowCrossplay", label: "Allow crossplay", type: "bool", tmplKey: "allow_crossplay", hint: "Console + PC. Requires EAC on." }, + { prop: "ServerDisabledNetworkProtocols", label: "Disabled network protocols", type: "string", tmplKey: "disabled_network_protocols" }, + { prop: "ServerMaxWorldTransferSpeedKiBs", label: "Max world transfer speed (KiB/s)", type: "number", min: 64, max: 8192, tmplKey: "max_world_transfer_speed" }, + ]}, + { title: "World", icon: "🌍", description: "⚠ Map, seed, size, and save folder. Changing any of these starts a new world save on the next restart.", fields: [ + { prop: "GameWorld", label: "World", type: "enum", tmplKey: "world_name", + options: ["Navezgane","Pregen06k01","Pregen06k02","Pregen08k01","Pregen08k02","Pregen10k01","Pregen10k02","RWG"] }, + { prop: "WorldGenSeed", label: "World seed", type: "string", tmplKey: "world_seed", hint: "Anything — used for RWG worlds" }, + { prop: "WorldGenSize", label: "World size", type: "number", min: 6144, max: 10240, step: 2048, tmplKey: "world_size", hint: "6144 / 8192 / 10240" }, + { prop: "GameName", label: "Save name", type: "string", tmplKey: "game_name" }, + { prop: "GameMode", label: "Game mode", type: "enum", tmplKey: "game_mode", options: ["GameModeSurvival"] }, + { prop: "PersistentPlayerProfiles", label: "Persistent player profiles", type: "bool", tmplKey: "persistent_player_profiles" }, + { prop: "MaxChunkAge", label: "Max chunk age (days, -1 = off)", type: "number", min: -1, max: 365, tmplKey: "max_chunk_age" }, + { prop: "SaveDataLimit", label: "Save data limit (MB, -1 = off)", type: "number", min: -1, max: 65536, tmplKey: "save_data_limit" }, + { prop: "MaxUncoveredMapChunksPerPlayer", label: "Max uncovered map chunks / player", type: "number", min: 16384, max: 1048576, tmplKey: "max_uncovered_chunks" }, + ]}, + // The 150 difficulty/gameplay options that V3.0 moved out of serverconfig. + // The single SandboxCode field is the durable carrier — the per-option UI + // (renderSandboxPane) computes its value. Listed in fields[] (rendered + // hidden) so the existing durable config-render + xml7dtd serializer pin + // and write it exactly like any other field; tmplKey maps it to the v3 + // template's {{ .Values.sandbox_code }}. + { title: "Sandbox Options", icon: "🎲", kind: "sandbox", description: "All 150 V3.0 gameplay options — difficulty, zombies, blood moon, loot, crafting, traders, quests — are now one encoded SandboxCode. Set them per-option below, paste a code from the game, or pick an official preset.", fields: [ + { prop: "SandboxCode", label: "Sandbox code", type: "string", tmplKey: "sandbox_code", hidden: true }, + ] }, + { title: "Survival & Spawn (kept)", icon: "⚡", description: "Gameplay properties that V3.0 KEPT in serverconfig.xml (everything else moved to Sandbox Options above).", fields: [ + { prop: "PlayerSafeZoneLevel", label: "Player safe-zone level", type: "number", min: 0, max: 100, tmplKey: "player_safe_zone_level" }, + { prop: "PlayerSafeZoneHours", label: "Player safe-zone hours", type: "number", min: 0, max: 72, tmplKey: "player_safe_zone_hours" }, + { prop: "BuildCreate", label: "Creative mode (building)", type: "bool", tmplKey: "build_create" }, + { prop: "BedrollDeadZoneSize", label: "Bedroll deadzone size", type: "number", min: 0, max: 100, tmplKey: "bedroll_dead_zone_size" }, + { prop: "BedrollExpiryTime", label: "Bedroll expiry (days offline)", type: "number", min: 1, max: 365, tmplKey: "bedroll_expiry_time" }, + { prop: "AllowSpawnNearFriend", label: "Spawn near friend", type: "number", min: 0, max: 2, tmplKey: "allow_spawn_near_friend", hint: "0 = off · 2 = forest only" }, + { prop: "CameraRestrictionMode", label: "Camera restriction mode", type: "number", min: 0, max: 2, tmplKey: "camera_restriction_mode" }, + { prop: "PlayerKillingMode",label: "PvP mode", type: "enum", tmplKey: "pk_mode", + options: [["0","No killing"],["1","Allies only"],["2","Strangers only"],["3","Everyone"]] }, + { prop: "PartySharedKillRange", label: "Party-shared kill range", type: "number", min: 0, max: 1000, tmplKey: "party_shared_kill_range" }, + ]}, + { title: "Performance", icon: "📈", description: "Server-side limits (kept in serverconfig.xml).", fields: [ + { prop: "MaxSpawnedZombies", label: "Max zombies on server", type: "number", min: 8, max: 128, tmplKey: "max_spawned_zombies" }, + { prop: "MaxSpawnedAnimals", label: "Max animals on server", type: "number", min: 0, max: 128, tmplKey: "max_spawned_animals" }, + { prop: "ServerMaxAllowedViewDistance", label: "Max view distance (chunks)", type: "number", min: 6, max: 12, tmplKey: "max_view_distance" }, + { prop: "MaxQueuedMeshLayers", label: "Max queued mesh layers", type: "number", min: 100, max: 5000, tmplKey: "max_queued_mesh_layers" }, + ]}, + { title: "Land Claims & Dynamic Mesh", icon: "📍", description: "Land-claim and dynamic-mesh settings (kept in serverconfig.xml).", fields: [ + { prop: "LandClaimCount", label: "Land claims per player", type: "number", min: 1, max: 25, tmplKey: "land_claim_count" }, + { prop: "LandClaimSize", label: "Claim block size (blocks)", type: "number", min: 5, max: 151, tmplKey: "land_claim_size", hint: "must be odd" }, + { prop: "LandClaimDeadZone", label: "Deadzone between claims (blocks)", type: "number", min: 5, max: 100, tmplKey: "land_claim_dead_zone" }, + { prop: "LandClaimExpiryTime", label: "Claim expiry (days offline)", type: "number", min: 1, max: 365, tmplKey: "land_claim_expiry_time" }, + { prop: "LandClaimDecayMode", label: "Claim decay mode", type: "enum", tmplKey: "land_claim_decay_mode", + options: [["0","Slow"],["1","Fast"],["2","None (always protected)"]] }, + { prop: "LandClaimOnlineDurabilityModifier", label: "Online durability multiplier", type: "number", min: 0, max: 128, tmplKey: "land_claim_online_durability_modifier" }, + { prop: "LandClaimOfflineDurabilityModifier",label: "Offline durability multiplier", type: "number", min: 0, max: 128, tmplKey: "land_claim_offline_durability_modifier" }, + { prop: "LandClaimOfflineDelay", label: "Offline delay (minutes)", type: "number", min: 0, max: 1440, tmplKey: "land_claim_offline_delay" }, + { prop: "DynamicMeshEnabled", label: "Dynamic mesh", type: "bool", tmplKey: "dynamic_mesh_enabled" }, + { prop: "DynamicMeshLandClaimOnly", label: "Dynamic mesh in claims only", type: "bool", tmplKey: "dynamic_mesh_land_claim_only" }, + { prop: "DynamicMeshLandClaimBuffer", label: "Dynamic mesh claim buffer", type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_land_claim_buffer" }, + { prop: "DynamicMeshMaxItemCache", label: "Dynamic mesh max item cache", type: "number", min: 1, max: 10, tmplKey: "dynamic_mesh_max_item_cache" }, + ]}, + { title: "Anti-Cheat & Telnet", icon: "🛡️", description: "EAC + telnet/RCON. Panel-owned fields are locked.", fields: [ + { prop: "EACEnabled", label: "EasyAntiCheat", type: "bool", tmplKey: "eac" }, + { prop: "IgnoreEOSSanctions", label: "Ignore EOS sanctions", type: "bool", tmplKey: "ignore_eos_sanctions" }, + { prop: "HideCommandExecutionLog", label: "Hide command execution log", type: "enum", tmplKey: "hide_command_log", + options: [["0","Show everything"],["1","Hide only from Telnet/CP"],["2","Also hide from remote clients"],["3","Hide everything"]] }, + { prop: "TelnetEnabled", label: "Telnet enabled", type: "bool", tmplKey: "telnet_enabled", hint: "⚠ The panel's console + RCON ride telnet." }, + { prop: "TelnetFailedLoginLimit", label: "Telnet failed-login limit", type: "number", min: 1, max: 100, tmplKey: "telnet_failed_login_limit" }, + { prop: "TelnetFailedLoginsBlocktime", label: "Telnet failed-login block (sec)", type: "number", min: 1, max: 86400, tmplKey: "telnet_failed_login_blocktime" }, + { prop: "TerminalWindowEnabled", label: "Terminal window enabled", type: "bool", tmplKey: "terminal_window_enabled" }, + ]}, + { title: "Web Dashboard & Map", icon: "🗺️", description: "7DTD's built-in web dashboard + map renderer.", fields: [ + { prop: "WebDashboardEnabled", label: "Web dashboard enabled", type: "bool", tmplKey: "webdash_enabled" }, + { prop: "WebDashboardUrl", label: "Web dashboard external URL", type: "string", tmplKey: "webdash_url" }, + { prop: "EnableMapRendering", label: "Enable map rendering", type: "bool", tmplKey: "enable_map_rendering" }, + { prop: "TwitchServerPermission", label: "Twitch integration permission level", type: "number", min: 0, max: 1000, tmplKey: "twitch_server_permission" }, + { prop: "TwitchBloodMoonAllowed", label: "Twitch actions during blood moon", type: "bool", tmplKey: "twitch_blood_moon_allowed" }, + ]}, + ], + }, + "ark-sa": { + file: "Config/WindowsServer/GameUserSettings.ini", + filePaths: [ + "Config/WindowsServer/GameUserSettings.ini", + ], + format: "ini_ark", + groups: [ + { title: "Identity", icon: "🏷️", description: "MOTD, hardcore, spectator password. Session name + join + admin passwords moved to Game launch settings above — ARK's launch args override these INI keys, so editing them here had no effect.", fields: [ + { prop: "SpectatorPassword", label: "Spectator password", type: "password" }, + { prop: "Message", label: "MOTD (login message)", type: "string" }, + { prop: "ServerHardcore", label: "Hardcore mode", type: "bool" }, + ]}, + + { title: "Slots & Network", icon: "🔌", description: "Reserved slots + RCON tuning. Max players moved to Game launch settings above — env-driven launch args override the INI value. Port changes need a container rebuild (Settings → Maintenance).", fields: [ + { prop: "ReservedPlayerSlots", label: "Reserved player slots", type: "number", min: 0, max: 64, hint: "Slots held open for specific Steam IDs" }, + { prop: "RCONEnabled", label: "RCON enabled", type: "bool" }, + { prop: "RCONPort", label: "RCON port", type: "number", min: 1, max: 65535 }, + { prop: "RCONServerGameLogBuffer", label: "RCON log buffer", type: "number", min: 100, max: 10000 }, + ]}, + + { title: "Rates", icon: "⚡", description: "All the multipliers. 1.0 = vanilla, higher = faster. Cluster conventions: 2×–5× is casual, 10×+ is arcade.", fields: [ + { prop: "HarvestAmountMultiplier", label: "Harvest amount", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "XPMultiplier", label: "XP gain", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "TamingSpeedMultiplier", label: "Taming speed", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "MatingIntervalMultiplier", label: "Mating interval", type: "number", step: 0.01, min: 0.001, max: 1, hint: "Lower = mate more often. 0.1 is common" }, + { prop: "EggHatchSpeedMultiplier", label: "Egg hatch speed", type: "number", step: 0.1, min: 0.1, max: 100 }, + { prop: "BabyMatureSpeedMultiplier", label: "Baby mature speed", type: "number", step: 0.1, min: 0.1, max: 100 }, + { prop: "BabyCuddleIntervalMultiplier",label: "Baby cuddle interval",type: "number", step: 0.1, min: 0, max: 100, hint: "Lower = cuddle sooner" }, + { prop: "BabyImprintAmountMultiplier", label: "Imprint amount/cuddle", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "BabyFoodConsumptionSpeedMultiplier", label: "Baby food consumption", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "CropGrowthSpeedMultiplier", label: "Crop growth speed", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "DayCycleSpeedScale", label: "Day/night cycle speed", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "DayTimeSpeedScale", label: "Day length scale", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "NightTimeSpeedScale", label: "Night length scale", type: "number", step: 0.1, min: 0, max: 100 }, + ]}, + + { title: "Players", icon: "🧍", description: "Stats + survival rates for human characters.", fields: [ + { prop: "PlayerDamageMultiplier", label: "Damage dealt", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerResistanceMultiplier", label: "Damage resistance", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerCharacterFoodDrainMultiplier", label: "Food drain rate", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerCharacterWaterDrainMultiplier",label: "Water drain rate", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerCharacterStaminaDrainMultiplier", label: "Stamina drain", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerCharacterHealthRecoveryMultiplier", label: "Health recovery", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "XPMultiplier", label: "XP multiplier", type: "number", step: 0.1, min: 0, max: 100 }, + ]}, + + { title: "Dinos", icon: "🦖", description: "Wild + tamed dinosaur behaviour and difficulty.", fields: [ + { prop: "DifficultyOffset", label: "Difficulty offset", type: "number", step: 0.01, min: 0, max: 1, hint: "1.0 = official difficulty (wild dinos up to level 150)" }, + { prop: "OverrideOfficialDifficulty", label: "Override official difficulty", type: "number", step: 0.1, min: 0, max: 50, hint: "5.0 = wild dinos up to level 150; 10.0 = up to 300" }, + { prop: "DinoCountMultiplier", label: "Dino spawn count", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "DinoDamageMultiplier", label: "Wild dino damage", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "TamedDinoDamageMultiplier", label: "Tamed dino damage", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "DinoResistanceMultiplier", label: "Wild dino resistance", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "TamedDinoResistanceMultiplier", label: "Tamed dino resistance", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "DinoCharacterFoodDrainMultiplier", label: "Dino food drain", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "DinoCharacterStaminaDrainMultiplier",label: "Dino stamina drain", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "DinoCharacterHealthRecoveryMultiplier", label: "Dino health recovery", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "MaxTamedDinos", label: "Server-wide tamed dino cap", type: "number", min: 0, max: 20000 }, + { prop: "MaxPersonalTamedDinos", label: "Per-tribe tamed dino cap", type: "number", min: 0, max: 2000 }, + { prop: "RaidDinoCharacterFoodDrainMultiplier", label: "Raid dino food drain", type: "number", step: 0.1, min: 0, max: 100 }, + ]}, + + { title: "Structures", icon: "🏛️", description: "Build limits, decay, and quality-of-life around placement and pickup.", fields: [ + { prop: "StructureDamageMultiplier", label: "Structure damage dealt", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "StructureResistanceMultiplier", label: "Structure damage taken", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "TheMaxStructuresInRange", label: "Max structures in range", type: "number", min: 100, max: 100000 }, + { prop: "bDisableStructureDecayPvE", label: "Disable structure decay (PvE)", type: "bool" }, + { prop: "AlwaysAllowStructurePickup", label: "Always allow pickup", type: "bool", hint: "Skip the 30s pickup window — pickup forever" }, + { prop: "StructurePickupTimeAfterPlacement", label: "Pickup window (seconds)", type: "number", min: 0, max: 3600 }, + { prop: "StructurePickupHoldDuration", label: "Pickup hold duration (sec)", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "bPvEAllowStructuresAtSupplyDrops", label: "PvE: allow builds near supply drops", type: "bool" }, + { prop: "PvPStructureDecay", label: "PvP: structure decay", type: "bool" }, + { prop: "DestroyTamesOverTheSoftTameLimit", label: "Auto-destroy tames over soft limit", type: "bool" }, + ]}, + + { title: "Combat & PvP", icon: "⚔️", description: "Combat modes and friendly-fire rules. Server mode is the master toggle — PvP allows player-on-player damage and base raiding; PvE blocks both. Default (when unset) is PvP.", fields: [ + // ServerPVE is the load-bearing toggle. Empty INI value = + // PvP (ARK default). Setting this to True flips the server to + // PvE on the next restart. Launch args can override (?ServerPVE=) + // but the acekorneya image doesn't pass that, so the INI wins. + { prop: "ServerPVE", label: "Server mode", type: "enum", + options: [["","PvP (default)"],["True","PvE"],["False","PvP (explicit)"]], + hint: "PvE prevents player-vs-player damage and structure raiding. Wild dinos can still attack. Takes effect on next server restart." }, + { prop: "PlayerKillingMode", label: "Player killing mode", type: "enum", + options: [["0","No killing"],["1","Allies only"],["2","Strangers only"],["3","Everyone"]] }, + { prop: "bDisableFriendlyFire", label: "Disable friendly fire (global)", type: "bool" }, + { prop: "bPvEDisableFriendlyFire", label: "Disable friendly fire (PvE only)", type: "bool" }, + { prop: "AllowHideDamageSourceFromLogs", label: "Allow hide damage source from logs", type: "bool" }, + { prop: "bIncreasePvPRespawnInterval", label: "Increase PvP respawn cooldown", type: "bool" }, + { prop: "IncreasePvPRespawnIntervalCheckPeriod", label: "PvP respawn check period (sec)", type: "number", min: 30, max: 3600 }, + { prop: "IncreasePvPRespawnIntervalMultiplier", label: "PvP respawn multiplier", type: "number", step: 0.1, min: 1, max: 10 }, + ]}, + + { title: "Tribes & Alliances", icon: "🛡️", description: "Tribe size, alliances, and log retention.", fields: [ + { prop: "TribeNameChangeCooldown", label: "Tribe rename cooldown (min)", type: "number", min: 0, max: 1440 }, + { prop: "MaxTribeLogs", label: "Max tribe log entries", type: "number", min: 0, max: 10000 }, + { prop: "MaxNumbersofPlayersInTribe", label: "Max players per tribe", type: "number", min: 1, max: 500 }, + { prop: "MaxAlliancesPerTribe", label: "Max alliances per tribe", type: "number", min: 0, max: 100 }, + { prop: "MaxTribesPerAlliance", label: "Max tribes per alliance", type: "number", min: 0, max: 100 }, + { prop: "TribeLogDestroyedEnemyStructures", label: "Log destroyed enemy structures", type: "bool" }, + ]}, + + { title: "Quality of Life", icon: "✨", description: "Stuff most servers turn on without thinking.", fields: [ + { prop: "ShowMapPlayerLocation", label: "Show player location on map", type: "bool" }, + { prop: "AllowThirdPersonPlayer", label: "Allow 3rd-person camera", type: "bool" }, + { prop: "ServerCrosshair", label: "Enable crosshair", type: "bool" }, + { prop: "bAllowFlyerCarryPvE", label: "Allow flyer carry (PvE)", type: "bool" }, + { prop: "bAllowUnlimitedRespecs", label: "Unlimited mindwipe respecs", type: "bool" }, + { prop: "bDisableDinoRiding", label: "Disable dino riding", type: "bool" }, + { prop: "bDisableDinoTaming", label: "Disable dino taming", type: "bool" }, + { prop: "bFlyerPlatformAllowUnalignedDinoBasing", label: "Unaligned dinos on flyer platforms", type: "bool" }, + { prop: "bAllowCustomRecipes", label: "Allow custom recipes", type: "bool" }, + { prop: "bAllowRaidDinoFeeding", label: "Allow raid dino feeding", type: "bool" }, + ]}, + + { title: "Cluster transfers", icon: "🔄", description: "Per-server transfer permissions. Only relevant when this server is in a cluster. Upload = sending FROM this server INTO the cluster (player at obelisk → cluster storage). Download = pulling FROM the cluster ONTO this server (arrivals on this map). Default off = transfers permitted both ways.", fields: [ + { prop: "PreventUploadSurvivors", label: "Prevent survivor uploads", type: "bool", hint: "Survivors on this server cannot leave to the cluster." }, + { prop: "PreventUploadDinos", label: "Prevent dino uploads", type: "bool", hint: "Dinos on this server cannot leave to the cluster." }, + { prop: "PreventUploadItems", label: "Prevent item uploads", type: "bool", hint: "Items on this server cannot leave to the cluster." }, + { prop: "PreventDownloadSurvivors", label: "Prevent survivor downloads", type: "bool", hint: "Survivors from the cluster cannot arrive on this server." }, + { prop: "PreventDownloadDinos", label: "Prevent dino downloads", type: "bool", hint: "Dinos from the cluster cannot arrive on this server." }, + { prop: "PreventDownloadItems", label: "Prevent item downloads", type: "bool", hint: "Items from the cluster cannot arrive on this server." }, + ]}, + + { title: "Mods", icon: "🧩", description: `
    + 💡 Mod IDs are managed in the 🧩 Mods tab, not here. That tab has CurseForge search, previews, and one-click install — it writes to the launch-arg -mods= / -passivemods= lists, which is the way ASA loads CurseForge mods (per Wildcard's own docs). +

    + The older ActiveMods= / ActiveModsList= entries in GameUserSettings.ini were the ASE / Steam-Workshop mechanism — ASA ignores them completely. That's why those fields aren't exposed in this form. +
    + The fields below are mod-adjacent server tuning that isn't mod IDs.`, fields: [ + { prop: "PreventHibernation", label: "Prevent hibernation", type: "bool", hint: "Some mods (big maps, heavy gameplay tweaks) need this ON so tamed dinos stay active in unloaded regions." }, + ]}, + + { title: "Admin & Logs", icon: "🛠️", description: "Logging, admin-command visibility, chat behaviour.", fields: [ + { prop: "AdminLogging", label: "Admin command logging", type: "bool" }, + { prop: "LogRCONChat", label: "Log RCON chat", type: "bool" }, + { prop: "NotifyAdminCommandsInChat", label: "Announce admin commands in chat", type: "bool" }, + { prop: "ServerChatAutoTime", label: "Show time in chat", type: "bool" }, + { prop: "AllowAnyoneBabyImprintCuddle", label: "Anyone can imprint babies", type: "bool" }, + { prop: "DisableImprintDinoBuff", label: "Disable imprint ride-damage buff", type: "bool" }, + { prop: "ClampResourceHarvestDamage", label: "Clamp resource harvest damage", type: "bool" }, + ]}, + ], + }, + "empyrion": { + file: "dedicated.yaml", + filePaths: ["dedicated.yaml", "/game-saves/dedicated.yaml", "/game/dedicated.yaml"], + format: "yaml_empyrion", + groups: [ + { title: "Identity", icon: "🏷️", description: "Server name, description, password, and visibility.", fields: [ + { prop: "Srv_Name", label: "Server name", type: "string" }, + { prop: "Srv_Description", label: "Description", type: "string" }, + { prop: "Srv_Password", label: "Join password", type: "password", hint: "Leave blank for an open server" }, + { prop: "Srv_Public", label: "Publicly listed", type: "bool" }, + ]}, + { title: "Slots & Network", icon: "🔌", description: "Player cap and the ports Empyrion listens on.", fields: [ + { prop: "Srv_MaxPlayers", label: "Max players", type: "number", min: 1, max: 128 }, + { prop: "Srv_Port", label: "Server port (UDP)", type: "number", min: 1, max: 65535 }, + { prop: "Srv_ReservePlayfields", label: "Reserved playfields", type: "number", min: 0, max: 8 }, + { prop: "Srv_StopPeriod", label: "Auto-stop after N min idle (0 = off)", type: "number", min: 0 }, + { prop: "EACActive", label: "EasyAntiCheat enabled", type: "bool" }, + { prop: "TelnetEnabled", label: "Telnet admin enabled", type: "bool" }, + { prop: "TelnetPort", label: "Telnet port", type: "number", min: 1, max: 65535 }, + { prop: "TelnetPassword", label: "Telnet password", type: "password" }, + ]}, + { title: "World", icon: "🌍", description: "Game mode, scenario, and seed. Changing these usually requires a fresh save.", fields: [ + { prop: "GameName", label: "Save name", type: "string", hint: "Folder under Saves/Games" }, + { prop: "Mode", label: "Game mode", type: "enum", options: ["Survival","Creative","Freedom"] }, + { prop: "Seed", label: "Seed", type: "string", hint: "Numeric seed; blank = random" }, + { prop: "CustomScenario", label: "Custom scenario", type: "string", hint: "Folder under Content/Scenarios; blank = Default" }, + ]}, + { title: "Tuning", icon: "⚙️", description: "Heartbeats, log detail, and blueprint policy.", fields: [ + { prop: "HeartbeatServer", label: "Server heartbeat (s)", type: "number", min: 5, max: 120 }, + { prop: "HeartbeatClient", label: "Client heartbeat (s)", type: "number", min: 5, max: 120 }, + { prop: "LogFlags", label: "Log flags (bitmask)", type: "number", min: 0, max: 4095 }, + { prop: "ChatChannelPermission", label: "Chat-channel permission", type: "number", min: 0, max: 9 }, + { prop: "MaxAllowedSizeClass", label: "Max allowed structure size class", type: "number", min: 1, max: 20 }, + { prop: "AllowedBlueprints", label: "Allowed blueprints", type: "enum", options: ["All","StockOnly","None"] }, + { prop: "PlayerLoginFullServerParallelCount", label: "Parallel logins to full server", type: "number", min: 1, max: 20 }, + ]}, + ], + }, + "valheim": { + // Panel-native Valheim reads /game-saves/server.cfg (shell-style + // KEY=value lines) on every boot — the entrypoint sources it and + // passes the values to the game binary as CLI flags. + file: "server.cfg", + filePaths: ["server.cfg", "/game-saves/server.cfg"], + format: "properties", + groups: [ + { title: "Identity", icon: "🏷️", description: "How your server shows up in the server browser and who can join.", fields: [ + { prop: "SERVER_NAME", label: "Server name", type: "string" }, + { prop: "SERVER_WORLD", label: "World name", type: "string", hint: "Becomes the save folder + .db/.fwl filenames under /game-saves" }, + { prop: "SERVER_PASSWORD", label: "Join password", type: "password", hint: "Valheim requires ≥5 chars, and it can't contain the server or world name" }, + { prop: "SERVER_PUBLIC", label: "Publicly listed", type: "enum", options: [["1","Public (crossplay)"],["0","Unlisted"]] }, + ]}, + ], + }, + "minecraft-java": { + file: "server.properties", + filePaths: ["server.properties", "/data/server.properties"], + format: "properties", + groups: [ + { title: "Identity", fields: [ + { prop: "motd", label: "MOTD (server description)", type: "string" }, + { prop: "server-port", label: "Server port", type: "number", min: 1, max: 65535 }, + { prop: "max-players", label: "Max players", type: "number", min: 1, max: 250 }, + ]}, + { title: "Gameplay", fields: [ + { prop: "difficulty", label: "Difficulty", type: "enum", options: ["peaceful","easy","normal","hard"] }, + { prop: "gamemode", label: "Default gamemode", type: "enum", options: ["survival","creative","adventure","spectator"] }, + { prop: "hardcore", label: "Hardcore", type: "bool" }, + { prop: "pvp", label: "PvP", type: "bool" }, + { prop: "allow-flight", label: "Allow flight (survival)", type: "bool" }, + { prop: "force-gamemode", label: "Force default gamemode on join", type: "bool" }, + ]}, + { title: "World", fields: [ + { prop: "level-name", label: "World folder", type: "string" }, + { prop: "level-seed", label: "World seed", type: "string", hint: "Blank = random" }, + { prop: "level-type", label: "World type", type: "enum", + options: ["minecraft:normal","minecraft:flat","minecraft:large_biomes","minecraft:amplified","minecraft:single_biome_surface"] }, + { prop: "generate-structures", label: "Generate structures", type: "bool" }, + { prop: "allow-nether", label: "Allow Nether", type: "bool" }, + { prop: "spawn-protection", label: "Spawn protection (blocks)", type: "number", min: 0, max: 1000 }, + ]}, + { title: "Players & auth", fields: [ + { prop: "online-mode", label: "Online mode (require Mojang auth)", type: "bool" }, + { prop: "white-list", label: "Enable whitelist", type: "bool" }, + { prop: "enforce-whitelist", label: "Kick non-whitelisted already online", type: "bool" }, + { prop: "op-permission-level", label: "Default op level", type: "number", min: 1, max: 4 }, + ]}, + { title: "Performance", fields: [ + { prop: "view-distance", label: "View distance (chunks)", type: "number", min: 3, max: 32 }, + { prop: "simulation-distance", label: "Simulation distance (chunks)", type: "number", min: 5, max: 32 }, + { prop: "entity-broadcast-range-percentage", label: "Entity broadcast range (%)", type: "number", min: 10, max: 500, step: 10 }, + ]}, + { title: "Commands & protection", fields: [ + { prop: "enable-command-block", label: "Enable command blocks", type: "bool" }, + { prop: "function-permission-level", label: "/function permission level", type: "number", min: 1, max: 4 }, + ]}, + ], + }, + + // Palworld stores its config inside a single OptionSettings=(…) tuple + // in PalWorldSettings.ini. The entrypoint patches identity / port / RCON + // fields from env on every boot, so this schema deliberately omits them + // to keep the source of truth where the operator expects it (the panel's + // Settings tab edits them via env, the entrypoint applies them). + "palworld": { + file: "PalWorldSettings.ini", + filePaths: [ + "Pal/Saved/Config/LinuxServer/PalWorldSettings.ini", + "/game/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini", + ], + format: "ini_palworld", + groups: [ + { title: "World", icon: "🌎", description: "World rules — difficulty + the speed multipliers Palworld exposes for day/night length, work speed, and breeding/egg pacing.", fields: [ + { prop: "Difficulty", label: "Difficulty", type: "enum", options: ["None","Casual","Normal","Hard"], hint: "None means \"use the per-field overrides below\"" }, + { prop: "DayTimeSpeedRate", label: "Daytime speed", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "NightTimeSpeedRate", label: "Nighttime speed", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "ExpRate", label: "Experience gain", type: "number", step: 0.1, min: 0.1, max: 20 }, + { prop: "WorkSpeedRate", label: "Player work speed", type: "number", step: 0.1, min: 0.1, max: 5 }, + { prop: "EnableInvaderEnemy", label: "Invader raids", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "DeathPenalty", label: "Death penalty", type: "enum", options: ["None","Item","ItemAndEquipment","All"] }, + ]}, + { title: "Pals", icon: "🐾", description: "Pal rates — capture odds, spawn density, drop bonuses, breeding/egg cadence.", fields: [ + { prop: "PalCaptureRate", label: "Capture rate", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PalSpawnNumRate", label: "Pal spawn density", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PalDamageRateAttack", label: "Pal attack damage", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PalDamageRateDefense", label: "Pal defense (incoming dmg multiplier)", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PalStomachDecreaseRate", label: "Pal hunger drain", type: "number", step: 0.1, min: 0.1, max: 5 }, + { prop: "PalStaminaDecreaseRate", label: "Pal stamina drain", type: "number", step: 0.1, min: 0.1, max: 5 }, + { prop: "PalAutoHPRegeneRate", label: "Pal HP regen (active)", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PalAutoHPRegeneRateInSleep", label: "Pal HP regen (sleeping)", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PalEggDefaultHatchingTime", label: "Egg hatch time (hours)", type: "number", step: 0.5, min: 0.5, max: 200 }, + { prop: "DropItemMultiplier", label: "Item drop multiplier", type: "number", step: 0.1, min: 0.1, max: 20 }, + ]}, + { title: "Player", icon: "🧑", description: "Player-side rates that don't fit \"world\" — damage, hunger/stamina, sanity.", fields: [ + { prop: "PlayerDamageRateAttack", label: "Player attack damage", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PlayerDamageRateDefense", label: "Player defense (incoming dmg multiplier)", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PlayerStomachDecreaseRate", label: "Player hunger drain", type: "number", step: 0.1, min: 0.1, max: 5 }, + { prop: "PlayerStaminaDecreaseRate", label: "Player stamina drain", type: "number", step: 0.1, min: 0.1, max: 5 }, + { prop: "PlayerAutoHPRegeneRate", label: "Player HP regen (active)", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "PlayerAutoHPRegeneRateInSleep", label: "Player HP regen (sleeping)", type: "number", step: 0.1, min: 0.1, max: 10 }, + ]}, + { title: "Base / Guild", icon: "🏠", description: "Pal-base capacity, guild player limits, build worker counts.", fields: [ + { prop: "BaseCampMaxNumInGuild", label: "Bases per guild", type: "number", min: 1, max: 50 }, + { prop: "BaseCampWorkerMaxNum", label: "Workers per base", type: "number", min: 1, max: 50 }, + { prop: "GuildPlayerMaxNum", label: "Players per guild", type: "number", min: 1, max: 100 }, + { prop: "bIsMultiplay", label: "Multiplayer", type: "enum", options: [["True","Multiplayer"],["False","Single-player only"]] }, + { prop: "bIsPvP", label: "PvP enabled", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "bHardcore", label: "Hardcore (no respawn)", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "bPalLost", label: "Lose pals on death", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "bCharacterRecreateInHardcore", label: "Recreate character in hardcore", type: "enum", options: [["True","On"],["False","Off"]] }, + ]}, + { title: "Resources", icon: "⛏️", description: "Tunables for collected/dropped resource rates and chest stack sizes.", fields: [ + { prop: "CollectionDropRate", label: "Collection drop rate", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "CollectionObjectHpRate", label: "Collection object HP", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "CollectionObjectRespawnSpeedRate", label: "Collection respawn speed", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "EnemyDropItemRate", label: "Enemy drop rate", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "BuildObjectHpRate", label: "Built object HP", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "BuildObjectDamageRate", label: "Built object damage taken", type: "number", step: 0.1, min: 0.1, max: 10 }, + { prop: "BuildObjectDeteriorationDamageRate", label: "Building decay rate", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "ItemContainerForceMarkDirtyInterval", label: "Container save interval (s)", type: "number", min: 0, max: 600 }, + ]}, + { title: "Chat / Notes", icon: "💬", description: "Operator-visible toggles that are NOT also patched from env on each boot. Server name / description / max players / RCON live in the Settings → Configuration tab (env-driven, applied on boot).", fields: [ + { prop: "Region", label: "Region tag", type: "string", hint: "Free-form tag, surfaces in the public listing" }, + { prop: "bUseAuth", label: "Require Steam auth", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "BanListURL", label: "Shared ban list URL", type: "string", hint: "Leave default unless you're running a community ban-list server" }, + ]}, + ], + }, + + // Windrose — ServerDescription.json is the whole config surface. Field + // list mirrors cytech-services' AMP template (windroseconfig.json) so + // operators migrating from AMP get the same 7-field view. Fields not in + // AMP's template (UseDirectConnection, UserSelectedRegion, etc.) are + // preserved verbatim by the JSON serializer but aren't exposed here — + // Windrose connects every client through its own P2P coop gateway, and + // direct-connection mode is not the intended path per-game. + // + // Invite code + world ID are auto-generated by Windrose on first boot + // when the corresponding field is empty, so auto-populate just works. + "windrose": { + file: "R5/ServerDescription.json", + filePaths: [ + "R5/ServerDescription.json", + ], + format: "json", + groups: [ + { title: "Identity", icon: "🏷️", description: "What players see when they browse or join. Server name is the label in-game; Note is an extra line to disambiguate servers with similar invite codes. Password protection + a password together gate who can join.", fields: [ + { prop: "ServerDescription_Persistent.ServerName", label: "Server name", type: "string", hint: "Shown in the in-game server browser" }, + { prop: "ServerDescription_Persistent.Note", label: "Server note", type: "string", hint: "Optional extra description — useful when invite codes collide" }, + { prop: "ServerDescription_Persistent.IsPasswordProtected", label: "Enable password protection", type: "bool" }, + { prop: "ServerDescription_Persistent.Password", label: "Server password", type: "password", hint: "Only enforced when password protection is on" }, + { prop: "ServerDescription_Persistent.MaxPlayerCount", label: "Player limit", type: "number", min: 1, max: 10, hint: "More than 4 may cause performance issues, especially with large frigates" }, + ]}, + + { title: "Invite & World", icon: "🧭", description: "Invite code is what players type in-game to find this server — Windrose routes them through its P2P coop gateway, so no port forwarding is required. Clearing the field and restarting forces Windrose to mint a fresh one. World island ID selects which save folder under R5/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<id>/ the server loads; clearing it creates a fresh world on next boot.", fields: [ + { prop: "ServerDescription_Persistent.InviteCode", label: "Invite code", type: "string", hint: "At least 6 alphanumeric characters, case-sensitive. Empty = regenerate on next start." }, + { prop: "ServerDescription_Persistent.WorldIslandId", label: "World island ID", type: "string", hint: "Must match the ID in that world's WorldDescription.json. Empty = new world on next start." }, + ]}, + ], + }, + + // Conan Exiles — settings live in ConanSandbox/Saved/Config// + // ServerSettings.ini under a single [ServerSettings] header. The subdir is + // edition-aware: Enhanced (UE5, native Linux) writes to LinuxServer/, + // Legacy (UE4, Wine) writes to WindowsServer/. The schema's `file` + // (display) and `filePaths` (lookup) get rewritten at openCfg() time based + // on the instance's config_values.EDITION. Same shape as ARK SA's + // GameUserSettings.ini so we reuse the ini_ark parser/serializer: updates + // land under [ServerSettings], stranded keys in other sections get healed + // automatically. Server name + admin password also live here, but panel + // ALSO sets SERVER_NAME / RCON_PASSWORD via env at launch — env wins for + // those two specifically, so editing them here is informational only (the + // launch arg overrides on next start). + "conan-exiles": { + file: "ConanSandbox/Saved/Config/LinuxServer/ServerSettings.ini", + filePaths: [ + "ConanSandbox/Saved/Config/LinuxServer/ServerSettings.ini", + "ConanSandbox/Saved/Config/WindowsServer/ServerSettings.ini", + ], + format: "ini_ark", + groups: [ + { title: "Identity & Access", icon: "🏷️", description: "Player-facing identity + login gates. ServerName is what shows up in the in-game server browser. AdminPassword is the in-game admin auth (used by /makeme admin); RCON has its own password set via env.", fields: [ + { prop: "ServerName", label: "Server name", type: "string", hint: "Shown in the in-game server browser. Stamped into [ServerSettings] ServerName= on every boot from the SERVER_NAME env (set via the instance's config_values)." }, + { prop: "AdminPassword", label: "In-game admin password", type: "password" }, + { prop: "ServerPassword", label: "Join password", type: "password", hint: "Empty = open server" }, + { prop: "ServerMessageOfTheDay", label: "MOTD", type: "string" }, + { prop: "MaxNudity", label: "Max nudity tier", type: "enum", options: [["0","None"],["1","Partial"],["2","Full"]] }, + { prop: "ServerCommunity", label: "Community tag", type: "enum", options: [["0","No tag"],["1","Purist"],["2","Relaxed"],["3","Hard core"],["4","Role playing"],["5","Experimental"]] }, + { prop: "serverRegion", label: "Region", type: "enum", options: [["0","EU"],["1","NA"],["2","Asia"],["3","Oceania"],["4","SA"]] }, + ]}, + + { title: "PvP & Combat", icon: "⚔️", description: "Master toggle is PVPEnabled. PvE servers leave it False. The various time-restricted PvP windows below only apply when PVPEnabled is True.", fields: [ + { prop: "PVPEnabled", label: "PvP enabled", type: "bool" }, + { prop: "RestrictPVPTime", label: "Restrict PvP to time windows", type: "bool" }, + { prop: "RestrictPVPBuildingDamageTime", label: "Restrict PvP building damage to time windows", type: "bool" }, + { prop: "FriendlyFireDamageMultiplier", label: "Friendly fire damage", type: "number", step: 0.05, min: 0, max: 5 }, + { prop: "CombatModeModifier", label: "Combat mode modifier", type: "enum", options: [["0","Default"],["1","Targeting"],["2","Soft tab target"]] }, + { prop: "CanDamagePlayerOwnedStructures", label: "Damage player-owned structures (PvE-only flag)", type: "bool" }, + ]}, + + { title: "Player Rates", icon: "🧍", description: "Per-player damage / movement / XP scaling. 1.0 = vanilla.", fields: [ + { prop: "PlayerDamageMultiplier", label: "Player damage dealt", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerDamageTakenMultiplier", label: "Player damage taken", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerKnockbackMultiplier", label: "Player knockback", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerMovementSpeedScale", label: "Movement speed", type: "number", step: 0.1, min: 0.1, max: 5 }, + { prop: "PlayerSprintSpeedScale", label: "Sprint speed", type: "number", step: 0.1, min: 0.1, max: 5 }, + { prop: "PlayerStaminaCostMultiplier", label: "Stamina cost (general)", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerStaminaCostSprintMultiplier", label: "Stamina cost (sprint)", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerStaminaRegenSpeedScale", label: "Stamina regen speed", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerHealthRegenSpeedScale", label: "Health regen speed", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerEncumbranceMultiplier", label: "Encumbrance", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "PlayerXPRateMultiplier", label: "XP gain (overall)", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerXPKillMultiplier", label: "XP from kills", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerXPHarvestMultiplier", label: "XP from harvest", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerXPCraftMultiplier", label: "XP from crafting", type: "number", step: 0.1, min: 0, max: 100 }, + { prop: "PlayerXPTimeMultiplier", label: "Idle XP time multiplier", type: "number", step: 0.1, min: 0, max: 100 }, + ]}, + + { title: "NPCs, Thralls & Pets", icon: "🐾", description: "NPC + minion (thrall, pet) tuning. Thrall decay can be globally disabled via the dedicated toggle.", fields: [ + { prop: "NPCDamageMultiplier", label: "NPC damage dealt", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "NPCDamageTakenMultiplier", label: "NPC damage taken", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "NPCHealthMultiplier", label: "NPC health", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "NPCKnockbackMultiplier", label: "NPC knockback", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "NPCRespawnMultiplier", label: "NPC respawn rate", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "NPCMaxSpawnCapMultiplier", label: "NPC spawn cap", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "MinionDamageMultiplier", label: "Thrall/pet damage dealt", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "MinionDamageTakenMultiplier", label: "Thrall/pet damage taken", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "ThrallDamageToPlayersMultiplier", label: "Thrall dmg to players", type: "number", step: 0.05, min: 0, max: 5 }, + { prop: "ThrallDamageToNPCsMultiplier", label: "Thrall dmg to NPCs", type: "number", step: 0.05, min: 0, max: 5 }, + { prop: "ThrallConversionMultiplier", label: "Thrall conversion speed", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "DisableThrallDecay", label: "Disable thrall decay", type: "bool" }, + { prop: "ThrallDecayTime", label: "Thrall decay time (s)", type: "number", min: 60, max: 5184000 }, + { prop: "AnimalPenCraftingTimeMultiplier", label: "Animal pen craft time", type: "number", step: 0.1, min: 0, max: 10 }, + ]}, + + { title: "Building & Decay", icon: "🏛️", description: "Structure tuning + abandonment / decay. DisableBuildingAbandonment is the master toggle for keeping bases up indefinitely; the multipliers below modify decay speed when abandonment is on.", fields: [ + { prop: "StructureDamageMultiplier", label: "Structure damage dealt", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "StructureHealthMultiplier", label: "Structure health", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "BuildingDamageMultiplier", label: "Building damage taken", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "DisableBuildingAbandonment", label: "Disable building abandonment", type: "bool" }, + { prop: "MaxBuildingDecayTime", label: "Max decay time (s)", type: "number", min: 0, max: 31536000 }, + { prop: "MaxDecayTimeToAutoDemolish", label: "Auto-demolish time (s)", type: "number", min: 0, max: 31536000 }, + { prop: "BuildingDecayTimePerScore", label: "Decay time per score", type: "number", min: 60, max: 86400 }, + { prop: "BuildingDecayTimeMultiplier", label: "Decay time multiplier", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "DecayShowBuildingScore", label: "Show building score", type: "bool" }, + { prop: "DynamicBuildingDamage", label: "Dynamic building damage", type: "bool" }, + { prop: "BuildingPickupEnabled", label: "Allow building pickup", type: "bool" }, + { prop: "AllowBuildingAnywhere", label: "Build anywhere (no biome restrictions)", type: "bool" }, + { prop: "LandClaimRadiusMultiplier", label: "Landclaim radius mult", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "DisableLandclaimNotifications", label: "Disable landclaim notifications", type: "bool" }, + { prop: "CampsIgnoreLandclaim", label: "Camps ignore landclaim", type: "bool" }, + { prop: "ContainersIgnoreOwnership", label: "Containers ignore ownership", type: "bool" }, + { prop: "StabilityLossMultiplier", label: "Stability loss", type: "number", step: 0.1, min: 0, max: 10 }, + ]}, + + { title: "Crafting & Resources", icon: "⚒️", description: "Time, cost and conversion rates for crafting + harvesting.", fields: [ + { prop: "CraftingCostMultiplier", label: "Crafting cost", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "ItemConvertionMultiplier", label: "Item conversion speed", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "FuelBurnTimeMultiplier", label: "Fuel burn time", type: "number", step: 0.1, min: 0, max: 10 }, + { prop: "ItemRepairMinimumDurability", label: "Min durability for repair", type: "number", step: 0.05, min: 0, max: 1 }, + ]}, + + { title: "Anti-Cheat & Network", icon: "🛡️", description: "BattlEye + VAC + ping/family-sharing gates. Conan on Linux/Wine usually leaves BattlEye off (the kernel driver is Windows-only).", fields: [ + { prop: "IsBattlEyeEnabled", label: "BattlEye enabled", type: "bool" }, + { prop: "IsVACEnabled", label: "VAC enabled", type: "bool" }, + { prop: "MaxAllowedPing", label: "Max allowed ping (ms)", type: "number", min: 0, max: 1000, hint: "0 = no limit" }, + { prop: "AllowFamilySharedAccount", label: "Allow family-shared accounts", type: "bool" }, + { prop: "ServerTransferEnabled", label: "Allow ServerTransfer", type: "bool" }, + { prop: "EnableLoginQueue", label: "Login queue", type: "bool" }, + { prop: "DisconnectionGraceTime", label: "Reconnect grace (s)", type: "number", min: 0, max: 600 }, + ]}, + + { title: "AFK & Population", icon: "💤", description: "Idle player handling + thrall/follower limits.", fields: [ + { prop: "KickAFKTime", label: "AFK kick time (s)", type: "number", min: 60, max: 86400 }, + { prop: "KickAFKPercentage", label: "AFK kick threshold (% full)", type: "number", min: 0, max: 100 }, + { prop: "ShowOnlinePlayers", label: "Show online players", type: "enum", options: [["0","Hide"],["1","Show"]] }, + { prop: "MinionPopulationBaseValue", label: "Minion population base", type: "number", min: 0, max: 1000 }, + { prop: "MinionPopulationPerPlayer", label: "Minion population per player", type: "number", min: 0, max: 100 }, + { prop: "UseMinionPopulationLimit", label: "Use minion population limit", type: "bool" }, + ]}, + + { title: "Misc", icon: "🧰", description: "Less-touched but occasionally useful flags.", fields: [ + { prop: "AmbientLifeEnabled", label: "Ambient life enabled", type: "bool" }, + { prop: "EnableTargetLock", label: "Enable target lock", type: "bool" }, + { prop: "EnableFatalities", label: "Enable fatalities", type: "bool" }, + { prop: "AvatarsDisabled", label: "Disable avatars (god summons)", type: "bool" }, + { prop: "AvatarLifetime", label: "Avatar lifetime (s)", type: "number", min: 60, max: 3600 }, + { prop: "AvatarSummonTime", label: "Avatar summon cast (s)", type: "number", min: 5, max: 600 }, + { prop: "EnableClanMarkers", label: "Enable clan markers", type: "bool" }, + { prop: "DisableChatFormatting", label: "Disable chat formatting", type: "bool" }, + { prop: "EventSystemEnabled", label: "Event system enabled", type: "bool" }, + { prop: "serverVoiceChat", label: "Voice chat", type: "enum", options: [["0","Off"],["1","Proximity"],["2","Faction"],["3","Global"]] }, + ]}, + ], + }, + + // Rust — server.cfg uses Facepunch's "convar" syntax, NOT INI: + // server.hostname "Server Name" + // server.maxplayers 100 + // server.url "" + // Key tokens are dot-namespaced; values are either bare numbers or quoted + // strings. We use a dedicated cfg_rust parser (parseCfgRust / + // serializeCfgRust below) since INI/properties parsers mangle the quoting. + // Most tunables also live as launch args (set via env vars in the Settings + // → Launch tab); server.cfg is best for the small set of "live editable" + // settings that don't justify a per-instance container recreate. + "rust": { + file: "server/$SERVER_IDENTITY/cfg/server.cfg", + filePaths: [ + "server/my_server_identity/cfg/server.cfg", + "server/panel-rust/cfg/server.cfg", + ], + format: "cfg_rust", + groups: [ + { title: "Identity & Branding", icon: "🏷️", description: "What players see in the in-game server browser. Hostname is the row title; Description wraps to the right-side panel; Header image must be a 512×256 PNG/JPG hosted publicly.", fields: [ + { prop: "server.hostname", label: "Server name", type: "string" }, + { prop: "server.description", label: "Description", type: "string" }, + { prop: "server.url", label: "Website URL", type: "string" }, + { prop: "server.headerimage", label: "Header image URL", type: "string", hint: "512×256 PNG/JPG, must be HTTPS" }, + { prop: "server.tags", label: "Tags", type: "string", hint: "Comma-separated, e.g. monthly,vanilla,pve" }, + ]}, + + { title: "World & Capacity", icon: "🌍", description: "Most of these are also baked into the launch args via env (SERVER_SEED / SERVER_WORLD_SIZE / MAX_PLAYERS / SERVER_LEVEL). Editing them here only takes effect after a stop+start; for permanent changes prefer the env-side fields under Launch settings.", fields: [ + { prop: "server.maxplayers", label: "Max players", type: "number", min: 1, max: 500 }, + { prop: "server.worldsize", label: "World size", type: "number", min: 1000, max: 6000, hint: "1000–6000. Bigger = longer procgen + more RAM." }, + { prop: "server.seed", label: "World seed", type: "number", min: 0, max: 2147483647 }, + { prop: "server.saveinterval", label: "Auto-save interval (s)", type: "number", min: 60, max: 3600 }, + { prop: "server.radiation", label: "Radiation enabled", type: "bool" }, + ]}, + + { title: "Gameplay Tuning", icon: "🎮", description: "Common gameplay knobs operators tune live without restarting.", fields: [ + { prop: "decay.scale", label: "Decay scale", type: "number", step: 0.1, min: 0, max: 10, hint: "0 = no decay; 1 = vanilla." }, + { prop: "smelting.speed", label: "Smelting speed", type: "number", step: 0.1, min: 0.1, max: 100 }, + { prop: "craft.instant_craft_max_seconds", label: "Instant-craft cap (s)", type: "number", min: 0, max: 60 }, + { prop: "server.gathermultiplier", label: "Gather multiplier", type: "number", step: 0.1, min: 0.1, max: 100 }, + { prop: "server.airdrops", label: "Airdrops on/off", type: "bool" }, + ]}, + + { title: "Anti-Cheat & Auth", icon: "🛡️", description: "EAC + Steam auth gates. Most installs leave these on.", fields: [ + { prop: "server.secure", label: "Steam VAC secure", type: "bool" }, + { prop: "server.eac", label: "Easy Anti-Cheat", type: "bool" }, + { prop: "server.queryport", label: "Query port", type: "number", min: 1, max: 65535, hint: "Usually GAME_PORT + 1." }, + ]}, + ], + }, + + // DayZ — serverDZ.cfg uses Bohemia's own config syntax: one `key = value;` + // per line, strings double-quoted, numbers bare, plus nested `class` blocks + // (Missions) that we must never touch. Dedicated cfg_dayz parser/serializer + // (parseCfgDayz / serializeCfgDayz below) — INI/properties parsers would + // mangle the trailing semicolons and the class block. The entrypoint seeds + // this file on FIRST boot only, so live edits stick across restarts. + // On/off flags are 0/1 integers in DayZ (NOT true/false) — modelled as + // enums so the serializer never writes a boolean word the engine rejects. + "dayz": { + file: "serverDZ.cfg", + filePaths: [ + "serverDZ.cfg", + "/game-saves/serverDZ.cfg", + ], + format: "cfg_dayz", + groups: [ + { title: "Identity & Access", icon: "🏷️", description: "Server-browser identity and join gates. hostname is what players see; an empty password means an open server.", fields: [ + { prop: "hostname", label: "Server name", type: "string", hint: "Shown in the DayZ launcher / in-game browser. Seeded from SERVER_NAME env on first boot only — edit here afterwards." }, + { prop: "password", label: "Join password", type: "password", hint: "Empty = open server." }, + { prop: "passwordAdmin", label: "Admin password", type: "password", hint: "Required for in-game admin (#login) commands." }, + { prop: "maxPlayers", label: "Max players", type: "number", min: 1, max: 127, hint: "Vanilla cap is 60; hard engine ceiling 127." }, + { prop: "enableWhitelist", label: "Whitelist", type: "enum", options: [["0","Off"],["1","On (whitelist.txt)"]] }, + { prop: "disableBanlist", label: "Disable ban list", type: "enum", options: [["0","No (bans enforced)"],["1","Yes"]] }, + { prop: "verifySignatures", label: "Verify mod signatures", type: "enum", options: [["0","Off (insecure)"],["1","v1 (legacy)"],["2","v2 (recommended)"]], hint: "2 = verify against keys/ — leave on unless debugging a mod." }, + { prop: "forceSameBuild", label: "Force same client build", type: "enum", options: [["0","Off"],["1","On"]] }, + ]}, + + { title: "Time & World", icon: "🌍", description: "In-game clock behaviour. Acceleration multiplies real time (12 = one full day/night in 2 real hours at default daylight split).", fields: [ + { prop: "serverTime", label: "Server start time", type: "string", hint: "\"SystemTime\" or a date like \"2015/4/8/17/23\" (Y/M/D/H/Min)." }, + { prop: "serverTimeAcceleration", label: "Time acceleration", type: "number", min: 0, max: 64, step: 0.1, hint: "Multiplier for the in-game clock. 0 = frozen, 1 = realtime, 12 = default." }, + { prop: "serverNightTimeAcceleration", label: "Night acceleration (extra)", type: "number", min: 0.1, max: 64, step: 0.1, hint: "Multiplies on TOP of time acceleration at night (12 × 4 = 48× nights)." }, + { prop: "serverTimePersistent", label: "Persistent time", type: "enum", options: [["0","Off (reset on restart)"],["1","On (clock survives restarts)"]] }, + { prop: "lightingConfig", label: "Night lighting", type: "enum", options: [["0","Bright (vanilla)"],["1","Dark (hardcore)"]] }, + ]}, + + { title: "Gameplay", icon: "🎮", description: "Player-facing rules: camera, HUD, voice, respawn.", fields: [ + { prop: "disable3rdPerson", label: "Disable 3rd-person camera", type: "enum", options: [["0","No (3PP allowed)"],["1","Yes (1PP only)"]] }, + { prop: "disableCrosshair", label: "Disable crosshair", type: "enum", options: [["0","No"],["1","Yes"]] }, + { prop: "disableVoN", label: "Disable voice (VoN)", type: "enum", options: [["0","No (voice on)"],["1","Yes"]] }, + { prop: "vonCodecQuality", label: "Voice codec quality", type: "number", min: 0, max: 30, hint: "0–30. Higher = clearer voice, more bandwidth." }, + { prop: "respawnTime", label: "Respawn delay (s)", type: "number", min: 0, max: 3600 }, + { prop: "motdInterval", label: "MOTD interval (s)", type: "number", min: 1, max: 3600, hint: "Rotation interval for motd[] lines (edit the motd[] array itself via the Files tab — array syntax isn't form-editable)." }, + ]}, + + { title: "Queue & Network", icon: "🔌", description: "Login queue and connection quality gates. The Steam query port is panel-allocated — change ports in the Network ports pane.", fields: [ + { prop: "loginQueueConcurrentPlayers", label: "Login queue: concurrent logins", type: "number", min: 1, max: 64, hint: "How many players may spawn in simultaneously. 5 = vanilla." }, + { prop: "loginQueueMaxPlayers", label: "Login queue: max size", type: "number", min: 1, max: 1000 }, + { prop: "maxPing", label: "Max ping (ms)", type: "number", min: 0, max: 1000, hint: "Players above this get kicked. 0 disables the check." }, + { prop: "guaranteedUpdates", label: "Guaranteed updates", type: "enum", options: [["0","Off"],["1","On"]], hint: "Communication protocol for object updates — leave at 1." }, + { prop: "steamQueryPort", label: "Steam query port", type: "number", min: 1, max: 65535, locked: true, + hint: "🔒 Panel-owned: allocated with the game port. Change it in the Network ports pane." }, + ]}, + + { title: "Persistence & Logs", icon: "💾", description: "Save integrity and the admin-log (.ADM) verbosity written under profiles/.", fields: [ + { prop: "instanceId", label: "Instance ID", type: "number", min: 1, max: 9999, hint: "Selects the storage_N persistence folder in the mission. Change = fresh persistence." }, + { prop: "storageAutoFix", label: "Storage auto-fix", type: "enum", options: [["0","Off"],["1","On (repair corrupt persistence)"]] }, + { prop: "timeStampFormat", label: "Log timestamp format", type: "enum", options: ["Short","Full"] }, + { prop: "logAverageFps", label: "Log average FPS (s interval)", type: "number", min: 0, max: 3600, hint: "0 disables. Writes to the RPT log." }, + { prop: "logMemory", label: "Log memory usage (s interval)", type: "number", min: 0, max: 3600 }, + { prop: "logPlayers", label: "Log player count (s interval)", type: "number", min: 0, max: 3600 }, + { prop: "logFile", label: "Console log file", type: "string" }, + { prop: "adminLogPlayerHitsOnly", label: "Admin log: player hits only", type: "enum", options: [["0","All damage"],["1","PvP hits only"]] }, + { prop: "adminLogPlacement", label: "Admin log: placements", type: "enum", options: [["0","Off"],["1","On (traps/tents)"]] }, + { prop: "adminLogBuildActions", label: "Admin log: build actions", type: "enum", options: [["0","Off"],["1","On (build/dismantle)"]] }, + { prop: "adminLogPlayerList", label: "Admin log: periodic player list", type: "enum", options: [["0","Off"],["1","On (every 5 min)"]] }, + { prop: "BattlEye", label: "BattlEye anti-cheat", type: "enum", options: [["0","Off"],["1","On"]], hint: "Also gates RCON — the panel's console uses BattlEye RCON, so leave on." }, + ]}, + ], + }, + + // RuneScape: Dragonwilds — DedicatedServer.ini, one UE5 section + // ([/Script/Dominion.DedicatedServerSettings]; "Dominion" is the internal + // project name). ue_ini format so a brand-new key lands under the right + // header. The entrypoint seeds this file from env on FIRST boot only — + // after that the file is authoritative, so live edits here stick. + "dragonwilds": { + file: "RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini", + filePaths: [ + "RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini", + "/game-saves/RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini", + ], + format: "ue_ini", + groups: [ + { title: "Server Settings", icon: "🐉", description: "Jagex's dedicated-server settings. ⚠ OwnerId is REQUIRED — without it the server boots but refuses every login. Changes apply on the next restart.", fields: [ + { prop: "ServerName", label: "Server name", type: "string", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "Shown in the in-game server list. Seeded from SERVER_NAME env on first boot only." }, + { prop: "DefaultWorldName", label: "Default world name", type: "string", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "Save-slot name for the world. Changing it starts a fresh world (the old save stays on disk)." }, + { prop: "OwnerId", label: "Owner ID (REQUIRED)", type: "string", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "⚠ REQUIRED — the Jagex account ID that owns this server. The server launches without it but refuses ALL logins until it's set. Find yours in the Dragonwilds client → server settings." }, + { prop: "AdminPassword", label: "Admin password", type: "password", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "In-game admin console access." }, + { prop: "WorldPassword", label: "World join password", type: "password", section: "[/Script/Dominion.DedicatedServerSettings]", hint: "Empty = open server." }, + ]}, + ], + }, + + // Factorio — config/server-settings.json under the saves volume. Plain + // JSON via parseJSON/serializeJSON: unknown keys (including Factorio's + // `_comment_*` documentation strings) survive untouched because the + // serializer patches a structuredClone of the parsed root. Nested keys + // use dotted paths (visibility.public). The entrypoint seeds the file + // from server-settings.example.json on first boot only, so edits stick. + "factorio": { + file: "config/server-settings.json", + filePaths: [ + "config/server-settings.json", + "/game-saves/config/server-settings.json", + ], + format: "json", + groups: [ + { title: "Identity & Visibility", icon: "🏷️", description: "Server-browser identity. Public listing requires a factorio.com username + token (next group) — without them the server still works for direct-IP and LAN joins.", fields: [ + { prop: "name", label: "Server name", type: "string" }, + { prop: "description", label: "Description", type: "textarea", rows: 2 }, + { prop: "tags", label: "Tags", type: "string", hint: "JSON array, e.g. [\"vanilla\", \"pve\"] — shown as filters in the public browser." }, + { prop: "max_players", label: "Max players", type: "number", min: 0, max: 65535, hint: "0 = unlimited." }, + { prop: "visibility.public", label: "List on public browser", type: "bool", hint: "Requires factorio.com credentials below; without them the log shows \"Missing token\" and the server stays direct-IP-only." }, + { prop: "visibility.lan", label: "List on LAN", type: "bool" }, + { prop: "game_password", label: "Join password", type: "password", hint: "Empty = open server." }, + { prop: "require_user_verification", label: "Verify users against factorio.com", type: "bool", hint: "Blocks pirated/unverified clients. Leave on for public servers." }, + ]}, + + { title: "factorio.com Account", icon: "🔑", description: "Only needed when List on public browser is on. Get the token from factorio.com → profile.", fields: [ + { prop: "username", label: "factorio.com username", type: "string" }, + { prop: "password", label: "factorio.com password", type: "password", hint: "Leave empty when using a token (preferred)." }, + { prop: "token", label: "factorio.com token", type: "password" }, + ]}, + + { title: "Autosave & Pause", icon: "💾", description: "Server-side autosave cadence and auto-pause behaviour.", fields: [ + { prop: "autosave_interval", label: "Autosave interval (min)", type: "number", min: 1, max: 1440 }, + { prop: "autosave_slots", label: "Autosave slots", type: "number", min: 1, max: 100, hint: "Rotated — oldest slot is overwritten." }, + { prop: "autosave_only_on_server", label: "Autosave only on server", type: "bool", hint: "Off makes every connected client autosave too — usually leave on." }, + { prop: "non_blocking_saving", label: "Non-blocking saving", type: "bool", hint: "Forks the process to save without freezing the game. Experimental but widely used on big bases." }, + { prop: "auto_pause", label: "Pause when empty", type: "bool" }, + { prop: "auto_pause_when_players_connect", label: "Pause while a player connects", type: "bool" }, + { prop: "only_admins_can_pause_the_game", label: "Only admins can pause", type: "bool" }, + ]}, + + { title: "Network & Limits", icon: "🔌", description: "Connection quality gates and command access.", fields: [ + { prop: "allow_commands", label: "Console commands", type: "enum", options: [["true","Everyone"],["admins-only","Admins only"],["false","Nobody"]], hint: "Lua commands disable achievements for the save — admins-only is the usual choice." }, + { prop: "afk_autokick_interval", label: "AFK autokick (min)", type: "number", min: 0, max: 1440, hint: "0 = never kick." }, + { prop: "ignore_player_limit_for_returning_players", label: "Returning players bypass player limit", type: "bool" }, + { prop: "max_upload_in_kilobytes_per_second", label: "Max upload (KB/s)", type: "number", min: 0, max: 1048576, hint: "Map upload rate to joining players. 0 = unlimited." }, + { prop: "max_upload_slots", label: "Max upload slots", type: "number", min: 0, max: 255, hint: "Concurrent map uploads. 0 = unlimited." }, + { prop: "minimum_latency_in_ticks", label: "Minimum latency (ticks)", type: "number", min: 0, max: 600, hint: "One tick = 16 ms. 0 = no artificial floor." }, + { prop: "max_heartbeats_per_second", label: "Max heartbeats/s", type: "number", min: 1, max: 240, hint: "Network tick rate ceiling. 60 = default." }, + ]}, + ], + }, + + // Minecraft Bedrock — itzg/minecraft-bedrock-server stamps every + // env-managed property (server-name, gamemode, difficulty, max-players, + // ports, …) into /data/server.properties on EVERY container start, so + // those keys belong to the Launch settings card (ENV_CONFIG_SCHEMA) — + // editing them here would silently revert on the next boot. This file + // schema exposes only the properties the module's env block does NOT + // manage. Same "properties" parser/serializer as minecraft-java. + "minecraft-bedrock": { + file: "server.properties", + filePaths: ["server.properties", "/data/server.properties"], + format: "properties", + groups: [ + { title: "World & Players", icon: "🌍", description: "Properties NOT stamped from env on boot — durable file edits. Server name, gamemode, difficulty, max players, cheats, view/tick distance and ports live in Launch settings (env-managed; itzg rewrites them into this file on every start).", fields: [ + { prop: "level-seed", label: "World seed", type: "string", hint: "Blank = random. Only used when the world is first generated." }, + { prop: "force-gamemode", label: "Force gamemode on join", type: "bool" }, + { prop: "allow-list", label: "Enable allow list (whitelist)", type: "bool", hint: "Players must be in allowlist.json (Files tab) to join." }, + { prop: "player-idle-timeout", label: "Idle kick (minutes)", type: "number", min: 0, max: 1440, hint: "0 = never kick idlers." }, + ]}, + { title: "Chat & Interaction", icon: "💬", fields: [ + { prop: "chat-restriction", label: "Chat restriction", type: "enum", options: [["None","None"],["Dropped","Chat messages dropped"],["Disabled","Chat UI disabled"]] }, + { prop: "disable-player-interaction", label: "Ignore player interaction", type: "bool" }, + { prop: "disable-custom-skins", label: "Disable custom skins", type: "bool" }, + { prop: "texturepack-required", label: "Require texture packs", type: "bool", hint: "Also settable via env — the module doesn't stamp it unless TEXTUREPACK_REQUIRED is changed in Launch settings." }, + ]}, + { title: "Performance & Anti-cheat", icon: "🛡️", description: "Movement authority + server-side performance knobs (Bedrock's server-authoritative anti-cheat family).", fields: [ + { prop: "server-authoritative-movement", label: "Movement authority", type: "enum", options: [["client-auth","Client authoritative"],["server-auth","Server simulates, accepts client (default)"],["server-auth-with-rewind","Server replays & corrects (strictest)"]] }, + { prop: "player-position-acceptance-threshold", label: "Position tolerance", type: "number", min: 0, step: 0.05 }, + { prop: "player-movement-action-direction-threshold", label: "Attack/look direction tolerance", type: "number", min: 0, max: 1, step: 0.05 }, + { prop: "server-authoritative-block-breaking", label: "Server-authoritative block breaking", type: "bool" }, + { prop: "max-threads", label: "Max CPU threads", type: "number", min: 0, max: 64, hint: "0 = use as many as possible." }, + { prop: "compression-threshold", label: "Compression threshold (bytes)", type: "number", min: 0, max: 65535 }, + { prop: "compression-algorithm", label: "Compression algorithm", type: "enum", options: ["zlib","snappy"] }, + { prop: "client-side-chunk-generation-enabled", label: "Client-side chunk generation", type: "bool" }, + { prop: "content-log-file-enabled", label: "Log content errors to file", type: "bool" }, + ]}, + ], + }, + + // V Rising — game RULES live in Settings/ServerGameSettings.json on the + // saves volume (the entrypoint seeds it from StreamingAssets on first + // boot). HOST settings (name/password/slots/ports/rcon) are driven by + // CLI flags the entrypoint assembles from env — CLI > JSON in V Rising's + // precedence — so they live in the Launch settings card + // (ENV_CONFIG_SCHEMA["v-rising"]), NOT here. Field list follows + // Stunlock's dedicated-server instructions + AMP's v-risingconfig.json; + // dotted props ride the same parseJSON/serializeJSON as factorio. + // + // ⚠ A non-empty GAME_SETTINGS_PRESET launch flag silently overrides this + // entire file (Stunlock gotcha) — the group description warns about it. + "v-rising": { + file: "Settings/ServerGameSettings.json", + filePaths: [ + "Settings/ServerGameSettings.json", + "/game-saves/Settings/ServerGameSettings.json", + ], + format: "json", + groups: [ + { title: "Game Mode", icon: "⚔️", description: "Core rules. ⚠ These only apply while Game settings preset in Launch settings is EMPTY — a preset overrides this whole file silently.", fields: [ + { prop: "GameModeType", label: "Game mode", type: "enum", options: ["PvE","PvP"] }, + { prop: "GameDifficulty", label: "Difficulty", type: "string", hint: "Relaxed / Normal / Brutal (older builds used 0/1/2 — the serializer keeps whatever type the file already has)." }, + { prop: "ClanSize", label: "Clan size", type: "number", min: 1, max: 10 }, + { prop: "PlayerDamageMode", label: "Player damage", type: "enum", options: ["Always","TimeRestricted"] }, + { prop: "PvPProtectionMode", label: "New-player PvP protection", type: "enum", options: [["Disabled","Disabled"],["VeryShort","Very short (15 min)"],["Short","Short (30 min)"],["Medium","Medium (1 h)"],["Long","Long (2 h)"]] }, + { prop: "DeathContainerPermission", label: "Who can loot your death bag", type: "enum", options: ["Anyone","ClanMembers","OnlySelf"] }, + { prop: "CanLootEnemyContainers", label: "Loot enemy containers", type: "bool" }, + { prop: "BloodBoundEquipment", label: "Blood-bound equipment (keep gear on death)", type: "bool" }, + { prop: "TeleportBoundItems", label: "Items block waypoint teleport", type: "bool" }, + { prop: "AllowGlobalChat", label: "Global chat", type: "bool" }, + { prop: "AllWaypointsUnlocked", label: "All waypoints unlocked", type: "bool" }, + { prop: "RelicSpawnType", label: "Soul shard spawns", type: "enum", options: ["Unique","Plentiful"] }, + { prop: "InactivityKillEnabled", label: "Kill inactive players", type: "bool" }, + ]}, + { title: "Castles & Sieges", icon: "🏰", fields: [ + { prop: "CastleDamageMode", label: "Castle damage", type: "enum", options: ["Always","Never","TimeRestricted"] }, + { prop: "CastleHeartDamageMode", label: "Castle heart damage", type: "enum", options: [["CanBeDestroyedOnlyWhenDecaying","Only when decaying"],["CanBeDestroyedByPlayers","Destroyable by players"],["CanBeSeizedOrDestroyedByPlayers","Seizable or destroyable"]] }, + { prop: "SiegeWeaponHealth", label: "Siege golem health", type: "enum", options: ["VeryLow","Low","Normal","High","VeryHigh","MegaHigh","UltraHigh","CrazyHigh","Max"] }, + { prop: "CastleRelocationEnabled", label: "Castle relocation", type: "bool" }, + { prop: "FreeCastleClaim", label: "Free castle claim", type: "bool" }, + { prop: "FreeCastleDestroy", label: "Free castle destroy", type: "bool" }, + { prop: "CastleDecayRateModifier", label: "Castle decay rate", type: "number", min: 0, step: 0.1 }, + { prop: "CastleBloodEssenceDrainModifier", label: "Heart essence drain", type: "number", min: 0, step: 0.1 }, + { prop: "CastleStatModifiers_Global.CastleLimit", label: "Castles per player/clan", type: "number", min: 1, max: 10 }, + { prop: "CastleStatModifiers_Global.CastleHeartLimitType", label: "Castle limit counted per", type: "enum", options: [["User","Player"],["Clan","Clan"]] }, + ]}, + { title: "Rates & Yields", icon: "📈", description: "1 = vanilla for every modifier.", fields: [ + { prop: "InventoryStacksModifier", label: "Stack size multiplier", type: "number", min: 0, step: 0.5 }, + { prop: "DropTableModifier_General", label: "Loot drop multiplier", type: "number", min: 0, step: 0.5 }, + { prop: "MaterialYieldModifier_Global", label: "Material yield", type: "number", min: 0, step: 0.5 }, + { prop: "BloodEssenceYieldModifier", label: "Blood essence yield", type: "number", min: 0, step: 0.5 }, + { prop: "CraftRateModifier", label: "Craft speed", type: "number", min: 0, step: 0.5 }, + { prop: "RefinementRateModifier", label: "Refinement speed", type: "number", min: 0, step: 0.5 }, + { prop: "ResearchCostModifier", label: "Research cost", type: "number", min: 0, step: 0.5 }, + { prop: "BuildCostModifier", label: "Build cost", type: "number", min: 0, step: 0.5 }, + { prop: "RecipeCostModifier", label: "Recipe cost", type: "number", min: 0, step: 0.5 }, + { prop: "RepairCostModifier", label: "Repair cost", type: "number", min: 0, step: 0.5 }, + { prop: "ServantConvertRateModifier", label: "Servant conversion speed", type: "number", min: 0, step: 0.5 }, + { prop: "BloodDrainModifier", label: "Blood drain rate", type: "number", min: 0, step: 0.5 }, + { prop: "DurabilityDrainModifier", label: "Durability drain", type: "number", min: 0, step: 0.5 }, + ]}, + { title: "World Hazards & Death", icon: "☀️", fields: [ + { prop: "SunDamageModifier", label: "Sun damage", type: "number", min: 0, step: 0.5 }, + { prop: "GarlicAreaStrengthModifier", label: "Garlic area strength", type: "number", min: 0, step: 0.5 }, + { prop: "HolyAreaStrengthModifier", label: "Holy area strength", type: "number", min: 0, step: 0.5 }, + { prop: "SilverStrengthModifier", label: "Silver strength", type: "number", min: 0, step: 0.5 }, + { prop: "Death_DurabilityFactorLoss", label: "Durability loss on death", type: "number", min: 0, max: 1, step: 0.05 }, + { prop: "PvPVampireRespawnModifier", label: "PvP respawn time", type: "number", min: 0, step: 0.5 }, + ]}, + { title: "Time & Blood Moon", icon: "🌙", fields: [ + { prop: "GameTimeModifiers.DayDurationInSeconds", label: "Day length (s)", type: "number", min: 60, max: 86400 }, + { prop: "GameTimeModifiers.DayStartHour", label: "Daylight starts (hour)", type: "number", min: 0, max: 23 }, + { prop: "GameTimeModifiers.DayEndHour", label: "Daylight ends (hour)", type: "number", min: 0, max: 23 }, + { prop: "GameTimeModifiers.BloodMoonFrequency_Min", label: "Blood moon every (min days)", type: "number", min: 1, max: 30 }, + { prop: "GameTimeModifiers.BloodMoonFrequency_Max", label: "Blood moon every (max days)", type: "number", min: 1, max: 30 }, + { prop: "GameTimeModifiers.BloodMoonBuff", label: "Blood moon buff strength", type: "number", min: 0, max: 1, step: 0.05 }, + ]}, + { title: "Player & Unit Stats", icon: "🧛", fields: [ + { prop: "VampireStatModifiers.MaxHealthModifier", label: "Vampire max health", type: "number", min: 0, step: 0.1 }, + { prop: "VampireStatModifiers.PhysicalPowerModifier", label: "Vampire physical power", type: "number", min: 0, step: 0.1 }, + { prop: "VampireStatModifiers.SpellPowerModifier", label: "Vampire spell power", type: "number", min: 0, step: 0.1 }, + { prop: "VampireStatModifiers.DamageReceivedModifier", label: "Vampire damage taken", type: "number", min: 0, step: 0.1 }, + { prop: "UnitStatModifiers_Global.MaxHealthModifier", label: "Enemy max health", type: "number", min: 0, step: 0.1 }, + { prop: "UnitStatModifiers_Global.PowerModifier", label: "Enemy power", type: "number", min: 0, step: 0.1 }, + { prop: "UnitStatModifiers_VBlood.MaxHealthModifier", label: "V Blood boss health", type: "number", min: 0, step: 0.1 }, + { prop: "UnitStatModifiers_VBlood.PowerModifier", label: "V Blood boss power", type: "number", min: 0, step: 0.1 }, + ]}, + ], + }, + + // Satisfactory — the file-config surface is genuinely tiny: nearly all + // server settings live in the HTTPS admin API's binary + // ServerSettings..sav (not text-editable), and AMP passes its + // handful of knobs as -ini: command-line overrides rather than file + // writes. The one setting operators reliably file-edit is MaxPlayers in + // Game.ini ([/Script/Engine.GameSession] — AMP's ini:Game target). + // HOME=/game-saves, so UE's config dir is on the saves volume. + "satisfactory": { + file: ".config/Epic/FactoryGame/Saved/Config/LinuxServer/Game.ini", + filePaths: [ + ".config/Epic/FactoryGame/Saved/Config/LinuxServer/Game.ini", + "/game-saves/.config/Epic/FactoryGame/Saved/Config/LinuxServer/Game.ini", + ], + format: "ue_ini", + groups: [ + { title: "Server", icon: "🏭", description: "Satisfactory keeps almost all settings in the in-game Server Manager (stored in the binary ServerSettings.<port>.sav) — this file only carries the player cap. Ports are panel-allocated (Network ports pane).", fields: [ + { prop: "MaxPlayers", label: "Max players", type: "number", min: 1, max: 64, section: "[/Script/Engine.GameSession]", hint: "Default 4. Applied on next restart." }, + ]}, + ], + }, + + // Barotrauma — serversettings.xml keeps EVERYTHING as attributes on the + // root element (format "xml_attrs" — parse/serialize + // below). The entrypoint seeds the file on first boot and sed-patches + // port=/queryport= on EVERY boot (panel port allocator owns them → + // locked). The server rewrites the whole file on exit, so edits should + // be made while the server is STOPPED. No RCON exists for this game. + "barotrauma": { + file: "serversettings.xml", + filePaths: ["serversettings.xml", "/game-saves/serversettings.xml"], + format: "xml_attrs", + groups: [ + { title: "Identity & Access", icon: "🏷️", description: "⚠ Barotrauma rewrites this file when the server shuts down — apply edits while the server is stopped, or they may be overwritten.", fields: [ + { prop: "name", label: "Server name", type: "string" }, + { prop: "ServerMessage", label: "Server message (MOTD)", type: "textarea", rows: 2 }, + { prop: "password", label: "Join password", type: "password", hint: "Empty = open server." }, + { prop: "MaxPlayers", label: "Max players", type: "number", min: 1, max: 32 }, + { prop: "IsPublic", label: "List publicly (Steam)", type: "enum", options: [["True","Public"],["False","Private"]], hint: "Also gates the A2S query port — with False the query port never binds." }, + { prop: "PlayStyle", label: "Play style tag", type: "enum", options: ["Serious","Casual","Roleplay","Rampage","SomethingDifferent"] }, + { prop: "Language", label: "Server language", type: "string", hint: "e.g. English, German, French — must match a language Barotrauma ships." }, + { prop: "port", label: "Game port", type: "number", locked: true, hint: "🔒 Panel-owned — the entrypoint re-stamps it from the port allocator on every boot. Change it in the Network ports pane." }, + { prop: "queryport", label: "Query port", type: "number", locked: true, hint: "🔒 Panel-owned — see Network ports pane." }, + ]}, + { title: "Rounds & Game Mode", icon: "🚢", fields: [ + { prop: "GameModeIdentifier", label: "Default game mode", type: "enum", options: [["sandbox","Sandbox"],["mission","Mission"],["pvp","PvP"],["multiplayercampaign","Campaign"]] }, + { prop: "ModeSelectionMode", label: "Mode selection", type: "enum", options: ["Manual","Random","Vote"] }, + { prop: "SubSelectionMode", label: "Submarine selection", type: "enum", options: ["Manual","Random","Vote"] }, + { prop: "LevelDifficulty", label: "Level difficulty (%)", type: "number", min: 0, max: 100 }, + { prop: "StartWhenClientsReady", label: "Start when players ready", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "StartWhenClientsReadyRatio", label: "Ready-ratio to start", type: "number", min: 0, max: 1, step: 0.05 }, + { prop: "AllowSpectating", label: "Allow spectating", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "AllowEndVoting", label: "Allow round-end vote", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "RandomizeSeed", label: "Randomize seed between rounds", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "TickRate", label: "Server tick rate", type: "number", min: 10, max: 60 }, + ]}, + { title: "Respawning & Death", icon: "💀", fields: [ + { prop: "AllowRespawn", label: "Allow respawning", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "RespawnInterval", label: "Respawn interval (s)", type: "number", min: 0, max: 3600 }, + { prop: "MinRespawnRatio", label: "Min dead ratio to respawn", type: "number", min: 0, max: 1, step: 0.05 }, + { prop: "UseRespawnShuttle", label: "Use respawn shuttle", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "MaxTransportTime", label: "Max shuttle transport time (s)", type: "number", min: 0, max: 3600 }, + { prop: "SkillLossPercentageOnDeath", label: "Skill loss on death (%)", type: "number", min: 0, max: 100 }, + { prop: "KillDisconnectedTime", label: "Kill disconnected after (s)", type: "number", min: 0, max: 3600 }, + { prop: "KickAFKTime", label: "AFK kick after (s)", type: "number", min: 0, max: 36000 }, + ]}, + { title: "Traitors & PvP", icon: "🗡️", fields: [ + { prop: "TraitorProbability", label: "Traitor probability", type: "number", min: 0, max: 1, step: 0.05 }, + { prop: "TraitorsMinPlayerCount", label: "Min players for traitors", type: "number", min: 1, max: 16 }, + { prop: "AllowFriendlyFire", label: "Friendly fire", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "KillableNPCs", label: "Killable outpost NPCs", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "DestructibleOutposts", label: "Destructible outposts", type: "enum", options: [["True","On"],["False","Off"]] }, + ]}, + { title: "Voice, Votes & Moderation", icon: "🗳️", fields: [ + { prop: "VoiceChatEnabled", label: "Voice chat", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "AllowVoteKick", label: "Allow vote-kick", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "KickVoteRequiredRatio", label: "Vote-kick ratio", type: "number", min: 0, max: 1, step: 0.05 }, + { prop: "AutoBanTime", label: "Vote-kick ban duration (s)", type: "number", min: 0, max: 604800 }, + { prop: "BanAfterWrongPassword", label: "Ban after wrong password", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "MaxPasswordRetriesBeforeBan", label: "Password retries before ban", type: "number", min: 1, max: 10 }, + { prop: "KarmaEnabled", label: "Karma system", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "AllowFileTransfers", label: "Share files with players (mods)", type: "enum", options: [["True","On"],["False","Off"]] }, + { prop: "AllowModDownloads", label: "Allow mod downloads", type: "enum", options: [["True","On"],["False","Off"]] }, + ]}, + ], + }, + + // Enshrouded — enshrouded_server.json on the saves volume. The + // entrypoint sed-stamps name/password/slotCount/queryPort from env on + // EVERY boot (those live in Launch settings / Network ports), so this + // schema is the gameplay half: gameSettingsPreset + the gameSettings + // object (shape per the server's own generated file / AMP's + // enshroudedserver.json). Individual gameSettings only take effect when + // gameSettingsPreset is "Custom". + "enshrouded": { + file: "enshrouded_server.json", + filePaths: ["enshrouded_server.json", "/game-saves/enshrouded_server.json"], + format: "json", + groups: [ + { title: "Preset & Chat", icon: "🌫️", description: "Server name, join password, slots and the port are env-managed (Launch settings / Network ports) — the entrypoint re-stamps them on every boot. Set preset to Custom for the individual settings below to apply.", fields: [ + { prop: "gameSettingsPreset", label: "Game settings preset", type: "enum", options: [["Default","Default"],["Relaxed","Relaxed"],["Hard","Hard"],["Survival","Survival"],["Custom","Custom (use fields below)"]] }, + { prop: "enableVoiceChat", label: "Voice chat", type: "bool" }, + { prop: "voiceChatMode", label: "Voice chat mode", type: "enum", options: ["Proximity","Global"] }, + { prop: "enableTextChat", label: "Text chat", type: "bool" }, + ]}, + { title: "Player", icon: "🧑", description: "All factors: 1 = vanilla. Require preset = Custom.", fields: [ + { prop: "gameSettings.playerHealthFactor", label: "Player health", type: "number", min: 0.25, max: 4, step: 0.25 }, + { prop: "gameSettings.playerManaFactor", label: "Player mana", type: "number", min: 0.25, max: 4, step: 0.25 }, + { prop: "gameSettings.playerStaminaFactor", label: "Player stamina", type: "number", min: 0.25, max: 4, step: 0.25 }, + { prop: "gameSettings.enableDurability", label: "Weapon durability", type: "bool" }, + { prop: "gameSettings.enableStarvingDebuff", label: "Hunger & starvation", type: "bool" }, + { prop: "gameSettings.foodBuffDurationFactor", label: "Food buff duration", type: "number", min: 0.5, max: 2, step: 0.25 }, + { prop: "gameSettings.shroudTimeFactor", label: "Shroud time", type: "number", min: 0.5, max: 2, step: 0.25 }, + { prop: "gameSettings.tombstoneMode", label: "Death penalty", type: "enum", options: [["AddBackpackMaterials","Lose backpack materials"],["Everything","Lose everything"],["NoTombstone","Keep everything"]] }, + { prop: "gameSettings.enableGliderTurbulences", label: "Glider turbulence", type: "bool" }, + ]}, + { title: "World & Economy", icon: "🌍", fields: [ + { prop: "gameSettings.weatherFrequency", label: "Weather frequency", type: "enum", options: ["Disabled","Rare","Normal","Often"] }, + { prop: "gameSettings.miningDamageFactor", label: "Mining effectiveness", type: "number", min: 0.5, max: 5, step: 0.5 }, + { prop: "gameSettings.plantGrowthSpeedFactor", label: "Plant growth speed", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.resourceDropStackAmountFactor", label: "Resource gain", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.factoryProductionSpeedFactor", label: "Workstation speed", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.fishingDifficulty", label: "Fishing difficulty", type: "enum", options: ["VeryEasy","Easy","Normal","Hard","VeryHard"] }, + { prop: "gameSettings.experienceCombatFactor", label: "Combat XP", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.experienceMiningFactor", label: "Mining XP", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.experienceExplorationQuestsFactor", label: "Exploration/quest XP", type: "number", min: 0.25, max: 5, step: 0.25 }, + ]}, + { title: "Enemies", icon: "👹", fields: [ + { prop: "gameSettings.randomSpawnerAmount", label: "Enemy amount", type: "enum", options: ["Few","Normal","Many","Extreme"] }, + { prop: "gameSettings.aggroPoolAmount", label: "Simultaneous attackers", type: "enum", options: ["Few","Normal","Many","Extreme"] }, + { prop: "gameSettings.enemyDamageFactor", label: "Enemy damage", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.enemyHealthFactor", label: "Enemy health", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.enemyPerceptionRangeFactor", label: "Enemy perception", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.bossDamageFactor", label: "Boss damage", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.bossHealthFactor", label: "Boss health", type: "number", min: 0.25, max: 5, step: 0.25 }, + { prop: "gameSettings.pacifyAllEnemies", label: "Pacify all enemies", type: "bool" }, + { prop: "gameSettings.tamingStartleRepercussion", label: "Taming failure penalty", type: "enum", options: [["KeepProgress","Keep all progress"],["LoseSomeProgress","Lose some progress"],["LoseAllProgress","Lose all progress"]] }, + ]}, + ], + }, + + // Terraria — serverconfig.txt (flat key=value → "properties" format). + // The entrypoint SEEDS this file on first boot from env and never + // rewrites it after, so file edits are durable — EXCEPT port, which the + // entrypoint passes as -port on the CLI every boot (locked). World + // identity keys only matter at world-creation time; the `world=` path + // is what actually selects the loaded world. + "terraria": { + file: "serverconfig.txt", + filePaths: ["serverconfig.txt", "/game-saves/serverconfig.txt"], + format: "properties", + groups: [ + { title: "Identity & Access", icon: "🏷️", fields: [ + { prop: "motd", label: "MOTD", type: "string", hint: "Shown to players on join." }, + { prop: "password", label: "Join password", type: "password", hint: "Empty = open server." }, + { prop: "maxplayers", label: "Max players", type: "number", min: 1, max: 255 }, + { prop: "secure", label: "Cheat protection", type: "enum", options: [["1","On"],["0","Off"]] }, + { prop: "language", label: "Language", type: "enum", options: [["en-US","English"],["de-DE","German"],["it-IT","Italian"],["fr-FR","French"],["es-ES","Spanish"],["ru-RU","Russian"],["zh-Hans","Chinese"],["pt-BR","Portuguese"],["pl-PL","Polish"]] }, + { prop: "port", label: "Port", type: "number", locked: true, hint: "🔒 Panel-owned — the entrypoint passes -port from the allocator on every boot, overriding this line. Change it in the Network ports pane." }, + ]}, + { title: "World", icon: "🌳", description: "⚠ world is the actual save file the server loads — changing it points the server at a different (or brand-new) world. The creation-time keys (name/size/seed/difficulty) only apply when the file at world doesn't exist yet.", fields: [ + { prop: "world", label: "World file path", type: "string", hint: "e.g. /game-saves/Worlds/MyWorld.wld" }, + { prop: "worldname", label: "New-world name", type: "string" }, + { prop: "autocreate", label: "New-world size", type: "enum", options: [["1","Small"],["2","Medium"],["3","Large"]] }, + { prop: "seed", label: "New-world seed", type: "string", hint: "Blank = random. Only used at world creation." }, + { prop: "difficulty", label: "New-world difficulty", type: "enum", options: [["0","Classic"],["1","Expert"],["2","Master"],["3","Journey"]] }, + ]}, + { title: "Performance", icon: "⚡", fields: [ + { prop: "npcstream", label: "NPC stream rate", type: "number", min: 0, max: 240, hint: "Lower = less enemy skipping, more bandwidth. 0 = off." }, + { prop: "priority", label: "Process priority", type: "enum", options: [["0","Realtime"],["1","High"],["2","Above normal"],["3","Normal"],["4","Below normal"],["5","Idle"]] }, + { prop: "slowliquids", label: "Slower liquids (lag reduction)", type: "enum", options: [["1","On"],["0","Off"]] }, + ]}, + ], + }, + + // Project Zomboid — ~/Zomboid/Server/.ini on the saves + // volume. The filename tracks the SERVER_NAME env (default "panel"); + // loadInstanceConfig rewrites file/filePaths per-instance the same way + // conan-exiles does for EDITION. The entrypoint enforces + // DefaultPort/UDPPort/RCONPort/RCONPassword/UPnP on EVERY boot + // (replace-or-append), so those render locked. Everything else here is + // a durable file edit, applied on next restart. + "project-zomboid": { + file: "Zomboid/Server/panel.ini", + filePaths: ["Zomboid/Server/panel.ini", "/game-saves/Zomboid/Server/panel.ini"], + format: "properties", + groups: [ + { title: "Identity & Access", icon: "🏷️", fields: [ + { prop: "PublicName", label: "Public server name", type: "string" }, + { prop: "PublicDescription", label: "Public description", type: "string" }, + { prop: "Public", label: "List on public browser", type: "enum", options: [["true","Listed"],["false","Unlisted"]] }, + { prop: "Password", label: "Join password", type: "password", hint: "Empty = open server." }, + { prop: "MaxPlayers", label: "Max players", type: "number", min: 1, max: 100 }, + { prop: "Open", label: "Open server (no whitelist account needed)", type: "enum", options: [["true","Open"],["false","Whitelist only"]] }, + { prop: "AutoCreateUserInWhiteList", label: "Auto-whitelist joining players", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "DropOffWhiteListAfterDeath", label: "Remove from whitelist on death", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "MaxAccountsPerUser", label: "Max accounts per user", type: "number", min: 0, max: 100, hint: "0 = unlimited." }, + { prop: "ServerWelcomeMessage", label: "Welcome message", type: "textarea", rows: 3, hint: "Use for line breaks." }, + ]}, + { title: "PvP & Safety", icon: "⚔️", fields: [ + { prop: "PVP", label: "PvP enabled", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "SafetySystem", label: "PvP toggle (safety system)", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "ShowSafety", label: "Show players' PvP status", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "SafetyToggleTimer", label: "Safety toggle timer (s)", type: "number", min: 0, max: 1000 }, + { prop: "SafetyCooldownTimer", label: "Safety cooldown (s)", type: "number", min: 0, max: 1000 }, + { prop: "PVPMeleeDamageModifier", label: "PvP melee damage (%)", type: "number", min: 0, max: 500 }, + { prop: "PVPFirearmDamageModifier", label: "PvP firearm damage (%)", type: "number", min: 0, max: 500 }, + { prop: "Faction", label: "Factions", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "AnnounceDeath", label: "Announce player deaths", type: "enum", options: [["true","On"],["false","Off"]] }, + ]}, + { title: "World & Gameplay", icon: "🌍", fields: [ + { prop: "Map", label: "Map(s)", type: "string", hint: "Semicolon-separated mod map names; vanilla = Muldraugh, KY." }, + { prop: "Mods", label: "Mods (load order)", type: "textarea", rows: 2, hint: "Semicolon-separated mod IDs." }, + { prop: "WorkshopItems", label: "Workshop items", type: "textarea", rows: 2, hint: "Semicolon-separated Steam Workshop IDs to download." }, + { prop: "SpawnPoint", label: "Spawn point", type: "string", hint: "x,y,z — 0,0,0 = use spawn regions." }, + { prop: "SpawnItems", label: "Starter items", type: "string", hint: "Comma-separated item IDs given to new characters." }, + { prop: "nightlengthmodifier", label: "Night length modifier", type: "number", min: 0, max: 10, step: 0.1 }, + { prop: "NoFire", label: "Disable fire spread", type: "enum", options: [["true","Fire disabled"],["false","Fire enabled"]] }, + { prop: "HoursForLootRespawn", label: "Loot respawn (in-game hours, 0=off)", type: "number", min: 0, max: 8760 }, + { prop: "MaxItemsForLootRespawn", label: "Max items for loot respawn", type: "number", min: 0, max: 1000 }, + { prop: "PlayerRespawnWithSelf", label: "Respawn at death location", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "PauseEmpty", label: "Pause when empty", type: "enum", options: [["true","On"],["false","Off"]] }, + ]}, + { title: "Saves & Network", icon: "💾", fields: [ + { prop: "SaveWorldEveryMinutes", label: "Autosave interval (min, 0=default)", type: "number", min: 0, max: 1440 }, + { prop: "BackupsCount", label: "Backups to keep", type: "number", min: 0, max: 100 }, + { prop: "BackupsOnStart", label: "Backup on start", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "PingLimit", label: "Ping limit (ms, 100=default)", type: "number", min: 0, max: 2000 }, + { prop: "SteamVAC", label: "Valve Anti-Cheat", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "DoLuaChecksum", label: "Kick on Lua checksum mismatch", type: "enum", options: [["true","On"],["false","Off"]] }, + { prop: "DefaultPort", label: "Game port", type: "number", locked: true, hint: "🔒 Panel-owned — enforced from the port allocator on every boot. Change it in the Network ports pane." }, + { prop: "UDPPort", label: "Direct UDP port", type: "number", locked: true, hint: "🔒 Panel-owned — see Network ports pane." }, + { prop: "RCONPort", label: "RCON port", type: "number", locked: true, hint: "🔒 Panel-owned." }, + { prop: "UPnP", label: "UPnP", type: "string", locked: true, hint: "🔒 Forced false by the entrypoint (default true can hang router detection)." }, + ]}, + ], + }, +}; + +function renderInstanceSettings() { + const id = instanceModalState.id; const wrap = $("#im-set-wrap"); if (!id) return; + const i = allInstances.find(x => x.instance_id === id); if (!i) return; + const mod = (allModules.find(m => m.id === i.module_id) || {}); + + wrap.innerHTML = ` +
    +
    +
    Overview
    +
    +
    ${esc(i.instance_id)}
    +
    ${esc(mod.name || i.module_id)}(${esc(i.module_id)})
    +
    ${esc(i.agent_id || "—")}
    +
    ${esc(i.status)}
    +
    ${esc(fmtTime(i.created_at))}
    +
    +
    + +
    +
    Maintenance
    +
    +
    Rebuild the container using the latest module image — useful after a module update, a Dockerfile change, or to pick up a new entrypoint. Volumes (world data, configs, saves) are preserved; only the container itself is replaced.
    + +
    +
    + + + + +
    +
    ⚠ Danger zone
    +
    +
    Deleting removes the container AND wipes Docker volumes for this server. World data, saves, and configs will be gone. Backups are NOT automatically kept — make one first.
    + +
    +
    +
    `; + + // Load bundled-mods catalog filtered to this instance's module. + // Card stays hidden when there's nothing applicable. + (async () => { + try { + const r = await fetchJSON(`/api/mods/installable?module=${encodeURIComponent(i.module_id)}`); + const mods = r.mods || []; + if (!mods.length) return; + const card = $("#im-set-mods-card"); + const list = $("#im-set-mods-list"); + if (!card || !list) return; + card.style.display = ""; + list.innerHTML = mods.map(m => ` +
    +
    🧩
    +
    +
    ${esc(m.display_name)}
    +
    ${esc(m.description || "")}
    +
    → ${esc(m.target_path)}
    +
    + +
    `).join(""); + list.addEventListener("click", async e => { + const modID = e.target.dataset.installMod; + if (!modID) return; + if (!confirm(`Install ${modID}? Files will be written into the container; restart the server to load.`)) return; + e.target.disabled = true; + e.target.textContent = "Installing…"; + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/install-mod`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ mod_id: modID }), + }); + toast(`${modID} installed: ${r.files} files (${fmtBytes(r.bytes)}). ${r.restart_after ? "Restart to load." : ""}`, "ok", 9000); + e.target.textContent = "Reinstall"; + e.target.disabled = false; + } catch (err) { + toast(`Install failed: ${err.message}`, "err", 9000); + e.target.disabled = false; + e.target.textContent = "Install"; + } + }); + } catch (err) { + // Silent — operator just doesn't see the card. + } + })(); + + $("#im-set-del").addEventListener("click", async () => { + if (!confirm(`Delete "${id}" and purge all its data?\n\nThis is irreversible. Volumes will be removed.\n\nAre you absolutely sure?`)) return; + try { await fetchJSON(`/api/instances/${encodeURIComponent(id)}?purge=true`, { method: "DELETE" }); toast(`Deleting ${id}…`, "ok"); hideModal("#instance-modal"); } + catch (err) { toast(`Delete failed: ${err.message}`, "err"); } + }); + + $("#im-set-rebuild").addEventListener("click", async e => { + if (!confirm(`Rebuild "${id}"?\n\nThe container will be stopped, removed, and re-created from the latest module image. Docker volumes (your world, saves, configs) are preserved.\n\nAny currently-connected players will be disconnected briefly.`)) return; + const btn = e.target; + btn.disabled = true; btn.textContent = "Rebuilding…"; + try { + // Server-side recreate via /rebuild — same path as env-config recreate + // with an empty updates set. This is the ONLY recreate path that + // preserves config_values + assigned_ports + mount_overrides (the §6 + // recreate-dance invariants). The old client-side stop/delete/POST- + // /api/instances flow dropped all three, silently reverting map + + // cluster + mods to module defaults — root caused 2026-05-20 when a + // Rebuild on a Ragnarok ARK instance booted as The Island. + const resp = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/rebuild`, { + method: "POST", + headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ updates: {} }), + }); + toast(`Rebuilt ${id}${resp && resp.restarted ? " — restarted" : ""}`, "ok"); + pollInstances(); + } catch (err) { + toast("Rebuild failed: " + err.message, "err"); + } finally { + btn.disabled = false; btn.textContent = "🔄 Rebuild container (keep data)"; + } + }); +} + +// ENV_CONFIG_SCHEMA: per-module env-var fields that show up in the +// "Game launch settings" card on the Config tab. Each field is the +// name of a config_values key (= the container env var) plus rendering +// hints. Changing any of these triggers a recreate-with-env-config — +// world data is preserved (volumes not purged), the container is +// reborn with new env, and starts again if it was running. +// +// Same shape as MODULE_CREATE_OPTIONS (the create-form extras), so +// extending one usually means duplicating into the other. +const ENV_CONFIG_SCHEMA = { + // 7 Days to Die deliberately has NO entry here anymore. Its entire + // serverconfig is edited through the DURABLE serverconfig.xml editor + // (configSchemas["7dtd"], each field carrying a tmplKey): Apply posts to + // /config-render, which merges into config_values + re-renders the boot + // template on the agent with NO recreate — changes land on the next + // restart. The old 10-field env card here forced a full container + // recreate for the exact same config_values keys. Container-level 7dtd + // settings that genuinely need a recreate (CLUSTER_ID, + // CLUSTER_PLAYER_SAVE, ports) are owned by the Cluster tab and the + // Network ports pane. + + // ARK SA env-driven settings. The acekorneya image reads these env + // vars at boot and assembles the ARK launch command (?SessionName=, + // ?ServerAdminPassword=, ?Port=, ?QueryPort=, etc.) from them. ARK's + // launch args BEAT GameUserSettings.ini values — so editing those + // fields in the INI is a no-op against env defaults. Setting them + // here is the only way to actually change them. Each apply runs a + // stop → delete-preserve → create → start dance (~30s). + "ark-sa": [{ + // The acekorneya image's launch_ASA.sh reads MAP_NAME (NOT SERVER_MAP) + // and assembles the launch arg as `${MAP_NAME}_WP` for unknown maps, + // or passes the value through verbatim if it already ends in _WP. So + // setting MAP_NAME=Ragnarok_WP gives `ArkAscendedServer.exe Ragnarok_WP?listen…`. + prop: "MAP_NAME", + label: "Map", + type: "select", + default: "TheIsland_WP", + hint: "Changing the map recreates the container with the new world. ★ = community map (mods required).", + options: [ + ["TheIsland_WP", "The Island"], + ["TheCenter_WP", "The Center"], + ["ScorchedEarth_WP", "Scorched Earth"], + ["Aberration_WP", "Aberration"], + ["Extinction_WP", "Extinction"], + ["LostColony_WP", "Lost Colony"], + ["Astraeos_WP", "Astraeos ★"], + ["Ragnarok_WP", "Ragnarok ★"], + ["Valguero_WP", "Valguero ★"], + ["BobsMissions_WP", "Club ARK (BobsMissions)"], + ], + // Paths are relative to the manifest's browseable_root, which for + // ARK SA is .../ShooterGame/Saved — so "SavedArks" wipes + // .../ShooterGame/Saved/SavedArks (every map's saves on this server). + wipe_paths: ["SavedArks"], + wipe_label: "Wipe existing world data (SavedArks/)", + wipe_hint: "Deletes every map's saves on this server, not just the old map's. Defaults on — most operators treat a map switch as a fresh server. Uncheck to keep old saves around for switching back later.", + }, { + prop: "SESSION_NAME", + label: "Session name", + type: "string", + default: "Panel ARK SA Server", + hint: "What players see in the Steam server browser. Changing this requires a container recreate (world data preserved).", + }, { + prop: "SERVER_ADMIN_PASSWORD", + label: "Admin password", + type: "password", + default: "changeme-please", + hint: "Used for RCON + in-game admin commands. Empty values aren't allowed by ARK — keep something here.", + }, { + prop: "SERVER_PASSWORD", + label: "Join password", + type: "password", + default: "", + hint: "Leave blank for an open server. Players are prompted for this on join.", + }, { + prop: "MAX_PLAYERS", + label: "Max players", + type: "string", + default: "70", + hint: "Hard cap on concurrent players. ARK SA's official caps are 70 (vanilla) and 100+ (modded). Numeric only.", + }, { + prop: "BATTLEEYE", + label: "BattlEye anti-cheat", + type: "select", + default: "FALSE", + hint: "BattlEye under Wine/Proton on Linux containers tends to crash the server 2-3 minutes after start (silent UE5 death). Default FALSE; flip to TRUE only if you know it works on your image.", + options: [["FALSE","Off (recommended on Linux/Wine)"],["TRUE","On"]], + }, { + prop: "MOD_IDS", + label: "Mod IDs", + type: "string", + default: "", + hint: "Comma-separated CurseForge mod IDs (the panel's Mods tab manages this for you with previews + one-click install — usually edit there, not here).", + }, { + prop: "PASSIVE_MODS", + label: "Passive Mod IDs", + type: "string", + default: "", + hint: "Mods loaded for clients but not enforced server-side (cosmetic / mapper mods).", + }, { + prop: "PUBLIC_IP_FOR_EPIC", + label: "Public IP for EOS (server browser)", + type: "string", + default: "", + hint: "Pin the IPv4 ASA advertises to Epic Online Services for the in-game server browser. Leave blank to let ASA auto-detect (the default — fine on a single-NIC host). Set to the WAN IPv4 players actually reach (your public edge IP) if servers periodically vanish from the browser while RCON still works. Fixes the EOS \"wrong adapter on re-register\" bug.", + }], + // V Rising env-driven settings. CLI flags (which our entrypoint + // assembles from these env vars) override ServerHostSettings.json, + // so this is the canonical place to set server identity + slots + + // password. Each apply runs the recreate dance (~30s) — world data + // (Saves/) is preserved. + "v-rising": [{ + prop: "SERVER_NAME", + label: "Server name", + type: "string", + default: "panel V Rising", + hint: "What players see in the in-game server browser.", + }, { + prop: "SERVER_DESCRIPTION", + label: "Description", + type: "string", + default: "A V Rising server hosted by panel.", + hint: "Short tagline shown in the server details pane.", + }, { + prop: "SERVER_PASSWORD", + label: "Join password", + type: "password", + default: "", + hint: "Leave blank for an open server. Players are prompted on join.", + }, { + prop: "MAX_PLAYERS", + label: "Max players", + type: "string", + default: "40", + hint: "Concurrent connection cap. Stunlock's hard ceiling is 128.", + }, { + prop: "MAX_ADMINS", + label: "Reserved admin slots", + type: "string", + default: "4", + hint: "Slots held open for admins when the server hits Max players.", + }, { + prop: "SAVE_NAME", + label: "Save name", + type: "string", + default: "panel", + hint: "Folder name under Saves/. Changing this starts a fresh world (the old one stays on disk under its old name).", + }, { + prop: "LIST_ON_STEAM", + label: "List on Steam", + type: "select", + default: "true", + options: [["true","Yes — show in Steam browser"],["false","No"]], + hint: "Steam server browser registration.", + }, { + prop: "LIST_ON_EOS", + label: "List on EOS (cross-play)", + type: "select", + default: "true", + options: [["true","Yes — show in in-game cross-play browser"],["false","No"]], + hint: "Epic Online Services registration. Both Steam + EOS clients use this.", + }, { + prop: "GAME_SETTINGS_PRESET", + label: "Game settings preset", + type: "select", + default: "", + options: [ + ["", "(none — use ServerGameSettings.json)"], + ["StandardPvP", "Standard PvP"], + ["StandardPvE", "Standard PvE"], + ["Duo_PvP", "Duo PvP"], + ["Solo_PvP", "Solo PvP"], + ["HardcorePvP", "Hardcore PvP"], + ["Hardcore_PvP_NoDuration","Hardcore PvP (no duration)"], + ], + hint: "Stunlock's bundled rule presets. Setting any preset OVERRIDES individual edits in ServerGameSettings.json — leave blank if you've customised game rules manually.", + }, { + prop: "RCON_ENABLED", + label: "RCON enabled", + type: "select", + default: "false", + options: [["false","Off"],["true","On"]], + hint: "Source-RCON over TCP. The panel's console uses RCON when available.", + }, { + prop: "RCON_PASSWORD", + label: "RCON password", + type: "password", + default: "", + hint: "Required when RCON is enabled. Used by the panel's console + any external RCON tools you connect.", + }], + + // Conan Exiles env-driven launch settings. The container's entrypoint + // assembles `-ServerName=…`, `-MaxPlayers=…`, `-AdminPassword=…` from + // these env vars, and these BEAT whatever's in ServerSettings.ini for + // the Funcom-validated keys (server name, password, slots). Map switches + // wipe the world unless you tick the wipe-skip checkbox in the dialog. + "conan-exiles": [{ + prop: "SERVER_NAME", + label: "Server name", + type: "string", + default: "panel Conan Exiles", + hint: "Shown in the in-game server browser. Set via -ServerName launch arg, overrides INI on every boot.", + }, { + prop: "SERVER_MAP", + label: "Map", + type: "select", + default: "/Game/Maps/ConanSandbox/ConanSandbox", + hint: "Switching maps does NOT wipe game.db — but the SQLite world is map-specific, so switching maps then back may corrupt actor references. Treat as fresh-world by default; uncheck the wipe to keep the old DB around.", + options: [ + ["/Game/Maps/ConanSandbox/ConanSandbox", "Exiled Lands"], + ["/Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah", "Isle of Siptah (DLC)"], + ], + wipe_paths: ["ConanSandbox/Saved/game.db", "ConanSandbox/Saved/game.db-shm", "ConanSandbox/Saved/game.db-wal"], + wipe_label: "Wipe world DB (game.db)", + wipe_hint: "Conan stores all world state in a single SQLite file. Default on for map switches — keeping the old DB across maps tends to leave orphaned thralls/buildings referencing the wrong map's actors.", + }, { + prop: "MAX_PLAYERS", + label: "Max players", + type: "string", + default: "40", + hint: "Concurrent player cap. Funcom's official cap is 40; Conan can technically host more but performance degrades.", + }, { + prop: "RCON_PASSWORD", + label: "RCON password", + type: "password", + default: "panel_rcon", + hint: "Source-RCON password the panel's console uses. Different from the in-game AdminPassword (which is in the Config tab).", + }, { + prop: "STEAM_APP_ID", + label: "Steam App ID override", + type: "string", + default: "440900", + hint: "Conan dedicated server reports itself as the regular Conan app (440900) for the master-server listing — leave this alone unless you know exactly what you're changing.", + advanced: true, + }, { + prop: "EDITION", + label: "Edition", + type: "select", + default: "enhanced", + hint: "Switching from UE5→UE4 may not load Enhanced-era saves; backup first. UE4→UE5 is safe (Funcom's migration handles it). Click Update after changing.", + options: [ + ["enhanced", "Enhanced (UE5, default)"], + ["legacy", "Legacy (UE4 — Steam beta branch)"], + ], + }], + + // Rust env-driven launch settings. RustDedicated takes most settings + // via -nounityaudio +server.X "Y" command-line flags; the entrypoint + // assembles those from these env vars. Procgen seed/size are baked + // into the world data on first boot — changing seed or world size + // requires a fresh world. + "rust": [{ + prop: "SERVER_NAME", + label: "Server name (hostname)", + type: "string", + default: "panel Rust", + hint: "Shown in the in-game server browser. Maps to +server.hostname on the launch line.", + }, { + prop: "SERVER_DESCRIPTION", + label: "Description", + type: "string", + default: "Powered by panel", + hint: "Long-form description shown in the server details pane.", + }, { + prop: "SERVER_URL", + label: "Website URL", + type: "string", + default: "", + hint: "Optional. Click-through link in the server browser.", + }, { + prop: "SERVER_HEADER_IMAGE", + label: "Header image URL", + type: "string", + default: "", + hint: "512×256 PNG/JPG hosted publicly over HTTPS. Shown above the description in the browser.", + }, { + prop: "SERVER_IDENTITY", + label: "Server identity", + type: "string", + default: "panel-rust", + hint: "Folder name under server/ where saves live. Changing this points the server at a different save dir — the old one stays on disk. Avoid spaces.", + }, { + prop: "SERVER_SEED", + label: "World seed", + type: "string", + default: "12345", + hint: "Procgen seed. Changing this generates a new world on next boot UNLESS the existing identity already has a generated map.", + }, { + prop: "SERVER_WORLD_SIZE", + label: "World size", + type: "string", + default: "3000", + hint: "1000–6000. Larger = longer procgen + more RAM. Same caveat as seed: existing world wins if it's already been generated.", + }, { + prop: "SERVER_LEVEL", + label: "Map preset", + type: "select", + default: "Procedural Map", + options: [ + ["Procedural Map", "Procedural Map (default)"], + ["Barren", "Barren"], + ["HapisIsland", "Hapis Island"], + ["SavasIsland", "Savas Island"], + ["CraggyIsland", "Craggy Island"], + ], + hint: "Most servers stay on Procedural Map. The fixed maps (Hapis, Savas, Craggy) ignore seed + world size.", + }, { + prop: "MAX_PLAYERS", + label: "Max players", + type: "string", + default: "50", + hint: "Concurrent player cap. Rust scales well — official servers run 200+; community private servers usually 50–150.", + }, { + prop: "RCON_PASSWORD", + label: "RCON password", + type: "password", + default: "panel_rcon", + hint: "WebSocket-RCON password. Used by the panel's console + RustAdmin / Battlemetrics if you wire them up.", + }], + // Soulmask — EVERYTHING goes through env-config recreate (gamehost calls + // this DurableConfig=false). Two kinds of key ride the same POST: + // - real container env (SERVER_NAME/GAME_MODE/… ) the entrypoint turns + // into launch args (-SteamServerName= / -pve / -PSW= / -adminpsw= …); + // - gx_* config_values consumed by templates/GameXishu.json.tmpl — the + // recreate re-renders the boot template, and the entrypoint copies it + // into WS/Saved/GameplaySettings/GameXishu.json on every start. + // config-render alone would apply only the gx_* half and silently drop + // the env half, hence one recreate path for both (2026-07-08 changelog). + // Fields carry `group` — the launch pane renders per-group headings. + "soulmask": [{ + prop: "SERVER_NAME", group: "Server", label: "Server name", type: "string", + default: "panel Soulmask", + hint: "Steam browser name (passed as -SteamServerName=).", + }, { + prop: "GAME_MODE", group: "Server", label: "Game mode", type: "select", + default: "pve", options: [["pve","PvE"],["pvp","PvP"]], + hint: "Launch flag -pve / -pvp. PvP damage between players is additionally gated by the PvP damage knob below.", + }, { + prop: "SERVER_PASSWORD", group: "Server", label: "Join password", type: "password", + default: "", hint: "Leave blank for an open server (-PSW=).", + }, { + prop: "ADMIN_PASSWORD", group: "Server", label: "Admin password", type: "password", + default: "changeme", hint: "In-game GM/admin panel password (-adminpsw= — NOT the join password).", + }, { + prop: "MAX_PLAYERS", group: "Server", label: "Max players", type: "string", + default: "30", hint: "Concurrent player cap (-MaxPlayers=). Numeric only.", + }, { + prop: "SAVING", group: "Server", label: "World save interval (s)", type: "string", + default: "600", hint: "-saving= — how often the world autosaves.", + }, { + prop: "BACKUP", group: "Server", label: "Backup interval (s)", type: "string", + default: "900", hint: "-backup= — rolling save-folder backups.", + }, + // --- GameXishu gameplay knobs (templates/GameXishu.json.tmpl, stamped + // into all 3 difficulty-preset sections). Ratios: 1 = vanilla. + { + prop: "gx_exp_ratio", group: "XP rates", label: "XP multiplier", type: "string", + default: "1", hint: "Overall character XP (ExpRatio). 1 = vanilla.", + }, { + prop: "gx_gather_exp_ratio", group: "XP rates", label: "Gathering XP", type: "string", + default: "1", hint: "XP from gathering/harvesting (CaiJiExpRatio).", + }, { + prop: "gx_kill_exp_ratio", group: "XP rates", label: "Kill XP", type: "string", + default: "1", hint: "XP from kills (ShaGuaiExpRatio).", + }, { + prop: "gx_harvest_ratio", group: "Harvest & loot", label: "Harvest yield", type: "string", + default: "1", hint: "Hand-gathering yield (CaiJiDiaoLuoRatio).", + }, { + prop: "gx_wood_ratio", group: "Harvest & loot", label: "Logging yield", type: "string", + default: "1", hint: "Wood from trees (FaMuDiaoLuoRatio).", + }, { + prop: "gx_mining_ratio", group: "Harvest & loot", label: "Mining yield", type: "string", + default: "1", hint: "Ore/stone from mining (CaiKuangDiaoLuoRatio).", + }, { + prop: "gx_animal_loot_ratio", group: "Harvest & loot", label: "Animal loot", type: "string", + default: "1", hint: "Drops from animal corpses (DongWuShiTiDiaoLuoRatio).", + }, { + prop: "gx_crop_growth_ratio", group: "Growth & survival", label: "Crop growth speed", type: "string", + default: "1", hint: "ZuoWuShengZhangRatio — higher = faster crops.", + }, { + prop: "gx_animal_growth_ratio", group: "Growth & survival", label: "Animal growth speed", type: "string", + default: "1", hint: "Tamed-animal maturing speed (DongWuShengZhangRatio).", + }, { + prop: "gx_food_consume_ratio", group: "Growth & survival", label: "Hunger rate", type: "string", + default: "1", hint: "Food drain (ShiWuXiaoHaoRatio). Lower = slower hunger.", + }, { + prop: "gx_water_consume_ratio", group: "Growth & survival", label: "Thirst rate", type: "string", + default: "1", hint: "Water drain (ShuiXiaoHaoRatio).", + }, { + prop: "gx_item_decay_ratio", group: "Decay & death", label: "Item spoil rate", type: "string", + default: "1", hint: "Food/item spoilage speed (WuPinFuHuaiRatio).", + }, { + prop: "gx_building_decay", group: "Decay & death", label: "Building decay", type: "select", + default: "1", options: [["1","On (vanilla)"],["0","Off"]], + hint: "JianZhuFuLanKaiGuan — structure decay over time.", + }, { + prop: "gx_drop_on_death", group: "Decay & death", label: "Drop items on death", type: "select", + default: "0", options: [["0","Keep inventory"],["1","Drop on death"]], + hint: "FuHuoMoveSiWangBaoKaiGuan — death penalty for inventory.", + }, { + prop: "gx_pvp_damage", group: "World & PvP", label: "PvP damage", type: "select", + default: "1", options: [["1","On"],["0","Off"]], + hint: "HuXIangShangHaiKaiGuan — player-vs-player damage switch (independent of the pve/pvp mode flag).", + }, { + prop: "gx_day_length", group: "World & PvP", label: "Day length (in-game hours/day)", type: "string", + default: "24", hint: "GameWorldTimePower. 24 = vanilla pacing; lower = faster days.", + }, { + prop: "gx_daytime_portion", group: "World & PvP", label: "Daylight portion (0–1)", type: "string", + default: "0.8", hint: "GameWorldDayTimePortion — fraction of the cycle that is daytime. Vanilla ≈ 0.8.", + }], + // Minecraft Bedrock env-driven settings. The itzg image stamps each of + // these into /data/server.properties on EVERY container start, so the + // env value is the source of truth — editing the same keys in the file + // editor would silently revert on the next boot (the file schema only + // exposes the NON-env-managed properties). Each apply = recreate dance; + // world data (the /data volume) is preserved. + "minecraft-bedrock": [{ + prop: "SERVER_NAME", group: "Identity", label: "Server name", type: "string", + default: "Refuge Bedrock", hint: "→ server-name. Shown in the in-game server list.", + }, { + prop: "MAX_PLAYERS", group: "Identity", label: "Max players", type: "string", + default: "10", hint: "→ max-players. Numeric only.", + }, { + prop: "ONLINE_MODE", group: "Identity", label: "Online mode (Xbox Live auth)", type: "select", + default: "true", options: [["true","On (recommended)"],["false","Off"]], + hint: "→ online-mode. Off allows unauthenticated clients.", + }, { + prop: "GAMEMODE", group: "Gameplay", label: "Default gamemode", type: "select", + default: "survival", options: [["survival","Survival"],["creative","Creative"],["adventure","Adventure"]], + }, { + prop: "DIFFICULTY", group: "Gameplay", label: "Difficulty", type: "select", + default: "easy", options: [["peaceful","Peaceful"],["easy","Easy"],["normal","Normal"],["hard","Hard"]], + }, { + prop: "ALLOW_CHEATS", group: "Gameplay", label: "Allow cheats (commands)", type: "select", + default: "false", options: [["false","Off"],["true","On"]], + }, { + prop: "DEFAULT_PLAYER_PERMISSION_LEVEL", group: "Gameplay", label: "Default permission level", type: "select", + default: "member", options: [["visitor","Visitor"],["member","Member"],["operator","Operator"]], + }, { + prop: "LEVEL_NAME", group: "World", label: "World folder (level name)", type: "string", + default: "level", hint: "→ level-name. ⚠ Changing it loads/creates a DIFFERENT world folder under worlds/ — the old one stays on disk.", + }, { + prop: "LEVEL_TYPE", group: "World", label: "World type", type: "select", + default: "DEFAULT", options: [["DEFAULT","Default"],["FLAT","Flat"],["LEGACY","Legacy"]], + hint: "Only applies when the world is first generated.", + }, { + prop: "VIEW_DISTANCE", group: "Performance", label: "View distance (chunks)", type: "string", + default: "10", hint: "→ view-distance. Numeric only.", + }, { + prop: "TICK_DISTANCE", group: "Performance", label: "Tick distance (chunks)", type: "string", + default: "4", hint: "→ tick-distance. 4–12.", + }, { + prop: "VERSION", group: "Updates", label: "Bedrock server version", type: "string", + default: "LATEST", hint: "LATEST, or a pinned version like 1.21.44.01 — itzg re-downloads the binary on change.", + }], + // Core Keeper env-driven settings. The entrypoint passes these straight + // to the binary as -worldname/-worldmode/-maxplayers/-gameid launch + // args; there is no config file at all (no RCON either — admin is the + // in-game founders key). Each apply = recreate dance; worlds live under + // /game-saves/DedicatedServer and are preserved. + "core-keeper": [{ + prop: "WORLD_NAME", group: "World", label: "World name", type: "string", + default: "panel", hint: "-worldname. ⚠ Changing it loads/creates a different world save.", + }, { + prop: "WORLD_MODE", group: "World", label: "World mode", type: "select", + default: "0", options: [["0","Normal"],["1","Hard"],["2","Creative"],["4","Casual"]], + hint: "-worldmode. Only applies when the world is first created.", + }, { + prop: "MAX_PLAYERS", group: "Server", label: "Max players", type: "string", + default: "10", hint: "-maxplayers. Numeric only.", + }, { + prop: "GAME_ID", group: "Server", label: "Game ID (invite code)", type: "string", + default: "", hint: "-gameid — pin a fixed 28+ char Game ID so players can rejoin by code. Empty = server generates one (see GameID.txt in /game).", + }], +}; + +// renderEnvConfigCard returns the HTML for the Game launch settings +// section. Returns "" when the instance's module has no env-config +// fields declared, so the section is hidden cleanly. +// +// UX: rows track local dirty state. A footer "Apply N changes" button +// batches every diverged field into a single recreate dance — earlier +// per-row Apply buttons forced the operator to recreate the container +// once per knob, which made multi-field edits painfully slow. +function renderEnvConfigCard(inst) { + const fields = ENV_CONFIG_SCHEMA[inst.module_id]; + if (!fields || !fields.length) return ""; + const cv = inst.config_values || {}; + const rowsHTML = fields.map(f => { + const id = "im-env-" + f.prop; + const cur = cv[f.prop] || f.default || ""; + let ctrl = ""; + if (f.type === "select") { + const opts = (f.options || []).map(o => { + const [val, lab] = Array.isArray(o) ? o : [o, o]; + const sel = (val === cur) ? " selected" : ""; + return ``; + }).join(""); + ctrl = ``; + } else if (f.type === "bool") { + const checked = (cur === "true" || cur === "1") ? " checked" : ""; + ctrl = ``; + } else if (f.type === "password") { + ctrl = ``; + } else { + ctrl = ``; + } + return `
    + +
    + ${ctrl} + ${f.hint ? `
    ${f.hint}
    ` : ""} +
    + +
    `; + }).join(""); + return `
    +
    Game launch settings env-var · recreates container · world data preserved
    +
    ${rowsHTML}
    +
    + No changes. + + + + +
    +
    `; +} + +// wireEnvConfigCard hooks input/change handlers on every env field and +// drives the single footer Apply button. Tracks dirty state in a local +// Set; on Apply click, bundles every diverged field into one POST to +// /env-config and runs a single recreate. Wipe-checkbox per dirty field +// (when the schema declares wipe_paths) is consolidated into the same +// confirm modal — operator gets one decision per affected wipe path +// instead of one modal per field. +function wireEnvConfigCard(instanceID) { + const inst = allInstances.find(x => x.instance_id === instanceID); + const schema = inst ? (ENV_CONFIG_SCHEMA[inst.module_id] || []) : []; + if (!schema.length) return; + const fieldByProp = {}; + for (const f of schema) fieldByProp[f.prop] = f; + + const inputs = Array.from(document.querySelectorAll("[data-env-prop]")); + const applyBtn = document.getElementById("env-config-apply"); + const resetBtn = document.getElementById("env-config-reset"); + const statusEl = document.getElementById("env-config-status"); + if (!applyBtn || !resetBtn || !statusEl) return; + + const readVal = (el) => el.type === "checkbox" ? (el.checked ? "true" : "false") : el.value; + const setVal = (el, v) => { + if (el.type === "checkbox") el.checked = (v === "true" || v === "1"); + else el.value = v; + }; + + const collectDirty = () => { + const dirty = []; + for (const el of inputs) { + const prop = el.dataset.envProp; + const orig = el.dataset.envOriginal || ""; + const cur = readVal(el); + if (cur !== orig) dirty.push({ prop, orig, cur, def: fieldByProp[prop] }); + } + return dirty; + }; + + const refreshFooter = () => { + const dirty = collectDirty(); + const dirtyProps = new Set(dirty.map(d => d.prop)); + // Per-row dirty dot + for (const el of inputs) { + const prop = el.dataset.envProp; + const dot = document.querySelector(`[data-env-dot="${CSS.escape(prop)}"]`); + const row = document.querySelector(`[data-env-row="${CSS.escape(prop)}"]`); + if (dot) dot.style.display = dirtyProps.has(prop) ? "" : "none"; + if (row) row.classList.toggle("row-dirty", dirtyProps.has(prop)); + } + applyBtn.disabled = dirty.length === 0; + resetBtn.disabled = dirty.length === 0; + if (dirty.length === 0) { + statusEl.textContent = "No changes."; + applyBtn.textContent = "Apply changes"; + } else { + statusEl.textContent = `${dirty.length} change${dirty.length === 1 ? "" : "s"} pending`; + applyBtn.textContent = `Apply ${dirty.length} change${dirty.length === 1 ? "" : "s"}`; + } + }; + + for (const el of inputs) { + el.addEventListener("input", refreshFooter); + el.addEventListener("change", refreshFooter); + } + + resetBtn.addEventListener("click", () => { + for (const el of inputs) { + setVal(el, el.dataset.envOriginal || ""); + } + refreshFooter(); + }); + + applyBtn.addEventListener("click", async () => { + const dirty = collectDirty(); + if (!dirty.length) return; + // Collect wipe-paths from any dirty field that declared them. Each + // distinct wipe_path set gets its own checkbox in the confirm modal + // so the operator can decide per affected data area (e.g. ARK's + // SavedArks wipe should be opt-in even when bundled with name+pwd + // changes that don't need any wipe). + const wipeOptions = []; + const seen = new Set(); + for (const d of dirty) { + const f = d.def; + if (f && Array.isArray(f.wipe_paths) && f.wipe_paths.length) { + const key = f.wipe_paths.join(","); + if (seen.has(key)) continue; + seen.add(key); + wipeOptions.push({ + paths: f.wipe_paths, + label: f.wipe_label || `Wipe related data (${key})`, + hint: f.wipe_hint || "", + fromProp: d.prop, + }); + } + } + // Detect cross-edition transitions on Conan Exiles (or anywhere else + // a module surfaces an EDITION key) and inject a high-visibility + // warning into the confirm modal. This is per-direction: Enhanced→ + // Legacy is the dangerous one (UE5 saves don't always load under UE4), + // Legacy→Enhanced is Funcom-handled and merely informational. + const warnings = []; + const editionChange = dirty.find(d => d.prop === "EDITION"); + if (editionChange) { + const from = (editionChange.orig || "enhanced").toLowerCase(); + const to = (editionChange.cur || "enhanced").toLowerCase(); + if (from === "enhanced" && to === "legacy") { + warnings.push({ + severity: "danger", + title: "⚠️ Switching from UE5 (Enhanced) to UE4 (Legacy)", + body: "Conan's Enhanced build writes saves with a newer ABI; loading them under UE4 may corrupt the world or refuse to start. Strongly recommend snapshotting the saves volume before applying. After this change, click Update on the Updates tab to pull the conan-exiles-legacy Steam beta branch.", + }); + } else if (from === "legacy" && to === "enhanced") { + warnings.push({ + severity: "info", + title: "↗ Switching from UE4 (Legacy) to UE5 (Enhanced)", + body: "Funcom's migration handles UE4→UE5 saves cleanly for standard world data; mods will need their UE5 Workshop versions. After this change, click Update on the Updates tab to pull the public Enhanced branch.", + }); + } + } + const confirmed = await openEnvConfigConfirmBatch({ + changes: dirty.map(d => ({ prop: d.prop, oldVal: d.orig, newVal: d.cur, label: d.def && d.def.label || d.prop })), + wipeOptions, + warnings, + }); + if (!confirmed) return; + applyBtn.disabled = true; + resetBtn.disabled = true; + applyBtn.textContent = "Recreating…"; + const updates = {}; + for (const d of dirty) updates[d.prop] = d.cur; + if (confirmed.wipePaths && confirmed.wipePaths.length) { + updates["_PANEL_WIPE_PATHS"] = confirmed.wipePaths.join(","); + } + try { + await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/env-config`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ updates }), + }); + toast(`${dirty.length} setting${dirty.length === 1 ? "" : "s"} applied${confirmed.wipePaths && confirmed.wipePaths.length ? " (data wiped)" : ""}`, "ok"); + loadInstanceConfig(); + pollInstances(); + } catch (err) { + toast(`Apply failed: ${err.message}`, "err"); + applyBtn.disabled = false; + resetBtn.disabled = false; + applyBtn.textContent = `Apply ${dirty.length} change${dirty.length === 1 ? "" : "s"}`; + } + }); + + refreshFooter(); +} + +// openEnvConfigConfirm — mini-modal on top of the instance modal. +// Resolves to either {wipe: bool} (proceed) or null (cancelled). +function openEnvConfigConfirm({ prop, oldVal, newVal, wipePaths, wipeLabel, wipeHint }) { + return new Promise(resolve => { + let modal = $("#env-config-confirm-modal"); + if (!modal) { + modal = document.createElement("div"); + modal.id = "env-config-confirm-modal"; + modal.className = "modal-bg"; + modal.style.display = "none"; + modal.innerHTML = ``; + document.body.appendChild(modal); + if (typeof _wireBackdropClose === "function") _wireBackdropClose(modal); + } + $("#ec-summary").innerHTML = + `Change ${esc(prop)} from ` + + `"${esc(oldVal || "—")}" to "${esc(newVal)}"?`; + const wipeRow = $("#ec-wipe-row"); + if (wipePaths) { + wipeRow.style.display = "flex"; + $("#ec-wipe-label").textContent = wipeLabel; + $("#ec-wipe-hint").textContent = wipeHint; + $("#ec-wipe-cb").checked = true; + } else { + wipeRow.style.display = "none"; + } + showModal("#env-config-confirm-modal"); + const goBtn = $("#ec-go"); + const closeAndResolve = (val) => { + hideModal("#env-config-confirm-modal"); + // Cleanup listeners so successive opens don't double-fire. + goBtn.onclick = null; + modal.querySelectorAll("[data-close]").forEach(b => b.onclick = null); + resolve(val); + }; + goBtn.onclick = () => closeAndResolve({ wipe: !!$("#ec-wipe-cb").checked }); + modal.querySelectorAll("[data-close]").forEach(b => b.onclick = () => closeAndResolve(null)); + }); +} + +// openEnvConfigConfirmBatch — confirm modal for the batched env-config +// flow. Lists every diverged field on a single screen and surfaces one +// checkbox per distinct wipe-path set declared by any dirty field. The +// resolver returns { wipePaths: [...] } where wipePaths is the union of +// every wipe set the operator left checked, or null on cancel. +function openEnvConfigConfirmBatch({ changes, wipeOptions, warnings }) { + return new Promise(resolve => { + let modal = document.getElementById("env-config-confirm-batch-modal"); + if (!modal) { + modal = document.createElement("div"); + modal.id = "env-config-confirm-batch-modal"; + modal.className = "modal-bg"; + modal.style.display = "none"; + modal.innerHTML = ``; + document.body.appendChild(modal); + if (typeof _wireBackdropClose === "function") _wireBackdropClose(modal); + } + // Warnings render at the TOP of the modal — high-visibility banner for + // changes that have data-loss potential beyond the standard recreate + // (e.g. cross-edition swaps on Conan Exiles). Severity drives colour: + // danger (red, demands attention), info (blue, just heads-up). + const warnEl = document.getElementById("ecb-warnings"); + if (warnEl) { + warnEl.innerHTML = (warnings || []).map(w => { + const isDanger = w.severity === "danger"; + const accent = isDanger ? "var(--err)" : "#93c5fd"; + const bg = isDanger ? "rgba(248,113,113,.08)" : "rgba(96,165,250,.08)"; + const border = isDanger ? "rgba(248,113,113,.35)" : "rgba(96,165,250,.35)"; + return `
    +
    ${esc(w.title || "")}
    +
    ${esc(w.body || "")}
    +
    `; + }).join(""); + warnEl.style.display = (warnings && warnings.length) ? "" : "none"; + } + const sumEl = document.getElementById("ecb-summary"); + const wipesEl = document.getElementById("ecb-wipes"); + sumEl.innerHTML = `
    ${changes.length} setting${changes.length === 1 ? "" : "s"} will change:
    ` + + `
      ` + + changes.map(c => + `
    • ${esc(c.label)}: ` + + `"${esc(c.oldVal || "—")}" ` + + ` ` + + `"${esc(c.newVal || "—")}"
    • ` + ).join("") + + `
    `; + if (wipeOptions && wipeOptions.length) { + wipesEl.innerHTML = wipeOptions.map((w, i) => ` + + `).join(""); + } else { + wipesEl.innerHTML = ""; + } + showModal("#env-config-confirm-batch-modal"); + const goBtn = document.getElementById("ecb-go"); + const closeAndResolve = (val) => { + hideModal("#env-config-confirm-batch-modal"); + goBtn.onclick = null; + modal.querySelectorAll("[data-close]").forEach(b => b.onclick = null); + resolve(val); + }; + goBtn.onclick = () => { + const wipePaths = []; + (wipeOptions || []).forEach((w, i) => { + const cb = document.getElementById(`ecb-wipe-${i}`); + if (cb && cb.checked) wipePaths.push(...w.paths); + }); + closeAndResolve({ wipePaths }); + }; + modal.querySelectorAll("[data-close]").forEach(b => b.onclick = () => closeAndResolve(null)); + }); +} + +// ---------------- CONFIG tab ---------------- +// ---- UNIFIED CONFIG TAB (rail + main + sticky-footer Apply) ---- +// Replaces the older 3-stacked-cards layout. One nav, one Apply, +// dirty-state tracked across env-config / ports / INI. Per-game +// schemas (ENV_CONFIG_SCHEMA + configSchemas) plug straight in; +// no module-side changes needed. +const cfgState = { + instanceID: "", + schema: null, + envFields: [], + ports: [], + iniPath: "", + iniParsed: null, + iniLoaded: false, + activePane: "", + dirty: new Map(), // key="scope|prop" → {scope, prop, oldVal, newVal} + portsHaveDupes: false, +}; + +function _cfgKey(scope, prop) { return scope + "|" + prop; } +function _cfgNormBool(v) { return /^(true|1|yes|on)$/i.test(String(v == null ? "" : v)) ? "true" : "false"; } + +function loadInstanceConfig() { + const id = instanceModalState.id; + const wrap = $("#im-cfg-wrap"); + if (!id || !wrap) return; + const inst = allInstances.find(x => x.instance_id === id); + if (!inst) return; + const mod = (allModules.find(m => m.id === inst.module_id) || {}); + + cfgState.instanceID = id; + cfgState.schema = configSchemas[inst.module_id] || null; + // 7DTD V3.0: a 3.0-branch instance uses the v3 schema (SandboxCode + only the + // serverconfig properties that survived the 3.0 overhaul). 2.6 and earlier keep + // the classic schema. + // + // Detection MUST NOT hinge on the literal provider_id string alone: the MOVING + // branches `public`/`current`/`stable` ALL resolve to the 3.0 build now that TFP + // flipped public to 3.0 (2026-06-29) — see sevenDtdBranchKey in httpapi.go. An + // earlier gate keyed only on `latest_experimental`/`v3*`, so updating a 3.0 + // server to "stable" (still 3.0 binaries) silently dropped the whole Sandbox + // Options tab. We now treat any 3.0-resolving branch as v3, AND — as the ground + // truth that survives future branch-label churn — treat the presence of a + // persisted SandboxCode as decisive (a 2.6 server never has one). + if (inst.module_id === "7dtd") { + const cv = inst.config_values || {}; + const pid = (cv.provider_id || "").toLowerCase(); + const hasSandboxCode = !!(cv.sandbox_code || cv.SandboxCode); + const v3Branch = pid === "latest_experimental" || pid === "current" || + pid === "stable" || pid === "v3_0" || pid.startsWith("v3"); + if (hasSandboxCode || v3Branch) { + cfgState.schema = configSchemas["7dtd-v3"] || cfgState.schema; + } + } + // Conan Exiles: rewrite the config file path based on EDITION. Enhanced + // (UE5, native) uses Config/LinuxServer/; Legacy (UE4, Wine) uses + // Config/WindowsServer/. We clone the static schema so we don't mutate + // the module-level entry. filePaths still lists both for graceful + // fallback (e.g. legacy data still on disk after an edition flip). + if (cfgState.schema && inst.module_id === "conan-exiles") { + const cv = inst.config_values || {}; + const edition = (cv.EDITION || "enhanced").toLowerCase(); + const subdir = edition === "legacy" ? "WindowsServer" : "LinuxServer"; + const other = edition === "legacy" ? "LinuxServer" : "WindowsServer"; + const primary = `ConanSandbox/Saved/Config/${subdir}/ServerSettings.ini`; + const fallback = `ConanSandbox/Saved/Config/${other}/ServerSettings.ini`; + cfgState.schema = Object.assign({}, cfgState.schema, { + file: primary, + filePaths: [primary, fallback], + }); + } + // Project Zomboid: the per-server ini filename tracks the SERVER_NAME + // env (~/Zomboid/Server/.ini, default "panel"). Same + // clone-and-rewrite pattern as conan-exiles' EDITION above. + if (cfgState.schema && inst.module_id === "project-zomboid") { + const cv = inst.config_values || {}; + const sname = cv.SERVER_NAME || "panel"; + const rel = `Zomboid/Server/${sname}.ini`; + cfgState.schema = Object.assign({}, cfgState.schema, { + file: rel, + filePaths: [rel, `/game-saves/${rel}`], + }); + } + cfgState.envFields = ENV_CONFIG_SCHEMA[inst.module_id] || []; + cfgState.ports = (mod.ports && Array.isArray(mod.ports) ? mod.ports : null) || guessPortsForModule(inst.module_id) || []; + cfgState.iniPath = ""; + cfgState.iniParsed = null; + cfgState.iniLoaded = false; + cfgState.dirty = new Map(); + cfgState.portsHaveDupes = false; + + // Build rail nav items. + const railItems = []; + if (cfgState.envFields.length) { + railItems.push({ pane: "launch", icon: "🚀", title: "Launch settings", subtitle: "env vars · recreates", count: cfgState.envFields.length, scope: "env" }); + } + if (cfgState.ports.length) { + railItems.push({ pane: "ports", icon: "🌐", title: "Network ports", subtitle: "host ports · recreates", count: cfgState.ports.length, scope: "port" }); + } + if (cfgState.schema) { + cfgState.schema.groups.forEach((g, gi) => railItems.push({ + pane: "ini-" + gi, icon: g.icon || "⚙️", title: g.title, subtitle: "", + count: g.fields.length, scope: "ini", groupIndex: gi, + })); + } + + if (!railItems.length) { + wrap.style.padding = "16px"; + wrap.style.display = "block"; + wrap.style.overflow = "auto"; + wrap.innerHTML = `
    📝
    No configuration availableThis module hasn't declared any editable settings. Use the Files tab to edit raw config files.
    `; + return; + } + + cfgState.activePane = railItems[0].pane; + + // Override the wrap's default padding/overflow so our shell fills it. + wrap.style.padding = "0"; + wrap.style.overflow = "hidden"; + wrap.style.display = "flex"; + wrap.style.flexDirection = "column"; + + const quickItems = railItems.filter(it => it.scope !== "ini"); + const iniItems = railItems.filter(it => it.scope === "ini"); + + const railHTML = ` + ${quickItems.length ? `
    +
    Server
    + ${quickItems.map(it => _cfgRailLinkHTML(it)).join("")} +
    ` : ""} + ${iniItems.length ? `
    +
    ${esc(cfgState.schema.file || "Config")}
    + ${iniItems.map(it => _cfgRailLinkHTML(it)).join("")} +
    ` : ""} + `; + + wrap.innerHTML = ` +
    +
    + +
    +
    +
    +
    + 🔍 + +
    +
    +
    +
    +
    +
    + No changes + + +
    +
    + `; + + wrap.querySelectorAll(".cfg-rail-link").forEach(b => { + b.addEventListener("click", () => switchCfgPane(b.dataset.cfgPane)); + }); + $("#cfg-search").addEventListener("input", e => filterCfgPane(e.target.value)); + $("#cfg-foot-reset").addEventListener("click", resetAllCfg); + $("#cfg-foot-apply").addEventListener("click", applyAllCfg); + + renderCfgPane(cfgState.activePane); + refreshCfgFooter(); +} + +function _cfgRailLinkHTML(it) { + return ``; +} + +async function switchCfgPane(pane) { + if (!pane || cfgState.activePane === pane) return; + cfgState.activePane = pane; + document.querySelectorAll(".cfg-rail-link").forEach(b => b.classList.toggle("active", b.dataset.cfgPane === pane)); + const search = $("#cfg-search"); + if (search) search.value = ""; + // Outgoing pane: slide-blur-fade out to the left, then swap content, + // then the incoming pane plays its own cfgPaneIn entrance. + const stage = $("#cfg-pane-stage"); + if (stage) { + stage.classList.add("cfg-pane-leaving"); + await new Promise(r => setTimeout(r, 140)); + } + await renderCfgPane(pane); + if (stage) stage.classList.remove("cfg-pane-leaving"); +} + +async function renderCfgPane(pane) { + const stage = $("#cfg-pane-stage"); + const title = $("#cfg-main-title"); + if (!stage || !title) return; + + if (pane === "launch") { + title.innerHTML = `🚀 Launch settings recreates the container · world data preserved`; + stage.innerHTML = _renderLaunchPaneInner(); + _wireFieldEvents(stage); + } else if (pane === "ports") { + title.innerHTML = `🌐 Network ports host:container mapping · recreates the container`; + stage.innerHTML = _renderPortsPaneInner(); + _wireFieldEvents(stage); + _wirePortsExtras(stage); + } else if (pane.startsWith("ini-")) { + if (!cfgState.iniLoaded) { + title.innerHTML = `⚙️ ${esc(cfgState.schema.file || "Config")}`; + stage.innerHTML = `
    Reading ${esc(cfgState.schema.file)}…
    `; + await _loadIniFileAsync(); + } + if (!cfgState.iniParsed) return; // _loadIniFileAsync already rendered empty state + const gi = parseInt(pane.split("-")[1], 10); + const g = cfgState.schema.groups[gi]; + if (!g) return; + if (g.kind === "sandbox") { + title.innerHTML = `${esc(g.icon || "🎲")} ${esc(g.title)} V3.0 SandboxCode · effective on next start`; + await renderSandboxPane(stage, gi); + filterCfgPane($("#cfg-search").value || ""); + return; + } + title.innerHTML = `${esc(g.icon || "⚙️")} ${esc(g.title)} ${esc(cfgState.iniPath || cfgState.schema.file)} · effective on next start`; + stage.innerHTML = _renderIniPaneInner(gi); + _wireFieldEvents(stage); + } + filterCfgPane($("#cfg-search").value || ""); +} + +// ===== 7DTD V3.0 SandboxCode panel ===== +// Renders the 150 in-game Sandbox Options as grouped dropdowns + a paste-a-code +// field + an official-preset picker. All three converge on a single hidden +// SandboxCode input (data-cfg-prop="SandboxCode") that flows through the normal +// durable config-render/apply pipeline. Encode/decode go through the server +// codec (/api/7dtd/sandbox/*) — the panel never re-implements the encoding. +let _sandboxSchemaCache = null; +async function _getSandboxSchema() { + if (_sandboxSchemaCache) return _sandboxSchemaCache; + _sandboxSchemaCache = await fetchJSON("/api/7dtd/sandbox/schema"); + return _sandboxSchemaCache; +} + +async function renderSandboxPane(stage, gi) { + const g = cfgState.schema.groups[gi]; + // Live SandboxCode from the parsed serverconfig (what the server boots with). + const live = (cfgState.iniParsed && cfgState.iniParsed.values) || {}; + const liveCode = (live.SandboxCode != null && live.SandboxCode !== "") ? String(live.SandboxCode) : "AAAJABJACJADJARFBNC"; + + stage.innerHTML = `
    ${g.description || ""}
    +
    Loading sandbox options…
    `; + + let schema, values; + try { + schema = await _getSandboxSchema(); + const dec = await fetchJSON("/api/7dtd/sandbox/decode", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ code: liveCode }), + }); + values = dec.values || {}; + } catch (e) { + stage.innerHTML = `
    Couldn't load sandbox options${esc((e && e.message) || e)}
    `; + return; + } + + const presetOpts = (schema.presets || []).map(p => + `` + ).join(""); + + // Build the category accordions. Each option = a labeled ${opts} + `; + }).join(""); + return `
    + ${esc(cat.icon || "⚙️")} ${esc(cat.title)} ${(cat.options||[]).length} +
    ${rows}
    +
    `; + }).join(""); + + stage.innerHTML = ` +
    ${g.description || ""}
    + +
    +
    + + +
    +
    + +
    + + +
    +
    +
    +
    Current code: ${esc(liveCode)} + + +
    + ${groupsHTML} + `; + + _wireFieldEvents(stage); // wires the hidden SandboxCode input into dirty tracking + _wireSandboxControls(stage); +} + +// _sbDisplay turns a raw value + its localization-key label into something +// readable. Many labels are xui/go-keys; for those we fall back to the raw +// value (a number/bool), which is meaningful enough. Pure values get a tidy +// percent/× hint for float multiplier sets. +function _sbDisplay(o, v, vi) { + const lab = o.labels && o.labels[vi]; + if (lab && !/^(xui|go|lbl|none)/i.test(lab)) return lab; // human label + if (o.type === "bool") return v === "true" ? "Yes" : "No"; + if (o.type === "float") { + const f = parseFloat(v); + if (!isNaN(f)) return (f === 1 ? "100% (default)" : Math.round(f * 100) + "%"); + } + return v; +} + +async function _recomputeSandboxCode(stage) { + // Gather every option's current value, send to the server encoder. + const vals = {}; + stage.querySelectorAll("select.sbopt").forEach(sel => { vals[sel.dataset.sbopt] = sel.value; }); + let code; + try { + const r = await fetchJSON("/api/7dtd/sandbox/encode", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ values: vals }), + }); + code = r.code; + } catch (e) { toast((e && e.message) || "Encode failed", "error"); return; } + const hidden = stage.querySelector("#sb-code-input"); + const disp = stage.querySelector("#sb-code-display"); + if (disp) disp.textContent = code; + if (hidden) { _writeCfgInputValue(hidden, code); hidden.dispatchEvent(new Event("change", {bubbles:true})); } + // Refresh per-row changed dots. + stage.querySelectorAll(".sbopt-row").forEach(row => { + const sel = row.querySelector("select.sbopt"); + row.classList.toggle("sb-changed", sel && sel.value !== row.dataset.sboptDefault); + }); +} + +function _wireSandboxControls(stage) { + stage.querySelectorAll("select.sbopt").forEach(sel => { + sel.addEventListener("change", () => _recomputeSandboxCode(stage)); + }); + // Paste a code → decode → set every dropdown → re-encode (round-trips through + // the server so an invalid code is rejected cleanly). + const pasteApply = stage.querySelector("#sb-paste-apply"); + if (pasteApply) pasteApply.addEventListener("click", async () => { + const code = (stage.querySelector("#sb-paste").value || "").trim(); + if (!code) return; + await _applySandboxCode(stage, code); + }); + // Preset picker → use its code. + const presetSel = stage.querySelector("#sb-preset"); + if (presetSel) presetSel.addEventListener("change", async () => { + const id = presetSel.value; if (!id) return; + const p = (_sandboxSchemaCache.presets || []).find(x => x.id === id); + if (!p || !p.code) return; + await _applySandboxCode(stage, p.code); + }); + const resetDef = stage.querySelector("#sb-reset-def"); + if (resetDef) resetDef.addEventListener("click", async () => { await _applySandboxCode(stage, "AAAJABJACJADJARFBNC"); }); + const copy = stage.querySelector("#sb-copy"); + if (copy) copy.addEventListener("click", () => { + const c = stage.querySelector("#sb-code-display").textContent || ""; + navigator.clipboard?.writeText(c); toast("Code copied", "ok"); + }); +} + +async function _applySandboxCode(stage, code) { + let values; + try { + const dec = await fetchJSON("/api/7dtd/sandbox/decode", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ code }), + }); + values = dec.values || {}; + } catch (e) { toast((e && e.message) || "Invalid code", "error"); return; } + stage.querySelectorAll("select.sbopt").forEach(sel => { + const v = values[sel.dataset.sbopt]; + if (v != null && [...sel.options].some(o => o.value === String(v))) sel.value = String(v); + }); + await _recomputeSandboxCode(stage); + toast("Sandbox options updated from code", "ok"); +} + +function _renderLaunchPaneInner() { + const inst = allInstances.find(x => x.instance_id === cfgState.instanceID); + if (!inst) return `
    Instance not found
    `; + const cv = inst.config_values || {}; + const fields = cfgState.envFields; + // Fields may declare a `group` — consecutive runs of the same group get a + // heading + their own grid (house rule: >10 fields need visual grouping; + // soulmask ships 24). Ungrouped schemas render exactly as before. + const runs = []; + for (const f of fields) { + const g = f.group || ""; + if (!runs.length || runs[runs.length - 1].group !== g) runs.push({ group: g, fields: [] }); + runs[runs.length - 1].fields.push(f); + } + const renderField = (f) => _renderUnifiedField({ + scope: "env", + prop: f.prop, + label: f.label, + type: f.type, + options: f.options, + hint: f.hint, + placeholder: f.placeholder, + rows: f.rows, + wide: f.wide || f.type === "textarea", + wipe_paths: f.wipe_paths, + // Show defaults as ghost placeholders, NOT pre-filled values — + // operators see "what good values look like" without us writing + // anything to config_values they didn't ask for. + default: f.default, + rawValue: cv[f.prop] != null && cv[f.prop] !== "" ? cv[f.prop] : "", + }); + return ` +
    + Container environment variables. Saving these recreates the container — saves and configs are preserved (volumes are kept). Players currently connected will be briefly disconnected. +
    + ${runs.map(r => ` + ${r.group ? `
    ${esc(r.group)}
    ` : ""} +
    ${r.fields.map(renderField).join("")}
    + `).join("")} + `; +} + +function _renderPortsPaneInner() { + const inst = allInstances.find(x => x.instance_id === cfgState.instanceID); + const portsCurrent = (inst && inst.ports) || {}; + if (!cfgState.ports.length) return `
    No ports declared by this module.
    `; + const friendly = (p) => { + const n = (p.name || "").toLowerCase(); + if (n === "game") return "Game traffic"; + if (n === "raw") return "Raw / query"; + if (n === "rcon") return "RCON"; + if (n === "telnet") return "Telnet RCON"; + if (n === "battleye") return "BattlEye RCON"; + if (n === "reserved") return "Reserved"; + if (n === "query") return "Server query"; + if (n === "webadmin" || n === "web") return "Web admin"; + return p.name.charAt(0).toUpperCase() + p.name.slice(1); + }; + const cardsHTML = cfgState.ports.map(p => { + const cur = portsCurrent[p.name] != null && portsCurrent[p.name] !== "" ? portsCurrent[p.name] : (p.default != null ? p.default : ""); + const proto = (p.proto || "").toUpperCase(); + const protoColor = proto === "UDP" ? "var(--proto-udp,#a78bfa)" : "var(--proto-tcp,#60a5fa)"; + const id = `cfg-port-${p.name}`; + const flags = [ + p.internal ? `🔒 internal` : "", + p.required ? `required` : "", + ].filter(Boolean).join(" "); + return `
    +
    + + +
    +
    + +
    + ${flags ? `
    ${flags}
    ` : ""} +
    `; + }).join(""); + return ` +
    + Host-side port forwarded to the container. Editing any value triggers stop → recreate → start. Port collisions are caught client-side as you type. +
    +
    ${cardsHTML}
    + + `; +} + +function _wirePortsExtras(stage) { + const refreshDup = () => { + const inputs = Array.from(stage.querySelectorAll("[data-cfg-scope='port']")); + const seen = new Map(); + inputs.forEach(inp => { + const v = (inp.value || "").trim(); + if (!v) return; + const key = `${v}|${(inp.dataset.proto || "").toLowerCase()}`; + if (!seen.has(key)) seen.set(key, []); + seen.get(key).push(inp); + }); + let anyDup = false; + inputs.forEach(inp => inp.classList.remove("dup")); + for (const [, arr] of seen) { + if (arr.length > 1) { anyDup = true; arr.forEach(inp => inp.classList.add("dup")); } + } + const warn = stage.querySelector("#cfg-ports-warn"); + if (warn) warn.style.display = anyDup ? "" : "none"; + cfgState.portsHaveDupes = anyDup; + refreshCfgFooter(); + }; + stage.querySelectorAll("[data-cfg-scope='port']").forEach(inp => inp.addEventListener("input", refreshDup)); + refreshDup(); +} + +async function _loadIniFileAsync() { + const schema = cfgState.schema; + if (!schema) { cfgState.iniLoaded = true; return; } + let content = null, usedPath = null, lastErr = null; + for (const p of schema.filePaths) { + try { + const q = new URLSearchParams({ path: p }); + const r = await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/files/read?${q}`); + content = r.content; usedPath = p; break; + } catch (e) { lastErr = e; } + } + cfgState.iniLoaded = true; + if (content == null) { + const msg = (lastErr && lastErr.message) || ""; + const isMissing = /no such file|not found|not a directory|cannot access|could not find|does not exist/i.test(msg); + const inst = allInstances.find(x => x.instance_id === cfgState.instanceID); + const isRunning = inst && inst.status === "running"; + const stage = $("#cfg-pane-stage"); + if (stage) { + stage.innerHTML = isMissing + ? `
    +
    📝
    + Configuration hasn't been generated yet. + Most game servers write their config files on first boot. ${isRunning ? "It's running now — give it a minute and reload." : "Click ▶ Start, let it initialize, then reload."} +
    Expected at: ${schema.filePaths.map(esc).join(" or ")}
    +
    ` + : `
    Couldn't load config file${esc(msg || "unknown error")}
    `; + } + cfgState.iniParsed = null; + return; + } + let parsed; + if (schema.format === "xml7dtd") parsed = parseXML7DTD(content); + else if (schema.format === "ini_ark") parsed = parseINI(content); + else if (schema.format === "ue_ini") parsed = parseUEIni(content); + else if (schema.format === "ini_palworld") parsed = parsePalworldINI(content); + else if (schema.format === "yaml_empyrion") parsed = parseEmpyrionYaml(content); + else if (schema.format === "json") parsed = parseJSON(content); + else if (schema.format === "cfg_rust") parsed = parseCfgRust(content); + else if (schema.format === "cfg_dayz") parsed = parseCfgDayz(content); + else if (schema.format === "xml_attrs") parsed = parseXmlAttrs(content, schema); + else parsed = parseProperties(content); + if (schema.format === "ue_ini") parsed._schemaSections = ueSchemaSections(schema); + cfgState.iniParsed = parsed; + cfgState.iniPath = usedPath; +} + +function _renderIniPaneInner(gi) { + const g = cfgState.schema.groups[gi]; + const v = cfgState.iniParsed.values || {}; + const fieldsHTML = g.fields.map(f => _renderUnifiedField({ + scope: "ini", + prop: f.prop, + label: f.label, + type: f.type, + options: f.options, + hint: f.hint, + placeholder: f.placeholder, + rows: f.rows, + min: f.min, max: f.max, step: f.step, + wide: f.wide || f.type === "textarea" || f.type === "modlist", + default: f.default, + locked: f.locked, + rawValue: v[f.prop] != null ? v[f.prop] : "", + })).join(""); + const durableNote = cfgState.schema.durable + ? `
    💾 Durable — saving writes the panel's boot template too (no recreate, no disconnect). Changes apply on the next restart.
    ` + : ""; + return ` + ${durableNote} + ${g.description ? `
    ${g.description}
    ` : ""} +
    ${fieldsHTML}
    + `; +} + +function _renderUnifiedField(f) { + const id = `cfg-${f.scope}-${f.prop}`; + const cur = f.rawValue == null ? "" : String(f.rawValue); + // Bool: store normalized "true"/"false" so dirty-check works regardless + // of the source-file casing (ARK uses True/False, properties use true/false). + const origForData = (f.type === "bool") ? _cfgNormBool(cur) : cur; + // locked: panel-owned field (port allocator / RCON password) — rendered + // for visibility but disabled, so it can never go dirty. + const dataAttrs = `data-cfg-scope="${esc(f.scope)}" data-cfg-prop="${esc(f.prop)}" data-cfg-orig="${esc(origForData)}"${f.locked ? " disabled" : ""}`; + const wide = f.wide ? " wide" : ""; + + // Ghost placeholder: when the field is empty, show the schema default + // (or an explicit placeholder) in light italics so operators see + // "what good values look like" without us pre-filling them. + const placeholderText = (() => { + if (f.placeholder) return f.placeholder; + if (cur === "" && f.default != null && String(f.default).length) return String(f.default); + return ""; + })(); + const phAttr = placeholderText ? ` placeholder="${esc(placeholderText)}"` : ""; + + let ctrl = ""; + if (f.type === "bool") { + const isOn = origForData === "true"; + ctrl = ``; + } else if (f.type === "select" || f.type === "enum") { + const opts = (f.options || []).map(o => { + const [val, label] = Array.isArray(o) ? o : [o, o]; + return ``; + }).join(""); + ctrl = ``; + } else if (f.type === "number") { + const attrs = [ + f.min != null ? `min="${esc(f.min)}"` : "", + f.max != null ? `max="${esc(f.max)}"` : "", + f.step != null ? `step="${esc(f.step)}"` : "", + ].filter(Boolean).join(" "); + ctrl = ``; + } else if (f.type === "password") { + ctrl = ``; + } else if (f.type === "textarea") { + const rows = f.rows || 4; + ctrl = ``; + } else if (f.type === "modlist") { + const ids = cur.split(",").map(s => s.trim()).filter(Boolean); + const chips = ids.length ? `
    ${ids.map(x => `${esc(x)}`).join("")}
    ` : ""; + ctrl = `${chips}`; + } else { + ctrl = ``; + } + + // Wipe-data warn renders as its own alert pill below the hint — never + // inline with the hint text (which made the chip look like a misaligned + // floating circle next to the label). + const wipeWarn = (f.scope === "env" && Array.isArray(f.wipe_paths) && f.wipe_paths.length) + ? `
    Saving this may wipe related data — you'll get a checkbox in the Apply dialog
    ` + : ""; + const searchText = (f.label || "") + " " + (f.prop || "") + " " + (f.hint || ""); + return `
    +
    + + +
    +
    ${ctrl}
    + ${f.hint ? `
    ${f.hint}
    ` : ""} + ${wipeWarn} +
    `; +} + +function _wireFieldEvents(stage) { + // Stagger card entrance — each card gets `--i` matching its DOM order, + // capped so very long panes don't end up with multi-second tails. + stage.querySelectorAll(".cfg-grid").forEach(grid => { + const cards = grid.querySelectorAll(":scope > .cfg-card"); + cards.forEach((c, i) => c.style.setProperty("--i", Math.min(i, 24))); + }); + stage.querySelectorAll("[data-cfg-scope][data-cfg-prop]").forEach(el => { + if (!el.matches("input, select, textarea")) return; + const handler = () => onCfgFieldChange(el); + el.addEventListener("input", handler); + el.addEventListener("change", handler); + }); +} + +function _readCfgInputValue(el) { + if (el.type === "checkbox") return el.checked ? "true" : "false"; + return el.value; +} + +function _writeCfgInputValue(el, v) { + if (el.type === "checkbox") { + el.checked = (v === "true" || v === "1" || /^(true|1|yes|on)$/i.test(v || "")); + } else { + el.value = v; + } + // iOS-style toggle label flip + const tog = el.closest(".toggle"); + if (tog) { + const lab = tog.querySelector("[data-toggle-label]"); + if (lab) { + const on = el.checked; + lab.textContent = on ? "Enabled" : "Disabled"; + lab.classList.toggle("on", on); + } + } +} + +function onCfgFieldChange(el) { + const scope = el.dataset.cfgScope; + const prop = el.dataset.cfgProp; + if (!scope || !prop) return; + const orig = el.dataset.cfgOrig || ""; + const cur = _readCfgInputValue(el); + const card = el.closest(".cfg-card"); + const k = _cfgKey(scope, prop); + if (cur !== orig) { + cfgState.dirty.set(k, { scope, prop, oldVal: orig, newVal: cur }); + if (card) card.setAttribute("data-dirty", "1"); + } else { + cfgState.dirty.delete(k); + if (card) card.removeAttribute("data-dirty"); + } + // toggle label flip on checkbox change + if (el.type === "checkbox") { + const tog = el.closest(".toggle"); + if (tog) { + const lab = tog.querySelector("[data-toggle-label]"); + if (lab) { + const on = el.checked; + lab.textContent = on ? "Enabled" : "Disabled"; + lab.classList.toggle("on", on); + } + } + } + // modlist: re-render chips below the textarea on change + if (el.tagName === "TEXTAREA") { + const chips = el.parentElement && el.parentElement.querySelector(`[data-cfg-chips="${CSS.escape(prop)}"]`); + if (chips) { + const ids = (el.value || "").split(",").map(s => s.trim()).filter(Boolean); + chips.innerHTML = ids.map(x => `${esc(x)}`).join(""); + } + } + refreshCfgFooter(); +} + +function refreshCfgFooter() { + const status = $("#cfg-foot-status"); + const reset = $("#cfg-foot-reset"); + const apply = $("#cfg-foot-apply"); + if (!status || !reset || !apply) return; + const n = cfgState.dirty.size; + + // Per-rail dirty dots — set on the link whose pane owns each dirty field. + const dirtyPanes = new Set(); + for (const d of cfgState.dirty.values()) { + if (d.scope === "env") dirtyPanes.add("launch"); + else if (d.scope === "port") dirtyPanes.add("ports"); + else if (d.scope === "ini" && cfgState.schema) { + const gi = cfgState.schema.groups.findIndex(g => g.fields.some(f => f.prop === d.prop)); + if (gi >= 0) dirtyPanes.add("ini-" + gi); + } + } + document.querySelectorAll("[data-cfg-rail-dirty]").forEach(el => { + el.style.display = dirtyPanes.has(el.dataset.cfgRailDirty) ? "" : "none"; + }); + + if (n === 0) { + status.classList.remove("dirty"); + status.innerHTML = `No changes`; + reset.disabled = true; + apply.disabled = true; + apply.textContent = "Apply"; + return; + } + status.classList.add("dirty"); + const byScope = { env: 0, port: 0, ini: 0 }; + for (const d of cfgState.dirty.values()) byScope[d.scope] = (byScope[d.scope] || 0) + 1; + const bits = []; + if (byScope.env) bits.push(`${byScope.env} launch`); + if (byScope.port) bits.push(`${byScope.port} port${byScope.port === 1 ? "" : "s"}`); + if (byScope.ini) bits.push(`${byScope.ini} setting${byScope.ini === 1 ? "" : "s"}`); + status.innerHTML = `${n} change${n === 1 ? "" : "s"} pending (${bits.join(", ")})`; + reset.disabled = false; + apply.disabled = !!cfgState.portsHaveDupes; + apply.textContent = `Apply ${n} change${n === 1 ? "" : "s"}`; +} + +function resetAllCfg() { + document.querySelectorAll("[data-cfg-scope][data-cfg-prop]").forEach(el => { + if (!el.matches("input, select, textarea")) return; + const orig = el.dataset.cfgOrig || ""; + _writeCfgInputValue(el, orig); + onCfgFieldChange(el); + }); +} + +function filterCfgPane(query) { + const stage = $("#cfg-pane-stage"); + if (!stage) return; + const q = (query || "").trim().toLowerCase(); + stage.querySelectorAll(".cfg-card").forEach(card => { + if (!q) { card.removeAttribute("data-filter-hide"); return; } + const text = ((card.dataset.searchText || "") + " " + (card.textContent || "")).toLowerCase(); + if (!text.includes(q)) card.setAttribute("data-filter-hide", "1"); + else card.removeAttribute("data-filter-hide"); + }); +} + +async function applyAllCfg() { + const dirty = Array.from(cfgState.dirty.values()); + if (!dirty.length) return; + if (cfgState.portsHaveDupes) { toast("Port collision — fix duplicates first", "err"); return; } + + const apply = $("#cfg-foot-apply"); + const reset = $("#cfg-foot-reset"); + const status = $("#cfg-foot-status"); + const setProgress = (msg) => { if (status) status.innerHTML = `${esc(msg)}`; }; + + const envChanges = dirty.filter(d => d.scope === "env"); + const portChanges = dirty.filter(d => d.scope === "port"); + const iniChanges = dirty.filter(d => d.scope === "ini"); + + // Confirm env-config changes (with wipe-path opt-ins) up front, before touching anything. + let confirmEnv = null; + if (envChanges.length) { + const wipeOptions = []; + const seen = new Set(); + for (const d of envChanges) { + const f = cfgState.envFields.find(x => x.prop === d.prop); + if (f && Array.isArray(f.wipe_paths) && f.wipe_paths.length) { + const key = f.wipe_paths.join(","); + if (seen.has(key)) continue; seen.add(key); + wipeOptions.push({ + paths: f.wipe_paths, + label: f.wipe_label || `Wipe related data (${key})`, + hint: f.wipe_hint || "", + fromProp: d.prop, + }); + } + } + confirmEnv = await openEnvConfigConfirmBatch({ + changes: envChanges.map(d => { + const f = cfgState.envFields.find(x => x.prop === d.prop); + return { prop: d.prop, oldVal: d.oldVal, newVal: d.newVal, label: (f && f.label) || d.prop }; + }), + wipeOptions, + }); + if (!confirmEnv) return; + } + + apply.disabled = true; + reset.disabled = true; + + try { + // 1. INI write — fast, no recreate. + if (iniChanges.length) { + const sch = cfgState.schema; + + // 1a. Durable modules (7dtd): the entrypoint re-stamps the live file + // from the panel-rendered boot template on EVERY boot, so a live-file + // write alone silently reverts on the next restart. Push the change + // through /config-render FIRST — it merges into config_values and + // re-renders the boot template on the agent (NO recreate; applies on + // the next restart). We pin EVERY template-mapped field at its + // current live-file value (what the editor shows), overlaid with the + // operator's edits: that reconciles any drift between config_values + // and the live file, so a re-render can never revert settings a + // long-lived server (Season 10!) was already running with. + if (sch && sch.durable) { + setProgress("Saving to boot template…"); + const tmplUpdates = {}; + const live = (cfgState.iniParsed && cfgState.iniParsed.values) || {}; + for (const g of sch.groups) for (const f of g.fields) { + if (!f.tmplKey || f.locked) continue; + let val = live[f.prop]; + if (val == null || val === "") continue; + if (f.type === "bool") val = _cfgNormBool(val); + tmplUpdates[f.tmplKey] = String(val); + } + for (const d of iniChanges) { + let f = null; + for (const g of sch.groups) { f = g.fields.find(x => x.prop === d.prop) || f; } + if (f && f.tmplKey) tmplUpdates[f.tmplKey] = d.newVal; // "" clears → template default + } + if (Object.keys(tmplUpdates).length) { + await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/config-render`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ updates: tmplUpdates }), + }); + } + } + + setProgress(`Writing ${cfgState.iniPath || cfgState.schema.file}…`); + const updates = {}; + const isArkIni = cfgState.schema && (cfgState.schema.format === "ini_ark" || cfgState.schema.format === "ue_ini"); + for (const d of iniChanges) { + let v = d.newVal; + if (isArkIni && (v === "true" || v === "false")) v = v === "true" ? "True" : "False"; + updates[d.prop] = v; + } + let newContent; + if (sch.format === "xml7dtd") newContent = serializeXML7DTD(cfgState.iniParsed, updates); + else if (sch.format === "ini_ark") newContent = serializeINI(cfgState.iniParsed, updates); + else if (sch.format === "ue_ini") newContent = serializeUEIni(cfgState.iniParsed, updates); + else if (sch.format === "ini_palworld") newContent = serializePalworldINI(cfgState.iniParsed, updates); + else if (sch.format === "yaml_empyrion") newContent = serializeEmpyrionYaml(cfgState.iniParsed, updates); + else if (sch.format === "json") newContent = serializeJSON(cfgState.iniParsed, updates); + else if (sch.format === "cfg_rust") newContent = serializeCfgRust(cfgState.iniParsed, updates); + else if (sch.format === "cfg_dayz") newContent = serializeCfgDayz(cfgState.iniParsed, updates); + else if (sch.format === "xml_attrs") newContent = serializeXmlAttrs(cfgState.iniParsed, updates); + else newContent = serializeProperties(cfgState.iniParsed, updates); + await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/files`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ path: cfgState.iniPath, content: newContent }), + }); + } + + // 2. Ports — recreates container. + if (portChanges.length) { + setProgress(`Recreating with new ports…`); + const entries = []; + for (const p of cfgState.ports) { + const el = document.querySelector(`[data-port="${CSS.escape(p.name)}"]`); + if (!el) continue; + const v = parseInt(el.value, 10); + if (!Number.isFinite(v) || v < 1 || v > 65535) throw new Error(`Invalid port for ${p.name}`); + entries.push({ + name: p.name, + proto: p.proto || "tcp", + container_port: Number(p.default) || v, + host_port: v, + internal: !!p.internal, + }); + } + await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/ports`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ ports: entries }), + }); + } + + // 3. Env-config — recreates container (after ports so the new ports are + // already in the DB row; backend env-config recreate uses row.AssignedPorts). + if (envChanges.length) { + setProgress(`Recreating with new launch settings…`); + const updates = {}; + for (const d of envChanges) updates[d.prop] = d.newVal; + if (confirmEnv && confirmEnv.wipePaths && confirmEnv.wipePaths.length) { + updates["_PANEL_WIPE_PATHS"] = confirmEnv.wipePaths.join(","); + } + await fetchJSON(`/api/instances/${encodeURIComponent(cfgState.instanceID)}/env-config`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ updates }), + }); + } + + if (iniChanges.length && cfgState.schema && cfgState.schema.durable && !envChanges.length && !portChanges.length) { + toast(`${dirty.length} setting${dirty.length === 1 ? "" : "s"} saved — applies on next restart, no recreate needed`, "ok"); + } else { + toast(`${dirty.length} change${dirty.length === 1 ? "" : "s"} applied`, "ok"); + } + setProgress("Reloading…"); + cfgState.dirty.clear(); + pollInstances(); + loadInstanceConfig(); + } catch (err) { + toast(`Apply failed: ${err.message}`, "err"); + apply.disabled = false; + reset.disabled = false; + refreshCfgFooter(); + } +} + +$("#im-cfg-reload").addEventListener("click", loadInstanceConfig); + +// ---------------- PLAYERS tab ---------------- +function loadInstancePlayers() { + const id = instanceModalState.id; if (!id) return; + refreshInstancePlayers(); + if (instanceModalState.playersTimer) clearInterval(instanceModalState.playersTimer); + instanceModalState.playersTimer = setInterval(refreshInstancePlayers, 5000); +} +async function refreshInstancePlayers() { + const id = instanceModalState.id; const wrap = $("#im-p-wrap"); if (!id || !wrap) return; + const i = allInstances.find(x => x.instance_id === id); + const appSt = latestAppState[id] || {}; + const running = i && i.status === "running"; + + // Headline card: count + cap. + const countText = appSt.players_online !== undefined + ? `${appSt.players_online} / ${appSt.players_max}` + : running ? `no data yet — RCON may still be connecting` : `server is stopped`; + + if (!running) { + wrap.innerHTML = `
    ${EMPTY_ICONS.users}
    Server isn't runningStart the server to see connected players
    `; + return; + } + + // First paint: show a loading state immediately. The roster RCON call + // below can take seconds (or hang) — without this the tab sits BLANK + // until the await resolves, which reads as a broken feature. + if (!wrap.firstElementChild) wrap.innerHTML = loadingState("Fetching roster…"); + + // Empyrion has no in-game RCON player-list command — its player roster + // comes from the panel-empyrion-bridge sidecar instead, with full + // metadata + per-row admin actions (give item, set role, +1k credits, + // teleport, etc). Render that here so the regular Players tab is the + // single source of truth — no need to hop into the Empyrion-tools tab + // just to see who's online or kick someone. + if (i && i.module_id === "empyrion") { + return renderEAHPlayers(wrap); + } + + // Pull the live list via RCON "lp" (7DTD) / "list" (Minecraft) etc. + const listCmd = playerListCommandFor(i.module_id); + let listErr = "", listText = ""; + if (listCmd) { + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/rcon`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ command: listCmd }), + // A wedged RCON tracker must not hang the tab forever. + signal: AbortSignal.timeout(8000), + }); + const data = await r.json(); + if (r.ok && !data.error) listText = data.output || ""; + else listErr = data.error || data.message || `HTTP ${r.status}`; + } catch (e) { listErr = e.name === "TimeoutError" ? "RCON timed out after 8s — the server may still be starting, or its telnet/RCON link is wedged." : e.message; } + } else { + listErr = "No list-players command wired up for this module yet."; + } + const parsed = parsePlayerList(i.module_id, listText); + + // App-state count when the tracker has one; otherwise the parsed roster + // itself is the source of truth (7dtd's tracker count can lag or be + // absent right after a controller restart). + const online = appSt.players_online !== undefined ? appSt.players_online : (parsed.length || undefined); + const cap = appSt.players_max; + const pct = (online !== undefined && cap) ? Math.min(100, Math.round((online / cap) * 100)) : null; + // Deterministic accent per player name so avatars are visually distinct. + const hueFor = (s) => { let h = 0; for (const c of (s||"")) h = (h*31 + c.charCodeAt(0)) >>> 0; return h % 360; }; + const initial = (s) => (s||"?").trim().charAt(0).toUpperCase() || "?"; + const canKick = i && i.module_id === "7dtd"; // RCON kick wired for 7DTD today + + wrap.innerHTML = ` +
    +
    + ${online !== undefined ? online : "—"} + ${cap ? "/ " + cap : ""} + players online +
    + ${pct !== null ? `
    ${pct}% full
    ` : `
    ${online === undefined ? "no data yet — RCON may still be connecting" : ""}
    `} +
    +
    + ${listErr && !parsed.length + ? `
    ${EMPTY_ICONS.antenna}
    Couldn't read the roster${esc(listErr)}
    ` + : parsed.length + ? parsed.map(p => ` +
    + ${esc(initial(p.name))} +
    +
    ${esc(p.name || "—")}
    +
    ${p.id ? `${esc(p.id)}` : ""}${p.extra ? `${esc(p.extra)}` : ""}
    +
    + ${canKick && p.name ? `` : ""} +
    `).join("") + : `
    ${EMPTY_ICONS.moon}
    No players connectedThe roster updates every 5s while this tab is open.
    `} +
    + ${listText && !parsed.length ? `
    RCON raw output +
    ${esc(listText)}
    ` : ""} + `; +} +$("#im-p-refresh").addEventListener("click", refreshInstancePlayers); + +// Kick a player from the roster (7DTD today). Confirms, fires the RCON +// kick, then refreshes the list. Delegated since rows are re-rendered. +$("#im-p-wrap").addEventListener("click", async (e) => { + const btn = e.target.closest("[data-kick-player]"); + if (!btn) return; + const name = btn.dataset.kickPlayer; + const id = instanceModalState.id; if (!id) return; + const ok = await (typeof themedConfirm === "function" + ? themedConfirm({ title: "Kick player", body: `Kick "${name}" from the server?`, confirmLabel: "Kick", danger: true }) + : Promise.resolve(confirm(`Kick "${name}"?`))); + if (!ok) return; + btn.disabled = true; btn.textContent = "Kicking…"; + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/rcon`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ command: `kick "${name}" "Kicked by an admin"` }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok || data.error) throw new Error(data.error || data.message || `HTTP ${r.status}`); + toast(`Kicked ${name}`, "ok"); + setTimeout(refreshInstancePlayers, 600); + } catch (err) { + toast("Kick failed: " + err.message, "err"); + btn.disabled = false; btn.textContent = "Kick"; + } +}); + +function playerListCommandFor(moduleID) { + return { + "7dtd": "lp", + "minecraft-java": "list", + "valheim": "", // no in-game RCON list + "rust": "players", + "ark": "listplayers", + "ark-sa": "listplayers", + "terraria": "playing", + }[moduleID] || ""; +} +// Best-effort parser — returns array of {name,id,extra}. Works for the +// common games; falls through to an empty array (UI shows raw text). +function parsePlayerList(moduleID, text) { + if (!text) return []; + const out = []; + if (moduleID === "7dtd") { + // Old format: "1. id=171, name=Pete, pos=(...), ..., steamid=7656..." + // V2/V3 format: "0. id=401303, Strongheart, pos=(...), ..., level=31, + // pltfmid=Steam_7656..., ..., ping=47" — bare name, no + // name= prefix, steamid moved into pltfmid=Steam_. + const lineRe = /^\s*\d+\.\s+id=(\d+),\s+(?:name=)?([^,]+),/; + for (const line of text.split(/\r?\n/)) { + const m = line.match(lineRe); + if (!m) continue; + const steam = (line.match(/(?:steamid=|pltfmid=Steam_)(\d+)/) || [])[1]; + const level = (line.match(/\blevel=(\d+)/) || [])[1]; + const ping = (line.match(/\bping=(\d+)/) || [])[1]; + const extra = [level ? `lvl ${level}` : "", ping ? `${ping}ms` : ""].filter(Boolean).join(" · "); + out.push({ name: m[2].trim(), id: steam || m[1], extra }); + } + } else if (moduleID === "minecraft-java") { + // "There are 2 of a max of 20 players online: Alice, Bob" + const m = text.match(/players online:\s*(.+)$/mi); + if (m) { for (const n of m[1].split(",").map(s => s.trim()).filter(Boolean)) out.push({ name: n, id: "", extra: "" }); } + } else if (moduleID === "ark" || moduleID === "ark-sa") { + // "0. Pete, 76561198...." + const re = /^\s*\d+\.\s+(.+?),\s*(\d+)\s*$/gm; + let m; while ((m = re.exec(text)) != null) out.push({ name: m[1].trim(), id: m[2], extra: "" }); + } else if (moduleID === "rust") { + // rust "players" has a header + "Pete : 192.168... : 0s : healthy" + for (const line of text.split(/\r?\n/)) { + const mm = line.match(/^(\S.+?)\s*:\s*([\d.:a-f]+)/); + if (mm) out.push({ name: mm[1].trim(), id: mm[2], extra: "" }); + } + } else if (moduleID === "palworld") { + // CSV with header: "name,playeruid,steamid" then one row per player. + for (const line of text.split(/\r?\n/).slice(1)) { + const cols = line.split(","); + if (cols.length >= 3 && cols[0].trim()) { + out.push({ name: cols[0].trim(), id: cols[2].trim(), extra: "uid:" + cols[1].trim() }); + } + } + } else if (moduleID === "rust") { + // Rust's `playerlist` RCON returns a JSON array. Empty server → "[]". + // Populated → [{"SteamID":"76561...","OwnerSteamID":"76561...", + // "DisplayName":"Player","Ping":42,"Address":"ip:port", + // "ConnectedSeconds":123, ...}, ...] + try { + const arr = JSON.parse(text); + if (Array.isArray(arr)) { + for (const p of arr) { + if (!p || typeof p !== "object") continue; + out.push({ + name: p.DisplayName || p.UserName || p.Name || "unknown", + id: p.SteamID || p.UserID || "", + extra: p.Ping != null ? (p.Ping + "ms") : "", + }); + } + } + } catch (_) { /* fallthrough — raw text shown */ } + } else if (moduleID === "conan-exiles") { + // Conan's `listplayers` prints a pipe-separated table with columns like + // Idx | Char name | Player name | User ID | Platform ID | Platform Type | Server | AFK + // Skip lines that look like the header or dividers; keep data rows. + for (const line of text.split(/\r?\n/)) { + if (!line.includes("|")) continue; + const cols = line.split("|").map(s => s.trim()); + if (cols.length < 5) continue; + if (/^idx$/i.test(cols[0]) || /^-+$/.test(cols[0])) continue; // header / divider + if (!/^\d+$/.test(cols[0])) continue; // non-numeric first col → skip + out.push({ name: cols[1] || cols[2], id: cols[4] || cols[3], extra: cols[5] || "" }); + } + } + return out; +} + +async function loadConfigForm(instanceID, schema, wrap) { + wrap.innerHTML = `
    Reading ${esc(schema.file)}…
    `; + // Try the declared file paths in order. Files API is relative to the + // instance's browseable_root so one of them should hit. + let content = null, usedPath = null, lastErr = null; + for (const p of schema.filePaths) { + try { + const q = new URLSearchParams({ path: p }); + const r = await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/files/read?${q}`); + content = r.content; usedPath = p; break; + } catch (e) { lastErr = e; } + } + if (content == null) { + // Most modules generate their config on first boot (ARK writes + // GameUserSettings.ini only after the game initializes). If the + // underlying agent error looks like "not found" we show a friendly + // "boot me first" state rather than a scary error. + const msg = (lastErr && lastErr.message) || ""; + // Normalise across different underlying readers: + // - GNU `ls` says "cannot access", "No such file or directory" + // - `docker cp` says "Could not find the file" / "path does not exist" + // - Our FsRead fallback says "not a directory" / "not found" + const isMissing = /no such file|not found|not a directory|cannot access|could not find|does not exist/i.test(msg); + const i = allInstances.find(x => x.instance_id === instanceID); + const isRunning = i && i.status === "running"; + if (isMissing) { + wrap.innerHTML = `
    +
    📝
    + Configuration hasn't been generated yet. + Most game servers write their config files on first boot. Start the server once, let it initialize (${isRunning ? "it's running now — may take a minute" : "click ▶ Start"}), then come back here. +
    Expected at: ${schema.filePaths.map(esc).join(" or ")}
    +
    `; + } else { + wrap.innerHTML = `
    Couldn't load config file.${esc(msg || "unknown error")}. Tried: ${schema.filePaths.map(esc).join(", ")}
    `; + } + return; + } + // Parse the file into a flat {key: value} map. Parsers preserve the raw + // text so Save writes back only the fields we touched. + let parsed; + if (schema.format === "xml7dtd") parsed = parseXML7DTD(content); + else if (schema.format === "ini_ark") parsed = parseINI(content); + else if (schema.format === "ue_ini") parsed = parseUEIni(content); + else if (schema.format === "ini_palworld") parsed = parsePalworldINI(content); + else if (schema.format === "yaml_empyrion") parsed = parseEmpyrionYaml(content); + else if (schema.format === "json") parsed = parseJSON(content); + else if (schema.format === "cfg_rust") parsed = parseCfgRust(content); + else if (schema.format === "cfg_dayz") parsed = parseCfgDayz(content); + else if (schema.format === "xml_attrs") parsed = parseXmlAttrs(content, schema); + else parsed = parseProperties(content); + if (schema.format === "ue_ini") parsed._schemaSections = ueSchemaSections(schema); + renderConfigForm(instanceID, schema, usedPath, parsed, wrap); +} + +function renderConfigForm(instanceID, schema, filePath, parsed, wrap) { + // Unknown-field list — anything in the file not in our schema. + const known = new Set(); + for (const g of schema.groups) for (const f of g.fields) known.add(f.prop); + const extras = Object.keys(parsed.values).filter(k => !known.has(k)).sort(); + + // Each schema group becomes a sub-tab at the top of the Config tab. + // Fits 10+ grouped sections without a mile of scrolling. + const navHTML = schema.groups.map((g, gi) => + `` + ).join(""); + + const panesHTML = schema.groups.map((g, gi) => ` +
    + ${g.description ? `
    ${g.description}
    ` : ""} +
    + ${g.fields.map(f => renderConfigField(f, parsed.values[f.prop])).join("")} +
    +
    `).join(""); + + wrap.innerHTML = ` +
    Editing ${esc(filePath)}. Unknown keys are preserved verbatim.
    + +
    ${panesHTML}
    + ${extras.length ? `
    Show ${extras.length} other keys in the file (not exposed as form fields — saved verbatim) +
    ${esc(extras.map(k => k + " = " + parsed.values[k]).join("\n"))}
    +
    ` : ""} +
    + + Changes take effect on next server start. +
    `; + + // Sub-tab switching. + $("#im-cfg-nav").addEventListener("click", e => { + const t = e.target.closest(".cfg-tab"); if (!t) return; + const gi = t.dataset.cfgGroup; + $$("#im-cfg-nav .cfg-tab").forEach(x => x.classList.toggle("active", x === t)); + $$("#im-cfg-panes .im-cfg-pane").forEach(p => p.classList.toggle("active", p.dataset.cfgPane === gi)); + }); + + $("#im-cfg-save").addEventListener("click", async () => { + const updates = {}; + for (const g of schema.groups) for (const f of g.fields) { + const el = document.getElementById("cfg_" + f.prop); + if (!el) continue; + let v; + if (f.type === "bool") { + // ARK + UE INI use True/False; most other configs use true/false; + // XML7DTD uses true/false. Capitalize based on format. + v = (schema.format === "ini_ark" || schema.format === "ue_ini") + ? (el.checked ? "True" : "False") + : (el.checked ? "true" : "false"); + } else v = el.value; + updates[f.prop] = v; + } + let newContent; + if (schema.format === "xml7dtd") newContent = serializeXML7DTD(parsed, updates); + else if (schema.format === "ini_ark") newContent = serializeINI(parsed, updates); + else if (schema.format === "ue_ini") newContent = serializeUEIni(parsed, updates); + else if (schema.format === "ini_palworld") newContent = serializePalworldINI(parsed, updates); + else if (schema.format === "yaml_empyrion") newContent = serializeEmpyrionYaml(parsed, updates); + else if (schema.format === "json") newContent = serializeJSON(parsed, updates); + else if (schema.format === "cfg_rust") newContent = serializeCfgRust(parsed, updates); + else if (schema.format === "cfg_dayz") newContent = serializeCfgDayz(parsed, updates); + else if (schema.format === "xml_attrs") newContent = serializeXmlAttrs(parsed, updates); + else newContent = serializeProperties(parsed, updates); + try { + await fetchJSON(`/api/instances/${encodeURIComponent(instanceID)}/files`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ path: filePath, content: newContent }), + }); + toast("Config saved — restart the server to apply", "ok"); + } catch (err) { toast("Save failed: " + err.message, "err"); } + }); +} + +function renderConfigField(f, rawVal) { + const id = "cfg_" + f.prop; + const cur = rawVal == null ? "" : String(rawVal); + const hint = f.hint ? `
    ${esc(f.hint)}
    ` : ""; + let ctrl = ""; + if (f.type === "bool") { + const isOn = /^(true|1|yes|on)$/i.test(cur); + ctrl = ``; + } else if (f.type === "number") { + const attrs = [`min="${f.min ?? ""}"`, `max="${f.max ?? ""}"`, f.step ? `step="${f.step}"` : ""].filter(Boolean).join(" "); + ctrl = ``; + } else if (f.type === "enum") { + const opts = f.options.map(o => { + const [val, label] = Array.isArray(o) ? o : [o, o]; + return ``; + }).join(""); + ctrl = ``; + } else if (f.type === "password") { + ctrl = ``; + } else if (f.type === "textarea") { + const rows = f.rows || 4; + ctrl = ``; + } else if (f.type === "modlist") { + // Mod IDs — comma-separated list. Render as a textarea for easy editing + // with chips below showing the parsed entries for visual feedback. + const ids = cur.split(",").map(s => s.trim()).filter(Boolean); + const chips = ids.length + ? `
    ${ids.map(id => `${esc(id)}`).join("")}
    ` + : ""; + ctrl = `${chips}`; + } else { + ctrl = ``; + } + const wide = (f.type === "textarea" || f.type === "modlist" || f.wide) ? " wide" : ""; + // Friendly name on its own line, mono config-key on the line below. + // CSS handles the truncation; `title=` on the key span lets a hover + // tooltip show the full key for the rare cases where it doesn't fit + // even the column width. Keeps "AdminPasswordsForServerSettings" from + // shoving the input off-screen. + return `
    + + ${ctrl} + ${hint} +
    `; +} + +// ---- parsers: properties (MC) ---- +// ---- parsers: generic JSON with dotted-path flattening ---- +// Flattens nested objects so the schema can target e.g. +// "ServerDescription_Persistent.InviteCode". Arrays and primitives land as +// leaves; unknown keys in the file are preserved on save because we +// structuredClone the original tree and only overwrite the paths in +// `updates`. Output is tab-indented to match the style Windrose itself +// writes ServerDescription.json with. +function parseJSON(text) { + let root = {}; + try { root = JSON.parse(text); } catch { /* tolerate empty/garbage, form just shows blanks */ } + const values = {}; + const walk = (node, prefix) => { + if (node === null || node === undefined) { if (prefix) values[prefix] = ""; return; } + if (Array.isArray(node) || typeof node !== "object") { + values[prefix] = Array.isArray(node) ? JSON.stringify(node) : String(node); + return; + } + for (const k of Object.keys(node)) walk(node[k], prefix ? prefix + "." + k : k); + }; + walk(root, ""); + return { root, values }; +} +function serializeJSON(parsed, updates) { + // structuredClone so we keep the original as a reference for type coercion. + const out = structuredClone(parsed.root ?? {}); + for (const [dotted, rawVal] of Object.entries(updates)) { + const path = dotted.split("."); + let node = out; + let ref = parsed.root ?? {}; + for (let i = 0; i < path.length - 1; i++) { + const k = path[i]; + if (typeof node[k] !== "object" || node[k] === null || Array.isArray(node[k])) node[k] = {}; + node = node[k]; + ref = (ref && typeof ref === "object") ? ref[k] : undefined; + } + const leaf = path[path.length - 1]; + const prior = ref && typeof ref === "object" ? ref[leaf] : undefined; + // Preserve the original JSON type where possible — the Windrose client + // parses MaxPlayerCount etc. as numbers and refuses string-typed ones. + if (typeof prior === "number") { + const n = Number(rawVal); + node[leaf] = Number.isFinite(n) ? n : prior; + } else if (typeof prior === "boolean") { + node[leaf] = /^(true|1|yes|on)$/i.test(String(rawVal)); + } else if (Array.isArray(prior)) { + // Array-typed keys (Factorio's tags) are shown as their JSON text — + // parse the edit back into a real array; a plain comma list is + // accepted as a fallback so operators can type `a, b` naturally. + try { + const arr = JSON.parse(rawVal); + node[leaf] = Array.isArray(arr) ? arr : prior; + } catch { + node[leaf] = String(rawVal).split(",").map(s => s.trim()).filter(Boolean); + } + } else if (prior === undefined) { + // Brand-new key (e.g. injecting gameSettings.* into enshrouded's + // minimal seeded config): no prior type to mirror, so infer from the + // value — game servers parse "1.5"-as-string / "true"-as-string as + // invalid. Non-numeric strings (enums, names) pass through verbatim. + if (/^(true|false)$/i.test(String(rawVal))) node[leaf] = /^true$/i.test(String(rawVal)); + else if (String(rawVal).trim() !== "" && Number.isFinite(Number(rawVal))) node[leaf] = Number(rawVal); + else node[leaf] = rawVal; + } else { + node[leaf] = rawVal; + } + } + // Tab indent + trailing newline matches Windrose's own writer. + return JSON.stringify(out, null, "\t") + "\n"; +} + +function parseProperties(text) { + const lines = text.split(/\r?\n/); + const values = {}, order = []; + lines.forEach((raw, idx) => { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("!")) return; + const eq = raw.indexOf("="); + if (eq <= 0) return; + const k = raw.slice(0, eq).trim(); + const v = raw.slice(eq + 1); + values[k] = v; + order.push(k); + }); + return { lines, values, order }; +} +function serializeProperties(parsed, updates) { + const seen = new Set(); + const out = parsed.lines.map(raw => { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("!")) return raw; + const eq = raw.indexOf("="); + if (eq <= 0) return raw; + const k = raw.slice(0, eq).trim(); + if (updates.hasOwnProperty(k)) { seen.add(k); return `${k}=${updates[k]}`; } + return raw; + }); + for (const k of Object.keys(updates)) if (!seen.has(k)) out.push(`${k}=${updates[k]}`); + return out.join("\n"); +} + +// ---- parsers: 7DTD serverconfig.xml ---- +// 7DTD uses — a flat list inside . +// We keep the original text so Save patches only the lines we need. +function parseXML7DTD(text) { + const lines = text.split(/\r?\n/); + const values = {}; + // Non-greedy match on the two attributes in either order. + const re = //; + const reRev = //; + lines.forEach(raw => { + let m = re.exec(raw); + if (m) { values[m[1]] = m[2]; return; } + m = reRev.exec(raw); + if (m) { values[m[2]] = m[1]; } + }); + return { lines, values, text }; +} +function serializeXML7DTD(parsed, updates) { + const seen = new Set(); + const out = parsed.lines.map(raw => { + const re = //; + const m = re.exec(raw); + if (!m) return raw; + const k = m[1]; + if (!updates.hasOwnProperty(k)) return raw; + seen.add(k); + const newVal = String(updates[k]).replace(/"/g, """); + return raw.replace(re, ``); + }); + // New keys that weren't in the file — insert before . + const extras = Object.keys(updates).filter(k => !seen.has(k)); + if (extras.length) { + for (let i = out.length - 1; i >= 0; i--) { + if (/<\/ServerSettings>/.test(out[i])) { + const insert = extras.map(k => + ` ` + ); + out.splice(i, 0, ...insert); + break; + } + } + } + return out.join("\n"); +} + +// ---- parsers: dedicated.yaml (Empyrion) ---- +// Empyrion's config is YAML but with a fixed two-level shape: +// SectionName: +// Key: Value +// # Key2: DefaultValue # optional commented-out defaults +// Key3: "Quoted Value" +// We flatten to a keyed map so the form can show bare keys (they're +// unique across ServerConfig + GameConfig). Commented defaults are +// surfaced as values too so the form renders them editable; on save we +// UNCOMMENT by replacing the # line with the new active setting. No new +// keys are introduced — Empyrion rejects unknown ones and aborts startup. +function parseEmpyrionYaml(text) { + const lines = text.split(/\r?\n/); + const values = {}; + const commented = {}; // key → { lineIdx, indent, defaultVal } for "# Key: X" lines + const activeRe = /^(\s+)([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/; + const commentedRe = /^(\s+)#\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/; + const unquote = (v) => { + if ((v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'"))) return v.slice(1, -1); + return v; + }; + lines.forEach((raw, idx) => { + // Try active setting first — beats the commented-default regex. + let m = activeRe.exec(raw); + if (m) { + let v = m[3]; + if (v === "" || v.startsWith("#")) return; // nested block / trailing-comment-only + values[m[2]] = unquote(v); + return; + } + m = commentedRe.exec(raw); + if (m) { + let v = m[3]; + // Strip trailing inline explanatory comment like " # Only use for public servers". + const hashIdx = v.indexOf(" #"); + if (hashIdx >= 0) v = v.slice(0, hashIdx).trim(); + v = unquote(v); + commented[m[2]] = { lineIdx: idx, indent: m[1], defaultVal: v }; + // Surface the default to the form so the field renders pre-filled + // (but DON'T shadow an active setting earlier in the file). + if (!values.hasOwnProperty(m[2])) values[m[2]] = v; + } + }); + return { lines, values, commented }; +} +function serializeEmpyrionYaml(parsed, updates) { + const activeRe = /^(\s+)([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*$/; + const seen = new Set(); + const formatVal = (newV, hint) => { + newV = String(newV); + const hadDouble = hint && hint.startsWith('"'); + const hadSingle = hint && hint.startsWith("'"); + const needs = newV === "" || /[\s:#]/.test(newV); + if (hadDouble) return `"${newV.replace(/"/g, '\\"')}"`; + if (hadSingle) return `'${newV.replace(/'/g, "''")}'`; + if (needs) return `"${newV.replace(/"/g, '\\"')}"`; + return newV; + }; + const out = parsed.lines.map(raw => { + const m = activeRe.exec(raw); + if (!m) return raw; + const indent = m[1], k = m[2], oldV = m[3]; + if (!updates.hasOwnProperty(k)) return raw; + seen.add(k); + return `${indent}${k}: ${formatVal(updates[k], oldV)}`; + }); + // For keys the operator set that only exist as "# Key: X" comments, + // uncomment in place with the new value. + for (const k of Object.keys(updates)) { + if (seen.has(k)) continue; + const c = parsed.commented && parsed.commented[k]; + if (!c) continue; + out[c.lineIdx] = `${c.indent}${k}: ${formatVal(updates[k], c.defaultVal)}`; + seen.add(k); + } + return out.join("\n"); +} + +// ---- parsers: INI (ARK GameUserSettings) ---- +// Values are scanned across all sections (ARK puts most gameplay keys under +// [ServerSettings]). On write we patch in place; unknown keys in any section +// are preserved verbatim. +function parseINI(text) { + const lines = text.split(/\r?\n/); + const values = {}; + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith(";") || line.startsWith("#") || line.startsWith("[")) continue; + const eq = raw.indexOf("="); + if (eq <= 0) continue; + const k = raw.slice(0, eq).trim(); + const v = raw.slice(eq + 1).trim(); + // Last occurrence wins (typical INI semantics). + values[k] = v; + } + return { lines, values }; +} +function serializeINI(parsed, updates) { + // Track which lines belong to which section so we can: + // (a) detect a key that already exists in the file but in the WRONG + // section (e.g. ServerPVE stranded under [KsM] because a prior + // buggy save appended it after the last header), and + // (b) insert truly-new keys INSIDE [ServerSettings] instead of at + // end-of-file (where they'd get scoped to whatever section was + // last). For ARK that's load-bearing — `ServerPVE` outside + // `[ServerSettings]` is silently ignored by the game. + const seen = new Set(); + let curSection = ""; + // First pass: replace in-place when the key is in [ServerSettings]. + // Drop existing matches that live in another section (we'll rewrite + // them under [ServerSettings] in the append step) so a one-time + // stale-section line gets healed. + const out = []; + for (const raw of parsed.lines) { + const line = raw.trim(); + if (line.startsWith("[") && line.endsWith("]")) { + curSection = line; + out.push(raw); + continue; + } + if (!line || line.startsWith(";") || line.startsWith("#")) { out.push(raw); continue; } + const eq = raw.indexOf("="); + if (eq <= 0) { out.push(raw); continue; } + const k = raw.slice(0, eq).trim(); + if (!updates.hasOwnProperty(k)) { out.push(raw); continue; } + if (curSection === "[ServerSettings]") { + seen.add(k); + out.push(`${k}=${updates[k]}`); + } else { + // Stranded copy in the wrong section — drop it; the append step + // below will write the canonical copy under [ServerSettings]. + } + } + const fresh = Object.keys(updates).filter(k => !seen.has(k)); + if (fresh.length) { + // Insert immediately AFTER the [ServerSettings] header so the new + // keys end up scoped correctly. If the section doesn't exist, append + // it at the bottom with the new keys. + const hdrIdx = out.findIndex(l => /^\s*\[ServerSettings\]\s*$/.test(l)); + if (hdrIdx >= 0) { + out.splice(hdrIdx + 1, 0, ...fresh.map(k => `${k}=${updates[k]}`)); + } else { + out.push("", "[ServerSettings]"); + for (const k of fresh) out.push(`${k}=${updates[k]}`); + } + } + return out.join("\n"); +} + +// --- Section-aware UE INI (format: "ue_ini") -------------------------------- +// VEIN's Game.ini spans multiple [/Script/...] sections; the ini_ark +// serializer above hardcodes a single [ServerSettings] and would corrupt it. +// ue_ini keys each value by its real section. parseUEIni returns {lines, +// values} (values keyed by bare key so the form finds them) + a _sectionByKey +// map; the schema attaches _schemaSections so brand-new keys land in the right +// place. +function parseUEIni(text) { + const lines = text.split(/\r?\n/); + const values = {}; + const sectionByKey = {}; + let cur = ""; + for (const raw of lines) { + const line = raw.trim(); + if (line.startsWith("[") && line.endsWith("]")) { cur = line; continue; } + if (!line || line.startsWith(";") || line.startsWith("#")) continue; + const eq = raw.indexOf("="); + if (eq <= 0) continue; + const k = raw.slice(0, eq).trim(); + const v = raw.slice(eq + 1).trim(); + values[k] = v; + sectionByKey[k] = cur; + } + return { lines, values, _sectionByKey: sectionByKey }; +} +function serializeUEIni(parsed, updates) { + const sectionByKey = parsed._sectionByKey || {}; + const schemaSections = parsed._schemaSections || {}; + const target = {}; + for (const k of Object.keys(updates)) target[k] = sectionByKey[k] || schemaSections[k] || ""; + const seen = new Set(); + let curSection = ""; + const out = []; + for (const raw of parsed.lines) { + const line = raw.trim(); + if (line.startsWith("[") && line.endsWith("]")) { curSection = line; out.push(raw); continue; } + if (!line || line.startsWith(";") || line.startsWith("#")) { out.push(raw); continue; } + const eq = raw.indexOf("="); + if (eq <= 0) { out.push(raw); continue; } + const k = raw.slice(0, eq).trim(); + if (updates.hasOwnProperty(k) && curSection === target[k]) { out.push(`${k}=${updates[k]}`); seen.add(k); } + else { out.push(raw); } + } + const fresh = Object.keys(updates).filter(k => !seen.has(k)); + const bySection = {}; + for (const k of fresh) (bySection[target[k]] ||= []).push(k); + for (const [sec, keys] of Object.entries(bySection)) { + if (!sec) { for (const k of keys) out.push(`${k}=${updates[k]}`); continue; } + const hdrIdx = out.findIndex(l => l.trim() === sec); + if (hdrIdx >= 0) { + out.splice(hdrIdx + 1, 0, ...keys.map(k => `${k}=${updates[k]}`)); + } else { + if (out.some(l => l.trim() !== "") && out[out.length - 1].trim() !== "") out.push(""); + out.push(sec); + for (const k of keys) out.push(`${k}=${updates[k]}`); + } + } + while (out.length && out[0].trim() === "") out.shift(); + return out.join("\n"); +} +function ueSchemaSections(schema) { + const m = {}; + for (const g of schema.groups) for (const f of g.fields) { + if (f.section) m[f.prop] = f.section; + } + return m; +} + +// Palworld stores its entire runtime config in PalWorldSettings.ini under +// `OptionSettings=(Difficulty=None,DayTimeSpeedRate=1.000000,…,ServerName="My Server",…)` +// — a single Unreal-engine config tuple, NOT a normal INI line list. +// +// parsePalworldINI returns the same shape as parseINI ({lines, values}) so +// the form renderer can stay format-agnostic, but `values` is keyed by the +// inner OptionSettings keys (Difficulty, ServerName, etc.) and we stash the +// outer line + tuple span on the parsed object so the serializer can patch +// only the changed keys without reflowing the file. +// Rust's server.cfg uses Facepunch convar syntax — one setting per line: +// +// server.hostname "Refuge Gaming" +// server.maxplayers 100 +// decay.scale 1.0 +// +// Tokens are dot-namespaced. Values are either bare numbers / true / false +// or double-quoted strings. We parse to {lines, values} (matching parseINI's +// shape) so the generic form renderer doesn't care about the format. +function parseCfgRust(text) { + const lines = text.split(/\r?\n/); + const values = {}; + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("//")) continue; + // First whitespace splits key from value. + const m = line.match(/^(\S+)\s+(.*)$/); + if (!m) continue; + let v = m[2].trim(); + // Strip surrounding double quotes if present. + if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); + values[m[1]] = v; + } + return { lines, values }; +} +function serializeCfgRust(parsed, updates) { + const seen = new Set(); + const isString = v => v !== "" && (isNaN(Number(v)) && v !== "true" && v !== "false"); + const fmt = (k, v) => isString(v) ? `${k} "${String(v).replace(/"/g, '\\"')}"` : `${k} ${v}`; + const out = parsed.lines.map(raw => { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith("//")) return raw; + const m = line.match(/^(\S+)\s+(.*)$/); + if (!m) return raw; + const k = m[1]; + if (!updates.hasOwnProperty(k)) return raw; + seen.add(k); + return fmt(k, updates[k]); + }); + for (const k of Object.keys(updates)) { + if (!seen.has(k)) out.push(fmt(k, updates[k])); + } + return out.join("\n"); +} + +// DayZ's serverDZ.cfg is Bohemia's config syntax — one `key = value;` per +// line, strings double-quoted, numbers bare, `//` comments, and nested +// `class Name { … };` blocks we must leave byte-identical. We only parse +// top-level assignments (brace depth 0); everything else passes through the +// serializer untouched. Shape matches parseINI ({lines, values}) plus a +// `quoted` map so the serializer re-quotes exactly like the original. +function parseCfgDayz(text) { + const lines = text.split(/\r?\n/); + const values = {}; + const quoted = {}; + let depth = 0; + for (const raw of lines) { + const line = raw.trim(); + // Track class-block nesting BEFORE deciding to parse: an opening line + // like `class Missions` has no '=' so it falls through harmlessly, but + // `{` / `};` adjust depth so inner `template = "…";` lines are skipped. + if (depth === 0 && !line.startsWith("//") && line !== "") { + const m = line.match(/^([A-Za-z_]\w*)\s*=\s*(.*?);?\s*(\/\/.*)?$/); + if (m && !m[1].endsWith("[")) { + let v = m[2].trim(); + if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) { + quoted[m[1]] = true; + v = v.slice(1, -1).replace(/\\"/g, '"'); + } + values[m[1]] = v; + } + } + for (const c of raw) { + if (c === "{") depth++; + else if (c === "}") depth = Math.max(0, depth - 1); + } + } + return { lines, values, quoted }; +} +function serializeCfgDayz(parsed, updates) { + const seen = new Set(); + const quotedOrig = parsed.quoted || {}; + // Re-quote like the original line when we saw the key; for brand-new keys + // quote anything that isn't a bare number (DayZ accepts only quoted strings). + const fmt = (k, v) => { + const isNum = v !== "" && !isNaN(Number(v)); + const q = quotedOrig.hasOwnProperty(k) ? quotedOrig[k] : !isNum; + return q ? `${k} = "${String(v).replace(/"/g, '\\"')}";` : `${k} = ${v};`; + }; + let depth = 0; + const out = parsed.lines.map(raw => { + let result = raw; + const line = raw.trim(); + if (depth === 0 && !line.startsWith("//") && line !== "") { + const m = line.match(/^([A-Za-z_]\w*)\s*=\s*(.*?);?\s*(\/\/.*)?$/); + if (m && !m[1].endsWith("[") && updates.hasOwnProperty(m[1])) { + seen.add(m[1]); + const comment = m[3] ? ` ${m[3]}` : ""; + result = fmt(m[1], updates[m[1]]) + comment; + } + } + for (const c of raw) { + if (c === "{") depth++; + else if (c === "}") depth = Math.max(0, depth - 1); + } + return result; + }); + for (const k of Object.keys(updates)) { + if (!seen.has(k)) out.push(fmt(k, updates[k])); + } + return out.join("\n"); +} + +// --- XML root-element attributes (format: "xml_attrs") ---------------------- +// Barotrauma's serversettings.xml keeps every setting as an attribute on the +// root element. Attribute names are case-INSENSITIVE to +// the game (the server rewrites the file with its own canonical casing, which +// differs from our seeded casing — e.g. EnableUPnP vs enableupnp), so we take +// the schema and mirror each file attribute onto the schema's prop name for +// display, remembering the real file key (_attrAlias) for the writeback. +// _xmlRootTagSpan finds the [start, end) of the root element's opening tag, +// quote-aware (a raw '>' inside a quoted attribute value is legal XML and +// must not terminate the tag). +function _xmlRootTagSpan(text) { + const start = text.search(/<[A-Za-z_]/); + if (start < 0) return null; + let inQuote = false; + for (let i = start + 1; i < text.length; i++) { + const c = text[i]; + if (c === '"') inQuote = !inQuote; + else if (c === ">" && !inQuote) return { start, end: i + 1 }; + } + return null; +} +function parseXmlAttrs(text, schema) { + const values = {}; + const alias = {}; // schema prop (or raw key) -> exact key as in the file + const span = _xmlRootTagSpan(text); + const raw = {}; + if (span) { + const re = /([A-Za-z_][\w.:-]*)\s*=\s*"([^"]*)"/g; + const body = text.slice(span.start, span.end).replace(/^<[A-Za-z_][\w-]*/, ""); + let a; + while ((a = re.exec(body))) { + raw[a[1]] = a[2].replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); + } + } + const lower = {}; + for (const k of Object.keys(raw)) { lower[k.toLowerCase()] = k; values[k] = raw[k]; alias[k] = k; } + if (schema && schema.groups) { + for (const g of schema.groups) for (const f of g.fields) { + const fk = lower[f.prop.toLowerCase()]; + if (fk !== undefined && !(f.prop in values)) { values[f.prop] = raw[fk]; alias[f.prop] = fk; } + } + } + return { text, values, _attrAlias: alias }; +} +function serializeXmlAttrs(parsed, updates) { + const out = parsed.text; + const span = _xmlRootTagSpan(out); + if (!span) return out; + let tag = out.slice(span.start, span.end); + const escAttr = v => String(v).replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); + const extras = []; + for (const [prop, v] of Object.entries(updates)) { + const fileKey = (parsed._attrAlias && parsed._attrAlias[prop]) || prop; + const keyRe = new RegExp("((?:^|\\s)" + fileKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + '\\s*=\\s*")[^"]*(")'); + if (keyRe.test(tag)) tag = tag.replace(keyRe, `$1${escAttr(v)}$2`); + else extras.push(`${fileKey}="${escAttr(v)}"`); + } + if (extras.length) { + // Insert new attributes just before the tag's closing "/>" or ">". + tag = tag.replace(/\s*(\/?)>$/, (mm, slash) => "\n " + extras.join("\n ") + (slash ? " /" : "") + ">"); + } + return out.slice(0, span.start) + tag + out.slice(span.end); +} + +function parsePalworldINI(text) { + const lines = text.split(/\r?\n/); + const values = {}; + let optionLine = -1; + let openParen = -1; + let closeParen = -1; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^\s*OptionSettings\s*=\s*\(/); + if (m) { + optionLine = i; + openParen = lines[i].indexOf("("); + closeParen = lines[i].lastIndexOf(")"); + break; + } + } + if (optionLine < 0 || openParen < 0 || closeParen < 0) { + return { lines, values, palworld: { found: false } }; + } + const inner = lines[optionLine].slice(openParen + 1, closeParen); + // Split on top-level commas — values may contain quoted commas. + const parts = []; + let buf = "", inQ = false; + for (const c of inner) { + if (c === '"') inQ = !inQ; + if (c === "," && !inQ) { parts.push(buf); buf = ""; continue; } + buf += c; + } + if (buf) parts.push(buf); + for (const p of parts) { + const eq = p.indexOf("="); + if (eq <= 0) continue; + const k = p.slice(0, eq).trim(); + let v = p.slice(eq + 1).trim(); + // Strip outer quotes for display; serializer re-adds based on field type. + if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1); + values[k] = v; + } + return { lines, values, palworld: { found: true, optionLine, openParen, closeParen, originalParts: parts } }; +} +function serializePalworldINI(parsed, updates) { + if (!parsed.palworld || !parsed.palworld.found) { + // No tuple in the file (rare — file should have been seeded from + // DefaultPalWorldSettings.ini). Fall back to appending one. + const out = parsed.lines.slice(); + const kvs = Object.entries(updates).map(([k, v]) => + // Quote string-y values; leave numerics + booleans bare. + `${k}=${/^(True|False|None|-?\d+(\.\d+)?)$/.test(v) ? v : `"${v}"`}` + ).join(","); + out.push("[/Script/Pal.PalGameWorldSettings]"); + out.push(`OptionSettings=(${kvs})`); + return out.join("\n"); + } + const { optionLine, originalParts } = parsed.palworld; + // Walk the original parts in order so unchanged keys preserve their + // original spelling/whitespace; replace the value when an update is set. + const newParts = originalParts.map(p => { + const eq = p.indexOf("="); + if (eq <= 0) return p; + const k = p.slice(0, eq).trim(); + if (!updates.hasOwnProperty(k)) return p; + const v = updates[k]; + // Heuristic for re-quoting: if the original was quoted, keep it quoted; + // otherwise leave bare. Pure numbers/booleans/None stay bare regardless. + const orig = p.slice(eq + 1).trim(); + const wasQuoted = orig.length >= 2 && orig[0] === '"' && orig[orig.length - 1] === '"'; + const looksScalar = /^(True|False|None|-?\d+(\.\d+)?)$/.test(v); + const formatted = (wasQuoted && !looksScalar) ? `"${v}"` : (looksScalar ? v : `"${v}"`); + return `${k}=${formatted}`; + }); + // Append any brand-new keys at the end of the tuple (rare; schema-only fields). + const seen = new Set(originalParts.map(p => p.slice(0, p.indexOf("=")).trim())); + for (const [k, v] of Object.entries(updates)) { + if (seen.has(k)) continue; + const looksScalar = /^(True|False|None|-?\d+(\.\d+)?)$/.test(v); + newParts.push(`${k}=${looksScalar ? v : `"${v}"`}`); + } + const out = parsed.lines.slice(); + out[optionLine] = `OptionSettings=(${newParts.join(",")})`; + return out.join("\n"); +} + +// browseable-roots map (module_id → [{name, path, hint}]). Mirrors what +// each module.yaml declares. The agent validates paths against its own +// copy of this list so even if the UI sends a bogus path, the agent +// refuses it. Modules with a single root don't need an entry here — the +// Files tab falls back to relative-path mode against the default root. +const moduleBrowseableRoots = { + "7dtd": [ + { name: "Saves & Configs", path: "/game-saves", hint: "World data, serverconfig.xml, admin files" }, + { name: "Game Files", path: "/game", hint: "Binaries, Mods/, Data/ — drop mod folders here" }, + ], + "empyrion": [ + { name: "Saves & Configs", path: "/game-saves", hint: "Saves/, dedicated.yaml, admin files" }, + { name: "Game Files", path: "/game", hint: "Binaries, Content/, Mods/ — drop mod folders here" }, + ], + "valheim": [ + { name: "Saves & Configs", path: "/game-saves", hint: "Worlds + adminlist.txt + banlist.txt" }, + { name: "Game Files", path: "/game", hint: "Binaries, plugins — drop BepInEx/Jotunn here" }, + ], + "palworld": [ + { name: "Saves & Configs", path: "/game-saves", hint: "Pal/Saved world data + PalWorldSettings.ini" }, + { name: "Game Files", path: "/game", hint: "PalServer install, binaries" }, + ], + "windrose": [ + { name: "Saves & Configs", path: "/game-saves", hint: "ServerDescription.json + R5/Saved world data" }, + { name: "Game Files", path: "/game", hint: "WindroseServer.exe, R5/ engine files" }, + ], + "conan-exiles": [ + { name: "Saves & Configs", path: "/game-saves", hint: "ConanSandbox/Saved/ world data + Config/LinuxServer/ (Enhanced) or Config/WindowsServer/ (Legacy) INIs" }, + { name: "Game Files", path: "/game", hint: "ConanSandbox/Binaries/Linux/ (Enhanced, UE5 native) or Binaries/Win64/ (Legacy, UE4 Wine) + ConanSandbox/Mods/ (Workshop drops)" }, + ], + "rust": [ + { name: "Saves & Configs", path: "/game-saves", hint: "server// world + cfg + oxide/" }, + { name: "Game Files", path: "/game", hint: "RustDedicated, Bundles/, shipped cfgs" }, + ], + "dragonwilds": [ + { name: "Saves & Configs", path: "/game-saves", hint: "RSDragonwilds/Saved/ — DedicatedServer.ini + world saves" }, + { name: "Game Files", path: "/game", hint: "RSDragonwildsServer.sh, RSDragonwilds/Binaries/Linux/" }, + ], + "dayz": [ + { name: "Saves & Configs", path: "/game-saves", hint: "serverDZ.cfg + profiles/ (logs, bans, RPTs)" }, + { name: "Game Files", path: "/game", hint: "DayZServer binary + battleye/, mpmissions/, keys/" }, + ], + "factorio": [ + { name: "Saves & Configs", path: "/game-saves", hint: "saves/, mods/, config/" }, + { name: "Game Files", path: "/game", hint: "Factorio binary tree" }, + ], + "satisfactory": [ + { name: "Saves & Configs", path: "/game-saves", hint: "SaveGames/ + Engine configs" }, + { name: "Game Files", path: "/game", hint: "Server binaries" }, + ], + "barotrauma": [ + { name: "Saves & Configs", path: "/game-saves", hint: "serversettings.xml + saves/" }, + { name: "Game Files", path: "/game", hint: "Server binaries, Mods/" }, + ], + "enshrouded": [ + { name: "Saves & Configs", path: "/game-saves", hint: "enshrouded_server.json + savegame/" }, + { name: "Game Files", path: "/game", hint: "Server binaries" }, + ], + "terraria": [ + { name: "Saves & Configs", path: "/game-saves", hint: "Worlds/, serverconfig.txt" }, + { name: "Game Files", path: "/game", hint: "Terraria binary" }, + ], + "project-zomboid": [ + { name: "Saves & Configs", path: "/game-saves", hint: "~/Zomboid tree" }, + { name: "Game Files", path: "/game", hint: "PZ install" }, + ], + "core-keeper": [ + { name: "Saves & Configs", path: "/game-saves", hint: "Worlds + server.json" }, + { name: "Game Files", path: "/game", hint: "Server binaries" }, + ], + "soulmask": [ + { name: "Saves & Configs", path: "/game-saves", hint: "WS/Saved/ + GameUserSettings.ini" }, + { name: "Game Files", path: "/game", hint: "Server binaries, WS/Content/" }, + ], + "v-rising": [ + { name: "Saves & Configs", path: "/game-saves", hint: "ServerHostSettings.json + Saves/" }, + { name: "Game Files", path: "/game", hint: "Server binaries" }, + ], + "sons-of-the-forest": [ + { name: "Saves & Configs", path: "/game-saves", hint: "Config/ + Saved/" }, + { name: "Game Files", path: "/game", hint: "Server binaries" }, + ], + "vein": [ + { name: "Saves & Configs", path: "/game-saves", hint: "Vein/Saved/ savegame + Config/LinuxServer/Game.ini" }, + { name: "Game Files", path: "/game", hint: "Vein/Binaries/Linux/ server binary + steamclient" }, + ], +}; +function rootsFor(moduleID) { + // WI-07: manifest browseable_roots (from /api/modules — the same + // module.yaml list the agent validates paths against) wins; the inline + // map is only a fallback for modules missing the field. + const meta = _modMeta(moduleID); + if (meta && Array.isArray(meta.browseable_roots) && meta.browseable_roots.length) { + return meta.browseable_roots; + } + return moduleBrowseableRoots[moduleID] || null; +} + +// Fallback if the controller doesn't expose manifest.ports yet. +// primaryPortFor returns the host-bound port of the instance's MAIN +// public connection endpoint (the one players type into their game +// client), formatted as ":NNNN". Prefers any per-instance override +// in i.ports over the module default from guessPortsForModule. +// Returns "" if no port can be resolved. +function primaryPortFor(i) { + if (!i) return ""; + const list = guessPortsForModule(i.module_id) || []; + // Heuristic: the first public (non-internal) required port named + // "game" wins. Falls back to the first required public port, then to + // the first in the list. + const pick = list.find(p => p.name === "game" && !p.internal && p.required) + || list.find(p => !p.internal && p.required) + || list.find(p => !p.internal) + || list[0]; + if (!pick) return ""; + // i.ports is now `{name: hostPortNumber}` per the new allocator DTO. + // Older code passed `{name: {host_port: N}}`; keep tolerant of both. + const overrides = i.ports && typeof i.ports === "object" ? i.ports : {}; + const ov = overrides[pick.name]; + let port; + if (typeof ov === "number") port = ov; + else if (ov && typeof ov === "object") port = ov.host_port || ov.hostPort; + if (!port) port = pick.default; + return port ? ":" + port : ""; +} + +function guessPortsForModule(id) { + // WI-07: manifest ports (from /api/modules, incl. env names) win; the + // inline map below is only a fallback for modules missing the field or + // before the first modules poll lands. + const meta = _modMeta(id); + if (meta && Array.isArray(meta.ports) && meta.ports.length) return meta.ports; + const m = { + "7dtd": [{name:"game",proto:"udp",default:26900,required:true},{name:"gameA",proto:"udp",default:26901,required:true},{name:"gameB",proto:"udp",default:26902,required:true},{name:"telnet",proto:"tcp",default:8081,internal:true}], + "minecraft-java":[{name:"game",proto:"tcp",default:25565,required:true},{name:"rcon",proto:"tcp",default:25575,internal:true}], + "valheim": [{name:"game",proto:"udp",default:2456,required:true},{name:"query",proto:"udp",default:2457,required:true}], + "ark": [{name:"game",proto:"udp",default:7777,required:true},{name:"raw",proto:"udp",default:7778,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:27020,internal:true}], + "ark-sa": [{name:"game",proto:"udp",default:7777,required:true},{name:"raw",proto:"udp",default:7778,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:27020,internal:true}], + "empyrion": [{name:"game",proto:"udp",default:30000,required:true},{name:"query",proto:"udp",default:30001,required:true},{name:"client",proto:"udp",default:30002,required:true},{name:"eac",proto:"udp",default:30003,required:true},{name:"csw",proto:"tcp",default:30004,internal:true}], + "valheim": [{name:"game",proto:"udp",default:2456,required:true},{name:"gameA",proto:"udp",default:2457,required:true},{name:"query",proto:"udp",default:2458,required:true}], + "palworld": [{name:"game",proto:"udp",default:8211,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:25575,internal:true}], + "windrose": [{name:"game",proto:"udp",default:7777,required:true}], + "conan-exiles": [{name:"game",proto:"tcp",default:7777,required:true},{name:"game-udp",proto:"udp",default:7777,required:true},{name:"pinger",proto:"udp",default:7778,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"tcp",default:25575,internal:true}], + "rust": [{name:"game",proto:"udp",default:28015,required:true},{name:"rcon",proto:"tcp",default:28016,internal:true},{name:"rustplus",proto:"tcp",default:28082,required:false}], + "dragonwilds": [{name:"game",proto:"udp",default:7777,required:true}], + "dayz": [{name:"game",proto:"udp",default:2302,required:true},{name:"reserved",proto:"udp",default:2303,required:false},{name:"battleye",proto:"udp",default:2304,required:false},{name:"rcon",proto:"udp",default:2305,internal:true}], + "factorio": [{name:"game",proto:"udp",default:34197,required:true},{name:"rcon",proto:"tcp",default:27015,internal:true}], + "satisfactory": [{name:"game",proto:"udp",default:7777,required:true},{name:"beacon",proto:"udp",default:15000,required:true},{name:"messaging",proto:"tcp",default:7777,required:true}], + "barotrauma": [{name:"game",proto:"udp",default:27015,required:true},{name:"query",proto:"udp",default:27016,required:true}], + "enshrouded": [{name:"game",proto:"udp",default:15636,required:true},{name:"query",proto:"udp",default:15637,required:true}], + "terraria": [{name:"game",proto:"tcp",default:7777,required:true}], + "project-zomboid":[{name:"game",proto:"udp",default:16261,required:true},{name:"gameB",proto:"udp",default:16262,required:true},{name:"rcon",proto:"tcp",default:27015,internal:true}], + "core-keeper": [{name:"game",proto:"udp",default:27015,required:true}], + "soulmask": [{name:"game",proto:"udp",default:8777,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"admin",proto:"tcp",default:8888,internal:true}], + "v-rising": [{name:"game",proto:"udp",default:9876,required:true},{name:"query",proto:"udp",default:9877,required:true}], + "sons-of-the-forest":[{name:"game",proto:"udp",default:8766,required:true},{name:"query",proto:"udp",default:27016,required:true},{name:"join",proto:"udp",default:9700,required:true}], + "vein": [{name:"game",proto:"udp",default:7777,required:true},{name:"query",proto:"udp",default:27015,required:true},{name:"rcon",proto:"udp",default:7878,internal:true}], + }; + return m[id] || null; +} + +// ================ events SSE ================ +// rAF coalescer for SSE stat bursts. An agent with N instances emits N +// instance_stats events back-to-back every sample tick; painting each one +// synchronously meant N card diffs + N hero recomputes per burst. Instead we +// mark ids dirty and flush once per animation frame — each card repaints at +// most once, hero stats recompute once. While the tab is hidden rAF doesn't +// fire; the data model (latestStats/latestAppState) is already updated, and +// the visible-tab refetch repaints everything. +const _cardDirty = new Set(); +let _statsFlushArmed = false; +let _heroDirty = false; +function _queueCardRefresh(id, hero) { + _cardDirty.add(id); + if (hero) _heroDirty = true; + if (_statsFlushArmed) return; + _statsFlushArmed = true; + requestAnimationFrame(() => { + _statsFlushArmed = false; + const ids = Array.from(_cardDirty); + _cardDirty.clear(); + for (const cid of ids) updateCardInPlace(cid); + if (_heroDirty) { _heroDirty = false; refreshHeroStats(); } + }); +} + +// SSE-health-adaptive polling (WI-11): while the global SSE stream is +// healthy (server snapshot received, no error since), instance polling is +// just a slow safety net — SSE pushes state/stats/app events live, and the +// connect snapshot replaces the initial poll. On SSE error we drop straight +// back to the user-configured cadence until the stream recovers. +let _sseHealthy = false; +let _snapshotSeen = false; +const SSE_FALLBACK_POLL_MS = 30000; +function _instancePollMs() { + return _sseHealthy ? SSE_FALLBACK_POLL_MS : (userSettings.refreshMs || 2000); +} +function _restartInstancePoll() { + if (_pollInstancesHandle) clearInterval(_pollInstancesHandle); + _pollInstancesHandle = setInterval(pollInstances, _instancePollMs()); +} +function _setSSEHealthy(ok) { + if (ok === _sseHealthy) return; + _sseHealthy = ok; + _restartInstancePoll(); + // Losing SSE means we may have missed transitions — resync immediately + // (pollInstances itself still respects the hidden-tab gate). + if (!ok) { pollInstances(); pollAgents(); } +} + +function connectEvents() { + // exclude=log: the dashboard has no DOM target for log lines, but they're + // ~99% of event volume during a server boot — shipping them anyway is what + // overflowed the SSE buffer (dropped state events → stale cards) and kept + // the browser main thread parsing thousands of lines. Consoles + the + // readiness scanner use per-instance streams, which keep their logs. + const src = new EventSource("/api/events?exclude=log"); + src.onopen = () => setStatus("connected", "ok"); + src.onerror = () => { setStatus("reconnecting…", "err"); _setSSEHealthy(false); src.close(); setTimeout(connectEvents, 2000); }; + src.onmessage = e => { try { appendEvent(JSON.parse(e.data)); } catch {} }; + // Connect-time state snapshot — the controller pushes the same DTO + // payloads the /api/instances + /api/agents polls return, so a (re)connect + // needs no immediate poll. Named event → older clients ignore it for free. + src.addEventListener("snapshot", async e => { + try { + const snap = JSON.parse(e.data); + if (Array.isArray(snap.agents)) applyAgents(snap.agents); + if (Array.isArray(snap.instances)) await applyInstances(snap.instances); + _snapshotSeen = true; + _setSSEHealthy(true); + } catch {} + }); +} +function appendEvent(ev) { + // Global readiness scanner — for READY_LOG_ONLY modules, the agent's + // STATUS_RUNNING is too early (it fires when docker says the container + // is up, NOT when the game is joinable). The console IS the source + // of truth: only after we see the joinable log line in the live stream + // does the frontend treat the server as actually "running." Tracked + // session-only via instanceConfirmedReady; for instances that have + // already been up for >10 min when the page loads, isConfirmedReady() + // assumes ready (the boot lines are long gone from the live stream). + if (ev.log && ev.log.instance_id && ev.log.line) { + const inst = allInstances.find(x => x.instance_id === ev.log.instance_id); + if (inst && READY_LOG_ONLY.has(inst.module_id)) { + const pat = readyPatternFor(inst.module_id); + if (pat && pat.test(ev.log.line) && !instanceConfirmedReady.has(ev.log.instance_id)) { + // Reject lines older than the current run. The SSE stream replays + // the agent's tail buffer on first connect, and the docker log + // file carries lines across container restarts — without this + // guard a previous run's "Server has completed startup" line + // marks the just-restarted instance ready immediately. + let stale = false; + if (ev.log.at && inst.updated_at) { + const lineAt = Date.parse(ev.log.at); + const startedAt = Date.parse(inst.updated_at) - 30 * 1000; // 30s clock-skew grace + if (!isNaN(lineAt) && !isNaN(startedAt) && lineAt < startedAt) stale = true; + } + if (!stale) { + instanceConfirmedReady.add(ev.log.instance_id); + // Repaint cards + modal so the pill flips from "starting" to + // "running" the instant the joinable line is seen. + paintStartingUI(); + if (typeof renderServerCards === "function") renderServerCards(); + } + } + } + } + const at = new Date(ev.at).toLocaleTimeString([], { hour12: false }); + let kind, body, cls; + if (ev.instance_state) { + cls = "state"; kind = "STATE"; + const s = ev.instance_state; + body = `${s.instance_id} → ${String(s.status).replace("INSTANCE_STATUS_","").toLowerCase()} ${s.detail || ""}`; + // Drop the confirmed-ready flag whenever the server stops, crashes, + // or is deleted. The next start has to re-prove joinability via the + // log line (otherwise a quick stop+start would inherit stale "ready" + // and skip the proper "starting" pulse). + const sStatus = String(s.status).replace("INSTANCE_STATUS_","").toLowerCase(); + if (sStatus === "stopped" || sStatus === "crashed" || s.detail === "deleted") { + resetReadyOnStop(s.instance_id); + // Drop the last stats sample so the card doesn't keep painting the + // pre-stop CPU/RAM until the next start. Without this clear the bars + // freeze at "0.4 CPU · 8.2 GB" indefinitely after Stop, and the hero + // totals double-count phantom load from servers that aren't running. + delete latestStats[s.instance_id]; + delete latestAppState[s.instance_id]; + _queueCardRefresh(s.instance_id, true); + } + } + else if (ev.log) { cls = "log"; kind = "LOG "; body = `${ev.log.instance_id} ${ev.log.line}`; } + else if (ev.player) { cls = "player"; kind = String(ev.player.kind).replace("KIND_","").padEnd(5); body = `${ev.player.instance_id} ${ev.player.player_name || ""} ${ev.player.detail || ""}`; } + else if (ev.app_state) { + const a = ev.app_state; + latestAppState[a.instance_id] = { players_online: a.players_online, players_max: a.players_max, uptime: a.uptime_seconds }; + _queueCardRefresh(a.instance_id, false); + return; + } + else if (ev.instance_stats) { + const s = ev.instance_stats; + latestStats[s.instance_id] = { + cpu: Number(s.cpu_percent) || 0, + hostCpus: Number(s.host_cpus) || 0, + memMB: Number(s.mem_used_bytes) / 1024 / 1024, + memLimitMB: Number(s.mem_limit_bytes) / 1024 / 1024, + rx: Number(s.net_rx_bytes) || 0, tx: Number(s.net_tx_bytes) || 0, pids: Number(s.pids) || 0, + }; + _queueCardRefresh(s.instance_id, true); + return; + } else { return; } + // The global #events feed was removed — we keep the SSE connection alive + // for the connection-status indicator and to update cards/state in place, + // but there's no DOM target for log/player/state events on the main page. + // They still surface in the per-instance modal's Console tab via a + // separate SSE connection. +} + +// ================ init ================ +// Schedules + live events used to be global panels on the main dashboard; +// they moved to per-instance modal tabs so the dashboard stays focused on +// "which servers do I have and what's their state". We still keep the +// stand-alone connection indicator in the nav via an SSE keepalive so the +// user sees at a glance whether the controller is reachable. +loadMe(); +// SSE first: the connect snapshot carries the same instances+agents payloads +// the initial polls used to fetch, so a healthy stream needs no immediate +// poll. If the snapshot hasn't landed shortly after load (SSE blocked or a +// buffering proxy), fall back to the classic initial polls. +connectEvents(); +pollModules(); +seedSparkHistory(); // prime overview sparklines from controller history +refreshUpdateCheckMap(); // WI-14: paint "Update available" badges from stored checks +setTimeout(() => { if (!_snapshotSeen) { pollAgents(); pollInstances(); } }, 1500); +setInterval(pollAgents, 5000); +let _pollInstancesHandle = setInterval(pollInstances, 2000); +setInterval(pollModules, 15000); +// All three polls skip while the tab is hidden; refetch immediately on +// return so the dashboard doesn't sit stale for up to a full interval. +document.addEventListener("visibilitychange", () => { + if (document.hidden) return; + pollAgents(); pollInstances(); pollModules(); +}); +// NOTE: page-load readiness catch-up for READY_LOG_ONLY modules is handled +// by scanReadyForUnconfirmedRunning(), which every pollInstances tick runs +// (throttled per instance) — no separate seeding pass needed even though +// the global SSE no longer carries log lines. + +// =============== USER SETTINGS (localStorage-backed) =============== +const SETTINGS_KEY = "rgsp.settings.v1"; +const _defaultSettings = { density: "comfortable", showBars: true, refreshMs: 2000, notifyOnCrash: false }; +function loadSettings() { + try { + const raw = localStorage.getItem(SETTINGS_KEY); + if (raw) return Object.assign({}, _defaultSettings, JSON.parse(raw)); + } catch {} + return Object.assign({}, _defaultSettings); +} +function saveSettings(s) { + try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch {} + applySettings(s); +} +function applySettings(s) { + const root = document.documentElement; + // Density via CSS var that .svr-grid reads. + // Single source of truth with the CSS fallback in .svr-grid/.svr-group-grid: + // comfortable = 300px (matches var(--svr-grid-min, 300px)), compact/cozy scaled. + const dens = s.density === "compact" ? "240px" : s.density === "cozy" ? "340px" : "300px"; + root.style.setProperty("--svr-grid-min", dens); + // CPU/RAM bar visibility + root.classList.toggle("hide-svr-bars", !s.showBars); + // Auto-refresh interval — restart the polling loop with the new period. + // While SSE is healthy the poll is only a fallback, so the stretched + // cadence wins over the user setting (SSE pushes the real-time updates). + if (_pollInstancesHandle) clearInterval(_pollInstancesHandle); + _pollInstancesHandle = setInterval(pollInstances, _sseHealthy ? SSE_FALLBACK_POLL_MS : (s.refreshMs || 2000)); +} +const userSettings = loadSettings(); +applySettings(userSettings); + +// Crash-notification tracker — fires a browser Notification when an +// instance flips into a crashed-like status. Only listens when the +// setting is on AND the user has granted browser permission. +const _lastStatusByID = {}; +function _checkCrashTransitions() { + if (!userSettings.notifyOnCrash) return; + if (typeof Notification === "undefined" || Notification.permission !== "granted") return; + for (const i of allInstances) { + const prev = _lastStatusByID[i.instance_id]; + const cur = i.status; + const isCrashLike = cur && /^(crashed|error|delete_failed|install_failed)$/i.test(cur); + const wasCrashLike = prev && /^(crashed|error|delete_failed|install_failed)$/i.test(prev); + if (isCrashLike && !wasCrashLike && prev !== undefined) { + try { + const mod = (allModules.find(m => m.id === i.module_id) || {}).name || i.module_id; + new Notification(`💥 ${i.instance_id} crashed`, { + body: `${mod} on agent ${i.agent_id}`, + tag: `rgsp-crash-${i.instance_id}`, + }); + } catch {} + } + _lastStatusByID[i.instance_id] = cur; + } +} +// Re-check after each render so we cover both polling and SSE-driven updates. +{ + const _origRender = renderServerCards; + window.renderServerCards = function() { + _origRender.apply(this, arguments); + _checkCrashTransitions(); + }; +} + +function openSettingsModal() { + $("#set-density").value = userSettings.density; + $("#set-show-bars").checked = userSettings.showBars; + $("#set-refresh").value = String(userSettings.refreshMs); + $("#set-notify").checked = userSettings.notifyOnCrash; + // Sync iOS-toggle label state + document.querySelectorAll("#settings-modal .toggle").forEach(t => { + const inp = t.querySelector("input"); const lab = t.querySelector("[data-toggle-label]"); + if (lab && inp) { + const on = inp.checked; + lab.textContent = on ? "Enabled" : "Disabled"; + lab.classList.toggle("on", on); + } + }); + // Populate About — module count + a build hash (best-effort). + const aboutMod = $("#about-modules"); if (aboutMod) aboutMod.textContent = String(allModules.length); + const aboutBuild = $("#about-build"); if (aboutBuild) aboutBuild.textContent = (window.__rgspBuild || "dev"); + // opnfwd integration card — load current state. Failures are benign: + // controller is too old to have the endpoint, or DB hasn't been migrated. + loadOpnfwdSettings(); + // Panel UI switch — back to the classic front-end. Sets the panel_ui cookie + // the controller's root route reads, then reloads "/". + const switchBtn = $("#set-switch-classic"); + if (switchBtn && !switchBtn._wired) { + switchBtn._wired = true; + switchBtn.addEventListener("click", () => switchPanelUI("classic")); + } + showModal("#settings-modal"); +} + +// switchPanelUI sets the panel_ui cookie (1 year, lax) and reloads to "/". +// "new" -> controller serves static/new.html (this UI) +// "classic" -> controller serves static/index.html +function switchPanelUI(which) { + const oneYear = 60 * 60 * 24 * 365; + document.cookie = `panel_ui=${which}; path=/; max-age=${oneYear}; samesite=lax`; + location.href = "/"; +} + +// ================ opnfwd integration settings ================ +// +// Card on the Settings modal that surfaces the panel ↔ opnfwd toggle +// (and config: URL, token, insecure-TLS), plus a Test button that +// round-trips opnfwd's /api/me, plus a Reconcile button that diffs +// what panel believes it owns against what opnfwd actually has on the +// router. Token field is masked — we send the new value only when the +// operator types something; blank = leave existing token alone. + +async function loadOpnfwdSettings() { + // Reset display state. + const out = $("#set-opnfwd-reconcile-out"); if (out) out.style.display = "none"; + $("#set-opnfwd-status-pill").textContent = "—"; + $("#set-opnfwd-status-pill").className = "dim mono"; + try { + const s = await fetchJSON("/api/admin/opnfwd"); + $("#set-opnfwd-enabled").checked = !!s.enabled; + $("#set-opnfwd-url").value = s.url || ""; + $("#set-opnfwd-insecure").checked = !!s.insecure_tls; + // Token field: show placeholder indicating "set/unset" without leaking + // the value. Operator types a new value to overwrite. + const tokInput = $("#set-opnfwd-token"); + tokInput.value = ""; + tokInput.placeholder = s.token_set ? "(token set — type to change, blank to clear)" : "(no token configured)"; + } catch (err) { + // Endpoint may not exist on older controllers; leave fields blank. + console.warn("opnfwd settings load:", err.message); + } + // Always-load tracked count. + try { + const r = await fetchJSON("/api/admin/opnfwd/forwards"); + $("#set-opnfwd-tracked").textContent = String((r.forwards || []).length) + " tracked"; + } catch (err) { + $("#set-opnfwd-tracked").textContent = "—"; + } + // Sync the opnfwd toggle label since loadOpnfwd ran AFTER the label-sync above. + document.querySelectorAll("#set-opnfwd-enabled, #set-opnfwd-insecure").forEach(inp => { + const t = inp.closest(".toggle"); if (!t) return; + const lab = t.querySelector("[data-toggle-label]"); if (!lab) return; + const on = inp.checked; + lab.textContent = on ? (inp.id === "set-opnfwd-insecure" ? "On" : "Enabled") : (inp.id === "set-opnfwd-insecure" ? "Off" : "Disabled"); + lab.classList.toggle("on", on); + }); +} + +// Save just the opnfwd fields. Called on change / blur. Token is sent only +// if the operator typed something new — blank means "leave it alone". +async function saveOpnfwdSettings(extra) { + const body = Object.assign({ + enabled: $("#set-opnfwd-enabled").checked, + url: $("#set-opnfwd-url").value.trim(), + insecure_tls: $("#set-opnfwd-insecure").checked, + }, extra || {}); + // Token: only include the field if the user typed something OR explicitly + // wants to clear (input value is empty AND the user clicked clear). The + // reset behavior is: blank = no change. If you want to clear, type a + // single space and save (server-trims to empty). + const tokVal = $("#set-opnfwd-token").value; + if (tokVal !== "") { + body.token = tokVal.trim(); + // Once consumed, blank out the input so reload doesn't re-send. + $("#set-opnfwd-token").value = ""; + } + try { + await fetchJSON("/api/admin/opnfwd", { + method: "PUT", headers: {"Content-Type":"application/json"}, + body: JSON.stringify(body), + }); + } catch (err) { + toast("opnfwd settings save failed: " + err.message, "err"); + return false; + } + return true; +} + +// Live-save on toggle/url/insecure change. Token saves on blur (not change) +// so partial typing doesn't hammer PUT. +$("#set-opnfwd-enabled")?.addEventListener("change", async (e) => { + if (await saveOpnfwdSettings()) { + toast(e.target.checked ? "opnfwd auto-forward: ON" : "opnfwd auto-forward: OFF", "ok"); + const lab = e.target.closest(".toggle")?.querySelector("[data-toggle-label]"); + if (lab) { lab.textContent = e.target.checked ? "Enabled" : "Disabled"; lab.classList.toggle("on", e.target.checked); } + } +}); +$("#set-opnfwd-url")?.addEventListener("change", saveOpnfwdSettings); +$("#set-opnfwd-insecure")?.addEventListener("change", async (e) => { + if (await saveOpnfwdSettings()) { + const lab = e.target.closest(".toggle")?.querySelector("[data-toggle-label]"); + if (lab) { lab.textContent = e.target.checked ? "On" : "Off"; lab.classList.toggle("on", e.target.checked); } + } +}); +$("#set-opnfwd-token")?.addEventListener("blur", async () => { + if ($("#set-opnfwd-token").value !== "") await saveOpnfwdSettings(); +}); + +$("#set-opnfwd-token-show")?.addEventListener("click", () => { + const inp = $("#set-opnfwd-token"); + inp.type = inp.type === "password" ? "text" : "password"; +}); + +$("#set-opnfwd-test")?.addEventListener("click", async () => { + const pill = $("#set-opnfwd-status-pill"); + pill.textContent = "testing…"; pill.className = "dim mono"; + try { + const r = await fetchJSON("/api/admin/opnfwd/test", { method: "POST" }); + if (r.ok) { + pill.textContent = `✓ OK (${r.elapsed_ms} ms)`; + pill.className = "mono"; + pill.style.color = "#4fcf63"; + } else { + pill.textContent = `✗ ${r.err || "failed"}`; + pill.className = "mono"; + pill.style.color = "var(--err)"; + } + } catch (err) { + pill.textContent = "✗ " + err.message; + pill.style.color = "var(--err)"; + } +}); + +$("#set-opnfwd-reconcile")?.addEventListener("click", async () => { + const out = $("#set-opnfwd-reconcile-out"); + out.style.display = "block"; out.textContent = "Reconciling…"; + try { + const r = await fetchJSON("/api/admin/opnfwd/reconcile", { method: "POST" }); + const lines = []; + lines.push(`matched: ${r.matched}`); + lines.push(`local_total: ${r.local_total} (panel-tracked rows)`); + lines.push(`remote_total: ${r.remote_total} (every forward on opnfwd)`); + lines.push(""); + lines.push(`panel_only (panel has it, opnfwd doesn't): ${(r.panel_only || []).length}`); + for (const x of (r.panel_only || [])) { + lines.push(` ${x.instance_id}/${x.port_name} → ${x.target_ip}:${x.host_port} nat=${(x.nat_uuid||"").slice(0,8)}`); + } + lines.push(""); + lines.push(`opnfwd_only (panel- prefix, no panel row): ${(r.opnfwd_only || []).length}`); + for (const x of (r.opnfwd_only || [])) { + lines.push(` ${x.name} → ${x.target}:${x.port} (${x.proto}) nat=${(x.nat_uuid||"").slice(0,8)}`); + } + out.textContent = lines.join("\n"); + // Refresh the tracked count after reconcile. + try { + const f = await fetchJSON("/api/admin/opnfwd/forwards"); + $("#set-opnfwd-tracked").textContent = String((f.forwards || []).length) + " tracked"; + } catch (e) {} + } catch (err) { + out.textContent = "Reconcile failed: " + err.message; + } +}); + +// =============== MOBILE NAV DRAWER =============== +// Below 900px the sidebar lives off-canvas. The hamburger toggles a body +// class which slides it in. Backdrop click / link click / Esc closes it. +// The backdrop uses pointer-down + pointer-up tracking so an accidental +// drag-out from inside the drawer (e.g. text selection) doesn't dismiss. +function _openMobileDrawer() { document.body.classList.add("sidebar-open"); } +function _closeMobileDrawer() { document.body.classList.remove("sidebar-open"); } +const _hamburger = $("#topbar-menu"); +if (_hamburger) _hamburger.addEventListener("click", () => { + if (document.body.classList.contains("sidebar-open")) _closeMobileDrawer(); + else _openMobileDrawer(); +}); +const _sidebarBackdrop = $("#sidebar-backdrop"); +if (_sidebarBackdrop) { + let _bdDownOnBackdrop = false; + _sidebarBackdrop.addEventListener("pointerdown", e => { + _bdDownOnBackdrop = (e.target === _sidebarBackdrop); + }); + _sidebarBackdrop.addEventListener("pointerup", e => { + if (_bdDownOnBackdrop && e.target === _sidebarBackdrop) _closeMobileDrawer(); + _bdDownOnBackdrop = false; + }); +} +// Close the drawer when any sidebar link is clicked (so navigation + +// scroll-to-anchor all collapse the menu after action). +document.querySelectorAll(".sidebar a, .sidebar button").forEach(el => { + el.addEventListener("click", () => { + if (window.matchMedia("(max-width: 900px)").matches) _closeMobileDrawer(); + }); +}); +// Esc closes the drawer if it's open AND no modal is on top. +document.addEventListener("keydown", e => { + if (e.key === "Escape" && document.body.classList.contains("sidebar-open")) { + const anyModal = [...document.querySelectorAll(".modal-bg")].some(m => m.style.display === "flex"); + if (!anyModal) _closeMobileDrawer(); + } +}); + +// =============== CARD "MORE" MENU =============== +// The overhauled card shows all primary/secondary actions inline (no more +// click-to-flip strip). Rare/dangerous actions (Backup / Force-kill / +// Delete) live in a small popover toggled by the ⋯ button — opened in the +// card-actions delegate above. Here we just handle dismissal: any click +// outside an open menu, or Esc, closes it. +function _closeCardMenus() { + document.querySelectorAll(".svr-act-menu:not([hidden])").forEach(m => { + m.setAttribute("hidden", ""); + m.classList.remove("drop-up"); + const wrap = m.closest(".svr-act-more-wrap"); + const btn = wrap && wrap.querySelector("[data-card-more]"); + if (btn) btn.setAttribute("aria-expanded", "false"); + }); +} +document.addEventListener("click", (e) => { + // The ⋯ toggle + the menu items are handled by the card-actions + // delegate (which calls _closeCardMenus where appropriate). Here we only + // need to catch clicks that land OUTSIDE any more-wrap and dismiss. + if (e.target.closest(".svr-act-more-wrap")) return; + _closeCardMenus(); +}); +document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + if (document.querySelector(".svr-act-menu:not([hidden])")) { _closeCardMenus(); e.stopPropagation(); } +}); + +// =============== CLICK-OUTSIDE TO DISMISS (selection-safe) =============== +// For each modal-bg, close the modal when the user clicks the dim area +// AROUND the inner panel — but ONLY when the pointer-down AND pointer-up +// both happened on the bg, not on the panel inside. This protects accidental +// drag-out-of-modal during text selection from dismissing the modal. +// +// Modals can opt out with `data-no-backdrop-close` on the .modal-bg. +function _wireBackdropClose(bg) { + let downedOnBg = false; + bg.addEventListener("pointerdown", (e) => { + downedOnBg = (e.target === bg); + }); + bg.addEventListener("pointerup", (e) => { + const wasOnBg = downedOnBg && (e.target === bg); + downedOnBg = false; + if (!wasOnBg) return; + if (bg.dataset.noBackdropClose !== undefined) return; + if (bg.id) hideModal("#" + bg.id); + }); +} +document.querySelectorAll(".modal-bg").forEach(_wireBackdropClose); +// Some modals are created lazily (e.g. env-config confirm dialog). The +// helper is exposed so any new modal can call _wireBackdropClose(bg) once +// inserted into the DOM. +window._wireBackdropClose = _wireBackdropClose; + +// =============== PULL-TO-REFRESH (mobile / PWA) =============== +// PWAs (Add to Home Screen on iOS Safari, install on Android Chrome) +// have no browser chrome, so there's no reload button. A drag-down +// gesture from the top of the page triggers a soft refresh — we re-run +// pollAgents + pollInstances + pollModules, which is what a manual +// reload would otherwise force. Animated indicator slides down with the +// pull, snaps when past the threshold, spins during the refresh. +(() => { + if (!("ontouchstart" in window) && !("onpointerdown" in window)) return; + const indicator = document.getElementById("ptr-indicator"); + if (!indicator) return; + + const PULL_THRESHOLD = 72; // pixels of *visible* pull required to trigger + const RESISTANCE = 0.55; // pulled distance is this fraction of finger travel + const MAX_VISIBLE = 110; // cap on indicator translation so it doesn't fly off + const scroller = document.scrollingElement || document.documentElement; + + let startY = 0; + let pulling = false; + let lastVisible = 0; + let armed = false; + + const isModalOpen = () => + [...document.querySelectorAll(".modal-bg")].some(m => m.style.display === "flex"); + const isDrawerOpen = () => document.body.classList.contains("sidebar-open"); + + const setIndicator = (visiblePx) => { + const v = Math.min(MAX_VISIBLE, Math.max(0, visiblePx)); + lastVisible = v; + indicator.style.transform = `translateY(${v - 70}px) translateX(-50%)`; + indicator.style.opacity = String(Math.min(1, v / 32)); + indicator.classList.toggle("ready", v >= PULL_THRESHOLD); + }; + + const reset = (animate) => { + if (animate) { + indicator.style.transition = "transform .28s var(--ease-out), opacity .25s"; + requestAnimationFrame(() => { + indicator.style.transform = "translateY(-70px) translateX(-50%)"; + indicator.style.opacity = "0"; + indicator.classList.remove("ready"); + }); + setTimeout(() => { indicator.style.transition = ""; }, 320); + } else { + indicator.style.transition = ""; + indicator.style.transform = "translateY(-70px) translateX(-50%)"; + indicator.style.opacity = "0"; + indicator.classList.remove("ready"); + } + }; + + const doRefresh = async () => { + indicator.classList.add("loading"); + indicator.style.transform = "translateY(16px) translateX(-50%)"; + indicator.style.opacity = "1"; + try { + const calls = []; + if (typeof pollAgents === "function") calls.push(pollAgents()); + if (typeof pollInstances === "function") calls.push(pollInstances()); + if (typeof pollModules === "function") calls.push(pollModules()); + await Promise.all(calls); + if (typeof toast === "function") toast("Refreshed", "ok"); + } catch (err) { + if (typeof toast === "function") toast("Refresh failed: " + (err && err.message || "unknown"), "err"); + } finally { + indicator.classList.remove("loading"); + reset(true); + } + }; + + // Pull is only allowed when: + // - Body is at scrollTop 0 + // - No modal / drawer open + // - The touch target isn't inside a scrollable child that has its own scrollTop > 0 + // - On a viewport that benefits from pull-to-refresh (mobile / standalone PWA) + const inStandalone = window.matchMedia("(display-mode: standalone)").matches + || window.navigator.standalone === true; + const wantPTR = () => inStandalone || window.matchMedia("(max-width: 900px)").matches; + const touchInScrolledChild = (target) => { + let el = target; + while (el && el !== document.body) { + if (el.scrollTop && el.scrollTop > 0) { + const cs = getComputedStyle(el); + if (/(auto|scroll)/.test(cs.overflowY)) return true; + } + el = el.parentElement; + } + return false; + }; + + document.addEventListener("touchstart", (e) => { + if (!wantPTR()) return; + if (e.touches.length !== 1) return; + if (scroller.scrollTop > 0) return; + if (isModalOpen() || isDrawerOpen()) return; + if (touchInScrolledChild(e.target)) return; + startY = e.touches[0].clientY; + pulling = true; + armed = false; + lastVisible = 0; + }, { passive: true }); + + document.addEventListener("touchmove", (e) => { + if (!pulling) return; + const dy = e.touches[0].clientY - startY; + if (dy <= 0) { + // User changed direction — abort. + if (armed) reset(true); + pulling = false; armed = false; + return; + } + armed = true; + setIndicator(dy * RESISTANCE); + }, { passive: true }); + + document.addEventListener("touchend", () => { + if (!pulling) return; + pulling = false; + if (lastVisible >= PULL_THRESHOLD) doRefresh(); + else if (armed) reset(true); + armed = false; + }, { passive: true }); + + // Cancellation (e.g. multi-touch resumes / system gesture) — fold gracefully. + document.addEventListener("touchcancel", () => { + if (pulling && armed) reset(true); + pulling = false; armed = false; + }, { passive: true }); +})(); + +// =============== CONAN EXILES MODS TAB =============== +// Steam Workshop-backed mod browser for Conan Exiles. Same async-install +// pattern as the DayZ Mods tab — debounced hero search, ID/URL direct +// install, live job-progress cards, installed-mod list with one-click +// uninstall. Backend lives in controller/cmd/controller/conanmods.go and +// reuses the dayzModJobManager (job state is generic per InstanceID, no +// cross-instance bleed). Reuses .dzm-* CSS classes for visual cohesion +// with the DayZ tab — operators get the same shape. +const conanModsState = { + searchTerm: "", + searchDebounce: null, + searchController: null, + searchResults: [], + searchPreview: null, + mods: [], + jobs: [], + jobPollTimer: null, + sinceOpenAt: 0, + // edition: "enhanced" | "legacy" — set on tab open from the instance's + // config_values["EDITION"]. Drives the workshop search tag filter so a + // UE5 server only sees Enhanced-tagged mods (UE4 mods load silently + // but don't run; filtering them out at search time keeps operators + // from chasing dead installs). Defaults to "enhanced" when the field + // hasn't been set yet (matches the module.yaml default). + edition: "enhanced", +}; + +// conanWorkshopTagForEdition translates "enhanced"/"legacy" → the actual +// Steam Workshop tag names ("Enhanced"/"Legacy") used in requiredtags[] +// URL params on the steamcommunity browse page. Centralised so we can +// add other editions if Funcom ever ships one (Hyborian, who knows). +function conanWorkshopTagForEdition(ed) { + switch ((ed || "").toLowerCase()) { + case "enhanced": return "Enhanced"; + case "legacy": return "Legacy"; + default: return "Enhanced"; // safest default for an unknown value + } +} + +async function loadConanModsTab() { + const id = instanceModalState.id; const wrap = $("#im-cnm-wrap"); if (!id) return; + conanModsState.sinceOpenAt = Date.now() - 2000; + // Pull the instance's edition from the live cache so the workshop + // search filter matches what the server can actually load. Falls back + // to "enhanced" when config_values hasn't been touched (matches the + // module's default — Funcom's current build). + const inst = (allInstances || []).find(i => i.instance_id === id); + const cv = (inst && inst.config_values) || {}; + conanModsState.edition = (cv.EDITION || "enhanced").toLowerCase(); + wrap.innerHTML = loadingState("Loading mod list…"); + try { + const [mods, jobs] = await Promise.all([ + fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`), + fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs`).catch(() => ({jobs:[]})), + ]); + conanModsState.mods = mods.mods || []; + conanModsState.jobs = jobs.jobs || []; + } catch (err) { + wrap.innerHTML = errorState(`Couldn't load mods: ${err.message}`); + return; + } + renderConanModsTab(); + startConanModJobPolling(); +} + +function visibleConanJobs() { + const cutoff = conanModsState.sinceOpenAt || 0; + return (conanModsState.jobs || []).filter(j => { + if (!j.terminal) return true; + if (!j.ended_at) return true; + return new Date(j.ended_at).getTime() >= cutoff; + }); +} + +function startConanModJobPolling() { + if (conanModsState.jobPollTimer) return; + // Same backoff shape as the DayZ job poll: 800ms hot, 2s after 10 + // consecutive identical payloads, snap back to 800ms on any change. + let idleTicks = 0, lastJobsJSON = "", curMs = 800; + const rearm = (ms) => { + if (curMs === ms || !conanModsState.jobPollTimer) return; + curMs = ms; + clearInterval(conanModsState.jobPollTimer); + conanModsState.jobPollTimer = setInterval(tick, ms); + }; + const tick = async () => { + const id = instanceModalState.id; + if (!id || instanceModalState.tab !== "conanmods") { stopConanModJobPolling(); return; } + try { + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs`); + const prev = conanModsState.jobs; + conanModsState.jobs = r.jobs || []; + const jobsJSON = JSON.stringify(conanModsState.jobs); + if (jobsJSON === lastJobsJSON) { + if (++idleTicks >= 10) rearm(2000); + } else { + lastJobsJSON = jobsJSON; idleTicks = 0; rearm(800); + } + const wasActive = jid => prev.find(p => p.id === jid && !p.terminal); + for (const j of conanModsState.jobs) { + if (j.terminal && j.phase === "done" && wasActive(j.id)) { + try { + const m = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`); + conanModsState.mods = m.mods || []; + } catch {} + break; + } + if (j.terminal && j.phase === "login_needed" && wasActive(j.id)) { + // Re-pop the Steam login modal so the operator can re-auth without + // hunting for the Update button. Same UX the DayZ flow uses. + const ok = await openSteamLoginModal(j.error || "Steam session expired."); + if (ok) runConanModInstallRaw(j.workshop_id); + } + } + renderConanModJobs(); + if (!conanModsState.jobs.some(j => !j.terminal)) { + setTimeout(() => { + if (!conanModsState.jobs.some(j => !j.terminal)) stopConanModJobPolling(); + }, 3000); + } + } catch (err) { + console.warn("conan job poll failed", err); + } + }; + tick(); + conanModsState.jobPollTimer = setInterval(tick, 800); +} + +function stopConanModJobPolling() { + if (conanModsState.jobPollTimer) { + clearInterval(conanModsState.jobPollTimer); + conanModsState.jobPollTimer = null; + } +} + +function renderConanModsTab() { + const wrap = $("#im-cnm-wrap"); + const mods = conanModsState.mods; + + // Group by workshop_id so a multi-pak mod renders as one card with + // sub-pak badges. Operators install at the workshop-id level — they + // shouldn't have to think about which paks ship together. + const groups = {}; + const order = []; + for (const m of mods) { + if (!groups[m.workshop_id]) { groups[m.workshop_id] = []; order.push(m.workshop_id); } + groups[m.workshop_id].push(m); + } + const installedRow = wsID => { + const items = groups[wsID]; + const head = items[0]; + const paks = items.map(it => `${esc(it.filename)}`).join(" "); + const thumb = head.preview_url + ? `` + : `
    🧩
    `; + return ` +
    +
    + ${thumb} +
    +
    ${esc(head.title || `Workshop #${wsID}`)}
    +
    + + ${paks} + ${head.size ? `${humanBytes(head.size)}` : ''} +
    +
    +
    + +
    `; + }; + + wrap.innerHTML = ` +
    + + +
    +
    +
    +
    Steam Workshop · Conan Exiles
    +
    Search by name, or paste a Workshop ID / URL to install directly. Downloads are shared across every Conan instance on this agent.
    +
    +
    + + ${conanModsState.edition === 'legacy' ? '🏛️ UE4 · Legacy' : '✨ UE5 · Enhanced'} + + filtering search by tag +
    +
    +
    + 🔎 + + + +
    +
    +
    + + +
    + + +
    +
    +

    🧩 Installed mods (${order.length})

    + listed in modlist.txt load order +
    + ${order.length + ? `
    ${order.map(installedRow).join('')}
    ` + : `
    ${EMPTY_ICONS.puzzle}
    No mods installed yet. Search above to add one.
    `} +
    + Tip: clients connecting to this server are prompted to subscribe to the same workshop items in the in-game launcher; Conan auto-downloads them on join. +
    +
    +
    `; + + const input = $("#im-cnm-search-input"); + input.addEventListener("input", onConanSearchInput); + input.addEventListener("keydown", e => { + if (e.key === "Enter") { + const v = input.value.trim(); + const m = v.match(/(\d{6,20})/); + if (m) { runConanModInstallRaw(m[1]); input.value = ""; renderConanSearchResults(); } + } else if (e.key === "Escape") { + input.value = ""; onConanSearchInput({ target: input }); + } + }); + $("#im-cnm-search-clear").addEventListener("click", () => { + input.value = ""; input.focus(); onConanSearchInput({ target: input }); + }); + $$("#im-cnm-wrap [data-conan-uninstall]").forEach(b => b.addEventListener("click", () => runConanModUninstall(b.dataset.conanUninstall))); + $$("#im-cnm-wrap [data-conan-info]").forEach(b => b.addEventListener("click", () => openConanModInfoModal(b.dataset.conanInfo))); + + renderConanSearchResults(); + renderConanModJobs(); + if (!conanModsState.searchTerm) setTimeout(() => input && input.focus(), 50); +} + +function onConanSearchInput(e) { + const v = (e.target.value || "").trim(); + conanModsState.searchTerm = v; + const clear = $("#im-cnm-search-clear"); + if (clear) clear.style.display = v ? "block" : "none"; + clearTimeout(conanModsState.searchDebounce); + if (conanModsState.searchController) { try { conanModsState.searchController.abort(); } catch {} } + if (!v) { + conanModsState.searchResults = []; + conanModsState.searchPreview = null; + toggleConanSearchSpin(false); + renderConanSearchResults(); + return; + } + toggleConanSearchSpin(true); + conanModsState.searchDebounce = setTimeout(() => runConanLiveSearch(v), 300); +} + +function toggleConanSearchSpin(on) { + const s = $("#im-cnm-search-spin"); + if (s) s.classList.toggle("on", !!on); +} + +async function runConanLiveSearch(q) { + const id = instanceModalState.id; if (!id) return; + const ctrl = new AbortController(); + conanModsState.searchController = ctrl; + try { + // ID-only or full URL with ?id= → fetch detail directly. + const idMatch = q.match(/^\s*(?:https?:\/\/\S*\?id=)?(\d{6,20})\s*$/); + if (idMatch) { + const wsID = idMatch[1]; + const d = await fetch(`/api/workshop/detail?id=${encodeURIComponent(wsID)}`, { signal: ctrl.signal }) + .then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + // Normalise: detail endpoint returns a workshopDetail object directly. + d.workshop_id = wsID; + conanModsState.searchPreview = d; + conanModsState.searchResults = []; + } else { + // The reused /api/workshop/search now takes ?app=440900 (Conan) + + // ?tag=Enhanced|Legacy to filter results to mods compatible with + // this instance's edition. Without the filter operators on a UE5 + // Enhanced server see piles of [Legacy] UE4 mods that load + // silently but don't actually run. + const tag = conanWorkshopTagForEdition(conanModsState.edition); + const d = await fetch(`/api/workshop/search?app=440900&tag=${encodeURIComponent(tag)}&q=${encodeURIComponent(q)}`, { signal: ctrl.signal }) + .then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + conanModsState.searchResults = d.items || []; + conanModsState.searchPreview = null; + } + } catch (err) { + if (err.name === "AbortError") return; + console.warn("conan live search failed", err); + } finally { + if (conanModsState.searchController === ctrl) { + conanModsState.searchController = null; + toggleConanSearchSpin(false); + } + } + renderConanSearchResults(); +} + +function renderConanSearchResults() { + const host = $("#im-cnm-search-results"); if (!host) return; + const q = conanModsState.searchTerm || ""; + if (!q) { host.innerHTML = ""; return; } + if (conanModsState.searchPreview) { + const d = conanModsState.searchPreview; + const installed = conanModsState.mods.some(m => String(m.workshop_id) === String(d.workshop_id || d.id)); + const wsID = d.workshop_id || d.id; + host.innerHTML = ` +
    + ${d.preview_url ? `` : ''} +
    +
    ${esc(d.title || `Workshop #${wsID}`)}
    +
    id ${esc(wsID)} · ${d.size ? humanBytes(d.size) : 'size unknown'}${d.subscriptions ? ` · ${d.subscriptions.toLocaleString()} subscribers` : ''}
    +
    ${esc((d.description || '').slice(0, 320))}${(d.description||'').length > 320 ? '…' : ''}
    +
    + + ${installed + ? `` + : ``} +
    +
    +
    `; + const ib = $("#im-cnm-preview-install"); + if (ib) ib.addEventListener("click", () => runConanModInstallRaw(wsID)); + const info = $("#im-cnm-preview-info"); + if (info) info.addEventListener("click", () => openConanModInfoModal(wsID)); + return; + } + const items = conanModsState.searchResults || []; + if (!items.length) { + host.innerHTML = `
    ${EMPTY_ICONS.search}
    No results for "${esc(q)}". Try a shorter term, or paste a Workshop URL.
    `; + return; + } + const installed = new Set(conanModsState.mods.map(m => String(m.workshop_id)).filter(Boolean)); + const card = it => { + const wsID = String(it.id); + const isInst = installed.has(wsID); + return ` +
    + ${it.preview_url ? `` : '
    🧩
    '} +
    +
    ${esc(it.title || `Workshop #${wsID}`)}
    +
    ${it.size ? humanBytes(it.size) : ''}${it.subscriptions ? ` · ${it.subscriptions.toLocaleString()}↓` : ''}
    +
    + ${isInst + ? `` + : ``} +
    +
    +
    `; + }; + host.innerHTML = `
    ${items.slice(0, 30).map(card).join("")}
    `; + $$("#im-cnm-search-results [data-conan-card]").forEach(c => + c.addEventListener("click", e => { + if (e.target.closest("[data-conan-install-search]")) return; + openConanModInfoModal(c.dataset.conanCard); + })); + $$("#im-cnm-search-results [data-conan-install-search]").forEach(b => + b.addEventListener("click", e => { e.stopPropagation(); runConanModInstallRaw(b.dataset.conanInstallSearch); })); +} + +function renderConanModJobs() { + const host = $("#im-cnm-jobs"); if (!host) return; + const jobs = visibleConanJobs(); + if (!jobs.length) { host.innerHTML = ""; return; } + const card = j => { + const phaseClass = j.terminal + ? (j.phase === "done" ? "ok" : j.phase === "login_needed" ? "warn" : "err") + : "active"; + const pct = Math.max(0, Math.min(100, j.percent || 0)); + const bar = pct > 0 + ? `
    ` + : (!j.terminal ? `
    ` : ''); + const lines = (j.lines || []).slice(-5).map(l => `
    ${esc(l)}
    `).join(""); + return ` +
    +
    +
    +
    ${esc(j.title || `Workshop #${j.workshop_id}`)}
    +
    id ${esc(j.workshop_id)} · ${esc(j.phase)}${j.attempt && j.attempt > 1 ? ` · retry #${j.attempt}` : ''}
    +
    +
    + ${j.can_retry ? `` : ''} + ${j.terminal ? `` : ''} +
    +
    + ${bar} + ${j.error ? `
    ${esc(j.error)}
    ` : ''} + ${lines ? `
    log tail
    ${lines}
    ` : ''} +
    `; + }; + host.innerHTML = `
    ${jobs.map(card).join("")}
    `; + $$("#im-cnm-jobs [data-conan-job-dismiss]").forEach(b => + b.addEventListener("click", async () => { + const id = instanceModalState.id; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs/${encodeURIComponent(b.dataset.conanJobDismiss)}`, { method: "DELETE" }); + conanModsState.jobs = conanModsState.jobs.filter(j => j.id !== b.dataset.conanJobDismiss); + renderConanModJobs(); + } catch {} + })); + $$("#im-cnm-jobs [data-conan-job-retry]").forEach(b => + b.addEventListener("click", () => { + const job = conanModsState.jobs.find(j => j.id === b.dataset.conanJobRetry); + if (job) runConanModInstallRaw(job.workshop_id); + })); +} + +async function runConanModInstallRaw(workshopID) { + const id = instanceModalState.id; if (!id || !workshopID) return; + try { + let r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`, + { method: "POST", body: JSON.stringify({ workshop_id: String(workshopID) }) }); + if (r && r.steam_login_required) { + const ok = await openSteamLoginModal(r.reason || "Sign in to Steam to download workshop content."); + if (!ok) return; + // Retry once after successful login. + r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods`, + { method: "POST", body: JSON.stringify({ workshop_id: String(workshopID) }) }); + } + toast(r && r.duplicate ? `Already installing ${r.title || workshopID}` : `Queued ${r && r.title ? r.title : workshopID}`, "ok"); + startConanModJobPolling(); + // Refresh jobs immediately so the operator sees their queued card. + try { + const jr = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/jobs`); + conanModsState.jobs = jr.jobs || []; + renderConanModJobs(); + } catch {} + } catch (err) { + toast(`Install failed: ${err.message}`, "err"); + } +} + +async function runConanModUninstall(workshopID) { + const id = instanceModalState.id; if (!id || !workshopID) return; + if (!confirm(`Uninstall workshop item ${workshopID}? The downloaded files stay cached on the agent so re-install is instant.`)) return; + try { + await fetchJSON(`/api/instances/${encodeURIComponent(id)}/conan/mods/${encodeURIComponent(workshopID)}`, { method: "DELETE" }); + conanModsState.mods = conanModsState.mods.filter(m => m.workshop_id !== workshopID); + renderConanModsTab(); + toast(`Uninstalled ${workshopID}`, "ok"); + } catch (err) { + toast(`Uninstall failed: ${err.message}`, "err"); + } +} + +// Rich mod info modal for Conan — same shape + visual density as the +// DayZ tab's openModInfoModal: thumbnail + stats sidebar on the left, +// BBCode-rendered description + tags on the right, footer with the +// install action. Reuses the existing #mod-info-modal dialog (it's the +// same DOM the DayZ tab uses; we just paint different button wiring). +async function openConanModInfoModal(workshopID) { + if (!workshopID) return; + const body = $("#mi-body"); + if (!body) { + // Modal not present (older HTML?). Fall back to console error. + console.warn("openConanModInfoModal: #mi-body missing"); + return; + } + $("#mi-title").textContent = "Mod info"; + body.innerHTML = loadingState("Loading Steam Workshop details…"); + showModal("#mod-info-modal"); + let d; + try { + d = await fetchJSON(`/api/workshop/detail?id=${encodeURIComponent(workshopID)}`); + } catch (err) { + body.innerHTML = errorState(`Couldn't load details: ${err.message}`); + return; + } + $("#mi-title").textContent = d.title || "Mod info"; + const installed = conanModsState.mods.some(m => String(m.workshop_id) === String(workshopID)); + const fmtSize = (b) => { + if (!b) return "?"; + const mb = b / 1048576; + if (mb < 1) return `${Math.round(b/1024)} KB`; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + return `${(mb/1024).toFixed(2)} GB`; + }; + const fmtDate = (unix) => { + if (!unix) return "—"; + const dt = new Date(unix * 1000); + return dt.toLocaleDateString() + " " + dt.toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"}); + }; + const fmtNum = (n) => { + n = Number(n) || 0; + if (n < 1000) return String(n); + if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k"; + return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M"; + }; + // Steam Workshop descriptions are BBCode. Reuse the same conservative + // transform as the DayZ tab: only [b][i][u][h*][url=https://…] are + // honored, all other tags stripped, newlines →
    . [img] is dropped + // on purpose — too easy to abuse with on-modal-load network exfil. + const renderDesc = (s) => { + if (!s) return "No description."; + let t = esc(s); + t = t.replace(/\[url=(https?:\/\/[^\s\]]+)\]([^\[]*)\[\/url\]/gi, + (_, u, inner) => `${inner || u}`); + t = t.replace(/\[h[1-6]\](.*?)\[\/h[1-6]\]/gi, "$1"); + t = t.replace(/\[b\](.*?)\[\/b\]/gi, "$1"); + t = t.replace(/\[i\](.*?)\[\/i\]/gi, "$1"); + t = t.replace(/\[u\](.*?)\[\/u\]/gi, "$1"); + t = t.replace(/\[\/?[a-z][^\]]*\]/gi, ""); + t = t.replace(/\n/g, "
    "); + return t; + }; + + body.innerHTML = ` +
    +
    + ${d.preview_url ? `` : ''} +
    +
    Size${fmtSize(d.size)}
    +
    Updated${fmtDate(d.time_updated)}
    +
    Created${fmtDate(d.time_created)}
    +
    Subscribers${fmtNum(d.subscriptions)}
    +
    Favorites${fmtNum(d.favorited)}
    +
    Views${fmtNum(d.views)}
    +
    + +
    +
    + ${(d.tags || []).length ? `
    + ${d.tags.map(tagChip).join("")} +
    ` : ''} +
    + ${renderDesc(d.description)} +
    +
    +
    +
    + + ${installed + ? installedBadge() + : ``} +
    `; + const btn = $("#mi-cnm-install"); + if (btn) btn.addEventListener("click", async () => { + btn.disabled = true; btn.textContent = "⏳ Queuing…"; + try { + await runConanModInstallRaw(workshopID); + hideModal("#mod-info-modal"); + } catch (err) { + btn.disabled = false; btn.textContent = "⬇ Install"; + toast(`Install failed: ${err.message}`, "err"); + } + }); +} + +// Tiny byte-formatter — shared with the DayZ tab via window scope but +// declared here defensively so the Conan tab still works if loaded +// before the DayZ helpers. +if (typeof window.humanBytes !== "function") { + window.humanBytes = function(n) { + if (!n || n < 1024) return `${n||0} B`; + if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`; + if (n < 1024*1024*1024) return `${(n/(1024*1024)).toFixed(1)} MB`; + return `${(n/(1024*1024*1024)).toFixed(2)} GB`; + }; +} + +// =============== 7DTD MODS TAB =============== +// CurseForge-backed mod browser for 7 Days to Die. Same visual UI as +// the ARK Mods tab — search bar, results grid, click for details. +// Differs in install: 7DTD has no built-in CurseForge downloader, so +// the operator downloads the zip from CurseForge in a new tab and +// drops it into the Files tab → Game Files → Mods/. Auto-install +// (download + extract via agent sidecar) is a Phase 2 follow-up. +const sdtdModsState = { + searchTerm: "", + searchResults: [], + searchPreview: null, + searchDebounce: null, + searchController: null, + installed: [], // [{name}] — folders under /game/Mods/ + nexusKey: { configured: false, premium: false, name: "" }, + installing: false, +}; + +async function _sdtdCheckNexusKey() { + try { + const r = await fetchJSON("/api/7dtd/nexus/check"); + sdtdModsState.nexusKey = { + configured: !!r.configured, premium: !!r.premium, name: r.name || "", + error: r.error || "" + }; + } catch { + sdtdModsState.nexusKey = { configured: false, premium: false }; + } +} + +async function loadSdtdModsTab() { + const id = instanceModalState.id; const wrap = $("#im-sdtdm-wrap"); if (!id) return; + wrap.innerHTML = loadingState("Loading installed mods…"); + await _sdtdCheckNexusKey(); + // Try to read /game/Mods/ on the agent. If the directory doesn't exist + // yet (game not installed, or never had mods), surface that as an + // empty list rather than an error. + try { + const q = new URLSearchParams({ path: "/game/Mods" }); + const r = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/files?${q}`).catch(() => ({entries: []})); + sdtdModsState.installed = (r.entries || []).filter(e => e.is_dir).map(e => ({ name: e.name })); + } catch (err) { + sdtdModsState.installed = []; + } + renderSdtdModsTab(); +} + +function renderSdtdModsTab() { + const wrap = $("#im-sdtdm-wrap"); + const installed = sdtdModsState.installed || []; + const installedRow = (m) => ` +
    +
    🧩
    +
    +
    ${esc(m.name)}
    +
    + /game/Mods/${esc(m.name)} +
    +
    + 📁 Files +
    `; + + wrap.innerHTML = ` +
    + +
    +
    +
    Drop mod zip files here to install
    +
    + Multiple zips at once · nested zips supported · auto-detects ModInfo.xml · already-installed mods are skipped automatically +
    + +
    +
    + +
    +
    +
    +
    🔎 Browse Nexus Mods (7 Days to Die)
    +
    Find a mod on Nexus, download the zip, then drop it into the zone above. Auto-install via API was investigated but Nexus locks direct downloads behind Premium — stick with the drop zone.
    +
    +
    +
    + 🔎 + + + +
    +
    +
    + +
    +
    +

    📦 Installed mods (${installed.length})

    + scanned from /game/Mods/ +
    + ${installed.length + ? `
    ${installed.map(installedRow).join('')}
    ` + : `
    ${EMPTY_ICONS.box}
    No mods detected in /game/Mods/ yet.Use the Files tab to drop a mod folder in, or grab one from the search above.
    `} +
    + + ${_sdtdHelpBannerHTML()} +
    `; + + // Drop zone wiring — drag/drop, click to browse, multi-zip upload. + _sdtdWireDropZone(); + + const input = $("#im-sdtdm-search-input"); + input.addEventListener("input", onSdtdModSearchInput); + input.addEventListener("keydown", e => { + if (e.key === "Enter") { + const v = input.value.trim(); + const m = v.match(/(\d{4,12})/); + if (m) { + // pasted a CurseForge URL or numeric id — fetch detail + runSdtdLiveSearch(v); + } + } else if (e.key === "Escape") { + input.value = ""; onSdtdModSearchInput({ target: input }); + } + }); + $("#im-sdtdm-search-clear").addEventListener("click", () => { + input.value = ""; input.focus(); onSdtdModSearchInput({ target: input }); + }); + // Installed-row "Files" link → switch to the Files tab + navigate to /game/Mods. + $$("#im-sdtdm-wrap [data-sdtdm-open-files]").forEach(b => b.addEventListener("click", e => { + e.preventDefault(); + if (typeof switchTab === "function") { + switchTab("files"); + // Best-effort: try to navigate the file browser to /game/Mods. + setTimeout(() => { + const rootBtn = document.querySelector(".fs-root-btn[data-fs-root='/game']"); + if (rootBtn) rootBtn.click(); + // navigateToPath isn't a stable API — leave the operator at the + // chosen root, they can drill in. The Mods/ folder will be + // visible at the top of the Game Files root. + }, 200); + } + })); + + renderSdtdSearchResults(); + if (!sdtdModsState.searchTerm) setTimeout(() => input && input.focus(), 50); +} + +function onSdtdModSearchInput(e) { + const v = (e.target.value || "").trim(); + sdtdModsState.searchTerm = v; + const clear = $("#im-sdtdm-search-clear"); + if (clear) clear.style.display = v ? "block" : "none"; + clearTimeout(sdtdModsState.searchDebounce); + if (sdtdModsState.searchController) { try { sdtdModsState.searchController.abort(); } catch {} } + if (!v) { + sdtdModsState.searchResults = []; + sdtdModsState.searchPreview = null; + toggleSdtdSearchSpin(false); + renderSdtdSearchResults(); + return; + } + toggleSdtdSearchSpin(true); + sdtdModsState.searchDebounce = setTimeout(() => runSdtdLiveSearch(v), 300); +} + +function toggleSdtdSearchSpin(on) { + const s = $("#im-sdtdm-search-spin"); + if (s) s.classList.toggle("on", !!on); +} + +async function runSdtdLiveSearch(q) { + const ctrl = new AbortController(); + sdtdModsState.searchController = ctrl; + try { + const idMatch = q.match(/^\s*(?:https?:\/\/\S+?)?(?:[?&/])?(\d{4,12})\s*$/); + if (idMatch) { + const mid = idMatch[1]; + const d = await fetch(`/api/7dtd/mods/detail?id=${encodeURIComponent(mid)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + sdtdModsState.searchPreview = d; + sdtdModsState.searchResults = []; + } else { + const d = await fetch(`/api/7dtd/mods/search?q=${encodeURIComponent(q)}`, { signal: ctrl.signal }).then(r => r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))); + if (ctrl.signal.aborted) return; + sdtdModsState.searchResults = d.items || []; + sdtdModsState.searchPreview = null; + } + } catch (err) { + if (err.name === "AbortError") return; + console.warn("7dtd live search failed", err); + } finally { + if (sdtdModsState.searchController === ctrl) { + sdtdModsState.searchController = null; + toggleSdtdSearchSpin(false); + } + } + renderSdtdSearchResults(); +} + +function renderSdtdSearchResults() { + const host = $("#im-sdtdm-search-results"); if (!host) return; + const q = sdtdModsState.searchTerm || ""; + if (!q) { host.innerHTML = ""; return; } + if (sdtdModsState.searchPreview) { + host.innerHTML = renderSdtdSearchPreview(sdtdModsState.searchPreview); + return; + } + const items = sdtdModsState.searchResults || []; + if (!items.length) { + host.innerHTML = `
    ${EMPTY_ICONS.search}
    No Nexus 7DTD mods found for "${esc(q)}"Try a shorter term, or paste a Nexus mod ID.
    `; + return; + } + host.innerHTML = `
    ${items.slice(0, 30).map(sdtdSearchCard).join("")}
    `; + // Click a card → fetch detail for the rich preview. Click an inner + // link or button (Install / Open on Nexus) → don't intercept. + $$("#im-sdtdm-search-results [data-sdtdm-card]").forEach(c => c.addEventListener("click", e => { + if (e.target.closest("a, button")) return; + runSdtdLiveSearch(c.dataset.sdtdmCard); + })); + // Wire install buttons (cards + preview). + _sdtdWireInstallButtons(); +} + +function _sdtdWireInstallButtons() { + $$("#im-sdtdm-wrap [data-sdtdm-install]").forEach(b => { + if (b.dataset._wired === "1") return; + b.dataset._wired = "1"; + b.addEventListener("click", e => { + e.stopPropagation(); + const modID = b.dataset.sdtdmInstall; + const name = b.dataset.sdtdmName || ""; + _sdtdInstallFromNexus(modID, name); + }); + }); +} + +// _sdtdInstallFromNexus — full install flow: +// 1. Fetch /api/7dtd/nexus/files/ — list main files +// 2. If multiple, show a tiny picker; if one, pick automatically +// 3. POST /api/instances/{id}/7dtd/nexus/install with {mod_id, file_id} +// 4. Toast progress, then refresh installed list +// Drop-zone wiring. Click the zone → opens the file picker. Drag files +// over → adds .drag-over class for the animated highlight. Drop → +// uploads via multipart and renders per-mod results. Multiple zips +// allowed; the server handles nested zips and dedupes on existing +// /game/Mods/ folder names. +// Window-level drag-drop guard. Without this, dropping a file ANYWHERE +// outside our zone (including drops that bubble past it) triggers the +// browser's default behavior — navigating to the dropped file's URL, +// blowing away the panel session. We install one set of listeners +// once; they swallow the default for any file drop on the window. Our +// zone's specific drop handler still fires for valid in-zone drops. +let _sdtdWindowGuardInstalled = false; +function _sdtdInstallWindowGuard() { + if (_sdtdWindowGuardInstalled) return; + _sdtdWindowGuardInstalled = true; + const swallow = (e) => { + // Only swallow when the user is dragging actual files. Internal + // drag events (text selections, etc.) shouldn't be touched. + if (e.dataTransfer && e.dataTransfer.types && Array.from(e.dataTransfer.types).includes("Files")) { + e.preventDefault(); + } + }; + window.addEventListener("dragover", swallow, false); + window.addEventListener("drop", swallow, false); +} + +function _sdtdWireDropZone() { + _sdtdInstallWindowGuard(); + const zone = $("#im-sdtdm-drop"); + const file = $("#im-sdtdm-file"); + if (!zone || !file) return; + + // Avoid re-binding when the tab re-renders (loadSdtdModsTab fires on + // every tab open). dataset._wired serves as our idempotency flag. + if (zone.dataset._wired === "1") return; + zone.dataset._wired = "1"; + + // Click the zone (anywhere except the hidden file input) → opens the + // native file picker. + zone.addEventListener("click", (e) => { + if (e.target.tagName === "INPUT") return; + file.click(); + }); + file.addEventListener("change", () => { + if (file.files && file.files.length) _sdtdUploadFiles(Array.from(file.files)); + file.value = ""; // allow re-uploading the same file + }); + + // Drag handling — counter-based so child elements don't flicker + // dragenter/dragleave. + let counter = 0; + zone.addEventListener("dragenter", (e) => { + e.preventDefault(); + e.stopPropagation(); + counter++; + zone.classList.add("drag-over"); + }); + zone.addEventListener("dragover", (e) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "copy"; + }); + zone.addEventListener("dragleave", (e) => { + e.preventDefault(); + e.stopPropagation(); + counter = Math.max(0, counter - 1); + if (counter === 0) zone.classList.remove("drag-over"); + }); + zone.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + counter = 0; + zone.classList.remove("drag-over"); + if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) { + toast("No files detected in the drop", "err"); + return; + } + const files = Array.from(e.dataTransfer.files).filter(f => /\.zip$/i.test(f.name)); + if (!files.length) { + toast(`Need .zip files — got ${Array.from(e.dataTransfer.files).map(f => f.name).join(", ")}`, "err"); + return; + } + _sdtdUploadFiles(files); + }); +} + +async function _sdtdUploadFiles(files) { + const id = instanceModalState.id; if (!id) return; + const status = $("#im-sdtdm-upload-status"); + if (!status) return; + const totalBytes = files.reduce((s, f) => s + f.size, 0); + const tStart = Date.now(); + // Show upload progress UI with a live elapsed timer + phase hint. + const renderPhase = (phase) => { + const elapsed = Math.floor((Date.now() - tStart) / 1000); + const elTxt = elapsed < 60 ? `${elapsed}s` : `${Math.floor(elapsed/60)}m ${elapsed%60}s`; + status.innerHTML = ` +
    +
    + + ${phase} · ${files.length} zip${files.length === 1 ? "" : "s"} (${(totalBytes/1048576).toFixed(1)} MB) + ${elTxt} +
    +
    +
    + Big overhauls (Darkness Falls / War of the Walkers) can take 30–90 seconds — files are written one at a time over a Docker exec channel. Progress is happening; check the Files tab to see new mod folders appearing. +
    +
    + `; + }; + renderPhase("Uploading + extracting"); + // Tick the elapsed counter every second. + const tickTimer = setInterval(() => renderPhase("Uploading + extracting"), 1000); + + // 10-minute hard timeout via AbortController so a true hang surfaces + // as a real error instead of an infinite spinner. + const ac = new AbortController(); + const timeoutId = setTimeout(() => ac.abort("client_timeout"), 10 * 60 * 1000); + + const form = new FormData(); + for (const f of files) form.append("files", f); + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/7dtd/mods/upload`, { + method: "POST", + body: form, + signal: ac.signal, + }).then(async res => { + const j = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(j.error || j.message || `${res.status}`); + return j; + }); + _sdtdRenderUploadResults(r); + loadSdtdModsTab(); + } catch (err) { + const reason = err && err.name === "AbortError" + ? "Timed out after 10 minutes — the install is probably still running on the server. Check the Files tab to see what made it through, then refresh this tab." + : "Upload failed: " + (err && err.message || err); + status.innerHTML = `
    +
    ${esc(reason)}
    +
    `; + } finally { + clearTimeout(timeoutId); + clearInterval(tickTimer); + } +} + +function _sdtdRenderUploadResults(r) { + const status = $("#im-sdtdm-upload-status"); + if (!status) return; + const results = r.results || []; + if (r.warning) { + status.innerHTML = `
    +
    ${esc(r.warning)}
    +
    `; + return; + } + if (!results.length) { + status.innerHTML = `
    No mods detected.
    `; + return; + } + const installed = results.filter(x => x.status === "installed").length; + const skipped = results.filter(x => x.status === "skipped").length; + const failed = results.filter(x => x.status === "failed").length; + const headline = [ + installed ? `${installed} installed` : "", + skipped ? `${skipped} skipped` : "", + failed ? `${failed} failed` : "", + ].filter(Boolean).join(" · "); + const rows = results.map((m, i) => { + const cls = m.status || "skipped"; + const icon = cls === "installed" ? "✓" : cls === "skipped" ? "↩" : "✕"; + const meta = [ + m.display_name && m.display_name !== m.name ? esc(m.display_name) : null, + m.version ? `v${esc(m.version)}` : null, + m.author ? `by ${esc(m.author)}` : null, + m.file_count ? `${m.file_count} files` : null, + m.note ? esc(m.note) : null, + m.error ? `${esc(m.error)}` : null, + ].filter(Boolean).join(" · "); + return `
    + ${icon} +
    +
    ${esc(m.name)}
    + ${meta ? `
    ${meta}
    ` : ""} +
    + ${cls} +
    `; + }).join(""); + status.innerHTML = ` +
    +
    + 📦 + ${headline || `${results.length} mods processed`} + ${installed ? `restart server to load` : ""} +
    +
    +
    ${rows}
    + `; +} + +async function _sdtdInstallFromNexus(modID, modName) { + const id = instanceModalState.id; if (!id) return; + if (sdtdModsState.installing) { toast("Already installing — please wait", "err"); return; } + sdtdModsState.installing = true; + try { + const r = await fetchJSON(`/api/7dtd/nexus/files/${encodeURIComponent(modID)}`); + const files = (r.files || []).filter(f => f.is_primary || f.category === "MAIN" || f.category === "Main"); + let picked = files.find(f => f.is_primary) || files[0] || (r.files || [])[0]; + if (!picked) { toast("No installable files found on Nexus for that mod", "err"); return; } + // If there are multiple main files, prompt for choice. + if (files.length > 1) { + const choices = files.map((f, i) => `${i + 1}. ${f.name} ${f.version ? `(v${f.version})` : ""} — ${(f.size/1048576).toFixed(1)} MB`).join("\n"); + const pick = prompt(`Multiple main files available for "${modName}":\n\n${choices}\n\nEnter a number (1–${files.length}) to install:`, "1"); + const n = parseInt(pick, 10); + if (!n || n < 1 || n > files.length) return; + picked = files[n - 1]; + } + toast(`Downloading "${picked.name}" (${(picked.size/1048576).toFixed(1)} MB)…`); + const installRes = await fetchJSON(`/api/instances/${encodeURIComponent(id)}/7dtd/nexus/install`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ mod_id: parseInt(modID, 10), file_id: picked.file_id, name: modName }), + }); + toast(`Installed ${installRes.folder} — ${installRes.files_written} files (${(installRes.bytes_written/1048576).toFixed(1)} MB). Restart the server to load.`, "ok"); + await loadSdtdModsTab(); + } catch (err) { + toast("Install failed: " + err.message, "err"); + } finally { + sdtdModsState.installing = false; + } +} + +function _sdtdHelpBannerHTML() { + const k = sdtdModsState.nexusKey || {}; + if (k.configured && k.premium) { + return `
    +
    + ✅ Auto-install enabled. Click + Install on any mod and the panel will download from Nexus, extract, and drop it into /game/Mods/ automatically. Restart the server to load. ${k.name ? `(API key: ${esc(k.name)}, Premium ✓)` : ""} +
    +
    `; + } + if (k.configured && !k.premium) { + return `
    +
    + ⚠ Nexus API key configured, but the account isn't Premium. Nexus only allows direct download URLs via API for Premium members. ${k.error ? `(${esc(k.error)})` : ""} +

    + Until upgraded: click a mod → Open on Nexus → download manually → upload into /game/Mods/ via the Files tab. +
    +
    `; + } + return `
    +
    + 💡 How 7DTD mod installs work today: Click a result → opens the mod's page on Nexus. Click FilesManual download, save the zip, extract, then upload the ModName/ folder into /game/Mods/ via the Files tab → Game Files. Restart the server to load. +

    + 🛠 Want one-click auto-install? Get a Nexus Premium API key ($5/mo or $40 lifetime), then on the controller host: +
    sudo systemctl edit panel-controller    # add Environment="PANEL_NEXUS_API_KEY=your-key-here"
    +sudo systemctl restart panel-controller
    + Then refresh this tab — the Install buttons will activate. +
    +
    `; +} + +function sdtdSearchCard(it) { + const fmtDownloads = (n) => { + n = Number(n) || 0; + if (n < 1000) return String(n); + if (n < 1000000) return (n/1000).toFixed(1).replace(/\.0$/, "") + "k"; + return (n/1000000).toFixed(1).replace(/\.0$/, "") + "M"; + }; + const site = it.website || `https://www.nexusmods.com/games/7daystodie`; + const canInstall = sdtdModsState.nexusKey.configured && sdtdModsState.nexusKey.premium; + return `
    + ${it.logo_url ? `` : ''} +
    +
    ${esc(it.name || '(untitled)')}
    +
    + ${it.author ? `${esc(it.author)}` : ''} + ${it.download_count ? `· ⬇ ${fmtDownloads(it.download_count)}` : ''} +
    +
    + ${canInstall ? `` : ''} + 🔗 Nexus +
    +
    +
    `; +} + +function renderSdtdSearchPreview(d) { + const site = d.website || `https://www.nexusmods.com/7daystodie/mods/${d.slug || d.id}`; + const updated = d.updated || d.released || ""; + return `
    + ${d.logo_url ? `` : `
    no preview
    `} +
    +
    ${esc(d.name || 'Mod #' + d.id)}
    +
    + 🆔 ${esc(d.id)} + ${d.author ? `👤 ${esc(d.author)}` : ''} + ${d.file_size_human ? `📦 ${esc(d.file_size_human)}` : ''} + ${d.download_count ? `⬇ ${Number(d.download_count).toLocaleString()}` : ''} + ${updated ? `🕒 ${esc(updated)}` : ''} +
    +
    ${esc(d.summary || 'No summary available.')}
    +
    +
    + ${(sdtdModsState.nexusKey.configured && sdtdModsState.nexusKey.premium) + ? `` + : `🔗 Open on Nexus`} + View on Nexus +
    +
    `; +} + +// =============== COMMAND PALETTE (Ctrl/Cmd+K) =============== +// Search-everywhere jumper. Items come from four sources: +// * Servers (every instance) → opens its Manage modal +// * Quick actions (Start/Stop/Restart per server) +// * Pages (Settings, Docs, + New server, Steam Accounts, etc.) +// * Docs sections (jumps inside the docs modal) +// Filter is plain substring match across the item's title + a +// pre-built keyword string. Up/Down navigates, Enter executes. +const cmdkState = { selectedIndex: 0, visibleItems: [] }; + +function _cmdkBuildItems() { + const items = []; + // Servers + const modules = allModules || []; + for (const i of allInstances || []) { + const mod = modules.find(m => m.id === i.module_id) || {}; + const app = (typeof appearanceFor === "function") ? appearanceFor(i.module_id) : {}; + const ic = (app && app.emoji) || "🎮"; + items.push({ + kind: "server", + ic, title: i.instance_id, + sub: `${mod.name || i.module_id} · ${i.status || "unknown"} · ${i.agent_id || "—"}`, + action: () => openInstanceModal(i.instance_id), + keywords: `${i.instance_id} ${i.module_id} ${mod.name || ""} ${i.agent_id || ""} ${i.status || ""}`.toLowerCase(), + }); + // Quick actions per server, contextual to running state. + if (i.status === "running") { + items.push({ + kind: "action", ic: "■", + title: `Stop ${i.instance_id}`, + sub: "Stop the server (graceful shutdown)", + action: () => doInstanceAction(i.instance_id, "stop", null), + keywords: `stop ${i.instance_id}`.toLowerCase(), + }); + items.push({ + kind: "action", ic: "↻", + title: `Restart ${i.instance_id}`, + sub: "Stop, then start", + action: () => doInstanceRestart(i.instance_id, null), + keywords: `restart ${i.instance_id}`.toLowerCase(), + }); + } else { + items.push({ + kind: "action", ic: "▶", + title: `Start ${i.instance_id}`, + sub: "Start the server", + action: () => doInstanceAction(i.instance_id, "start", null), + keywords: `start ${i.instance_id} run launch`.toLowerCase(), + }); + } + items.push({ + kind: "action", ic: "💾", + title: `Backup ${i.instance_id}`, + sub: "Take a snapshot now", + action: () => { openInstanceModal(i.instance_id); setTimeout(() => { switchTab && switchTab("backups"); $("#im-b-create")?.click(); }, 350); }, + keywords: `backup ${i.instance_id} snapshot save`.toLowerCase(), + }); + } + // Pages + items.push({ kind: "page", ic: "⚙", title: "Settings", sub: "Density, refresh interval, notifications, About", + action: () => (typeof openSettingsModal === "function" && openSettingsModal()), + keywords: "settings preferences density refresh notifications" }); + items.push({ kind: "page", ic: "📘", title: "Docs", sub: "Open the RGSP documentation", + action: () => (typeof openDocsModal === "function" && openDocsModal()), + keywords: "docs documentation help guide" }); + items.push({ kind: "page", ic: "+", title: "+ New server", sub: "Spin up a new game server", + action: () => showModal("#new-server-modal"), + keywords: "new server create add make" }); + items.push({ kind: "page", ic: "+", title: "+ Pair agent", sub: "Add a new agent host", + action: () => $("#pair-agent")?.click(), + keywords: "pair agent new host machine" }); + items.push({ kind: "page", ic: "🎮", title: "Steam Accounts", sub: "Link your Steam ID, manage panel users", + action: () => (typeof openSteamAccountsModal === "function" && openSteamAccountsModal()), + keywords: "steam accounts auth users" }); + // Docs sections — jumps inside the docs modal + const docSections = [ + { id: "welcome", t: "Docs: Welcome" }, + { id: "architecture", t: "Docs: Architecture" }, + { id: "quickstart", t: "Docs: Quick start" }, + { id: "tile", t: "Docs: Server tile" }, + { id: "manage", t: "Docs: Manage modal" }, + { id: "tab-config", t: "Docs: Config tab" }, + { id: "tab-files", t: "Docs: Files tab" }, + { id: "tab-backups", t: "Docs: Backups tab" }, + { id: "tab-schedules", t: "Docs: Schedules tab" }, + { id: "ark", t: "Docs: ARK Survival Ascended" }, + { id: "ark-mods", t: "Docs: ARK Mods" }, + { id: "ark-cluster", t: "Docs: ARK Clusters" }, + { id: "dayz", t: "Docs: DayZ" }, + { id: "agents", t: "Docs: Agents" }, + { id: "networking", t: "Docs: Networking" }, + { id: "shortcuts", t: "Docs: Keyboard shortcuts" }, + { id: "troubleshooting",t: "Docs: Troubleshooting" }, + ]; + for (const ds of docSections) { + items.push({ + kind: "doc", ic: "📖", title: ds.t, sub: "RGSP documentation", + action: () => (typeof openDocsModal === "function" && openDocsModal(ds.id)), + keywords: ds.t.toLowerCase(), + }); + } + return items; +} + +function _cmdkRender(query) { + const all = _cmdkBuildItems(); + const q = (query || "").trim().toLowerCase(); + const filtered = q + ? all.filter(it => it.keywords.includes(q) || it.title.toLowerCase().includes(q)) + : all.slice(0, 30); // unfiltered: show first 30 so the modal isn't a wall of text + cmdkState.visibleItems = filtered.slice(0, 60); + if (cmdkState.selectedIndex >= cmdkState.visibleItems.length) cmdkState.selectedIndex = 0; + + const groups = { server: [], action: [], page: [], doc: [] }; + for (const it of cmdkState.visibleItems) (groups[it.kind] || (groups[it.kind] = [])).push(it); + const sectionTitle = { server: "Servers", action: "Quick actions", page: "Pages", doc: "Documentation" }; + + const host = $("#cmdk-results"); + if (!cmdkState.visibleItems.length) { + host.innerHTML = `
    ${EMPTY_ICONS.search}
    No matches for ${esc(query)}
    Try a server name, "start", "backup", or a docs topic.
    `; + return; + } + let html = ""; + let idx = 0; + for (const k of ["server", "action", "page", "doc"]) { + if (!groups[k] || !groups[k].length) continue; + html += `
    ${sectionTitle[k]}
    `; + for (const it of groups[k]) { + html += `
    + ${esc(it.ic)} +
    +
    ${esc(it.title)}
    +
    ${esc(it.sub)}
    +
    + ${esc(sectionTitle[k] || k)} +
    `; + idx++; + } + } + host.innerHTML = html; +} + +function _cmdkSelect(delta) { + const n = cmdkState.visibleItems.length; + if (!n) return; + cmdkState.selectedIndex = (cmdkState.selectedIndex + delta + n) % n; + document.querySelectorAll("#cmdk-results .cmdk-row").forEach((r, i) => { + const sel = i === cmdkState.selectedIndex; + r.classList.toggle("selected", sel); + if (sel) r.scrollIntoView({ block: "nearest" }); + }); +} + +function _cmdkExecute() { + const it = cmdkState.visibleItems[cmdkState.selectedIndex]; + if (!it) return; + hideModal("#cmdk-modal"); + // Defer until the close animation has ticked one frame so the next + // modal/page open doesn't fight the cmdk close. + setTimeout(() => { try { it.action && it.action(); } catch (err) { console.warn("cmdk action failed:", err); } }, 90); +} + +function openCmdK() { + showModal("#cmdk-modal"); + const inp = $("#cmdk-input"); + if (!inp) return; + inp.value = ""; + cmdkState.selectedIndex = 0; + _cmdkRender(""); + setTimeout(() => inp.focus(), 60); +} + +// Topbar hint shows ⌘K on Mac, Ctrl K elsewhere. +(() => { + const k = $("#topbar-cmdk-key"); + if (k && /Mac|iP(hone|ad)/i.test(navigator.platform)) k.textContent = "⌘ K"; +})(); + +// Topbar "+ New server" button — defers to the existing sidebar +// new-server flow so init logic (agents list, module picker tiles) +// runs the same way regardless of which entry point fired it. +const _topbarNewBtn = $("#topbar-new"); +if (_topbarNewBtn) _topbarNewBtn.addEventListener("click", () => { + const sidebarBtn = $("#new-server"); + if (sidebarBtn) sidebarBtn.click(); + else showModal("#new-server-modal"); +}); + +// Wire keyboard + click handlers. +document.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) { + e.preventDefault(); + e.stopPropagation(); + openCmdK(); + } +}); +const _topbarCmdkBtn = $("#topbar-cmdk"); +if (_topbarCmdkBtn) _topbarCmdkBtn.addEventListener("click", () => openCmdK()); + +const _cmdkInputEl = $("#cmdk-input"); +if (_cmdkInputEl) { + _cmdkInputEl.addEventListener("input", (e) => _cmdkRender(e.target.value)); + _cmdkInputEl.addEventListener("keydown", (e) => { + if (e.key === "ArrowDown") { e.preventDefault(); _cmdkSelect(+1); } + else if (e.key === "ArrowUp") { e.preventDefault(); _cmdkSelect(-1); } + else if (e.key === "Enter") { e.preventDefault(); _cmdkExecute(); } + else if (e.key === "Escape") { e.preventDefault(); hideModal("#cmdk-modal"); } + }); +} +const _cmdkResultsEl = $("#cmdk-results"); +if (_cmdkResultsEl) { + _cmdkResultsEl.addEventListener("click", (e) => { + const row = e.target.closest("[data-cmdk-idx]"); + if (!row) return; + cmdkState.selectedIndex = parseInt(row.dataset.cmdkIdx, 10) || 0; + _cmdkExecute(); + }); +} + +// =============== SERVICE WORKER REGISTRATION =============== +// Required for PWA installability on Android Chrome. Also enables +// "Add to Home Screen" with full-screen launch on iOS Safari. +if ("serviceWorker" in navigator) { + // Defer to load so we don't compete with the initial paint. + window.addEventListener("load", () => { + navigator.serviceWorker.register("/sw.js").catch(err => { + console.warn("RGSP service worker registration failed:", err); + }); + }); +} + +// Wire the Settings cog in the topbar + the sidebar Settings/About/Docs links. +const _settingsBtn = $("#open-settings"); +if (_settingsBtn) _settingsBtn.addEventListener("click", openSettingsModal); +document.querySelectorAll(".side-link").forEach(l => { + if (l.dataset.scroll === "settings" || l.dataset.scroll === "about") { + l.addEventListener("click", e => { e.preventDefault(); openSettingsModal(); }); + } + if (l.dataset.scroll === "docs") { + l.addEventListener("click", e => { e.preventDefault(); openDocsModal(); }); + } +}); + +// =============== DOCS MODAL =============== +function openDocsModal(anchor) { + showModal("#docs-modal"); + // Default landing: top of welcome. + const target = anchor || "welcome"; + setTimeout(() => _docsScrollTo(target), 80); +} + +function _docsScrollTo(id) { + const content = $("#docs-content"); + const sec = content && content.querySelector(`#${CSS.escape(id)}`); + if (!content || !sec) return; + // Compute the section's offset *relative to the scroll container* — + // section.offsetTop walks up to the nearest positioned ancestor, which + // for the docs panel is somewhere near the modal root, NOT .docs-content + // itself. Using getBoundingClientRect math lands us on the correct + // scrollTop so the smooth scroll actually moves to the right spot. + const contentRect = content.getBoundingClientRect(); + const secRect = sec.getBoundingClientRect(); + const target = (secRect.top - contentRect.top) + content.scrollTop - 6; + content.scrollTo({ top: Math.max(0, target), behavior: "smooth" }); + _docsSetActiveLink(id); + _docsFlashSection(sec); +} + +function _docsFlashSection(sec) { + sec.classList.remove("docs-flash"); + // Force reflow so the animation can re-trigger on the same element. + void sec.offsetWidth; + sec.classList.add("docs-flash"); +} + +function _docsSetActiveLink(id) { + document.querySelectorAll("#docs-modal .docs-rail-link").forEach(a => { + const href = a.getAttribute("href") || ""; + a.classList.toggle("active", href === "#" + id); + }); +} + +// Rail + in-content anchor clicks → smooth-scroll inside the modal. +// Use event delegation on the modal root so it covers both the static +// rail links AND any cross-reference inside the +// docs prose. +const _docsModal = $("#docs-modal"); +if (_docsModal) { + _docsModal.addEventListener("click", e => { + const a = e.target.closest("a[href^='#']"); + if (!a || !_docsModal.contains(a)) return; + const href = a.getAttribute("href") || ""; + if (!href.startsWith("#") || href === "#") return; + e.preventDefault(); + e.stopPropagation(); + _docsScrollTo(href.slice(1)); + }); +} + +// Active-section tracker — on every scroll tick, find the last section +// whose top has passed the content's top-band (~80px in). That's the +// section "you're reading right now". Fresh DOM measurements per frame, +// scoped to a requestAnimationFrame so the work is bounded. +(() => { + const content = $("#docs-content"); + if (!content) return; + const sections = Array.from(content.querySelectorAll("section[id]")); + if (!sections.length) return; + let raf = 0; + const update = () => { + raf = 0; + const contentTop = content.getBoundingClientRect().top; + let best = sections[0]; + for (const s of sections) { + const top = s.getBoundingClientRect().top - contentTop; + if (top <= 90) best = s; else break; + } + if (best) _docsSetActiveLink(best.id); + }; + content.addEventListener("scroll", () => { + if (!raf) raf = requestAnimationFrame(update); + }, { passive: true }); +})(); + +// Filter input on the rail — hides non-matching links AND non-matching +// sections so a search like "ark" focuses the operator instantly. +(() => { + const inp = $("#docs-search"); + if (!inp) return; + inp.addEventListener("input", () => { + const q = inp.value.trim().toLowerCase(); + const links = document.querySelectorAll("#docs-modal .docs-rail-link"); + const sections = document.querySelectorAll("#docs-content section"); + if (!q) { + links.forEach(l => l.removeAttribute("data-doc-hidden")); + sections.forEach(s => s.removeAttribute("data-doc-hidden")); + const empty = $("#docs-content .docs-no-results"); if (empty) empty.remove(); + return; + } + let anyVisible = 0; + sections.forEach(s => { + const hay = (s.textContent || "").toLowerCase(); + const id = s.id; + const linkText = Array.from(links).find(l => l.getAttribute("href") === "#" + id); + const linkHay = linkText ? (linkText.textContent || "").toLowerCase() : ""; + const match = hay.includes(q) || linkHay.includes(q); + if (match) { + s.removeAttribute("data-doc-hidden"); + if (linkText) linkText.removeAttribute("data-doc-hidden"); + anyVisible++; + } else { + s.setAttribute("data-doc-hidden", "1"); + if (linkText) linkText.setAttribute("data-doc-hidden", "1"); + } + }); + // Show a "no results" placeholder when nothing matches. + let empty = $("#docs-content .docs-no-results"); + if (anyVisible === 0) { + if (!empty) { + empty = document.createElement("div"); + empty.className = "docs-no-results"; + $("#docs-content").appendChild(empty); + } + empty.innerHTML = `
    🔍
    No sections match ${esc(inp.value)}
    Try a shorter term, or clear to see everything.
    `; + } else if (empty) empty.remove(); + }); +})(); + +// About-card "📘 Docs" link inside the Settings modal. +const _aboutDocsLink = $("#about-open-docs"); +if (_aboutDocsLink) _aboutDocsLink.addEventListener("click", e => { + e.preventDefault(); + hideModal("#settings-modal"); + setTimeout(() => openDocsModal(), 180); +}); + +// Settings change → save + apply. +function _onSettingsChange() { + userSettings.density = $("#set-density").value; + userSettings.showBars = $("#set-show-bars").checked; + userSettings.refreshMs = parseInt($("#set-refresh").value, 10) || 2000; + const wantNotify = $("#set-notify").checked; + if (wantNotify && typeof Notification !== "undefined" && Notification.permission === "default") { + Notification.requestPermission().then(p => { + userSettings.notifyOnCrash = (p === "granted"); + $("#set-notify").checked = userSettings.notifyOnCrash; + saveSettings(userSettings); + }); + return; + } + userSettings.notifyOnCrash = wantNotify && (typeof Notification === "undefined" ? false : Notification.permission === "granted"); + saveSettings(userSettings); +} +["#set-density", "#set-show-bars", "#set-refresh", "#set-notify"].forEach(sel => { + const el = $(sel); + if (el) el.addEventListener("change", _onSettingsChange); +}); + +// Topbar search — feeds the new flat-grid filter via serverFilter.search. +// _applyServerFilter is the single source of truth for visibility +// (status + agent + search), so keystrokes don't fight per-render +// display swaps from poll ticks. +(() => { + const input = $("#srv-search"); if (!input) return; + const apply = () => { + serverFilter.search = input.value || ""; + if (typeof _applyServerFilter === "function") _applyServerFilter(); + }; + input.addEventListener("input", apply); + // Re-apply after each poll-driven re-render. + const origRender = renderServerCards; + window.renderServerCards = function() { origRender.apply(this, arguments); apply(); }; +})(); + +// Sidebar scroll-to-section links. +document.querySelectorAll(".side-link[data-scroll]").forEach(link => { + link.addEventListener("click", e => { + e.preventDefault(); + const t = link.dataset.scroll; + document.querySelectorAll(".side-link").forEach(x => x.classList.remove("active")); + link.classList.add("active"); + // Special-cased "scroll targets" that actually open modals — the + // sidebar is a hub so any link can route to a modal too. Keeps the + // shell minimal without sprouting a separate "settings" page tree. + if (t === "steam") { + try { openSteamAccountsModal(); } catch (err) { toast("Couldn't open Steam Accounts: " + err.message, "err"); } + return; + } + if (t === "library") { + try { openTemplateLibrary(); } catch (err) { toast("Couldn't open the Template Library: " + err.message, "err"); } + return; + } + if (t === "top") window.scrollTo({ top: 0, behavior: "smooth" }); + else { + const el = document.querySelector(t); + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }); +}); + +// ================ Steam Accounts modal ================ +// +// Opens from the sidebar 🎮 Steam Accounts link. Shows the current +// user's Steam linkage with avatar pulled from steamcommunity.com via +// the controller proxy, plus an admin-only list of every panel user. +// Each row supports linking by SteamID64 (paste the 17-digit number) +// and unlinking. Avatars come from /api/steam/profile which fetches + +// caches Steam's XML profile server-side. + +async function openSteamAccountsModal() { + showModal("#steam-accounts-modal"); + await renderSteamAccountsModal(); +} +async function renderSteamAccountsModal() { + const meEl = $("#sa-me"); + const teamEl = $("#sa-team"); + const teamWrap = $("#sa-team-wrap"); + if (!meEl) return; + meEl.innerHTML = `
    Loading…
    `; + let me = null; + try { me = await fetchJSON("/api/me"); } catch {} + meEl.innerHTML = await renderSteamUserRow(me, /*self*/true); + // Admin-only — non-admins don't see the team list. + if (me && me.role === "admin") { + teamWrap.style.display = ""; + teamEl.innerHTML = `
    Loading…
    `; + try { + const r = await fetchJSON("/api/users"); + const others = (r.users || []).filter(u => u.id !== me.id); + if (!others.length) { + teamEl.innerHTML = `
    No other users on this panel.
    `; + } else { + const rows = await Promise.all(others.map(u => renderSteamUserRow(u, /*self*/false))); + teamEl.innerHTML = rows.join(""); + } + } catch (err) { + teamEl.innerHTML = `
    Couldn't load users: ${esc(err.message)}
    `; + } + } else { + teamWrap.style.display = "none"; + } +} +async function renderSteamUserRow(u, isSelf) { + if (!u) return `
    Not signed in.
    `; + let avatar = ""; + let displayName = u.email; + if (u.steam_id) { + try { + const p = await fetchJSON(`/api/steam/profile?steamid=${encodeURIComponent(u.steam_id)}`); + avatar = p.avatar_medium || ""; + if (p.display_name) displayName = `${p.display_name} · ${u.email}`; + } catch { + // Profile fetch failed (private, network) — show a placeholder. + } + } + const tile = avatar + ? `` + : `
    ${u.steam_id ? "❓" : "🎮"}
    `; + const sidLine = u.steam_id + ? `${esc(u.steam_id)}` + : `No Steam linked`; + // Action buttons differ for self vs. other users: + // - self, no link: "Sign in with Steam" (real OpenID flow, no paste) + // - self, linked: "Unlink" + // - other, no link: "Paste SteamID64" (admin convenience for teammates who can't sign in) + // - other, linked: "Unlink" + let action = ""; + if (isSelf && !u.steam_id) { + // Real OpenID — redirects to Steam, comes back to /api/auth/steam/link-callback, + // which links the verified ID to the current session user. + action = `
    `; + } else if (u.steam_id) { + action = ``; + } else { + action = ``; + } + return `
    + ${tile} +
    +
    ${esc(displayName)}
    +
    ${sidLine}
    +
    ${esc(u.role || "user")}
    +
    + ${action} +
    `; +} + +// On page load, detect ?steam_linked=ok (Steam OpenID redirected back +// here after a successful link). Clean the URL + auto-open the Steam +// Accounts modal so the operator sees their newly-linked account +// immediately. Also handles the err variant so they see the reason. +document.addEventListener("DOMContentLoaded", () => { + const params = new URLSearchParams(location.search); + const linked = params.get("steam_linked"); + if (linked === "ok") { + history.replaceState({}, "", location.pathname); + setTimeout(() => { + toast("Steam account linked", "ok"); + try { openSteamAccountsModal(); } catch {} + }, 200); + } else if (linked === "err") { + history.replaceState({}, "", location.pathname); + const msg = params.get("msg") || "unknown"; + toast(`Steam link failed: ${msg}`, "err", 9000); + } +}); +document.addEventListener("DOMContentLoaded", () => { + const refresh = $("#sa-refresh"); + if (refresh) refresh.addEventListener("click", () => renderSteamAccountsModal()); + const add = $("#sa-add"); + if (add) add.addEventListener("click", async () => { + const id = prompt("Paste the new user's SteamID64 (17 digits, starts with 7656119):", ""); + if (!id) return; + const trimmed = id.trim(); + if (!/^\d{17}$/.test(trimmed)) { + toast("That doesn't look like a SteamID64 (need 17 digits).", "err"); + return; + } + const role = (prompt("Role for this user — type 'admin' for full panel access, blank for normal 'user':", "") || "").trim().toLowerCase(); + if (role && role !== "admin" && role !== "user") { + toast("Role must be 'admin' or 'user' (or blank).", "err"); + return; + } + try { + const r = await fetchJSON("/api/users", { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ steam_id: trimmed, role: role || "user" }), + }); + toast(`Created user ${r.email} (Steam ${r.steam_id})`, "ok"); + await renderSteamAccountsModal(); + } catch (err) { + toast(`Add failed: ${err.message}`, "err", 8000); + } + }); +}); +document.addEventListener("click", async e => { + const linkID = e.target.dataset && e.target.dataset.saLink; + const unlinkID = e.target.dataset && e.target.dataset.saUnlink; + if (linkID) { + e.preventDefault(); + const id = prompt("Paste the 17-digit SteamID64 to link:", ""); + if (!id) return; + const trimmed = id.trim(); + if (!/^7656119\d{10}$/.test(trimmed)) { + toast("That doesn't look like a SteamID64 (need 17 digits starting with 7656119).", "err"); + return; + } + try { + await fetchJSON(`/api/users/${encodeURIComponent(linkID)}/steam`, { + method: "POST", headers: {"Content-Type":"application/json"}, + body: JSON.stringify({ steam_id: trimmed }), + }); + toast("Steam linked", "ok"); + await renderSteamAccountsModal(); + } catch (err) { toast(`Link failed: ${err.message}`, "err"); } + } + if (unlinkID) { + e.preventDefault(); + if (!confirm("Remove this Steam link?")) return; + try { + await fetchJSON(`/api/users/${encodeURIComponent(unlinkID)}/steam`, { method: "DELETE" }); + toast("Steam unlinked", "ok"); + await renderSteamAccountsModal(); + } catch (err) { toast(`Unlink failed: ${err.message}`, "err"); } + } +}); + +// =================================================================== +// Empyrion EAH tab — calls controller's /api/instances/{id}/empyrion/* +// proxy, which forwards to the per-agent panel-empyrion-bridge. +// Sub-views: Players / Structures / Factions / Chat / Items / Console +// =================================================================== +// Default sub-tab is Console — Players moved to its own top-level tab, +// so the most-used remaining admin function leads here. +const eahState = { sub: "console" }; + +async function eahFetch(sub, opts = {}) { + const id = instanceModalState.id; + if (!id) throw new Error("no instance"); + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/${sub}`, opts); + if (!r.ok) { + const txt = await r.text(); + let msg = txt; + try { msg = JSON.parse(txt).error || msg; } catch {} + throw new Error(`${r.status}: ${msg}`); + } + return r.json(); +} + +function eahActivatePill(pill) { + if (!pill) return; + eahState.sub = pill.dataset.eah; + $$("#im-eah-pills .eah-pill").forEach(b => b.classList.toggle("active", b === pill)); + pill.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + renderEAHSub(); +} + +async function loadEmpyrionEAHTab() { + // Pills wired once. + if (!$("#im-eah-pills").dataset.wired) { + $("#im-eah-pills").dataset.wired = "1"; + $("#im-eah-pills").addEventListener("click", e => { + const p = e.target.closest(".eah-pill"); if (!p) return; + eahActivatePill(p); + }); + // Keyboard: 1-9 + 0 jump to pill N; [ ] cycle prev/next. Only fires + // while the EAH tab is active and no text-entry element has focus. + document.addEventListener("keydown", e => { + if (instanceModalState.tab !== "empyrioneah") return; + if (e.altKey || e.ctrlKey || e.metaKey) return; + const t = e.target; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return; + const pills = $$("#im-eah-pills .eah-pill"); + if (!pills.length) return; + const cur = pills.findIndex(p => p.classList.contains("active")); + let target = -1; + if (/^[1-9]$/.test(e.key)) { + target = parseInt(e.key, 10) - 1; + } else if (e.key === "0") { + target = 9; + } else if (e.key === "[") { + target = (cur - 1 + pills.length) % pills.length; + } else if (e.key === "]") { + target = (cur + 1) % pills.length; + } else { + return; + } + if (target < 0 || target >= pills.length) return; + e.preventDefault(); + eahActivatePill(pills[target]); + }); + } + renderEAHSub(); +} + +function renderEAHSub() { + // Tear down any subscription from the previous pill — the live SSE stream + // and the ban-list 1s countdown interval (which otherwise keeps ticking + // against detached DOM until the next Bans render replaces it). + _eahLiveStop(); + if (window._banTimer) { clearInterval(window._banTimer); window._banTimer = null; } + const body = $("#im-eah-body"); + body.innerHTML = loadingState("Loading…"); + switch (eahState.sub) { + case "players": return renderEAHPlayers(body); + case "structures": return renderEAHStructures(body); + case "factions": return renderEAHFactions(body); + case "chat": return renderEAHChat(body); + case "items": return renderEAHItems(body); + case "console": return renderEAHConsole(body); + case "scenarios": return renderEAHScenarios(body); + case "history": return renderEAHHistory(body); + case "templates": return renderEAHTemplates(body); + case "map": return renderEAHMap(body); + case "chatlog": return renderEAHChatLog(body); + case "missions": return renderEAHMissions(body); + case "blueprints": return renderEAHBlueprints(body); + case "recipes": return renderEAHRecipes(body); + case "loot": return renderEAHLoot(body); + case "health": return renderEAHHealth(body); + case "galaxy": return renderEAHGalaxy(body); + case "bans": return renderEAHBans(body); + case "live": return renderEAHLive(body); + case "discoveries": return renderEAHDiscoveries(body); + case "library": return renderEAHLibrary(body); + case "coords": return renderEAHCoords(body); + case "warnings": return renderEAHWarnings(body); + case "chatbot": return renderEAHChatBot(body); + case "dupes": return renderEAHDupes(body); + case "freshstart": return renderEAHFreshStart(body); + } +} + +// --- Galaxy / star-map view --- +async function renderEAHGalaxy(body) { + body.innerHTML = loadingState("Loading galaxy from savegame…"); + try { + const id = instanceModalState.id; + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/galaxy`); + if (!r.ok) { + const err = await r.text(); + body.innerHTML = `
    ${esc(err)}
    `; + return; + } + const data = await r.json(); + const stars = data.stars || []; + const sectors = data.sectors || []; + if (stars.length === 0) { + body.innerHTML = `
    No stars in Sectors.yaml.
    `; + return; + } + const xs = stars.map(s => s.x), zs = stars.map(s => s.z), ys = stars.map(s => s.y || 0); + // 5/95 percentile bounds (NOT 2/98 — when a galaxy has a few extreme + // outliers like Sea-of-Silence at z=-1150, p2 still picks them up + // and squashes the main cluster). Empyrion saves frequently put 10–20 + // distinct stars on the SAME (x, z) row at varying y; if we projected + // pure top-down (x, z) they'd visually overlap as "circle after circle + // on the same line" — what looks like duplication. Isometric tilt below + // separates them by their y altitude. + const pctRange = arr => { + const s = [...arr].sort((a, b) => a - b); + const lo = s[Math.max(0, Math.floor(0.05 * (s.length - 1)))]; + const hi = s[Math.min(s.length - 1, Math.ceil(0.95 * (s.length - 1)))]; + const pad = Math.max(5, (hi - lo) * 0.08); + return [lo - pad, hi + pad]; + }; + const [minX, maxX] = pctRange(xs); + const [minZ, maxZ] = pctRange(zs); + const [minY, maxY] = pctRange(ys); + const W = 760, H = 480; + // Isometric-ish projection: x → screen-x; (z, y) → screen-y. + // Treat y as up-axis (Empyrion's vertical), z as depth-axis. Stars + // sharing (x, z) but differing in y separate vertically on screen. + // yTilt = how much y influences screen-y. 0.35 = noticeable but doesn't + // dominate the topology. + const yTilt = 0.35; + const yRange = (maxY - minY) || 1; + const sx = x => ((x - minX) / (maxX - minX || 1)) * W; + const sz = z => H - ((z - minZ) / (maxZ - minZ || 1)) * H; + const projectY = (z, y) => { + const baseY = sz(z); + const yShift = ((y - minY) / yRange) * H * yTilt - (H * yTilt) / 2; + return baseY - yShift; + }; + const isOutlier = s => s.x < minX || s.x > maxX || s.z < minZ || s.z > maxZ; + const starColor = cls => { + const c = (cls || "").toUpperCase(); + if (c.startsWith("O") || c.startsWith("B")) return "#9bb5ff"; + if (c.startsWith("A")) return "#cad7ff"; + if (c.startsWith("F")) return "#fff4ea"; + if (c.startsWith("G")) return "#fff2a1"; + if (c.startsWith("K")) return "#ffd2a1"; + if (c.startsWith("M")) return "#ffa899"; + if (c.startsWith("NEUTRON") || c.startsWith("WHITEDWARF")) return "#e0e0ff"; + if (c.startsWith("BLACKHOLE")) return "#222"; + return "var(--warn)"; + }; + // Procedural dim starfield — deterministic per-mount via a tiny LCG so + // panning doesn't shimmer. ~250 background dots scattered across viewBox. + let seed = 0xC0FFEE; + const rnd = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 0xFFFFFFFF; }; + const bgDots = []; + for (let i = 0; i < 250; i++) { + const bx = rnd() * W, by = rnd() * H; + const br = 0.4 + rnd() * 1.0; + const op = 0.15 + rnd() * 0.45; + bgDots.push(``); + } + const dots = stars.map(s => { + const r = 4 + Math.min(8, Math.sqrt(s.playfieldCount)); + // Clamp outliers to canvas with 6 px margin so the glow stays visible. + const out = isOutlier(s); + const cx = Math.max(6, Math.min(W - 6, sx(s.x))); + const cy = Math.max(6, Math.min(H - 6, projectY(s.z, s.y || 0))); + const stroke = out ? "rgba(255,180,80,.95)" : "rgba(0,0,0,.45)"; + const strokeW = out ? 1.5 : 0.5; + const titleSuffix = out ? `\n(outlier — true position x=${Math.round(s.x)} z=${Math.round(s.z)}; clamped to edge)` : ""; + return ` + ${esc(s.name)} — class ${esc(s.starClass || "?")}, ${s.sectorCount} sectors, ${s.playfieldCount} playfields\nx=${Math.round(s.x)} y=${Math.round(s.y || 0)} z=${Math.round(s.z)}${titleSuffix} + + + ${esc(s.name)} + `; + }).join(""); + const outlierCount = stars.filter(isOutlier).length; + body.innerHTML = ` +
    +
    +

    Galaxy (${stars.length} stars · ${sectors.length} sectors)

    +
    + + + + drag to pan · wheel to zoom +
    +
    +
    +
    + + ${bgDots.join("")} + + + ${dots} + +
    iso projection · x→horiz, z+y→vert · range x:[${Math.round(minX)},${Math.round(maxX)}] y:[${Math.round(minY)},${Math.round(maxY)}] z:[${Math.round(minZ)},${Math.round(maxZ)}] LY${outlierCount ? ` · ${outlierCount} outlier${outlierCount===1?"":"s"} clamped (orange ring; hover for true coords)` : ""} · source: ${esc(data.path || "")}
    +
    +
    Click a star to see its sectors and playfields.
    +
    +
    `; + const svg = document.getElementById("galaxy-svg"); + // Pan / zoom state. We mutate viewBox in place; SVG handles scaling. + const view = { x: 0, y: 0, w: W, h: H }; + const apply = () => svg.setAttribute("viewBox", `${view.x.toFixed(2)} ${view.y.toFixed(2)} ${view.w.toFixed(2)} ${view.h.toFixed(2)}`); + const reset = () => { view.x = 0; view.y = 0; view.w = W; view.h = H; apply(); }; + // Convert a client (px) point to user-space (viewBox) coords. + const toUser = (clientX, clientY) => { + const r = svg.getBoundingClientRect(); + return { x: view.x + ((clientX - r.left) / r.width) * view.w, + y: view.y + ((clientY - r.top) / r.height) * view.h }; + }; + svg.addEventListener("wheel", e => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15; + const newW = view.w * factor, newH = view.h * factor; + // Clamp: don't zoom out past 4× or in past 0.05×. + if (newW > W * 4 || newW < W * 0.05) return; + const before = toUser(e.clientX, e.clientY); + view.w = newW; view.h = newH; + const after = toUser(e.clientX, e.clientY); + view.x += before.x - after.x; + view.y += before.y - after.y; + apply(); + }, { passive: false }); + let drag = null; + svg.addEventListener("pointerdown", e => { + if (e.target.closest(".galaxy-star")) return; // let click handler win + drag = { startX: e.clientX, startY: e.clientY, vx: view.x, vy: view.y }; + svg.setPointerCapture(e.pointerId); + svg.style.cursor = "grabbing"; + }); + svg.addEventListener("pointermove", e => { + if (!drag) return; + const r = svg.getBoundingClientRect(); + const dx = (e.clientX - drag.startX) * (view.w / r.width); + const dy = (e.clientY - drag.startY) * (view.h / r.height); + view.x = drag.vx - dx; + view.y = drag.vy - dy; + apply(); + }); + const endDrag = e => { + if (drag) { svg.releasePointerCapture?.(e.pointerId); drag = null; svg.style.cursor = "grab"; } + }; + svg.addEventListener("pointerup", endDrag); + svg.addEventListener("pointercancel", endDrag); + svg.addEventListener("dblclick", reset); + document.getElementById("galaxy-reset").addEventListener("click", reset); + document.getElementById("galaxy-zoom-in").addEventListener("click", () => { + const cx = view.x + view.w / 2, cy = view.y + view.h / 2; + view.w /= 1.4; view.h /= 1.4; + view.x = cx - view.w / 2; view.y = cy - view.h / 2; + apply(); + }); + document.getElementById("galaxy-zoom-out").addEventListener("click", () => { + const cx = view.x + view.w / 2, cy = view.y + view.h / 2; + const newW = view.w * 1.4, newH = view.h * 1.4; + if (newW > W * 4) return; + view.w = newW; view.h = newH; + view.x = cx - view.w / 2; view.y = cy - view.h / 2; + apply(); + }); + body.querySelectorAll(".galaxy-star").forEach(el => el.addEventListener("click", () => { + const name = el.dataset.name; + const star = stars.find(s => s.name === name); + const localSectors = sectors.filter(s => s.star === name); + const totalPF = localSectors.reduce((a, s) => a + (s.playfields || []).length, 0); + $("#galaxy-detail").innerHTML = ` +

    ${esc(name)}

    +
    Class ${esc(star?.starClass || "?")} · ${star?.x.toFixed(0)}, ${star?.y.toFixed(0)}, ${star?.z.toFixed(0)} LY · ${star?.sectorCount} sectors · ${totalPF} playfields
    + ${localSectors.map(sec => ` +
    +
    Sector [${sec.x},${sec.y},${sec.z}] ${esc(sec.type || "")}
    + ${(sec.playfields || []).map(p => `
    ${esc(p.name)} ${esc(p.type)}
    `).join("")} +
    `).join("")}`; + })); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- Bans (with expiry countdown) --- +async function renderEAHBans(body) { + body.innerHTML = loadingState("Loading ban list…"); + try { + const id = instanceModalState.id; + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/banned`); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const list = (data && data.BannedPlayers) || []; + if (!list.length) { + body.innerHTML = `
    No banned players.
    `; + $("#bans-refresh")?.addEventListener("click", () => renderEAHBans(body)); + return; + } + const now = Date.now(); + const rows = list.map(b => { + // dateTime is .NET DateTime.ToBinary() — we receive raw int64. + // Empyrion stores ban expiry as the ToBinary representation of a UTC + // DateTime. Heuristic conversion to ms epoch: + // ticks (100ns since 0001-01-01) → ms since 1970-01-01: + // raw & 0x3FFFFFFFFFFFFFFF gives the ticks; (ticks - 621355968000000000) / 10000 = ms + const raw = BigInt(b.dateTime || 0); + const ticks = raw & 0x3FFFFFFFFFFFFFFFn; + const ms = Number((ticks - 621355968000000000n) / 10000n); + const expires = isFinite(ms) && ms > 0 ? ms : null; + const remaining = expires ? Math.max(0, expires - now) : null; + return { steamId: b.steam64Id, raw: b.dateTime, expires, remaining }; + }); + body.innerHTML = ` +
    +
    +

    Ban list (${rows.length})

    + +
    + + + ${rows.map(b => ` + + + + + + `).join("")} + +
    Steam IDExpiresTime remaining
    ${esc(String(b.steamId))}${b.expires ? new Date(b.expires).toLocaleString() : "permanent"}${b.remaining !== null ? formatDuration(b.remaining) : "—"}
    +
    `; + // Live countdown — tick once per second. + const tick = () => { + const now = Date.now(); + body.querySelectorAll(".ban-remaining[data-expires]").forEach(td => { + const exp = parseInt(td.dataset.expires, 10); + if (!exp) return; + td.textContent = formatDuration(Math.max(0, exp - now)); + }); + }; + if (window._banTimer) clearInterval(window._banTimer); + window._banTimer = setInterval(tick, 1000); + $("#bans-refresh").addEventListener("click", () => renderEAHBans(body)); + body.querySelectorAll("button[data-unban]").forEach(b => b.addEventListener("click", async () => { + const steam = b.dataset.unban; + // Empyrion's "ban remove" console command takes the steam ID. + try { + const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/console`, { + method: "POST", headers: { "Content-Type": "text/plain" }, + body: `ban remove ${steam}` + }); + if (!res.ok) throw new Error(await res.text()); + toast("Unban sent — refresh in a few seconds", "ok"); + setTimeout(() => renderEAHBans(body), 2000); + } catch (err) { toast(err.message, "err"); } + })); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +function formatDuration(ms) { + if (ms <= 0) return "expired"; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m ${sec}s`; + if (m > 0) return `${m}m ${sec}s`; + return `${sec}s`; +} + +// --- Live event tail (SSE) --- +// +// Subscribes to the bridge's /events stream via the controller proxy and +// renders incoming Event_* packages as a scrolling log. Buffer is capped +// so a busy server doesn't blow up the DOM. +let _eahLiveES = null; +function _eahLiveStop() { + if (_eahLiveES) { try { _eahLiveES.close(); } catch {} _eahLiveES = null; } +} + +function renderEAHLive(body) { + const id = instanceModalState.id; + const MAX_ROWS = 500; + const buf = []; // newest first + let filterCmd = ""; + let paused = false; + + body.innerHTML = ` +
    +
    +

    Live event stream

    + connecting… + + + + +
    +
    Streaming directly from the bridge — every Event_* the mod fans out lands here. Buffer holds the last ${MAX_ROWS} entries.
    +
    +
    `; + + const statusEl = body.querySelector("#live-status"); + const logEl = body.querySelector("#live-log"); + const filterEl = body.querySelector("#live-filter"); + const pauseBtn = body.querySelector("#live-pause"); + const clearBtn = body.querySelector("#live-clear"); + + const fmtCell = (v) => v === undefined || v === null ? "" : esc(String(v)); + const renderRow = (ev) => { + const cmd = (ev.cmd || "").replace(/^Event_/, ""); + const playerLabel = ev.playerName ? ev.playerName : (ev.playerId ? `#${ev.playerId}` : ""); + const detailJson = ev.data ? JSON.stringify(ev.data) : ""; + const detail = detailJson.length > 220 ? detailJson.slice(0, 220) + "…" : detailJson; + return `
    ${fmtCell(ev.ts || new Date().toISOString().slice(11,19))} ${fmtCell(cmd)}${playerLabel ? ` ${fmtCell(playerLabel)}` : ""}${ev.playfield ? ` ${fmtCell(ev.playfield)}` : ""}${detail ? ` ${fmtCell(detail)}` : ""}
    `; + }; + + const repaint = () => { + if (paused) return; + const filtered = filterCmd + ? buf.filter(e => (e.cmd || "").toLowerCase().includes(filterCmd)) + : buf; + logEl.innerHTML = filtered.slice(0, MAX_ROWS).map(renderRow).join(""); + }; + + filterEl.addEventListener("input", () => { filterCmd = filterEl.value.trim().toLowerCase(); repaint(); }); + pauseBtn.addEventListener("click", () => { + paused = !paused; + pauseBtn.textContent = paused ? "▶ Resume" : "⏸ Pause"; + if (!paused) repaint(); + }); + clearBtn.addEventListener("click", () => { buf.length = 0; logEl.innerHTML = ""; }); + + _eahLiveStop(); + try { + const es = new EventSource(`/api/instances/${encodeURIComponent(id)}/empyrion/events`); + _eahLiveES = es; + es.addEventListener("open", () => { statusEl.textContent = "connected"; statusEl.style.color = "rgba(80,220,140,.85)"; }); + es.addEventListener("error", () => { statusEl.textContent = "disconnected — retrying"; statusEl.style.color = "rgba(255,160,90,.85)"; }); + es.addEventListener("message", (e) => { + let payload; + try { payload = JSON.parse(e.data); } catch { return; } + // bridge wraps as { cmd, seq, data, ts? } — we tolerate either flat or nested. + const ev = { + ts: payload.ts || new Date().toISOString().slice(11, 19), + cmd: payload.cmd || "(unknown)", + playerId: payload.playerId ?? payload.data?.id ?? payload.data?.entityId, + playerName: payload.playerName ?? payload.data?.playerName ?? payload.data?.name, + playfield: payload.playfield ?? payload.data?.playfield, + data: payload.data ?? payload, + }; + buf.unshift(ev); + if (buf.length > MAX_ROWS) buf.length = MAX_ROWS; + repaint(); + }); + } catch (err) { + statusEl.textContent = `error: ${err.message}`; + statusEl.style.color = "rgba(255,90,90,.9)"; + } +} + +// --- Discoveries (per-player POI map) --- +// +// Queries the empyrion server's global.db SQLite directly via the agent. +// Two modes: "personal" (POIs THIS player walked into) and "faction" +// (everything anyone in their faction has — matches the in-game shared +// map). Operator picks a player from the known-players autocomplete. +async function renderEAHDiscoveries(body) { + const id = instanceModalState.id; + // Pull the known-players list to populate the autocomplete. Reuses + // the same DB-backed projection the History pill shows. + let knownPlayers = []; + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/players`); + if (r.ok) { + const data = await r.json(); + knownPlayers = (data.players || []).filter(p => p.steamId); + } + } catch {} + body.innerHTML = ` +
    +
    +
    + + +
    Or paste a SteamID64 below if the player isn't in the list yet.
    + +
    +
    + + +
    + +
    +
    Pick a player and click Look up. Personal mode = POIs they walked into themselves. Faction mode = the in-game shared map (anyone in their faction's discoveries).
    +
    `; + const goBtn = $("#eah-disc-go"); + const resultsEl = $("#eah-disc-results"); + goBtn.addEventListener("click", async () => { + const sid = ($("#eah-disc-steamid").value || $("#eah-disc-player").value || "").trim(); + const mode = $("#eah-disc-mode").value; + if (!sid) { toast("Pick a player or paste a SteamID64", "err"); return; } + if (!/^\d{17}$/.test(sid)) { + toast("SteamID64 should be 17 digits", "err"); + return; + } + goBtn.disabled = true; goBtn.textContent = "Loading…"; + resultsEl.innerHTML = `
    querying global.db on the agent…
    `; + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/discoveries?player=${encodeURIComponent(sid)}&mode=${encodeURIComponent(mode)}`); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + if (data.error) throw new Error(data.error); + renderEAHDiscoveriesTable(resultsEl, data, mode); + } catch (err) { + resultsEl.innerHTML = `
    ${esc(err.message)}
    `; + } finally { + goBtn.disabled = false; goBtn.textContent = "Look up"; + } + }); +} + +function renderEAHDiscoveriesTable(wrap, data, mode) { + const pois = data.pois || []; + const playerLabel = data.player_name ? `${esc(data.player_name)} (${esc(data.steam_id)})` : esc(data.steam_id); + if (!pois.length) { + wrap.innerHTML = `
    +
    🗺️
    + No discoveries yet for ${playerLabel}. + ${mode === "faction" ? "No one in their faction has discovered anything." : "They haven't walked into a POI yet — the in-game game registers POIs when the player gets close enough to read the name overhead."} +
    `; + return; + } + // Group by solar system → playfield → POIs. + const bySys = new Map(); + for (const p of pois) { + const sys = p.solar_system || "—"; + if (!bySys.has(sys)) bySys.set(sys, new Map()); + const byPF = bySys.get(sys); + if (!byPF.has(p.playfield)) byPF.set(p.playfield, []); + byPF.get(p.playfield).push(p); + } + const sysList = [...bySys.keys()].sort(); + wrap.innerHTML = ` +
    + ${pois.length} discoveries + ${mode === "faction" ? "(faction-shared view)" : "(personal)"} for ${playerLabel} + +
    +
    + ${sysList.map(sys => { + const byPF = bySys.get(sys); + const pfList = [...byPF.keys()].sort(); + return `
    + 🌟 ${esc(sys)} ${pfList.length} playfield${pfList.length === 1 ? "" : "s"} + ${pfList.map(pf => { + const list = byPF.get(pf); + return `
    + 🪐 ${esc(pf)} ${list.length} POI${list.length === 1 ? "" : "s"} + + + + ${list.map(p => ` + + + + + `).join("")} +
    POITypeDiscovererGame-time
    ${esc(p.name)}${p.type}${esc(p.discoverer_name || "—")}${p.game_time}
    +
    `; + }).join("")} +
    `; + }).join("")} +
    `; + // Cheap client-side filter — hide rows whose data-search doesn't match. + $("#eah-disc-filter").addEventListener("input", e => { + const q = e.target.value.toLowerCase().trim(); + document.querySelectorAll("#eah-disc-list .eah-disc-row").forEach(tr => { + tr.style.display = (!q || tr.dataset.search.includes(q)) ? "" : "none"; + }); + }); +} + +// --- Chat log (DB-backed) --- +async function renderEAHChatLog(body) { + body.innerHTML = loadingState("Loading chat log…"); + try { + const id = instanceModalState.id; + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/chat?limit=400`); + const data = await r.json(); + const msgs = data.messages || []; + const channels = ["Global", "Faction", "Allies", "Whisper", "Server"]; + body.innerHTML = ` +
    +
    + + + ${msgs.length} messages (latest first) +
    +
    ${msgs.map(m => ` +
    + ${esc(m.ts)} + ${esc(channels[m.channel] || "?")} + ${esc(m.playerName || ("#" + (m.playerId || 0)))} + ${esc(m.msg || "")} +
    `).join("") || `
    No chat captured yet — players need to talk.
    `} +
    +
    `; + $("#chat-r").addEventListener("click", () => renderEAHChatLog(body)); + $("#chat-channel").addEventListener("change", async e => { + const ch = e.target.value; + const r2 = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/chat?limit=400${ch !== "" ? `&channel=${ch}` : ""}`); + const d = await r2.json(); + $("#chat-out").innerHTML = (d.messages || []).map(m => ` +
    + ${esc(m.ts)} + ${esc(channels[m.channel] || "?")} + ${esc(m.playerName || ("#" + (m.playerId || 0)))} + ${esc(m.msg || "")} +
    `).join(""); + }); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- PDA Missions --- +async function renderEAHMissions(body) { + body.innerHTML = loadingState("Loading missions…"); + try { + const id = instanceModalState.id; + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/missions`); + const data = await r.json(); + const list = data.missions || []; + body.innerHTML = ` +
    +

    PDA mission progress

    +
    Captured from Event_PdaStateChange. Each row is the most recent state for a player's chapter.
    + ${list.length === 0 ? `
    No PDA events yet — players need to interact with PDA missions.
    ` : ` + + + ${list.map(m => ` + + + + + `).join("")} +
    TimePlayerState
    ${esc(m.ts)}${esc(m.playerName || ("#" + (m.playerId || 0)))}
    ${esc(JSON.stringify(m.state))}
    `} +
    `; + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- Blueprints --- +async function renderEAHBlueprints(body) { + body.innerHTML = loadingState("Scanning Saves/Blueprints…"); + try { + const id = instanceModalState.id; + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/blueprints`); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const list = data.blueprints || []; + body.innerHTML = ` +
    +

    Blueprints (${list.length})

    +
    Files under Saves/Blueprints/<steamId>/. Click "Spawn" to drop one near a connected player via the console.
    + ${list.length === 0 ? `
    No blueprints yet — players save them in-game with the build menu.
    ` : ` + + + ${list.map(b => ` + + + + + + `).join("")} +
    NameSizePath
    ${esc(b.name.replace(/\.epb$/, ""))}${(b.size / 1024).toFixed(1)} KB${esc(b.path)}
    `} +
    `; + body.querySelectorAll("[data-spawn]").forEach(b => b.addEventListener("click", async () => { + const pid = prompt("Spawn near which player ID?"); + if (!pid) return; + try { + const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/blueprints/${encodeURIComponent(b.dataset.spawn)}/spawn`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ playerId: parseInt(pid, 10) }) + }); + if (!res.ok) throw new Error(await res.text()); + toast(`Spawn requested: ${b.dataset.spawn}`, "ok"); + } catch (err) { toast(err.message, "err"); } + })); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- Recipes --- +let _eahRecipeItem = null; +async function renderEAHRecipes(body) { + const data = await _eahLoadItems(); + body.innerHTML = ` +
    + +
    +
    +
    Pick an item.
    +
    +
    `; + const drawList = q => { + const ql = q.toLowerCase(); + const list = data.items.filter(it => !ql || it.name.toLowerCase().includes(ql)).slice(0, 200); + $("#recipe-list").innerHTML = list.map(it => ` +
    + +
    ${esc(it.name)} #${it.id}
    +
    `).join(""); + $("#recipe-list").querySelectorAll("[data-name]").forEach(row => row.addEventListener("click", () => loadRecipe(row.dataset.name, row.dataset.id))); + }; + drawList(""); + $("#recipe-q").addEventListener("input", e => drawList(e.target.value)); + + async function loadRecipe(name, id) { + $("#recipe-detail").innerHTML = `
    Loading…
    `; + try { + const r = await fetch(`/api/empyrion/recipe/${encodeURIComponent(name)}`); + if (r.status === 404) { + $("#recipe-detail").innerHTML = `
    ${esc(name)} is a base item — no crafting recipe.
    `; + return; + } + const d = await r.json(); + const recipe = d.recipe; + const used = d.usedIn || []; + const itemsByName = {}; + data.items.forEach(it => { itemsByName[it.name.toLowerCase()] = it; }); + $("#recipe-detail").innerHTML = ` +

    ${esc(recipe.name)}

    +
    Craft target: ${esc(recipe.target || "(any)")}
    +
    Craft time: ${esc(recipe.craftTime || "?")}s
    + ${recipe.outputCount ? `
    Output count: ${esc(recipe.outputCount)}
    ` : ""} + ${recipe.baseItem ? `
    ⛏️ Base ingredient (no recipe; mined / harvested directly)
    ` : ""} +

    Inputs

    +
    ${Object.entries(recipe.inputs || {}).map(([k, v]) => { + const item = itemsByName[k.toLowerCase()]; + return `
    + ${item ? `` : ""} +
    ${esc(k)}${item ? ` #${item.id}` : ""}
    +
    ×${esc(v)}
    +
    `; + }).join("") || `
    No inputs (free recipe).
    `} +
    +

    Used in (${used.length})

    +
    ${used.slice(0, 30).map(u => { + const item = itemsByName[u.name.toLowerCase()]; + return `
    + ${item ? `` : ""} + ${esc(u.name)}${item ? ` #${item.id}` : ""} +
    `; + }).join("") || `
    Not used in any recipe.
    `}
    `; + $("#recipe-detail").querySelectorAll("[data-name]").forEach(row => row.addEventListener("click", () => loadRecipe(row.dataset.name, row.dataset.id))); + } catch (err) { $("#recipe-detail").innerHTML = `
    ${esc(err.message)}
    `; } + } +} + +// --- Loot tables --- +async function renderEAHLoot(body) { + body.innerHTML = loadingState("Loading loot tables…"); + try { + const r = await fetch("/api/empyrion/loot"); + const d = await r.json(); + const itemData = await _eahLoadItems(); + const itemsByName = {}; + itemData.items.forEach(it => { itemsByName[it.name.toLowerCase()] = it; }); + body.innerHTML = ` +
    + +
    +
    +
    Pick a group.
    +
    +
    `; + const groups = d.groups || []; + const drawList = q => { + const ql = q.toLowerCase(); + const list = groups.filter(g => !ql || g.name.toLowerCase().includes(ql)).slice(0, 200); + $("#loot-list").innerHTML = list.map(g => ` +
    + ${esc(g.name)} (${(g.items || []).length}) +
    `).join(""); + $("#loot-list").querySelectorAll("[data-name]").forEach(row => row.addEventListener("click", () => drawDetail(row.dataset.name))); + }; + const drawDetail = async name => { + $("#loot-detail").innerHTML = `
    Loading…
    `; + const r2 = await fetch(`/api/empyrion/loot/${encodeURIComponent(name)}`); + const g = await r2.json(); + $("#loot-detail").innerHTML = ` +

    ${esc(g.name)}

    +
    Count strategy: ${esc(g.count || "default")}
    + + + ${(g.items || []).map(it => { + const item = itemsByName[it.item.toLowerCase()]; + return ` + + + + + `; + }).join("")} +
    ItemCountProbability
    ${item ? `` : ""}${esc(it.item)}${item ? ` #${item.id}` : ""}${esc(it.count || "")}${esc(it.probability || "")}
    `; + }; + drawList(""); + $("#loot-q").addEventListener("input", e => drawList(e.target.value)); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- Server health (dedi-stats sparkline) --- +async function renderEAHHealth(body) { + body.innerHTML = loadingState("Loading server health…"); + try { + const id = instanceModalState.id; + let window = "1h"; + const draw = async () => { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/dedi-stats?window=${window}`); + const d = await r.json(); + const pts = d.points || []; + body.innerHTML = ` +
    +
    +

    Server health

    + + +
    + ${pts.length === 0 ? `
    No dedi-stats heartbeats yet — they fire every ~60s once the server is up and the bridge is connected.
    ` : ` +
    + ${sparkline("FPS", pts.map(p => p.fps), pts, "fps")} + ${sparkline("Heap (MB)", pts.map(p => p.heap), pts, "heap")} + ${sparkline("Players", pts.map(p => p.players), pts, "players")} + ${sparkline("Pfs running", pts.map(p => p.pfsRunning), pts, "pfsRunning")} + ${sparkline("Pfs inactive", pts.map(p => p.pfsInactive), pts, "pfsInactive")} + ${sparkline("Pfs active", pts.map(p => p.pfsActive), pts, "pfsActive")} +
    +
    ${pts.length} samples in ${window}.
    `} +
    `; + $("#health-w")?.addEventListener("change", e => { window = e.target.value; draw(); }); + $("#health-r")?.addEventListener("click", draw); + }; + draw(); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +function sparkline(label, values, pts, key) { + if (!values.length) return `
    ${esc(label)}
    `; + const W = 220, H = 60; + const min = Math.min(...values), max = Math.max(...values); + const span = max - min || 1; + const last = values[values.length - 1]; + const path = values.map((v, i) => { + const x = (i / (values.length - 1 || 1)) * W; + const y = H - ((v - min) / span) * (H - 4) - 2; + return `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`; + }).join(" "); + return ` +
    +
    ${esc(label)}
    +
    + ${typeof last === "number" ? (Number.isInteger(last) ? last : last.toFixed(1)) : "?"} + range ${typeof min === "number" ? min.toFixed(0) : "?"}–${typeof max === "number" ? max.toFixed(0) : "?"} +
    + + + +
    `; +} + +// --- History (DB-backed: known players + recent events) --- +async function renderEAHHistory(body) { + body.innerHTML = loadingState("Loading history…"); + try { + const id = instanceModalState.id; + const [pRes, eRes] = await Promise.all([ + fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/players`).then(r => r.json()), + fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/events?limit=300`).then(r => r.json()), + ]); + const players = pRes.players || []; + const events = eRes.events || []; + body.innerHTML = ` +
    +

    Known players (${players.length})

    +
    Projected from event log. Updates live as players join/leave. Notes are private to panel admins; auto-save on blur.
    + + + ${players.map(p => ` + + + + + + + + + + `).join("") || ``} + +
    IDSteamNameFirst seenLast seenLoginsLast playfieldNotes
    ${p.playerId}${esc(p.steamId || "")}${esc(p.playerName || "")}${esc(p.firstSeen)}${esc(p.lastSeen)}${p.loginCount}${esc(p.lastPlayfield || "")}
    No history yet — players need to log in at least once after the bridge subscriber starts.
    +

    Recent events (${events.length})

    + + + ${events.map(e => ` + + + + + + + `).join("") || ``} + +
    TimeEventPlayerPlayfieldDetails
    ${esc(e.ts)}${esc(e.cmd.replace("Event_", ""))}${e.playerId ? esc(e.playerName || e.playerId) : ""}${esc(e.playfield || "")}${esc(e.msg || "")}
    No events yet.
    +
    `; + body.querySelectorAll("textarea.eah-note").forEach(ta => { + let lastSaved = ta.value; + ta.addEventListener("blur", async () => { + if (ta.value === lastSaved) return; + const pid = ta.dataset.pid; + ta.classList.add("saving"); + try { + const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion-history/players/${pid}/notes`, { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: ta.value, + }); + if (!res.ok) throw new Error(await res.text()); + lastSaved = ta.value; + ta.classList.remove("saving"); + ta.classList.add("saved"); + setTimeout(() => ta.classList.remove("saved"), 1200); + } catch (err) { + ta.classList.remove("saving"); + ta.classList.add("save-err"); + toast(`Save note failed: ${err.message}`, "err"); + } + }); + }); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- Templates (inventory kits) --- +async function renderEAHTemplates(body) { + body.innerHTML = loadingState("Loading kits…"); + try { + const data = await _eahLoadItems(); + const r = await fetch("/api/empyrion/templates"); + const tdata = await r.json(); + const tpls = tdata.templates || []; + body.innerHTML = ` +
    +
    +

    Inventory kits (${tpls.length})

    + +
    +
    Define named bundles of items. Apply to any online player from the Players tab. Kits are global to the panel — shared across all empyrion instances.
    +
    ${tpls.map(t => ` +
    +
    + ${esc(t.name)} — ${(t.items || []).length} items +
    ${esc(t.description || "")}
    +
    + + +
    `).join("") || `
    No kits yet. Create one to get started.
    `} +
    +
    `; + $("#eah-new-tpl").addEventListener("click", () => eahShowTemplateEditor(null, data)); + body.querySelectorAll("button[data-edit]").forEach(b => b.addEventListener("click", () => { + const t = tpls.find(x => x.id === parseInt(b.dataset.edit, 10)); + eahShowTemplateEditor(t, data); + })); + body.querySelectorAll("button[data-del]").forEach(b => b.addEventListener("click", async () => { + if (!confirm("Delete this kit?")) return; + try { + const res = await fetch(`/api/empyrion/templates/${b.dataset.del}`, { method: "DELETE" }); + if (!res.ok) throw new Error(await res.text()); + toast("Kit deleted", "ok"); + renderEAHTemplates(body); + } catch (err) { toast(err.message, "err"); } + })); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +function eahShowTemplateEditor(tpl, itemData) { + const isEdit = !!tpl; + const overlay = document.createElement("div"); + overlay.className = "eah-modal"; + overlay.innerHTML = ` +
    +
    ${isEdit ? "Edit kit" : "New kit"}
    +
    + + +
    +
    Items in kit
    +
    +
    Add item
    + +
    +
    + + +
    +
    `; + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.addEventListener("click", e => { if (e.target === overlay || e.target.dataset.close !== undefined) close(); }); + let items = isEdit ? (tpl.items || []).map(i => ({...i})) : []; + const drawItems = () => { + overlay.querySelector("#tpl-items").innerHTML = items.map((it, idx) => { + const def = itemData.items.find(d => d.id === it.itemId); + return `
    + ${esc(def?.name || "?")} #${it.itemId} + + +
    `; + }).join("") || `
    No items yet. Pick from below.
    `; + overlay.querySelectorAll("input[data-field='count']").forEach(inp => inp.addEventListener("input", e => { + items[+e.target.dataset.idx].count = parseInt(e.target.value, 10) || 1; + })); + overlay.querySelectorAll("button[data-rm]").forEach(b => b.addEventListener("click", () => { items.splice(+b.dataset.rm, 1); drawItems(); })); + }; + drawItems(); + const drawSearch = q => { + const list = itemData.items.filter(it => !q || it.name.toLowerCase().includes(q.toLowerCase())).slice(0, 100); + overlay.querySelector("#tpl-item-list").innerHTML = list.map(it => `
    ${esc(it.name)} #${it.id} ${esc(it.category || "")}
    `).join(""); + }; + drawSearch(""); + overlay.querySelector("#tpl-item-q").addEventListener("input", e => drawSearch(e.target.value)); + overlay.querySelector("#tpl-item-list").addEventListener("click", e => { + const row = e.target.closest("[data-add]"); if (!row) return; + const itemId = parseInt(row.dataset.add, 10); + if (items.some(x => x.itemId === itemId)) return; + items.push({ itemId, count: 1 }); + drawItems(); + }); + overlay.querySelector("#tpl-save").addEventListener("click", async () => { + const name = overlay.querySelector("#tpl-name").value.trim(); + if (!name) { toast("Name is required", "err"); return; } + try { + const res = await fetch("/api/empyrion/templates", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: tpl?.id || 0, name, description: overlay.querySelector("#tpl-desc").value, items }), + }); + if (!res.ok) throw new Error(await res.text()); + toast(isEdit ? "Saved" : "Created", "ok"); + close(); + renderEAHSub(); + } catch (err) { toast(err.message, "err"); } + }); +} + +// --- Map view: 2D rendering of structures by playfield --- +async function renderEAHMap(body) { + body.innerHTML = loadingState("Loading structures…"); + try { + const id = instanceModalState.id; + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/structures`); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const list = data.structures || []; + if (!list.length) { + body.innerHTML = `
    No structures known yet.
    `; + $("#eah-map-r")?.addEventListener("click", () => renderEAHMap(body)); + return; + } + // Group by playfield + const byPF = {}; + list.forEach(s => { (byPF[s.playfield || "(unknown)"] ||= []).push(s); }); + const pfNames = Object.keys(byPF).sort(); + body.innerHTML = ` +
    +
    + + +
    +
    +
    + Playfields +
    +
    +
    `; + // Fetch playfields for lifecycle buttons + const pfFetch = fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/playfields`).then(r => r.ok ? r.json() : {playfields:[]}).catch(() => ({playfields:[]})); + pfFetch.then(pfData => { + const pfList = pfData.playfields || []; + eahState.playfields = pfList; + const sb = $("#eah-pf-sidebar"); + if (!sb) return; + sb.innerHTML = pfList.map(pf => { + const pfName = esc(typeof pf === 'string' ? pf : (pf.name || pf.playfield || "")); + return `
    + ${pfName} + + + + +
    `; + }).join("") || '
    No playfields.
    '; + sb.querySelectorAll('[data-pf-load]').forEach(b => b.addEventListener('click', async e => { + try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfLoad)}/load`, {method:'POST'}); toast('Loaded','ok'); renderEAHMap(body); } catch(ex) { toast(ex.message,'err'); } + })); + sb.querySelectorAll('[data-pf-unload]').forEach(b => b.addEventListener('click', async e => { + try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfUnload)}/unload`, {method:'POST'}); toast('Unloaded','ok'); } catch(ex) { toast(ex.message,'err'); } + })); + sb.querySelectorAll('[data-pf-reset]').forEach(b => b.addEventListener('click', async e => { + try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfReset)}/reset`, {method:'POST'}); toast('Reset','ok'); } catch(ex) { toast(ex.message,'err'); } + })); + sb.querySelectorAll('[data-pf-wipe]').forEach(b => b.addEventListener('click', async e => { + const scope = prompt('Wipe scope (poi/all/terrain/deposit):', 'poi'); + if (!scope) return; + try { await eahFetch(`playfields/${encodeURIComponent(e.target.dataset.pfWipe)}/wipe`, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({scope})}); toast('Wiped','ok'); } catch(ex) { toast(ex.message,'err'); } + })); + }); + const drawPF = pf => { + const items = byPF[pf]; + const xs = items.map(s => s.pos[0]); + const zs = items.map(s => s.pos[2]); + const minX = Math.min(...xs) - 100, maxX = Math.max(...xs) + 100; + const minZ = Math.min(...zs) - 100, maxZ = Math.max(...zs) + 100; + const W = 760, H = 460; + const sx = x => ((x - minX) / (maxX - minX)) * W; + const sz = z => H - ((z - minZ) / (maxZ - minZ)) * H; + const colors = ["#5c7cfa", "var(--ok)", "#f59e0b", "#ec4899", "#10b981", "#a855f7"]; + const dots = items.map((s, i) => ` + + + ${esc(s.name || "#" + s.id)} + `).join(""); + $("#eah-map-canvas").innerHTML = ` + + + + ${dots} + +
    ${items.length} structures · X/Z plane (Y altitude not rendered) · Range x:[${Math.round(minX)},${Math.round(maxX)}] z:[${Math.round(minZ)},${Math.round(maxZ)}]
    `; + }; + drawPF(pfNames[0]); + $("#eah-map-pf").addEventListener("change", e => drawPF(e.target.value)); + $("#eah-map-r").addEventListener("click", () => renderEAHMap(body)); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- Scenarios (Reforged Eden 2 etc.) --- +async function renderEAHScenarios(body) { + body.innerHTML = loadingState("Loading scenario presets…"); + const id = instanceModalState.id; + try { + const [presetsRes, installedRes] = await Promise.all([ + fetch("/api/empyrion/scenarios/presets").then(r => r.json()).catch(() => ({})), + fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenarios/installed`).then(r => r.ok ? r.json() : { scenarios: [] }).catch(() => ({ scenarios: [] })), + ]); + const installed = installedRes.scenarios || []; + const inst = allInstances.find(x => x.instance_id === id) || {}; + const activeScenario = (inst.config_values && inst.config_values.SCENARIO) || ""; + body.innerHTML = ` +
    + +
    +
    + Active scenario + + +
    +
    ${installed.length} scenarios on disk · the entrypoint reads SCENARIO from config and patches dedicated.yaml on each boot · changing this requires a server restart to take effect.
    +
    + + Install from Steam Workshop +
    Pick a community scenario. The panel runs SteamCMD on the agent, downloads the workshop item, copies it into Content/Scenarios/, and auto-activates it.
    +
    + ${(presetsRes.presets || []).map(p => ` +
    +
    + ${esc(p.name)} +
    Workshop ID ${esc(p.id)} — ${esc(p.blurb || "")}
    +
    + +
    `).join("")} +
    +
    + Custom workshop ID +
    Paste any Empyrion workshop ID. Use the folder name the scenario expects in dedicated.yaml.
    +
    + + + +
    +
    +
    +
    `; + $("#eah-scen-apply").addEventListener("click", () => eahActivateScenario($("#eah-scen-active").value)); + body.querySelectorAll("button[data-wid]").forEach(b => b.addEventListener("click", () => eahStartScenarioJob(b.dataset.wid, b.dataset.name))); + $("#eah-scen-go").addEventListener("click", () => { + const wid = $("#eah-scen-wid").value.trim(), name = $("#eah-scen-name").value.trim(); + if (!wid || !name) { toast("Both workshop ID and folder name are required", "err"); return; } + eahStartScenarioJob(wid, name); + }); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// PATCHes config_values.SCENARIO on the instance and tells the operator +// the change needs a restart to take effect. The "Restart now" button +// in the toast triggers the existing instance-action flow. +async function eahActivateScenario(name) { + const id = instanceModalState.id; if (!id) return; + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/activate`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario: name }), + }); + if (!r.ok) throw new Error(await r.text()); + // Update the in-memory cache so other UI surfaces (e.g. switching + // tabs and coming back) reflect the new active scenario without a + // full instances-list refetch. + const inst = allInstances.find(x => x.instance_id === id); + if (inst) { + inst.config_values = inst.config_values || {}; + if (name) inst.config_values.SCENARIO = name; else delete inst.config_values.SCENARIO; + } + eahShowRestartPrompt(name); + } catch (err) { toast("Couldn't set active scenario: " + err.message, "err"); } +} + +// Sticky toast with a "Restart now" action button. Operators can choose +// to defer the restart (toast auto-dismisses after 30s) or click to +// trigger an immediate stop+start of the instance. +function eahShowRestartPrompt(scenarioName) { + const id = instanceModalState.id; + const label = scenarioName ? `"${scenarioName}"` : "Default Random (vanilla)"; + // Use the toast() helper but customize for an action button via DOM. + const t = document.createElement("div"); + t.className = "toast warn eah-restart-toast"; + t.innerHTML = ` +
    +
    + Active scenario set: ${esc(label)} +
    Restart the server for the new scenario to take effect — Empyrion only reads dedicated.yaml on boot.
    +
    + + +
    `; + Object.assign(t.style, { + position: "fixed", bottom: "24px", right: "24px", maxWidth: "440px", + padding: "12px 14px", background: "rgba(217,119,6,0.18)", + border: "1px solid rgba(217,119,6,0.55)", borderRadius: "8px", + color: "#fef3c7", zIndex: "10001", + boxShadow: "0 12px 32px rgba(0,0,0,.45)", + }); + document.body.appendChild(t); + const dismiss = () => { try { t.remove(); } catch {} }; + t.querySelector(".eah-restart-dismiss").addEventListener("click", dismiss); + t.querySelector(".eah-restart-now").addEventListener("click", async () => { + const btn = t.querySelector(".eah-restart-now"); + btn.disabled = true; btn.textContent = "Restarting…"; + try { + // Reuse the dashboard's existing restart flow (stop with grace, + // poll for container exit, then start) so the per-card "starting" + // overlay + ready-confirm logic kicks in normally. + await doInstanceRestart(id, btn); + toast(`Restarting server — new scenario will take effect once it's back up`, "ok"); + } catch (err) { + toast("Restart failed: " + err.message, "err"); + } finally { + dismiss(); + } + }); + setTimeout(dismiss, 30000); +} + +async function eahStartScenarioJob(workshopID, scenarioName) { + const id = instanceModalState.id; if (!id) return; + try { + const post = () => fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/install`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workshop_id: workshopID, scenario_name: scenarioName }) + }).then(async r => { + if (!r.ok) throw new Error(await r.text()); + return r.json(); + }); + let resp = await post(); + // Mirror the DayZ flow: controller returns {steam_login_required:true} + // when no Steam creds are configured. Pop the credential modal, + // retry on success. + if (resp && resp.steam_login_required) { + const ok = await openSteamLoginModal(resp.reason || "Empyrion workshop downloads need a Steam account that owns the game."); + if (!ok) { + toast("Steam login cancelled — install aborted", "err"); + return; + } + // After credential modal succeeds, the agent on the target host + // will do ITS OWN Steam login when it starts the workshop download + // — that's a SECOND Steam Guard push, on a different docker daemon + // than the controller's test-login. Surface a sticky toast so the + // operator keeps watching their phone instead of putting it down + // after the first approval. Without this, the second push times + // out unread and the install fails with "Timed out waiting for + // confirmation". + toast("📱 Watch for a SECOND Steam Mobile push — the agent on the target host now needs its own login approval. Approve it when it pops.", "warn", 30000); + resp = await post(); + if (resp && resp.steam_login_required) { + throw new Error("Steam login didn't take; try again."); + } + } + toast(`Started install: ${scenarioName}`, "ok"); + eahPollScenarioJob(resp.jobId); + } catch (err) { toast("Install failed to start: " + err.message, "err"); } +} + +async function eahPollScenarioJob(jobId) { + const id = instanceModalState.id; if (!id) return; + const wrap = $("#eah-scen-jobs"); + if (!wrap) return; + let last; + for (let i = 0; i < 200; i++) { // ~30 min @ 9s avg + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/jobs/${encodeURIComponent(jobId)}`); + if (!r.ok) break; + const j = await r.json(); + last = j; + wrap.innerHTML = ` +
    +
    ${esc(j.scenarioName)} (${esc(j.workshopId)})${esc(j.state)}
    + ${j.error ? `
    ${esc(j.error)}
    ` : ""} +
    ${esc(j.log || "")}
    +
    `; + if (j.state === "done" || j.state === "error") break; + } catch {} + await new Promise(r => setTimeout(r, 5000)); + } + if (last && last.state === "done") { + // Auto-activate the freshly-installed scenario — the operator + // installed it because they want to use it. No reason to make + // them go to a separate dropdown to enable it after. + try { + await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/scenario/activate`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scenario: last.scenarioName }), + }); + const inst = allInstances.find(x => x.instance_id === id); + if (inst) { + inst.config_values = inst.config_values || {}; + inst.config_values.SCENARIO = last.scenarioName; + } + } catch (err) { + toast(`Installed but couldn't auto-activate: ${err.message}`, "warn"); + return; + } + toast(`Scenario "${last.scenarioName}" installed and set as active`, "ok"); + eahShowRestartPrompt(last.scenarioName); + // Re-render the scenarios pill so the "Active scenario" dropdown + // refreshes (now lists the newly-installed folder + shows it as + // selected). + if (eahState.sub === "scenarios") { + const body = $("#im-eah-body"); if (body) renderEAHScenarios(body); + } + } +} + +// --- Blueprint Library --- +async function renderEAHLibrary(body) { + body.innerHTML = loadingState("Loading…"); + try { + const data = await fetchJSON('/api/empyrion/blueprint-library'); + const list = data.blueprints || []; + body.innerHTML = ` +
    +
    +
    📤 Upload blueprint (.epb)
    +
    + + + +
    +
    +
    +
    ${list.length} blueprints
    + + + ${list.map(bp => ` + + + + + + + `).join('') || ''} + +
    NameSizeUploadedNotes
    ${esc(bp.name)}${formatBytes(bp.size_bytes)}${esc(fmtTime(bp.uploaded_at))}${esc(bp.notes || '')} + + +
    No blueprints yet — upload one above.
    +
    +
    `; + body.querySelector('#bplib-up').addEventListener('submit', async (e) => { + e.preventDefault(); + const fd = new FormData(e.target); + try { + const r = await fetch('/api/empyrion/blueprint-library', { method:'POST', body: fd }); + if (!r.ok) throw new Error(await r.text()); + toast('Uploaded','ok'); + renderEAHLibrary(body); + } catch (err) { toast(err.message,'err'); } + }); + body.querySelectorAll('[data-bplib-del]').forEach(b => b.addEventListener('click', async () => { + if (!confirm('Delete this blueprint from the library?')) return; + try { + const r = await fetch(`/api/empyrion/blueprint-library/${b.dataset.bplibDel}`, { method:'DELETE' }); + if (!r.ok) throw new Error(await r.text()); + toast('Deleted','ok'); + renderEAHLibrary(body); + } catch (err) { toast(err.message, 'err'); } + })); + body.querySelectorAll('[data-bplib-spawn]').forEach(b => b.addEventListener('click', async () => { + const pid = prompt('Spawn near player ID:'); if (!pid) return; + const fid = prompt('Faction ID (optional, leave blank for default):'); + const id = instanceModalState.id; + const payload = { playerId: Number(pid) }; + if (fid && fid.trim()) payload.factionId = Number(fid); + try { + const r = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/blueprint-library/${b.dataset.bplibSpawn}/spawn`, { + method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) + }); + if (!r.ok) throw new Error(await r.text()); + toast('Spawn dispatched','ok'); + } catch (err) { toast(err.message, 'err'); } + })); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} +function formatBytes(n) { if (!n) return '0 B'; const u=['B','KB','MB','GB']; let i=0,v=n; while(v>=1024&&iNo players online.`; + return; + } + // Fetch each player's info in parallel. Cached on eahState so other + // actions (teleport-to-player) can look up target coords without a refetch. + // + // Pin entityId to the ID we already have from the list. The bridge + // returns an empty PlayerInfo `{}` for players who are mid-connect / + // un-spawned (no last playfield, no entity yet) — without this, every + // action button would render data-pid="undefined" and 404 on click. + const infos = await Promise.all(ids.map(id => + eahFetch(`players/${id}`) + .then(info => ({ ...(info || {}), entityId: (info && (info.entityId ?? info.EntityId)) ?? id })) + .catch(() => ({ entityId: id, playerName: "?", _loadFailed: true })) + )); + eahState.players = infos; + body.innerHTML = ` + + + + + ${infos.map(p => { + // A "stub" record is one where the bridge returned an empty + // PlayerInfo (`{}`) — common for players who've connected but + // haven't fully spawned (mid-login, no last playfield, etc.). + // We surface this visibly so the operator knows actions like + // give-credits will likely no-op until they spawn. + const stub = !p.playerName && !p.playfield && !p._loadFailed; + const nameCell = p.playerName + ? esc(p.playerName) + : (stub + ? `(connecting…)` + : (p._loadFailed ? `(load failed)` : "")); + return ` + + + + + + + + + `; + }).join("")} + +
    IDNamePlayfieldFactionHPCreditsActions
    ${esc(p.entityId)}${nameCell}${esc(p.playfield || "")}${esc(p.factionId || 0)}${Math.round(p.health || 0)}/${Math.round(p.healthMax || 0)}${Math.round(p.credits || 0)} + + + + + + + + + +
    `; + body.querySelectorAll("button[data-act]").forEach(b => b.addEventListener("click", eahPlayerAction)); + } catch (err) { + body.innerHTML = `
    ${esc(err.message)}
    `; + } +} + +// Modal-style picker for ban duration. Returns the duration string Empyrion's +// `ban add` verb expects (`30s`, `5m`, `1d`, `0` for permanent), or null if +// the operator cancels. +function eahShowBanPicker(pid) { + return new Promise(resolve => { + const presets = [ + { dur: "5m", label: "5 minutes" }, + { dur: "1h", label: "1 hour" }, + { dur: "1d", label: "1 day" }, + { dur: "7d", label: "7 days" }, + { dur: "30d", label: "30 days" }, + { dur: "1y", label: "1 year" }, + { dur: "0", label: "Permanent", danger: true }, + ]; + const dlg = document.createElement("dialog"); + dlg.className = "eah-ban-dlg"; + dlg.innerHTML = ` +
    +

    Ban player ${esc(String(pid))}

    +
    Pick a duration or enter a custom one (e.g. 3h, 14d).
    +
    + ${presets.map(p => ``).join("")} +
    +
    + + +
    +
    +
    + +
    +
    `; + document.body.appendChild(dlg); + const finish = (val) => { try { dlg.close(); } catch {} dlg.remove(); resolve(val); }; + dlg.querySelectorAll("button[data-dur]").forEach(b => b.addEventListener("click", () => finish(b.dataset.dur))); + dlg.querySelector("#eah-ban-cancel").addEventListener("click", () => finish(null)); + dlg.addEventListener("close", () => finish(null)); + const customInput = dlg.querySelector("#eah-ban-custom"); + const errLine = dlg.querySelector("#eah-ban-err"); + const customGo = () => { + const v = customInput.value.trim(); + if (!v) return; + if (!/^[0-9]+(s|m|h|d|y)$/.test(v) && v !== "0") { + errLine.textContent = "Format: 30s / 5m / 7h / 30d / 1y, or 0 for permanent."; + return; + } + errLine.textContent = ""; + finish(v); + }; + dlg.querySelector("#eah-ban-custom-go").addEventListener("click", customGo); + customInput.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); customGo(); } }); + dlg.showModal(); + }); +} + +async function eahPlayerAction(e) { + const pid = e.target.dataset.pid; + const act = e.target.dataset.act; + try { + if (act === "kick") { + const reason = prompt("Kick reason?", "kicked by admin"); + if (reason === null) return; + await eahFetch(`players/${pid}/kick`, { method: "POST", body: reason }); + toast(`Kicked ${pid}`, "ok"); + } else if (act === "ban") { + const dur = await eahShowBanPicker(pid); + if (!dur) return; + await eahFetch(`players/${pid}/ban?dur=${encodeURIComponent(dur)}`, { method: "POST" }); + toast(`Banned ${pid} (${dur === "0" ? "permanent" : dur})`, "ok"); + } else if (act === "give") { + eahShowGiveItemPicker(pid); + return; + } else if (act === "kit") { + await eahShowKitPicker(pid); + return; + } else if (act === "credits") { + const amt = prompt("Credits to add (negative subtracts)?", "1000"); + if (amt === null) return; + await eahFetch(`players/${pid}/give-credits`, { method: "POST", body: amt }); + toast(`+${amt} credits to ${pid}`, "ok"); + } else if (act === "whisper") { + const msg = prompt("Whisper to player:"); + if (!msg) return; + await eahFetch(`chat/whisper/${pid}`, { method: "POST", body: msg }); + toast("Whispered", "ok"); + } else if (act === "profile") { + showPlayerProfileModal(pid); + return; + } else if (act === "role") { + const role = await eahShowRolePicker(pid); + if (!role) return; + await eahFetch(`players/${pid}/set-role?role=${encodeURIComponent(role)}`, { method: "POST" }); + toast(`Set role: ${role}`, "ok"); + } else if (act === "tpto") { + const target = await eahShowTeleportPicker(pid, e.target.dataset.name); + if (!target) return; + await eahFetch(`players/${pid}/teleport`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ x: target.pos.x, y: target.pos.y, z: target.pos.z, playfield: target.playfield || "" }), + }); + toast(`Teleported ${pid} → ${target.playerName}`, "ok"); + } + renderEAHSub(); + } catch (err) { toast(err.message, "err"); } +} + +// Role picker — four-button dialog for Empyrion's documented role names. +function eahShowRolePicker(pid) { + return new Promise(resolve => { + const roles = [ + { v: "admin", label: "Admin", desc: "Full server permissions" }, + { v: "gamemaster", label: "Game Master", desc: "Most admin commands" }, + { v: "moderator", label: "Moderator", desc: "Moderation commands only" }, + { v: "player", label: "Player", desc: "Strip back to default" }, + ]; + const dlg = document.createElement("dialog"); + dlg.className = "eah-ban-dlg"; + dlg.innerHTML = ` +
    +

    Set role for player ${esc(String(pid))}

    +
    Wraps the setrole <id> <role> console verb. Takes effect immediately.
    +
    + ${roles.map(r => ``).join("")} +
    +
    + +
    +
    `; + document.body.appendChild(dlg); + const finish = v => { try { dlg.close(); } catch {} dlg.remove(); resolve(v); }; + dlg.querySelectorAll("button[data-role]").forEach(b => b.addEventListener("click", () => finish(b.dataset.role))); + dlg.querySelector("#eah-role-cancel").addEventListener("click", () => finish(null)); + dlg.addEventListener("close", () => finish(null)); + dlg.showModal(); + }); +} + +// Teleport-to-player picker. Lists every other online player from the +// cached eahState.players and resolves with the chosen target's +// {playerName, playfield, pos:{x,y,z}}. +function eahShowTeleportPicker(actorPid, actorName) { + return new Promise(resolve => { + const candidates = (eahState.players || []).filter(p => p.entityId !== Number(actorPid) && p.pos); + const dlg = document.createElement("dialog"); + dlg.className = "eah-ban-dlg"; + dlg.innerHTML = ` +
    +

    Teleport ${esc(actorName || ("#" + actorPid))} to…

    +
    Pick a destination player. Actor is moved to their current playfield + position.
    +
    + ${candidates.length ? candidates.map(p => ``).join("") : `
    No other online players to teleport to.
    `} +
    +
    + +
    +
    `; + document.body.appendChild(dlg); + const finish = v => { try { dlg.close(); } catch {} dlg.remove(); resolve(v); }; + dlg.querySelectorAll("button[data-pid]").forEach(b => b.addEventListener("click", () => { + const target = candidates.find(p => p.entityId === Number(b.dataset.pid)); + finish(target || null); + })); + dlg.querySelector("#eah-tp-cancel").addEventListener("click", () => finish(null)); + dlg.addEventListener("close", () => finish(null)); + dlg.showModal(); + }); +} + +// --- Items / Give-item picker --- +let _eahItemsCache = null; +async function _eahLoadItems() { + if (_eahItemsCache) return _eahItemsCache; + const r = await fetch("/api/empyrion/items?limit=5000"); + const data = await r.json(); + _eahItemsCache = data; + return data; +} + +async function renderEAHItems(body) { + try { + const data = await _eahLoadItems(); + body.innerHTML = ` + +
    `; + const drawList = (q, cat) => { + const ql = q.toLowerCase(); + const list = data.items.filter(it => + (!cat || it.category === cat) && + (!ql || it.name.toLowerCase().includes(ql))); + $("#eah-item-list").innerHTML = list.slice(0, 300).map(it => ` +
    + +
    +
    ${esc(it.name)} #${it.id}
    +
    ${esc(it.category || "")} ${esc(it.group || "")}
    +
    +
    `).join("") + (list.length > 300 ? `
    … ${list.length - 300} more
    ` : ""); + }; + drawList("", ""); + $("#eah-item-q").addEventListener("input", e => drawList(e.target.value, $("#eah-item-cat").value)); + $("#eah-item-cat").addEventListener("change", e => drawList($("#eah-item-q").value, e.target.value)); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +async function eahShowGiveItemPicker(pid) { + const data = await _eahLoadItems(); + const overlay = document.createElement("div"); + overlay.className = "eah-modal"; + overlay.innerHTML = ` +
    +
    Give item to player ${pid}
    + +
    Click to give 1× to player.
    +
    +
    + +
    +
    `; + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.addEventListener("click", e => { if (e.target === overlay || e.target.dataset.close !== undefined) close(); }); + const draw = q => { + const list = data.items.filter(it => !q || it.name.toLowerCase().includes(q.toLowerCase())).slice(0, 200); + overlay.querySelector("#eah-give-list").innerHTML = list.map(it => + `
    + +
    ${esc(it.name)} #${it.id} ${esc(it.category || "")}
    +
    `).join(""); + }; + draw(""); + overlay.querySelector("#eah-give-q").addEventListener("input", e => draw(e.target.value)); + overlay.querySelector("#eah-give-list").addEventListener("click", async e => { + const row = e.target.closest(".eah-give-row"); if (!row) return; + const itemId = parseInt(row.dataset.id, 10); + const count = parseInt(overlay.querySelector("#eah-give-count").value, 10) || 1; + try { + await eahFetch(`players/${pid}/give-item`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemId, count }) }); + toast(`Gave ${count}× item ${itemId}`, "ok"); + close(); + } catch (err) { toast(err.message, "err"); } + }); +} + +async function eahShowKitPicker(pid) { + const r = await fetch("/api/empyrion/templates"); + const data = await r.json(); + const tpls = data.templates || []; + if (!tpls.length) { toast("No kits defined yet — create one in the Kits tab", "err"); return; } + const overlay = document.createElement("div"); + overlay.className = "eah-modal"; + overlay.innerHTML = ` +
    +
    Drop kit on player ${pid}
    +
    ${tpls.map(t => ` +
    + ${esc(t.name)} ${(t.items || []).length} items +
    ${esc(t.description || "")}
    +
    `).join("")}
    +
    `; + document.body.appendChild(overlay); + const close = () => overlay.remove(); + overlay.addEventListener("click", e => { if (e.target === overlay || e.target.dataset.close !== undefined) close(); }); + overlay.querySelectorAll("[data-tid]").forEach(row => row.addEventListener("click", async () => { + const tid = row.dataset.tid; + const id = instanceModalState.id; + try { + const res = await fetch(`/api/instances/${encodeURIComponent(id)}/empyrion/templates/${tid}/apply/${pid}`, { method: "POST" }); + const out = await res.json(); + toast(`Applied ${out.applied}/${out.applied + out.failed} items`, out.failed ? "err" : "ok"); + close(); + } catch (err) { toast(err.message, "err"); } + })); +} + +// --- Structures --- +async function renderEAHStructures(body) { + try { + const r = await eahFetch("structures"); + const list = r.structures || []; + eahState.structures = list; + if (!list.length) { + body.innerHTML = `
    No structures yet (or list isn't loaded; click Refresh).
    `; + $("#eah-str-r")?.addEventListener("click", () => renderEAHStructures(body)); + return; + } + body.innerHTML = ` + + + ${list.map(s => ` + + + + + + + + + + `).join("")} + +
    IDNameTypePlayfieldPosFactionBlocksActions
    ${s.id}${esc(s.name || "")}${s.type}${esc(s.playfield || "")}${(s.pos || []).map(n => Math.round(n)).join(",")}${s.factionId}${s.blocks || 0} + +
    `; + body.querySelectorAll("button[data-act]").forEach(b => b.addEventListener("click", eahStructureAction)); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +async function eahStructureAction(e) { + const sid = e.target.dataset.sid; const act = e.target.dataset.act; + try { + if (act === "rename") { + const name = prompt("New name?"); + if (!name) return; + await eahFetch(`structures/${sid}/rename`, { method: "POST", body: name }); + toast("Renamed", "ok"); + } else if (act === "destroy") { + if (!confirm(`Destroy structure ${sid}? This cannot be undone.`)) return; + await eahFetch(`structures/${sid}/destroy`, { method: "POST" }); + toast("Destroyed", "ok"); + } else if (act === "faction") { + const fid = prompt("New faction id (number):"); if (fid === null) return; + await eahFetch(`structures/${sid}/faction`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ factionId: Number(fid) }) }); + toast("Faction changed", "ok"); + } else if (act === "heal") { + await eahFetch(`structures/${sid}/heal`, { method: "POST" }); + toast("Healed", "ok"); + } else if (act === "refuel") { + await eahFetch(`structures/${sid}/refuel`, { method: "POST" }); + toast("Refueled", "ok"); + } else if (act === "set-core") { + const mode = prompt("Core mode (admin|player|hardcore):"); if (!mode) return; + await eahFetch(`structures/${sid}/set-core`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({ mode }) }); + toast("Core set", "ok"); + } else if (act === "move") { + const c = prompt("Move to (x y z [playfield]):"); if (!c) return; + const parts = c.split(/\s+/); + const body = { x: Number(parts[0]), y: Number(parts[1]), z: Number(parts[2]) }; + if (parts[3]) body.playfield = parts[3]; + await eahFetch(`structures/${sid}/move`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body) }); + toast("Moved", "ok"); + } else if (act === "save-as-bp") { + const name = prompt("Blueprint name (optional):"); + const body = name ? { name } : {}; + await eahFetch(`structures/${sid}/save-as-blueprint`, { method: "POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body) }); + toast("Saved as blueprint", "ok"); + } + renderEAHSub(); + } catch (err) { toast(err.message, "err"); } +} + +// --- Context-menu helpers for structures and players --- +// Picker for the per-faction "Regenerate as…" right-click flow on +// structures. Empty faction = vanilla regenerate (whatever was there). +async function eahShowRegenPicker(sid) { + const factions = ["any", "Aln", "Civilian", "Kriel", "Pirates", "Polaris", "Talon", "Traders", "UCH", "Zirax"]; + const choice = prompt( + `Regenerate structure ${sid} as which faction?\n` + + `Options: ${factions.join(" · ")}\n\n` + + `(blank or "any" = default regen, no faction override)`, + "any" + ); + if (choice === null) return; + const trimmed = (choice || "").trim(); + const body = trimmed && trimmed.toLowerCase() !== "any" ? { faction: trimmed } : {}; + try { + await eahFetch(`structures/${sid}/regenerate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + toast(`Regenerated ${sid}${trimmed ? ` as ${trimmed}` : ""}`, "ok"); + } catch (err) { toast(err.message, "err"); } +} + +function eahStructureCtxById(e, id) { + e.preventDefault(); + const s = (eahState.structures || []).find(x => x.id === id); + if (s) eahStructureCtx(e, s); +} +function eahStructureCtx(e, s) { + e.preventDefault(); + panelContextMenu({ x: e.clientX, y: e.clientY, items: [ + { icon:'??', label:'Rename.', onClick: () => triggerEahStrAct('rename', s.id) }, + { icon:'??', label:'Change faction.', onClick: () => triggerEahStrAct('faction', s.id) }, + { icon:'??', label:'Heal full', onClick: () => triggerEahStrAct('heal', s.id) }, + { icon:'?', label:'Refuel full', onClick: () => triggerEahStrAct('refuel', s.id) }, + { icon:'???', label:'Set admin core.', onClick: () => triggerEahStrAct('set-core', s.id) }, + { icon:'??', label:'Move to coords.', onClick: () => triggerEahStrAct('move', s.id) }, + { icon:'??', label:'Save as blueprint.',onClick: () => triggerEahStrAct('save-as-bp', s.id) }, + { divider:true }, + { icon:'🔄', label:'Regenerate as…', onClick: () => eahShowRegenPicker(s.id) }, + { icon:'📋', label:'Activity', onClick: () => showStructureActivityModal(s.id) }, + { divider:true }, + { icon:'??', label:'Destroy', danger:true, onClick: () => triggerEahStrAct('destroy', s.id) }, + ]}); +} +async function triggerEahStrAct(act, sid) { + try { + if (act === 'rename') { + const name = prompt('New name?'); if (!name) return; + await eahFetch(`structures/${sid}/rename`, { method:'POST', body: name }); + toast('Renamed','ok'); + } else if (act === 'destroy') { + if (!confirm(`Destroy structure ${sid}? This cannot be undone.`)) return; + await eahFetch(`structures/${sid}/destroy`, { method:'POST' }); + toast('Destroyed','ok'); + } else if (act === 'faction') { + const fid = prompt('New faction id (number):'); if (fid === null) return; + await eahFetch(`structures/${sid}/faction`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ factionId: Number(fid) }) }); + toast('Faction changed','ok'); + } else if (act === 'heal') { + await eahFetch(`structures/${sid}/heal`, { method:'POST' }); + toast('Healed','ok'); + } else if (act === 'refuel') { + await eahFetch(`structures/${sid}/refuel`, { method:'POST' }); + toast('Refueled','ok'); + } else if (act === 'set-core') { + const mode = prompt('Core mode (admin|player|hardcore):'); if (!mode) return; + await eahFetch(`structures/${sid}/set-core`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ mode }) }); + toast('Core set','ok'); + } else if (act === 'move') { + const c = prompt('Move to (x y z [playfield]):'); if (!c) return; + const parts = c.split(/\s+/); + const body = { x: Number(parts[0]), y: Number(parts[1]), z: Number(parts[2]) }; + if (parts[3]) body.playfield = parts[3]; + await eahFetch(`structures/${sid}/move`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); + toast('Moved','ok'); + } else if (act === 'save-as-bp') { + const name = prompt('Blueprint name (optional):'); + const body = name ? { name } : {}; + await eahFetch(`structures/${sid}/save-as-blueprint`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }); + toast('Saved as blueprint','ok'); + } + renderEAHSub(); + } catch (err) { toast(err.message, 'err'); } +} +function eahPlayerCtxById(e, id) { + e.preventDefault(); + const p = (eahState.players || []).find(x => x.entityId === id); + if (p) eahPlayerCtx(e, p); +} +function eahPlayerCtx(e, p) { + e.preventDefault(); + const stub = !p.playerName && !p.playfield; + panelContextMenu({ x: e.clientX, y: e.clientY, items: [ + { icon:'👢', label:'Kick', onClick: () => triggerEahPlayerAct('kick', p.entityId) }, + { icon:'🚫', label:'Ban…', onClick: () => triggerEahPlayerAct('ban', p.entityId) }, + { icon:'✅', label:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) }, + { divider:true }, + { icon:'🎒', label:'View inventory', disabled:stub, onClick: () => triggerEahPlayerAct('inv', p.entityId) }, + { icon:'🎁', label:'Drop kit…', disabled:stub, onClick: () => triggerEahPlayerAct('kit', p.entityId) }, + { icon:'🧪', label:'Give item…', disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) }, + { icon:'💰', label:'+1k credits', disabled:stub, onClick: () => triggerEahPlayerAct('credits', p.entityId) }, + { icon:'💵', label:'Set credits…', disabled:stub, onClick: () => triggerEahPlayerAct('setcredits', p.entityId) }, + { divider:true }, + { icon:'💬', label:'Whisper…', onClick: () => triggerEahPlayerAct('whisper', p.entityId) }, + { icon:'🛡️', label:'Set role…', onClick: () => triggerEahPlayerAct('role', p.entityId) }, + { icon:'🚀', label:'Teleport →', disabled:stub, onClick: () => triggerEahPlayerAct('tpto', p.entityId, p) }, + { icon:'📍', label:'Teleport to coords…', disabled:stub, onClick: () => triggerEahPlayerAct('tpcoord', p.entityId) }, + ]}); +} +function triggerEahPlayerAct(act, pid, p) { + if (act === 'unban') return eahFetch(`players/${pid}/unban`, { method:'POST' }).then(()=>toast('Unbanned','ok')).catch(e=>toast(e.message,'err')); + if (act === 'setcredits') { + const amt = prompt('Set credits to:'); if (amt === null) return; + return eahFetch(`players/${pid}/set-credits`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ amount: Number(amt) }) }) + .then(()=>toast('Credits set','ok')).catch(e=>toast(e.message,'err')); + } + if (act === 'tpcoord') { + const c = prompt('Teleport to coords (x y z):'); if (!c) return; + const [x,y,z] = c.split(/\s+/).map(Number); + return eahFetch(`players/${pid}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ x, y, z }) }) + .then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err')); + } + if (act === 'inv') return alert('Inventory view: open in modal — TODO; for now use the existing player row.'); + // Fallthrough — replay through eahPlayerAction. + const fakeBtn = document.createElement('button'); + fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid; + if (p && p.playerName) fakeBtn.dataset.name = p.playerName; + const ev = new MouseEvent('click', { bubbles: true }); + Object.defineProperty(ev, 'target', { value: fakeBtn }); + return eahPlayerAction(ev); +} + +// --- Factions --- +async function renderEAHFactions(body) { + try { + const r = await eahFetch("factions"); + const list = r.factions || []; + body.innerHTML = ` +
    ${list.length} factions on server.
    + + + ${list.map(f => ` + + + + + + + `).join("")} + +
    IDNameAbbrevOriginActions
    ${f.factionId}${esc(f.name || "")}${esc(f.abbrev || "")}${f.origin} + + +
    +
    +

    Faction Reputation Matrix

    +
    Loading alliances…
    +
    `; + // Wire message buttons + body.querySelectorAll("button[data-act='msg']").forEach(b => b.addEventListener("click", async e => { + const fid = e.target.dataset.fid; + const msg = prompt(`Message faction ${fid}?`); if (!msg) return; + try { await eahFetch(`chat/faction/${fid}`, { method: "POST", body: msg }); toast("Sent", "ok"); } + catch (err) { toast(err.message, "err"); } + })); + // Wire activity buttons + body.querySelectorAll("button[data-act='activity']").forEach(b => b.addEventListener("click", async e => { + showFactionActivityModal(e.target.dataset.fid); + })); + // Reputation matrix + try { + const aData = await eahFetch("factions/alliances"); + const matrix = aData && aData.alliances ? aData.alliances : (aData || {}); + const factionIds = list.map(f => String(f.factionId)); + const matrixWrap = document.getElementById("eah-faction-matrix"); + if (!matrixWrap) return; + if (!factionIds.length) { matrixWrap.innerHTML = 'No factions to show.'; return; } + let html = '
    '; + factionIds.forEach(id => { html += ``; }); + html += ''; + factionIds.forEach(rowId => { + html += `'; + } else { + const key = rowId + '_' + colId; + const state = matrix[key] || 'neutral'; + const colors = { ally: 'var(--ok)', neutral: 'rgba(255,255,255,.35)', hostile: 'var(--err)' }; + const labels = { ally: 'A', neutral: '○', hostile: 'X' }; + html += ``; + } + }); + html += ''; + }); + html += '
    \\${esc(id)}
    ${esc(rowId)}`; + factionIds.forEach(colId => { + if (rowId === colId) { + html += '${labels[state] || labels.neutral}
    '; + html += '
    Click a cell to cycle: Ally (green) / Neutral (gray) / Hostile (red). Diagonal is self.
    '; + matrixWrap.innerHTML = html; + matrixWrap.querySelectorAll('[data-matrix-rel]').forEach(td => td.addEventListener('click', async e => { + const key = e.target.dataset.matrixRel; + const parts = key.split('_'); + if (parts.length !== 2) return; + const [factionA, factionB] = parts; + const cycle = { ally: 'neutral', neutral: 'hostile', hostile: 'ally' }; + const curState = e.target.dataset.matrixState; + const nextState = cycle[curState] || 'neutral'; + e.target.dataset.matrixState = nextState; + const colors = { ally: 'var(--ok)', neutral: 'rgba(255,255,255,.35)', hostile: 'var(--err)' }; + const labels = { ally: 'A', neutral: '○', hostile: 'X' }; + e.target.style.color = colors[nextState] || colors.neutral; + e.target.textContent = labels[nextState] || labels.neutral; + try { + await eahFetch('factions/alliance-state', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ factionA: Number(factionA), factionB: Number(factionB), state: nextState }) }); + toast(`${factionA} vs ${factionB}: ${nextState}`, 'ok'); + } catch(err) { toast(err.message, 'err'); } + })); + } catch (e) { + const mw = document.getElementById("eah-faction-matrix"); + if (mw) mw.innerHTML = `
    ${esc(e.message)}
    `; + } + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// --- Chat --- +function renderEAHChat(body) { + body.innerHTML = ` +
    +
    Server-wide broadcast (visible to all online players).
    + +
    + + + +
    +
    `; + $("#eah-bc-send").addEventListener("click", async () => { + const msg = $("#eah-bc-msg").value.trim(); + if (!msg) { toast("Empty message", "err"); return; } + const prio = $("#eah-bc-prio").value, time = $("#eah-bc-time").value; + try { + await eahFetch(`chat/broadcast?prio=${prio}&time=${time}`, { method: "POST", body: msg }); + toast("Broadcast sent", "ok"); + $("#eah-bc-msg").value = ""; + } catch (err) { toast(err.message, "err"); } + }); +} + +// --- Console (raw mod-API console command) --- +function renderEAHConsole(body) { + body.innerHTML = ` +
    +
    Send any in-game console command via the Mod API. Output appears in the main Console tab.
    + +
    + + + +
    +
    
    +    
    `; + const out = $("#eah-cc-out"); + $("#eah-cc-send").addEventListener("click", async () => { + const cmd = $("#eah-cc-cmd").value.trim(); if (!cmd) return; + try { const r = await eahFetch("console", { method: "POST", body: cmd }); out.textContent += `> ${cmd}\n${JSON.stringify(r)}\n`; $("#eah-cc-cmd").value = ""; } + catch (err) { toast(err.message, "err"); } + }); + $("#eah-cc-save").addEventListener("click", async () => { + try { await eahFetch("save", { method: "POST" }); toast("save sent", "ok"); } + catch (err) { toast(err.message, "err"); } + }); + $("#eah-cc-stats").addEventListener("click", async () => { + try { const r = await eahFetch("dedi/stats", { method: "POST" }); out.textContent += JSON.stringify(r, null, 2) + "\n"; } + catch (err) { toast(err.message, "err"); } + }); +} + +// --- Coords (CRUD saved coordinates) --- +async function renderEAHCoords(body) { + body.innerHTML = loadingState("Loading coords.…"); + const instId = instanceModalState.id; + if (!instId) { body.innerHTML = `
    no instance
    `; return; } + try { + const data = await (await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords`)).json(); + const list = Array.isArray(data) ? data : (data.coords || []); + const renderList = () => { + const tbody = list.length ? list.map(c => { + const posStr = `${c.x ?? 0} ${c.y ?? 0} ${c.z ?? 0}`; + return ` + ${esc(c.name || "")} + ${esc(c.playfield || "")} + ${posStr} + ${esc(c.notes || "")} + + + + + + `; + }).join("") : `No coords saved yet.`; + const table = document.createElement('table'); + table.className = 'eah-table'; + table.innerHTML = `NamePlayfieldPosNotesActions${tbody}`; + return table; + }; + body.innerHTML = ` +
    +
    + Add coord +
    + + + + + + +
    + +
    +
    +
    `; + const listWrap = $("#eah-crd-list"); + listWrap.appendChild(renderList()); + // Refresh list helper + const refreshList = async () => { + const d = await (await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords`)).json(); + list.length = 0; + const arr = Array.isArray(d) ? d : (d.coords || []); + arr.forEach(c => list.push(c)); + listWrap.innerHTML = ''; + listWrap.appendChild(renderList()); + wireCoordButtons(); + }; + const wireCoordButtons = () => { + body.querySelectorAll('[data-coord-tp]').forEach(b => b.addEventListener('click', async e => { + const cid = e.target.dataset.coordTp; + const pid = prompt('Player ID to teleport:'); if (!pid) return; + try { + await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords/${encodeURIComponent(cid)}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ playerId: Number(pid) }) }); + toast('Teleported','ok'); + } catch (err) { toast(err.message,'err'); } + })); + body.querySelectorAll('[data-coord-copy]').forEach(b => b.addEventListener('click', e => { + navigator.clipboard.writeText(e.target.dataset.coordCopy).then(() => toast('Coords copied','ok')).catch(() => toast('Copy failed','err')); + })); + body.querySelectorAll('[data-coord-del]').forEach(b => b.addEventListener('click', async e => { + const cid = e.target.dataset.coordDel; + if (!confirm(`Delete coord ${cid}?`)) return; + try { + await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords/${encodeURIComponent(cid)}`, { method:'DELETE' }); + toast('Deleted','ok'); + refreshList(); + } catch (err) { toast(err.message,'err'); } + })); + }; + wireCoordButtons(); + $("#eah-crd-save").addEventListener('click', async () => { + const name = $("#eah-crd-name").value.trim(); + const playfield = $("#eah-crd-pf").value.trim(); + const x = Number($("#eah-crd-x").value); + const y = Number($("#eah-crd-y").value); + const z = Number($("#eah-crd-z").value); + if (!name || !playfield) { toast('Name and playfield required','err'); return; } + const notes = $("#eah-crd-notes").value.trim(); + try { + await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/coords`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, playfield, x, y, z, notes }) }); + toast('Saved','ok'); + $("#eah-crd-name").value = ''; $("#eah-crd-pf").value = ''; $("#eah-crd-x").value = ''; $("#eah-crd-y").value = ''; $("#eah-crd-z").value = ''; $("#eah-crd-notes").value = ''; + refreshList(); + } catch (err) { toast(err.message,'err'); } + }); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} +// --- Warnings --- +async function renderEAHWarnings(body) { + body.innerHTML = loadingState("Loading warnings.…"); + const instId = instanceModalState.id; + if (!instId) { body.innerHTML = `
    no instance
    `; return; } + try { + const [rulesData, warnsData] = await Promise.all([ + fetch(`/api/empyrion/warning-rules`).then(r => r.ok ? r.json() : []).catch(() => []), + fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/warnings?limit=100`).then(r => r.ok ? r.json() : []).catch(() => []), + ]); + const rules = Array.isArray(rulesData) ? rulesData : (rulesData.rules || []); + const warns = Array.isArray(warnsData) ? warnsData : (warnsData.warnings || []); + body.innerHTML = ` +
    +

    Active rules

    +
    + + +

    Recent warnings (last 100)

    +
    +
    `; + const renderRules = () => { + const wrap = $("#eah-warn-rules"); + if (!rules.length) { wrap.innerHTML = '
    No rules defined.
    '; return; } + const tbl = document.createElement('table'); tbl.className = 'eah-table'; + tbl.innerHTML = `DescriptionThresholdActionEnabled + ${rules.map(r => ` + ${esc(r.description || "")} + ${esc(r.threshold || "")} + ${esc(r.thresholdAction || "")} + + + + + + `).join('')}`; + wrap.innerHTML = ''; wrap.appendChild(tbl); + wrap.querySelectorAll('[data-rule-toggle]').forEach(cb => cb.addEventListener('change', async e => { + const id = e.target.dataset.ruleToggle; + try { await fetch(`/api/empyrion/warning-rules/${encodeURIComponent(id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ enabled: e.target.checked }) }); toast('Updated','ok'); } catch (err) { toast(err.message,'err'); } + })); + wrap.querySelectorAll('[data-rule-del]').forEach(b => b.addEventListener('click', async e => { + const id = e.target.dataset.ruleDel; + if (!confirm(`Delete rule ${id}?`)) return; + try { await fetch(`/api/empyrion/warning-rules/${encodeURIComponent(id)}`, { method:'DELETE' }); toast('Deleted','ok'); renderEAHWarnings(body); } catch (err) { toast(err.message,'err'); } + })); + wrap.querySelectorAll('[data-rule-edit]').forEach(b => b.addEventListener('click', e => { + const id = e.target.dataset.ruleEdit; + const r = rules.find(x => (x.id || x._id) == id); + if (!r) return; + showRuleForm(r); + })); + }; + const showRuleForm = (existing) => { + const f = $("#eah-warn-rule-form"); + f.style.display = 'block'; + const desc = existing ? existing.description : ''; + const cel = existing ? (existing.cel || existing.condition || '') : ''; + const warnMsg = existing ? (existing.warnMessage || existing.message || '') : ''; + const thr = existing ? existing.threshold : ''; + const thrAct = existing ? existing.thresholdAction : ''; + const en = existing ? existing.enabled : true; + f.innerHTML = ` + ${existing ? 'Edit' : 'Add'} warning rule +
    + + + + + + +
    + `; + $("#ewr-save").addEventListener('click', async () => { + const payload = { + description: $("#ewr-desc").value.trim(), + cel: $("#ewr-cel").value.trim(), + warnMessage: $("#ewr-warn").value.trim(), + threshold: $("#ewr-thr").value.trim(), + thresholdAction: $("#ewr-act").value.trim(), + enabled: $("#ewr-en").checked, + }; + try { + if (existing) { + await fetch(`/api/empyrion/warning-rules/${encodeURIComponent(existing.id || existing._id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + toast('Updated','ok'); + } else { + await fetch(`/api/empyrion/warning-rules`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + toast('Created','ok'); + } + f.style.display = 'none'; + renderEAHWarnings(body); + } catch (err) { toast(err.message,'err'); } + }); + }; + renderRules(); + $("#eah-warn-add-rule").addEventListener('click', () => showRuleForm(null)); + // Render warnings list + const warnWrap = $("#eah-warn-list"); + if (!warns.length) { warnWrap.innerHTML = '
    No warnings issued.
    '; } + else { + const tbl = document.createElement('table'); tbl.className = 'eah-table'; + tbl.innerHTML = `PlayerReasonByWhen + ${warns.slice(0,100).map(w => ` + ${esc(w.playerName || w.playerId || "")} (${esc(w.playerId || "")}) + ${esc(w.reason || "")} + ${esc(w.given_by || w.givenBy || "")} + ${esc(w.given_at || w.created_at || w.timestamp || "")} + `).join('')}`; + warnWrap.innerHTML = ''; warnWrap.appendChild(tbl); + } + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} +// --- ChatBot rules --- +async function renderEAHChatBot(body) { + body.innerHTML = loadingState("Loading chatbot rules.…"); + try { + const data = await (await fetch(`/api/empyrion/chatbot-rules`)).json(); + const rules = Array.isArray(data) ? data : (data.rules || []); + body.innerHTML = ` +
    +
    + Available placeholders: + {playerName}, {playerId}, {factionId}, {playfield}, {now} +
    +
    + + +
    `; + const renderRules = () => { + const wrap = $("#eah-cbr-list"); + if (!rules.length) { wrap.innerHTML = '
    No chatbot rules defined.
    '; return; } + const tbl = document.createElement('table'); tbl.className = 'eah-table'; + tbl.innerHTML = `CommandDescriptionChannelResponseEnabled + ${rules.map(r => ` + ${esc(r.command || "")} + ${esc(r.description || "")} + ${esc(r.channel || r.channelType || "")} + ${esc((r.response||"").slice(0,80))} + + + + + + `).join('')}`; + wrap.innerHTML = ''; wrap.appendChild(tbl); + wrap.querySelectorAll('[data-cbr-toggle]').forEach(cb => cb.addEventListener('change', async e => { + const id = e.target.dataset.cbrToggle; + try { await fetch(`/api/empyrion/chatbot-rules/${encodeURIComponent(id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ enabled: e.target.checked }) }); toast('Updated','ok'); } catch (err) { toast(err.message,'err'); } + })); + wrap.querySelectorAll('[data-cbr-del]').forEach(b => b.addEventListener('click', async e => { + const id = e.target.dataset.cbrDel; + if (!confirm(`Delete rule ${id}?`)) return; + try { await fetch(`/api/empyrion/chatbot-rules/${encodeURIComponent(id)}`, { method:'DELETE' }); toast('Deleted','ok'); renderEAHChatBot(body); } catch (err) { toast(err.message,'err'); } + })); + wrap.querySelectorAll('[data-cbr-edit]').forEach(b => b.addEventListener('click', e => { + const id = e.target.dataset.cbrEdit; + const r = rules.find(x => (x.id || x._id) == id); + if (r) showRuleForm(r); + })); + }; + const showRuleForm = (existing) => { + const f = $("#eah-cbr-form"); + f.style.display = 'block'; + const cmd = existing ? existing.command : ''; + const desc = existing ? existing.description : ''; + const resp = existing ? existing.response : ''; + const ch = existing ? (existing.channel || existing.channelType || 'global') : 'global'; + const en = existing ? existing.enabled : true; + f.innerHTML = ` + ${existing ? 'Edit' : 'Add'} chatbot rule +
    + + + + + +
    + `; + $("#ecr-save").addEventListener('click', async () => { + const payload = { command: $("#ecr-cmd").value.trim(), description: $("#ecr-desc").value.trim(), response: $("#ecr-resp").value.trim(), channel: $("#ecr-ch").value, enabled: $("#ecr-en").checked }; + try { + if (existing) { + await fetch(`/api/empyrion/chatbot-rules/${encodeURIComponent(existing.id || existing._id)}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + toast('Updated','ok'); + } else { + await fetch(`/api/empyrion/chatbot-rules`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + toast('Created','ok'); + } + f.style.display = 'none'; + renderEAHChatBot(body); + } catch (err) { toast(err.message,'err'); } + }); + }; + renderRules(); + $("#eah-cbr-add").addEventListener('click', () => showRuleForm(null)); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} +// --- Dupes --- +async function renderEAHDupes(body) { + body.innerHTML = loadingState("Loading dupes.…"); + const instId = instanceModalState.id; + if (!instId) { body.innerHTML = `
    no instance
    `; return; } + try { + const data = await (await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/dupes`)).json(); + const groups = Array.isArray(data) ? data : (data.groups || []); + if (!groups.length) { body.innerHTML = `
    No dupes found.
    `; return; } + body.innerHTML = `
    ${groups.length} dupe group(s).
    `; + const wrap = $("#eah-dupe-groups"); + groups.forEach(g => { + const card = document.createElement('div'); + card.style.cssText = 'background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.08);border-radius:var(--radius);padding:12px;margin-bottom:8px'; + const members = g.members || g.structures || []; + card.innerHTML = ` +
    + ${esc(g.name || g.structureName || "Group")} + ${g.blockCount || g.block_count || "?"} blocks +
    + + + ${members.map(m => ` + + + + + `).join('')} +
    IDPlayfieldFaction
    ${esc(m.id || m.structureId || "")}${esc(m.playfield || "")}${esc(m.factionId || m.faction || "")}
    `; + wrap.appendChild(card); + }); + body.querySelectorAll('[data-dupe-destroy]').forEach(b => b.addEventListener('click', async e => { + const sid = e.target.dataset.dupeDestroy; + if (!confirm(`Destroy structure ${sid}?`)) return; + try { await eahFetch(`structures/${sid}/destroy`, { method:'POST' }); toast('Destroyed','ok'); renderEAHDupes(body); } catch (err) { toast(err.message,'err'); } + })); + } catch (err) { body.innerHTML = `
    ${esc(err.message)}
    `; } +} +// --- Fresh Start --- +async function renderEAHFreshStart(body) { + body.innerHTML = loadingState("Loading.…"); + const instId = instanceModalState.id; + if (!instId) { body.innerHTML = `
    no instance
    `; return; } + body.innerHTML = ` +
    +
    +

    Fresh Start

    +
    + + + + +
    + +
    +
    +
    `; + $("#eh-fs-launch").addEventListener('click', async () => { + const btn = $("#eh-fs-launch"); + const resultDiv = $("#eh-fs-result"); + btn.disabled = true; btn.textContent = 'Launching...'; + resultDiv.innerHTML = ''; + try { + const payload = { + announcement_minutes: Number($("#eh-fs-ann").value), + wipe_scope: $("#eh-fs-scope").value, + playfield: $("#eh-fs-pf").value.trim() || undefined, + restart: $("#eh-fs-restart").checked, + }; + const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/fresh-start`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); + if (!r.ok) throw new Error(await r.text()); + const { job_id } = await r.json(); + resultDiv.innerHTML = `
    Job ID: ${esc(job_id)}
    `; + // Poll for progress + const poll = async () => { + try { + const jr = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/fresh-start/jobs/${encodeURIComponent(job_id)}`); + if (!jr.ok) { resultDiv.innerHTML += `
    Poll failed: ${jr.status}
    `; return; } + const jd = await jr.json(); + const prog = $("#eh-fs-progress"); + prog.innerHTML = `
    Phase: ${esc(jd.phase || jd.status || "?")}
    ${esc(jd.message || "")}
    ${jd.done ? '
    Complete!
    ' : ''}`; + if (!jd.done) setTimeout(poll, 2000); + else { btn.disabled = false; btn.textContent = 'Launch'; } + } catch (e) { resultDiv.innerHTML += `
    ${esc(e.message)}
    `; btn.disabled = false; btn.textContent = 'Launch'; } + }; + poll(); + } catch (err) { resultDiv.innerHTML = `
    ${esc(err.message)}
    `; btn.disabled = false; btn.textContent = 'Launch'; } + }); +} + +// --- Per-player profile modal --- +async function showPlayerProfileModal(pid) { + const instId = instanceModalState.id; + if (!instId) { toast("no instance","err"); return; } + const bg = document.createElement("div"); bg.className = "modal-bg"; bg.style.display = "flex"; + bg.innerHTML = ` + `; + document.body.appendChild(bg); + // Stack above the parent instance modal via the shared modal stack + // (z-bump, aria, focus trap, backdrop click, Escape-pops-top). + initDynamicModal(bg); + const mb = bg.querySelector(".modal-body"); + try { + const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion-history/players/${encodeURIComponent(pid)}/profile`); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const playerName = esc(data.playerName || data.name || ""); + const lastSeen = esc(data.lastSeen || data.last_seen || ""); + const loginCount = data.loginCount ?? data.login_count ?? "?"; + const events = Array.isArray(data.events) ? data.events : []; + const chat = Array.isArray(data.chat) ? data.chat : []; + const warnings = Array.isArray(data.warnings) ? data.warnings : []; + const escPid = esc(pid); + mb.innerHTML = ` +
    +
    +
    ${playerName} (ID ${escPid})
    +
    Last seen: ${lastSeen} · Logins: ${loginCount}
    +
    + +
    +
    + + + +
    +
    +
    +
    `; + function renderTab(tab) { + var tc = document.getElementById("pp-tab-content"); + if (tab === "events") { + if (!events.length) { tc.innerHTML = '
    No events.
    '; return; } + var h = ''; + for (var i=0;i"; } + h += "
    TimeTypeDetail
    " + esc(e.type || "") + "" + esc((e.detail || e.message || JSON.stringify(e)).slice(0,120)) + "
    "; tc.innerHTML = h; + } else if (tab === "chat") { + if (!chat.length) { tc.innerHTML = '
    No chat messages.
    '; return; } + var h = ''; + for (var i=0;i"; } + h += "
    TimeFromMessage
    " + esc(c.from || c.playerName || "") + "" + esc((c.message || c.text || "").slice(0,200)) + "
    "; tc.innerHTML = h; + } else if (tab === "warnings") { + if (!warnings.length) { tc.innerHTML = '
    No warnings.
    '; return; } + var h = ''; + for (var i=0;i"; } + h += "
    TimeReasonBy
    " + esc(w.reason || "") + "" + esc(w.given_by || w.givenBy || "") + "
    "; tc.innerHTML = h; + } + }; + renderTab("events"); + var tabs = mb.querySelectorAll("#pp-tabs .eah-pill"); + for (var i=0;i${esc(err.message)}`; } +} + +// --- Activity drill-down modals (factions & structures) --- +async function showFactionActivityModal(fid) { + const instId = instanceModalState.id; + if (!instId) { toast("no instance","err"); return; } + const bg = document.createElement("div"); bg.className = "modal-bg"; bg.style.display = "flex"; + bg.innerHTML = ` + `; + document.body.appendChild(bg); + // Stack above the parent instance modal via the shared modal stack + // (z-bump, aria, focus trap, backdrop click, Escape-pops-top). + initDynamicModal(bg); + const mb = bg.querySelector(".modal-body"); + try { + const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/factions/${encodeURIComponent(fid)}/activity?limit=200`); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const events = Array.isArray(data) ? data : (data.events || data.activity || []); + if (!events.length) { mb.innerHTML = '
    No activity recorded.
    '; return; } + let html = '
    '; + events.sort((a,b) => (a.ts || a.timestamp || "") > (b.ts || b.timestamp || "") ? -1 : 1); + for (const ev of events) { + const detail = JSON.stringify(ev).slice(0, 80); + html += ``; + } + html += '
    TimeCommandPlayerDetail
    ${esc(ev.ts || ev.timestamp || "")}${esc(ev.cmd || ev.command || ev.type || "")}${esc(ev.player || ev.playerName || "")}${esc(detail)}
    '; + mb.innerHTML = html; + } catch (err) { mb.innerHTML = `
    ${esc(err.message)}
    `; } +} + +async function showStructureActivityModal(sid) { + const instId = instanceModalState.id; + if (!instId) { toast("no instance","err"); return; } + const bg = document.createElement("div"); bg.className = "modal-bg"; bg.style.display = "flex"; + bg.innerHTML = ` + `; + document.body.appendChild(bg); + // Stack above the parent instance modal via the shared modal stack + // (z-bump, aria, focus trap, backdrop click, Escape-pops-top). + initDynamicModal(bg); + const mb = bg.querySelector(".modal-body"); + try { + const r = await fetch(`/api/instances/${encodeURIComponent(instId)}/empyrion/structures/${encodeURIComponent(sid)}/activity?limit=200`); + if (!r.ok) throw new Error(await r.text()); + const data = await r.json(); + const events = Array.isArray(data) ? data : (data.events || data.activity || []); + if (!events.length) { mb.innerHTML = '
    No activity recorded.
    '; return; } + let html = '
    '; + events.sort((a,b) => (a.ts || a.timestamp || "") > (b.ts || b.timestamp || "") ? -1 : 1); + for (const ev of events) { + const detail = JSON.stringify(ev).slice(0, 80); + html += ``; + } + html += '
    TimeCommandPlayerDetail
    ${esc(ev.ts || ev.timestamp || "")}${esc(ev.cmd || ev.command || ev.type || "")}${esc(ev.player || ev.playerName || "")}${esc(detail)}
    '; + mb.innerHTML = html; + } catch (err) { mb.innerHTML = `
    ${esc(err.message)}
    `; } +} + +// ================ Template Library (WI-09) ================ +// +// Browses /api/template-catalog (the amp-distill'd CubeCoders AMPTemplates +// catalog, embedded in the controller) and scaffolds starter modules via +// the admin-gated POST /api/scaffold-module. The library modal is static +// shell markup (#template-library-modal); each entry's detail modal is +// built dynamically and bumps _modalZ so it stacks above the library. +// +// Art policy: never hotlink the Steam CDN at runtime. If local art exists +// at /game-art/.jpg we show it (onerror falls back); +// otherwise the tile is a CSS game-initial monogram. + +const libState = { entries: null, search: "", filters: new Set(), meRole: "" }; + +async function openTemplateLibrary() { + showModal("#template-library-modal"); + if (!libState.entries) { + try { + const [entries, me] = await Promise.all([ + fetchJSON("/api/template-catalog"), + fetchJSON("/api/me").catch(() => null), + ]); + libState.entries = entries; + libState.meRole = (me && me.role) || ""; + const cnt = $("#side-library-count"); if (cnt) cnt.textContent = String(entries.length); + const lc = $("#lib-count"); if (lc) lc.textContent = `${entries.length} game templates`; + } catch (err) { + $("#lib-grid").innerHTML = `
    ${esc(err.message)}
    `; + return; + } + const search = $("#lib-search"); + if (search && !search._libWired) { + search._libWired = true; + search.addEventListener("input", () => { libState.search = search.value; renderLibraryGrid(); }); + $$("#lib-filters .lib-chip").forEach(chip => { + chip.addEventListener("click", () => { + const f = chip.dataset.libf; + // ported/unported and native/wine are mutually exclusive pairs. + const excl = { ported: "unported", unported: "ported", native: "wine", wine: "native" }; + if (libState.filters.has(f)) libState.filters.delete(f); + else { libState.filters.delete(excl[f]); libState.filters.add(f); } + $$("#lib-filters .lib-chip").forEach(c => c.classList.toggle("active", libState.filters.has(c.dataset.libf))); + renderLibraryGrid(); + }); + }); + $("#lib-grid").addEventListener("click", e => { + const card = e.target.closest("[data-lib-id]"); + if (card) openLibraryDetail(card.dataset.libId); + }); + } + } + renderLibraryGrid(); +} + +function libFiltered() { + const q = (libState.search || "").trim().toLowerCase(); + const f = libState.filters; + return (libState.entries || []).filter(e => { + if (f.has("native") && e.wine) return false; + if (f.has("wine") && !e.wine) return false; + if (f.has("steamlogin") && !e.steam_login_required) return false; + if (f.has("ported") && !e.ported) return false; + if (f.has("unported") && e.ported) return false; + if (!q) return true; + return (e.name || "").toLowerCase().includes(q) || + (e.id || "").toLowerCase().includes(q) || + (e.description || "").toLowerCase().includes(q); + }); +} + +// libArtHTML: local art when present (module_id first — that's how +// /game-art files are named for ported games), monogram otherwise. +// The onerror collapses to the CSS monogram; no external requests. +function libArtHTML(e) { + const initial = esc((e.name || "?").trim().charAt(0).toUpperCase()); + // Deterministic hue from the id so tiles vary without art assets. + let hash = 0; for (const ch of e.id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; + const hue = hash % 360; + const mono = `${initial}`; + const artId = e.module_id || e.id; + return `${mono}`; +} + +function renderLibraryGrid() { + const grid = $("#lib-grid"); if (!grid) return; + const list = libFiltered(); + if (!list.length) { + grid.innerHTML = `
    No templates match.
    `; + return; + } + grid.innerHTML = list.map(e => ` + `).join(""); +} + +function openLibraryDetail(id) { + const e = (libState.entries || []).find(x => x.id === id); + if (!e) return; + const ports = (e.ports || []).map(p => + `${esc(p.name)}${p.port}${esc(p.proto)}`).join(""); + const events = Object.entries(e.events || {}).map(([k, v]) => + `${esc(k)}${esc(v)}`).join(""); + const stages = (e.update_stages || []).map(s => + `${esc(s.kind)}${esc(s.name || "")}${esc(s.app_id || s.repo || s.url || "")}${s.conditional ? "optional" : ""}`).join(""); + const isAdmin = libState.meRole === "admin"; + + const bg = document.createElement("div"); + bg.className = "modal-bg"; + bg.style.display = "flex"; + // Dynamic modal: stack above the library modal via the shared stack. + const close = initDynamicModal(bg); + bg.innerHTML = ` + `; + document.body.appendChild(bg); + bg.addEventListener("click", ev => { if (ev.target.closest("[data-lib-close]")) close(); }); + + const sBtn = bg.querySelector("[data-lib-scaffold]"); + if (sBtn) sBtn.addEventListener("click", async () => { + sBtn.disabled = true; sBtn.textContent = "Scaffolding…"; + const out = bg.querySelector(".lib-scaffold-out"); + try { + const r = await fetch("/api/scaffold-module", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: e.id }), + }); + const body = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(body.message || body.error || `HTTP ${r.status}`); + out.style.display = ""; + out.innerHTML = ` +
    + Scaffolded ${esc(body.dir || body.module_id)} — EXPERIMENTAL, needs a real boot test. +
      ${(body.files || []).map(f => `
    • ${esc(f)}
    • `).join("")}
    + ${(body.warnings || []).length ? `
    ${(body.warnings || []).map(w => `⚠ ${esc(w)}`).join("
    ")}
    ` : ""} +
    `; + sBtn.textContent = "Scaffolded ✓"; + toast(`Scaffolded modules/${body.module_id}`, "ok"); + } catch (err) { + out.style.display = ""; + out.innerHTML = `
    ${esc(err.message)}
    `; + sBtn.disabled = false; sBtn.textContent = "⚗ Scaffold module"; + } + }); +} diff --git a/controller/cmd/controller/static/sw.js b/controller/cmd/controller/static/sw.js new file mode 100644 index 00000000..c650eb1a --- /dev/null +++ b/controller/cmd/controller/static/sw.js @@ -0,0 +1,31 @@ +// RGSP service worker — SELF-DESTRUCT build. +// +// The previous SW cached the app shell and served stale HTML that +// survived hard-refresh / incognito / different machines, masking +// deploys. We have removed SW registration from the app. This file +// remains only so that any browser that STILL has an old SW registered +// will, when it re-fetches /sw.js, install this version which +// immediately unregisters itself and deletes all caches — healing the +// client without any manual "clear site data" step. + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + // Wipe every cache this origin holds. + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + // Unregister self so future loads talk straight to the network. + await self.registration.unregister(); + // Force all open tabs to reload from the network now. + const clients = await self.clients.matchAll({ type: "window" }); + clients.forEach((c) => c.navigate(c.url)); + })() + ); +}); + +// Never intercept anything — pure passthrough to the network. +self.addEventListener("fetch", () => {}); diff --git a/controller/cmd/controller/steamauth.go b/controller/cmd/controller/steamauth.go new file mode 100644 index 00000000..e2471947 --- /dev/null +++ b/controller/cmd/controller/steamauth.go @@ -0,0 +1,455 @@ +package main + +// Steam credential storage + test-login flow. +// Handlers are hung off httpServer (the REST surface) via h.handleSteam* +// after registration in httpapi.go's handler() routing table. +// +// Some SteamCMD apps (DayZ app 223350, Arma 3, a handful of paid servers) +// refuse `+login anonymous` with "No subscription". The panel needs to +// supply real Steam credentials from an account that owns the target app. +// We do this with a one-time login ceremony: +// +// 1. Operator triggers Create/Update on a module whose provider declares +// `requires_steam_login: true`. +// 2. Controller checks for a cached credential. None → returns 200 with +// {"steam_login_required": true, "reason": "..."} — frontend pops a modal. +// 3. Operator enters Steam username + password (+ optional Steam Guard code). +// 4. POST /api/steam/login runs a one-shot SteamCMD sidecar with those +// creds. Outcomes: +// - success → persist password ciphertext + sentry/SSFN blob, return 200 +// - Steam Guard required → return {"need_guard": true}; modal re-prompts +// - invalid password → 401 +// 5. Controller re-enqueues the original update request with the now-known +// credentials. +// +// Secrets handling: +// - Password is encrypted at rest with AES-GCM, key HKDF-derived from the +// CA private key. Never logged, never sent back to the UI. +// - Sentry file (SSFN) is stored separately and mounted into every +// SteamCMD sidecar so 2FA isn't re-prompted every run. +// - Database column is BYTEA; transport is over HTTPS (dashboard) → the +// password crosses the wire once per login ceremony. + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "golang.org/x/crypto/hkdf" +) + +// steamKEK derives a 32-byte key from the controller's CA private key. +// The CA key is unique per panel install, never leaves disk, and is +// already backed up/restored as a unit with the rest of data/ca/ — so +// tying our encryption key to it means "restore data/ca + postgres dump +// → everything still decrypts" without a separate key file to manage. +// +// We pass the PKCS1-marshalled private key bytes into HKDF. The marshal +// output is deterministic for a given key so the KEK is stable across +// restarts. +func steamKEK(privKey *rsa.PrivateKey) ([]byte, error) { + if privKey == nil { + return nil, errors.New("ca key is nil") + } + seed := x509.MarshalPKCS1PrivateKey(privKey) + h := hkdf.New(sha256.New, seed, []byte("panel.steam.credentials.v1"), []byte("aes-256-gcm")) + out := make([]byte, 32) + if _, err := io.ReadFull(h, out); err != nil { + return nil, fmt.Errorf("hkdf: %w", err) + } + return out, nil +} + +// encryptSteamPassword returns (ciphertext, nonce). AES-GCM provides both +// confidentiality and integrity. +func encryptSteamPassword(kek []byte, password string) ([]byte, []byte, error) { + block, err := aes.NewCipher(kek) + if err != nil { + return nil, nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, nil, err + } + ct := gcm.Seal(nil, nonce, []byte(password), nil) + return ct, nonce, nil +} + +// decryptSteamPassword is the inverse of encryptSteamPassword. +func decryptSteamPassword(kek, ciphertext, nonce []byte) (string, error) { + block, err := aes.NewCipher(kek) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + pt, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + return string(pt), nil +} + +// ---- HTTP handlers ---- + +type steamLoginRequest struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` // required on first login, absent on guard-retry + GuardCode string `json:"guard_code,omitempty"` // Steam Guard code — empty unless server asked for it +} + +type steamLoginResponse struct { + OK bool `json:"ok"` + Username string `json:"username,omitempty"` + NeedGuard bool `json:"need_guard,omitempty"` + Error string `json:"error,omitempty"` +} + +func (h *httpServer) handleSteamLogin(w http.ResponseWriter, r *http.Request) { + var req steamLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" { + writeError(w, http.StatusBadRequest, "bad_request", "username required") + return + } + + // If no password was supplied AND we don't have one cached, reject. + // (UI sends password on the initial call but not on guard-retries; + // on guard-retry we reuse the cached-plaintext path via the new creds + // we just stored a moment ago.) + var password string + if req.Password != "" { + password = req.Password + } else { + kek, err := steamKEK(h.ca.RootKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "kek derive: "+err.Error()) + return + } + existing, err := h.db.GetSteamCredentialByUsername(r.Context(), req.Username) + if err != nil { + writeError(w, http.StatusUnauthorized, "no_cached_password", "provide a password for first login") + return + } + pw, err := decryptSteamPassword(kek, existing.PasswordCiphertext, existing.PasswordNonce) + if err != nil { + writeError(w, http.StatusInternalServerError, "decrypt", err.Error()) + return + } + password = pw + } + + // Run SteamCMD test-login. Mounts the panel-wide SteamCMD auth volume + // so a successful login's sentry/SSFN blob persists for subsequent + // update sidecars. + result, err := runSteamCMDTestLogin(r.Context(), req.Username, password, req.GuardCode) + if err != nil { + h.log.Error("steam test-login infra error", "username", req.Username, "err", err) + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + h.log.Info("steam test-login", + "username", req.Username, + "outcome", result.outcome, + "guard_supplied", req.GuardCode != "", + "message_head", firstLine(result.message), + ) + // On unclassified outcomes, dump the tail of SteamCMD's output so we + // can grow the classifier. Password is never in this message (we + // redact via redactSteamOutput before it reaches here, and SteamCMD + // doesn't echo +login args back on stdout). + if result.outcome == steamLoginUnknown { + h.log.Warn("steam test-login: unclassified output (please copy to a dev issue)", + "tail", result.message, + ) + } + + switch result.outcome { + case steamLoginSuccess: + kek, err := steamKEK(h.ca.RootKey) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "kek derive: "+err.Error()) + return + } + ct, nonce, err := encryptSteamPassword(kek, password) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", "encrypt: "+err.Error()) + return + } + if err := h.db.UpsertSteamCredential(r.Context(), req.Username, ct, nonce); err != nil { + writeError(w, http.StatusInternalServerError, "internal", "persist: "+err.Error()) + return + } + if err := h.db.TouchSteamCredential(r.Context(), req.Username); err != nil { + h.log.Warn("touch steam credential", "err", err) + } + writeJSON(w, http.StatusOK, steamLoginResponse{OK: true, Username: req.Username}) + case steamLoginGuardRequired: + writeJSON(w, http.StatusOK, steamLoginResponse{OK: false, NeedGuard: true, Username: req.Username}) + case steamLoginInvalidPassword: + writeJSON(w, http.StatusUnauthorized, steamLoginResponse{OK: false, Error: "invalid Steam password"}) + case steamLoginRateLimited: + writeJSON(w, http.StatusTooManyRequests, steamLoginResponse{OK: false, Error: "Steam rate-limited the login. Wait a few minutes."}) + default: + writeJSON(w, http.StatusInternalServerError, steamLoginResponse{OK: false, Error: result.message}) + } +} + +type steamLoginOutcome int + +const ( + steamLoginUnknown steamLoginOutcome = iota + steamLoginSuccess + steamLoginGuardRequired + steamLoginInvalidPassword + steamLoginRateLimited +) + +type steamLoginResult struct { + outcome steamLoginOutcome + message string +} + +// runSteamCMDTestLogin runs `docker run --rm steamcmd/steamcmd +set_steam_guard_code +login

    +quit` +// and classifies the result. The panel-wide auth volume is mounted so the +// sentry blob persists across sidecars. +func runSteamCMDTestLogin(ctx context.Context, username, password, guardCode string) (steamLoginResult, error) { + const authVolume = "panel-steamcmd-auth" + const image = "steamcmd/steamcmd:latest" + + // The steamcmd:latest image runs as root with HOME=/root; its data dir + // is /root/.local/share/Steam (config.vdf login cache + ssfn sentry + + // workshop cache all live under here). Mounting the auth volume at + // /root/Steam (as earlier versions did) catches nothing — fresh 2FA + // prompt on EVERY login. Fixed: mount at the real data dir. + args := []string{ + "run", "--rm", + "-v", authVolume + ":/root/.local/share/Steam", + image, + } + if guardCode != "" { + args = append(args, "+set_steam_guard_code", guardCode) + } + args = append(args, "+login", username, password, "+quit") + + // Allow 90 s for login (Steam Guard email delivery + user typing takes + // real time — but here we're handed the code already). Rate-limiting + // is the slow case. + cctx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + // Locate docker. Try the standard Windows install path first (that's + // where Claude Code sessions + the panel's dev loop always run), then + // fall back to $PATH + $DOCKER_BIN. Using os.Stat with a native-style + // path — Unix-style `/c/Program Files/...` fails on Windows. + dockerCandidates := []string{ + os.Getenv("DOCKER_BIN"), + `C:\Program Files\Docker\Docker\resources\bin\docker.exe`, + "/usr/bin/docker", + "/usr/local/bin/docker", + } + dockerBin := "docker" + for _, c := range dockerCandidates { + if c == "" { + continue + } + if fi, err := os.Stat(c); err == nil && !fi.IsDir() { + dockerBin = c + break + } + } + + cmd := exec.CommandContext(cctx, dockerBin, args...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + runErr := cmd.Run() // classify via stdout regardless of exit code + rawOut := out.String() + // SteamCMD's stdout is laced with ANSI color escapes. They routinely + // split the literal strings we classify on ("Waiting for user info..." + // then ESC[0m then "OK"), so normalize before matching. We also case- + // fold to tolerate minor casing drift between SteamCMD versions. + outStr := strings.ToLower(ansiEscapeRE.ReplaceAllString(rawOut, "")) + if runErr != nil && outStr == "" { + // exec itself failed (docker not found, bad args, etc.). Return a + // real error so the caller can show "Failed to run SteamCMD: ...". + return steamLoginResult{outcome: steamLoginUnknown, message: runErr.Error()}, nil + } + + // Classify by the tell-tale SteamCMD phrases. Order matters — guard- + // required and invalid-password need to win over a bare "OK" earlier + // in the stream that's from the Steam-API probe, not the real login. + hasAny := func(needles ...string) bool { + for _, n := range needles { + if strings.Contains(outStr, n) { + return true + } + } + return false + } + + switch { + // Hard-stop failure markers win first. + case hasAny("invalid password", "invalidpassword", "failed (invalid password)", + "password is incorrect", "failed to login"): + return steamLoginResult{outcome: steamLoginInvalidPassword, message: redactSteamOutput(rawOut)}, nil + case hasAny("ratelimitexceeded", "rate limit", "too many login failures"): + return steamLoginResult{outcome: steamLoginRateLimited, message: redactSteamOutput(rawOut)}, nil + case hasAny("two-factor code", "steam guard code", + "accountlogondenied", "accountlogondeniedneedtwofactorcode", + "requires a steam guard", "please check your email"): + return steamLoginResult{outcome: steamLoginGuardRequired, message: redactSteamOutput(rawOut)}, nil + + // Success markers — any of these in the stream means we got past auth. + // The most reliable is "logging in user '' ... to steam public" + "ok" + // later in the stream. We also accept the older "logged in ok" phrasing. + case hasAny("logged in ok", + "logging in user", // immediately followed by success in practice + "waiting for user info", // only reached after a clean login + "waiting for client config"): // same — follows successful auth + return steamLoginResult{outcome: steamLoginSuccess}, nil + } + + // Unknown outcome — dump a redacted snippet. + return steamLoginResult{outcome: steamLoginUnknown, message: redactSteamOutput(rawOut)}, nil +} + +// ansiEscapeRE matches the common ANSI CSI color/style escapes SteamCMD +// emits (e.g. \x1b[0m, \x1b[1m, \x1b[33;1m). We strip these before +// classifying stdout so color codes don't fragment our search strings. +var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +// firstLine returns the first non-empty line of s, for log-friendly +// one-liners derived from multi-line SteamCMD output. +func firstLine(s string) string { + for _, ln := range strings.Split(s, "\n") { + ln = strings.TrimSpace(ln) + if ln != "" { + if len(ln) > 120 { + ln = ln[:120] + "…" + } + return ln + } + } + return "" +} + +// redactSteamOutput strips anything that looks like a credential before +// we return it as an error message. +func redactSteamOutput(s string) string { + // Trim to the last ~20 lines and mask obvious credentials. + lines := strings.Split(s, "\n") + if len(lines) > 25 { + lines = lines[len(lines)-25:] + } + for i, ln := range lines { + if strings.Contains(ln, "+login") { + lines[i] = "[+login redacted]" + } + } + return strings.Join(lines, "\n") +} + +// ---- helpers used from handlers ---- + +func (h *httpServer) handleSteamList(w http.ResponseWriter, r *http.Request) { + names, err := h.db.ListSteamCredentialUsernames(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"usernames": names}) +} + +func (h *httpServer) handleSteamDelete(w http.ResponseWriter, r *http.Request) { + username := strings.TrimSpace(r.URL.Query().Get("username")) + if username == "" { + writeError(w, http.StatusBadRequest, "bad_request", "username query param required") + return + } + if err := h.db.DeleteSteamCredential(r.Context(), username); err != nil { + writeError(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// providerNeedsSteamLogin inspects the module-summary advertisement the +// agent sent at handshake and returns (true, providerID) if the update +// provider the caller picked (or the module's first provider) declares +// requires_steam_login. Returns (false, "") when credentials aren't +// needed — the update path then proceeds with the normal +login anonymous. +func (h *httpServer) providerNeedsSteamLogin(ctx context.Context, agentID, instanceID, providerID string) (bool, string) { + rows, err := h.db.ListInstances(ctx, "") + if err != nil { + return false, "" + } + var moduleID string + for _, r := range rows { + if r.InstanceID == instanceID { + moduleID = r.ModuleID + break + } + } + if moduleID == "" { + return false, "" + } + mod := h.registry.moduleByID(agentID, moduleID) + if mod == nil { + return false, "" + } + for _, p := range mod.UpdateProviders { + if providerID == "" || p.Id == providerID { + return p.RequiresSteamLogin, p.Id + } + } + return false, "" +} + +// getSteamLoginForUpdate returns the cached Steam username + plaintext +// password to use for the next SteamCMD sidecar. The agent receives these +// over the normal gRPC control envelope (mTLS-encrypted channel), used as +// `+login ` args, and they never appear in the Console log +// stream (the dispatch-side log pump redacts them; see steamcmd.go). +// Returns (_, _, false) if no credential is cached. +func (h *httpServer) getSteamLoginForUpdate(ctx context.Context) (username, password string, ok bool) { + row, err := h.db.GetAnySteamCredential(ctx) + if err != nil { + return "", "", false + } + kek, err := steamKEK(h.ca.RootKey) + if err != nil { + return "", "", false + } + pw, err := decryptSteamPassword(kek, row.PasswordCiphertext, row.PasswordNonce) + if err != nil { + return "", "", false + } + return row.Username, pw, true +} + diff --git a/controller/cmd/controller/steamlogin.go b/controller/cmd/controller/steamlogin.go new file mode 100644 index 00000000..1319b389 --- /dev/null +++ b/controller/cmd/controller/steamlogin.go @@ -0,0 +1,329 @@ +package main + +// Steam OpenID 2.0 sign-in. Separate from the Steam credential store +// we use for SteamCMD workshop downloads (see steamauth.go) — this +// feature is about panel login, not game-install auth. +// +// Flow: +// +// 1. User clicks "Sign in with Steam" on /login. +// 2. Frontend navigates to GET /api/auth/steam/start. +// 3. Handler builds the OpenID redirect URL and 302s the browser to +// https://steamcommunity.com/openid/login with: +// openid.ns = http://specs.openid.net/auth/2.0 +// openid.mode = checkid_setup +// openid.return_to = /api/auth/steam/callback +// openid.realm = +// openid.identity = http://specs.openid.net/auth/2.0/identifier_select +// openid.claimed_id = http://specs.openid.net/auth/2.0/identifier_select +// 4. User logs in with Steam, Steam 302s back to our callback with +// several openid.* query params (including openid.claimed_id +// containing the 17-digit SteamID64). +// 5. Handler re-POSTs every received openid.* param back to Steam +// with openid.mode=check_authentication. Steam replies with +// "is_valid:true" if the signature is genuine. +// 6. On success, extract the SteamID from claimed_id, look up a user +// with that steam_id, create a session cookie. +// +// Steam's OpenID dialect has no secrets — the verify-with-Steam round +// trip is what authenticates the claim. We only accept claimed_id +// URLs under https://steamcommunity.com/openid/id/<17 digits>. + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/dbledeez/panel/controller/internal/db" +) + +const ( + steamOpenIDEndpoint = "https://steamcommunity.com/openid/login" + steamClaimedIDPrefix = "https://steamcommunity.com/openid/id/" + steamLoginCookieTTL = 30 * 24 * time.Hour // same as password-based sessions +) + +// steamIDRE matches the 17-digit SteamID64 at the end of the claimed_id +// URL. Steam's claimed_id is always https://steamcommunity.com/openid/id/. +var steamIDRE = regexp.MustCompile(`^https://steamcommunity\.com/openid/id/(\d{17})$`) + +// ---- /api/auth/steam/start ---- + +// handleSteamLoginStart builds the OpenID redirect and 302s the caller. +// The `next` query param (where to send the user after a successful +// login) is embedded in return_to so the callback can honour it. +func (a *auth) handleSteamLoginStart(w http.ResponseWriter, r *http.Request) { + realm := a.publicURL(r) + next := strings.TrimSpace(r.URL.Query().Get("next")) + if next == "" || !strings.HasPrefix(next, "/") { + next = "/" + } + + returnTo, _ := url.Parse(realm + "/api/auth/steam/callback") + q := returnTo.Query() + q.Set("next", next) + returnTo.RawQuery = q.Encode() + + params := url.Values{} + params.Set("openid.ns", "http://specs.openid.net/auth/2.0") + params.Set("openid.mode", "checkid_setup") + params.Set("openid.return_to", returnTo.String()) + params.Set("openid.realm", realm) + params.Set("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select") + params.Set("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select") + + http.Redirect(w, r, steamOpenIDEndpoint+"?"+params.Encode(), http.StatusFound) +} + +// ---- /api/auth/steam/callback ---- + +// handleSteamLoginCallback verifies the OpenID assertion with Steam, +// extracts the SteamID, and either logs the matched user in (by setting +// a session cookie) or bounces them back to /login with an error. +func (a *auth) handleSteamLoginCallback(w http.ResponseWriter, r *http.Request) { + // The incoming request has lots of openid.* query params. We verify + // by re-POSTing them to Steam's OpenID endpoint with mode flipped. + claimedID := r.URL.Query().Get("openid.claimed_id") + if claimedID == "" { + redirectToLoginWithErr(w, r, "Steam login didn't return a claimed ID.") + return + } + m := steamIDRE.FindStringSubmatch(claimedID) + if len(m) != 2 { + redirectToLoginWithErr(w, r, "Steam login returned an unexpected claimed ID format.") + return + } + steamID := m[1] + + ok, err := verifySteamOpenIDAssertion(r.Context(), r.URL.Query()) + if err != nil { + a.log.Warn("steam openid verify failed", "err", err, "steam_id", steamID) + redirectToLoginWithErr(w, r, "Couldn't verify your Steam login with Steam's servers. Try again.") + return + } + if !ok { + a.log.Warn("steam openid verify returned is_valid=false", "steam_id", steamID) + redirectToLoginWithErr(w, r, "Steam reported the login assertion as invalid. Try again.") + return + } + + user, err := a.db.GetUserBySteamID(r.Context(), steamID) + if err != nil { + if errors.Is(err, db.ErrUserNotFound) { + a.log.Info("steam login refused — no user linked", "steam_id", steamID) + redirectToLoginWithErr(w, r, "That Steam account isn't linked to any panel user. Ask an admin to link it.") + return + } + a.log.Error("steam login db lookup", "err", err, "steam_id", steamID) + redirectToLoginWithErr(w, r, "Login database error — see controller logs.") + return + } + if user.Disabled { + a.log.Warn("steam login refused — user disabled", "steam_id", steamID, "user_id", user.ID) + redirectToLoginWithErr(w, r, "This account is disabled.") + return + } + + // Success — mint a session cookie and redirect to the original `next` target. + token, err := newSessionToken() + if err != nil { + a.log.Error("generate session token", "err", err) + redirectToLoginWithErr(w, r, "Session creation failed.") + return + } + expiresAt := time.Now().Add(steamLoginCookieTTL) + if err := a.db.CreateSession(r.Context(), token, user.ID, expiresAt, clientIP(r)); err != nil { + a.log.Error("create session", "err", err) + redirectToLoginWithErr(w, r, "Session save failed.") + return + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + Expires: expiresAt, + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + }) + a.log.Info("steam login success", "user_id", user.ID, "email", user.Email, "steam_id", steamID) + + next := r.URL.Query().Get("next") + if next == "" || !strings.HasPrefix(next, "/") { + next = "/" + } + http.Redirect(w, r, next, http.StatusFound) +} + +// ---- /api/auth/steam/link-start + link-callback ---- +// +// The login flow above maps a verified SteamID → existing user row. +// Here we do the inverse: an already-signed-in operator redirects to +// Steam to PROVE they own a SteamID, then the callback links it to +// the current session user. Lets users add Steam without an admin +// pasting their 17-digit ID by hand. Admin can still paste IDs for +// teammates via the existing /api/users/{id}/steam endpoint. + +// handleSteamLinkStart kicks the OpenID redirect, but with a return_to +// of /link-callback so the verified ID gets stamped onto the current +// session user. requireSession is enforced at the router level. +func (a *auth) handleSteamLinkStart(w http.ResponseWriter, r *http.Request) { + realm := a.publicURL(r) + next := strings.TrimSpace(r.URL.Query().Get("next")) + if next == "" || !strings.HasPrefix(next, "/") { + next = "/?steam_linked=ok" + } + returnTo, _ := url.Parse(realm + "/api/auth/steam/link-callback") + q := returnTo.Query() + q.Set("next", next) + returnTo.RawQuery = q.Encode() + + params := url.Values{} + params.Set("openid.ns", "http://specs.openid.net/auth/2.0") + params.Set("openid.mode", "checkid_setup") + params.Set("openid.return_to", returnTo.String()) + params.Set("openid.realm", realm) + params.Set("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select") + params.Set("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select") + + http.Redirect(w, r, steamOpenIDEndpoint+"?"+params.Encode(), http.StatusFound) +} + +// handleSteamLinkCallback verifies with Steam, then writes the SteamID +// onto the current session user's row. Browser still has the session +// cookie because Steam redirected back to our origin. +func (a *auth) handleSteamLinkCallback(w http.ResponseWriter, r *http.Request) { + // Resolve current session user — required to know whose row to update. + user, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow) + if !ok { + http.Redirect(w, r, "/login?err=link-needs-session", http.StatusFound) + return + } + claimedID := r.URL.Query().Get("openid.claimed_id") + if claimedID == "" { + http.Redirect(w, r, "/?steam_linked=err&msg=no-claim", http.StatusFound) + return + } + m := steamIDRE.FindStringSubmatch(claimedID) + if len(m) != 2 { + http.Redirect(w, r, "/?steam_linked=err&msg=bad-claim", http.StatusFound) + return + } + steamID := m[1] + + ok2, err := verifySteamOpenIDAssertion(r.Context(), r.URL.Query()) + if err != nil { + a.log.Warn("steam link verify failed", "err", err, "steam_id", steamID) + http.Redirect(w, r, "/?steam_linked=err&msg=verify-failed", http.StatusFound) + return + } + if !ok2 { + a.log.Warn("steam link verify returned is_valid=false", "steam_id", steamID) + http.Redirect(w, r, "/?steam_linked=err&msg=invalid", http.StatusFound) + return + } + + // Refuse to silently steal a SteamID that's already linked to a + // different panel user — operator should explicitly unlink there + // first. Same SteamID re-link to the same user is a no-op success. + if other, lerr := a.db.GetUserBySteamID(r.Context(), steamID); lerr == nil && other.ID != user.ID { + a.log.Warn("steam link refused — id already linked to another user", "steam_id", steamID, "other_user_id", other.ID, "current_user_id", user.ID) + http.Redirect(w, r, "/?steam_linked=err&msg=already-linked", http.StatusFound) + return + } + + if err := a.db.LinkSteamIDToUser(r.Context(), user.ID, steamID); err != nil { + a.log.Error("steam link db update", "err", err, "user_id", user.ID, "steam_id", steamID) + http.Redirect(w, r, "/?steam_linked=err&msg=db", http.StatusFound) + return + } + a.log.Info("steam linked", "user_id", user.ID, "email", user.Email, "steam_id", steamID) + + next := r.URL.Query().Get("next") + if next == "" || !strings.HasPrefix(next, "/") { + next = "/?steam_linked=ok" + } + http.Redirect(w, r, next, http.StatusFound) +} + +// verifySteamOpenIDAssertion POSTs every received openid.* parameter +// back to Steam with `openid.mode=check_authentication`, then parses the +// key-value response. Returns (is_valid, err). +func verifySteamOpenIDAssertion(ctx context.Context, in url.Values) (bool, error) { + out := url.Values{} + for k, vs := range in { + if !strings.HasPrefix(k, "openid.") { + continue + } + for _, v := range vs { + out.Add(k, v) + } + } + out.Set("openid.mode", "check_authentication") + + cctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(cctx, "POST", steamOpenIDEndpoint, + strings.NewReader(out.Encode())) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("post to steam: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return false, fmt.Errorf("read steam reply: %w", err) + } + // Response is a key-value text document like: + // ns:http://specs.openid.net/auth/2.0 + // is_valid:true + kv := parseKVResponse(string(body)) + return kv["is_valid"] == "true", nil +} + +func parseKVResponse(s string) map[string]string { + out := map[string]string{} + for _, line := range strings.Split(s, "\n") { + i := strings.IndexByte(line, ':') + if i <= 0 { + continue + } + out[line[:i]] = strings.TrimSpace(line[i+1:]) + } + return out +} + +// publicURL is the base origin the OpenID realm + return_to URLs are built +// against. Falls back to the request's scheme+host if the operator didn't +// set --public-url on the CLI. +func (a *auth) publicURL(r *http.Request) string { + if a.publicURLOverride != "" { + return strings.TrimRight(a.publicURLOverride, "/") + } + scheme := "http" + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { + scheme = "https" + } + return scheme + "://" + r.Host +} + +func redirectToLoginWithErr(w http.ResponseWriter, r *http.Request, msg string) { + u := url.URL{Path: "/login"} + q := u.Query() + q.Set("steam_err", msg) + if next := r.URL.Query().Get("next"); next != "" { + q.Set("next", next) + } + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} diff --git a/controller/cmd/controller/steamusers.go b/controller/cmd/controller/steamusers.go new file mode 100644 index 00000000..a72dd531 --- /dev/null +++ b/controller/cmd/controller/steamusers.go @@ -0,0 +1,345 @@ +package main + +// Steam-user management + Steam profile proxy. +// +// Two surfaces: +// +// 1. Panel-user-side Steam linkage: every user row has a steam_id +// column (set by the OpenID sign-in flow). This file exposes +// CRUD-ish endpoints for managing those links from the panel: +// list users, link a SteamID by hand (admin pasting a SteamID64 +// for a teammate who doesn't sign in via Steam), unlink. +// +// 2. Steam profile proxy: GET /api/steam/profile?steamid=X — server +// fetches the public XML profile from steamcommunity.com and +// returns just the avatar URL + display name. Browser-side fetch +// can't hit steamcommunity directly because Steam doesn't send +// CORS headers. The avatarMedium URL itself IS CORS-friendly +// (Steam CDN), so the browser can it after this +// proxy gives the URL. + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/dbledeez/panel/controller/internal/db" + "github.com/dbledeez/panel/pkg/version" +) + +// steamID64RE lives in auth.go (shared with the OpenID parsing path). + +type userDTO struct { + ID int64 `json:"id"` + Email string `json:"email"` + Role string `json:"role"` + SteamID string `json:"steam_id,omitempty"` + Disabled bool `json:"disabled,omitempty"` + CreatedAt string `json:"created_at"` +} + +// handleCreateSteamUser — POST /api/users. Admin creates a new panel +// user from a SteamID64 paste. The resulting user has no password +// (Steam OpenID-only sign-in). Email is derived from the Steam display +// name when not supplied — admins typically don't have / want to type +// emails for everyone they're inviting. +// +// Refuses if the SteamID is already linked to another panel user +// (one Steam account → one user, enforced by the unique constraint). +func (h *httpServer) handleCreateSteamUser(w http.ResponseWriter, r *http.Request) { + me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow) + if !ok || me.Role != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin only") + return + } + var req struct { + SteamID string `json:"steam_id"` + Email string `json:"email,omitempty"` + Role string `json:"role,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", err.Error()) + return + } + if !steamID64RE.MatchString(req.SteamID) { + writeError(w, http.StatusBadRequest, "bad_steam_id", "must be a 17-digit SteamID64") + return + } + role := req.Role + if role == "" { + role = "user" + } + if role != "user" && role != "admin" { + writeError(w, http.StatusBadRequest, "bad_role", "role must be 'user' or 'admin'") + return + } + // Reject if Steam already linked. + if existing, err := h.db.GetUserBySteamID(r.Context(), req.SteamID); err == nil && existing != nil { + writeError(w, http.StatusConflict, "already_linked", + fmt.Sprintf("Steam ID is already linked to %s", existing.Email)) + return + } + // Derive email from Steam display name if not supplied. Steam + // nicknames can contain anything; we sanitize to a sensible local- + // part. Falls back to steamid@steam.local on lookup failure. + email := strings.TrimSpace(req.Email) + if email == "" { + email = h.deriveSteamEmail(r.Context(), req.SteamID) + } + id, err := h.db.CreateSteamUser(r.Context(), email, req.SteamID, role) + if err != nil { + // Likely a unique-constraint hit on email/steam_id; surface the + // raw DB error since the panel UI shows it verbatim. + writeError(w, http.StatusConflict, "create_failed", err.Error()) + return + } + h.log.Info("steam user created", "by_admin_id", me.ID, "new_user_id", id, "email", email, "steam_id", req.SteamID, "role", role) + writeJSON(w, http.StatusCreated, map[string]any{ + "id": id, + "email": email, + "steam_id": req.SteamID, + "role": role, + }) +} + +// deriveSteamEmail builds a placeholder email from a Steam profile. +// Tries the display name first ("Pete (76561198...)" → "pete@steam.local"), +// falls back to "steam-@steam.local". Email column has a unique +// constraint, so we suffix collisions with the SteamID's last 6 chars +// — admins can edit later if they care. +func (h *httpServer) deriveSteamEmail(ctx context.Context, steamID string) string { + suffix := steamID + if len(steamID) > 6 { + suffix = steamID[len(steamID)-6:] + } + body, err := steamProfileCacheStore.fetch(ctx, steamID) + if err == nil { + var prof steamProfileXML + if xerr := xml.Unmarshal(body, &prof); xerr == nil && prof.SteamID != "" { + // Slug the display name: lower, alphanumeric + dashes, max 20. + s := strings.ToLower(prof.SteamID) + var b strings.Builder + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + b.WriteRune(c) + } else if c == ' ' || c == '-' || c == '_' { + b.WriteByte('-') + } + } + slug := strings.Trim(b.String(), "-") + if len(slug) > 20 { + slug = slug[:20] + } + if slug != "" { + return slug + "-" + suffix + "@steam.local" + } + } + } + return "steam-" + suffix + "@steam.local" +} + +// handleListUsers — admin-only. Returns every user row with their +// linked SteamID (if any). The Steam Accounts modal renders this. +func (h *httpServer) handleListUsers(w http.ResponseWriter, r *http.Request) { + me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow) + if !ok || me.Role != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin only") + return + } + rows, err := h.db.ListUsers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "db", err.Error()) + return + } + out := make([]userDTO, 0, len(rows)) + for _, u := range rows { + out = append(out, userDTO{ + ID: u.ID, + Email: u.Email, + Role: u.Role, + SteamID: u.SteamID, + Disabled: u.Disabled, + CreatedAt: u.CreatedAt.Format(time.RFC3339), + }) + } + writeJSON(w, http.StatusOK, map[string]any{"users": out}) +} + +// handleLinkSteamID — POST /api/users/{id}/steam {steam_id: "..."}. +// Admin can link any user's Steam; non-admin can only link their own. +func (h *httpServer) handleLinkSteamID(w http.ResponseWriter, r *http.Request) { + me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthenticated", "log in") + return + } + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_id", err.Error()) + return + } + if me.Role != "admin" && me.ID != id { + writeError(w, http.StatusForbidden, "forbidden", "you can only link your own Steam account") + return + } + var req struct { + SteamID string `json:"steam_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "bad_json", err.Error()) + return + } + if !steamID64RE.MatchString(req.SteamID) { + writeError(w, http.StatusBadRequest, "bad_steam_id", "must be a 17-digit SteamID64 starting with 7656119") + return + } + if err := h.db.LinkSteamIDToUser(r.Context(), id, req.SteamID); err != nil { + writeError(w, http.StatusInternalServerError, "link", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"id": id, "steam_id": req.SteamID}) +} + +// handleUnlinkSteamID — DELETE /api/users/{id}/steam. +// Admin or self. +func (h *httpServer) handleUnlinkSteamID(w http.ResponseWriter, r *http.Request) { + me, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthenticated", "log in") + return + } + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_id", err.Error()) + return + } + if me.Role != "admin" && me.ID != id { + writeError(w, http.StatusForbidden, "forbidden", "you can only unlink your own Steam account") + return + } + if err := h.db.UnlinkSteamID(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "unlink", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unlinked"}) +} + +// ---- Steam public profile proxy ---- + +// steamProfileXML is the subset of fields we surface to the panel UI. +// The full XML has dozens; only avatar URLs + display name are useful. +type steamProfileXML struct { + XMLName xml.Name `xml:"profile"` + SteamID64 string `xml:"steamID64"` + SteamID string `xml:"steamID"` // display name (can have HTML entities) + OnlineState string `xml:"onlineState"` // online | offline | in-game + StateMessage string `xml:"stateMessage"` + AvatarIcon string `xml:"avatarIcon"` + AvatarMedium string `xml:"avatarMedium"` + AvatarFull string `xml:"avatarFull"` + CustomURL string `xml:"customURL"` + VacBanned int `xml:"vacBanned"` + TradeBanState string `xml:"tradeBanState"` +} + +// Cache profile fetches for a few minutes — Steam rate-limits hard if +// the panel hammers their server (ratelimit kicks in around 200/min +// per IP). Operators reloading the modal repeatedly shouldn't lock +// the panel out of Steam queries. +type steamProfileCache struct { + mu sync.Mutex + byID map[string]steamProfileCacheEntry + maxAge time.Duration +} +type steamProfileCacheEntry struct { + body []byte + at time.Time +} + +var steamProfileCacheStore = &steamProfileCache{ + byID: map[string]steamProfileCacheEntry{}, + maxAge: 5 * time.Minute, +} + +// handleSteamProfileFetch — GET /api/steam/profile?steamid=X. Pulls the +// public XML profile and returns a slim JSON the UI can render: avatar +// URL, display name, online state. +func (h *httpServer) handleSteamProfileFetch(w http.ResponseWriter, r *http.Request) { + if _, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow); !ok { + writeError(w, http.StatusUnauthorized, "unauthenticated", "log in") + return + } + steamID := r.URL.Query().Get("steamid") + if !steamID64RE.MatchString(steamID) { + writeError(w, http.StatusBadRequest, "bad_steam_id", "expected 17-digit SteamID64") + return + } + body, err := steamProfileCacheStore.fetch(r.Context(), steamID) + if err != nil { + writeError(w, http.StatusBadGateway, "fetch", err.Error()) + return + } + var prof steamProfileXML + if err := xml.Unmarshal(body, &prof); err != nil { + writeError(w, http.StatusBadGateway, "parse", err.Error()) + return + } + if prof.SteamID64 == "" { + // Steam's XML returns a different root element when the profile + // is private/missing — the unmarshal succeeds but key fields are + // empty. Surface a clear 404 instead of a broken-looking 200. + writeError(w, http.StatusNotFound, "profile_not_found", "Steam returned no public profile for that ID (private or invalid)") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "steam_id_64": prof.SteamID64, + "display_name": prof.SteamID, + "avatar_icon": prof.AvatarIcon, + "avatar_medium": prof.AvatarMedium, + "avatar_full": prof.AvatarFull, + "online_state": prof.OnlineState, + "state_message": prof.StateMessage, + "custom_url": prof.CustomURL, + }) +} + +func (c *steamProfileCache) fetch(ctx context.Context, steamID string) ([]byte, error) { + c.mu.Lock() + if e, ok := c.byID[steamID]; ok && time.Since(e.at) < c.maxAge { + c.mu.Unlock() + return e.body, nil + } + c.mu.Unlock() + url := fmt.Sprintf("https://steamcommunity.com/profiles/%s/?xml=1", steamID) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("User-Agent", version.UserAgent()) + httpClient := &http.Client{Timeout: 8 * time.Second} + resp, err := httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("steamcommunity.com returned %s", resp.Status) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) + if err != nil { + return nil, err + } + c.mu.Lock() + c.byID[steamID] = steamProfileCacheEntry{body: body, at: time.Now()} + c.mu.Unlock() + return body, nil +} diff --git a/controller/cmd/controller/update_check.go b/controller/cmd/controller/update_check.go new file mode 100644 index 00000000..b9e336af --- /dev/null +++ b/controller/cmd/controller/update_check.go @@ -0,0 +1,140 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/dbledeez/panel/controller/internal/db" + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +// WI-14 "update available" detection. CHECK-ONLY: nothing here ever +// triggers an update or restart — it compares the installed Steam +// buildid (from the instance's appmanifest ACF) against the latest +// buildid for its branch (steamcmd app_info, cached 15 min agent-side) +// and stores the answer. + +// updateCheckTimeout bounds the agent round-trip. A cold check spins a +// steamcmd sidecar (image pull possible on first-ever run), so this is +// generous; cached checks answer in milliseconds. +const updateCheckTimeout = 5 * time.Minute + +func updateCheckJSON(r *db.UpdateCheckRow) map[string]any { + return map[string]any{ + "instance_id": r.InstanceID, + "app_id": r.AppID, + "branch": r.Branch, + "installed_buildid": r.InstalledBuildID, + "latest_buildid": r.LatestBuildID, + "update_available": r.UpdateAvailable, + "error": r.Error, + "checked_at": r.CheckedAt, + } +} + +// getUpdateCheck returns the last stored check for an instance without +// contacting the agent. 200 {"checked": false} when never checked. +func (h *httpServer) getUpdateCheck(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + row, err := h.db.GetUpdateCheck(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db", err.Error()) + return + } + if row == nil { + writeJSON(w, http.StatusOK, map[string]any{"checked": false}) + return + } + out := updateCheckJSON(row) + out["checked"] = true + writeJSON(w, http.StatusOK, out) +} + +// listUpdateChecks returns every stored check — the dashboard uses it to +// paint "Update available" badges on server cards in one request. +func (h *httpServer) listUpdateChecks(w http.ResponseWriter, r *http.Request) { + rows, err := h.db.ListUpdateChecks(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "db", err.Error()) + return + } + out := make([]map[string]any, 0, len(rows)) + for i := range rows { + out = append(out, updateCheckJSON(&rows[i])) + } + writeJSON(w, http.StatusOK, map[string]any{"checks": out}) +} + +// postUpdateCheck runs a live check via the instance's agent, stores the +// result, and returns it. +func (h *httpServer) postUpdateCheck(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + _, conn, err := h.resolveInstanceConn(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "resolve", err.Error()) + return + } + // Provider selection mirrors updateInstance: for 7DTD the pinned + // branch lives in config_values.provider_id; empty means "the + // module's default provider" agent-side. `?provider=` overrides. + providerID := r.URL.Query().Get("provider") + if providerID == "" { + if row, rerr := h.db.GetInstanceRow(r.Context(), id); rerr == nil && row.ConfigValues != nil { + providerID = row.ConfigValues["provider_id"] + } + } + refresh := r.URL.Query().Get("refresh") == "1" + + res, err := h.sendUpdateCheck(r.Context(), conn, id, providerID, refresh) + if err != nil { + writeError(w, http.StatusBadGateway, "check_failed", err.Error()) + return + } + row := db.UpdateCheckRow{ + InstanceID: id, + AppID: res.AppId, + Branch: res.Branch, + InstalledBuildID: res.InstalledBuildid, + LatestBuildID: res.LatestBuildid, + UpdateAvailable: res.UpdateAvailable, + Error: res.Error, + CheckedAt: time.Now(), + } + if err := h.db.UpsertUpdateCheck(r.Context(), row); err != nil { + h.log.Warn("update check: persist failed", "instance_id", id, "err", err) + } + out := updateCheckJSON(&row) + out["checked"] = true + out["cached"] = res.Cached + writeJSON(w, http.StatusOK, out) +} + +// sendUpdateCheck forwards an UpdateCheckRequest and awaits the +// correlation-routed reply. +func (h *httpServer) sendUpdateCheck(ctx context.Context, conn *agentConn, instanceID, providerID string, refresh bool) (*panelv1.UpdateCheckResult, error) { + corrID := newCorrelationID("update-check") + env := &panelv1.ControllerEnvelope{ + CorrelationId: corrID, SentAt: timestamppb.Now(), + Payload: &panelv1.ControllerEnvelope_UpdateCheck{UpdateCheck: &panelv1.UpdateCheckRequest{ + InstanceId: instanceID, + ProviderId: providerID, + Refresh: refresh, + }}, + } + if err := conn.Send(env); err != nil { + return nil, err + } + reply, err := h.pending.Await(ctx, corrID, updateCheckTimeout) + if err != nil { + return nil, err + } + res := reply.GetUpdateCheckResult() + if res == nil { + return nil, fmt.Errorf("agent reply missing update_check_result") + } + return res, nil +} diff --git a/controller/cmd/controller/update_check_test.go b/controller/cmd/controller/update_check_test.go new file mode 100644 index 00000000..05106831 --- /dev/null +++ b/controller/cmd/controller/update_check_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "log/slog" + "sync" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/protobuf/types/known/timestamppb" + + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +// fakeAgentStream captures envelopes the controller sends to an agent. +// Only Send is exercised by these tests; the embedded nil ServerStream +// panics loudly if anything else is called. +type fakeAgentStream struct { + grpc.ServerStream + mu sync.Mutex + sent []*panelv1.ControllerEnvelope +} + +func (f *fakeAgentStream) Send(env *panelv1.ControllerEnvelope) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sent = append(f.sent, env) + return nil +} + +func (f *fakeAgentStream) Recv() (*panelv1.AgentEnvelope, error) { + select {} // never called in these tests +} + +func (f *fakeAgentStream) lastSent() *panelv1.ControllerEnvelope { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.sent) == 0 { + return nil + } + return f.sent[len(f.sent)-1] +} + +// TestSendUpdateCheckRoundTrip drives sendUpdateCheck against a fake +// agent: the request envelope goes out, a fake UpdateCheckResult comes +// back through the pending registry (as handleAgentMessage would deliver +// it), and the parsed result surfaces to the caller. +func TestSendUpdateCheckRoundTrip(t *testing.T) { + stream := &fakeAgentStream{} + conn := &agentConn{id: "agent-1", stream: stream} + h := &httpServer{pending: newPendingRegistry(), log: slog.Default()} + + type outcome struct { + res *panelv1.UpdateCheckResult + err error + } + done := make(chan outcome, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + res, err := h.sendUpdateCheck(ctx, conn, "inst-7dtd", "v2_6", false) + done <- outcome{res, err} + }() + + // Wait for the outbound envelope, then answer it like the agent would. + var env *panelv1.ControllerEnvelope + deadline := time.Now().Add(2 * time.Second) + for env == nil && time.Now().Before(deadline) { + env = stream.lastSent() + if env == nil { + time.Sleep(5 * time.Millisecond) + } + } + if env == nil { + t.Fatal("no envelope sent to agent") + } + req := env.GetUpdateCheck() + if req == nil { + t.Fatalf("sent envelope is not an UpdateCheck: %+v", env) + } + if req.InstanceId != "inst-7dtd" || req.ProviderId != "v2_6" { + t.Fatalf("bad request: %+v", req) + } + + reply := &panelv1.AgentEnvelope{ + CorrelationId: env.CorrelationId, + SentAt: timestamppb.Now(), + Payload: &panelv1.AgentEnvelope_UpdateCheckResult{UpdateCheckResult: &panelv1.UpdateCheckResult{ + InstanceId: "inst-7dtd", + AppId: "294420", + Branch: "v2.6", + InstalledBuildid: "14503775", + LatestBuildid: "14600000", + UpdateAvailable: true, + }}, + } + // Route through handleAgentMessage — this is the regression guard for + // the "wired into the correlation switch" golden rule: if the case is + // missing, delivery never happens and sendUpdateCheck times out. + srv := &agentServer{log: slog.Default(), pending: h.pending} + srv.handleAgentMessage(context.Background(), &agentConn{id: "agent-1"}, reply) + + o := <-done + if o.err != nil { + t.Fatalf("sendUpdateCheck: %v", o.err) + } + if !o.res.UpdateAvailable || o.res.InstalledBuildid != "14503775" || o.res.LatestBuildid != "14600000" { + t.Fatalf("bad result: %+v", o.res) + } +} + +// TestSendUpdateCheckWrongPayload ensures a mis-typed reply surfaces as +// an error instead of a nil-deref. +func TestSendUpdateCheckWrongPayload(t *testing.T) { + stream := &fakeAgentStream{} + conn := &agentConn{id: "agent-1", stream: stream} + h := &httpServer{pending: newPendingRegistry(), log: slog.Default()} + + errCh := make(chan error, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err := h.sendUpdateCheck(ctx, conn, "inst-x", "", true) + errCh <- err + }() + var env *panelv1.ControllerEnvelope + deadline := time.Now().Add(2 * time.Second) + for env == nil && time.Now().Before(deadline) { + env = stream.lastSent() + if env == nil { + time.Sleep(5 * time.Millisecond) + } + } + if env == nil { + t.Fatal("no envelope sent to agent") + } + if got := env.GetUpdateCheck(); got == nil || !got.Refresh { + t.Fatalf("expected refresh=true UpdateCheck, got %+v", env) + } + h.pending.Deliver(env.CorrelationId, &panelv1.AgentEnvelope{ + CorrelationId: env.CorrelationId, + Payload: &panelv1.AgentEnvelope_Pong{Pong: &panelv1.Pong{}}, + }) + if err := <-errCh; err == nil { + t.Fatal("expected error for wrong payload type") + } +} diff --git a/controller/cmd/controller/valheimmods.go b/controller/cmd/controller/valheimmods.go new file mode 100644 index 00000000..7c999ce2 --- /dev/null +++ b/controller/cmd/controller/valheimmods.go @@ -0,0 +1,747 @@ +package main + +// Valheim mod browser — Thunderstore backend. +// +// Valheim mods load through BepInEx (a Unity mod framework injected at launch +// by Doorstop). The mod ecosystem lives on Thunderstore. This mirrors the +// existing ARK (CurseForge) / Conan (Workshop) / 7DTD (Nexus) browsers: +// +// GET /api/valheim/mods/search?q=...&serveronly=1 → Thunderstore search +// GET /api/valheim/mods/detail?full_name=... → one package's detail +// GET /api/instances/{id}/valheim/mods → installed mods +// POST /api/instances/{id}/valheim/mods → install {full_name} +// DELETE /api/instances/{id}/valheim/mods/{slug} → uninstall +// +// Install pipeline (controller does ALL the work, like the 7DTD/Nexus path): +// 1. Resolve the package's latest version + download_url from Thunderstore. +// 2. Ensure BepInEx is installed first (the loader every mod needs). +// 3. Download the mod zip → extract → fsWriteToAgent each file into the +// instance's /game/BepInEx/plugins// tree. +// 4. Flip VALHEIM_MODS_ENABLED=1 in config_values so the entrypoint injects +// Doorstop on next (re)start. +// +// The full Thunderstore Valheim package list (~156MB, ~9800 packages) is +// fetched once and cached in-process; search filters that cache. Refreshed +// lazily after `thunderstoreCacheTTL`. + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/dbledeez/panel/controller/internal/db" + panelv1 "github.com/dbledeez/panel/proto/panel/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// --- Thunderstore types (subset of the v1 package API) -------------------- + +type tsPackageVersion struct { + Name string `json:"name"` + FullName string `json:"full_name"` + VersionNumber string `json:"version_number"` + DownloadURL string `json:"download_url"` + Icon string `json:"icon"` + Description string `json:"description"` + Dependencies []string `json:"dependencies"` + Downloads int64 `json:"downloads"` +} + +type tsPackage struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Owner string `json:"owner"` + PackageURL string `json:"package_url"` + IsPinned bool `json:"is_pinned"` + IsDeprecated bool `json:"is_deprecated"` + RatingScore int `json:"rating_score"` + Categories []string `json:"categories"` + Versions []tsPackageVersion `json:"versions"` +} + +func (p tsPackage) latest() (tsPackageVersion, bool) { + if len(p.Versions) == 0 { + return tsPackageVersion{}, false + } + return p.Versions[0], true // Thunderstore lists newest first +} + +func (p tsPackage) totalDownloads() int64 { + var n int64 + for _, v := range p.Versions { + n += v.Downloads + } + return n +} + +func (p tsPackage) serverSide() bool { + for _, c := range p.Categories { + if strings.EqualFold(c, "Server-side") { + return true + } + } + return false +} + +func (p tsPackage) clientSide() bool { + for _, c := range p.Categories { + if strings.EqualFold(c, "Client-side") { + return true + } + } + return false +} + +// --- Thunderstore package cache ------------------------------------------- + +const ( + thunderstoreListURL = "https://thunderstore.io/c/valheim/api/v1/package/" + thunderstoreCacheTTL = 30 * time.Minute + // The canonical BepInEx loader pack on Thunderstore. Most mods depend on + // this; we install it first whenever a mod is added. + bepinexFullName = "denikson-BepInExPack_Valheim" +) + +type thunderstoreCache struct { + mu sync.Mutex + packages []tsPackage + byName map[string]*tsPackage + fetched time.Time +} + +var tsCache = &thunderstoreCache{byName: map[string]*tsPackage{}} + +// thunderstoreFetch does a GET with a browser UA + redirect following — the +// Valheim community endpoint 302s through Cloudflare and rejects non-browser +// UAs otherwise. +func thunderstoreFetch(ctx context.Context, u string, timeout time.Duration) ([]byte, error) { + cctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "Mozilla/5.0 (panel valheim mod browser)") + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("thunderstore %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, 256<<20)) // 256MB cap +} + +// ensureCatalog returns the cached package list, refreshing if stale/empty. +func (c *thunderstoreCache) ensure(ctx context.Context) ([]tsPackage, error) { + c.mu.Lock() + fresh := time.Since(c.fetched) < thunderstoreCacheTTL && len(c.packages) > 0 + if fresh { + pkgs := c.packages + c.mu.Unlock() + return pkgs, nil + } + c.mu.Unlock() + + body, err := thunderstoreFetch(ctx, thunderstoreListURL, 60*time.Second) + if err != nil { + // On refresh failure, serve a stale cache if we have one. + c.mu.Lock() + defer c.mu.Unlock() + if len(c.packages) > 0 { + return c.packages, nil + } + return nil, err + } + var pkgs []tsPackage + if err := json.Unmarshal(body, &pkgs); err != nil { + return nil, fmt.Errorf("decode thunderstore list: %w", err) + } + byName := make(map[string]*tsPackage, len(pkgs)) + for i := range pkgs { + byName[pkgs[i].FullName] = &pkgs[i] + } + c.mu.Lock() + c.packages, c.byName, c.fetched = pkgs, byName, time.Now() + c.mu.Unlock() + return pkgs, nil +} + +func (c *thunderstoreCache) lookup(ctx context.Context, fullName string) (*tsPackage, error) { + if _, err := c.ensure(ctx); err != nil { + return nil, err + } + c.mu.Lock() + defer c.mu.Unlock() + if p, ok := c.byName[fullName]; ok { + return p, nil + } + return nil, fmt.Errorf("package %q not found", fullName) +} + +// --- search ---------------------------------------------------------------- + +type tsSearchResult struct { + FullName string `json:"full_name"` + Name string `json:"name"` + Owner string `json:"owner"` + Version string `json:"version"` + Description string `json:"description"` + Icon string `json:"icon"` + Downloads int64 `json:"downloads"` + Rating int `json:"rating"` + Pinned bool `json:"pinned"` + Deprecated bool `json:"deprecated"` + ServerSide bool `json:"server_side"` + ClientSide bool `json:"client_side"` + PackageURL string `json:"package_url"` +} + +func (h *httpServer) handleValheimModSearch(w http.ResponseWriter, r *http.Request) { + pkgs, err := tsCache.ensure(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "thunderstore", err.Error()) + return + } + q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) + serverOnly := r.URL.Query().Get("serveronly") == "1" + + type scored struct { + res tsSearchResult + score int64 + } + var out []scored + for i := range pkgs { + p := &pkgs[i] + if p.IsDeprecated { + continue + } + if serverOnly && !p.serverSide() { + continue + } + if q != "" { + hay := strings.ToLower(p.Name + " " + p.Owner + " " + strings.Join(p.Categories, " ")) + if !strings.Contains(hay, q) { + continue + } + } + v, ok := p.latest() + if !ok { + continue + } + out = append(out, scored{ + res: tsSearchResult{ + FullName: p.FullName, + Name: p.Name, + Owner: p.Owner, + Version: v.VersionNumber, + Description: v.Description, + Icon: v.Icon, + Downloads: p.totalDownloads(), + Rating: p.RatingScore, + Pinned: p.IsPinned, + Deprecated: p.IsDeprecated, + ServerSide: p.serverSide(), + ClientSide: p.clientSide(), + PackageURL: p.PackageURL, + }, + score: p.totalDownloads(), + }) + } + // Rank: pinned first, then by total downloads. + sort.Slice(out, func(i, j int) bool { + if out[i].res.Pinned != out[j].res.Pinned { + return out[i].res.Pinned + } + return out[i].score > out[j].score + }) + limit := 60 + results := make([]tsSearchResult, 0, limit) + for i := 0; i < len(out) && i < limit; i++ { + results = append(results, out[i].res) + } + writeJSON(w, http.StatusOK, map[string]any{"results": results, "total": len(out)}) +} + +func (h *httpServer) handleValheimModDetail(w http.ResponseWriter, r *http.Request) { + full := r.URL.Query().Get("full_name") + if full == "" { + writeError(w, http.StatusBadRequest, "missing", "full_name required") + return + } + p, err := tsCache.lookup(r.Context(), full) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + v, _ := p.latest() + writeJSON(w, http.StatusOK, map[string]any{ + "full_name": p.FullName, + "name": p.Name, + "owner": p.Owner, + "version": v.VersionNumber, + "description": v.Description, + "icon": v.Icon, + "downloads": p.totalDownloads(), + "rating": p.RatingScore, + "categories": p.Categories, + "dependencies": v.Dependencies, + "server_side": p.serverSide(), + "client_side": p.clientSide(), + "package_url": p.PackageURL, + "download_url": v.DownloadURL, + }) +} + +// --- installed-mods state (stored in config_values) ------------------------ +// +// We track installed mods in the instance config_values key VALHEIM_MODS as a +// JSON array of {full_name, version, dir}. This persists across recreates and +// lets the UI list/uninstall without re-reading the volume. + +const valheimModsKey = "VALHEIM_MODS" + +type installedMod struct { + FullName string `json:"full_name"` + Version string `json:"version"` + Dir string `json:"dir"` // relative under BepInEx/plugins +} + +func readInstalledMods(cv map[string]string) []installedMod { + var mods []installedMod + if s := cv[valheimModsKey]; s != "" { + _ = json.Unmarshal([]byte(s), &mods) + } + return mods +} + +func (h *httpServer) handleValheimModsList(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + row, err := h.valheimRow(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"mods": readInstalledMods(row.ConfigValues)}) +} + +func (h *httpServer) handleValheimModInstall(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + agentID, err := h.lookupInstance(r, id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + var req struct { + FullName string `json:"full_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.FullName == "" { + writeError(w, http.StatusBadRequest, "bad_json", "full_name required") + return + } + pkg, err := tsCache.lookup(r.Context(), req.FullName) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + + // 1. BepInEx loader first (idempotent; skips if the package IS BepInEx). + if req.FullName != bepinexFullName { + if err := h.ensureBepInEx(ctx, agentID, id); err != nil { + writeError(w, http.StatusBadGateway, "bepinex", "install BepInEx: "+err.Error()) + return + } + } + + // 2. Install the requested package. + dir, ver, err := h.installThunderstorePackage(ctx, agentID, id, pkg) + if err != nil { + writeError(w, http.StatusBadGateway, "install", err.Error()) + return + } + + // 3. Record in config_values + enable the mod loader. + row, err := h.valheimRow(ctx, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "db", err.Error()) + return + } + mods := readInstalledMods(row.ConfigValues) + replaced := false + for i := range mods { + if mods[i].FullName == req.FullName { + mods[i] = installedMod{FullName: req.FullName, Version: ver, Dir: dir} + replaced = true + break + } + } + if !replaced { + mods = append(mods, installedMod{FullName: req.FullName, Version: ver, Dir: dir}) + } + modsJSON, _ := json.Marshal(mods) + if err := h.mergeValheimConfig(ctx, id, map[string]string{ + valheimModsKey: string(modsJSON), + "VALHEIM_MODS_ENABLED": "1", + }); err != nil { + writeError(w, http.StatusInternalServerError, "save", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "installed": req.FullName, "version": ver, "dir": dir, + "note": "restart the server to load the mod", + }) +} + +func (h *httpServer) handleValheimModUninstall(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + slug := r.PathValue("slug") // full_name with '/' encoded; we accept full_name + agentID, err := h.lookupInstance(r, id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + row, err := h.valheimRow(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + mods := readInstalledMods(row.ConfigValues) + var target *installedMod + kept := mods[:0] + for i := range mods { + if mods[i].FullName == slug { + m := mods[i] + target = &m + continue + } + kept = append(kept, mods[i]) + } + if target == nil { + writeError(w, http.StatusNotFound, "not_found", "mod not installed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + // Remove the mod's plugin directory. + if target.Dir != "" { + _ = h.fsDeleteOnAgent(ctx, agentID, id, "/game/BepInEx/plugins/"+target.Dir) + } + + updates := map[string]string{} + modsJSON, _ := json.Marshal(kept) + updates[valheimModsKey] = string(modsJSON) + // If no mods remain (other than possibly BepInEx itself), turn the loader + // off so the server runs vanilla again. + if len(kept) == 0 { + updates["VALHEIM_MODS_ENABLED"] = "0" + updates["VALHEIM_PLUS_ENABLED"] = "0" + } + if err := h.mergeValheimConfig(ctx, id, updates); err != nil { + writeError(w, http.StatusInternalServerError, "save", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"uninstalled": slug, "note": "restart to apply"}) +} + +// --- install plumbing ------------------------------------------------------ + +// ensureBepInEx installs the BepInEx loader pack if its preloader isn't present. +// Cheap to call before every mod install; it short-circuits when already there. +func (h *httpServer) ensureBepInEx(ctx context.Context, agentID, instanceID string) error { + // Heuristic: if the row already records a mod OR we can't tell, just (re)write + // BepInEx — extraction is idempotent (OverwriteExistingFiles). To avoid a + // re-download every time, we check the recorded mods list for BepInEx. + row, err := h.valheimRow(ctx, instanceID) + if err == nil { + for _, m := range readInstalledMods(row.ConfigValues) { + if m.FullName == bepinexFullName { + return nil // already installed + } + } + } + pkg, err := tsCache.lookup(ctx, bepinexFullName) + if err != nil { + return err + } + if _, _, err := h.installThunderstorePackage(ctx, agentID, instanceID, pkg); err != nil { + return err + } + // Record BepInEx so we don't re-download on the next mod. + if row != nil { + mods := readInstalledMods(row.ConfigValues) + mods = append(mods, installedMod{FullName: bepinexFullName, Version: pkgVersion(pkg), Dir: ""}) + modsJSON, _ := json.Marshal(mods) + _ = h.mergeValheimConfig(ctx, instanceID, map[string]string{valheimModsKey: string(modsJSON)}) + } + return nil +} + +func pkgVersion(p *tsPackage) string { + if v, ok := p.latest(); ok { + return v.VersionNumber + } + return "" +} + +// installThunderstorePackage downloads a package's zip and writes its files into +// the instance. BepInEx pack zips contain a top-level "BepInExPack_Valheim/" +// whose CONTENTS go into /game (BepInEx/, doorstop_libs/, winhttp.dll, +// start_server_bepinex.sh, etc.). Plugin mods extract into +// /game/BepInEx/plugins//. Returns the plugin dir (relative to +// plugins/) and the version. +func (h *httpServer) installThunderstorePackage(ctx context.Context, agentID, instanceID string, pkg *tsPackage) (string, string, error) { + v, ok := pkg.latest() + if !ok { + return "", "", fmt.Errorf("no versions for %s", pkg.FullName) + } + raw, err := thunderstoreFetch(ctx, v.DownloadURL, 90*time.Second) + if err != nil { + return "", "", fmt.Errorf("download %s: %w", pkg.FullName, err) + } + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + return "", "", fmt.Errorf("open zip: %w", err) + } + + isBepInEx := pkg.FullName == bepinexFullName + // For BepInEx the pack nests everything under BepInExPack_Valheim/; strip it + // and write the contents to /game. For plugin mods, place files under + // BepInEx/plugins/

    / — but if the zip already contains a BepInEx/plugins + // layout, honor it (write relative to /game). + pluginDir := sanitizeModDir(pkg.FullName) + var writeErr error + count := 0 + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + name := f.Name + // Thunderstore meta files we don't deploy. + base := name[strings.LastIndex(name, "/")+1:] + if base == "icon.png" || base == "manifest.json" || base == "README.md" || base == "CHANGELOG.md" { + continue + } + // All dests are ABSOLUTE under /game (safeJoinAny on the agent matches + // the /game browseable root; a relative path resolves under /game-saves). + var rel string + switch { + case isBepInEx: + // strip the leading top folder (e.g. "BepInExPack_Valheim/") + rel = stripTopFolder(name) + if rel == "" { + continue + } + case strings.HasPrefix(name, "BepInEx/"): + // zip already targets the BepInEx tree → write as-is under /game + rel = name + default: + // loose plugin files → BepInEx/plugins// + rel = "BepInEx/plugins/" + pluginDir + "/" + name + } + dest := "/game/" + strings.TrimPrefix(rel, "/") + rc, err := f.Open() + if err != nil { + writeErr = err + break + } + content, err := io.ReadAll(io.LimitReader(rc, 256<<20)) + rc.Close() + if err != nil { + writeErr = err + break + } + if err := h.fsWriteToAgent(ctx, agentID, instanceID, dest, content); err != nil { + writeErr = fmt.Errorf("write %s: %w", dest, err) + break + } + count++ + } + if writeErr != nil { + return "", "", writeErr + } + if count == 0 { + return "", "", fmt.Errorf("zip for %s contained no installable files", pkg.FullName) + } + if isBepInEx { + return "", v.VersionNumber, nil + } + return pluginDir, v.VersionNumber, nil +} + +// zipReader opens an in-memory zip. +func zipReader(raw []byte) (*zip.Reader, error) { + return zip.NewReader(bytes.NewReader(raw), int64(len(raw))) +} + +// readZipFile reads one zip entry's content (capped). +func readZipFile(f *zip.File) ([]byte, error) { + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, 256<<20)) +} + +// stripTopFolder removes the first path segment ("Top/rest" -> "rest"). +func stripTopFolder(p string) string { + if i := strings.IndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return "" +} + +// sanitizeModDir turns "Owner-ModName" into a safe plugin directory name. +func sanitizeModDir(fullName string) string { + s := strings.ReplaceAll(fullName, "/", "-") + var b strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + return b.String() +} + +// valheimRow loads an instance row, erroring unless it's a valheim instance. +func (h *httpServer) valheimRow(ctx context.Context, id string) (*db.InstanceRow, error) { + row, err := h.db.GetInstanceRow(ctx, id) + if err != nil { + return nil, err + } + if row.ModuleID != "valheim" { + return nil, fmt.Errorf("instance %q is not a valheim server", id) + } + return &row, nil +} + +// mergeValheimConfig merges the given config_values into the instance and +// recreates the container so the entrypoint picks up the new env (the mod +// loader flag). Files already written to the volume survive the recreate +// (PurgeVolumes:false). Mirrors recreateArkWithMods. If the instance was +// stopped, it stays stopped (suppress autostart). +func (h *httpServer) mergeValheimConfig(ctx context.Context, id string, updates map[string]string) error { + row, err := h.db.GetInstanceRow(ctx, id) + if err != nil { + return err + } + conn := h.registry.get(row.AgentID) + if conn == nil { + return fmt.Errorf("agent %q not connected", row.AgentID) + } + cfg := map[string]string{} + for k, v := range row.ConfigValues { + cfg[k] = v + } + for k, v := range updates { + cfg[k] = v + } + wasRunning := row.Status == "running" + cctx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + if wasRunning { + if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{ + InstanceStop: &panelv1.InstanceStop{InstanceId: id, GraceSeconds: 30}, + }); err != nil { + return fmt.Errorf("stop: %w", err) + } + } + if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{ + InstanceDelete: &panelv1.InstanceDelete{InstanceId: id, PurgeVolumes: false}, + }); err != nil { + return fmt.Errorf("delete: %w", err) + } + row.ConfigValues = cfg + row.Status = "creating" + if err := h.db.UpsertInstance(cctx, row); err != nil { + return fmt.Errorf("db upsert: %w", err) + } + var ports []*panelv1.PortMap + for name, hp := range row.AssignedPorts { + ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp}) + } + createCV := cfg + if !wasRunning { + createCV = make(map[string]string, len(cfg)+1) + for k, v := range cfg { + createCV[k] = v + } + createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true" + } + return h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{ + InstanceCreate: &panelv1.InstanceCreate{ + InstanceId: id, + ModuleId: row.ModuleID, + Version: row.ModuleVersion, + RunMode: panelv1.RunMode_RUN_MODE_DOCKER, + ConfigValues: createCV, + Ports: ports, + }, + }) +} + +// mergeValheimConfigNoRecreate updates config_values in the DB WITHOUT +// recreating the container. Used for transient flags (install-in-progress) that +// the UI polls but that shouldn't bounce the server. +func (h *httpServer) mergeValheimConfigNoRecreate(ctx context.Context, id string, updates map[string]string) error { + row, err := h.db.GetInstanceRow(ctx, id) + if err != nil { + return err + } + cfg := map[string]string{} + for k, v := range row.ConfigValues { + cfg[k] = v + } + for k, v := range updates { + if v == "" { + delete(cfg, k) + } else { + cfg[k] = v + } + } + row.ConfigValues = cfg + return h.db.UpsertInstance(ctx, row) +} + +// fsDeleteOnAgent removes a path (recursively) inside the instance volume. +func (h *httpServer) fsDeleteOnAgent(ctx context.Context, agentID, instanceID, p string) error { + conn := h.registry.get(agentID) + if conn == nil { + return fmt.Errorf("agent %q not connected", agentID) + } + corrID := newCorrelationID("fsdelete") + if err := conn.Send(&panelv1.ControllerEnvelope{ + CorrelationId: corrID, + SentAt: timestamppb.Now(), + Payload: &panelv1.ControllerEnvelope_FsDelete{ + FsDelete: &panelv1.FsDeleteRequest{InstanceId: instanceID, Path: p, Recursive: true}, + }, + }); err != nil { + return err + } + env, err := h.pending.Await(ctx, corrID, 60*time.Second) + if err != nil { + return err + } + if res := env.GetFsDeleteResult(); res != nil && res.Error != "" { + return fmt.Errorf("%s", res.Error) + } + return nil +} diff --git a/controller/cmd/controller/valheimplus.go b/controller/cmd/controller/valheimplus.go new file mode 100644 index 00000000..8004bfd3 --- /dev/null +++ b/controller/cmd/controller/valheimplus.go @@ -0,0 +1,311 @@ +package main + +// ValheimPlus — first-class mod integration (separate from the generic +// Thunderstore browser because V+ has a 1500-line config we expose in full). +// +// POST /api/instances/{id}/valheim/valheimplus/install → install + enable +// POST /api/instances/{id}/valheim/valheimplus/config → write valheim_plus.cfg from VP_* settings +// DELETE /api/instances/{id}/valheim/valheimplus → disable + remove +// +// Install: download Grantapher/ValheimPlus UnixServer.zip (bundles BepInEx + +// the ValheimPlus.dll + Doorstop loader) → extract into /game; download the +// stock valheim_plus.cfg → BepInEx/config. Then flip VALHEIM_MODS_ENABLED=1 + +// VALHEIM_PLUS_ENABLED=1 and recreate so Doorstop is injected. +// +// Config: the gamehost UI holds all ~345 V+ settings in config_values as VP_* +// keys (VP_
    __). On save we render the FULL valheim_plus.cfg by +// merging those overrides onto the stock cfg, then fsWrite it to +// BepInEx/config/valheim_plus.cfg. (Done controller-side so we never need 345 +// container env vars.) + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + "time" +) + +const ( + // Grantapher's maintained ValheimPlus fork. Pin a known-good release. + valheimPlusVersion = "0.9.17.1" + valheimPlusRepo = "Grantapher/ValheimPlus" + valheimPlusServerZip = "UnixServer.zip" + valheimPlusCfgAsset = "valheim_plus.cfg" + valheimPlusCfgPath = "/game/BepInEx/config/valheim_plus.cfg" +) + +func valheimPlusAssetURL(asset string) string { + return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", + valheimPlusRepo, valheimPlusVersion, asset) +} + +func (h *httpServer) handleValheimPlusInstall(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + agentID, err := h.lookupInstance(r, id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + if _, err := h.valheimRow(r.Context(), id); err != nil { + writeError(w, http.StatusBadRequest, "bad_module", err.Error()) + return + } + // Mark install in-progress so the UI can show a spinner + poll. Then do the + // heavy work (download + ~40 file writes + recreate) in the BACKGROUND and + // return 202 immediately — the install far exceeds the public proxy's 60s + // timeout, so a synchronous response would 504 even though it succeeds. + if err := h.mergeValheimConfigNoRecreate(r.Context(), id, map[string]string{ + "VALHEIM_PLUS_INSTALLING": "1", + }); err != nil { + writeError(w, http.StatusInternalServerError, "save", err.Error()) + return + } + go h.installValheimPlusAsync(agentID, id) + writeJSON(w, http.StatusAccepted, map[string]any{ + "accepted": true, "version": valheimPlusVersion, + "warning": "Every player must also install ValheimPlus to join (enforceMod is on).", + "note": "installing — this takes a minute; the page will update when ready", + }) +} + +// installValheimPlusAsync does the actual V+ install off the request path. +func (h *httpServer) installValheimPlusAsync(agentID, id string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + fail := func(stage string, err error) { + h.log.Warn("valheimplus install failed", "instance_id", id, "stage", stage, "err", err) + _ = h.mergeValheimConfigNoRecreate(ctx, id, map[string]string{ + "VALHEIM_PLUS_INSTALLING": "", + "VALHEIM_PLUS_INSTALL_ERR": stage + ": " + err.Error(), + }) + } + + zipRaw, err := thunderstoreFetch(ctx, valheimPlusAssetURL(valheimPlusServerZip), 90*time.Second) + if err != nil { + fail("download", err) + return + } + if err := h.extractZipToInstance(ctx, agentID, id, zipRaw, ""); err != nil { + fail("extract", err) + return + } + cfgRaw, err := thunderstoreFetch(ctx, valheimPlusAssetURL(valheimPlusCfgAsset), 30*time.Second) + if err != nil { + fail("download_cfg", err) + return + } + if err := h.fsWriteToAgent(ctx, agentID, id, valheimPlusCfgPath, cfgRaw); err != nil { + fail("write_cfg", err) + return + } + // Enable + clear the installing flag, then recreate so Doorstop is injected. + if err := h.mergeValheimConfig(ctx, id, map[string]string{ + "VALHEIM_MODS_ENABLED": "1", + "VALHEIM_PLUS_ENABLED": "1", + "VALHEIM_PLUS_VERSION": valheimPlusVersion, + "VALHEIM_PLUS_INSTALLING": "", + "VALHEIM_PLUS_INSTALL_ERR": "", + }); err != nil { + fail("enable", err) + return + } + h.log.Info("valheimplus install complete", "instance_id", id, "version", valheimPlusVersion) +} + +func (h *httpServer) handleValheimPlusUninstall(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + agentID, err := h.lookupInstance(r, id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + // Remove the ValheimPlus plugin (leave BepInEx so other mods still work). + _ = h.fsDeleteOnAgent(ctx, agentID, id, "/game/BepInEx/plugins/ValheimPlus.dll") + _ = h.fsDeleteOnAgent(ctx, agentID, id, valheimPlusCfgPath) + + // Disable V+; only disable the whole loader if no other mods are installed. + updates := map[string]string{"VALHEIM_PLUS_ENABLED": "0", "VALHEIM_PLUS_VERSION": ""} + if row, err := h.valheimRow(ctx, id); err == nil { + if len(readInstalledMods(row.ConfigValues)) == 0 { + updates["VALHEIM_MODS_ENABLED"] = "0" + } + } + if err := h.mergeValheimConfig(ctx, id, updates); err != nil { + writeError(w, http.StatusInternalServerError, "save", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"uninstalled": "ValheimPlus", "note": "restart applied"}) +} + +// handleValheimPlusConfig regenerates valheim_plus.cfg from the instance's VP_* +// config_values and writes it to the volume. Does NOT recreate (the running V+ +// reads the cfg at start; restart applies). The gamehost UI POSTs the VP_* +// values into config_values first (via env-config), then calls this. +func (h *httpServer) handleValheimPlusConfig(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + agentID, err := h.lookupInstance(r, id) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", err.Error()) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + // Optional body: {"updates": {VP_*: value}}. Persist these into config_values + // WITHOUT a recreate (we recreate once at the end), so the whole save is a + // single restart instead of two. + var body struct { + Updates map[string]string `json:"updates"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if len(body.Updates) > 0 { + if err := h.mergeValheimConfigNoRecreate(ctx, id, body.Updates); err != nil { + writeError(w, http.StatusInternalServerError, "save", err.Error()) + return + } + } + + row, err := h.valheimRow(ctx, id) + if err != nil { + writeError(w, http.StatusBadRequest, "bad_module", err.Error()) + return + } + + // Fetch the stock cfg as the base (preserves comments + any keys we don't + // expose). Cache-friendly: GitHub release asset. + base, err := thunderstoreFetch(ctx, valheimPlusAssetURL(valheimPlusCfgAsset), 30*time.Second) + if err != nil { + writeError(w, http.StatusBadGateway, "stock_cfg", err.Error()) + return + } + merged := renderValheimPlusCfg(base, row.ConfigValues) + if err := h.fsWriteToAgent(ctx, agentID, id, valheimPlusCfgPath, merged); err != nil { + writeError(w, http.StatusBadGateway, "write_cfg", err.Error()) + return + } + // Recreate so (a) the entrypoint's root-stage re-chowns /game to panel — the + // cfg we just wrote via fsWrite is root-owned, and V+ (running as panel) gets + // "Access denied" + falls back to a GitHub download that clobbers our + // settings; and (b) V+ only reads valheim_plus.cfg at startup, so a restart + // is required for the change to apply at all. + if err := h.mergeValheimConfig(ctx, id, map[string]string{}); err != nil { + writeError(w, http.StatusBadGateway, "recreate", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restarted": true}) +} + +// --- cfg rendering --------------------------------------------------------- + +var vpKeyRe = regexp.MustCompile(`^([A-Za-z0-9_]+)=`) +var vpSectionRe = regexp.MustCompile(`^\[(.+)\]\s*$`) + +// renderValheimPlusCfg merges VP_
    __ overrides from config_values +// onto the stock cfg, preserving structure + comments. Unknown/un-set keys keep +// their stock value. +func renderValheimPlusCfg(stock []byte, cv map[string]string) []byte { + // Index overrides by "Section\x1fkey". + ov := map[string]string{} + for k, v := range cv { + if !strings.HasPrefix(k, "VP_") { + continue + } + rest := strings.TrimPrefix(k, "VP_") + i := strings.Index(rest, "__") + if i < 0 { + continue + } + sec, key := rest[:i], rest[i+2:] + ov[sec+"\x1f"+key] = v + } + + // FORCE disableConfigAutoUpdates=true regardless of the customer's setting. + // V+ otherwise re-downloads the stock cfg from GitHub on every boot and + // OVERWRITES our rendered settings (so e.g. disableServerPassword silently + // reverts to false). The panel is the source of truth for the cfg. + ov["ValheimPlus\x1fdisableConfigAutoUpdates"] = "true" + + var out bytes.Buffer + sc := bufio.NewScanner(bytes.NewReader(stock)) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + section := "" + for sc.Scan() { + line := sc.Text() + if m := vpSectionRe.FindStringSubmatch(line); m != nil { + section = m[1] + out.WriteString(line) + out.WriteByte('\n') + continue + } + if m := vpKeyRe.FindStringSubmatch(line); m != nil { + key := m[1] + if val, ok := ov[section+"\x1f"+key]; ok { + out.WriteString(key) + out.WriteByte('=') + out.WriteString(val) + out.WriteByte('\n') + continue + } + } + out.WriteString(line) + out.WriteByte('\n') + } + return out.Bytes() +} + +// extractZipToInstance unzips raw into the instance volume under destPrefix +// (relative to /game). Skips meta files. Reused by the V+ installer. +func (h *httpServer) extractZipToInstance(ctx context.Context, agentID, instanceID string, raw []byte, destPrefix string) error { + zr, err := zipReader(raw) + if err != nil { + return err + } + count := 0 + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + // Absolute /game path — safeJoinAny on the agent matches the /game + // browseable root. A relative path would resolve under /game-saves. + dest := "/game/" + strings.TrimPrefix(f.Name, "/") + if destPrefix != "" { + dest = "/game/" + strings.Trim(destPrefix, "/") + "/" + strings.TrimPrefix(f.Name, "/") + } + content, err := readZipFile(f) + if err != nil { + return err + } + if err := h.fsWriteToAgent(ctx, agentID, instanceID, dest, content); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + count++ + } + if count == 0 { + return fmt.Errorf("zip contained no files") + } + return nil +} + +// vpSectionNames returns the cfg's section names in file order (used by the UI +// generator). Not called at runtime; kept for tooling parity. +func vpSectionNames(stock []byte) []string { + var secs []string + seen := map[string]bool{} + sc := bufio.NewScanner(bytes.NewReader(stock)) + for sc.Scan() { + if m := vpSectionRe.FindStringSubmatch(sc.Text()); m != nil && !seen[m[1]] { + seen[m[1]] = true + secs = append(secs, m[1]) + } + } + sort.Strings(secs) + return secs +} diff --git a/controller/cmd/panelctl/admin.go b/controller/cmd/panelctl/admin.go new file mode 100644 index 00000000..57f62e70 --- /dev/null +++ b/controller/cmd/panelctl/admin.go @@ -0,0 +1,329 @@ +package main + +// panelctl admin: out-of-band repair commands that drive the controller's +// /admin/v1/ HTTP surface (NOT the gRPC Panel service used by every other +// command in this CLI). Auth is loopback-bypass on the controller host or +// X-Panel-Admin-Token over the wire — see admin.go in the controller for +// the server-side pair. +// +// Token resolution order, used by every admin subcommand: +// 1. --admin-token flag value (if non-empty) +// 2. $PANEL_ADMIN_TOKEN environment variable +// 3. /admin-token file (default data dir: ./data) +// +// The empty-token case is allowed: when running on the controller host +// itself, the request reaches the admin mux over loopback and the +// server bypasses the token check. + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + defaultAdminHTTP = "http://localhost:8080" + adminTokenFilename = "admin-token" + adminAuthHeaderName = "X-Panel-Admin-Token" +) + +func adminUsage() { + fmt.Fprintln(os.Stderr, `panelctl admin — out-of-band repair commands. + +Usage: + panelctl admin [flags] token-show + panelctl admin [flags] status + panelctl admin [flags] instances + panelctl admin [flags] ark-rebind + panelctl admin [flags] set-config KEY=VALUE [KEY=VALUE...] + panelctl admin [flags] del-config KEY [KEY...] + panelctl admin [flags] recreate [--mount CONTAINER_PATH=HOST_PATH ...] + +Flags: + --http URL Controller HTTP base URL (default `+defaultAdminHTTP+`, prod: http://localhost:8180) + Or set PANEL_HTTP env var. + --admin-token TOK Admin token. Defaults to $PANEL_ADMIN_TOKEN or /admin-token. + Not required when calling from the controller host (loopback bypass). + --data-dir DIR Where to look for admin-token file (default ./data; prod: /home/refuge/panel/data). + Or set PANEL_DATA_DIR env var. + +Notes: + set-config / del-config / recreate trigger a stop→delete-preserve→ + recreate→start dance. ARK cluster mount overrides are preserved + automatically. Pass --mount to recreate to override the mount map.`) +} + +// cmdAdmin is the entrypoint dispatched from main.go's switch. +func cmdAdmin(args []string) { + fs := flag.NewFlagSet("panelctl admin", flag.ExitOnError) + httpURL := fs.String("http", envOrDefault("PANEL_HTTP", defaultAdminHTTP), "controller HTTP base URL") + tokenFlag := fs.String("admin-token", os.Getenv("PANEL_ADMIN_TOKEN"), "admin token (or use PANEL_ADMIN_TOKEN, or read from data-dir)") + dataDir := fs.String("data-dir", envOrDefault("PANEL_DATA_DIR", "./data"), "data directory containing admin-token") + fs.Usage = adminUsage + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + rest := fs.Args() + if len(rest) < 1 { + adminUsage() + os.Exit(2) + } + + // Resolve the token unless the user explicitly passed --admin-token=. + // The empty result is fine — server-side loopback bypass covers it. + token := *tokenFlag + if token == "" { + token = readTokenFile(*dataDir) + } + + switch rest[0] { + case "token-show": + adminTokenShow(*dataDir) + case "status", "ping", "health": + adminStatus(*httpURL, token) + case "instances", "ls": + adminInstances(*httpURL, token) + case "ark-rebind": + if len(rest) < 2 { + die("usage: panelctl admin ark-rebind ") + } + adminArkRebind(*httpURL, token, rest[1]) + case "set-config": + if len(rest) < 3 { + die("usage: panelctl admin set-config KEY=VALUE [KEY=VALUE...]") + } + adminSetConfig(*httpURL, token, rest[1], rest[2:]) + case "del-config": + if len(rest) < 3 { + die("usage: panelctl admin del-config KEY [KEY...]") + } + adminDelConfig(*httpURL, token, rest[1], rest[2:]) + case "recreate": + if len(rest) < 2 { + die("usage: panelctl admin recreate [--mount CONTAINER=HOST ...]") + } + adminRecreate(*httpURL, token, rest[1], rest[2:]) + default: + adminUsage() + os.Exit(2) + } +} + +// ---- handlers ---- + +func adminTokenShow(dataDir string) { + tok := readTokenFile(dataDir) + if tok == "" { + fmt.Fprintf(os.Stderr, "panelctl: no admin-token file found at %s\n", filepath.Join(dataDir, adminTokenFilename)) + fmt.Fprintln(os.Stderr, " (controller writes this on first start; check $PANEL_DATA_DIR)") + os.Exit(1) + } + fmt.Println(tok) +} + +func adminStatus(httpURL, token string) { + body, status := adminGET(httpURL, "/admin/v1/health", token) + if status != http.StatusOK { + fmt.Fprintf(os.Stderr, "health: HTTP %d\n%s\n", status, string(body)) + os.Exit(1) + } + prettyPrintJSON(body) +} + +func adminInstances(httpURL, token string) { + body, status := adminGET(httpURL, "/admin/v1/instances", token) + if status != http.StatusOK { + fmt.Fprintf(os.Stderr, "instances: HTTP %d\n%s\n", status, string(body)) + os.Exit(1) + } + var resp struct { + Instances []struct { + InstanceID string `json:"instance_id"` + AgentID string `json:"agent_id"` + ModuleID string `json:"module_id"` + Status string `json:"status"` + ConfigValues map[string]string `json:"config_values"` + AssignedPorts map[string]uint32 `json:"assigned_ports"` + ARKClusterID string `json:"ark_cluster_id"` + } `json:"instances"` + } + if err := json.Unmarshal(body, &resp); err != nil { + fmt.Fprintf(os.Stderr, "decode: %v\n%s\n", err, string(body)) + os.Exit(1) + } + if len(resp.Instances) == 0 { + fmt.Println("(no instances)") + return + } + fmt.Printf("%-20s %-18s %-14s %-10s %-12s %s\n", + "INSTANCE_ID", "AGENT_ID", "MODULE", "STATUS", "CFG_KEYS", "CLUSTER") + for _, i := range resp.Instances { + fmt.Printf("%-20s %-18s %-14s %-10s %-12d %s\n", + i.InstanceID, i.AgentID, i.ModuleID, i.Status, len(i.ConfigValues), i.ARKClusterID) + } +} + +func adminArkRebind(httpURL, token, instanceID string) { + body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/ark-cluster-rebind", token, nil) + if status != http.StatusOK { + fmt.Fprintf(os.Stderr, "ark-rebind: HTTP %d\n%s\n", status, string(body)) + os.Exit(1) + } + prettyPrintJSON(body) +} + +func adminSetConfig(httpURL, token, instanceID string, kvs []string) { + set := map[string]string{} + for _, kv := range kvs { + eq := strings.IndexByte(kv, '=') + if eq <= 0 { + die("malformed KEY=VALUE: %q (must have a non-empty key before '=')", kv) + } + set[kv[:eq]] = kv[eq+1:] + } + payload := map[string]any{"set": set} + body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/config", token, payload) + if status != http.StatusOK { + fmt.Fprintf(os.Stderr, "set-config: HTTP %d\n%s\n", status, string(body)) + os.Exit(1) + } + prettyPrintJSON(body) +} + +func adminDelConfig(httpURL, token, instanceID string, keys []string) { + payload := map[string]any{"delete": keys} + body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/config", token, payload) + if status != http.StatusOK { + fmt.Fprintf(os.Stderr, "del-config: HTTP %d\n%s\n", status, string(body)) + os.Exit(1) + } + prettyPrintJSON(body) +} + +func adminRecreate(httpURL, token, instanceID string, args []string) { + mounts := map[string]string{} + hasMount := false + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--mount": + if i+1 >= len(args) { + die("--mount requires CONTAINER_PATH=HOST_PATH") + } + i++ + parseMountInto(args[i], mounts) + hasMount = true + case strings.HasPrefix(a, "--mount="): + parseMountInto(a[len("--mount="):], mounts) + hasMount = true + default: + die("unknown arg %q to recreate", a) + } + } + payload := map[string]any{} + if hasMount { + payload["mount_overrides"] = mounts + } + body, status := adminPOST(httpURL, "/admin/v1/instances/"+instanceID+"/recreate", token, payload) + if status != http.StatusOK { + fmt.Fprintf(os.Stderr, "recreate: HTTP %d\n%s\n", status, string(body)) + os.Exit(1) + } + prettyPrintJSON(body) +} + +func parseMountInto(spec string, out map[string]string) { + eq := strings.IndexByte(spec, '=') + if eq <= 0 { + die("malformed --mount %q (expected CONTAINER_PATH=HOST_PATH)", spec) + } + out[spec[:eq]] = spec[eq+1:] +} + +// ---- HTTP helpers ---- + +func adminGET(httpURL, path, token string) ([]byte, int) { + req, err := http.NewRequest(http.MethodGet, strings.TrimRight(httpURL, "/")+path, nil) + if err != nil { + die("request: %v", err) + } + return doAdminRequest(req, token) +} + +func adminPOST(httpURL, path, token string, payload any) ([]byte, int) { + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + die("marshal: %v", err) + } + body = bytes.NewReader(b) + } + req, err := http.NewRequest(http.MethodPost, strings.TrimRight(httpURL, "/")+path, body) + if err != nil { + die("request: %v", err) + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + return doAdminRequest(req, token) +} + +func doAdminRequest(req *http.Request, token string) ([]byte, int) { + if token != "" { + req.Header.Set(adminAuthHeaderName, token) + } + client := &http.Client{Timeout: 4 * time.Minute} + resp, err := client.Do(req) + if err != nil { + die("http: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + die("read body: %v", err) + } + return body, resp.StatusCode +} + +// ---- misc ---- + +func readTokenFile(dataDir string) string { + b, err := os.ReadFile(filepath.Join(dataDir, adminTokenFilename)) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(os.Stderr, "panelctl: warn: read admin-token: %v\n", err) + } + return "" + } + return strings.TrimSpace(string(b)) +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func prettyPrintJSON(body []byte) { + var v any + if err := json.Unmarshal(body, &v); err != nil { + fmt.Println(string(body)) + return + } + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + fmt.Println(string(body)) + return + } + fmt.Println(string(out)) +} diff --git a/controller/cmd/panelctl/main.go b/controller/cmd/panelctl/main.go new file mode 100644 index 00000000..7e06fc0f --- /dev/null +++ b/controller/cmd/panelctl/main.go @@ -0,0 +1,483 @@ +// panelctl is the operator-facing CLI. It talks to the Controller's Panel +// gRPC service to inspect connected agents and drive instance lifecycle. +// +// Usage: +// +// panelctl [flags] agents +// panelctl [flags] create +// panelctl [flags] start +// panelctl [flags] stop [grace-seconds] +// +// Flags: +// +// --controller host:port Controller gRPC address (default localhost:8443) +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "flag" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +func usage() { + fmt.Fprintln(os.Stderr, `panelctl — operator CLI for panel. + +Usage: + panelctl [flags] agents + panelctl [flags] instances [agent-id] + panelctl [flags] create + panelctl [flags] start + panelctl [flags] stop [grace-seconds] + panelctl [flags] watch [instance-id] + panelctl [flags] rcon + panelctl [flags] delete [--purge] + panelctl [flags] update [provider-id] + panelctl [flags] modules + panelctl [flags] backup list + panelctl [flags] backup create [description] + panelctl [flags] backup restore + panelctl admin Out-of-band repair (HTTP). See: panelctl admin + +Flags: + --controller host:port Controller gRPC address (default localhost:8443) + --cert-dir DIR Use mTLS with agent certs from DIR (ca.crt + agent.crt + agent.key). + Defaults to /home/refuge/panel/data/certs if it exists. + Without certs, dials plaintext (only works against --insecure controllers).`) +} + +func main() { + // "admin" is a parallel subcommand group that uses HTTP, not gRPC, and + // has its own --http / --admin-token / --data-dir flags. Dispatch it + // before flag.Parse so its FlagSet handles its own args without colliding + // with the gRPC --controller flag below. + if len(os.Args) >= 2 && os.Args[1] == "admin" { + cmdAdmin(os.Args[2:]) + return + } + + controllerAddr := flag.String("controller", "localhost:8443", "controller gRPC address") + certDir := flag.String("cert-dir", defaultCertDir(), "directory holding ca.crt + agent.crt + agent.key for mTLS (empty disables)") + flag.Usage = usage + flag.Parse() + + args := flag.Args() + if len(args) < 1 { + usage() + os.Exit(2) + } + + // Most subcommands need a short deadline; watch overrides. + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if len(args) > 0 && (args[0] == "watch" || args[0] == "w" || args[0] == "tail") { + cancel() + ctx = context.Background() + } + + dialCreds, err := buildCreds(*certDir, *controllerAddr) + if err != nil { + die("tls: %v", err) + } + conn, err := grpc.NewClient(*controllerAddr, grpc.WithTransportCredentials(dialCreds)) + if err != nil { + die("grpc client: %v", err) + } + defer conn.Close() + + client := panelv1.NewPanelClient(conn) + + switch args[0] { + case "agents", "ls", "list": + cmdAgents(ctx, client) + case "instances", "insts", "i": + agentFilter := "" + if len(args) >= 2 { + agentFilter = args[1] + } + cmdInstances(ctx, client, agentFilter) + case "watch", "w", "tail": + // watch has its own long-lived ctx (not timeout-bound) + watchCtx, watchCancel := context.WithCancel(context.Background()) + defer watchCancel() + instanceFilter := "" + if len(args) >= 2 { + instanceFilter = args[1] + } + cmdWatch(watchCtx, client, instanceFilter) + case "create": + if len(args) < 4 { + die("usage: panelctl create ") + } + cmdCreate(ctx, client, args[1], args[2], args[3]) + case "start": + if len(args) < 3 { + die("usage: panelctl start ") + } + cmdStart(ctx, client, args[1], args[2]) + case "stop": + if len(args) < 3 { + die("usage: panelctl stop [grace-seconds]") + } + grace := uint32(30) + if len(args) >= 4 { + g, err := strconv.ParseUint(args[3], 10, 32) + if err != nil { + die("invalid grace-seconds %q: %v", args[3], err) + } + grace = uint32(g) + } + cmdStop(ctx, client, args[1], args[2], grace) + case "rcon", "r": + if len(args) < 4 { + die("usage: panelctl rcon ") + } + cmd := args[3] + for _, s := range args[4:] { + cmd += " " + s + } + cmdRCON(ctx, client, args[1], args[2], cmd) + case "delete", "rm", "del": + if len(args) < 3 { + die("usage: panelctl delete [--purge]") + } + purge := false + for _, a := range args[3:] { + if a == "--purge" { + purge = true + } + } + cmdDelete(ctx, client, args[1], args[2], purge) + case "update", "upd": + if len(args) < 3 { + die("usage: panelctl update [provider-id]") + } + providerID := "" + if len(args) >= 4 { + providerID = args[3] + } + cmdUpdate(ctx, client, args[1], args[2], providerID) + case "modules", "mods": + cmdModules(ctx, *controllerAddr) + case "backup", "bkp": + if len(args) < 3 { + die("usage: panelctl backup ...") + } + cmdBackup(ctx, client, *controllerAddr, args[2:]) + default: + usage() + os.Exit(2) + } +} + +func cmdAgents(ctx context.Context, c panelv1.PanelClient) { + resp, err := c.ListAgents(ctx, &panelv1.ListAgentsRequest{}) + if err != nil { + die("list agents: %v", err) + } + if len(resp.Agents) == 0 { + fmt.Println("(no agents connected)") + return + } + fmt.Printf("%-24s %-10s %-12s %-10s %s\n", "AGENT_ID", "VERSION", "OS/ARCH", "HOSTNAME", "CONNECTED") + for _, a := range resp.Agents { + conn := a.ConnectedAt.AsTime().Format(time.RFC3339) + fmt.Printf("%-24s %-10s %-12s %-10s %s\n", + a.AgentId, a.Version, a.HostOs+"/"+a.HostArch, a.Hostname, conn) + } +} + +func cmdInstances(ctx context.Context, c panelv1.PanelClient, agentFilter string) { + resp, err := c.ListInstances(ctx, &panelv1.ListInstancesRequest{AgentId: agentFilter}) + if err != nil { + die("list instances: %v", err) + } + if len(resp.Instances) == 0 { + fmt.Println("(no instances)") + return + } + fmt.Printf("%-18s %-18s %-14s %-10s %-18s %s\n", + "INSTANCE_ID", "AGENT_ID", "MODULE", "STATUS", "UPDATED", "DETAIL") + for _, i := range resp.Instances { + module := i.ModuleId + if i.ModuleVersion != "" { + module = module + "@" + i.ModuleVersion + } + updated := i.UpdatedAt.AsTime().Format("01-02 15:04:05") + fmt.Printf("%-18s %-18s %-14s %-10s %-18s %s\n", + i.InstanceId, i.AgentId, module, shortStatus(i.Status), updated, i.Detail) + } +} + +func shortStatus(s panelv1.InstanceStatus) string { + switch s { + case panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED: + return "stopped" + case panelv1.InstanceStatus_INSTANCE_STATUS_STARTING: + return "starting" + case panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING: + return "running" + case panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING: + return "stopping" + case panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED: + return "crashed" + case panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING: + return "updating" + default: + return "unknown" + } +} + +func cmdWatch(ctx context.Context, c panelv1.PanelClient, instanceFilter string) { + stream, err := c.StreamEvents(ctx, &panelv1.StreamEventsRequest{InstanceId: instanceFilter}) + if err != nil { + die("stream events: %v", err) + } + if instanceFilter == "" { + fmt.Fprintln(os.Stderr, "watching all events; Ctrl-C to stop") + } else { + fmt.Fprintf(os.Stderr, "watching instance %s; Ctrl-C to stop\n", instanceFilter) + } + for { + ev, err := stream.Recv() + if err != nil { + fmt.Fprintf(os.Stderr, "stream ended: %v\n", err) + return + } + renderEvent(ev) + } +} + +func renderEvent(ev *panelv1.Event) { + at := ev.At.AsTime().Format("15:04:05") + switch p := ev.Payload.(type) { + case *panelv1.Event_InstanceState: + fmt.Printf("%s STATE %-14s %-10s %s\n", + at, p.InstanceState.InstanceId, shortStatus(p.InstanceState.Status), p.InstanceState.Detail) + case *panelv1.Event_AppState: + fmt.Printf("%s APP %-14s players=%d/%d uptime=%ds\n", + at, p.AppState.InstanceId, p.AppState.PlayersOnline, p.AppState.PlayersMax, p.AppState.UptimeSeconds) + case *panelv1.Event_Player: + fmt.Printf("%s %-6s %-14s %s %s\n", + at, shortKind(p.Player.Kind), p.Player.InstanceId, p.Player.PlayerName, p.Player.Detail) + case *panelv1.Event_Log: + fmt.Printf("%s LOG %-14s %s\n", + at, p.Log.InstanceId, p.Log.Line) + default: + fmt.Printf("%s ? %T\n", at, ev.Payload) + } +} + +func shortKind(k panelv1.PlayerEvent_Kind) string { + switch k { + case panelv1.PlayerEvent_KIND_JOIN: + return "JOIN" + case panelv1.PlayerEvent_KIND_LEAVE: + return "LEAVE" + case panelv1.PlayerEvent_KIND_CHAT: + return "CHAT" + case panelv1.PlayerEvent_KIND_DEATH: + return "DEATH" + case panelv1.PlayerEvent_KIND_CUSTOM: + return "CUST" + default: + return "?" + } +} + +func cmdCreate(ctx context.Context, c panelv1.PanelClient, agentID, instanceID, moduleID string) { + resp, err := c.CreateInstance(ctx, &panelv1.CreateInstanceRequest{ + AgentId: agentID, + InstanceId: instanceID, + ModuleId: moduleID, + RunMode: panelv1.RunMode_RUN_MODE_DOCKER, + }) + if err != nil { + die("create: %v", err) + } + fmt.Printf("create OK instance=%s correlation=%s\n", resp.InstanceId, resp.CorrelationId) +} + +func cmdStart(ctx context.Context, c panelv1.PanelClient, agentID, instanceID string) { + resp, err := c.StartInstance(ctx, &panelv1.StartInstanceRequest{ + AgentId: agentID, + InstanceId: instanceID, + }) + if err != nil { + die("start: %v", err) + } + fmt.Printf("start OK instance=%s correlation=%s\n", instanceID, resp.CorrelationId) +} + +func cmdRCON(ctx context.Context, c panelv1.PanelClient, agentID, instanceID, command string) { + resp, err := c.ExecRCON(ctx, &panelv1.ExecRCONRequest{ + AgentId: agentID, + InstanceId: instanceID, + Command: command, + TimeoutMs: 10000, + }) + if err != nil { + die("rcon: %v", err) + } + if resp.Error != "" { + fmt.Fprintf(os.Stderr, "error: %s\n", resp.Error) + } + if resp.Output != "" { + fmt.Print(resp.Output) + if !strings.HasSuffix(resp.Output, "\n") { + fmt.Println() + } + } +} + +func cmdStop(ctx context.Context, c panelv1.PanelClient, agentID, instanceID string, grace uint32) { + resp, err := c.StopInstance(ctx, &panelv1.StopInstanceRequest{ + AgentId: agentID, + InstanceId: instanceID, + GraceSeconds: grace, + }) + if err != nil { + die("stop: %v", err) + } + fmt.Printf("stop OK instance=%s correlation=%s\n", instanceID, resp.CorrelationId) +} + +func cmdDelete(ctx context.Context, c panelv1.PanelClient, agentID, instanceID string, purge bool) { + resp, err := c.DeleteInstance(ctx, &panelv1.DeleteInstanceRequest{ + AgentId: agentID, + InstanceId: instanceID, + PurgeVolumes: purge, + }) + if err != nil { + die("delete: %v", err) + } + fmt.Printf("delete queued instance=%s purge=%t correlation=%s\n", instanceID, purge, resp.CorrelationId) +} + +func cmdUpdate(ctx context.Context, c panelv1.PanelClient, agentID, instanceID, providerID string) { + resp, err := c.UpdateInstance(ctx, &panelv1.UpdateInstanceRequest{ + AgentId: agentID, + InstanceId: instanceID, + ProviderId: providerID, + }) + if err != nil { + die("update: %v", err) + } + fmt.Printf("update accepted=%t provider=%s kind=%s correlation=%s error=%s\n", + resp.Accepted, resp.ProviderId, resp.ProviderKind, resp.CorrelationId, resp.Error) + fmt.Fprintln(os.Stderr, "watch progress with: panelctl watch "+instanceID) +} + +// cmdModules is REST because the modules endpoint isn't a Panel RPC. +func cmdModules(ctx context.Context, controllerAddr string) { + base := "http://" + strings.TrimPrefix(strings.Split(controllerAddr, ":")[0], "http://") + ":8080" + _ = ctx + _ = base + fmt.Println("modules: hit the HTTP dashboard's /api/modules or visit the UI") + fmt.Println("(gRPC ListModules RPC not wired yet — mirroring only via HTTP for now)") +} + +func cmdBackup(ctx context.Context, c panelv1.PanelClient, controllerAddr string, args []string) { + if len(args) < 1 { + die("usage: panelctl backup ...") + } + switch args[0] { + case "create": + if len(args) < 3 { + die("usage: panelctl backup create [description]") + } + desc := "" + if len(args) >= 4 { + desc = strings.Join(args[3:], " ") + } + resp, err := c.CreateBackup(ctx, &panelv1.CreateBackupRequest{ + AgentId: args[1], + InstanceId: args[2], + Description: desc, + }) + if err != nil { + die("backup: %v", err) + } + fmt.Printf("backup %s %d bytes path=%s\n", resp.BackupId, resp.SizeBytes, resp.Path) + case "restore": + if len(args) < 3 { + die("usage: panelctl backup restore ") + } + resp, err := c.RestoreBackup(ctx, &panelv1.RestoreBackupRequest{ + InstanceId: args[1], + BackupId: args[2], + }) + if err != nil { + die("restore: %v", err) + } + fmt.Printf("restore ok — %d bytes restored\n", resp.BytesRestored) + case "list": + fmt.Println("backup list: hit /api/instances//backups via HTTP (no gRPC ListBackups RPC yet)") + default: + die("unknown backup subcommand %q", args[0]) + } + _ = controllerAddr +} + +func die(format string, args ...interface{}) { + fmt.Fprintf(os.Stderr, "panelctl: "+format+"\n", args...) + os.Exit(1) +} + +// defaultCertDir returns /home/refuge/panel/data/certs when it exists (the +// standard prod path), otherwise empty string. This lets `panelctl agents` +// just work when run on princess without operators having to specify a flag. +func defaultCertDir() string { + const prodPath = "/home/refuge/panel/data/certs" + if _, err := os.Stat(filepath.Join(prodPath, "agent.crt")); err == nil { + return prodPath + } + return "" +} + +// buildCreds returns mTLS credentials when certDir contains the expected +// agent material; falls back to insecure (plaintext) when certDir is empty. +// The mTLS path mirrors the agent's loadClientTLS so panelctl can talk to +// the same prod gRPC endpoint without a special-cased "operator cert". +func buildCreds(certDir, controllerAddr string) (credentials.TransportCredentials, error) { + if certDir == "" { + return insecure.NewCredentials(), nil + } + caPath := filepath.Join(certDir, "ca.crt") + certPath := filepath.Join(certDir, "agent.crt") + keyPath := filepath.Join(certDir, "agent.key") + caPEM, err := os.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("read ca: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("bad CA PEM") + } + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("load cert+key: %w", err) + } + serverName := controllerAddr + if i := strings.LastIndex(serverName, ":"); i > 0 { + serverName = serverName[:i] + } + return credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: pool, + ServerName: serverName, + MinVersion: tls.VersionTLS12, + }), nil +} diff --git a/controller/cmd/reset-admin/main.go b/controller/cmd/reset-admin/main.go new file mode 100644 index 00000000..04cd0ded --- /dev/null +++ b/controller/cmd/reset-admin/main.go @@ -0,0 +1,116 @@ +// reset-admin resets a panel user's password. +// +// Two modes: +// +// reset-admin --email admin@panel.local --password NEWPW [--db-url postgres://...] +// Connects to Postgres (flag, or $PANEL_DB_URL, or the controller's +// default dev DSN) and writes a fresh argon2id hash for that user. +// +// reset-admin +// Legacy: prints the argon2id hash to stdout for a manual +// `UPDATE users SET password_hash = '...'`. +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/argon2" +) + +// Must match controller/cmd/controller/auth.go hashPassword parameters. +const ( + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 2 + argonKeyLen = 32 + argonSaltLen = 16 + + defaultDBURL = "postgres://panel:panel_dev@127.0.0.1:5432/panel?sslmode=disable" +) + +func hash(password string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", err + } + h := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", + argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(h), + ), nil +} + +func main() { + email := flag.String("email", "", "user email to reset (writes the DB; e.g. admin@panel.local)") + password := flag.String("password", "", "new password (used with --email)") + dbURL := flag.String("db-url", "", "Postgres connection string (default: $PANEL_DB_URL, else the controller's dev default)") + flag.Usage = func() { + fmt.Fprintln(os.Stderr, `usage: + reset-admin --email EMAIL --password NEWPW [--db-url postgres://...] + reset-admin (hash-only: prints argon2id hash for manual UPDATE)`) + } + flag.Parse() + + // Legacy hash-only mode: one positional arg, no --email. + if *email == "" { + if flag.NArg() != 1 { + flag.Usage() + os.Exit(2) + } + h, err := hash(flag.Arg(0)) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(h) + return + } + + if *password == "" { + fmt.Fprintln(os.Stderr, "reset-admin: --password is required with --email") + os.Exit(2) + } + dsn := *dbURL + if dsn == "" { + dsn = os.Getenv("PANEL_DB_URL") + } + if dsn == "" { + dsn = defaultDBURL + } + + h, err := hash(*password) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + fmt.Fprintf(os.Stderr, "reset-admin: connect: %v\n", err) + os.Exit(1) + } + defer conn.Close(ctx) + + tag, err := conn.Exec(ctx, + `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE email = $2`, + h, *email) + if err != nil { + fmt.Fprintf(os.Stderr, "reset-admin: update: %v\n", err) + os.Exit(1) + } + if tag.RowsAffected() == 0 { + fmt.Fprintf(os.Stderr, "reset-admin: no user with email %q\n", *email) + os.Exit(1) + } + fmt.Printf("reset-admin: password updated for %s\n", *email) +} diff --git a/controller/go.mod b/controller/go.mod new file mode 100644 index 00000000..4d0babef --- /dev/null +++ b/controller/go.mod @@ -0,0 +1,27 @@ +module github.com/dbledeez/panel/controller + +go 1.26 + +require ( + github.com/dbledeez/panel/proto v0.0.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/prometheus/client_golang v1.23.2 + github.com/robfig/cron/v3 v3.0.1 + golang.org/x/crypto v0.49.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/google/cel-go v0.28.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect +) + +replace github.com/dbledeez/panel/proto => ../proto diff --git a/controller/go.sum b/controller/go.sum new file mode 100644 index 00000000..0e12c6cf --- /dev/null +++ b/controller/go.sum @@ -0,0 +1,76 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/controller/internal/ca/ca.go b/controller/internal/ca/ca.go new file mode 100644 index 00000000..c0c7c10e --- /dev/null +++ b/controller/internal/ca/ca.go @@ -0,0 +1,261 @@ +// Package ca implements the Controller's internal Certificate Authority: +// the root-of-trust that signs both the Controller's own server cert and +// every Agent's client cert during the pairing flow. +// +// Files on disk under caDir: +// +// ca.crt — self-signed root CA certificate (PEM) +// ca.key — CA private key (PEM, mode 0600) +// controller.crt — Controller's server cert, signed by ca.key (PEM) +// controller.key — Controller's server key (PEM, mode 0600) +// +// Bootstrapping is idempotent: on first start we generate both pairs; on +// every subsequent start we load them. Rotate by deleting the specific +// file(s) you want regenerated and restarting. +package ca + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +const ( + rsaBits = 2048 + caValidity = 10 * 365 * 24 * time.Hour + serverValidity = 365 * 24 * time.Hour + agentValidity = 365 * 24 * time.Hour + caCommonName = "panel Internal CA" + serverCommonName = "panel controller" +) + +// CA wraps the loaded root cert/key plus the Controller's server cert/key. +type CA struct { + Root *x509.Certificate + RootKey *rsa.PrivateKey + RootPEM []byte // cached ca.crt bytes + Server *x509.Certificate + ServerKey *rsa.PrivateKey +} + +// Ensure loads (or initializes) the CA + server cert under dir. If +// hostnames is non-empty, it's added as SANs on the server cert (e.g. +// "mypanel.example.com"). localhost + 127.0.0.1 + the machine's +// hostname are always included. +func Ensure(dir string, extraHostnames []string) (*CA, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", dir, err) + } + rootCert, rootKey, rootPEM, err := ensureRoot(dir) + if err != nil { + return nil, err + } + serverCert, serverKey, err := ensureServer(dir, rootCert, rootKey, extraHostnames) + if err != nil { + return nil, err + } + return &CA{ + Root: rootCert, RootKey: rootKey, RootPEM: rootPEM, + Server: serverCert, ServerKey: serverKey, + }, nil +} + +func ensureRoot(dir string) (*x509.Certificate, *rsa.PrivateKey, []byte, error) { + certPath := filepath.Join(dir, "ca.crt") + keyPath := filepath.Join(dir, "ca.key") + if _, err := os.Stat(certPath); err == nil { + cert, pemBytes, err := loadCert(certPath) + if err != nil { + return nil, nil, nil, fmt.Errorf("load ca.crt: %w", err) + } + key, err := loadKey(keyPath) + if err != nil { + return nil, nil, nil, fmt.Errorf("load ca.key: %w", err) + } + return cert, key, pemBytes, nil + } + + priv, err := rsa.GenerateKey(rand.Reader, rsaBits) + if err != nil { + return nil, nil, nil, fmt.Errorf("rsa key: %w", err) + } + tpl := &x509.Certificate{ + SerialNumber: randomSerial(), + Subject: pkix.Name{CommonName: caCommonName, Organization: []string{"panel"}}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(caValidity), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: false, + } + der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv) + if err != nil { + return nil, nil, nil, fmt.Errorf("create ca cert: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse ca cert: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + return nil, nil, nil, err + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return nil, nil, nil, err + } + return cert, priv, certPEM, nil +} + +func ensureServer(dir string, root *x509.Certificate, rootKey *rsa.PrivateKey, extraHostnames []string) (*x509.Certificate, *rsa.PrivateKey, error) { + certPath := filepath.Join(dir, "controller.crt") + keyPath := filepath.Join(dir, "controller.key") + if _, err := os.Stat(certPath); err == nil { + cert, _, err := loadCert(certPath) + if err != nil { + return nil, nil, fmt.Errorf("load controller.crt: %w", err) + } + key, err := loadKey(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("load controller.key: %w", err) + } + return cert, key, nil + } + priv, err := rsa.GenerateKey(rand.Reader, rsaBits) + if err != nil { + return nil, nil, err + } + host, _ := os.Hostname() + ips := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")} + dns := []string{"localhost", host} + // Split extraHostnames into DNS vs IP — TLS validates IP dials against + // IPAddresses, not DNSNames, so any LAN IP an agent will dial must + // land in the IP set. + for _, h := range extraHostnames { + if ip := net.ParseIP(h); ip != nil { + ips = append(ips, ip) + } else { + dns = append(dns, h) + } + } + dns = uniq(dns) + + tpl := &x509.Certificate{ + SerialNumber: randomSerial(), + Subject: pkix.Name{CommonName: serverCommonName, Organization: []string{"panel"}}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(serverValidity), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: dns, + IPAddresses: ips, + } + der, err := x509.CreateCertificate(rand.Reader, tpl, root, &priv.PublicKey, rootKey) + if err != nil { + return nil, nil, err + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, nil, err + } + if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil { + return nil, nil, err + } + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}), 0o600); err != nil { + return nil, nil, err + } + return cert, priv, nil +} + +// SignAgentCSR validates a CSR from a pairing agent and returns the +// signed cert as PEM. The agent's CN becomes its identity. The cert +// is marked ExtKeyUsageClientAuth (mTLS client only). +func (c *CA) SignAgentCSR(csrPEM []byte, agentID string) (certPEM []byte, err error) { + block, _ := pem.Decode(csrPEM) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + return nil, errors.New("bad CSR PEM") + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse CSR: %w", err) + } + if err := csr.CheckSignature(); err != nil { + return nil, fmt.Errorf("CSR signature: %w", err) + } + // Force our agent_id into CN + DNS SANs regardless of what the CSR asked for. + // (This is the panel's canonical identity for the cert; the agent can't + // claim to be someone else.) + tpl := &x509.Certificate{ + SerialNumber: randomSerial(), + Subject: pkix.Name{CommonName: agentID, Organization: []string{"panel-agent"}}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(agentValidity), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + DNSNames: []string{agentID}, + } + der, err := x509.CreateCertificate(rand.Reader, tpl, c.Root, csr.PublicKey, c.RootKey) + if err != nil { + return nil, fmt.Errorf("sign: %w", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), nil +} + +// ---- helpers ---- + +func randomSerial() *big.Int { + max := new(big.Int).Lsh(big.NewInt(1), 128) + n, _ := rand.Int(rand.Reader, max) + return n +} + +func loadCert(path string) (*x509.Certificate, []byte, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + block, _ := pem.Decode(body) + if block == nil { + return nil, nil, errors.New("no PEM block") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, err + } + return cert, body, nil +} + +func loadKey(path string) (*rsa.PrivateKey, error) { + body, err := os.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(body) + if block == nil { + return nil, errors.New("no PEM block") + } + return x509.ParsePKCS1PrivateKey(block.Bytes) +} + +func uniq(in []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, s := range in { + if s == "" || seen[s] { + continue + } + seen[s] = true + out = append(out, s) + } + return out +} diff --git a/controller/internal/db/agents.go b/controller/internal/db/agents.go new file mode 100644 index 00000000..c248568a --- /dev/null +++ b/controller/internal/db/agents.go @@ -0,0 +1,175 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +// AgentRow is the persisted form of an agent record. +type AgentRow struct { + AgentID string + Version string + HostOS string + HostArch string + Hostname string + Label string + Notes string + // PortRangeStart/End define the inclusive host-port window the panel + // allocates from when creating new instances on this agent. Both 0 + // (unset) means use the global default 7000-9999. Set via the agent + // edit modal so operators can isolate per-agent port pools (e.g. + // agent A: 7000-7999, agent B: 8000-8999) for firewall sanity. + PortRangeStart int32 + PortRangeEnd int32 + // LANIP is the agent's RFC1918 LAN IP — used as the `target` field + // when the opnfwd integration creates a port forward for an instance + // on this agent. Empty disables the integration for this agent (loud + // log, no-op). Operator-edited via the agent edit modal. + LANIP string + FirstSeen time.Time + LastSeen time.Time +} + +// UpsertAgent inserts the agent or updates its metadata on reconnect, +// bumping last_seen. first_seen is preserved on conflict. +func (db *DB) UpsertAgent(ctx context.Context, a AgentRow) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO agents (agent_id, version, host_os, host_arch, hostname, first_seen, last_seen) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + ON CONFLICT (agent_id) DO UPDATE SET + version = EXCLUDED.version, + host_os = EXCLUDED.host_os, + host_arch = EXCLUDED.host_arch, + hostname = EXCLUDED.hostname, + last_seen = NOW() + `, a.AgentID, a.Version, a.HostOS, a.HostArch, a.Hostname) + if err != nil { + return fmt.Errorf("upsert agent: %w", err) + } + return nil +} + +// TouchAgent bumps last_seen for an agent. Used on heartbeat arrival. +func (db *DB) TouchAgent(ctx context.Context, agentID string) error { + _, err := db.pool.Exec(ctx, `UPDATE agents SET last_seen = NOW() WHERE agent_id = $1`, agentID) + if err != nil { + return fmt.Errorf("touch agent: %w", err) + } + return nil +} + +// UpdateAgentMeta sets operator-assigned display fields. +func (db *DB) UpdateAgentMeta(ctx context.Context, agentID, label, notes string) error { + _, err := db.pool.Exec(ctx, `UPDATE agents SET label = $2, notes = $3 WHERE agent_id = $1`, agentID, label, notes) + if err != nil { + return fmt.Errorf("update agent meta: %w", err) + } + return nil +} + +// SetAgentPortRange persists the host-port window this agent allocates +// new instances from. Pass 0,0 to clear (allocator falls back to global +// default). Validates that start <= end and both fit in uint16 range. +func (db *DB) SetAgentPortRange(ctx context.Context, agentID string, start, end int32) error { + if start == 0 && end == 0 { + _, err := db.pool.Exec(ctx, `UPDATE agents SET port_range_start = NULL, port_range_end = NULL WHERE agent_id = $1`, agentID) + if err != nil { + return fmt.Errorf("clear port range: %w", err) + } + return nil + } + if start <= 0 || end <= 0 || start > 65535 || end > 65535 || start > end { + return fmt.Errorf("invalid port range %d-%d (must be 1-65535 and start<=end)", start, end) + } + _, err := db.pool.Exec(ctx, `UPDATE agents SET port_range_start = $2, port_range_end = $3 WHERE agent_id = $1`, agentID, start, end) + if err != nil { + return fmt.Errorf("set port range: %w", err) + } + return nil +} + +// GetAgentPortRange returns the configured window or (0,0) if unset. +// (0,0) means "use the global default" — allocator decides what. +func (db *DB) GetAgentPortRange(ctx context.Context, agentID string) (start, end int32, err error) { + var s, e *int32 + err = db.pool.QueryRow(ctx, `SELECT port_range_start, port_range_end FROM agents WHERE agent_id = $1`, agentID).Scan(&s, &e) + if err != nil { + return 0, 0, err + } + if s != nil { + start = *s + } + if e != nil { + end = *e + } + return +} + +// GetAgentMeta returns just the label + notes (avoids pulling the full row +// when we already have everything else from the in-memory agent registry). +func (db *DB) GetAgentMeta(ctx context.Context, agentID string) (label, notes string, err error) { + err = db.pool.QueryRow(ctx, `SELECT COALESCE(label, ''), COALESCE(notes, '') FROM agents WHERE agent_id = $1`, agentID).Scan(&label, ¬es) + return +} + +// GetAgentLANIP returns the operator-set LAN IP for this agent. Empty +// string means the operator hasn't filled it in — opnfwd integration +// no-ops for instances on this agent until they do. +func (db *DB) GetAgentLANIP(ctx context.Context, agentID string) (string, error) { + var v string + err := db.pool.QueryRow(ctx, `SELECT COALESCE(lan_ip, '') FROM agents WHERE agent_id = $1`, agentID).Scan(&v) + return v, err +} + +// SetAgentLANIP persists the LAN IP. Empty string is allowed (clears). +// Caller is responsible for sanity-checking the IP format. +func (db *DB) SetAgentLANIP(ctx context.Context, agentID, ip string) error { + _, err := db.pool.Exec(ctx, `UPDATE agents SET lan_ip = $2 WHERE agent_id = $1`, agentID, ip) + if err != nil { + return fmt.Errorf("set agent lan_ip: %w", err) + } + return nil +} + +// DeleteAgent removes an agent record. The agent's cert remains valid +// until expiry — true revocation would require a CRL; for MVP this is a +// forget-from-DB. If the agent is live it'll just re-register next beat. +func (db *DB) DeleteAgent(ctx context.Context, agentID string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM agents WHERE agent_id = $1`, agentID) + if err != nil { + return fmt.Errorf("delete agent: %w", err) + } + return nil +} + +// ListAgents returns every row in the agents table. The in-memory registry +// only knows about live-connected agents, but the dashboard needs to surface +// offline ones too so instances owned by an offline agent don't silently +// disappear from the UI. Caller merges live + DB and marks the DB-only ones +// as offline. +func (db *DB) ListAgents(ctx context.Context) ([]AgentRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT agent_id, version, host_os, host_arch, hostname, + COALESCE(label, ''), COALESCE(notes, ''), + COALESCE(port_range_start, 0), COALESCE(port_range_end, 0), + COALESCE(lan_ip, ''), + first_seen, last_seen + FROM agents + ORDER BY agent_id`) + if err != nil { + return nil, fmt.Errorf("list agents: %w", err) + } + defer rows.Close() + var out []AgentRow + for rows.Next() { + var a AgentRow + if err := rows.Scan(&a.AgentID, &a.Version, &a.HostOS, &a.HostArch, &a.Hostname, + &a.Label, &a.Notes, &a.PortRangeStart, &a.PortRangeEnd, &a.LANIP, + &a.FirstSeen, &a.LastSeen); err != nil { + return nil, fmt.Errorf("scan agent row: %w", err) + } + out = append(out, a) + } + return out, rows.Err() +} diff --git a/controller/internal/db/backups.go b/controller/internal/db/backups.go new file mode 100644 index 00000000..4490b716 --- /dev/null +++ b/controller/internal/db/backups.go @@ -0,0 +1,76 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// BackupRow is the persisted form of a backup. +type BackupRow struct { + ID string + InstanceID string + AgentID string + Path string + SizeBytes int64 + Description string + CreatedAt time.Time +} + +func (db *DB) InsertBackup(ctx context.Context, r BackupRow) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO backups (id, instance_id, agent_id, path, size_bytes, description) + VALUES ($1, $2, $3, $4, $5, $6) + `, r.ID, r.InstanceID, r.AgentID, r.Path, r.SizeBytes, r.Description) + if err != nil { + return fmt.Errorf("insert backup: %w", err) + } + return nil +} + +func (db *DB) DeleteBackup(ctx context.Context, id string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM backups WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete backup: %w", err) + } + return nil +} + +func (db *DB) GetBackup(ctx context.Context, id string) (*BackupRow, error) { + var r BackupRow + err := db.pool.QueryRow(ctx, ` + SELECT id, instance_id, agent_id, path, size_bytes, description, created_at + FROM backups WHERE id = $1 + `, id).Scan(&r.ID, &r.InstanceID, &r.AgentID, &r.Path, &r.SizeBytes, &r.Description, &r.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("backup not found") + } + if err != nil { + return nil, fmt.Errorf("get backup: %w", err) + } + return &r, nil +} + +func (db *DB) ListBackupsByInstance(ctx context.Context, instanceID string) ([]BackupRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, instance_id, agent_id, path, size_bytes, description, created_at + FROM backups WHERE instance_id = $1 + ORDER BY created_at DESC + `, instanceID) + if err != nil { + return nil, fmt.Errorf("list backups: %w", err) + } + defer rows.Close() + var out []BackupRow + for rows.Next() { + var r BackupRow + if err := rows.Scan(&r.ID, &r.InstanceID, &r.AgentID, &r.Path, &r.SizeBytes, &r.Description, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("scan: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/controller/internal/db/db.go b/controller/internal/db/db.go new file mode 100644 index 00000000..68f8661d --- /dev/null +++ b/controller/internal/db/db.go @@ -0,0 +1,58 @@ +// Package db owns the Controller's durable state in Postgres. It exposes +// a small typed surface (UpsertAgent, InsertInstance, ListInstances, …) +// built on pgx/v5 with a connection pool. Migrations are embedded so the +// binary is self-contained. +package db + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DB wraps a pgx pool with our typed query methods. +type DB struct { + pool *pgxpool.Pool +} + +// Open connects to Postgres using dsn, pings once to fail fast on bad +// credentials or unreachable server, and runs any pending migrations. +func Open(ctx context.Context, dsn string) (*DB, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("parse db dsn: %w", err) + } + cfg.MaxConns = 10 + cfg.MaxConnLifetime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("new pool: %w", err) + } + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping db: %w", err) + } + + if err := Migrate(ctx, pool); err != nil { + pool.Close() + return nil, fmt.Errorf("migrate: %w", err) + } + + return &DB{pool: pool}, nil +} + +// Close releases pool resources. +func (db *DB) Close() { + if db != nil && db.pool != nil { + db.pool.Close() + } +} + +// Pool exposes the underlying pool for tests / advanced callers. +func (db *DB) Pool() *pgxpool.Pool { return db.pool } diff --git a/controller/internal/db/instance_forwards.go b/controller/internal/db/instance_forwards.go new file mode 100644 index 00000000..7aeb6fcf --- /dev/null +++ b/controller/internal/db/instance_forwards.go @@ -0,0 +1,104 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +// InstanceForwardRow is one panel-owned port forward in opnfwd. Composite +// key (instance_id, port_name) — the panel allocates one forward per +// manifest port per instance. +type InstanceForwardRow struct { + InstanceID string + PortName string + NatUUID string + FilterUUID string + TargetIP string + HostPort int32 + Proto string // "tcp" | "udp" | "tcp/udp" + Descr string // "panel--" — matches the OPNsense rule's + CreatedAt time.Time +} + +// UpsertInstanceForward persists a single forward, replacing any prior row +// for the same (instance, port_name). The replace is intentional — if the +// panel re-creates a forward for an existing port (e.g. operator clicked +// Reconcile and we recreated), the new uuids supersede the old. +func (db *DB) UpsertInstanceForward(ctx context.Context, r InstanceForwardRow) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO instance_forwards (instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (instance_id, port_name) DO UPDATE SET + nat_uuid = EXCLUDED.nat_uuid, + filter_uuid = EXCLUDED.filter_uuid, + target_ip = EXCLUDED.target_ip, + host_port = EXCLUDED.host_port, + proto = EXCLUDED.proto, + descr = EXCLUDED.descr + `, r.InstanceID, r.PortName, r.NatUUID, r.FilterUUID, r.TargetIP, r.HostPort, r.Proto, r.Descr) + if err != nil { + return fmt.Errorf("upsert instance_forward: %w", err) + } + return nil +} + +// ListInstanceForwards returns every forward for the given instance, used +// at delete time + by the instance-modal "Forwards" pill. +func (db *DB) ListInstanceForwards(ctx context.Context, instanceID string) ([]InstanceForwardRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr, created_at + FROM instance_forwards + WHERE instance_id = $1 + ORDER BY port_name + `, instanceID) + if err != nil { + return nil, fmt.Errorf("list instance_forwards: %w", err) + } + defer rows.Close() + var out []InstanceForwardRow + for rows.Next() { + var r InstanceForwardRow + if err := rows.Scan(&r.InstanceID, &r.PortName, &r.NatUUID, &r.FilterUUID, + &r.TargetIP, &r.HostPort, &r.Proto, &r.Descr, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("scan instance_forward: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// ListAllInstanceForwards is for the reconcile path — compare every +// panel-tracked forward against opnfwd's view of the world. +func (db *DB) ListAllInstanceForwards(ctx context.Context) ([]InstanceForwardRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT instance_id, port_name, nat_uuid, filter_uuid, target_ip, host_port, proto, descr, created_at + FROM instance_forwards + ORDER BY instance_id, port_name + `) + if err != nil { + return nil, fmt.Errorf("list all instance_forwards: %w", err) + } + defer rows.Close() + var out []InstanceForwardRow + for rows.Next() { + var r InstanceForwardRow + if err := rows.Scan(&r.InstanceID, &r.PortName, &r.NatUUID, &r.FilterUUID, + &r.TargetIP, &r.HostPort, &r.Proto, &r.Descr, &r.CreatedAt); err != nil { + return nil, fmt.Errorf("scan instance_forward: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// DeleteInstanceForwards removes every panel-tracked forward for an +// instance. Called from the delete path AFTER opnfwd's bulk-delete +// returned successfully — this just drops the local mirror. +func (db *DB) DeleteInstanceForwards(ctx context.Context, instanceID string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM instance_forwards WHERE instance_id = $1`, instanceID) + if err != nil { + return fmt.Errorf("delete instance_forwards: %w", err) + } + return nil +} diff --git a/controller/internal/db/instances.go b/controller/internal/db/instances.go new file mode 100644 index 00000000..bdfc955b --- /dev/null +++ b/controller/internal/db/instances.go @@ -0,0 +1,191 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// InstanceRow is the persisted form of an instance record. +type InstanceRow struct { + InstanceID string + AgentID string + ModuleID string + ModuleVersion string + RunMode string + DataPath string + ConfigValues map[string]string + // AssignedPorts maps manifest port name → host port the panel + // allocated. Persisted so stopped instances still reserve their + // slots — net.Listen probing can't see ports for instances that + // aren't currently running, and would otherwise hand the same + // port to a new instance. + AssignedPorts map[string]uint32 + Status string // 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'updating' + LastExitCode int32 + Detail string + CreatedAt time.Time + UpdatedAt time.Time +} + +// UpsertInstance inserts or updates an instance by instance_id. Used from +// CreateInstance — it's idempotent so retry is safe. +func (db *DB) UpsertInstance(ctx context.Context, r InstanceRow) error { + cfgJSON, err := json.Marshal(r.ConfigValues) + if err != nil { + return fmt.Errorf("marshal config_values: %w", err) + } + if r.AssignedPorts == nil { + r.AssignedPorts = map[string]uint32{} + } + portsJSON, err := json.Marshal(r.AssignedPorts) + if err != nil { + return fmt.Errorf("marshal assigned_ports: %w", err) + } + _, err = db.pool.Exec(ctx, ` + INSERT INTO instances + (instance_id, agent_id, module_id, module_version, run_mode, data_path, config_values, assigned_ports, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (instance_id) DO UPDATE SET + agent_id = EXCLUDED.agent_id, + module_id = EXCLUDED.module_id, + module_version = EXCLUDED.module_version, + run_mode = EXCLUDED.run_mode, + data_path = EXCLUDED.data_path, + config_values = EXCLUDED.config_values, + assigned_ports = EXCLUDED.assigned_ports, + status = EXCLUDED.status, + updated_at = NOW() + `, r.InstanceID, r.AgentID, r.ModuleID, r.ModuleVersion, r.RunMode, r.DataPath, cfgJSON, portsJSON, r.Status) + if err != nil { + return fmt.Errorf("upsert instance: %w", err) + } + return nil +} + +// UpdateInstanceConfigValues replaces ONLY the config_values column. Used by +// the durable config-render path, which must not touch status / data_path / +// assigned_ports the way the full UpsertInstance does (a config save racing +// an instance start could otherwise stomp a live status transition). +func (db *DB) UpdateInstanceConfigValues(ctx context.Context, instanceID string, values map[string]string) error { + cfgJSON, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("marshal config_values: %w", err) + } + _, err = db.pool.Exec(ctx, ` + UPDATE instances SET config_values = $2, updated_at = NOW() + WHERE instance_id = $1 + `, instanceID, cfgJSON) + if err != nil { + return fmt.Errorf("update instance config_values: %w", err) + } + return nil +} + +// UpdateInstanceStatus records a status transition from an InstanceStateUpdate. +// updated_at is only bumped when the status actually changed — re-announces +// from agent reconnects (e.g. controller restart triggering an announce loop) +// would otherwise reset the timestamp every time, which breaks the frontend's +// "instance has been running for >10 min" heuristic for clearing the +// "starting" overlay on log-only modules. +func (db *DB) UpdateInstanceStatus(ctx context.Context, instanceID, status string, exitCode int32, detail string) error { + _, err := db.pool.Exec(ctx, ` + UPDATE instances + SET status = $2, + last_exit_code = $3, + detail = $4, + updated_at = CASE WHEN status IS DISTINCT FROM $2 THEN NOW() ELSE updated_at END + WHERE instance_id = $1 + `, instanceID, status, exitCode, detail) + if err != nil { + return fmt.Errorf("update instance status: %w", err) + } + return nil +} + +// DeleteInstance is not yet used; included so the schema doesn't grow a +// parallel soft-delete pattern before we need one. Call when an operator +// permanently removes an instance. +func (db *DB) DeleteInstance(ctx context.Context, instanceID string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM instances WHERE instance_id = $1`, instanceID) + if err != nil { + return fmt.Errorf("delete instance: %w", err) + } + return nil +} + +// GetInstanceRow returns one instance row by ID, or an error if missing. +func (db *DB) GetInstanceRow(ctx context.Context, instanceID string) (InstanceRow, error) { + const q = ` + SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''), + run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status, + COALESCE(last_exit_code, 0), COALESCE(detail, ''), + created_at, updated_at + FROM instances WHERE instance_id = $1 + ` + var r InstanceRow + var cfgJSON, portsJSON []byte + err := db.pool.QueryRow(ctx, q, instanceID).Scan( + &r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion, + &r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status, + &r.LastExitCode, &r.Detail, + &r.CreatedAt, &r.UpdatedAt, + ) + if err != nil { + return r, fmt.Errorf("get instance %q: %w", instanceID, err) + } + if len(cfgJSON) > 0 { + _ = json.Unmarshal(cfgJSON, &r.ConfigValues) + } + if len(portsJSON) > 0 { + _ = json.Unmarshal(portsJSON, &r.AssignedPorts) + } + return r, nil +} + +// ListInstances returns all instances, optionally filtered by agent_id. +func (db *DB) ListInstances(ctx context.Context, agentFilter string) ([]InstanceRow, error) { + var rows pgx.Rows + var err error + const selectAll = ` + SELECT instance_id, agent_id, module_id, COALESCE(module_version, ''), + run_mode, COALESCE(data_path, ''), config_values, assigned_ports, status, + COALESCE(last_exit_code, 0), COALESCE(detail, ''), + created_at, updated_at + FROM instances + ` + if agentFilter != "" { + rows, err = db.pool.Query(ctx, selectAll+` WHERE agent_id = $1 ORDER BY instance_id`, agentFilter) + } else { + rows, err = db.pool.Query(ctx, selectAll+` ORDER BY instance_id`) + } + if err != nil { + return nil, fmt.Errorf("list instances: %w", err) + } + defer rows.Close() + + var out []InstanceRow + for rows.Next() { + var r InstanceRow + var cfgJSON, portsJSON []byte + if err := rows.Scan( + &r.InstanceID, &r.AgentID, &r.ModuleID, &r.ModuleVersion, + &r.RunMode, &r.DataPath, &cfgJSON, &portsJSON, &r.Status, + &r.LastExitCode, &r.Detail, + &r.CreatedAt, &r.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan instance: %w", err) + } + if len(cfgJSON) > 0 { + _ = json.Unmarshal(cfgJSON, &r.ConfigValues) + } + if len(portsJSON) > 0 { + _ = json.Unmarshal(portsJSON, &r.AssignedPorts) + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/controller/internal/db/migrate.go b/controller/internal/db/migrate.go new file mode 100644 index 00000000..566ff2d3 --- /dev/null +++ b/controller/internal/db/migrate.go @@ -0,0 +1,95 @@ +package db + +import ( + "context" + "embed" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// migrateLockKey is the pg advisory-lock key serializing Migrate across +// controllers sharing one database. Arbitrary but fixed: fnv-ish of +// "panel:schema_migrations". Session-scoped (not transaction-scoped) so it +// spans the whole multi-transaction run and releases on disconnect if the +// process dies mid-migrate. +const migrateLockKey int64 = 0x70616e656c6d6967 // "panelmig" + +// Migrate applies any pending SQL files in migrations/ in filename-sorted +// order. Each file is wrapped in a transaction alongside its schema_migrations +// insert so failure leaves the DB in a consistent state. A session-level +// pg advisory lock serializes concurrent controllers: the second one blocks +// here, then finds every version already recorded and no-ops. +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + // Dedicated connection: session advisory locks belong to a connection, + // and pool.Exec could lock on one conn and unlock on another. + lockConn, err := pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("acquire migrate lock conn: %w", err) + } + defer lockConn.Release() + if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockKey); err != nil { + return fmt.Errorf("acquire migrate advisory lock: %w", err) + } + defer func() { + _, _ = lockConn.Exec(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrateLockKey) + }() + + if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read embedded migrations: %w", err) + } + var files []string + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) + + for _, name := range files { + var exists bool + if err := pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name, + ).Scan(&exists); err != nil { + return fmt.Errorf("check %s: %w", name, err) + } + if exists { + continue + } + + body, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read %s: %w", name, err) + } + + tx, err := pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx for %s: %w", name, err) + } + if _, err := tx.Exec(ctx, string(body)); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("apply %s: %w", name, err) + } + if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, name); err != nil { + _ = tx.Rollback(ctx) + return fmt.Errorf("record %s: %w", name, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit %s: %w", name, err) + } + } + return nil +} diff --git a/controller/internal/db/migrate_test.go b/controller/internal/db/migrate_test.go new file mode 100644 index 00000000..6387249e --- /dev/null +++ b/controller/internal/db/migrate_test.go @@ -0,0 +1,49 @@ +package db + +import ( + "context" + "os" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// TestMigrateConcurrent runs Migrate from two goroutines against a real +// Postgres and asserts neither errors (the advisory lock serializes them; +// the loser sees every version recorded and no-ops). Requires a live DB: +// set PANEL_TEST_DSN to run, e.g. +// +// PANEL_TEST_DSN=postgres://user:pw@localhost:5432/panel_test go test ./controller/internal/db/ +// +// Skipped otherwise — no Postgres is assumed on dev machines or CI. +func TestMigrateConcurrent(t *testing.T) { + dsn := os.Getenv("PANEL_TEST_DSN") + if dsn == "" { + t.Skip("PANEL_TEST_DSN not set; migration lock test needs a live Postgres") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + var wg sync.WaitGroup + errs := make([]error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = Migrate(ctx, pool) + }(i) + } + wg.Wait() + for i, e := range errs { + if e != nil { + t.Errorf("concurrent Migrate %d: %v", i, e) + } + } +} diff --git a/controller/internal/db/migrations/001_init.sql b/controller/internal/db/migrations/001_init.sql new file mode 100644 index 00000000..cf5c4cd9 --- /dev/null +++ b/controller/internal/db/migrations/001_init.sql @@ -0,0 +1,39 @@ +-- 001_init.sql — initial schema +-- +-- agents: one row per Target agent the controller has ever seen. Updated +-- with each connection + heartbeat. Serves as audit trail — we +-- never delete rows, so an agent disappearing from the network +-- doesn't wipe its history. +-- instances: one row per declared game server. The controller, not the +-- agent, is the source of truth for "instance X exists". Status +-- is updated from InstanceStateUpdate messages streamed up from +-- the agent that owns the instance. + +CREATE TABLE agents ( + agent_id TEXT PRIMARY KEY, + version TEXT NOT NULL, + host_os TEXT NOT NULL, + host_arch TEXT NOT NULL, + hostname TEXT NOT NULL, + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE instances ( + instance_id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + module_id TEXT NOT NULL, + module_version TEXT, + run_mode TEXT NOT NULL DEFAULT 'docker', + data_path TEXT, + config_values JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'stopped', + last_exit_code INTEGER, + detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_instances_agent ON instances(agent_id); +CREATE INDEX idx_instances_module ON instances(module_id); +CREATE INDEX idx_instances_status ON instances(status); diff --git a/controller/internal/db/migrations/002_schedules.sql b/controller/internal/db/migrations/002_schedules.sql new file mode 100644 index 00000000..4ce6e415 --- /dev/null +++ b/controller/internal/db/migrations/002_schedules.sql @@ -0,0 +1,29 @@ +-- 002_schedules.sql — timed / event-driven actions on instances. +-- +-- For the MVP only cron triggers are supported; trigger_kind leaves room +-- for future 'event' / 'composite' without a schema change. +-- +-- action is JSONB so new action types (webhook, exec, etc.) can be added +-- without migrations. The controller's scheduler engine validates shape +-- at dispatch time. +-- +-- last_fired_at / last_status let the UI show whether a schedule is +-- actually doing what it says. + +CREATE TABLE schedules ( + id TEXT PRIMARY KEY, + instance_id TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + trigger_kind TEXT NOT NULL DEFAULT 'cron', + cron_spec TEXT NOT NULL, + action JSONB NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_fired_at TIMESTAMPTZ, + last_status TEXT, + last_detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_schedules_instance ON schedules(instance_id); +CREATE INDEX idx_schedules_enabled ON schedules(enabled) WHERE enabled = TRUE; diff --git a/controller/internal/db/migrations/003_users.sql b/controller/internal/db/migrations/003_users.sql new file mode 100644 index 00000000..8bb60a7a --- /dev/null +++ b/controller/internal/db/migrations/003_users.sql @@ -0,0 +1,29 @@ +-- 003_users.sql — local accounts + session cookies. +-- +-- Password hashes are argon2id in PHC string format: all params live in the +-- hash itself so we can bump time/memory costs without migrating. +-- +-- Roles are coarse for now — admin (full control) / user (read-only). Fine- +-- grained per-resource ACL is a later migration layered on top. + +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + disabled BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, -- 32 bytes hex; random via crypto/rand + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + last_seen_ip TEXT, + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_sessions_expires ON sessions(expires_at); diff --git a/controller/internal/db/migrations/004_event_triggers.sql b/controller/internal/db/migrations/004_event_triggers.sql new file mode 100644 index 00000000..756f39af --- /dev/null +++ b/controller/internal/db/migrations/004_event_triggers.sql @@ -0,0 +1,20 @@ +-- 004_event_triggers.sql — event-driven schedule triggers. +-- +-- Until now schedules only supported cron. This migration adds: +-- * trigger_kind = 'event' schedules, powered by a CEL expression over +-- the event bus. Example: players_online == 0 +-- * optional sustained-state: fire only after the condition has been +-- continuously true for N seconds. Enables "idle shutdown if 0 players +-- for 30m" without firing every 60s when the polled player count is 0. +-- +-- cron_spec becomes nullable so event triggers can exist with NULL there. +-- Exactly one of cron_spec / event_spec is populated; the application +-- enforces this. + +ALTER TABLE schedules ALTER COLUMN cron_spec DROP NOT NULL; + +ALTER TABLE schedules + ADD COLUMN event_spec TEXT, + ADD COLUMN event_sustained_seconds INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX idx_schedules_trigger_kind ON schedules(trigger_kind); diff --git a/controller/internal/db/migrations/005_backups.sql b/controller/internal/db/migrations/005_backups.sql new file mode 100644 index 00000000..78c38522 --- /dev/null +++ b/controller/internal/db/migrations/005_backups.sql @@ -0,0 +1,18 @@ +-- 005_backups.sql — instance backups (tar.gz of volumes). +-- +-- The agent writes the archive to disk under its --backup-dir and +-- reports back path + size. Controller persists the row so operators +-- can list / restore / delete later. + +CREATE TABLE backups ( + id TEXT PRIMARY KEY, -- bkp_ + instance_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + path TEXT NOT NULL, -- absolute path on the agent + size_bytes BIGINT NOT NULL DEFAULT 0, + description TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_backups_instance ON backups(instance_id); +CREATE INDEX idx_backups_created ON backups(created_at DESC); diff --git a/controller/internal/db/migrations/006_pair_tokens.sql b/controller/internal/db/migrations/006_pair_tokens.sql new file mode 100644 index 00000000..93bce592 --- /dev/null +++ b/controller/internal/db/migrations/006_pair_tokens.sql @@ -0,0 +1,21 @@ +-- 006_pair_tokens.sql — pairing tokens for agent mTLS onboarding. +-- +-- An operator generates a token, hands it to a new agent, the agent uses +-- it exactly once to fetch a signed client cert. Tokens expire fast +-- (default 15 min) to limit damage from leaked tokens. token_hash is +-- sha256 of the raw token — we never store the raw value. + +CREATE TABLE pair_tokens ( + id TEXT PRIMARY KEY, -- pt_ + token_hash TEXT NOT NULL UNIQUE, -- sha256 hex of the raw token + description TEXT NOT NULL DEFAULT '', + created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + used_by_agent TEXT, -- agent_id that consumed it + issued_cert_fingerprint TEXT +); + +CREATE INDEX idx_pair_tokens_hash ON pair_tokens(token_hash); +CREATE INDEX idx_pair_tokens_expires ON pair_tokens(expires_at); diff --git a/controller/internal/db/migrations/007_agent_labels.sql b/controller/internal/db/migrations/007_agent_labels.sql new file mode 100644 index 00000000..e5c998ab --- /dev/null +++ b/controller/internal/db/migrations/007_agent_labels.sql @@ -0,0 +1,8 @@ +-- 007_agent_labels.sql — operator-set display metadata for agents. +-- +-- agent_id is the cryptographic identity (fixed, used in cert CN). Label +-- is a friendly name the operator assigns + can change. Notes is free-form. + +ALTER TABLE agents + ADD COLUMN label TEXT NOT NULL DEFAULT '', + ADD COLUMN notes TEXT NOT NULL DEFAULT ''; diff --git a/controller/internal/db/migrations/008_steam_credentials.sql b/controller/internal/db/migrations/008_steam_credentials.sql new file mode 100644 index 00000000..e571d69f --- /dev/null +++ b/controller/internal/db/migrations/008_steam_credentials.sql @@ -0,0 +1,25 @@ +-- 008_steam_credentials.sql — operator's cached Steam login for SteamCMD. +-- +-- Some games (DayZ app 223350, Arma, a handful of others) require a Steam +-- account that owns the game — anonymous SteamCMD login returns +-- "No subscription". The panel stores one Steam credential per operator +-- (for v1 we only support single-user mode, so a single row is enough). +-- +-- password_ciphertext is AES-GCM encrypted using a key derived from the +-- controller's CA private key via HKDF-SHA256; see auth.go's +-- steamCredentialKey(). The plaintext password never touches disk or logs. +-- +-- sentry_file is the SSFN-style device-auth blob SteamCMD writes after a +-- successful Steam Guard challenge. Storing it lets subsequent logins skip +-- the 2FA prompt. NULL until first successful login. + +CREATE TABLE IF NOT EXISTS steam_credentials ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_ciphertext BYTEA NOT NULL, + password_nonce BYTEA NOT NULL, + sentry_file BYTEA, -- SSFN device-auth blob, filled after successful login + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/controller/internal/db/migrations/009_steam_login.sql b/controller/internal/db/migrations/009_steam_login.sql new file mode 100644 index 00000000..85f82631 --- /dev/null +++ b/controller/internal/db/migrations/009_steam_login.sql @@ -0,0 +1,22 @@ +-- 009_steam_login.sql — Steam OpenID sign-in support. +-- +-- Adds a nullable `steam_id` column to `users` so an admin or operator +-- can log in with their Steam account instead of (or in addition to) +-- email + password. +-- +-- steam_id stores the 17-digit SteamID64 as TEXT (not BIGINT) because +-- while the value fits in uint64, using TEXT avoids any sign-extension +-- surprises across the app / frontend / logs. Unique constraint so one +-- Steam account can only be linked to one panel user. +-- +-- password_hash is made nullable: a user may exist who only signs in via +-- Steam with no local password. The email column stays NOT NULL — even +-- Steam-only users need a panel identity, we auto-fill with the +-- Steam-derived email "steam+@panel.local" when there's nothing +-- better. + +ALTER TABLE users ADD COLUMN steam_id TEXT; +ALTER TABLE users ADD CONSTRAINT users_steam_id_unique UNIQUE (steam_id); +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; + +CREATE INDEX idx_users_steam_id ON users(steam_id) WHERE steam_id IS NOT NULL; diff --git a/controller/internal/db/migrations/010_port_allocation.sql b/controller/internal/db/migrations/010_port_allocation.sql new file mode 100644 index 00000000..2d744de3 --- /dev/null +++ b/controller/internal/db/migrations/010_port_allocation.sql @@ -0,0 +1,24 @@ +-- 010_port_allocation.sql — per-agent port range + per-instance assigned ports +-- +-- Why: probing net.Listen for "is this port free?" only catches CURRENTLY +-- bound ports. Two ARK servers, one running, one stopped — the running +-- one's 7777 binding is detected, but the stopped one's intent isn't. +-- Result: a fresh create assigns 7777 to a new server, then the stopped +-- one fails to start because something else now owns its port. +-- +-- Fix: persist each instance's assigned host ports in the DB, and let +-- operators set a per-agent port range. Allocation now consults BOTH +-- the persisted assignments AND the live probe — whichever rules out +-- a port first wins. + +ALTER TABLE agents + ADD COLUMN port_range_start INTEGER, + ADD COLUMN port_range_end INTEGER; + +-- assigned_ports holds the actual host ports the panel allocated for +-- this instance, keyed by manifest port name. Example: +-- {"game": 7777, "raw": 7778, "rcon": 27020} +-- Stays attached even when the instance is stopped — that's the whole +-- point: stopped instances reserve their ports against the next create. +ALTER TABLE instances + ADD COLUMN assigned_ports JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/controller/internal/db/migrations/011_opnfwd.sql b/controller/internal/db/migrations/011_opnfwd.sql new file mode 100644 index 00000000..122f780f --- /dev/null +++ b/controller/internal/db/migrations/011_opnfwd.sql @@ -0,0 +1,47 @@ +-- 011_opnfwd.sql — wire panel into opnfwd's port-forward HTTP API. +-- +-- Three changes: +-- 1. agents.lan_ip — operator-set LAN IP for this agent. opnfwd's `target` +-- field needs the LAN-side IP of the host running the game container. +-- Defaulted blank; the dashboard's agent edit modal lets the operator +-- fill it in. Without it, the opnfwd integration silently no-ops for +-- instances on that agent (loud log line, no destructive failure). +-- +-- 2. panel_settings — singleton key/value table for instance-wide knobs. +-- Today: opnfwd_enabled / opnfwd_url / opnfwd_token. Future: anything +-- else that's "global, operator-edited, doesn't fit elsewhere." +-- +-- 3. instance_forwards — what the panel believes it owns in opnfwd. We +-- persist the {nat_uuid, filter_uuid} returned by `POST /api/forwards` +-- so we can target opnfwd's bulk-delete on instance delete. Composite +-- PK on (instance_id, port_name) — every public port gets one row. + +ALTER TABLE agents ADD COLUMN IF NOT EXISTS lan_ip TEXT NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS panel_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO panel_settings (key, value) VALUES + ('opnfwd_enabled', 'false'), + ('opnfwd_url', ''), + ('opnfwd_token', '') +ON CONFLICT (key) DO NOTHING; + +CREATE TABLE IF NOT EXISTS instance_forwards ( + instance_id TEXT NOT NULL, + port_name TEXT NOT NULL, + nat_uuid TEXT NOT NULL DEFAULT '', + filter_uuid TEXT NOT NULL DEFAULT '', + target_ip TEXT NOT NULL, + host_port INTEGER NOT NULL, + proto TEXT NOT NULL, + descr TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (instance_id, port_name) +); + +CREATE INDEX IF NOT EXISTS instance_forwards_instance_id_idx + ON instance_forwards(instance_id); diff --git a/controller/internal/db/migrations/012_empyrion.sql b/controller/internal/db/migrations/012_empyrion.sql new file mode 100644 index 00000000..ae0d17c3 --- /dev/null +++ b/controller/internal/db/migrations/012_empyrion.sql @@ -0,0 +1,66 @@ +-- Empyrion EAH-style features. Three tables: +-- +-- 1. empyrion_events — append-only log of unsolicited Event_* packages +-- streamed from the bridge. Powers the player-history view, chat log, +-- and the "what happened while I was away" overview. +-- +-- 2. empyrion_inv_templates — operator-defined inventory kits (name + +-- JSON list of {itemId, count}). Click "Apply" on a player row to +-- drop the whole kit via Request_Player_AddItem fan-out. +-- +-- 3. empyrion_known_players — projected from empyrion_events. Cached +-- last-seen / first-seen / login count for the players-list view so +-- we don't aggregate the events table on every refresh. +-- +-- All tables are scoped by instance_id so two running empyrion instances +-- on the same agent (rare but allowed) keep separate state. + +CREATE TABLE IF NOT EXISTS empyrion_events ( + id BIGSERIAL PRIMARY KEY, + instance_id TEXT NOT NULL, + ts TIMESTAMPTZ NOT NULL DEFAULT NOW(), + cmd TEXT NOT NULL, + player_id INTEGER, + steam_id TEXT, + player_name TEXT, + playfield TEXT, + msg TEXT, + raw JSONB +); +CREATE INDEX IF NOT EXISTS empyrion_events_instance_ts + ON empyrion_events (instance_id, ts DESC); +CREATE INDEX IF NOT EXISTS empyrion_events_player_ts + ON empyrion_events (player_id, ts DESC) WHERE player_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS empyrion_events_steam_ts + ON empyrion_events (steam_id, ts DESC) WHERE steam_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS empyrion_events_cmd + ON empyrion_events (instance_id, cmd, ts DESC); + +CREATE TABLE IF NOT EXISTS empyrion_inv_templates ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + items JSONB NOT NULL DEFAULT '[]'::jsonb, + -- items shape: [{ "itemId": 4307, "count": 1, "ammo": 0, "decay": 0 }, ...] + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS empyrion_inv_templates_name + ON empyrion_inv_templates (name); + +CREATE TABLE IF NOT EXISTS empyrion_known_players ( + instance_id TEXT NOT NULL, + player_id INTEGER NOT NULL, + steam_id TEXT NOT NULL DEFAULT '', + player_name TEXT NOT NULL DEFAULT '', + first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), + login_count INTEGER NOT NULL DEFAULT 0, + last_playfield TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + PRIMARY KEY (instance_id, player_id) +); +CREATE INDEX IF NOT EXISTS empyrion_known_players_steam + ON empyrion_known_players (steam_id) WHERE steam_id != ''; +CREATE INDEX IF NOT EXISTS empyrion_known_players_lastseen + ON empyrion_known_players (instance_id, last_seen DESC); diff --git a/controller/internal/db/migrations/013_empyrion_bp_library.sql b/controller/internal/db/migrations/013_empyrion_bp_library.sql new file mode 100644 index 00000000..ae2ec3f4 --- /dev/null +++ b/controller/internal/db/migrations/013_empyrion_bp_library.sql @@ -0,0 +1,9 @@ +CREATE TABLE empyrion_blueprints ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + uploader TEXT, + notes TEXT +); +CREATE INDEX idx_empyrion_blueprints_name ON empyrion_blueprints(name); diff --git a/controller/internal/db/migrations/014_empyrion_admin.sql b/controller/internal/db/migrations/014_empyrion_admin.sql new file mode 100644 index 00000000..e9e3dd01 --- /dev/null +++ b/controller/internal/db/migrations/014_empyrion_admin.sql @@ -0,0 +1,43 @@ +-- Saved-coordinate book (wdcoordinates_edit equivalent). +CREATE TABLE empyrion_coords ( + id BIGSERIAL PRIMARY KEY, + instance_id TEXT NOT NULL, + name TEXT NOT NULL, + playfield TEXT NOT NULL, + x DOUBLE PRECISION NOT NULL, + y DOUBLE PRECISION NOT NULL, + z DOUBLE PRECISION NOT NULL, + notes TEXT, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (instance_id, name) +); +CREATE INDEX idx_empyrion_coords_instance ON empyrion_coords(instance_id); + +-- Player-warning log (wdwarnings + ctrlplayerwarnings equivalent). +-- Append-only history of admin- or auto-issued warnings. +CREATE TABLE empyrion_warnings ( + id BIGSERIAL PRIMARY KEY, + instance_id TEXT NOT NULL, + player_id INT NOT NULL, + player_name TEXT, + reason TEXT NOT NULL, + given_by TEXT, + auto_rule TEXT, + given_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_empyrion_warnings_player ON empyrion_warnings(instance_id, player_id, given_at DESC); + +-- Configurable warning rules (CEL-driven, matches scheduler_event.go's CEL env). +CREATE TABLE empyrion_warning_rules ( + id TEXT PRIMARY KEY, + instance_id TEXT, -- NULL = applies to all empyrion instances + description TEXT NOT NULL DEFAULT '', + cel TEXT NOT NULL, + warn_message TEXT NOT NULL, + threshold INT NOT NULL DEFAULT 3, + threshold_action TEXT NOT NULL DEFAULT 'kick', -- kick | ban | none + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/controller/internal/db/migrations/015_empyrion_chatbot.sql b/controller/internal/db/migrations/015_empyrion_chatbot.sql new file mode 100644 index 00000000..fa86f71c --- /dev/null +++ b/controller/internal/db/migrations/015_empyrion_chatbot.sql @@ -0,0 +1,19 @@ +-- Chat-bot configuration: per-instance trigger registry +CREATE TABLE empyrion_chatbot_rules ( + id TEXT PRIMARY KEY, + instance_id TEXT, -- NULL = applies to all empyrion instances + command TEXT NOT NULL, -- player types "!" in chat + description TEXT NOT NULL DEFAULT '', + response TEXT NOT NULL, -- supports {playerName} {playerId} {factionId} {playfield} placeholders + channel TEXT NOT NULL DEFAULT 'whisper', -- whisper | global | faction + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (instance_id, command) +); + +-- Seed three default rules (global, applies to all instances) +INSERT INTO empyrion_chatbot_rules (id, instance_id, command, description, response, channel, enabled) VALUES + ('cb_help', NULL, 'help', 'List available commands', 'Bot commands: !help · !coords · !time', 'whisper', true), + ('cb_coords', NULL, 'coords', 'Tell player their coords', 'Your position: {playfield} ({x},{y},{z})', 'whisper', true), + ('cb_time', NULL, 'time', 'Server clock', 'Server time: {now}', 'whisper', true); diff --git a/controller/internal/db/migrations/016_update_checks.sql b/controller/internal/db/migrations/016_update_checks.sql new file mode 100644 index 00000000..a649c7b8 --- /dev/null +++ b/controller/internal/db/migrations/016_update_checks.sql @@ -0,0 +1,17 @@ +-- 016_update_checks.sql — WI-14 "update available" detection. +-- +-- One row per instance: the result of the last manual update check +-- (installed buildid from the Steam ACF vs the latest buildid for the +-- instance's branch from steamcmd app_info). CHECK-ONLY bookkeeping — +-- nothing in the panel acts on this table automatically. + +CREATE TABLE IF NOT EXISTS update_checks ( + instance_id TEXT PRIMARY KEY, + app_id TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT '', + installed_buildid TEXT NOT NULL DEFAULT '', + latest_buildid TEXT NOT NULL DEFAULT '', + update_available BOOLEAN NOT NULL DEFAULT FALSE, + error TEXT NOT NULL DEFAULT '', + checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/controller/internal/db/pair_tokens.go b/controller/internal/db/pair_tokens.go new file mode 100644 index 00000000..9f8444ba --- /dev/null +++ b/controller/internal/db/pair_tokens.go @@ -0,0 +1,79 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +type PairTokenRow struct { + ID string + TokenHash string + Description string + CreatedBy int64 + CreatedAt time.Time + ExpiresAt time.Time + UsedAt *time.Time + UsedByAgent string +} + +var ErrPairTokenNotFound = errors.New("pairing token not found or already used") + +func (db *DB) CreatePairToken(ctx context.Context, r PairTokenRow) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO pair_tokens (id, token_hash, description, created_by, expires_at) + VALUES ($1, $2, $3, $4, $5) + `, r.ID, r.TokenHash, r.Description, r.CreatedBy, r.ExpiresAt) + if err != nil { + return fmt.Errorf("insert pair_token: %w", err) + } + return nil +} + +// ConsumePairToken looks up a token by its hash, validates that it's +// unused + unexpired, and atomically marks it consumed. Returns the row +// on success. Returns ErrPairTokenNotFound if no such valid token. +func (db *DB) ConsumePairToken(ctx context.Context, hash, agentID, fingerprint string) (*PairTokenRow, error) { + var r PairTokenRow + err := db.pool.QueryRow(ctx, ` + UPDATE pair_tokens + SET used_at = NOW(), used_by_agent = $2, issued_cert_fingerprint = $3 + WHERE token_hash = $1 + AND used_at IS NULL + AND expires_at > NOW() + RETURNING id, token_hash, description, created_by, created_at, expires_at + `, hash, agentID, fingerprint).Scan( + &r.ID, &r.TokenHash, &r.Description, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrPairTokenNotFound + } + if err != nil { + return nil, fmt.Errorf("consume pair_token: %w", err) + } + return &r, nil +} + +func (db *DB) ListPairTokens(ctx context.Context) ([]PairTokenRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, token_hash, description, created_by, created_at, expires_at, used_at, COALESCE(used_by_agent, '') + FROM pair_tokens + ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []PairTokenRow + for rows.Next() { + var r PairTokenRow + if err := rows.Scan(&r.ID, &r.TokenHash, &r.Description, &r.CreatedBy, &r.CreatedAt, &r.ExpiresAt, &r.UsedAt, &r.UsedByAgent); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} diff --git a/controller/internal/db/panel_settings.go b/controller/internal/db/panel_settings.go new file mode 100644 index 00000000..6de27400 --- /dev/null +++ b/controller/internal/db/panel_settings.go @@ -0,0 +1,53 @@ +package db + +import ( + "context" + "fmt" +) + +// GetSetting returns the value of a panel_settings key, or empty string if +// the key isn't present (caller treats empty as "unset / disabled / default"). +func (db *DB) GetSetting(ctx context.Context, key string) (string, error) { + var v string + err := db.pool.QueryRow(ctx, `SELECT value FROM panel_settings WHERE key = $1`, key).Scan(&v) + if err != nil { + // pgx returns ErrNoRows when key missing — surface as empty, not error. + // Settings are always optional from the caller's POV. + if err.Error() == "no rows in result set" { + return "", nil + } + return "", fmt.Errorf("get setting %q: %w", key, err) + } + return v, nil +} + +// SetSetting upserts a panel_settings row. +func (db *DB) SetSetting(ctx context.Context, key, value string) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO panel_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + `, key, value) + if err != nil { + return fmt.Errorf("set setting %q: %w", key, err) + } + return nil +} + +// ListSettings dumps every key→value, used by the admin settings page to +// populate fields. Keep this small — it's not paginated. +func (db *DB) ListSettings(ctx context.Context) (map[string]string, error) { + rows, err := db.pool.Query(ctx, `SELECT key, value FROM panel_settings`) + if err != nil { + return nil, fmt.Errorf("list settings: %w", err) + } + defer rows.Close() + out := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("scan setting: %w", err) + } + out[k] = v + } + return out, rows.Err() +} diff --git a/controller/internal/db/retention.go b/controller/internal/db/retention.go new file mode 100644 index 00000000..201e3d15 --- /dev/null +++ b/controller/internal/db/retention.go @@ -0,0 +1,25 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +// PruneEmpyrionEvents deletes empyrion_events rows older than keep. +// empyrion_events is append-only (one row per bridge Event_* package) and +// grows without bound on a busy server; the history/activity views only +// ever page over recent windows. Returns the number of rows deleted. +// +// empyrion_known_players is NOT pruned here: it is already compact — one +// upserted row per (instance_id, player_id) carrying login_count and +// first/last_seen aggregates the players view depends on. +func (db *DB) PruneEmpyrionEvents(ctx context.Context, keep time.Duration) (int64, error) { + tag, err := db.pool.Exec(ctx, + `DELETE FROM empyrion_events WHERE ts < NOW() - $1::interval`, + fmt.Sprintf("%d seconds", int64(keep.Seconds()))) + if err != nil { + return 0, fmt.Errorf("prune empyrion_events: %w", err) + } + return tag.RowsAffected(), nil +} diff --git a/controller/internal/db/schedules.go b/controller/internal/db/schedules.go new file mode 100644 index 00000000..bce033ae --- /dev/null +++ b/controller/internal/db/schedules.go @@ -0,0 +1,139 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// ScheduleRow is the persisted form of a schedule. +type ScheduleRow struct { + ID string + InstanceID string + Description string + TriggerKind string // "cron" | "event" + CronSpec string // populated for TriggerKind == "cron" + EventSpec string // populated for TriggerKind == "event" — CEL expression returning bool + EventSustainedSeconds int32 // for event: fire only after condition true this many seconds + Action json.RawMessage + Enabled bool + LastFiredAt *time.Time + LastStatus string + LastDetail string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (db *DB) InsertSchedule(ctx context.Context, r ScheduleRow) error { + var cronSpec, eventSpec *string + if r.CronSpec != "" { + cronSpec = &r.CronSpec + } + if r.EventSpec != "" { + eventSpec = &r.EventSpec + } + _, err := db.pool.Exec(ctx, ` + INSERT INTO schedules (id, instance_id, description, trigger_kind, cron_spec, event_spec, event_sustained_seconds, action, enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `, r.ID, r.InstanceID, r.Description, r.TriggerKind, cronSpec, eventSpec, r.EventSustainedSeconds, []byte(r.Action), r.Enabled) + if err != nil { + return fmt.Errorf("insert schedule: %w", err) + } + return nil +} + +func (db *DB) DeleteSchedule(ctx context.Context, id string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM schedules WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete schedule: %w", err) + } + return nil +} + +func (db *DB) SetScheduleEnabled(ctx context.Context, id string, enabled bool) error { + _, err := db.pool.Exec(ctx, `UPDATE schedules SET enabled = $2, updated_at = NOW() WHERE id = $1`, id, enabled) + if err != nil { + return fmt.Errorf("set schedule enabled: %w", err) + } + return nil +} + +// UpdateSchedule persists changes to an existing schedule row. +func (db *DB) UpdateSchedule(ctx context.Context, r ScheduleRow) error { + _, err := db.pool.Exec(ctx, ` + UPDATE schedules + SET description = $2, + trigger_kind = $3, + cron_spec = $4, + event_spec = $5, + event_sustained_seconds = $6, + action = $7, + enabled = $8, + updated_at = NOW() + WHERE id = $1`, + r.ID, r.Description, r.TriggerKind, r.CronSpec, r.EventSpec, r.EventSustainedSeconds, []byte(r.Action), r.Enabled) + if err != nil { + return fmt.Errorf("update schedule: %w", err) + } + return nil +} + +// UpdateScheduleFireResult records the outcome of a trigger fire — used by +// the engine to surface success/failure in the UI. +func (db *DB) UpdateScheduleFireResult(ctx context.Context, id, status, detail string) error { + _, err := db.pool.Exec(ctx, ` + UPDATE schedules + SET last_fired_at = NOW(), last_status = $2, last_detail = $3, updated_at = NOW() + WHERE id = $1 + `, id, status, detail) + if err != nil { + return fmt.Errorf("update schedule result: %w", err) + } + return nil +} + +// ListSchedules returns every schedule, optionally filtered by instance_id. +func (db *DB) ListSchedules(ctx context.Context, instanceFilter string) ([]ScheduleRow, error) { + const selectAll = ` + SELECT id, instance_id, description, trigger_kind, + COALESCE(cron_spec, ''), COALESCE(event_spec, ''), event_sustained_seconds, + action, + enabled, last_fired_at, COALESCE(last_status, ''), COALESCE(last_detail, ''), + created_at, updated_at + FROM schedules + ` + var rows pgx.Rows + var err error + if instanceFilter != "" { + rows, err = db.pool.Query(ctx, selectAll+` WHERE instance_id = $1 ORDER BY id`, instanceFilter) + } else { + rows, err = db.pool.Query(ctx, selectAll+` ORDER BY instance_id, id`) + } + if err != nil { + return nil, fmt.Errorf("list schedules: %w", err) + } + defer rows.Close() + + var out []ScheduleRow + for rows.Next() { + var r ScheduleRow + var action []byte + var lastFired *time.Time + if err := rows.Scan( + &r.ID, &r.InstanceID, &r.Description, &r.TriggerKind, + &r.CronSpec, &r.EventSpec, &r.EventSustainedSeconds, + &action, + &r.Enabled, &lastFired, &r.LastStatus, &r.LastDetail, + &r.CreatedAt, &r.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan schedule: %w", err) + } + r.Action = action + r.LastFiredAt = lastFired + out = append(out, r) + } + return out, rows.Err() +} diff --git a/controller/internal/db/steam_credentials.go b/controller/internal/db/steam_credentials.go new file mode 100644 index 00000000..bfb630da --- /dev/null +++ b/controller/internal/db/steam_credentials.go @@ -0,0 +1,124 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// SteamCredentialRow is a single stored Steam account for SteamCMD +// non-anonymous login. v1 is single-operator, so most panels will have +// at most one row. +type SteamCredentialRow struct { + ID int64 + Username string + PasswordCiphertext []byte // AES-GCM ciphertext of the password + PasswordNonce []byte // AES-GCM nonce (12 bytes) + SentryFile []byte // SSFN device-auth blob; NULL until first success + LastUsedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// GetSteamCredentialByUsername returns the stored credential for the given +// username or pgx.ErrNoRows if none exists. +func (db *DB) GetSteamCredentialByUsername(ctx context.Context, username string) (*SteamCredentialRow, error) { + row := db.pool.QueryRow(ctx, ` + SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at + FROM steam_credentials + WHERE username = $1`, username) + var r SteamCredentialRow + if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + return &r, nil +} + +// GetAnySteamCredential returns the most-recently-used Steam credential, or +// pgx.ErrNoRows if none exist. Used when the caller doesn't care which +// account (single-operator panels typically have one). +func (db *DB) GetAnySteamCredential(ctx context.Context) (*SteamCredentialRow, error) { + row := db.pool.QueryRow(ctx, ` + SELECT id, username, password_ciphertext, password_nonce, sentry_file, last_used_at, created_at, updated_at + FROM steam_credentials + ORDER BY COALESCE(last_used_at, created_at) DESC + LIMIT 1`) + var r SteamCredentialRow + if err := row.Scan(&r.ID, &r.Username, &r.PasswordCiphertext, &r.PasswordNonce, &r.SentryFile, &r.LastUsedAt, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + return &r, nil +} + +// UpsertSteamCredential stores (or replaces) a Steam credential. Leaves +// sentry_file untouched on update — that's populated separately by +// UpdateSteamSentryFile once a SteamCMD login succeeds. +func (db *DB) UpsertSteamCredential(ctx context.Context, username string, cipher, nonce []byte) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO steam_credentials (username, password_ciphertext, password_nonce, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (username) + DO UPDATE SET password_ciphertext = EXCLUDED.password_ciphertext, + password_nonce = EXCLUDED.password_nonce, + updated_at = NOW()`, + username, cipher, nonce) + if err != nil { + return fmt.Errorf("upsert steam credential: %w", err) + } + return nil +} + +// UpdateSteamSentryFile stores the SSFN blob after a successful login so +// subsequent SteamCMD invocations can skip the Steam Guard 2FA prompt. +func (db *DB) UpdateSteamSentryFile(ctx context.Context, username string, sentry []byte) error { + res, err := db.pool.Exec(ctx, ` + UPDATE steam_credentials + SET sentry_file = $2, last_used_at = NOW(), updated_at = NOW() + WHERE username = $1`, + username, sentry) + if err != nil { + return fmt.Errorf("update sentry: %w", err) + } + if res.RowsAffected() == 0 { + return errors.New("steam credential not found") + } + return nil +} + +// TouchSteamCredential updates last_used_at without writing a new sentry. +func (db *DB) TouchSteamCredential(ctx context.Context, username string) error { + _, err := db.pool.Exec(ctx, `UPDATE steam_credentials SET last_used_at = NOW() WHERE username = $1`, username) + return err +} + +// DeleteSteamCredential removes a cached credential (operator action). +func (db *DB) DeleteSteamCredential(ctx context.Context, username string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM steam_credentials WHERE username = $1`, username) + return err +} + +// ListSteamCredentialUsernames returns just the usernames (no password +// material) so the UI can show "Steam accounts configured: X" without +// touching the secrets. +func (db *DB) ListSteamCredentialUsernames(ctx context.Context) ([]string, error) { + rows, err := db.pool.Query(ctx, `SELECT username FROM steam_credentials ORDER BY username`) + if err != nil { + return nil, err + } + defer rows.Close() + var names []string + for rows.Next() { + var u string + if err := rows.Scan(&u); err != nil { + return nil, err + } + names = append(names, u) + } + return names, rows.Err() +} + +// avoid unused-import removal +var _ = pgx.ErrNoRows diff --git a/controller/internal/db/update_checks.go b/controller/internal/db/update_checks.go new file mode 100644 index 00000000..29242221 --- /dev/null +++ b/controller/internal/db/update_checks.go @@ -0,0 +1,92 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// UpdateCheckRow is the persisted result of the last update-available +// check for an instance (WI-14). CHECK-ONLY bookkeeping. +type UpdateCheckRow struct { + InstanceID string + AppID string + Branch string + InstalledBuildID string + LatestBuildID string + UpdateAvailable bool + Error string + CheckedAt time.Time +} + +// UpsertUpdateCheck records the latest check result for an instance. +func (db *DB) UpsertUpdateCheck(ctx context.Context, r UpdateCheckRow) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO update_checks (instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (instance_id) DO UPDATE SET + app_id = EXCLUDED.app_id, + branch = EXCLUDED.branch, + installed_buildid = EXCLUDED.installed_buildid, + latest_buildid = EXCLUDED.latest_buildid, + update_available = EXCLUDED.update_available, + error = EXCLUDED.error, + checked_at = NOW() + `, r.InstanceID, r.AppID, r.Branch, r.InstalledBuildID, r.LatestBuildID, r.UpdateAvailable, r.Error) + if err != nil { + return fmt.Errorf("upsert update check: %w", err) + } + return nil +} + +// GetUpdateCheck returns the last stored check for an instance, or +// (nil, nil) when the instance has never been checked. +func (db *DB) GetUpdateCheck(ctx context.Context, instanceID string) (*UpdateCheckRow, error) { + row := db.pool.QueryRow(ctx, ` + SELECT instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at + FROM update_checks WHERE instance_id = $1 + `, instanceID) + var r UpdateCheckRow + err := row.Scan(&r.InstanceID, &r.AppID, &r.Branch, &r.InstalledBuildID, &r.LatestBuildID, &r.UpdateAvailable, &r.Error, &r.CheckedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get update check: %w", err) + } + return &r, nil +} + +// ListUpdateChecks returns every stored check, keyed for the dashboard's +// "Update available" badges. +func (db *DB) ListUpdateChecks(ctx context.Context) ([]UpdateCheckRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT instance_id, app_id, branch, installed_buildid, latest_buildid, update_available, error, checked_at + FROM update_checks ORDER BY instance_id + `) + if err != nil { + return nil, fmt.Errorf("list update checks: %w", err) + } + defer rows.Close() + var out []UpdateCheckRow + for rows.Next() { + var r UpdateCheckRow + if err := rows.Scan(&r.InstanceID, &r.AppID, &r.Branch, &r.InstalledBuildID, &r.LatestBuildID, &r.UpdateAvailable, &r.Error, &r.CheckedAt); err != nil { + return nil, fmt.Errorf("scan update check: %w", err) + } + out = append(out, r) + } + return out, rows.Err() +} + +// DeleteUpdateCheck removes an instance's row (instance deleted). +func (db *DB) DeleteUpdateCheck(ctx context.Context, instanceID string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM update_checks WHERE instance_id = $1`, instanceID) + if err != nil { + return fmt.Errorf("delete update check: %w", err) + } + return nil +} diff --git a/controller/internal/db/users.go b/controller/internal/db/users.go new file mode 100644 index 00000000..89c4a4ce --- /dev/null +++ b/controller/internal/db/users.go @@ -0,0 +1,259 @@ +package db + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// UserRow is a user record. +type UserRow struct { + ID int64 + Email string + PasswordHash string // empty for Steam-only users + SteamID string // SteamID64 (17-digit); empty if not linked + Role string // "admin" | "user" + Disabled bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// SessionRow is a valid (or expired) browser session. +type SessionRow struct { + Token string + UserID int64 + CreatedAt time.Time + ExpiresAt time.Time + LastSeenIP string + LastSeenAt time.Time +} + +func (db *DB) CountUsers(ctx context.Context) (int, error) { + var n int + if err := db.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + return 0, fmt.Errorf("count users: %w", err) + } + return n, nil +} + +func (db *DB) CreateUser(ctx context.Context, email, passwordHash, role string) (int64, error) { + var id int64 + err := db.pool.QueryRow(ctx, ` + INSERT INTO users (email, password_hash, role) + VALUES ($1, $2, $3) + RETURNING id + `, email, passwordHash, role).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create user: %w", err) + } + return id, nil +} + +// CreateSteamUser provisions a new panel user with only a SteamID +// linked — no password, sign-in via Steam OpenID only. Used by the +// admin-side "Add user by SteamID" flow so a teammate can be granted +// access without giving them a panel email/password. +func (db *DB) CreateSteamUser(ctx context.Context, email, steamID, role string) (int64, error) { + var id int64 + err := db.pool.QueryRow(ctx, ` + INSERT INTO users (email, password_hash, steam_id, role) + VALUES ($1, NULL, $2, $3) + RETURNING id + `, email, steamID, role).Scan(&id) + if err != nil { + return 0, fmt.Errorf("create steam user: %w", err) + } + return id, nil +} + +// ErrUserNotFound / ErrSessionNotFound are sentinel errors for the middleware +// to distinguish "not logged in" from real DB errors. +var ( + ErrUserNotFound = errors.New("user not found") + ErrSessionNotFound = errors.New("session not found") +) + +// scanSteamAware is the generic row scan that handles the nullable +// password_hash + steam_id columns the 009 migration introduced. Used by +// every GetUser* helper so their SQL SELECT clauses stay aligned. +func scanSteamAware(row pgx.Row, u *UserRow) error { + var pwHash, steamID *string + err := row.Scan(&u.ID, &u.Email, &pwHash, &steamID, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + return err + } + if pwHash != nil { + u.PasswordHash = *pwHash + } + if steamID != nil { + u.SteamID = *steamID + } + return nil +} + +func (db *DB) GetUserByEmail(ctx context.Context, email string) (*UserRow, error) { + var u UserRow + err := scanSteamAware(db.pool.QueryRow(ctx, ` + SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at + FROM users WHERE email = $1 + `, email), &u) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return &u, nil +} + +func (db *DB) GetUserByID(ctx context.Context, id int64) (*UserRow, error) { + var u UserRow + err := scanSteamAware(db.pool.QueryRow(ctx, ` + SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at + FROM users WHERE id = $1 + `, id), &u) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + return &u, nil +} + +// GetUserBySteamID looks up a user by their linked SteamID64. Returns +// ErrUserNotFound if nobody on this panel has that Steam account linked. +func (db *DB) GetUserBySteamID(ctx context.Context, steamID string) (*UserRow, error) { + var u UserRow + err := scanSteamAware(db.pool.QueryRow(ctx, ` + SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at + FROM users WHERE steam_id = $1 + `, steamID), &u) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("get user by steam: %w", err) + } + return &u, nil +} + +// GetFirstAdmin returns the earliest-created admin user, or ErrUserNotFound +// if no admin exists. Used by --initial-admin-steam-id to locate the +// account to link a Steam ID to when no explicit target is specified. +func (db *DB) GetFirstAdmin(ctx context.Context) (*UserRow, error) { + var u UserRow + err := scanSteamAware(db.pool.QueryRow(ctx, ` + SELECT id, email, password_hash, steam_id, role, disabled, created_at, updated_at + FROM users WHERE role = 'admin' AND disabled = FALSE + ORDER BY created_at ASC + LIMIT 1 + `), &u) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + if err != nil { + return nil, fmt.Errorf("get first admin: %w", err) + } + return &u, nil +} + +// LinkSteamIDToUser sets/replaces the steam_id on an existing user. +// If another user already owns that Steam ID, returns an error (the +// unique constraint enforces one-user-per-Steam-account). +func (db *DB) LinkSteamIDToUser(ctx context.Context, userID int64, steamID string) error { + _, err := db.pool.Exec(ctx, ` + UPDATE users SET steam_id = $2, updated_at = NOW() WHERE id = $1 + `, userID, steamID) + if err != nil { + return fmt.Errorf("link steam id: %w", err) + } + return nil +} + +// UnlinkSteamID nulls the steam_id on a user. Operator pulled the +// Steam link from the panel UI. +func (db *DB) UnlinkSteamID(ctx context.Context, userID int64) error { + _, err := db.pool.Exec(ctx, `UPDATE users SET steam_id = NULL, updated_at = NOW() WHERE id = $1`, userID) + if err != nil { + return fmt.Errorf("unlink steam id: %w", err) + } + return nil +} + +// ListUsers returns every user. Admin-only at the API surface; this +// helper has no auth check itself. +func (db *DB) ListUsers(ctx context.Context) ([]UserRow, error) { + rows, err := db.pool.Query(ctx, ` + SELECT id, email, COALESCE(password_hash, ''), steam_id, role, disabled, created_at, updated_at + FROM users ORDER BY created_at ASC + `) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + var out []UserRow + for rows.Next() { + var u UserRow + var sid *string + if err := rows.Scan(&u.ID, &u.Email, &u.PasswordHash, &sid, &u.Role, &u.Disabled, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + if sid != nil { + u.SteamID = *sid + } + out = append(out, u) + } + return out, rows.Err() +} + +func (db *DB) UpdateUserPassword(ctx context.Context, id int64, passwordHash string) error { + _, err := db.pool.Exec(ctx, ` + UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1 + `, id, passwordHash) + if err != nil { + return fmt.Errorf("update user password: %w", err) + } + return nil +} + +func (db *DB) CreateSession(ctx context.Context, token string, userID int64, expiresAt time.Time, ip string) error { + _, err := db.pool.Exec(ctx, ` + INSERT INTO sessions (token, user_id, expires_at, last_seen_ip) + VALUES ($1, $2, $3, $4) + `, token, userID, expiresAt, ip) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + return nil +} + +// GetSessionUser returns the user associated with a valid, non-expired +// session token. It also bumps last_seen_at so we can show active sessions +// in a future admin UI. +func (db *DB) GetSessionUser(ctx context.Context, token, ip string) (*UserRow, error) { + var u UserRow + err := db.pool.QueryRow(ctx, ` + UPDATE sessions SET last_seen_at = NOW(), last_seen_ip = $2 + WHERE token = $1 AND expires_at > NOW() + RETURNING user_id + `, token, ip).Scan(&u.ID) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrSessionNotFound + } + if err != nil { + return nil, fmt.Errorf("get session: %w", err) + } + return db.GetUserByID(ctx, u.ID) +} + +func (db *DB) DeleteSession(ctx context.Context, token string) error { + _, err := db.pool.Exec(ctx, `DELETE FROM sessions WHERE token = $1`, token) + if err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} diff --git a/controller/internal/events/bus.go b/controller/internal/events/bus.go new file mode 100644 index 00000000..4a334e93 --- /dev/null +++ b/controller/internal/events/bus.go @@ -0,0 +1,228 @@ +// Package events is the Controller's in-memory pub-sub for live agent +// events (instance state, app state, player, log). The Panel service's +// StreamEvents RPC subscribes; agent message handlers publish. Subscribers +// have bounded buffers and are dropped rather than back-pressuring the +// whole pipeline if they can't keep up. +package events + +import ( + "sync" + "sync/atomic" + + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +// Filter narrows which events a subscriber receives. Empty string on a +// field means "no filter on that field". +type Filter struct { + InstanceID string + AgentID string + // ExcludeLogs drops Event_Log payloads for this subscriber. The global + // dashboard stream sets this: log lines are by far the bulk of event + // volume (a 7DTD boot is thousands of lines in seconds), the dashboard + // has no DOM target for them, and shipping them anyway is what + // overflowed the subscriber buffer (dropped events → stale status) and + // kept the browser main thread busy. Consoles + readiness scanners use + // per-instance subscriptions, which keep their logs. + ExcludeLogs bool +} + +// Subscription is the subscriber's handle. Call Close when done. +type Subscription struct { + bus *Bus + id int64 + filter Filter + ch chan *panelv1.Event + // sch is non-nil only for SubscribeStamped subscribers; exactly one of + // ch/sch carries events for a given subscription. + sch chan Stamped + dropped atomic.Uint64 + closed atomic.Bool +} + +// Events returns the receive channel. Consumer should range over it. +func (s *Subscription) Events() <-chan *panelv1.Event { return s.ch } + +// Stamped returns the id-stamped receive channel. Only valid on +// subscriptions created via SubscribeStamped (nil otherwise). +func (s *Subscription) Stamped() <-chan Stamped { return s.sch } + +// Dropped returns the number of events that were dropped because the +// subscriber's buffer was full. +func (s *Subscription) Dropped() uint64 { return s.dropped.Load() } + +// Close unsubscribes and closes the channel. Safe to call multiple times. +func (s *Subscription) Close() { + if !s.closed.CompareAndSwap(false, true) { + return + } + s.bus.unsubscribe(s.id) + if s.sch != nil { + close(s.sch) + } else { + close(s.ch) + } +} + +// Bus is a concurrency-safe event fan-out with per-subscriber bounded +// buffers. Subscribers register with optional filters; publishers deliver +// each matching event non-blocking (drop on full buffer). +type Bus struct { + mu sync.RWMutex + next int64 + subs map[int64]*Subscription + bufCap int + // ring retains the most recent events (with monotonic ids) so SSE + // clients can resume via Last-Event-ID. See ring.go. + ring *replayRing + // droppedTotal counts every event dropped on a full subscriber buffer, + // across all subscribers (including since-closed ones). Metrics surface. + droppedTotal atomic.Uint64 +} + +// New creates a Bus. bufCap is the per-subscriber channel buffer. +// +// The default is intentionally generous: a busy install/download (SteamCMD +// preallocation, large Minecraft world seed, etc.) can emit 40+ log lines +// per second per instance. At the old default of 256, a browser subscriber +// that paused for half a second could lose dozens of intermediate events — +// the user saw "0 → 88% → 7.11%" jumps because the progress-bar frames +// between them hit the default: branch in Publish. +func New(bufCap int) *Bus { + if bufCap <= 0 { + bufCap = 4096 + } + return &Bus{subs: map[int64]*Subscription{}, bufCap: bufCap, ring: newReplayRing(DefaultRingCap)} +} + +// Subscribe returns a new Subscription. Call Sub.Close when done — typically +// via defer from the stream handler. +func (b *Bus) Subscribe(filter Filter) *Subscription { + b.mu.Lock() + defer b.mu.Unlock() + b.next++ + s := &Subscription{ + bus: b, + id: b.next, + filter: filter, + ch: make(chan *panelv1.Event, b.bufCap), + } + b.subs[s.id] = s + return s +} + +// SubscribeStamped is Subscribe, but the subscription's channel carries the +// event's monotonic replay id alongside the event (Stamped()). Used by the +// SSE handler to emit `id:` lines for Last-Event-ID resume. +func (b *Bus) SubscribeStamped(filter Filter) *Subscription { + b.mu.Lock() + defer b.mu.Unlock() + b.next++ + s := &Subscription{ + bus: b, + id: b.next, + filter: filter, + sch: make(chan Stamped, b.bufCap), + } + b.subs[s.id] = s + return s +} + +// Replay returns the retained events with ID > afterID that match the +// filter, oldest first. ok=false signals the resume point was overwritten +// (a gap precedes the returned events) — callers should resync via snapshot. +func (b *Bus) Replay(afterID uint64, f Filter) ([]Stamped, bool) { + all, ok := b.ring.since(afterID) + out := all[:0] + for _, s := range all { + if match(f, s.Event) { + out = append(out, s) + } + } + return out, ok +} + +// LastID returns the id of the most recently published event (0 if none). +func (b *Bus) LastID() uint64 { return b.ring.lastID() } + +// DroppedTotal returns the process-lifetime count of events dropped on +// full subscriber buffers. +func (b *Bus) DroppedTotal() uint64 { return b.droppedTotal.Load() } + +func (b *Bus) unsubscribe(id int64) { + b.mu.Lock() + delete(b.subs, id) + b.mu.Unlock() +} + +// Publish fans the event to every matching subscriber. Non-blocking — +// slow subscribers lose events rather than back-pressuring publishers. +func (b *Bus) Publish(e *panelv1.Event) { + // Stamp + retain first so replay ids are assigned in publish order. + id := b.ring.append(e) + b.mu.RLock() + subs := make([]*Subscription, 0, len(b.subs)) + for _, s := range b.subs { + if match(s.filter, e) { + subs = append(subs, s) + } + } + b.mu.RUnlock() + for _, s := range subs { + if s.sch != nil { + select { + case s.sch <- Stamped{ID: id, Event: e}: + default: + s.dropped.Add(1) + b.droppedTotal.Add(1) + } + continue + } + select { + case s.ch <- e: + default: + s.dropped.Add(1) + b.droppedTotal.Add(1) + } + } +} + +// SubscriberCount is useful for metrics / debug. +func (b *Bus) SubscriberCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.subs) +} + +func match(f Filter, e *panelv1.Event) bool { + if f.AgentID != "" && f.AgentID != e.AgentId { + return false + } + if f.InstanceID != "" { + if eventInstanceID(e) != f.InstanceID { + return false + } + } + if f.ExcludeLogs { + if _, isLog := e.Payload.(*panelv1.Event_Log); isLog { + return false + } + } + return true +} + +func eventInstanceID(e *panelv1.Event) string { + switch p := e.Payload.(type) { + case *panelv1.Event_InstanceState: + return p.InstanceState.InstanceId + case *panelv1.Event_AppState: + return p.AppState.InstanceId + case *panelv1.Event_Player: + return p.Player.InstanceId + case *panelv1.Event_Log: + return p.Log.InstanceId + case *panelv1.Event_InstanceStats: + return p.InstanceStats.InstanceId + } + return "" +} diff --git a/controller/internal/events/bus_test.go b/controller/internal/events/bus_test.go new file mode 100644 index 00000000..0664a4a1 --- /dev/null +++ b/controller/internal/events/bus_test.go @@ -0,0 +1,86 @@ +package events + +import ( + "testing" + + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +func logEvent(agentID, instanceID string) *panelv1.Event { + return &panelv1.Event{ + AgentId: agentID, + Payload: &panelv1.Event_Log{Log: &panelv1.LogLine{InstanceId: instanceID, Line: "x"}}, + } +} + +func statsEvent(agentID, instanceID string) *panelv1.Event { + return &panelv1.Event{ + AgentId: agentID, + Payload: &panelv1.Event_InstanceStats{InstanceStats: &panelv1.InstanceStatsUpdate{InstanceId: instanceID}}, + } +} + +// drain returns however many events are immediately buffered. +func drain(s *Subscription) int { + n := 0 + for { + select { + case <-s.Events(): + n++ + default: + return n + } + } +} + +func TestFilterExcludeLogs(t *testing.T) { + b := New(16) + sub := b.Subscribe(Filter{ExcludeLogs: true}) + defer sub.Close() + + b.Publish(logEvent("a1", "i1")) + b.Publish(statsEvent("a1", "i1")) + b.Publish(logEvent("a2", "i2")) + + if got := drain(sub); got != 1 { + t.Fatalf("ExcludeLogs subscriber got %d events, want 1 (stats only)", got) + } +} + +func TestFilterInstanceAndAgent(t *testing.T) { + b := New(16) + byInst := b.Subscribe(Filter{InstanceID: "i1"}) + defer byInst.Close() + byAgent := b.Subscribe(Filter{AgentID: "a2"}) + defer byAgent.Close() + all := b.Subscribe(Filter{}) + defer all.Close() + + b.Publish(logEvent("a1", "i1")) + b.Publish(statsEvent("a2", "i2")) + + if got := drain(byInst); got != 1 { + t.Fatalf("InstanceID filter got %d, want 1", got) + } + if got := drain(byAgent); got != 1 { + t.Fatalf("AgentID filter got %d, want 1", got) + } + if got := drain(all); got != 2 { + t.Fatalf("unfiltered got %d, want 2", got) + } +} + +func TestDropOnFullBuffer(t *testing.T) { + b := New(2) + sub := b.Subscribe(Filter{}) + defer sub.Close() + for i := 0; i < 5; i++ { + b.Publish(statsEvent("a1", "i1")) + } + if got := drain(sub); got != 2 { + t.Fatalf("buffered %d, want 2", got) + } + if d := sub.Dropped(); d != 3 { + t.Fatalf("dropped %d, want 3", d) + } +} diff --git a/controller/internal/events/logbuf.go b/controller/internal/events/logbuf.go new file mode 100644 index 00000000..6774879d --- /dev/null +++ b/controller/internal/events/logbuf.go @@ -0,0 +1,154 @@ +package events + +import ( + "sync" + + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +// LogBuffer is a bounded per-instance ring buffer of recent events. Its +// purpose is UX: when the operator opens the Console tab for an instance, +// we want them to see what's been going on — not just events that happen +// *after* they subscribed. The buffer tails the event bus continuously so +// a short history is always available. +// +// Events are stored verbatim (full *Event payload) so the caller can render +// them exactly like the live SSE stream would. Only log, player, and +// instance_state payloads are retained — stats and app_state churn too +// fast and aren't what "Console history" should show. +type LogBuffer struct { + bus *Bus + cap int + mu sync.Mutex + byInst map[string]*ringSlice + stopped bool + done chan struct{} +} + +// NewLogBuffer creates the buffer and starts a background goroutine that +// consumes the bus. Call Stop to shut down. +// +// capPerInstance is the maximum number of events kept per instance id. The +// UI typically asks for the last 400, so 500 is a comfortable default. +func NewLogBuffer(bus *Bus, capPerInstance int) *LogBuffer { + if capPerInstance <= 0 { + capPerInstance = 500 + } + b := &LogBuffer{ + bus: bus, + cap: capPerInstance, + byInst: map[string]*ringSlice{}, + done: make(chan struct{}), + } + go b.run() + return b +} + +func (b *LogBuffer) run() { + defer close(b.done) + sub := b.bus.Subscribe(Filter{}) + defer sub.Close() + for ev := range sub.Events() { + if !isRetained(ev) { + continue + } + id := eventInstanceID(ev) + if id == "" { + continue + } + b.mu.Lock() + if b.stopped { + b.mu.Unlock() + return + } + r := b.byInst[id] + if r == nil { + r = newRing(b.cap) + b.byInst[id] = r + } + r.push(ev) + b.mu.Unlock() + } +} + +// Recent returns up to `limit` most-recent retained events for an instance, +// oldest first. Safe to call concurrently with Run. +func (b *LogBuffer) Recent(instanceID string, limit int) []*panelv1.Event { + b.mu.Lock() + defer b.mu.Unlock() + r := b.byInst[instanceID] + if r == nil { + return nil + } + return r.tail(limit) +} + +// Forget drops the buffer for an instance. Called when an instance is +// deleted so memory doesn't leak for dead ids. +func (b *LogBuffer) Forget(instanceID string) { + b.mu.Lock() + delete(b.byInst, instanceID) + b.mu.Unlock() +} + +// Stop ends the background goroutine. Safe to call multiple times. +func (b *LogBuffer) Stop() { + b.mu.Lock() + if b.stopped { + b.mu.Unlock() + return + } + b.stopped = true + b.mu.Unlock() +} + +// isRetained decides whether an event belongs in the console history. +// +// - log lines: yes, that's the whole point. +// - player events: yes — joins/leaves/chat/deaths belong on the console. +// - instance_state: yes — "server started"-style transitions are context. +// - stats / app_state: no — churn too fast; would push real content out +// of the ring. +func isRetained(e *panelv1.Event) bool { + switch e.Payload.(type) { + case *panelv1.Event_Log, *panelv1.Event_Player, *panelv1.Event_InstanceState: + return true + } + return false +} + +// ringSlice is a fixed-capacity FIFO. Implemented as a slice with head/len +// tracking to avoid allocating on steady-state appends. +type ringSlice struct { + items []*panelv1.Event + head int // index of the oldest entry (when len == cap) + len int + cap int +} + +func newRing(cap int) *ringSlice { + return &ringSlice{items: make([]*panelv1.Event, cap), cap: cap} +} + +func (r *ringSlice) push(e *panelv1.Event) { + if r.len < r.cap { + r.items[(r.head+r.len)%r.cap] = e + r.len++ + return + } + // At capacity: overwrite the oldest. + r.items[r.head] = e + r.head = (r.head + 1) % r.cap +} + +func (r *ringSlice) tail(limit int) []*panelv1.Event { + if limit <= 0 || limit > r.len { + limit = r.len + } + out := make([]*panelv1.Event, 0, limit) + start := (r.head + r.len - limit + r.cap) % r.cap + for i := 0; i < limit; i++ { + out = append(out, r.items[(start+i)%r.cap]) + } + return out +} diff --git a/controller/internal/events/ring.go b/controller/internal/events/ring.go new file mode 100644 index 00000000..ba97d0a3 --- /dev/null +++ b/controller/internal/events/ring.go @@ -0,0 +1,89 @@ +package events + +// Replay ring: every published event gets a monotonic id and lands in a +// fixed-capacity ring buffer so an SSE client that reconnects with +// Last-Event-ID can be caught up without a full poll. The ring is +// best-effort — when a client's resume point has already been overwritten, +// Replay reports the gap and the caller falls back to the state snapshot +// it sends on every connect anyway. + +import ( + "sync" + + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +// Stamped pairs an event with its bus-assigned monotonic id. IDs start at 1 +// and never repeat for the lifetime of the process. +type Stamped struct { + ID uint64 + Event *panelv1.Event +} + +// DefaultRingCap is how many recent events the bus retains for replay. +// State/app/player events are small and logs dominate volume; at a burst of +// ~200 events/s this still covers ~20s of reconnect window, and the SSE +// snapshot covers anything older. +const DefaultRingCap = 4096 + +type replayRing struct { + mu sync.Mutex + buf []Stamped + next uint64 // id to assign to the NEXT appended event (starts at 1) + head int // index of oldest valid entry + n int // number of valid entries (<= cap) +} + +func newReplayRing(capacity int) *replayRing { + if capacity <= 0 { + capacity = DefaultRingCap + } + return &replayRing{buf: make([]Stamped, capacity), next: 1} +} + +// append stamps e with the next monotonic id, stores it, and returns the id. +func (r *replayRing) append(e *panelv1.Event) uint64 { + r.mu.Lock() + defer r.mu.Unlock() + id := r.next + r.next++ + idx := (r.head + r.n) % len(r.buf) + if r.n < len(r.buf) { + r.buf[idx] = Stamped{ID: id, Event: e} + r.n++ + } else { + r.buf[r.head] = Stamped{ID: id, Event: e} + r.head = (r.head + 1) % len(r.buf) + } + return id +} + +// lastID returns the most recently assigned id (0 when nothing published). +func (r *replayRing) lastID() uint64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.next - 1 +} + +// since returns every retained event with ID > afterID, oldest first. +// ok=false means the resume point has been overwritten (afterID is older +// than the oldest retained event), so the returned slice — while still the +// full retained suffix — has a gap before it; callers should resync from a +// snapshot. afterID >= lastID returns (nil, true). +func (r *replayRing) since(afterID uint64) (out []Stamped, ok bool) { + r.mu.Lock() + defer r.mu.Unlock() + if r.n == 0 { + // Nothing retained: only gapless if the caller is already current. + return nil, afterID >= r.next-1 + } + oldest := r.buf[r.head].ID + ok = afterID >= oldest-1 + for i := 0; i < r.n; i++ { + s := r.buf[(r.head+i)%len(r.buf)] + if s.ID > afterID { + out = append(out, s) + } + } + return out, ok +} diff --git a/controller/internal/events/ring_test.go b/controller/internal/events/ring_test.go new file mode 100644 index 00000000..6ee00613 --- /dev/null +++ b/controller/internal/events/ring_test.go @@ -0,0 +1,93 @@ +package events + +import ( + "fmt" + "testing" + + panelv1 "github.com/dbledeez/panel/proto/panel/v1" +) + +func stateEvent(instanceID string) *panelv1.Event { + return &panelv1.Event{Payload: &panelv1.Event_InstanceState{ + InstanceState: &panelv1.InstanceStateUpdate{InstanceId: instanceID}, + }} +} + +func TestRingMonotonicIDsAndSince(t *testing.T) { + r := newReplayRing(8) + for i := 0; i < 5; i++ { + id := r.append(stateEvent(fmt.Sprintf("i%d", i))) + if want := uint64(i + 1); id != want { + t.Fatalf("append id = %d, want %d", id, want) + } + } + if r.lastID() != 5 { + t.Fatalf("lastID = %d, want 5", r.lastID()) + } + out, ok := r.since(2) + if !ok { + t.Fatalf("since(2) reported a gap on an unfilled ring") + } + if len(out) != 3 || out[0].ID != 3 || out[2].ID != 5 { + t.Fatalf("since(2) = %+v, want ids 3..5", out) + } + // Fully caught up → empty, gapless. + if out, ok := r.since(5); len(out) != 0 || !ok { + t.Fatalf("since(lastID) = (%d events, ok=%v), want (0, true)", len(out), ok) + } +} + +func TestRingOverflowReportsGap(t *testing.T) { + r := newReplayRing(4) + for i := 0; i < 10; i++ { // ids 1..10; ring retains 7..10 + r.append(stateEvent("x")) + } + out, ok := r.since(3) + if ok { + t.Fatalf("since(3) after overflow should report a gap") + } + if len(out) != 4 || out[0].ID != 7 || out[3].ID != 10 { + t.Fatalf("since(3) = %d events (first=%d), want retained suffix 7..10", len(out), out[0].ID) + } + // Resume point exactly at oldest-1 is gapless. + if _, ok := r.since(6); !ok { + t.Fatalf("since(oldest-1) should be gapless") + } +} + +func TestBusReplayFiltersAndStampedSubscribe(t *testing.T) { + b := New(16) + sub := b.SubscribeStamped(Filter{InstanceID: "a"}) + defer sub.Close() + + b.Publish(stateEvent("a")) // id 1 + b.Publish(stateEvent("b")) // id 2 + b.Publish(stateEvent("a")) // id 3 + + // Stamped subscription only sees matching events, with real ids. + got1 := <-sub.Stamped() + got2 := <-sub.Stamped() + if got1.ID != 1 || got2.ID != 3 { + t.Fatalf("stamped ids = %d,%d, want 1,3", got1.ID, got2.ID) + } + + // Replay applies the same filter. + out, ok := b.Replay(0, Filter{InstanceID: "a"}) + if !ok || len(out) != 2 || out[0].ID != 1 || out[1].ID != 3 { + t.Fatalf("Replay = %+v ok=%v, want ids 1,3 gapless", out, ok) + } + if b.LastID() != 3 { + t.Fatalf("LastID = %d, want 3", b.LastID()) + } +} + +func TestBusDroppedTotal(t *testing.T) { + b := New(1) + sub := b.Subscribe(Filter{}) + defer sub.Close() + b.Publish(stateEvent("a")) // fills the 1-slot buffer + b.Publish(stateEvent("a")) // dropped + if b.DroppedTotal() != 1 { + t.Fatalf("DroppedTotal = %d, want 1", b.DroppedTotal()) + } +} diff --git a/controller/internal/opnfwd/client.go b/controller/internal/opnfwd/client.go new file mode 100644 index 00000000..de8758e6 --- /dev/null +++ b/controller/internal/opnfwd/client.go @@ -0,0 +1,217 @@ +// Package opnfwd is a thin HTTP client wrapping an opnfwd service (an +// OPNsense port-forward automation helper) running on a LAN host. Used by +// the panel to auto-create / auto-delete OPNsense port forwards when a +// game-server instance is created or deleted, so operators don't have to +// click through OPNsense's two-page rule-editor by hand for every server. +// +// This integration is OPTIONAL — it only activates when an opnfwd URL + +// token are configured in panel settings. Auth is via a Bearer token the +// panel persists in panel_settings.opnfwd_token. +// +// The client never retries on failure — opnfwd writes a backup of +// `/conf/config.xml` BEFORE every mutation, so a partial failure is +// recoverable from opnfwd's History modal. Silent retries would risk +// duplicate rule creation. +package opnfwd + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/dbledeez/panel/pkg/version" + "strings" + "time" +) + +// Client points at an opnfwd HTTP endpoint and signs every request with the +// Bearer token. Zero-value Client is unusable — callers must populate at +// least BaseURL and Token. +type Client struct { + BaseURL string // e.g. "http://:14995" or "https://" + Token string // matches opnfwd's [api] token in secrets/config.toml + Timeout time.Duration // default 30s if zero + // Optional: when calling the public reverse-proxy URL with a self-signed + // cert chain, set true to skip TLS verify. Leave false for the LAN URL. + InsecureTLSSkipVerify bool +} + +// AddRequest mirrors opnfwd's POST /api/forwards body. +type AddRequest struct { + Name string `json:"name"` // 1-64 chars, used as the rule's + Port string `json:"port"` // single port like "7021" or range "10000-10005" + Target string `json:"target"` // RFC1918 IPv4 destination (the LAN IP of the panel agent) + Proto string `json:"proto"` // "tcp" | "udp" | "tcp/udp" +} + +// AddResult mirrors opnfwd's success response — see opnfwd/app/opnsense.py +// `add_forward` return dict. The `pairs` array has one entry per port (a +// range expansion creates N pairs); single-port adds always return len 1. +type AddResult struct { + Output string `json:"output"` + Pairs []ForwardPair `json:"pairs"` + Count int `json:"count"` +} + +// ForwardPair is what we persist in panel's instance_forwards table. +type ForwardPair struct { + Port string `json:"port"` + NatUUID string `json:"nat_uuid"` + FilterUUID string `json:"filter_uuid"` +} + +// DeleteRequest is the body of POST /api/forwards/bulk-delete. +type DeleteRequest struct { + Items []DeleteItem `json:"items"` +} + +// DeleteItem is one forward to remove. Either uuid alone is fine — opnfwd +// drops whichever side it can find. Passing both is the recommended form +// (the panel always has both because it persisted them at create time). +type DeleteItem struct { + NatUUID string `json:"nat_uuid,omitempty"` + FilterUUID string `json:"filter_uuid,omitempty"` +} + +// ForwardOut is one row from GET /api/forwards. Used by the reconcile path +// to compare what opnfwd has against what panel believes it has. +type ForwardOut struct { + Name string `json:"name"` + Port string `json:"port"` + Target string `json:"target"` + Proto string `json:"proto"` + NatUUID string `json:"nat_uuid,omitempty"` + FilterUUID string `json:"filter_uuid,omitempty"` +} + +// ---- public API ---- + +// Health calls /api/me — round-trips the token and confirms the service is +// reachable. Use this from the panel's settings page to surface a green/red +// dot before the operator flips the toggle. +func (c *Client) Health(ctx context.Context) error { + if c.BaseURL == "" { + return errors.New("opnfwd client: BaseURL is empty") + } + body, status, err := c.do(ctx, http.MethodGet, "/api/me", nil) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("health: HTTP %d: %s", status, truncate(body, 200)) + } + return nil +} + +// Add creates ONE forward (single port). The panel always calls Add with a +// single port — it tracks each manifest port individually so a deleted +// instance can have its forwards removed precisely. opnfwd's range support +// is a UX nicety for humans; we don't use it. +func (c *Client) Add(ctx context.Context, req AddRequest) (AddResult, error) { + if req.Name == "" || req.Port == "" || req.Target == "" { + return AddResult{}, errors.New("opnfwd Add: name, port, target are required") + } + if req.Proto == "" { + req.Proto = "tcp/udp" + } + body, status, err := c.do(ctx, http.MethodPost, "/api/forwards", req) + if err != nil { + return AddResult{}, err + } + if status != http.StatusOK { + return AddResult{}, fmt.Errorf("add forward: HTTP %d: %s", status, truncate(body, 400)) + } + var out AddResult + if err := json.Unmarshal(body, &out); err != nil { + return AddResult{}, fmt.Errorf("decode add response: %w (body: %s)", err, truncate(body, 200)) + } + return out, nil +} + +// BulkDelete removes N forwards in one atomic mutation. opnfwd writes a +// /conf/config.xml backup before this fires. +func (c *Client) BulkDelete(ctx context.Context, items []DeleteItem) error { + if len(items) == 0 { + return nil + } + body, status, err := c.do(ctx, http.MethodPost, "/api/forwards/bulk-delete", DeleteRequest{Items: items}) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("bulk delete: HTTP %d: %s", status, truncate(body, 400)) + } + return nil +} + +// List returns every forward opnfwd knows about. The reconcile button +// compares this to what the panel persisted in instance_forwards. +func (c *Client) List(ctx context.Context) ([]ForwardOut, error) { + body, status, err := c.do(ctx, http.MethodGet, "/api/forwards", nil) + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("list forwards: HTTP %d: %s", status, truncate(body, 200)) + } + var out []ForwardOut + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("decode list response: %w", err) + } + return out, nil +} + +// ---- internals ---- + +func (c *Client) do(ctx context.Context, method, path string, body any) ([]byte, int, error) { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, fmt.Errorf("marshal body: %w", err) + } + bodyReader = bytes.NewReader(b) + } + url := strings.TrimRight(c.BaseURL, "/") + path + req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, 0, fmt.Errorf("new request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("User-Agent", "panel-controller/"+version.Version+" (+opnfwd integration)") + + timeout := c.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + transport := &http.Transport{} + if c.InsecureTLSSkipVerify { + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + httpClient := &http.Client{Timeout: timeout, Transport: transport} + resp, err := httpClient.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("read body: %w", err) + } + return respBody, resp.StatusCode, nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 00000000..ddb3217e --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,21 @@ +# Panel production compose — copy to deploy/.env and edit. +# NOTE: values containing '#' or '$' must be quoted. + +# REQUIRED — Postgres password for the "panel" DB user. +PANEL_DB_PASSWORD=change-me + +# Optional — stable admin password for admin@panel.local on first boot. +# Leave empty to get a generated one printed in `docker compose logs controller`. +PANEL_ADMIN_PASSWORD= + +# Optional — host ports for the dashboard and agent gRPC. +PANEL_HTTP_PORT=8080 +PANEL_GRPC_PORT=8443 + +# Optional — image/version tag baked into the binaries (ldflags). +PANEL_VERSION=dev + +# Optional — host directory for agent instance data. MUST be an absolute +# path that exists on the host; it is mounted at the SAME path inside the +# agent container (game-server bind mounts require host==container path). +PANEL_AGENT_DATA=/opt/panel/data diff --git a/deploy/Dockerfile.agent b/deploy/Dockerfile.agent new file mode 100644 index 00000000..08c37cea --- /dev/null +++ b/deploy/Dockerfile.agent @@ -0,0 +1,36 @@ +# Panel agent — production image. +# Build from the repo root: docker build -f deploy/Dockerfile.agent . +# +# ┌─────────────────────────────── SECURITY ───────────────────────────────┐ +# │ The agent container mounts the HOST Docker socket (/var/run/docker.sock)│ +# │ to create/manage game-server containers. Access to the docker socket │ +# │ is equivalent to root on the host. Only run this on a machine you │ +# │ fully control, and never expose the socket beyond this container. │ +# └─────────────────────────────────────────────────────────────────────────┘ + +FROM golang:1.26 AS build +ARG VERSION=dev +WORKDIR /src +COPY . . +ENV CGO_ENABLED=0 +RUN go build -trimpath \ + -ldflags "-s -w -X github.com/dbledeez/panel/pkg/version.Version=${VERSION}" \ + -o /out/agent ./agent/cmd/agent + +# Not distroless: game-module install hooks may shell out (tar, unzip). +FROM debian:12-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates tar unzip xz-utils \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /opt/panel +COPY --from=build /out/agent /opt/panel/bin/agent +COPY --from=build /src/modules /opt/panel/modules +# Instance data, sidecar metadata, backups, pairing certs — persist this. +VOLUME ["/opt/panel/data"] +ENTRYPOINT ["/opt/panel/bin/agent"] +CMD ["--controller", "controller:8443", \ + "--modules-dir", "./modules", \ + "--data-root", "./data/instances", \ + "--meta-dir", "./data/instance-meta", \ + "--backup-dir", "./data/backups", \ + "--cert-dir", "./data/certs"] diff --git a/deploy/Dockerfile.controller b/deploy/Dockerfile.controller new file mode 100644 index 00000000..7767330d --- /dev/null +++ b/deploy/Dockerfile.controller @@ -0,0 +1,29 @@ +# Panel controller — production image. +# Build from the repo root: docker build -f deploy/Dockerfile.controller . +# (the Makefile "docker" target does exactly this, injecting VERSION) + +FROM golang:1.26 AS build +ARG VERSION=dev +WORKDIR /src +# The repo is a Go workspace (go.work) spanning controller/agent/pkg/proto — +# copy the whole tree so workspace resolution works. +COPY . . +ENV CGO_ENABLED=0 +RUN go build -trimpath \ + -ldflags "-s -w -X github.com/dbledeez/panel/pkg/version.Version=${VERSION}" \ + -o /out/controller ./controller/cmd/controller \ + && go build -trimpath \ + -ldflags "-s -w -X github.com/dbledeez/panel/pkg/version.Version=${VERSION}" \ + -o /out/panelctl ./controller/cmd/panelctl + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /opt/panel +COPY --from=build /out/controller /opt/panel/bin/controller +COPY --from=build /out/panelctl /opt/panel/bin/panelctl +# Module manifests: the controller's port allocator reads ./modules. +COPY --from=build /src/modules /opt/panel/modules +# CA, admin-token, cluster stores — persist this volume. +VOLUME ["/opt/panel/data"] +EXPOSE 8080 8443 +ENTRYPOINT ["/opt/panel/bin/controller"] +CMD ["-http", ":8080", "-grpc", ":8443", "-ca-dir", "./data/ca"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 00000000..b3970bfd --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,90 @@ +# Panel — PRODUCTION single-box compose (postgres + controller + agent). +# (Dev Postgres-only compose lives at ../docker-compose.dev.yml.) +# +# Usage, from the repo root: +# cp deploy/.env.example deploy/.env # then edit deploy/.env +# docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build +# +# First run: watch `docker compose logs controller` for the ADMIN BOOTSTRAP +# banner (generated admin password), then create a pair token in the UI +# (or via POST /api/admin/pair-tokens) — the agent pairs itself on start +# only if you exec the pairing step; see README / install.sh for the flow. + +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: panel + POSTGRES_PASSWORD: ${PANEL_DB_PASSWORD:?set PANEL_DB_PASSWORD in deploy/.env} + POSTGRES_DB: panel + volumes: + - panel_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U panel -d panel"] + interval: 5s + timeout: 3s + retries: 10 + + controller: + build: + context: .. + dockerfile: deploy/Dockerfile.controller + args: + VERSION: ${PANEL_VERSION:-dev} + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + PANEL_DB_URL: postgres://panel:${PANEL_DB_PASSWORD}@postgres:5432/panel?sslmode=disable + # Optional: stable first-boot admin password instead of a generated one. + PANEL_ADMIN_PASSWORD: ${PANEL_ADMIN_PASSWORD:-} + ports: + - "${PANEL_HTTP_PORT:-8080}:8080" # dashboard + REST API + - "${PANEL_GRPC_PORT:-8443}:8443" # agent gRPC (mTLS) + volumes: + - panel_controller_data:/opt/panel/data + + agent: + build: + context: .. + dockerfile: deploy/Dockerfile.agent + args: + VERSION: ${PANEL_VERSION:-dev} + restart: unless-stopped + depends_on: + - controller + # ┌────────────────────────────── SECURITY ──────────────────────────────┐ + # │ The agent mounts the HOST Docker socket to create and manage game- │ + # │ server containers. Docker-socket access is ROOT-EQUIVALENT on the │ + # │ host: anyone who can reach this container's filesystem or process │ + # │ can own the machine. Run this stack only on a host you fully trust, │ + # │ keep the panel dashboard behind auth/TLS, and never re-export the │ + # │ socket from this container. │ + # └───────────────────────────────────────────────────────────────────────┘ + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # IMPORTANT: the agent passes instance paths to the HOST docker daemon + # as bind mounts, so the host path and in-container path MUST match. + # Keep both sides of this mapping identical. + - ${PANEL_AGENT_DATA:-/opt/panel/data}:${PANEL_AGENT_DATA:-/opt/panel/data} + environment: + PANEL_GRPC_PORT: "8443" + command: + - "--controller" + - "controller:8443" + - "--modules-dir" + - "./modules" + - "--data-root" + - "${PANEL_AGENT_DATA:-/opt/panel/data}/instances" + - "--meta-dir" + - "${PANEL_AGENT_DATA:-/opt/panel/data}/instance-meta" + - "--backup-dir" + - "${PANEL_AGENT_DATA:-/opt/panel/data}/backups" + - "--cert-dir" + - "${PANEL_AGENT_DATA:-/opt/panel/data}/certs" + +volumes: + panel_pgdata: + panel_controller_data: diff --git a/deploy/systemd/panel-agent.service b/deploy/systemd/panel-agent.service new file mode 100644 index 00000000..13bbd6a1 --- /dev/null +++ b/deploy/systemd/panel-agent.service @@ -0,0 +1,45 @@ +# REFERENCE ONLY — install.sh GENERATES the real unit at install time, +# substituting the actual PANEL_DIR/ports/user (see write_unit in install.sh). +# This static copy assumes the /opt/panel defaults; edit it by hand if you wire +# the units up yourself (e.g. via compose) instead of running install.sh. +[Unit] +Description=Panel agent (Docker runtime for game-server instances) +Documentation=https://github.com/dbledeez/panel +After=network-online.target docker.service panel-controller.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +# The agent user MUST be able to talk to the Docker daemon +# (member of the "docker" group, or root). install.sh handles this. +User=panel +Group=panel +SupplementaryGroups=docker +WorkingDirectory=/opt/panel +# Defaults first; /opt/panel/panel.env overrides (applied later). +Environment=PANEL_GRPC_PORT=8443 +EnvironmentFile=-/opt/panel/panel.env +# Pairing (one-time cert issuance) is done by install.sh / the operator: +# /opt/panel/bin/agent --pair-url http://localhost:8080 --pair-token --cert-dir ./data/certs +ExecStart=/opt/panel/bin/agent \ + --controller localhost:${PANEL_GRPC_PORT} \ + --modules-dir ./modules \ + --data-root ./data/instances \ + --meta-dir ./data/instance-meta \ + --backup-dir ./data/backups \ + --cert-dir ./data/certs +Restart=on-failure +RestartSec=3 + +# Hardening is intentionally lighter than the controller's: the agent +# manages Docker containers and bind-mounts instance data, so it needs +# the docker socket and its data tree. +NoNewPrivileges=true +ProtectHome=true +ReadWritePaths=/opt/panel +PrivateTmp=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/panel-controller.service b/deploy/systemd/panel-controller.service new file mode 100644 index 00000000..2ddc6f59 --- /dev/null +++ b/deploy/systemd/panel-controller.service @@ -0,0 +1,45 @@ +# REFERENCE ONLY — install.sh GENERATES the real unit at install time, +# substituting the actual PANEL_DIR/ports/user (see write_unit in install.sh). +# This static copy assumes the /opt/panel defaults; edit it by hand if you wire +# the units up yourself (e.g. via compose) instead of running install.sh. +[Unit] +Description=Panel controller (HTTP dashboard :8080 + agent gRPC :8443) +Documentation=https://github.com/dbledeez/panel +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +# install.sh creates this user; change to taste. The controller does NOT +# need docker access — only the agent does. +User=panel +Group=panel +# The controller resolves ./data (CA, admin-token, cluster stores) and +# ./modules relative to its working directory — keep them together here. +WorkingDirectory=/opt/panel +# Defaults first; /opt/panel/panel.env (PANEL_DB_URL, PANEL_HTTP_PORT, +# PANEL_ADMIN_PASSWORD, ...) overrides them because it is applied later. +Environment=PANEL_HTTP_PORT=8080 +Environment=PANEL_GRPC_PORT=8443 +EnvironmentFile=-/opt/panel/panel.env +ExecStart=/opt/panel/bin/controller \ + -http :${PANEL_HTTP_PORT} \ + -grpc :${PANEL_GRPC_PORT} \ + -ca-dir ./data/ca +Restart=on-failure +RestartSec=3 + +# Hardening — the controller only needs its own tree + the network. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/panel +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..7ea9ec7d --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,21 @@ +services: + postgres: + image: postgres:17-alpine + container_name: panel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: panel + POSTGRES_PASSWORD: panel_dev + POSTGRES_DB: panel + ports: + - "127.0.0.1:5432:5432" + volumes: + - panel_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U panel -d panel"] + interval: 5s + timeout: 3s + retries: 10 + +volumes: + panel_pgdata: diff --git a/docs/ADMIN.md b/docs/ADMIN.md new file mode 100644 index 00000000..c61a7d51 --- /dev/null +++ b/docs/ADMIN.md @@ -0,0 +1,152 @@ +# Admin accounts, password reset, and Steam + +## First-admin bootstrap + +On every start the controller checks the `users` table +(`controller/cmd/controller/auth.go`, `bootstrapAdmin`). If it's +**empty**, it creates: + +- email: `admin@panel.local` +- role: `admin` +- password: **randomly generated** and printed to the log inside a loud + `ADMIN BOOTSTRAP` banner (systemd: `journalctl -u panel-controller`; + compose: `docker compose logs controller`). Log in once and change it + via the UI. + +To pre-seed a stable password instead (dev, CI, unattended installs), +set `PANEL_ADMIN_PASSWORD` in the controller's environment before first +boot. The banner then says the password came from the env var and never +prints it. + +Sessions are cookie-based (`panel_session`); passwords are stored as +argon2id hashes. + +## Resetting a lost admin password + +Use the `reset-admin` tool (`controller/cmd/reset-admin`). It writes the +DB directly, so it works even when you can't log in: + +```bash +go build -o bin/reset-admin ./controller/cmd/reset-admin # or use a prebuilt copy + +./bin/reset-admin --email admin@panel.local --password 'NewS3cret' \ + --db-url 'postgres://panel:...@127.0.0.1:5432/panel?sslmode=disable' +# → reset-admin: password updated for admin@panel.local +``` + +`--db-url` falls back to `$PANEL_DB_URL`, then to the controller's dev +default DSN. The controller does **not** need a restart — the next login +verifies against the new hash. Existing sessions stay valid; log out / +delete rows from `sessions` if you want them revoked. + +Hash-only mode (no DB access from the tool — e.g. you only have `psql`): + +```bash +./bin/reset-admin 'NewS3cret' +# → $argon2id$v=19$m=65536,t=3,p=2$...$... +``` + +then apply it manually: + +```sql +UPDATE users + SET password_hash = '$argon2id$v=19$m=65536,t=3,p=2$...$...', + updated_at = NOW() + WHERE email = 'admin@panel.local'; +``` + +(Quote the hash in single quotes — it contains `$`.) + +With the managed `panel-postgres` container: + +```bash +docker exec -it panel-postgres psql -U panel -d panel +``` + +## Adding more users + +The account you set at install is the **first admin**. To add teammates, +open the **🎮 Steam Accounts** panel (sidebar) — this is the user-management +screen. As an admin you can: + +- **List** every panel user and their linked Steam ID. +- **Add a user** by their 17-digit **SteamID64**, as role `user` or `admin` + (`POST /api/users`). They then log in with **Sign in with Steam** — no + password for you to manage or hand out. +- **Link / unlink** a Steam ID on any account. + +The intended model: you (the install admin) sign in with your email + +password; everyone else you add is **Steam-based**. That's ideal for a +gaming community — players already have Steam. + +**Limitation, stated plainly:** there is currently **no UI/API to create +additional *email + password* users** — `POST /api/users` is Steam-only. +If you need a second non-Steam login, add it at the database level or with +`reset-admin --email --password ` (see +[Resetting a lost admin password](#resetting-a-lost-admin-password)). A +proper "invite a password user" flow is a known follow-up. + +## Sign in with Steam (operator login) + +Optional OpenID login for the dashboard. Two controller flags: + +- `-public-url https://panel.example.com` — the public base URL of the + dashboard. Steam's OpenID flow redirects back to + `/api/auth/steam/callback`, so this must be the URL your + browser actually uses (reverse-proxied name included). +- `-initial-admin-steam-id 7656119…` — your 17-digit SteamID64. + - Empty DB → the bootstrap admin is created with this Steam ID linked; + "Sign in with Steam" works from the first boot. + - Existing DB → the first admin gets retro-linked, **if** it has no + Steam ID yet (an existing different link is never clobbered). + +Users can also link/unlink Steam on their own account from the UI +(`/api/auth/steam/link-start`). Steam-linked users may sign in with +either method; Steam-only users (created by an admin via +`POST /api/users`) have no password at all. + +Find your SteamID64 at steamid.io or via your profile URL. + +## Steam credentials for paid games (`requires_steam_login`) + +Some dedicated servers refuse SteamCMD's `+login anonymous` — DayZ +(app 223350) is the shipped example; its provider declares +`requires_steam_login: true` in `modules/dayz/module.yaml`. Those +downloads need a real Steam account **that owns the game**. + +How you supply them — you don't pre-configure anything: + +1. Click **Update** (or create) on such an instance. +2. No cached credential → the API answers + `{"steam_login_required": true, ...}` and the dashboard pops the + **Steam login modal**. +3. Enter Steam username + password. If the account has Steam Guard, + the first attempt returns `{"need_guard": true}` and the modal + re-prompts for the emailed / mobile-authenticator code. +4. The controller runs a one-shot SteamCMD sidecar to validate the + login, then re-runs your original update with the now-cached + credential. + +Storage (see the header of `controller/cmd/controller/steamauth.go`): + +- The password is encrypted at rest with AES-GCM; the key is + HKDF-derived from the internal CA private key (so restoring + `data/ca` + the Postgres dump restores decryptability — no separate + key file). +- The Steam Guard **sentry** blob is stored and mounted into every later + SteamCMD run, so 2FA is a one-time ceremony per account. +- The password is never logged and never sent back to the UI. + +Managing stored accounts: `GET /api/steam/accounts` lists them (no +secrets), `DELETE /api/steam/accounts` removes one. Use a throwaway +account that owns only the games you host if you're uncomfortable +storing your main account's credential. + +## Out-of-band admin (dashboard is broken) + +A separate token-authed HTTP surface (`/admin/v1/*`) plus +`panelctl admin` exist for repair when the UI is down — see the +"Admin HTTP" section of the [README](../README.md). Auth is loopback +bypass on the controller host, or the `X-Panel-Admin-Token` header with +the token from `/admin-token` (auto-generated, mode 0600; +`panelctl admin token-show` prints it). diff --git a/docs/BACKUP.md b/docs/BACKUP.md new file mode 100644 index 00000000..e5a420d3 --- /dev/null +++ b/docs/BACKUP.md @@ -0,0 +1,106 @@ +# Backups and disaster recovery + +Two different things need backing up: + +1. **Instance backups** — game saves/worlds, made by the panel itself. +2. **Panel state** — the Postgres database + the controller's `data/` + directory. The panel does NOT back these up for you. + +## Instance backups (panel-side) + +`POST /api/instances/{id}/backups` (dashboard Backups tab, or +`panelctl backup create [description]`) spawns +a tar-sidecar container that mounts the instance's volumes and writes a +timestamped tarball to the **agent's** `--backup-dir` (default +`./data/backups//` on the agent host — not the controller, +unless they're the same box). + +Restore stops the game container first, then reverses the flow: +dashboard, or `panelctl backup restore `. +`panelctl backup list ` lists. + +**Scheduling:** the scheduler has a `backup` action type, so a nightly +auto-backup is one schedule row: + +```json +{ + "instance_id": "mc-1", + "trigger_kind": "cron", + "cron_spec": "0 0 4 * * *", + "action": {"type": "backup", "description": "Nightly auto-backup"} +} +``` + +**Retention is manual.** Nothing prunes old tarballs — list, pick, +delete (UI or `DELETE /api/instances/{id}/backups/{bkpId}`, which +removes the DB row). Watch your disk if you schedule backups. + +Instance backup tarballs live outside Postgres; copy +`data/backups/` off-host if you want them to survive the machine. + +## Panel state: what to save + +| What | Where | Why | +|---|---|---| +| Postgres dump | `pg_dump` (below) | users, agents, instances, assigned ports, schedules, backup index, Steam credential ciphertext | +| `data/ca/` | controller host | internal CA — all agent certs chain to it, and the Steam-credential encryption key is derived from it. **Losing it = re-pairing every agent + re-entering Steam creds.** | +| `data/admin-token` | controller host | out-of-band admin auth (regenerable, but scripts may pin it) | +| `data/certs/` | each agent host | that agent's cert (regenerable by re-pairing) | +| `data/instance-meta/` | each agent host | rehydrated from the controller on connect — no need to back up | +| `data/instances/` + named volumes | each agent host | the actual game data — covered by instance backups above, or copy raw | + +## pg_dump procedure + +Managed `panel-postgres` container (from `install.sh` / compose): + +```bash +docker exec panel-postgres pg_dump -U panel -d panel -Fc \ + > panel-$(date +%F).dump +``` + +External Postgres: `pg_dump -h dbhost -U panel -d panel -Fc > panel.dump`. + +Cron it; the dump is small (panel metadata, not game data). + +## DR runbook — restore to a new host + +Assumes you have: a Postgres dump, a copy of the controller's `data/` +directory (at minimum `data/ca/`), and (optionally) instance backup +tarballs / raw instance data from the agent hosts. + +1. **Fresh install, controller only:** + ```bash + PANEL_SKIP_AGENT=1 bash install.sh + sudo systemctl stop panel-controller + ``` +2. **Restore the database** (drop the schema the fresh boot created): + ```bash + docker exec -i panel-postgres psql -U panel -d postgres \ + -c 'DROP DATABASE panel;' -c 'CREATE DATABASE panel OWNER panel;' + docker exec -i panel-postgres pg_restore -U panel -d panel < panel-YYYY-MM-DD.dump + ``` +3. **Restore `data/`** over the fresh one — `data/ca/` is the critical + piece; keep ownership (`chown -R panel:panel /opt/panel/data`). +4. **Start the controller:** `sudo systemctl start panel-controller`. + Because the users table is non-empty, no new admin is bootstrapped — + your old logins work. +5. **Agents:** + - Same agent hosts, new controller IP/name → update `--controller` + in their units; their existing certs still chain to the restored CA. + If the controller's *hostname* changed, mint the server cert with + the new name (`-tls-hostnames`) — the CA is what matters. + - New agent hosts → pair them fresh ([PAIRING.md](PAIRING.md)) using + the **same agent-id** as before so instance rows re-attach, then + restore game data into `--data-root` / named volumes (or use the + panel's Restore on each instance from copied-in tarballs under + `--backup-dir//`). +6. **Verify:** dashboard lists agents as connected, instances present; + start one instance and check its world loaded. + +## What is NOT covered + +- No automatic offsite copies — everything above is manual/cron. +- Instance backup tarballs sit on the same disk as the instances by + default; point `--backup-dir` at other storage if that worries you. +- Steam Guard sentry blobs restore with the DB + `data/ca`; if either is + lost you'll redo the Steam login ceremony ([ADMIN.md](ADMIN.md)). diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 00000000..28d60bb4 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,313 @@ +# Installing panel + +Four supported paths, in order of least effort: + +1. [Linux one-liner](#linux-one-liner) — `install.sh` (recommended) +2. [Windows one-liner](#windows-one-liner) — `install.ps1` +3. [docker-compose](#docker-compose) — controller + agent + Postgres as containers +4. [From source](#from-source) — full manual control + +Then: [Postgres provisioning](#postgres-provisioning), +[flags reference](#controller-flags), [systemd](#systemd), +[reverse proxy + TLS](#reverse-proxy--real-tls), [firewall](#firewall-basics). + +--- + +## Linux one-liner + +Debian, Ubuntu, Fedora, CentOS/RHEL, Raspbian. Run as root: + +```bash +curl -fsSL https://raw.githubusercontent.com/dbledeez/panel/main/install.sh | sudo bash +``` + +What it does (idempotent — safe to re-run; existing containers, units, +CA, DB and passwords are kept, binaries and modules refreshed): + +1. Installs Docker via `get.docker.com` if missing. +2. Starts a managed `panel-postgres` container (unless you point it at + an existing DB with `PANEL_DB_URL`). +3. Builds (or downloads, with `PANEL_PREBUILT_URL`) `controller`, + `agent`, and `panelctl` into `/opt/panel/bin/`. +4. Boots the controller once to bootstrap the internal CA and the admin + account, then pairs the local agent over mTLS. +5. Installs and enables `panel-controller.service` and + `panel-agent.service`. + +Tunables (env vars, all optional — from `install.sh`'s header): + +| Env var | Meaning | Default | +|---|---|---| +| `PANEL_REPO_URL` | git/https base of the panel source | github repo | +| `PANEL_PREBUILT_URL` | tarball with prebuilt `bin/{controller,agent,panelctl}` + `modules/` (skips the Go build) | — | +| `PANEL_VERSION` | version string baked into the binaries | `git describe \|\| dev` | +| `PANEL_DIR` | install prefix | `/opt/panel` | +| `PANEL_DATA_DIR` | data directory | `$PANEL_DIR/data` | +| `PANEL_HTTP_PORT` | dashboard port | `8080` | +| `PANEL_GRPC_PORT` | agent gRPC port | `8443` | +| `PANEL_ADMIN_PASSWORD` | stable first-boot admin password | generated by the controller | +| `PANEL_DB_URL` | use an EXISTING Postgres instead of the managed container | — | +| `PANEL_DB_PASSWORD` | password for the managed `panel-postgres` container | generated | +| `PANEL_SKIP_AGENT=1` | controller only (add agents on other boxes later — see [PAIRING.md](PAIRING.md)) | `0` | + +Example — controller-only box on a custom port with a pinned admin password: + +```bash +PANEL_SKIP_AGENT=1 PANEL_HTTP_PORT=9090 PANEL_ADMIN_PASSWORD='s3cret' \ + bash install.sh +``` + +## Windows one-liner + +PowerShell 5+, run **as Administrator**: + +```powershell +irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex +``` + +**Docker Desktop** is required (it runs Postgres + your game servers). If it +isn't installed, the installer offers to install it for you via `winget`. +Because Docker Desktop needs WSL2/virtualization and usually a **reboot**, the +Windows install is effectively two steps the first time: + +1. Run the command above. If Docker is missing it installs Docker Desktop and + then asks you to reboot + start Docker Desktop (wait for the whale icon to + settle). +2. Re-run the exact same command — it picks up from there and finishes. + +To skip the prompt: `-InstallDocker` (or `PANEL_INSTALL_DOCKER=1`) auto-installs +Docker without asking; `-SkipDockerInstall` keeps the old fail-if-missing +behavior. On a box that already has Docker Desktop running, it's a genuine +one-liner. + +> **Windows note:** the controller runs great on Windows. Running game +> *servers* on Windows uses Docker Desktop, where the agent automatically +> switches modules from host networking to published bridge ports so they're +> reachable — but the smoothest, fully-proven game-hosting path is a Linux +> agent. See [NETWORKING.md](NETWORKING.md). + +Parameters (mirror the Linux env vars): `-RepoUrl`, `-PrebuiltUrl`, +`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080), +`-GrpcPort` (8443), `-AdminEmail`, `-AdminPassword`, `-DbUrl`, `-DbPassword`, +`-DbContainer`, `-DbPort`, `-DbVolume`, `-SkipAgent`, `-InstallDocker`, +`-SkipDockerInstall`. The same `PANEL_*` env vars are honored as defaults. + +When run in a real console it prompts you to choose the admin email + password +up front (no log-reading); when piped (`irm | iex`) it uses env/param values or +generates a password. + +The controller and agent are registered as **logon-time Scheduled +Tasks**. For a hardened always-on Windows service, wrap the same command +lines with [nssm](https://nssm.cc): +`nssm install panel-controller C:\Panel\bin\controller.exe ...`. + +## docker-compose + +A production single-box stack (Postgres + controller + agent) lives at +`deploy/docker-compose.yml`. From the repo root: + +```bash +cp deploy/.env.example deploy/.env # then edit deploy/.env (PANEL_DB_PASSWORD is required) +docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build +``` + +First run: watch `docker compose logs controller` for the +`ADMIN BOOTSTRAP` banner (generated admin password), then mint a pair +token in the UI and pair the agent — see [PAIRING.md](PAIRING.md). + +> **Security note (from the compose file itself):** the agent container +> mounts the **host** Docker socket to manage game-server containers. +> Docker-socket access is root-equivalent on the host. Run this stack +> only on a host you fully trust and keep the dashboard behind auth/TLS. + +## From source + +Prereqs: Go 1.26+, Docker, [`buf`](https://buf.build/docs/installation/) +on PATH for proto stubs. + +```bash +buf generate # proto stubs (once per clone / .proto change) +go build -o bin/controller ./controller/cmd/controller +go build -o bin/agent ./agent/cmd/agent +go build -o bin/panelctl ./controller/cmd/panelctl + +# 1) Postgres (dev container) — or see "Postgres provisioning" below +docker compose -f docker-compose.dev.yml up -d + +# 2) Controller — first run prints the ADMIN BOOTSTRAP banner +./bin/controller + +# 3) Agent (same box, dev): plaintext is fine locally +./bin/agent --insecure --modules-dir=./modules \ + --data-root=./data/instances --meta-dir=./data/instance-meta \ + --backup-dir=./data/backups +``` + +For production, pair the agent instead of `--insecure` +([PAIRING.md](PAIRING.md)) and run both under systemd (below). + +There is also a root `Makefile` (`make build`, `make test`, `make vet`, +`make install-local`, ...). + +--- + +## Postgres provisioning + +The controller needs Postgres (17 is what the managed container and +compose stack use; the schema is plain SQL with embedded migrations, run +automatically at controller start). + +Bring your own instance: + +```sql +CREATE ROLE panel LOGIN PASSWORD 'choose-a-password'; +CREATE DATABASE panel OWNER panel; +``` + +Point the controller at it via either: + +- `--db-url 'postgres://panel:...@dbhost:5432/panel?sslmode=disable'` +- `PANEL_DB_URL` env var (honored when `--db-url` is left at its default) + +The default (dev) DSN is +`postgres://panel:panel_dev@127.0.0.1:5432/panel?sslmode=disable`. + +Or let `install.sh` run the `panel-postgres` container for you. + +--- + +## Controller flags + +From `controller/cmd/controller/main.go`: + +| Flag | Default | Meaning | +|---|---|---| +| `-grpc` | `:8443` | gRPC listen address (both Agent + Panel services) | +| `-http` | `:8080` | HTTP dashboard + REST API listen address | +| `-ca-dir` | `./data/ca` | directory holding internal CA + server cert (auto-generated on first run) | +| `-tls` | `auto` | `auto` \| `on` \| `off` — `auto` enables mTLS if CA + server cert present | +| `-tls-hostnames` | — | comma-separated SAN DNS names to add to the server cert (e.g. `panel.example.com`) | +| `-log-level` | `info` | `debug`, `info`, `warn`, `error` | +| `-db-url` | dev DSN | Postgres connection string (env `PANEL_DB_URL` also honored) | +| `-public-url` | — | public base URL of the dashboard (needed for Steam OpenID sign-in — see [ADMIN.md](ADMIN.md)) | +| `-initial-admin-steam-id` | — | 17-digit SteamID64 linked to the first admin, enabling Sign in with Steam ([ADMIN.md](ADMIN.md)) | + +Env vars: `PANEL_DB_URL`, `PANEL_ADMIN_PASSWORD` (first-boot admin +password preseed). + +## Agent flags + +From `agent/cmd/agent/main.go`: + +| Flag | Default | Meaning | +|---|---|---| +| `--controller` | `localhost:8443` | controller gRPC address `host:port` | +| `--agent-id` | mTLS cert CN, or hostname when `--insecure` | agent identifier | +| `--modules-dir` | `./modules` | path to modules directory | +| `--data-root` | `./data/instances` | root directory for instance data | +| `--meta-dir` | `./data/instance-meta` | per-instance sidecar metadata (survives agent restart) | +| `--backup-dir` | `./data/backups` | where instance backup archives are written | +| `--cert-dir` | `./data/certs` | holds `agent.crt` + `agent.key` + `ca.crt` after pairing | +| `--heartbeat` | `10s` | heartbeat interval | +| `--insecure` | `false` | force plaintext (no TLS) — dev/local only | +| `--pair-token` | — | pair mode: fetch CA + signed cert from `--pair-url` and exit ([PAIRING.md](PAIRING.md)) | +| `--pair-url` | `http://localhost:8080` | controller HTTP URL for the pair endpoints | +| `--log-level` | `info` | `debug`, `info`, `warn`, `error` | + +## systemd + +Reference units live in `deploy/systemd/` (`panel-controller.service`, +`panel-agent.service`) — `install.sh` installs them for you. Highlights: + +- Both run as the `panel` user from `WorkingDirectory=/opt/panel`; the + agent additionally gets `SupplementaryGroups=docker`. +- `/opt/panel/panel.env` (optional `EnvironmentFile`) overrides + `PANEL_HTTP_PORT`, `PANEL_GRPC_PORT`, `PANEL_DB_URL`, + `PANEL_ADMIN_PASSWORD`, ... +- The controller unit is hardened (`ProtectSystem=strict`, + `ReadWritePaths=/opt/panel`); the agent's hardening is intentionally + lighter because it drives the Docker socket. + +```bash +sudo cp deploy/systemd/*.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now panel-controller panel-agent +journalctl -u panel-controller -f # ADMIN BOOTSTRAP banner lives here +``` + +## Reverse proxy + real TLS + +The dashboard speaks plain HTTP on `:8080`; put a reverse proxy with a +real certificate in front of it for anything internet-facing. **Only +proxy the HTTP port** — agents talk to gRPC `:8443` directly (mTLS, +LAN/VPN is fine; see [PAIRING.md](PAIRING.md)). + +The dashboard uses SSE (`/api/events`) — disable proxy buffering on +that path or the live console will lag. + +### Caddy + +``` +panel.example.com { + reverse_proxy 127.0.0.1:8080 { + flush_interval -1 # SSE: stream immediately + } +} +``` + +That's the whole file — Caddy handles Let's Encrypt automatically. + +### nginx + +```nginx +server { + listen 443 ssl; + server_name panel.example.com; + ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem; + + client_max_body_size 512m; # scenario/mod zip uploads + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/events { # SSE — no buffering + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } +} +``` + +If the proxy terminates TLS with a public hostname and agents pair +through it, add that hostname to the controller cert SANs: +`-tls-hostnames panel.example.com`. + +## Firewall basics + +On the **controller** box: + +| Port | Proto | Who needs it | Expose to internet? | +|---|---|---|---| +| 8080 (HTTP) | tcp | browsers, `panelctl admin`, pairing agents | only via the reverse proxy above | +| 8443 (gRPC) | tcp | agents + `panelctl` | no — LAN/VPN preferred; mTLS protects it if you must | +| 5432 (Postgres) | tcp | controller only | never | + +On each **agent** box: no inbound panel ports at all — the agent dials +out to the controller. You only open the **game ports** of the instances +it hosts; see [NETWORKING.md](NETWORKING.md) for the per-game table. + +Example (ufw, controller on a LAN): + +```bash +sudo ufw allow from 192.168.1.0/24 to any port 8080 proto tcp +sudo ufw allow from 192.168.1.0/24 to any port 8443 proto tcp +``` diff --git a/docs/NETWORKING.md b/docs/NETWORKING.md new file mode 100644 index 00000000..961c616c --- /dev/null +++ b/docs/NETWORKING.md @@ -0,0 +1,105 @@ +# Networking for normal humans + +You run panel at home, your friends want to join your server, and you +have a consumer router. This page is for you. + +## The 60-second version + +1. panel gives every instance its **host ports** at create time (see + [what the panel auto-allocates](#what-the-panel-auto-allocates)). + The dashboard's instance card / Ports panel shows the exact numbers. +2. Log in to your router and **forward those ports** (right protocol — + most games are UDP) to the LAN IP of the **agent box** running that + instance. +3. Give your friends `your-public-ip:game-port`. + +You do **not** need to forward the panel's own ports (8080/8443) for +people to *play* — only to administer the panel from outside. + +## What the panel auto-allocates + +At instance create time the controller assigns each declared port a +**host port** from the agent's configured port window — default +**7000–9999** (`controller/cmd/controller/portalloc.go`). You can set a +per-agent window in the agent edit modal (e.g. box A gets 7000–7999, +box B gets 8000–8999). The allocator: + +- reserves ports of **stopped** instances too (they come back), +- probes live sockets so it won't hand out a port some other process holds, +- honors manual overrides from the Ports UI when they fit the window, +- errors when the window is exhausted (widen it or delete instances). + +So the *actual* host port is usually **not** the game's default port — +always read it off the dashboard. Ports marked `internal` in the module +manifest (RCON, admin APIs) are **never** exposed on the host and never +need forwarding. + +## Per-game port table + +Defaults from each `modules//module.yaml` (`ports:`). "Forward" +means players/queries need it; internal ports are panel-only. + +| Game | Port (default) | Proto | Forward? | Notes | +|---|---|---|---|---| +| 7 Days to Die | 26900, 26901, 26902 | udp | yes (all 3, contiguous) | 8080/tcp web dashboard optional; telnet 8081 internal | +| ARK: Survival Ascended | 7777, 7778 game + 27015 query | udp | yes | RCON 27020/tcp internal | +| Barotrauma | 27015 game + 27016 query | udp | yes | | +| Conan Exiles | 7777 game + 7778 pinger + 27015 query | udp | yes | RCON 25575/tcp internal | +| Core Keeper | 27015 | udp | yes | | +| DayZ | 2302 game/query | udp | yes | 2303/2304 optional; BE RCON 2305 internal | +| Dragonwilds | 7777 | udp | yes | | +| Empyrion | 30000–30003 (game, query, client, EAC) | udp | yes (all 4) | CSW 30004 internal; EAH API 30007/tcp only if you use EAH | +| Enshrouded | 15637 | udp | yes | | +| Factorio | 34197 | udp | yes | RCON 27015/tcp internal | +| Minecraft Bedrock | 19132 (+19133 IPv6) | udp | yes | | +| Minecraft Java | 25565 | tcp | yes | RCON 25575 internal | +| Palworld | 8211 game + 27015 query | udp | yes | RCON 25575/tcp internal | +| Project Zomboid | 16261, 16262 | udp | yes (both) | RCON 27015/tcp internal | +| Rust | 28015 | udp | yes | RCON 28016 internal; Rust+ 28082/tcp optional | +| Satisfactory | 7777/udp game + 8888/tcp reliable | both | yes (both) | | +| Sons of the Forest | 8766 game + 27016 query | udp | yes | | +| Soulmask | 8777 game + 27015 query | udp | yes | admin 8888/tcp internal | +| Terraria | 7777 | tcp | yes | | +| V Rising | 9876 game + 9877 query | udp | yes | | +| Valheim | 2456, 2457, 2458 | udp | yes (all 3, contiguous) | | +| Vein | 7777 game + 27015 query | udp | yes | RCON 7878/udp internal | +| Windrose | 7777 | udp | yes | | + +Remember: these are the *container defaults* — forward the **host ports +the panel actually assigned** (dashboard → instance → Ports). + +## Forwarding ports on a generic router + +Every router is different; the shape is always: + +1. Browse to your router's admin page — usually `http://192.168.1.1` or + `http://192.168.0.1` (it's the "Default Gateway" in + `ipconfig` / `ip route`). +2. Find **Port Forwarding** — sometimes under "NAT", "Virtual Server", + "Gaming", or "Advanced". +3. Add a rule per port (or range): external port = internal port = + the host port panel assigned; protocol per the table above; internal + IP = the LAN address of the **agent** box. +4. Give the agent box a **static LAN IP** (DHCP reservation) so the rule + doesn't break after a reboot. +5. Test from *outside* your network (phone on cellular). Sites like + canyouseeme.org only test TCP — most game ports are UDP, so the real + test is a friend joining. + +If you're behind **CGNAT** (ISP gives you a shared public IP — common on +cellular/fiber ISPs), port forwarding can't work; use a VPN/tunnel +(WireGuard on a cheap VPS, playit.gg, Tailscale Funnel, etc.) or ask +your ISP for a public IP. + +## What about `opnfwd`? + +You may notice an "OPNsense auto-forward" card in the admin settings and +`opnfwd_*` code in the tree. That is **maintainer-specific automation** +for a home lab running an OPNsense firewall: when enabled, the panel +calls the OPNsense API to create port forwards automatically on instance +create/delete. + +It is **off by default** (`panel_settings.opnfwd_enabled` must be +explicitly set to `true`) and is irrelevant to normal users — if you +don't run OPNsense, ignore it entirely and forward ports on your router +as described above. diff --git a/docs/PAIRING.md b/docs/PAIRING.md new file mode 100644 index 00000000..a27ee4ca --- /dev/null +++ b/docs/PAIRING.md @@ -0,0 +1,116 @@ +# Agent pairing (mTLS) + +The controller runs an internal CA (`--ca-dir`, default `./data/ca`, +auto-generated on first run). Every agent gets a client certificate +signed by that CA via a one-time pairing token. After pairing, all +controller↔agent gRPC traffic is mutually-authenticated TLS, and any +"agent" without a CA-signed cert is rejected during the TLS handshake — +it never reaches application code. + +`install.sh` / `install.ps1` pair the local agent for you. Do this by +hand only for **additional** hosts or manual installs. + +## The flow + +### 1. Mint a one-time token (admin) + +**Dashboard:** Agents view → **Pair agent** → optional description + +expiry (default 15 minutes) → **Generate**. The token is displayed +**exactly once** — only its SHA-256 hash is stored, so copy it now. + +**Or curl** (with an authenticated admin session cookie): + +```bash +curl -X POST https://panel.example.com/api/admin/pair-tokens \ + -H 'Content-Type: application/json' \ + -b "panel_session=$SESSION" \ + -d '{"description":"gamebox-2","expires_minutes":15}' +# → {"id":"pt_…","token":"…copy me…","expires_at":"…"} +``` + +(`GET /api/admin/pair-tokens` lists outstanding tokens.) + +### 2. Run the agent once in pair mode (new host) + +```bash +./bin/agent \ + --pair-url https://panel.example.com \ + --pair-token 'THE_TOKEN' \ + --agent-id gamebox-2 \ + --cert-dir ./data/certs +``` + +What happens (`agent/cmd/agent/pair.go`): + +1. Fetches the controller CA cert from `GET /api/pair/ca`. This first + fetch skips TLS verification — the one-time token is what gates + actual cert issuance. +2. Generates an RSA-2048 keypair **locally** — the private key never + leaves the host. +3. Builds a CSR with CN = the agent id and POSTs + `{token, agent_id, csr_pem}` to `POST /api/pair/issue`, this time + verifying the server against the CA it just fetched. +4. Writes `ca.crt`, `agent.crt`, `agent.key` (key mode 0600) into + `--cert-dir` and **exits**. + +`--pair-url` is the controller's **HTTP** dashboard port (8080 / +your reverse proxy), not the gRPC port. + +### 3. Run the agent normally + +```bash +./bin/agent --controller panel.example.com:8443 \ + --modules-dir ./modules --data-root ./data/instances \ + --meta-dir ./data/instance-meta --backup-dir ./data/backups \ + --cert-dir ./data/certs +``` + +The agent auto-detects the cert files in `--cert-dir` and dials with +mTLS. Its identity (`--agent-id` default) is the cert CN. No +`--insecure` anywhere. + +## Multi-host setup + +- **Controller box:** one controller + Postgres. Agents dial *out* to + gRPC `:8443`, so agent boxes need **no inbound panel ports** at all. +- **Each game box:** copy (or clone) the repo's `modules/` directory and + the agent binary, pair once (steps above), run under systemd + (`deploy/systemd/panel-agent.service` — set + `--controller :8443`). +- **Port windows:** give each agent a disjoint port range in the agent + edit modal (e.g. 7000–7999 / 8000–8999) so instance host ports never + collide in your router's forwarding rules. +- **Hostnames:** the controller's server cert covers its hostname/IPs at + generation time. If agents will dial a DNS name (or a reverse-proxied + public name), start the controller with + `-tls-hostnames panel.example.com` **before** pairing so the SAN is in + the cert. + +`panelctl` uses the same certs: it defaults to mTLS with the certs in +`--cert-dir` and falls back to plaintext only against `--insecure` +controllers. + +## How rogue agents are rejected + +- The controller's gRPC listener (in `-tls auto|on` mode) requires a + client certificate **signed by its internal CA**. A connection without + one fails the TLS handshake — before any RPC is served. +- Pair tokens are one-time, short-lived (default 15 min), and stored + only as hashes; a leaked *expired* token is useless, and a used token + can't be replayed. +- The agent's identity is the certificate CN, so an agent can't + impersonate another agent id without a cert bearing that CN. +- Compromised host? Delete its cert material and stop trusting it — + and treat any secrets that agent held (instance data) as exposed. + +## Troubleshooting + +- **`connection refused` in pair mode** — `--pair-url` points at gRPC + `:8443`; it must be the HTTP port (`:8080` or your proxy URL). +- **TLS handshake errors after pairing** — controller cert doesn't cover + the name the agent dials. Re-run the controller with `-tls-hostnames`, + delete `data/ca` server cert *only if you know what you're doing* + (re-pairing all agents), or dial by the covered IP. +- **Dev shortcut** — `--insecure` on the agent + `-tls off` on the + controller skips all of this on a trusted single box. Never on a + network you don't own. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..d91136be --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,128 @@ +# Architecture + +How panel actually fits together. (An earlier revision of this document +described the original design — SvelteKit, SQLite, Casbin, SFTP; none of +that shipped. This is the real stack.) + +## Topology + +``` +┌───────────────────────────────┐ ┌────────────────────────────┐ +│ Controller │ gRPC/mTLS │ Agent (per host) │ +│ - Embedded web dashboard │◄──────────►│ - one persistent bidi │ +│ - REST /api/* + SSE events │ :8443 │ stream (Agent.Connect) │ +│ - Postgres (all state) │ │ - Docker runtime │ +│ - Internal CA + pairing │ │ - module loader │ +│ - Scheduler (cron + CEL) │ │ - RCON adapters │ +│ - Admin HTTP /admin/v1/* │ │ - state tracker │ +│ - Prometheus /metrics │ │ - SteamCMD / tar sidecars │ +└───────────────────────────────┘ └────────────────────────────┘ + ▲ HTTP :8080 │ docker.sock + browser / panelctl / curl game containers (one per instance) +``` + +One controller orchestrates N agents. Agents connect **outbound only** +— an agent box needs no inbound panel ports, just its game ports. + +## Components + +**Controller** (`controller/cmd/controller`) — a single Go binary: + +- **Embedded vanilla-JS dashboard.** No framework, no build step: + `static/new.html` (shell) + `static/rcc.js` + `static/rcc.css`, + compiled in via `//go:embed` and served hash-stamped with immutable + caching. The legacy single-file `static/index.html` is a frozen + "classic" UI, opt-in via the `panel_ui=classic` cookie. Live data + reaches the browser over **SSE** (`GET /api/events`, with + `Last-Event-ID` resume), not WebSockets. +- **Postgres** for everything durable: users/sessions, agents, + instances + assigned ports, schedules, backup index, pair-token + hashes, Steam credential ciphertext. Migrations are embedded and run + at startup. (No SQLite anywhere.) +- **Internal CA** (`controller/internal/ca`, material in `data/ca/`) — + issues the gRPC server cert and, via the pairing flow, each agent's + client cert. `-tls auto` turns mTLS on whenever the CA exists. +- **Scheduler** — robfig/cron 6-field triggers **and** CEL expressions + evaluated against the live event bus with sustained-for windows + ("empty for 30 minutes → stop"). Actions: rcon, instance + start/stop/restart, backup. +- **Auth** — argon2id + session cookies; roles are just + `admin`/`user`; optional Sign-in-with-Steam (OpenID). No + OIDC/WebAuthn/Casbin. +- **Admin HTTP** (`/admin/v1/*`) — out-of-band repair surface, gated by + loopback or the `X-Panel-Admin-Token` header, usable when the + dashboard or a session is broken. + +**Agent** (`agent/cmd/agent`) — a single Go binary per game host: + +- Dials the controller's gRPC port and holds one bidi stream; all + control traffic (create/start/stop/update/backup/RCON/file ops, + events, heartbeats) is multiplexed over `oneof` envelope messages + (`proto/panel/v1`). Reconnect + rehydrate: instance metadata survives + agent restarts via `--meta-dir` sidecar files rebuilt from the + controller's DB. +- **Docker-per-game runtime** (`agent/internal/runtime`): one container + per instance, named volumes and/or bind mounts under `--data-root`, + optional host networking. `panel-*` images are **auto-built** from + the module's in-tree Dockerfile on first use; third-party images are + pulled. Windows-only servers run under Wine + Xvfb in those images. +- **Sidecars**: `steamcmd/steamcmd` for installs/updates (Windows-depot + and beta-branch capable), alpine tar containers for backup/restore. +- **State tracker** (`agent/internal/state`): container state + RCON + polling + log-line pattern matching → typed PlayerEvent/AppState + events streamed upstream and fanned out to the browser (SSE), the + scheduler (CEL), and `panelctl watch`. +- **RCON adapters** (`agent/internal/rcon`): `source_rcon`, `telnet`, + `websocket_rcon` (Rust), `stdio`, `be_rcon` (DayZ), `docker_exec_rcon` + — all redial on EOF. + +**panelctl** (`controller/cmd/panelctl`) — operator CLI. gRPC surface +for day-to-day lifecycle (agents/instances/create/start/stop/rcon/ +backup/watch), plus a `panelctl admin` HTTP surface for the repair +endpoints. Uses the same mTLS certs as agents. + +**Modules** (`modules//module.yaml`) — declarative manifests: image +or Dockerfile, ports, env, volumes, config files + templated values, +RCON adapter + commands, state-source polls, log event patterns, update +providers, appearance. The loader (`pkg/module`) is strict +(`KnownFields`) — unknown keys are rejected, not ignored. The README's +"Module manifest" section documents the schema by example. + +## Security model + +- **Control plane:** gRPC with mTLS; agent certs are issued via + one-time pairing tokens ([PAIRING.md](PAIRING.md)) and verified at the + TLS handshake (`tls.RequireAndVerifyClientCert`). Plaintext exists + only as an explicit dev opt-in (`--insecure` / `-tls off`). +- **Browser:** session cookies over your reverse proxy's TLS + ([INSTALL.md](INSTALL.md)). +- **Secrets:** per-instance generated secrets in instance metadata; + Steam credentials AES-GCM-encrypted with a key HKDF-derived from the + CA key ([ADMIN.md](ADMIN.md)). +- **Blast radius:** the agent needs the Docker socket — root-equivalent + on its host by design. Run agents on hosts you trust and don't expose + the controller's gRPC port to the internet without cause. + +## Data flow, end to end + +1. Operator clicks Create → `POST /api/instances` → controller + allocates host ports (per-agent window, DB-reserved — see + [NETWORKING.md](NETWORKING.md)) → `InstanceCreate` envelope to the + agent. +2. Agent resolves the manifest (env filtering, `$INSTANCE_ID` / + `$DATA_PATH` substitution), auto-builds/pulls the image, creates the + container, persists sidecar metadata. +3. Update → SteamCMD sidecar into the game volume. Start → container + start; state tracker begins polling/tailing; readiness regex flips + the card to Running. +4. Events stream to the controller → Postgres aggregates + SSE fanout → + dashboard, scheduler CEL evaluation, `panelctl watch`. + +## Not implemented (on purpose or yet) + +- OIDC / WebAuthn / Casbin ACLs — local auth + Steam OpenID only. +- SFTP — the web file manager (docker exec/cp with bind-mount fast + path) covers the common case. +- Host-mode (non-Docker) runtime — manifests declare it, the agent + fails loudly if asked. +- Multi-tenancy/billing — this is for someone running their own boxes. diff --git a/go.work b/go.work new file mode 100644 index 00000000..6a67b917 --- /dev/null +++ b/go.work @@ -0,0 +1,10 @@ +go 1.26 + +use ( + ./agent + ./controller + ./pkg + ./proto + ./tools/amp-distill + ./tools/module-meta-dump +) diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 00000000..501c6993 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,26 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..3522b947 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,363 @@ +<# +.SYNOPSIS + Panel one-line installer for Windows — controller + agent + Postgres on one box. + +.DESCRIPTION + PowerShell 5+ equivalent of install.sh. Checks Docker Desktop, starts a + panel-postgres container, builds (or downloads) the panel binaries with the + version baked in via ldflags, boots the controller once to bootstrap the CA + and admin account, pairs the local agent, and registers both as logon-time + Scheduled Tasks so they survive reboots. + + For a hardened always-on Windows service, wrap the same ExecStart lines with + nssm (https://nssm.cc): `nssm install panel-controller \bin\controller.exe ...`. + +.EXAMPLE + powershell -ExecutionPolicy Bypass -File install.ps1 -AdminPassword secret123 +#> +[CmdletBinding()] +param( + [string]$RepoUrl = $(if ($env:PANEL_REPO_URL) { $env:PANEL_REPO_URL } else { 'https://git.pdxtechs.com/dbledeez/panel' }), + # Defaults to the published v0.9.1 windows/amd64 prebuilt zip so the installer + # needs no Go toolchain. Pass -PrebuiltUrl '' (empty) to force a source build. + # A local checkout (running install.ps1 from inside the repo) wins over this. + [string]$PrebuiltUrl = $(if ($null -ne $env:PANEL_PREBUILT_URL) { $env:PANEL_PREBUILT_URL } else { 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.1/panel-v0.9.1-windows-amd64.zip' }), + [string]$Version = $env:PANEL_VERSION, + [string]$InstallDir = $(if ($env:PANEL_DIR) { $env:PANEL_DIR } else { 'C:\Panel' }), + [int] $HttpPort = $(if ($env:PANEL_HTTP_PORT) { [int]$env:PANEL_HTTP_PORT } else { 8080 }), + [int] $GrpcPort = $(if ($env:PANEL_GRPC_PORT) { [int]$env:PANEL_GRPC_PORT } else { 8443 }), + [string]$AdminEmail = $env:PANEL_ADMIN_EMAIL, + [string]$AdminPassword = $env:PANEL_ADMIN_PASSWORD, + [string]$DbUrl = $env:PANEL_DB_URL, + [string]$DbPassword = $env:PANEL_DB_PASSWORD, + [string]$DbContainer = $(if ($env:PANEL_DB_CONTAINER) { $env:PANEL_DB_CONTAINER } else { 'panel-postgres' }), + [int] $DbPort = $(if ($env:PANEL_DB_PORT) { [int]$env:PANEL_DB_PORT } else { 5432 }), + [string]$DbVolume = $(if ($env:PANEL_DB_VOLUME) { $env:PANEL_DB_VOLUME } else { 'panel_pgdata' }), + [switch]$SkipAgent, + # -InstallDocker (or PANEL_INSTALL_DOCKER=1) auto-installs Docker Desktop via + # winget without prompting; -SkipDockerInstall keeps the old fail-if-missing. + [switch]$InstallDocker, + [switch]$SkipDockerInstall +) +$ErrorActionPreference = 'Stop' +# Run two independent panels on one host by giving each install distinct +# -InstallDir, -HttpPort, -GrpcPort, -DbContainer, -DbPort and -DbVolume. +$PgContainer = $DbContainer + +function Log($msg) { Write-Host "[panel] $msg" -ForegroundColor Green } +function Warn($msg) { Write-Host "[panel] $msg" -ForegroundColor Yellow } +function Fail($msg) { Write-Host "[panel] ERROR: $msg" -ForegroundColor Red; exit 1 } + +# The literal one-liner to paste to run this installer again. Used by the +# "you must reboot / start Docker, then run this again" messages so the user +# is never left guessing what "re-run" means. +$RerunCommand = 'irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex' + +# Show-RerunAndExit prints a loud, unmissable "not done yet — run this again" +# banner with the exact command, then exits. Windows Docker Desktop needs a +# reboot on first install, so a second run of the installer is expected and +# normal — this makes that crystal clear instead of a terse error. +function Show-RerunAndExit($why) { + Write-Host '' + Write-Host '===============================================================' -ForegroundColor Yellow + Write-Host ' ACTION NEEDED — the panel is not installed yet' -ForegroundColor Yellow + Write-Host '===============================================================' -ForegroundColor Yellow + Write-Host " $why" -ForegroundColor Yellow + Write-Host '' + Write-Host ' Do this, in order:' -ForegroundColor Yellow + Write-Host ' 1. REBOOT if Windows/winget asked you to (Docker Desktop needs' -ForegroundColor Yellow + Write-Host ' WSL2 + virtualization, which require a restart).' -ForegroundColor Yellow + Write-Host ' 2. Start Docker Desktop and wait until the whale icon in the' -ForegroundColor Yellow + Write-Host ' system tray stops animating (daemon ready).' -ForegroundColor Yellow + Write-Host ' 3. >>> RUN THIS INSTALLER AGAIN <<< in an Administrator' -ForegroundColor Green + Write-Host ' PowerShell window. It will pick up from here and finish:' -ForegroundColor Green + Write-Host '' + Write-Host " $RerunCommand" -ForegroundColor Cyan + Write-Host '' + Write-Host ' (Running it again is safe and expected — it is idempotent.)' -ForegroundColor Yellow + Write-Host '===============================================================' -ForegroundColor Yellow + exit 1 +} + +# ---- 0. Admin account: prompt when interactive ----------------------------- +# A real person (interactive console) is asked to choose the admin login + +# password up front. When run non-interactively (irm | iex with no console, +# CI) the prompt is skipped: -AdminPassword / $env:PANEL_ADMIN_PASSWORD is used +# and a missing password is generated by the controller as before. +if (-not $AdminPassword -and [Environment]::UserInteractive -and $Host.UI.RawUI) { + Write-Host '' + Write-Host '-- Set up your panel admin login --' -ForegroundColor Cyan + if (-not $AdminEmail) { + $e = Read-Host 'Admin email [admin@panel.local]' + if ($e) { $AdminEmail = $e } + } + while (-not $AdminPassword) { + $s1 = Read-Host 'Admin password (min 8 chars, blank = auto-generate)' -AsSecureString + $p1 = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s1)) + if (-not $p1) { Warn 'no password entered - the controller will generate one and print it.'; break } + if ($p1.Length -lt 8) { Write-Host ' password too short (need 8+); try again.'; continue } + $s2 = Read-Host 'Confirm password' -AsSecureString + $p2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto( + [Runtime.InteropServices.Marshal]::SecureStringToBSTR($s2)) + if ($p1 -ne $p2) { Write-Host ' passwords did not match; try again.'; continue } + $AdminPassword = $p1 + } +} + +# ---- 1. Docker Desktop ----------------------------------------------------- +# The panel needs Docker for Postgres + every game server. Unlike Linux we +# can't fully script Docker Desktop (it's a GUI install + WSL2 + reboot), but +# we CAN kick it off via winget and guide the rest. -InstallDocker / env +# PANEL_INSTALL_DOCKER=1 answers "yes" non-interactively; -SkipDockerInstall +# forces the old fail-fast behavior. +if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + $wantDocker = $InstallDocker -or ($env:PANEL_INSTALL_DOCKER -eq '1') + if (-not $wantDocker -and -not $SkipDockerInstall -and [Environment]::UserInteractive -and $Host.UI.RawUI) { + Write-Host '' + Write-Host 'Docker Desktop is required (runs Postgres + your game servers) and is not installed.' -ForegroundColor Yellow + $ans = Read-Host 'Install Docker Desktop now via winget? [Y/n]' + $wantDocker = ($ans -eq '' -or $ans -match '^[Yy]') + } + if ($wantDocker) { + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Fail @" +winget isn't available to auto-install Docker. Install Docker Desktop manually: + https://www.docker.com/products/docker-desktop/ +Then start it (wait for the whale icon to settle) and re-run this installer. +"@ + } + Log "installing Docker Desktop via winget (this is large; a reboot is usually required)" + winget install --id Docker.DockerDesktop -e --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -ne 0) { Fail "winget install of Docker Desktop failed (exit $LASTEXITCODE). Install it manually, then re-run." } + Show-RerunAndExit "Docker Desktop was just installed — the panel is NOT installed yet." + } else { + Fail "Docker not found. Install Docker Desktop (https://www.docker.com/products/docker-desktop/), start it, then re-run." + } +} +docker info *> $null +if ($LASTEXITCODE -ne 0) { + Show-RerunAndExit "Docker is installed but its daemon isn't running yet — the panel is NOT installed yet." +} +Log "docker present: $(docker --version)" + +# ---- 2. Postgres ----------------------------------------------------------- +New-Item -ItemType Directory -Force -Path "$InstallDir\bin", "$InstallDir\data" | Out-Null +$EnvFile = Join-Path $InstallDir 'panel.env' + +if ($DbUrl) { + Log "using external Postgres from -DbUrl" +} else { + docker inspect $PgContainer *> $null + if ($LASTEXITCODE -eq 0) { + Log "postgres container '$PgContainer' already exists - starting" + docker start $PgContainer | Out-Null + if (-not $DbPassword -and (Test-Path $EnvFile)) { + $DbPassword = (Get-Content $EnvFile | Where-Object { $_ -match '^PANEL_DB_PASSWORD=' } | + Select-Object -First 1) -replace '^PANEL_DB_PASSWORD=', '' + } + if (-not $DbPassword) { Fail "existing $PgContainer but no DbPassword known (pass -DbPassword, or 'docker rm -f $PgContainer' to start fresh)" } + } else { + if (-not $DbPassword) { + $DbPassword = -join ((1..32) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) }) + } + Log "starting postgres container '$PgContainer' on 127.0.0.1:$DbPort (volume $DbVolume)" + docker run -d --name $PgContainer --restart unless-stopped ` + -e POSTGRES_USER=panel -e "POSTGRES_PASSWORD=$DbPassword" -e POSTGRES_DB=panel ` + -p "127.0.0.1:${DbPort}:5432" ` + -v "${DbVolume}:/var/lib/postgresql/data" ` + postgres:17-alpine | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "docker run for postgres failed" } + } + $DbUrl = "postgres://panel:$DbPassword@127.0.0.1:$DbPort/panel?sslmode=disable" + Log "waiting for postgres" + $ready = $false + foreach ($i in 1..30) { + docker exec $PgContainer pg_isready -U panel -d panel *> $null + if ($LASTEXITCODE -eq 0) { $ready = $true; break } + Start-Sleep -Seconds 1 + } + if (-not $ready) { Fail "postgres did not become ready" } +} + +# ---- 3. Source / binaries --------------------------------------------------- +$Src = $null +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +if ($ScriptDir -and (Test-Path (Join-Path $ScriptDir 'go.work')) -and (Test-Path (Join-Path $ScriptDir 'controller'))) { + $Src = $ScriptDir + Log "using local checkout at $Src" + # Running from inside the repo means build THIS tree. Don't let the prebuilt + # default override it. An explicit -PrebuiltUrl or $env:PANEL_PREBUILT_URL still + # forces prebuilt (it won't equal the untouched default / the env is non-null). + $prebuiltDefault = 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.1/panel-v0.9.1-windows-amd64.zip' + if (($null -eq $env:PANEL_PREBUILT_URL) -and ($PrebuiltUrl -eq $prebuiltDefault)) { + $PrebuiltUrl = '' + } +} + +if ($PrebuiltUrl) { + Log "downloading prebuilt binaries: $PrebuiltUrl" + $zip = Join-Path $env:TEMP 'panel-prebuilt.zip' + Invoke-WebRequest -Uri $PrebuiltUrl -OutFile $zip -UseBasicParsing + Expand-Archive -Path $zip -DestinationPath $InstallDir -Force + Remove-Item $zip -Force + if (-not (Test-Path "$InstallDir\bin\controller.exe")) { Fail "prebuilt archive did not contain bin\controller.exe" } +} else { + if (-not $Src) { + if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Fail "git required to fetch sources (or pass -PrebuiltUrl)" } + $Src = Join-Path $InstallDir 'src' + if (Test-Path (Join-Path $Src '.git')) { + Log "updating source at $Src" + git -C $Src pull --ff-only + } else { + Log "cloning $RepoUrl" + git clone --depth 1 $RepoUrl $Src + } + } + if (-not (Get-Command go -ErrorAction SilentlyContinue)) { Fail "Go toolchain not found. Install Go >= 1.26 (https://go.dev/dl/) or pass -PrebuiltUrl." } + if (-not $Version) { + $Version = (git -C $Src describe --tags --always --dirty 2>$null) + if (-not $Version) { $Version = 'dev' } + } + $ldflags = "-s -w -X github.com/dbledeez/panel/pkg/version.Version=$Version" + Log "building panel $Version" + Push-Location $Src + try { + $env:CGO_ENABLED = '0' + go build -trimpath -ldflags $ldflags -o "$InstallDir\bin\controller.exe" ./controller/cmd/controller + if ($LASTEXITCODE -ne 0) { Fail 'controller build failed' } + go build -trimpath -ldflags $ldflags -o "$InstallDir\bin\agent.exe" ./agent/cmd/agent + if ($LASTEXITCODE -ne 0) { Fail 'agent build failed' } + go build -trimpath -ldflags $ldflags -o "$InstallDir\bin\panelctl.exe" ./controller/cmd/panelctl + if ($LASTEXITCODE -ne 0) { Fail 'panelctl build failed' } + } finally { Pop-Location } + Log "syncing game modules" + if (Test-Path "$InstallDir\modules") { Remove-Item "$InstallDir\modules" -Recurse -Force } + Copy-Item -Recurse "$Src\modules" "$InstallDir\modules" +} + +# ---- 4. Env file ------------------------------------------------------------ +$envLines = @("PANEL_DB_URL=$DbUrl") +if ($DbPassword) { $envLines += "PANEL_DB_PASSWORD=$DbPassword" } +if ($AdminEmail) { $envLines += "PANEL_ADMIN_EMAIL=$AdminEmail" } +if ($AdminPassword) { $envLines += "PANEL_ADMIN_PASSWORD=$AdminPassword" } +$envLines += "PANEL_HTTP_PORT=$HttpPort" +$envLines += "PANEL_GRPC_PORT=$GrpcPort" +Set-Content -Path $EnvFile -Value $envLines -Encoding ascii + +# ---- 5. Start controller (background) + bootstrap --------------------------- +$ctrlLog = Join-Path $InstallDir 'controller.log' +$firstBoot = -not (Test-Path "$InstallDir\data\ca") + +# Stop a previous run of this script's processes, if any. +Get-Process -Name controller, agent -ErrorAction SilentlyContinue | + Where-Object { $_.Path -like "$InstallDir*" } | Stop-Process -Force -ErrorAction SilentlyContinue + +$env:PANEL_DB_URL = $DbUrl +if ($AdminEmail) { $env:PANEL_ADMIN_EMAIL = $AdminEmail } +if ($AdminPassword) { $env:PANEL_ADMIN_PASSWORD = $AdminPassword } +Log "starting controller" +Start-Process -FilePath "$InstallDir\bin\controller.exe" ` + -ArgumentList @('-http', ":$HttpPort", '-grpc', ":$GrpcPort", '-ca-dir', './data/ca') ` + -WorkingDirectory $InstallDir -WindowStyle Hidden ` + -RedirectStandardError $ctrlLog + +$up = $false +foreach ($i in 1..60) { + try { + Invoke-WebRequest -Uri "http://127.0.0.1:$HttpPort/login" -UseBasicParsing -TimeoutSec 2 | Out-Null + $up = $true; break + } catch { Start-Sleep -Seconds 1 } +} +if (-not $up) { Fail "controller did not come up - see $ctrlLog" } + +$adminEmail = if ($AdminEmail) { $AdminEmail } else { 'admin@panel.local' } +# BUG-5b: always give the operator a path to the password. +$adminShown = "(the one you set via -AdminPassword; also in $EnvFile)" +if (-not $AdminPassword) { + if ($firstBoot) { + $m = Select-String -Path $ctrlLog -Pattern 'password: ([a-f0-9]{16})' | Select-Object -First 1 + if ($m) { + $AdminPassword = $m.Matches[0].Groups[1].Value + $adminShown = $AdminPassword + } else { + $adminShown = "(see $ctrlLog, line 'password:')" + } + } else { + $adminShown = '(unchanged from previous install)' + } +} + +# ---- 6. Pair token + local agent --------------------------------------------- +$pairCmd = "$InstallDir\bin\agent.exe --pair-url http://:$HttpPort --pair-token --cert-dir ./data/certs" +if (-not $SkipAgent) { + $paired = Test-Path "$InstallDir\data\certs\agent.crt" + if (-not $paired -and $AdminPassword) { + try { + $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession + $body = @{ email = $adminEmail; password = $AdminPassword } | ConvertTo-Json + Invoke-RestMethod -Uri "http://127.0.0.1:$HttpPort/api/login" -Method Post ` + -ContentType 'application/json' -Body $body -WebSession $session | Out-Null + $tokBody = @{ description = 'install.ps1 local agent'; expires_minutes = 60 } | ConvertTo-Json + $tok = Invoke-RestMethod -Uri "http://127.0.0.1:$HttpPort/api/admin/pair-tokens" -Method Post ` + -ContentType 'application/json' -Body $tokBody -WebSession $session + Log "pairing local agent" + Push-Location $InstallDir + try { + & "$InstallDir\bin\agent.exe" --pair-url "http://127.0.0.1:$HttpPort" --pair-token $tok.token --cert-dir ./data/certs + if ($LASTEXITCODE -eq 0) { $paired = $true } else { Warn 'agent pairing exited non-zero' } + } finally { Pop-Location } + } catch { + Warn "auto-pairing failed: $($_.Exception.Message)" + } + } + if ($paired) { + Log "starting agent" + Start-Process -FilePath "$InstallDir\bin\agent.exe" ` + -ArgumentList @('--controller', "localhost:$GrpcPort", '--modules-dir', './modules', + '--data-root', './data/instances', '--meta-dir', './data/instance-meta', + '--backup-dir', './data/backups', '--cert-dir', './data/certs') ` + -WorkingDirectory $InstallDir -WindowStyle Hidden ` + -RedirectStandardError (Join-Path $InstallDir 'agent.log') + } else { + Warn "agent not paired. Create a pair token in the dashboard (Admin -> Pair tokens) and run:" + Warn " cd $InstallDir; $pairCmd" + } +} + +# ---- 7. Scheduled tasks (survive reboots) ------------------------------------- +Log "registering logon Scheduled Tasks (panel-controller / panel-agent)" +try { + $ctrlAction = New-ScheduledTaskAction -Execute "$InstallDir\bin\controller.exe" ` + -Argument "-http :$HttpPort -grpc :$GrpcPort -ca-dir ./data/ca" -WorkingDirectory $InstallDir + $agentAction = New-ScheduledTaskAction -Execute "$InstallDir\bin\agent.exe" ` + -Argument "--controller localhost:$GrpcPort --modules-dir ./modules --data-root ./data/instances --meta-dir ./data/instance-meta --backup-dir ./data/backups --cert-dir ./data/certs" ` + -WorkingDirectory $InstallDir + $trigger = New-ScheduledTaskTrigger -AtLogOn + $settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit (New-TimeSpan -Days 3650) -StartWhenAvailable + Register-ScheduledTask -TaskName 'panel-controller' -Action $ctrlAction -Trigger $trigger -Settings $settings -Force | Out-Null + Register-ScheduledTask -TaskName 'panel-agent' -Action $agentAction -Trigger $trigger -Settings $settings -Force | Out-Null + Warn "NOTE: the scheduled tasks read PANEL_DB_URL etc. from the machine environment;" + Warn "for a true always-on service (no logon needed), use nssm: https://nssm.cc" + # Persist the DB URL machine-wide so the scheduled-task runs see it. + [Environment]::SetEnvironmentVariable('PANEL_DB_URL', $DbUrl, 'Machine') +} catch { + Warn "scheduled-task registration failed (not admin?): $($_.Exception.Message)" + Warn "the controller/agent started by this script keep running until reboot." +} + +# ---- 8. Summary ---------------------------------------------------------------- +Write-Host "" +Log "------------------------------------------------------------" +Log " Panel is installed." +Log " Dashboard : http://localhost:$HttpPort" +Log " Login : $adminEmail" +Log " Password : $adminShown" +Log " (log in once and change the password in the UI)" +Log "" +Log " To pair an agent on ANOTHER machine, create a pair token in the" +Log " dashboard, copy the panel binaries + modules\ there, then run:" +Log " $pairCmd" +Log " and start it: .\bin\agent.exe --controller :$GrpcPort --cert-dir ./data/certs" +Log "------------------------------------------------------------" diff --git a/install.sh b/install.sh new file mode 100644 index 00000000..8ab8a718 --- /dev/null +++ b/install.sh @@ -0,0 +1,440 @@ +#!/usr/bin/env bash +# Panel one-line installer — controller + agent + Postgres on a single Linux box. +# +# curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash +# +# Non-interactive knobs (env vars, all optional): +# PANEL_REPO_URL git/https base of the panel source (default: placeholder, see below) +# PANEL_PREBUILT_URL tarball with prebuilt bin/{controller,agent,panelctl} + modules/ (skips Go build) +# (default: the published v0.9.1 linux/amd64 release — so +# curl|bash needs no Go. Set it EMPTY to force a source build.) +# PANEL_VERSION version string baked into the binaries (default: git describe || dev) +# PANEL_DIR install prefix (default: /opt/panel) +# PANEL_DATA_DIR data directory (default: $PANEL_DIR/data) +# PANEL_HTTP_PORT dashboard port (default: 8080) +# PANEL_GRPC_PORT agent gRPC port (default: 8443) +# PANEL_ADMIN_PASSWORD stable first-boot admin password (default: generated by the controller) +# PANEL_DB_URL use an EXISTING Postgres instead of the managed container +# PANEL_DB_PASSWORD password for the managed Postgres container (default: generated) +# PANEL_DB_CONTAINER name of the managed Postgres container (default: panel-postgres) +# PANEL_DB_PORT host port to bind Postgres on (127.0.0.1) (default: 5432) +# PANEL_DB_VOLUME docker volume for Postgres data (default: panel_pgdata) +# PANEL_SKIP_AGENT=1 install controller only (add agents on other boxes later) +# +# Run TWO independent panels on one host by giving each install distinct +# PANEL_DIR, PANEL_HTTP_PORT, PANEL_GRPC_PORT, PANEL_DB_CONTAINER, +# PANEL_DB_PORT and PANEL_DB_VOLUME values so nothing collides. +# +# Idempotent: safe to re-run — existing containers, units, CA, DB and +# passwords are kept; binaries and modules are refreshed. +set -euo pipefail + +PANEL_REPO_URL="${PANEL_REPO_URL:-https://git.pdxtechs.com/dbledeez/panel}" +# Default to the published prebuilt release so `curl … | sudo bash` works with no +# Go toolchain and no env. Explicitly set PANEL_PREBUILT_URL= (empty) to force the +# git-clone + Go source-build fallback instead. A local checkout (running +# ./install.sh from inside the repo) always wins over this default — see §4. +PANEL_PREBUILT_DEFAULT="https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.1/panel-v0.9.1-linux-amd64.tar.gz" +# Was the var set in the environment at all (even to empty)? Distinguishes an +# operator who explicitly wants prebuilt from the plain default. +if [ "${PANEL_PREBUILT_URL+set}" = set ]; then PANEL_PREBUILT_URL_EXPLICIT=1; else PANEL_PREBUILT_URL_EXPLICIT=""; fi +PANEL_PREBUILT_URL="${PANEL_PREBUILT_URL-$PANEL_PREBUILT_DEFAULT}" +PANEL_DIR="${PANEL_DIR:-/opt/panel}" +PANEL_DATA_DIR="${PANEL_DATA_DIR:-$PANEL_DIR/data}" +PANEL_HTTP_PORT="${PANEL_HTTP_PORT:-8080}" +PANEL_GRPC_PORT="${PANEL_GRPC_PORT:-8443}" +PANEL_ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-}" +PANEL_ADMIN_PASSWORD="${PANEL_ADMIN_PASSWORD:-}" +PANEL_DB_URL="${PANEL_DB_URL:-}" +PANEL_DB_PASSWORD="${PANEL_DB_PASSWORD:-}" +PANEL_DB_CONTAINER="${PANEL_DB_CONTAINER:-panel-postgres}" +PANEL_DB_PORT="${PANEL_DB_PORT:-5432}" +PANEL_DB_VOLUME="${PANEL_DB_VOLUME:-panel_pgdata}" +PANEL_SKIP_AGENT="${PANEL_SKIP_AGENT:-0}" +PANEL_USER=panel +PG_CONTAINER="$PANEL_DB_CONTAINER" + +# Legacy escape hatch (kept for compatibility): a release asset tried only when +# no prebuilt URL, no local checkout and no Go are available. Now that +# PANEL_PREBUILT_URL defaults to the published release above, this is rarely hit — +# it only matters if someone explicitly emptied PANEL_PREBUILT_URL but still has +# no Go and no checkout. +PANEL_RELEASE_TARBALL_URL="${PANEL_RELEASE_TARBALL_URL:-$PANEL_PREBUILT_DEFAULT}" + +log() { printf '\033[1;32m[panel]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[panel]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31m[panel]\033[0m ERROR: %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" = 0 ] || die "run as root (sudo bash install.sh)" +command -v curl >/dev/null || die "curl is required" + +# ---- 0. Admin account: prompt when interactive --------------------------- +# When a real person runs this (a TTY is available), ask them to choose the +# admin login + password up front — no digging a generated password out of +# the log afterwards. When piped non-interactively (curl | bash with no TTY, +# CI, cloud-init) we skip the prompt: any PANEL_ADMIN_* env values are used, +# and a missing password is generated by the controller as before. +if [ -z "$PANEL_ADMIN_PASSWORD" ] && [ -r /dev/tty ] && { [ -t 0 ] || [ -t 1 ]; }; then + printf '\n\033[1;36m── Set up your panel admin login ──\033[0m\n' > /dev/tty + if [ -z "$PANEL_ADMIN_EMAIL" ]; then + printf 'Admin email [admin@panel.local]: ' > /dev/tty + read -r _in_email < /dev/tty || _in_email="" + PANEL_ADMIN_EMAIL="${_in_email:-}" + fi + while [ -z "$PANEL_ADMIN_PASSWORD" ]; do + printf 'Admin password (min 8 chars, blank = auto-generate): ' > /dev/tty + stty -echo 2>/dev/null < /dev/tty + read -r _pw1 < /dev/tty || _pw1="" + stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty + if [ -z "$_pw1" ]; then + warn "no password entered — the controller will generate one and print it." + break + fi + if [ "${#_pw1}" -lt 8 ]; then + printf ' password too short (need 8+); try again.\n' > /dev/tty + continue + fi + printf 'Confirm password: ' > /dev/tty + stty -echo 2>/dev/null < /dev/tty + read -r _pw2 < /dev/tty || _pw2="" + stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty + if [ "$_pw1" != "$_pw2" ]; then + printf ' passwords did not match; try again.\n' > /dev/tty + continue + fi + PANEL_ADMIN_PASSWORD="$_pw1" + done + unset _in_email _pw1 _pw2 +fi + +# ---- 1. Distro detection ------------------------------------------------- +DISTRO=unknown +if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + DISTRO="${ID:-unknown}" +fi +log "distro: $DISTRO" + +# ---- 2. Docker ----------------------------------------------------------- +if command -v docker >/dev/null 2>&1; then + log "docker present: $(docker --version)" +else + case "$DISTRO" in + ubuntu|debian|fedora|centos|rhel|raspbian) + log "installing Docker via get.docker.com" + curl -fsSL https://get.docker.com | sh + ;; + *) + die "Docker not found and no auto-install path for '$DISTRO'. +Install Docker Engine first: https://docs.docker.com/engine/install/ then re-run." + ;; + esac +fi +systemctl enable --now docker >/dev/null 2>&1 || true +docker info >/dev/null 2>&1 || die "docker daemon is not running/reachable" + +# ---- 3. Postgres --------------------------------------------------------- +mkdir -p "$PANEL_DIR/bin" "$PANEL_DATA_DIR" +ENV_FILE="$PANEL_DIR/panel.env" + +if [ -n "$PANEL_DB_URL" ]; then + log "using external Postgres from \$PANEL_DB_URL" +else + if docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then + log "postgres container '$PG_CONTAINER' already exists — starting" + docker start "$PG_CONTAINER" >/dev/null + # Recover the password persisted by a previous run. + if [ -z "$PANEL_DB_PASSWORD" ] && [ -f "$ENV_FILE" ]; then + PANEL_DB_PASSWORD="$(sed -n 's/^PANEL_DB_PASSWORD=//p' "$ENV_FILE" | head -n1)" + fi + [ -n "$PANEL_DB_PASSWORD" ] || die "existing $PG_CONTAINER but no PANEL_DB_PASSWORD (set it, or remove the container to start fresh)" + else + if [ -z "$PANEL_DB_PASSWORD" ]; then + PANEL_DB_PASSWORD="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')" + fi + log "starting postgres container '$PG_CONTAINER' on 127.0.0.1:$PANEL_DB_PORT (volume $PANEL_DB_VOLUME)" + docker run -d --name "$PG_CONTAINER" --restart unless-stopped \ + -e POSTGRES_USER=panel -e POSTGRES_PASSWORD="$PANEL_DB_PASSWORD" -e POSTGRES_DB=panel \ + -p "127.0.0.1:$PANEL_DB_PORT:5432" \ + -v "$PANEL_DB_VOLUME:/var/lib/postgresql/data" \ + postgres:17-alpine >/dev/null + fi + PANEL_DB_URL="postgres://panel:${PANEL_DB_PASSWORD}@127.0.0.1:$PANEL_DB_PORT/panel?sslmode=disable" + log "waiting for postgres to accept connections" + for _ in $(seq 1 30); do + if docker exec "$PG_CONTAINER" pg_isready -U panel -d panel >/dev/null 2>&1; then break; fi + sleep 1 + done + docker exec "$PG_CONTAINER" pg_isready -U panel -d panel >/dev/null 2>&1 || die "postgres did not become ready" +fi + +# ---- 4. Source / binaries ------------------------------------------------ +SRC="" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd || true)" +if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.work" ] && [ -d "$SCRIPT_DIR/controller" ]; then + SRC="$SCRIPT_DIR" + log "using local checkout at $SRC" + # A local checkout means the operator wants to build THIS tree. Don't silently + # override it with the prebuilt release default. (An explicit PANEL_PREBUILT_URL + # env still forces prebuilt — it survives because it was exported, not defaulted.) + if [ -z "${PANEL_PREBUILT_URL_EXPLICIT:-}" ] && [ "$PANEL_PREBUILT_URL" = "$PANEL_PREBUILT_DEFAULT" ]; then + PANEL_PREBUILT_URL="" + fi +fi + +# If no prebuilt URL, no local checkout and no Go, but a release tarball URL is +# known, auto-fall-back to the published release so the one-liner still works. +if [ -z "$PANEL_PREBUILT_URL" ] && [ -z "$SRC" ] \ + && ! command -v go >/dev/null 2>&1 && [ -n "$PANEL_RELEASE_TARBALL_URL" ]; then + log "no Go toolchain — falling back to the published release tarball" + PANEL_PREBUILT_URL="$PANEL_RELEASE_TARBALL_URL" +fi + +if [ -n "$PANEL_PREBUILT_URL" ]; then + log "downloading prebuilt binaries: $PANEL_PREBUILT_URL" + TMP_TGZ="$(mktemp)" + curl -fsSL "$PANEL_PREBUILT_URL" -o "$TMP_TGZ" + tar -xzf "$TMP_TGZ" -C "$PANEL_DIR" + rm -f "$TMP_TGZ" + [ -x "$PANEL_DIR/bin/controller" ] || die "prebuilt tarball did not contain bin/controller" +else + if [ -z "$SRC" ]; then + command -v git >/dev/null || die "git is required to fetch sources (or set PANEL_PREBUILT_URL)" + SRC="$PANEL_DIR/src" + # BUG-4: the src tree is cloned as root then chowned to $PANEL_USER; on a + # re-run git would refuse with "detected dubious ownership". Mark it safe + # for whatever user runs git (root here) so re-runs are idempotent. + git config --global --add safe.directory "$SRC" 2>/dev/null || true + if [ -d "$SRC/.git" ]; then + log "updating source checkout at $SRC" + git -C "$SRC" pull --ff-only + else + log "cloning $PANEL_REPO_URL" + git clone --depth 1 "$PANEL_REPO_URL" "$SRC" + fi + fi + if ! command -v go >/dev/null 2>&1; then + die "Go toolchain not found. The one-line installer normally uses prebuilt +binaries; falling back to a source build requires Go >= 1.26 (https://go.dev/dl/). +Alternatively set PANEL_PREBUILT_URL to a prebuilt tarball, or +PANEL_RELEASE_TARBALL_URL to a published release asset." + fi + VERSION="${PANEL_VERSION:-$(git -C "$SRC" describe --tags --always --dirty 2>/dev/null || echo dev)}" + LDFLAGS="-s -w -X github.com/dbledeez/panel/pkg/version.Version=$VERSION" + log "building panel $VERSION" + (cd "$SRC" && \ + CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "$PANEL_DIR/bin/controller" ./controller/cmd/controller && \ + CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "$PANEL_DIR/bin/agent" ./agent/cmd/agent && \ + CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "$PANEL_DIR/bin/panelctl" ./controller/cmd/panelctl) + log "syncing game modules" + rm -rf "$PANEL_DIR/modules.new" + cp -r "$SRC/modules" "$PANEL_DIR/modules.new" + rm -rf "$PANEL_DIR/modules" + mv "$PANEL_DIR/modules.new" "$PANEL_DIR/modules" +fi + +# ---- 5. User + env file + systemd units ---------------------------------- +if ! id "$PANEL_USER" >/dev/null 2>&1; then + useradd --system --home-dir "$PANEL_DIR" --shell /usr/sbin/nologin "$PANEL_USER" +fi +usermod -aG docker "$PANEL_USER" 2>/dev/null || true + +umask 077 +{ + echo "PANEL_DB_URL=$PANEL_DB_URL" + [ -n "$PANEL_DB_PASSWORD" ] && echo "PANEL_DB_PASSWORD=$PANEL_DB_PASSWORD" + [ -n "$PANEL_ADMIN_EMAIL" ] && echo "PANEL_ADMIN_EMAIL=$PANEL_ADMIN_EMAIL" + [ -n "$PANEL_ADMIN_PASSWORD" ] && echo "PANEL_ADMIN_PASSWORD=$PANEL_ADMIN_PASSWORD" + echo "PANEL_HTTP_PORT=$PANEL_HTTP_PORT" + echo "PANEL_GRPC_PORT=$PANEL_GRPC_PORT" +} > "$ENV_FILE" +umask 022 +chown -R "$PANEL_USER:$PANEL_USER" "$PANEL_DIR" +chmod 600 "$ENV_FILE" + +# BUG-2/3/5: do NOT install deploy/systemd/*.service verbatim — those hardcode +# /opt/panel, ProtectHome=true and stale :8080/:8443 in the Description. Instead +# GENERATE both units here, substituting the REAL install parameters. +# +# BUG-3: systemd ProtectHome=true makes any binary under /home unreadable to the +# service (203/EXEC). When PANEL_DIR is under /home we emit the unit WITHOUT +# ProtectHome so it actually runs (reduced hardening, noted inline). The default +# /opt/panel keeps ProtectHome=true. +case "$PANEL_DIR/" in + /home/*) PROTECT_HOME=""; log "PANEL_DIR is under /home — generating units without ProtectHome (reduced hardening)";; + *) PROTECT_HOME="ProtectHome=true";; +esac + +write_unit() { + # $1 = destination path + cat > "$1" + chmod 0644 "$1" +} + +write_unit /etc/systemd/system/panel-controller.service </dev/null +systemctl restart panel-controller + +log "waiting for the controller on :$PANEL_HTTP_PORT" +for _ in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:$PANEL_HTTP_PORT/login" >/dev/null 2>&1; then + break + fi + sleep 1 +done +curl -fsS "http://127.0.0.1:$PANEL_HTTP_PORT/login" >/dev/null 2>&1 || die "controller did not come up — check: journalctl -u panel-controller" + +ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-admin@panel.local}" +# BUG-5b: always give the operator a path to the password. If they supplied/chose +# one, say so and point at the env file; otherwise scrape the generated one below. +ADMIN_PW_SHOWN="(the password you set at install; also in $ENV_FILE)" +if [ -z "$PANEL_ADMIN_PASSWORD" ]; then + if [ "$FIRST_BOOT" = 1 ]; then + # The controller prints the generated password once in its log. + GEN_PW="$(journalctl -u panel-controller --since "$BOOT_MARK" --no-pager 2>/dev/null \ + | sed -n 's/.*password: \([a-f0-9]\{16\}\).*/\1/p' | head -n1 || true)" + if [ -n "$GEN_PW" ]; then + ADMIN_PW_SHOWN="$GEN_PW" + PANEL_ADMIN_PASSWORD="$GEN_PW" + else + ADMIN_PW_SHOWN="(see: journalctl -u panel-controller | grep 'password:')" + fi + else + ADMIN_PW_SHOWN="(unchanged from previous install)" + fi +fi + +# ---- 7. Pair token + local agent ------------------------------------------ +PAIR_TOKEN="" +if [ -n "$PANEL_ADMIN_PASSWORD" ]; then + COOKIES="$(mktemp)" + if curl -fsS -c "$COOKIES" -H 'Content-Type: application/json' \ + -d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$PANEL_ADMIN_PASSWORD\"}" \ + "http://127.0.0.1:$PANEL_HTTP_PORT/api/login" >/dev/null 2>&1; then + PAIR_TOKEN="$(curl -fsS -b "$COOKIES" -H 'Content-Type: application/json' \ + -d '{"description":"install.sh local agent","expires_minutes":60}' \ + "http://127.0.0.1:$PANEL_HTTP_PORT/api/admin/pair-tokens" 2>/dev/null \ + | sed -n 's/.*"token":"\([^"]*\)".*/\1/p' || true)" + fi + rm -f "$COOKIES" +fi + +PAIR_CMD="$PANEL_DIR/bin/agent --pair-url http://:$PANEL_HTTP_PORT --pair-token --cert-dir ./data/certs" +if [ "$PANEL_SKIP_AGENT" != 1 ]; then + if [ -f "$PANEL_DATA_DIR/certs/agent.crt" ]; then + log "local agent already paired — restarting" + systemctl enable --now panel-agent >/dev/null + systemctl restart panel-agent + elif [ -n "$PAIR_TOKEN" ]; then + log "pairing local agent" + (cd "$PANEL_DIR" && sudo -u "$PANEL_USER" \ + "$PANEL_DIR/bin/agent" --pair-url "http://127.0.0.1:$PANEL_HTTP_PORT" \ + --pair-token "$PAIR_TOKEN" --cert-dir ./data/certs) + systemctl enable --now panel-agent >/dev/null + else + warn "could not auto-create a pair token (admin password unknown to this run)." + warn "Create one in the dashboard (Admin → Pair tokens) and run:" + warn " cd $PANEL_DIR && sudo -u $PANEL_USER $PAIR_CMD" + warn "then: systemctl enable --now panel-agent" + fi +fi + +# ---- 8. Summary ------------------------------------------------------------ +HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || echo 127.0.0.1)" +echo +log "──────────────────────────────────────────────────────────" +log " Panel is installed." +log " Dashboard : http://${HOST_IP:-127.0.0.1}:$PANEL_HTTP_PORT" +log " Login : $ADMIN_EMAIL" +log " Password : $ADMIN_PW_SHOWN" +log " (log in once and change the password in the UI)" +log "" +log " To pair an agent on ANOTHER machine, create a pair token in the" +log " dashboard, copy the panel binaries + modules/ there, then run:" +log " $PAIR_CMD" +log " and start it: ./bin/agent --controller ${HOST_IP:-}:$PANEL_GRPC_PORT --cert-dir ./data/certs" +log "──────────────────────────────────────────────────────────" diff --git a/modules/7dtd/.gitattributes b/modules/7dtd/.gitattributes new file mode 100644 index 00000000..7de93629 --- /dev/null +++ b/modules/7dtd/.gitattributes @@ -0,0 +1,5 @@ +# 7DTD module ships shell scripts that run as the container entrypoint. +# A CRLF here breaks the container's /bin/bash interpreter line and +# `set -euo pipefail`. Force LF regardless of the editor's platform. +*.sh text eol=lf +entrypoint.sh text eol=lf diff --git a/modules/7dtd/CANONICAL_NIM_FREEZE.md b/modules/7dtd/CANONICAL_NIM_FREEZE.md new file mode 100644 index 00000000..e8cf2bf9 --- /dev/null +++ b/modules/7dtd/CANONICAL_NIM_FREEZE.md @@ -0,0 +1,299 @@ +# 7 Days to Die — Cluster Canonical `.nim` ID Freeze + +> How the panel stops a 7DTD cluster's **shared player saves** from corrupting on +> cross‑server transfer (items "morphing" into other items, characters resetting +> to level 1 / naked). Read this before touching cluster `.nim` files, the 7dtd +> `entrypoint.sh` freeze guard, the canonical, or regenerating a clustered world. +> +> Author: deployed live 2026‑06‑13 on cluster `cl_37e3a7f72cdc` (Refuge). +> Companion code: `modules/7dtd/entrypoint.sh` (the guard), +> `modules/7dtd/nim-freeze/rebuild.py` (regenerate the canonical), and +> `modules/7dtd/nim-freeze/remap_7dt.py` + `REMAP_RUNBOOK.md` (new‑world deco remap). + +--- + +## ⚠️ CORRECTION / SECOND HALF OF THE PROBLEM (2026‑06‑13, later same day) + +The freeze below fixes **inventory** (`.ttp`) transfers — correct and live. But it is +**NOT sufficient for a freshly‑generated DIFFERENT world**, and that's why insane/pvp +came up "online but unjoinable" with a `DecoManager.TryAddToOccupiedMap` NullReference. + +Root cause (proven): a world also bakes its **decorations** into `decoration.7dt` +(and structures into `multiblocks.7dt`) **by numeric block id**, using the engine's +**native runtime ids at generation time**. The cluster's current runtime (RefugeBot +v1.2.39) assigns the tree‑deco band at ids **21895+**, while the season10‑anchored +canonical assigns the same trees at **24141+** (season10's world was baked *before* a +RefugeBot rebuild shifted the band). Stamping the canonical over a new world's `.nim` +makes its native‑baked deco ids resolve to a **null Block** → NRE → world‑load +coroutine dies → never `GameStartDone`. season10/creative load clean only because their +deco was baked when runtime == canonical. + +**Fix for a new world = a one‑time per‑world deco remap** (`remap_7dt.py`): translate +`decoration.7dt`/`multiblocks.7dt` block ids `native → name → canonical`, preserving the +high/rotation bits, then stamp the canonical. The engine **preserves** loaded deco ids on +save (only writes native at *generation*), so the remap is **stable across reboots**. +`.7dt` format: `u8 ver=6, u32 count, count×17B recs; id = u16@+12 & 0x7FFF`. The cluster +runtime native table is identical across all instances (one capture is authoritative). +**Full step‑by‑step: `nim-freeze/REMAP_RUNBOOK.md`.** insane (Yixacove) + pvp (Tuxeno) +were fixed this way and verified: `GameStartDone`, `NRE=0`, deco band 24141, on canon. + +Permanent removal of the per‑world remap step: pin block ids explicitly in a top‑priority +modlet (so runtime native == canonical) or freeze the RefugeBot build season10 was baked +under. Until then, every NEW clustered world needs this one remap. + +--- + +## TL;DR + +* 7DTD stores a player's **inventory by numeric ID, not by name**. Each *world* + bakes its own `name <-> id` lookup tables: `itemmappings.nim` and + `blockmappings.nim` in that world's save dir. +* Our cluster **shares one player save folder** across servers that each run a + **different world**. The shared `.ttp` carries one world's numbers; every other + world decodes them through a **different** table → glass block reads as car + hood, etc. Severe mismatches fail to load → fresh level‑1 character, written + back over the shared save = **permanent wipe**. +* **Fix:** build ONE *complete, season10‑anchored* canonical id table and **freeze + it onto every world in the cluster**. Because it preserves season10's exact ids + and contains every block/item the mods can produce, every world decodes the + shared `.ttp` identically and the engine never appends → it stays frozen. +* A small **entrypoint guard** re‑stamps the canonical on every boot, so it's + self‑healing and survives recreates/regens. + +--- + +## The problem (root cause, proven by forensics) + +1. A player's `.ttp` inventory region is a stream of **2‑byte numeric ids** — no + item/block name strings (names only appear later, for name‑keyed progression + like perks/recipes, which is why **level/skills survive** a transfer but + **inventory/placed‑blocks do not**). +2. Those numbers mean something only relative to the **per‑world** `.nim` tables. + The engine logs `INF Block IDs with mapping` / `INF ItemIDs from Mapping` on + every world load — it resolves stored ids → names **through that world's + `.nim`**. +3. The `.nim` files are **NOT shared** — only the `Player/` folder is symlinked to + `/cluster/Player`. Each world baked its table independently at genesis, so the + same id maps to a different name per world. Live proof on the cluster: + * block id **9243** = `steelShapes:cube` on season10 but `awningShapes:cube` + on the others. + * item id **66/67** = the inverse swap `meleeHandPlayer <-> meleeHandZombie01`. +4. **Why identical mods don't save you:** ids for un‑pinned/modded content are + **auto‑assigned at world genesis in encounter order and frozen into that + world's `.nim`**. Two worlds generated at different times/states freeze + *different* assignments even from byte‑identical XML. So mod‑syncing, mod + symlinks, identical load order — all irrelevant. The divergence lives in the + **saves** volume, not the mods. +5. The "naked / level 1" variant: the destination's table is missing ids the + `.ttp` references (e.g. season10 had 1888 item ids; a fresh RWG world's table + had ~133). Unresolvable ids are dropped; enough drops and the character loads + empty/level 1. Because the save is shared and **written back**, the wipe is + permanent (`.ttp` *and* `.ttp.bak` both overwritten). + +This is the unsupported corner of 7DTD: **The Fun Pimps disable cross‑world +characters for exactly this reason.** Sharing one inventory across servers running +*different worlds* only works if every world uses an identical, frozen id table. + +--- + +## The fix: a complete, frozen canonical table on every world + +### What "canonical" means here + +A single pair of `.nim` files that is: + +* **Anchored to season10** — every id season10 currently uses is preserved + **byte‑for‑byte**. season10 authored the 87 shared inventories and has the + richest/oldest table, so the existing saves already match it. This makes the + freeze **non‑destructive to season10's world and all existing inventories**. +* **Complete** — contains every block and item the mods can ever produce: + * all named items + **all 237 `item_modifier` names** (these serialize into + `itemmappings.nim` when installed — the missing‑21 of these was the live + char‑wipe vector), + * all named blocks + the **entire `base:shape` cross‑product** (10 shape‑helper + bases × every shape in `shapes.xml`) + the engine's authoritative block dump. + +Because it's complete, the engine never meets a name not already in the table → +the **append branch never fires** → the file is never rewritten → **frozen**. +Verified live on creative/season10: the `.nim` sha256 is byte‑identical before and +after boot + world‑load. + +### Final numbers (cluster `cl_37e3a7f72cdc`) + +| file | count | sha256 (prefix) | +|---|---|---| +| `blockmappings.nim` | 38,626 blocks (ids 0..63004) | `0a7db915…` | +| `itemmappings.nim` | 2,719 items (ids 1..3513) | `fa4af1df…` | + +u16 ceiling is 65,535 — headroom remains. + +--- + +## Components & locations + +| What | Where | +|---|---| +| **Deployed canonical** (the source of truth the guard reads) | `figaro:/home/refuge/panel/data/7dtdcluster//canonical/{blockmappings.nim,itemmappings.nim}` — this is the host side of the shared `/cluster` bind mount, so it appears as `/cluster/canonical/` inside every member container. | +| **The freeze guard** | `modules/7dtd/entrypoint.sh`, the `--- cluster canonical .nim freeze guard ---` block. Baked into `panel-7dtd:latest`. | +| **Per‑world `.nim`** (what gets stamped) | `/.local/share/7DaysToDie/Saves///{block,item}mappings.nim` | +| **Rebuild tool** | `modules/7dtd/nim-freeze/rebuild.py` (re‑anchor) | +| **Build artifacts / name registries** (for a from‑scratch rebuild) | `figaro:/home/refuge/nimcanon/` — `tool/`, `registry/` (`blocks_shapes_complete.txt`, `item_modifiers_all.txt`, `blocks_named_currentconfig.txt`, …), `canonical_final/`, `src/` (anchor copies). Persisted out of `/tmp` so a figaro reboot doesn't lose them. | + +--- + +## The entrypoint guard (the automation) + +On every boot, after the cluster Player‑symlink setup, the entrypoint runs: + +```sh +if [ -f /cluster/canonical/blockmappings.nim ] && [ -f /cluster/canonical/itemmappings.nim ] \ + && [ -d "$SAVE_BASE" ]; then + cp -f /cluster/canonical/itemmappings.nim "$SAVE_BASE/itemmappings.nim" + cp -f /cluster/canonical/blockmappings.nim "$SAVE_BASE/blockmappings.nim" +fi +``` + +* `SAVE_BASE` = `Saves/$CLUSTER_PLAYER_SAVE` (set by the Cluster‑tab "Shared player + world" picker), or the literal `Saves/$GameWorld/$GameName` fallback. +* Only fires for cluster members that have a `/cluster/canonical/` present → + non‑cluster servers and other clusters are untouched. +* **Idempotent + self‑healing**: a complete canonical never triggers an append, so + re‑stamping the same bytes every boot is a no‑op; if a stray append ever + happened, the next boot resets it. + +You'll see this line in the boot log when it works: + +``` +[panel-7dtd] cluster : stamped canonical id maps over ...// (frozen, divergence-proof) +``` + +--- + +## Is it automated? (what's hands‑off vs. operator action) + +* **Hands‑off:** every existing frozen world stays frozen forever; every + boot/recreate/`/rebuild` re‑applies the canonical automatically. +* **One operator step for a NEW world:** when a clustered server **regenerates an + RWG world** (new seed), the new world's folder name is **seed‑derived and + unknown until after gen**, so the guard can't target it until you set the + Cluster‑tab **Shared player world** (`CLUSTER_PLAYER_SAVE`). Set that (and the + Region Medic `world`) to the new world and the guard freezes it on the next + boot. This is the same one‑click step a normal cluster *join* already needs. +* **Rebuild needed only if mods change** (see below). + +> Future hardening (not yet done): make the guard/symlink **auto‑detect** the +> active world dir (newest `Saves/*//` with a `main.ttw`) so even RWG +> regens need zero manual settings. Deliberately left as an explicit Cluster‑tab +> setting for now. + +--- + +## Operating + +### Add a clustered server / regenerate a clustered world (RWG) + +Goal: fresh world, frozen on the canonical, sharing the player folder. (Fresh +worlds are clean — worldgen places vanilla blocks whose ids already match the +canonical, so **no morph**.) + +1. **New map:** change the seed — `POST /api/instances//env-config` body + `{"updates":{"world_seed":""}}`. The engine gens a new seed‑named world. +2. Find the new world dir (newest `Saves/*//main.ttw`). +3. **Point the cluster settings at it:** + * `POST .../env-config` `{"updates":{"CLUSTER_PLAYER_SAVE":"/"}}` + → recreate → guard stamps it + symlinks `Player -> /cluster/Player`. + * Write `region-medic.json` `"world": "/"` into the saves + volume (keep `enabled`/`keep`/`discord_channel`). +4. Confirm the boot log shows the "stamped canonical" line and the world `.nim` + sha256 == the cluster canonical. + +### After a mod change (new blocks/items added) — REBUILD the canonical + +The canonical is a static snapshot of the merged config's name universe. New mods +add names it doesn't have, so those would append per‑world again. + +1. Re‑harvest the complete name set (the two ultracode workflows did this; the + essential outputs are in `~/nimcanon/registry/`): + * blocks: `:` cross‑product from `shapes.xml` × the 10 shape‑helper + bases, ∪ the engine's authoritative block dump (`exportcurrentconfigs` / + runtime `Block` list), ∪ named blocks. + * items: every `` ∪ every `` + (`grep -rhoE '/canonical/` (chmod 644) and `/rebuild` each member. + +### Verify a server is frozen + +```sh +# .nim on disk == the cluster canonical: +docker run --rm -v panel--saves:/sv:ro debian:12-slim \ + sha256sum "/sv/.local/share/7DaysToDie/Saves///blockmappings.nim" +# vs: +sha256sum /home/refuge/panel/data/7dtdcluster//canonical/blockmappings.nim +# and the boot log: +docker logs panel- 2>&1 | grep "stamped canonical" +``` + +The **real** test is in‑game: transfer a player between two members and confirm +glass stays glass, inventory intact, no level reset. + +### Troubleshooting / gotchas + +* **`/rebuild` left the server STOPPED.** If an instance was stopped *before* + `/rebuild`, the recreate suppresses autostart and leaves it `Created`/stopped. + Just **Start** it — the guard runs on boot. +* **Guard didn't stamp / wrong dir.** `CLUSTER_PLAYER_SAVE` is empty or stale → + for RWG the fallback `Saves/RWG/` doesn't exist, so the guard skips. + Set the Cluster‑tab Shared‑player‑world to the real (seed‑derived) world. +* **season10 grew between snapshot and stamp.** season10's `.nim` appends as + players place new shapes (we saw 12259 → 12263 in a few hours). **Always + re‑anchor (`rebuild.py`) against season10's CURRENT, STOPPED `.nim` immediately + before stamping season10.** Once season10 is frozen on the complete canonical it + stops growing. +* **Stamp only while the target is STOPPED**, as the runtime uid (`1000:1000`), + mode `0644`. An unreadable `.nim` makes the engine silently gen a fresh table = + full remap. +* Old orphaned worlds (e.g. `Tefasizi County`, `Kopaneke Territory`) are left in + the saves volumes after an RWG regen — safe to delete to reclaim space. + +--- + +## If we need to pivot (alternatives, ranked) + +If the freeze ever proves unworkable (e.g. a mod that registers ids at **runtime** +— none currently do; all 7DTD content here is static XML): + +1. **One shared world** across the whole cluster (same GameWorld+seed+GameName). + One world → one `.nim` → impossible to diverge. Cost: every server is the SAME + map (defeats distinct‑experience servers). Simplest and bulletproof. +2. **Don't carry numeric‑id state across worlds.** Share only name‑keyed + progression (level/skills/perks/recipes — these already transfer cleanly) and + **strip bag/belt/equipment** on join to a world that didn't author the + inventory (an admin/RefugeBot hook). Players keep their character, not carried + items. Preserves distinct worlds. +3. **Re‑encode on transfer** (true but fiddly): decode the `.ttp` inventory + through the SOURCE world's `.nim` and re‑encode through the DESTINATION's, + dropping ids the destination lacks. Must run while the player is offline. +4. The current approach (**freeze a complete canonical**) is strictly better than + "sync the `.nim` across different worlds" — never try that bare, because the + `.nim` is also the codebook for each world's OWN placed blocks: swapping it on + a world with existing modded builds **morphs that world's terrain** (that's why + insane/pvp got fresh worlds instead of an in‑place stamp). + +--- + +## Key facts to remember + +* Inventory/placed blocks = **numeric id**; level/skills/perks/recipes = + **name‑keyed** (the latter survive any transfer). +* `.nim` is written **only on table growth**, not per save — a complete table is + therefore frozen. +* The canonical must be **anchored to season10** (the authoring world) or existing + inventories morph. +* New RWG world folder names are **seed‑derived**; you must set + `CLUSTER_PLAYER_SAVE` + medic `world` to the real name after gen. +* The deployed canonical lives in the **shared cluster dir** (`/cluster/canonical`) + so one copy serves every member and the guard is purely local file ops. diff --git a/modules/7dtd/CLUSTER_PLAYBOOK.md b/modules/7dtd/CLUSTER_PLAYBOOK.md new file mode 100644 index 00000000..0690bd77 --- /dev/null +++ b/modules/7dtd/CLUSTER_PLAYBOOK.md @@ -0,0 +1,341 @@ +# 7 Days to Die — Cluster Playbook + +> The single operator + architecture doc for the Refuge 7DTD cluster: how shared +> characters/inventory work, how new servers auto-fix themselves on create, the +> one rule you must never break, and exactly what we do at the 3.0 wipe. +> +> Deep "why" lives in `CANONICAL_NIM_FREEZE.md` (root cause) and the manual +> fallback in `nim-freeze/REMAP_RUNBOOK.md`. **This doc is the one to read first.** + +--- + +## 1. The one thing to understand + +7DTD stores everything — your inventory, the blocks in the world, the trees — +by a **numeric id**, not by name. The engine hands out those numbers automatically +when it boots, and the assignment **drifts** whenever the *build* changes. "Build" += the exact set of mods + their load order + the RefugeBot DLL version + the engine +version. Nothing else matters. + +* **Same build everywhere → same numbers everywhere.** Inventory transfers cleanly, + worlds load. (Proven: two cluster servers on the same build produce byte-identical + id tables.) +* **Different builds → different numbers → "morph".** A glass block saved as id 21895 + on one server reads as a car hood on another that put a different block at 21895. + Characters can even reset to level 1. + +Everything below — the canonical table, the stamp, the decoration remap, the +auto-provision — exists for **one** reason: to bridge servers/worlds that were +baked under *different* builds so they all agree on the numbers. + +--- + +## 2. The golden rule (non-negotiable) + +> **Every server in the cluster must run the byte-identical build at all times.** + +Same mods, same load order, same RefugeBot build, same game version — on **all** +members, always. Break this and inventory morphs. The build *can* evolve over time, +but only as a **coordinated, all-servers-at-once maintenance event** (see §6), never +one server at a time. + +--- + +## 3. The two moving parts + +| Part | What it is | Who maintains it | +|---|---|---| +| **The canonical** | One frozen `name ↔ id` table (`blockmappings.nim` + `itemmappings.nim`) every server stamps on, so the **shared player inventory** (`.ttp`) decodes identically everywhere. Lives at `/cluster/canonical/` (host: `…/7dtdcluster//canonical/`). | `nim-freeze/rebuild.py` (re-anchor / extend) | +| **The decoration remap** | A world bakes its **decorations** (`decoration.7dt` / `multiblocks.7dt`) with the engine's *native* ids at generation. Those must be rewritten to the canonical's ids or the world NREs on load. | `nim-freeze/remap_7dt.py`, now **automatic** (see §4) | + +Why both: the canonical fixes **inventory**, the remap fixes **the world the +decorations live in**. The deep mechanics are in `CANONICAL_NIM_FREEZE.md`. + +--- + +## 4. How it works now — auto-provision on create (the steady state) + +You don't run any of the remap tooling by hand anymore. The game container's +entrypoint + the agent do it for you. A brand-new clustered world becomes +canon-compatible **on its own**, in two boots: + +1. **Gen boot.** You create the server (cluster picked), Update (downloads the + game), and Start. The engine generates the world and bakes its decorations + with native ids. The world is up but *not yet aligned* to the cluster. +2. **Align boot (automatic).** The agent's auto-provision watcher notices the + fresh world's decorations are in the native band and **bounces the container + once**. On that boot, the entrypoint remaps `decoration.7dt` + `multiblocks.7dt` + native → canonical (using the world's own native table), drops a + `.canon-provisioned` marker, and stamps the canonical. The world comes back + **clean, on canon, joinable, transfer-safe.** + +**What you see (visibility):** in the server's **Console** — +`[panel] auto-provision: aligning the new world's decorations to the cluster…` +then `remapped decoration.7dt native->canonical` then +`decorations aligned to cluster canonical — world is now canon-compatible` then +`StartGame done`. The card briefly shows **"aligning decorations to cluster…"**. + +**It is a strict no-op for everything else:** existing canon worlds +(season10/creative/insane/pvp) and non-cluster servers never enter the remap +branch (their decorations are already in the canon band), so recreating/restarting +them does nothing. The remap is **stable** — once a world is canon, it stays canon +across every save and reboot (the engine preserves loaded decoration ids; it only +writes native ids at *generation*). + +**The backstop:** even if the auto-bounce is ever missed, the entrypoint aligns the +world on its *next* ordinary restart. The auto-bounce just makes it zero-touch. + +### Files that implement it (for the next engineer) +* `modules/7dtd/entrypoint.sh` — the remap block (right before the canonical-stamp + guard) + the `.panel-save-base` writer. Gated on the **decoration band** only. +* `modules/7dtd/Dockerfile` — adds `python3-minimal` + the vendored remapper. +* `modules/7dtd/nim-freeze/remap_7dt.vendored.py` — self-contained in-container remapper. +* `agent/internal/dispatch/decoremap_provision.go` — the watcher (single-flight, + probes the running container, bounces once on a native-band world). + +--- + +## 5. Adding a new server (operator steps) + +1. **Add server** in the panel, module **7 Days to Die**, and **pick the cluster** + in the create form. (This wires the shared Player folder + the canonical mount.) +2. **Update** (installs the game) → **Start**. Let it generate the world. +3. **RWG worlds only:** once the world has generated, set the **"Shared player world"** + in the **Cluster tab** to the new (seed-named) world. The picker writes + `CLUSTER_PLAYER_SAVE` and triggers a recreate, so the entrypoint stamps + aligns the + right dir. **Also point Region Medic at the same `/`** (Cluster tab → + Region Medic card), or Medic keeps healing the old, now-deleted world. Until both are + set, alignment can't target the world. +4. That's it. The world auto-aligns to the cluster (watch the Console). When you see + `StartGame done` after the alignment bounce, it's joinable and transfer-safe. + +> **Is the new world done? Don't let players join until all three pass:** +> 1. Console shows `StartGame done` *after* the `auto-provision: aligning…` line. +> 2. Save dir has a `.canon-provisioned` file. +> 3. Decorations read in the **24xxx** band — verify it yourself (read-only): +> ```sh +> docker run --rm -v panel--saves:/sv:ro debian:12-slim sh -c \ +> 'f="/sv/.local/share/7DaysToDie/Saves///decoration.7dt"; \ +> echo "first deco id = $(( $(od -An -tu2 -j17 -N2 "$f") & 0x7FFF ))"' +> ``` +> `>= 24000` = canon (good). `< 24000` = still native (NOT aligned — do not let players in). +> +> **If after 2 boots there's still no `.canon-provisioned` / the band is native** (the +> §12 #3 case): do **one** more panel **Restart** — the entrypoint backstop aligns it on +> that boot — and re-check. Still native after that → manual repair via +> `nim-freeze/REMAP_RUNBOOK.md`. + +--- + +## 6. Changing mods (a maintenance event, not a casual edit) + +Because of the golden rule, a mod change is a **whole-cluster** operation: + +1. Update **every** server's mods together (same set, same order, same RefugeBot + build). Never one server at a time. +2. **Rebuild the canonical** so it contains the new names (else they append per-world + again → morph). This is **two** steps — ⚠️ **`rebuild.py` does NOT discover new mod + content; it only re-anchors an existing name universe.** Doing 2b without 2a silently + produces an incomplete canonical and re-introduces the exact wipe bug this system + prevents. + * **2a — Re-harvest the complete name universe** (the hard part). Blocks = the + `:` cross-product (`shapes.xml` × the 10 shape-helper bases) ∪ the + engine's authoritative block dump (telnet `exportcurrentconfigs`, or the runtime + `Block` list) ∪ every named block; items = every `` ∪ every + ``. The original harvest ran as two ultracode workflows; its + inputs/outputs live on figaro at `~/nimcanon/registry/` but **the generator is not + committed** — exact greps/commands are in `CANONICAL_NIM_FREEZE.md` → "After a mod + change". **Do not attempt a mod change without re-running this harvest.** + * **2b — Re-anchor** against season10's CURRENT, STOPPED `.nim` (the + `West Apeeni Mountains/MyGame` anchor — verify 38,626/2,719+ first, §13) with the new + universe: `nim-freeze/rebuild.py` preserves every existing id and appends new names + at fresh ids (so old worlds + the ~88 shared characters still decode). Verify + `GATE PASS: True`, then copy into `/cluster/canonical/` (chmod 644). +3. **Re-stamp every member** so they pick up the new canonical — a panel **Restart** + is enough (the entrypoint re-stamps from `/cluster/canonical` on every boot; season10 + too, by Restart only — never Rebuild). New worlds genned after the change auto-align + (§4); existing worlds keep working (their content + the canonical's existing ids are + unchanged). + +Adding content is safe (append). Removing/reordering content is where ids shift — +that's exactly what a **wipe** (§7) resets cleanly. + +--- + +## 7. The 3.0 wipe roadmap (the easy button) + +A version wipe is the moment to make the whole thing **bulletproof and remap-free**, +because you're discarding all the old worlds anyway. Plan: + +1. **Freeze + hash the 3.0 build.** Lock the exact mod set + load order + RefugeBot DLL + + engine version, and record a hash of the artifact set (e.g. `tar c Mods/ | sha256sum`) + into a committed `3.0-build.lock`. That snapshot *is* the contract going forward. +2. **Wipe all worlds — through the panel, never docker CLI.** Per server: delete the old + `Saves//` dirs (mount the saves volume, per the runbook's volume-access + pattern) and set a fresh `world_seed` via `env-config`, or delete-preserve-volumes + + recreate. Characters too — `.ttp` is build-dependent; a wipe is a fresh start for + inventories (carrying them across a build change needs the `.7rg`/`.ttp` codec work in §8). +3. **Re-anchor the canonical to the LIVE 3.0 runtime.** "Capture the current runtime's + table" = the `REMAP_RUNBOOK.md` **"CAPTURE the world's native table"** sub-procedure: on + a fresh 3.0 world, Stop → move the canonical aside (back it up first!) → write the 8-byte + empty `.nim` → Start (the engine writes the world's authoritative native table) → copy it + out → restore the canonical. Feed that as the new anchor to `rebuild.py` together with a + freshly-harvested 3.0 universe (§6 step 2a). Now **`canonical == runtime`** and the gap + that forces per-world remapping is gone. +4. **From then on, fresh maps just work with zero remap** — every world is born from the + same starting numbers. The auto-provision/stamp machinery stays in place as a harmless + safety net; it simply has nothing to fix. +5. Only a *future* mod change re-introduces the need to rebuild the canonical (§6). + +> ⚠️ **The 3.0 wipe is irreversible and depends on the (currently uncommitted) §6 harvest +> workflow.** Do NOT execute it from this strategy alone — when 3.0 lands, write a dedicated, +> tested 3.0 runbook with the exact harvest + capture + wipe commands first. The plan above +> is sound; the runnable commands are the gap to close before you pull the trigger. + +Net: **today** = same-mods rule + auto-provision handles new servers; **at 3.0** = +clean re-anchor and new maps need no special handling at all. It's a ~one-evening job +when 3.0 lands; nothing to prep now. + +The truly permanent fix that removes even the maintenance events: pin every block/item +to an explicit `id=` in a top-priority modlet so the runtime can never drift from the +canonical (untested on this engine version — a worthwhile experiment), or freeze the +RefugeBot build the canonical was anchored under. + +--- + +## 8. Known limits / frontier + +* **RWG needs one pick.** A random-gen world's save folder name is seed-derived and + unknown until after gen, so the operator sets "Shared player world" once (§5 step 3). + Auto-align kicks in after that. +* **Regions + player `.ttp` are not remapped.** The codecs for `Region/*.7rg` + (explored chunk blocks) and `Player/*.ttp` (inventory) weren't cracked, so alignment + is done on a **fresh, 0-region world before exploration** (which is exactly when a + new server provisions). The 87 shared characters are fine (already canon-written). + Solving these codecs (decompile `Assembly-CSharp.dll`) is what would let you migrate + characters across a build change instead of wiping them. + +--- + +## 9. Quick reference + +| Want to… | Do this | +|---|---| +| Add a server | §5 (auto-aligns; RWG needs the one Cluster-tab pick) | +| Confirm a world is aligned | save dir has `.canon-provisioned`; decorations in 24xxx band | +| Change mods | §6 (all servers together + rebuild canonical) | +| Wipe for 3.0 | §7 (freeze build → wipe → re-anchor canonical to live runtime) | +| Manually fix a stuck world | `nim-freeze/REMAP_RUNBOOK.md` | +| Understand the root cause | `CANONICAL_NIM_FREEZE.md` | +| Re-anchor / extend the canonical | `nim-freeze/rebuild.py` | +| Deploy controller / agent / module | §10 (+ the `panel-deploy` skill) | +| Recover a wedged agent (config fields won't load / 504s) | restart `panel-agent` on figaro — §10 / §11 | +| Known issues & open work | §12 (season10 console, the "setting up" indicator, probe-path) | +| What's deployed vs. uncommitted | §13 | + +--- + +## 10. Operational architecture & deploy (for the next engineer) + +**Where it runs** + +| Piece | Host | Notes | +|---|---|---| +| **Controller** (dashboard + gRPC) | kaiten `@` | binary `/home/refuge/panel/bin/controller`, unit `panel-controller.service`, HTTP `:8180`, gRPC `:8443` | +| **Agent** (owns the docker game containers) | figaro `@` | binary `/home/refuge/panel/bin/agent`, unit `panel-agent.service`, dials the controller | +| **Cluster shared dir** (the `/cluster` bind) | figaro `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/` | holds `canonical/` + `Player/`; bind-mounted into every member as `/cluster` | +| **Game containers** | figaro, image `panel-7dtd:latest` | named `panel-` (e.g. `panel-rg-season-10`) | + +SSH everywhere with `~/.ssh/bubbly_ed25519`, user `refuge`. **The full deploy recipe is the `panel-deploy` skill — follow it.** The NOPASSWD sudoers is exact-match (single-service `systemctl` forms only). + +**Panel access & conventions (read before driving anything)** +* **Dashboard:** `http://:8180` on the LAN (controller HTTP). Log in with your panel account. On kaiten itself the `http://127.0.0.1:8180/api/...` routes are unauthenticated — that's what the read-only diagnostic `curl`s in this doc use. +* **Server name → ids:** an instance's id is its slug. "Season 10" ⇒ instance id `rg-season-10` ⇒ container `panel-rg-season-10` ⇒ saves volume `panel-rg-season-10-saves`. List them all (with status) via `GET /api/instances`. Full live table in §13. +* **UI = API:** the `POST /api/instances//{env-config,start,stop,rebuild}` calls in these docs are exactly what the dashboard's Cluster-tab / Maintenance buttons fire. Use either — just target the right ``. +* **"Stop / Start / Restart" always means the PANEL action** (agent-owned, clean shutdown). **NEVER `docker stop/start` a `panel-*` container** — the agent owns lifecycle + state reconciliation. Confirm a container is stopped via the grey card, or read-only `docker inspect -f '{{.State.Running}}' panel-` → `false`. (Read-only `docker exec/logs/inspect` and `docker run` sidecars are fine.) +* **"Restart" vs "Rebuild" (load-bearing):** Restart = panel Stop then Start, **same image** — safe for any server *including season10*. **Rebuild/Recreate = adopts `panel-7dtd:latest`** — needed to pick up a module/image change, but **forbidden for `rg-season-10`** (it must stay on its old image; §13). +* **New entrypoint env vars MUST be declared in `module.yaml` `env:`** or the agent silently drops them (symptom: the var is empty at runtime even though you set it). `CLUSTER_ID` + `CLUSTER_PLAYER_SAVE` are already declared — add any new `CLUSTER_*` / provisioning var there too. + +**Three deployable pieces** + +1. **Controller** (Go + the dashboard HTML in `controller/cmd/controller/static/{new,index}.html`) — `GOOS=linux GOARCH=amd64 go build -o bin/controller-linux ./controller/cmd/controller`, scp to kaiten `bin/controller.new`, bak + swap + `systemctl restart panel-controller.service`. + * ⚠️ **After every controller deploy, HARD-refresh the browser (Ctrl-Shift-R).** A normal refresh serves the *old* cached JS against the new backend and the page half-breaks (fields won't load, console blank). It looks exactly like a real outage but is just stale cache — rule this out first. +2. **Agent** (Go) — `… go build -o bin/agent-linux ./agent/cmd/agent`, scp to figaro, bak + swap + `systemctl restart panel-agent.service`. + * Restarting the agent does **NOT** stop the game containers — it re-attaches/rehydrates them cleanly (stats + rcon + log streams re-open). Safe anytime; it is also the **recovery** for a wedged agent (§11). +3. **Module** (`modules/7dtd/{entrypoint.sh,Dockerfile,nim-freeze/remap_7dt.vendored.py}`) — sync the module dir Windows → kaiten → figaro, then build the image **on figaro** (where the containers run; kaiten holds the source-of-truth copy but builds nothing): + ```sh + # from the Windows repo C:\Users\dbled\sources\panel : + scp -r modules/7dtd/ @:/home/refuge/panel/modules/ + ssh @ 'rsync -av /home/refuge/panel/modules/7dtd/ @:/home/refuge/panel/modules/7dtd/' + ssh @ 'cd /home/refuge/panel/modules/7dtd && docker build -t panel-7dtd:latest .' + ``` + Existing containers **pin to the old image ID** — a container only adopts the new image via **Settings → Maintenance → Rebuild** (or delete-preserve-volumes + recreate). + * ⚠️ **Never Rebuild/recreate `rg-season-10`.** It is the live released server *and* the canonical anchor; it must stay on its current (old) image. `.Config.Image` shows the name `:latest` for every container — verify by image **ID**, not name. + +**Do NOT `docker stop/start` a `panel-*` game container from the CLI** — let the panel do it (the agent owns lifecycle + state reconciliation). Read-only `docker exec`, `docker logs`, `docker inspect`, and `docker run` sidecars are fine. + +--- + +## 11. Incident log & landmines + +### 2026-06-13 — agent-wide hang (ExecCapture connection leak) ✅ FIXED + HARDENED + +* **Symptom:** config fields wouldn't load on **any** server (the durable `serverconfig.xml` editor → `/api/instances/{id}/files/read` returned **HTTP 504 "timeout waiting for agent response"**); one server's console went blank; the agent was **idle (0% CPU) but unresponsive to file ops**; no panic. +* **Root cause:** the auto-provision watcher (`agent/internal/dispatch/decoremap_provision.go`) polls a running container with a docker exec (`DockerRuntime.ExecCapture`) every 20s. `ExecCapture` ran `stdcopy.StdCopy(…att.Reader)` on the hijacked exec stream **with no `ctx` enforcement on the Read**. When it caught the just-bounced `cluster-seed-test` mid-restart, the read blocked **forever** and leaked a docker SDK connection each cycle → the SDK client's connection pool exhausted → **every** later docker op (`CopyFileFromContainer` for config reads, a log-stream re-attach) hung. It is **not** a Go-mutex deadlock (the only mutex in `docker.go` — `hijackedStream.mu` — just guards the stdio-attach `Close()` and is unrelated); it's connection-pool starvation. +* **Recover:** `ssh @ 'sudo -n /usr/bin/systemctl restart panel-agent.service'` — clears the leaked connections; game containers keep running. **Confirm recovery:** re-open any server's Config tab (fields load within a few seconds), or on kaiten `curl -s -o /dev/null -w '%{http_code}\n' 'http://127.0.0.1:8180/api/instances/rg-season-10/files/read?path=/game-saves/serverconfig.xml'` → expect **200**, not 504. +* **Permanent fix (shipped):** `ExecCapture` now runs `StdCopy` on a goroutine and `select`s on `ctx.Done()`; on timeout it `att.Close()`s to unblock the read, then drains the goroutine before reading the buffers (race-free). On the timeout path it returns the partial stdout/stderr with exit code `-1` and `exec copy: `, so callers can tell a wedged exec from a clean one. An exec can no longer hang the whole agent. +* **Landmine for the future:** *any* agent docker exec/stream read that ignores `ctx` can leak connections and wedge the entire agent the same way. If you add exec-based features, bound the read by `ctx`. (The log-stream demux at `docker.go:271` is a long-lived `Follow` stream — a different model, closes on the docker `rc`'s EOF/ctx — and is fine.) +* **Recognize it next time:** `files/read` 504s for *every* server + agent at 0% CPU + no crash ⇒ restart the agent first, then hunt for a new unbounded docker read. + +--- + +## 12. Known issues & open work + +1. **season10's panel console is blank** (separate from §11; pre-existing). season10's docker json-log has grown to **13 MB** and it is actively spamming exceptions: `No ItemClass entry for type 678xx` (×611) + `Log:Exception` / `ThreadManager:UpdateMainThreadTasks`. Docker's own `--follow --tail 400` serves season10 instantly and the other four servers stream fine, but the agent's SDK log-follow **stalls on season10's stream before forwarding a single line** (blocked-inside, not thrashing — no "log stream ended" retries). Re-attaching (two agent restarts) did **not** fix it. It will likely clear on a **season10 container restart** — a plain panel **Stop → Start**, which keeps season10 on its current image and is safe; this is **not** a Rebuild/recreate (that would adopt `panel-7dtd:latest` and break the canonical anchor — forbidden, see §10/§13). It may re-blank once the exception spam refills the log. **Durable fix:** resolve the `No ItemClass entry` item-config gap (kills the console stall, the log bloat, *and* the exceptions at once), **or** harden the agent log-follow — mechanism not fully pinned (the agent has no pprof endpoint, so no clean goroutine dump was possible). **Do NOT recreate season10 to fix this** (old-image constraint, §10). + +2. **The "setting up" provisioning indicator doesn't surface.** The agent watcher emits `detail="Finishing cluster setup…"` and the dashboard renders a matching amber "setting up" pill — but the agent's own **state-reconciliation loop emits `detail="announced:running"` a beat later and overwrites it**, so the card flips green prematurely. The **Console line** (`auto-provision: aligning the new world's decorations to the cluster…`) *does* show and survives a refresh — that part works. Proper fix: a **dedicated provisioning state field** the reconciler respects, not the shared `detail` string. (The card-pill matcher is live but dormant in `new.html`/`index.html`; harmless.) + +3. **Auto-provision watcher probe-path caveat (zero-touch isn't universal yet).** The watcher gates "gen finished" on `main.ttw` **only**; it reads `decoration.7dt` solely to detect the already-canon (≥24000) case. A *missing* `decoration.7dt` still yields `NEEDS_ALIGN` and a bounce — the graceful-stop save writes the native deco, which the entrypoint then remaps on the align boot. For some RWG worlds (e.g. Tuxeno on `cluster-seed-test`/pvp) that two-step doesn't always *complete* zero-touch, but the **entrypoint backstop still aligns the world on its next ordinary restart**, so it ends up correct. Worlds that bake deco at the save path at gen (e.g. refuge-mall) provision fully zero-touch. **Treat the entrypoint remap as the reliable path; the watcher bounce is the optimization.** + +4. **`No ItemClass entry for type 678xx` on season10** (root of #1). A set of high-band item ids (mod / RefugeBot runtime items) the merged config references but doesn't define. Tracing the missing items cleans up the console stall, the 13 MB log bloat, and the on-server exceptions in one shot. Not urgent — season10 runs fine (20 FPS, players on). + +--- + +## 13. Handoff status — what's deployed, what's uncommitted + +**Cluster:** `cl_37e3a7f72cdc`. Shared `Player/` ≈ 88 `.ttp`. Live members: + +| Server | Instance id | Container / saves volume | Active world (`CLUSTER_PLAYER_SAVE`) | Role | +|---|---|---|---|---| +| Season 10 | `rg-season-10` | `panel-rg-season-10` / `panel-rg-season-10-saves` | `West Apeeni Mountains/MyGame` *(canonical anchor)* | **master / anchor — DO NOT Rebuild** | +| Creative | `creative` | `panel-creative` / `panel-creative-saves` | `Tefasizi County/Refuge` | creative | +| Insane Survival | `insane-survival` | `panel-insane-survival` / `panel-insane-survival-saves` | `Yixacove Valley/Refuge` | survival | +| PvP | `cluster-seed-test` | `panel-cluster-seed-test` / `panel-cluster-seed-test-saves` | `Tuxeno Territory/RefugeTHREEPVP` | pvp | +| Mall | `refuge-mall` | `panel-refuge-mall` / `panel-refuge-mall-saves` | `New Vebape Territory/RefugeMall` | mall | + +> **Canonical anchor — the single highest-blast-radius fact.** The complete frozen table (**38,626 blocks / 2,719 items**) lives ONLY at season10's `…/Saves/West Apeeni Mountains/MyGame/{block,item}mappings.nim`. season10's *other* save dirs are stale partials (e.g. `Navezgane/MyGame` = 3,356 blocks, `Iupu Territory/Troutdale` = 5,028) and its `CLUSTER_PLAYER_SAVE` env is **empty** (old image, predates the picker — so don't trust the env to find the anchor). Any re-anchor (§6 / §7) MUST copy from exactly `West Apeeni Mountains/MyGame` and **verify the counts read 38,626 / 2,719 (or higher — never lower)** before running `rebuild.py`. Anchoring against a partial silently wipes the ~88 shared characters. + +**Deployed (live):** +* **Controller** on kaiten — has the create-form cluster note + the auto-provision "starting" chip + the (dormant) "setting up" card-pill code. +* **Agent** on figaro — has the auto-provision watcher **and** the hardened `ExecCapture` (§11). +* **Image `panel-7dtd:latest`** on figaro — has the entrypoint remap block + `python3-minimal` + the vendored `remap_7dt.py`. Running on creative / insane-survival / cluster-seed-test / refuge-mall. +* **`rg-season-10` deliberately on the OLD image** (no `.panel-save-base`, no remap block) — the live anchor; do not move it. +* **refuge-mall** — created via the panel this session and registered as a RefugeBot tenant (prod DB row Id=31 + `refugebot.json` in its saves volume), connected. + +**⚠️ UNCOMMITTED — nothing from this work is in git.** `C:\Users\dbled\sources\panel` is a git working tree but all of the below is uncommitted: the controller dashboard edits, the agent Go (`decoremap_provision.go`, the `dispatch.go` hook + `provisionGuard`, the `ExecCapture` hardening), the module (`entrypoint.sh` remap block, `Dockerfile`, `nim-freeze/remap_7dt.vendored.py`), and these docs. **Rollback points** if a deploy goes bad: `controller.bak-` on kaiten, `agent.bak-` on figaro (newest = the binary just before the current one). + +**If/when you commit,** suggested grouping: (1) canonical + deco freeze/remap, (2) auto-provision (entrypoint + watcher), (3) the `ExecCapture` hardening, (4) docs. Note in the message that the cluster is **live** so a reviewer treats it as load-bearing. + +--- + +## 14. Disaster recovery — the three irreplaceable trees + +These three live on figaro and **cannot be regenerated from the repo** — they *are* the source of truth. If figaro's disk dies, this is what hurts: + +| Tree | Path on figaro | What it is | If lost | +|---|---|---|---| +| **The canonical** | `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/canonical/{block,item}mappings.nim` | the frozen stamp source every member uses | rebuildable from season10's `West Apeeni Mountains/MyGame` anchor via `rebuild.py` (needs `~/nimcanon`) | +| **Shared characters** | `…/panel/data/7dtdcluster/cl_37e3a7f72cdc/Player/` (= `/cluster/Player`) | the ~88 player `.ttp` (every character + inventory) | **unrecoverable** — everyone resets to level 1 | +| **The rebuild toolchain** | `~/nimcanon/` (`tool/`, `registry/`, `canonical_final/`, `remap/`) | the only parser + name registry + capture tooling to rebuild any of the above | must re-harvest from scratch (the §6 harvest — currently uncommitted) | + +> ⚠️ **Open risk — confirm these are actually backed up off-figaro.** `~/nimcanon` is persisted out of `/tmp` (survives a reboot) but a disk loss is unaddressed in these docs. **Recommendation:** snapshot all three trees to another host on a schedule (reuse the panel's backup system or a periodic `tar`). Restore order: canonical → Player → validate with an in-game transfer (CANONICAL_NIM_FREEZE "Verify a server is frozen"). Treat this as the **top open risk** until a backup is confirmed. diff --git a/modules/7dtd/Dockerfile b/modules/7dtd/Dockerfile new file mode 100644 index 00000000..b1000297 --- /dev/null +++ b/modules/7dtd/Dockerfile @@ -0,0 +1,71 @@ +# panel-native 7 Days to Die runtime image. +# +# Unlike third-party images (vinanrra/LGSM, didstopia, etc.), this image does +# ONE thing: run a 7DTD dedicated server. Download + validate is handled by +# the panel's SteamCMD updater sidecar, which writes into the same Docker +# volume we mount read/write here. No LGSM, no tmux, no GitHub-fetch on +# start, no backup-before-start "surprises" — just the game. +# +# Expected runtime layout inside the container: +# /game — SteamCMD-installed 7DTD binaries (panel volume) +# /game-saves — save + config data (panel volume) +# /entrypoint.sh — this image's entrypoint (renders serverconfig.xml and +# execs the game with stdout/stderr unbuffered) + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +# Runtime deps 7DTD's Linux build needs. lib32* for the 32-bit Steam bits, +# libxi/libxrandr for the server's Unity runtime probes, ca-certs so any +# outbound HTTPS (analytics, steam auth) resolves. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + python3-minimal \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Unprivileged user that owns /game. The updater sidecar writes as root, so +# at start time we chown once to panel (cheap since SteamCMD leaves a known +# set of files). This keeps the long-running game process non-root. +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Vendored nim-tools: the in-container decoration remapper used by the cluster +# auto-provision step in entrypoint.sh. Self-contained (nimtool.read_nim inlined), +# python3-minimal (above) is its only dependency. A new clustered world's native +# decorations are remapped to the cluster canonical here so it loads clean. +COPY nim-freeze/remap_7dt.vendored.py /opt/panel-nimtools/remap_7dt.py + +# Panel-owned helper mod — exposes blood-moon status as a JSON file the +# scheduler reads to defer restarts through hordes. Lives in /opt so the +# entrypoint can copy it into /game/Mods/ on every boot (so a steam +# validate or operator overwrite re-seeds it). +COPY mod-source/PanelBloodMoonAgent/bin/Release/net48/PanelBloodMoonAgent.dll /opt/panel-mods/PanelBloodMoonAgent/PanelBloodMoonAgent.dll +COPY mod-source/PanelBloodMoonAgent/ModInfo.xml /opt/panel-mods/PanelBloodMoonAgent/ModInfo.xml + +# Game UDP ports — published by the panel manifest at run time. +EXPOSE 26900/udp 26901/udp 26902/udp +# Telnet RCON +EXPOSE 8081/tcp + +# tini reaps zombies + forwards signals cleanly so our SIGTERM → graceful stop +# actually lands on the 7DTD process (otherwise docker stop becomes kill -9). +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/7dtd/README.md b/modules/7dtd/README.md new file mode 100644 index 00000000..1216925e --- /dev/null +++ b/modules/7dtd/README.md @@ -0,0 +1,22 @@ +# 7 Days to Die module + +Steam app id `294420`. Dedicated server runs on Windows and Linux. Configuration is a single `serverconfig.xml` with `` entries — the panel's schema loader needs an XML-format adapter (not `.properties`). + +## RCON surface + +7DTD does not speak Source RCON. The admin interface is **telnet on port 8081**, line-oriented, with a password prompt on connect. The module sets `rcon.adapter: telnet`. Commands are plain strings (`lp`, `say "..."`, `kick`, `shutdown`). + +## Log patterns + +Join and leave patterns use single-quoted fields — `PlayerName='Bob'`. These are stable across A19–A22. The panel's log tailer uses named regex groups; extracted fields map into `PlayerEvent.player_id`, `player_name`, etc. + +## Ports + +- `26900/udp` — game + Steam query +- `26901/udp`, `26902/udp` — peer-to-peer data +- `8080/tcp` — optional web admin +- `8081/tcp` — telnet admin (internal — should NOT be exposed outside the Target) + +## Updates + +Via SteamCMD, `app_update 294420`. Two branches are surfaced: `stable` and `latest_experimental` (the latter via SteamCMD `beta: latest_experimental`) — see `module.yaml` `update_providers`. The telnet password is auto-generated on first install **only if `TelnetPassword` is empty**, and stored plaintext at `/game-saves/.panel-telnet-password` (also surfaced in the Config tab). Read it there to telnet/RCON in manually during an incident. diff --git a/modules/7dtd/entrypoint.sh b/modules/7dtd/entrypoint.sh new file mode 100644 index 00000000..d769e99a --- /dev/null +++ b/modules/7dtd/entrypoint.sh @@ -0,0 +1,341 @@ +#!/bin/bash +# panel-native 7DTD entrypoint. +# +# Contract: +# /game — SteamCMD-installed dedicated server (volume, populated by +# the panel's SteamCMD updater sidecar). If missing, exit +# with a helpful message telling the operator to run Update. +# /game-saves — save + config writable volume. +# $TELNET_PASSWORD — injected by the panel via a module secret; rendered +# into serverconfig.xml so telnet RCON matches. +# +# On every start we re-render serverconfig.xml from the bind-mounted +# template so operator edits to module values take effect without needing a +# Docker image rebuild. Saves and world data live in /game-saves so a steam +# validate won't ever touch them. + +set -euo pipefail + +log() { printf '[panel-7dtd] %s\n' "$*"; } + +GAME_DIR=/game +SAVE_DIR=/game-saves +CONFIG=${SAVE_DIR}/serverconfig.xml +TEMPLATE=${SAVE_DIR}/serverconfig.xml.rendered + +# Allocator-derived ports. Panel sets these via container env (manifest's +# ports[].env: + runtime.docker.env: declarations). Defaults mirror the +# canonical 7DTD numbers so single-instance setups still work without +# the panel allocator. +GAME_PORT="${GAME_PORT:-26900}" +TELNET_PORT="${TELNET_PORT:-8081}" +WEBDASH_PORT="${WEBDASH_PORT:-8080}" + +# --- sanity: game files installed? --- +if [[ ! -x "${GAME_DIR}/startserver.sh" && ! -x "${GAME_DIR}/7DaysToDieServer.x86_64" ]]; then + log "ERROR: /game is empty." + log " Game files haven't been downloaded yet." + log " Run 'Update' on this server in the panel — that kicks off a" + log " SteamCMD sidecar which installs 7DTD into this volume." + exit 78 # EX_CONFIG +fi + +# --- render serverconfig.xml --- +# The panel writes a pre-rendered serverconfig.xml.rendered into the save +# volume at create/update time (see module create hook). We symlink it into +# the game dir so the game binary finds it on the expected path. +if [[ -s "${TEMPLATE}" ]]; then + log "using panel-rendered config (${TEMPLATE})" + cp -f "${TEMPLATE}" "${CONFIG}" +elif [[ ! -s "${CONFIG}" ]]; then + log "no panel config found — using the shipped default in ${GAME_DIR}/serverconfig.xml" + if [[ -s "${GAME_DIR}/serverconfig.xml" ]]; then + cp -f "${GAME_DIR}/serverconfig.xml" "${CONFIG}" + else + log "ERROR: no default serverconfig.xml shipped with the game either — aborting" + exit 78 + fi +fi + +# Make sure the game reads OUR config (symlink from game dir → save dir). +ln -sf "${CONFIG}" "${GAME_DIR}/serverconfig.xml" + +# --- auto-populate TelnetPassword if empty --- +# +# 7DTD is surprising here: if TelnetPassword="", the server *enables* telnet +# but binds it to the container's loopback only, so any host-mapped port is +# unreachable. The panel's RCON cannot connect → every "list players" call +# fails with `telnet auth: read password prompt: EOF`. +# +# Fix: on first boot, if TelnetPassword is empty we stamp a random one into +# the config + persist the plaintext to /game-saves/.panel-telnet-password +# so the Config tab can surface it to the operator. Once set, we never +# rotate it — operator owns it from then on. +PASS_FILE="${SAVE_DIR}/.panel-telnet-password" +if grep -Eq '' "${CONFIG}" 2>/dev/null; then + if [[ -s "${PASS_FILE}" ]]; then + NEW_PW=$(cat "${PASS_FILE}") + log "using persisted TelnetPassword from ${PASS_FILE}" + else + NEW_PW=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n') + printf '%s' "${NEW_PW}" > "${PASS_FILE}" + chmod 600 "${PASS_FILE}" || true + log "generated random TelnetPassword (persisted to ${PASS_FILE})" + fi + # Replace the empty-password line in-place. Using | as sed delimiter so + # the replacement can contain / without escaping. + sed -i "s|||" "${CONFIG}" +fi + +# --- patch port properties from env on every boot --- +# Multi-instance safety: the panel allocator picks unique host ports per +# instance, the agent injects them as env vars, and we patch them into +# serverconfig.xml here so 7DTD actually binds them. Without this, two +# 7DTD instances on the same agent would both default to 26900/8081/8080 +# and only one would launch successfully. +# +# 7DTD computes the LiteNetLib alt + p2p backup ports as ServerPort+1 and +# ServerPort+2 internally — only ServerPort needs to be patched. We DO +# allocate the +1/+2 slots in module.yaml so the panel knows they're taken. +log "patching ports: ServerPort=${GAME_PORT}, TelnetPort=${TELNET_PORT}, WebDashboardPort/ControlPanelPort=${WEBDASH_PORT}" +# NOTE: 7DTD 1.0 renamed the web port property "ControlPanelPort" → "WebDashboardPort". +# Both are patched so this works across versions; the legacy line is a harmless +# no-op on 1.0 configs (no ControlPanelPort property to match). Without the +# WebDashboardPort line every instance kept the template default 8080 and, on +# this host-network agent, all but the first to boot threw the Allocs WebServer +# "Address already in use" exception. +sed -i -E \ + -e "s|(/ (correct when GameWorld is already a concrete + # world name; an RWG server will just need a one-time pick after first boot). + if [[ -n "${CLUSTER_PLAYER_SAVE:-}" ]]; then + SAVE_BASE="${SAVES_ROOT}/${CLUSTER_PLAYER_SAVE}" + log "cluster ${CLUSTER_ID}: shared player world selected by operator -> ${CLUSTER_PLAYER_SAVE}" + elif [[ -n "${GW}" && -n "${GN}" ]]; then + SAVE_BASE="${SAVES_ROOT}/${GW}/${GN}" + log "cluster ${CLUSTER_ID}: shared player world = ${GW}/${GN} (literal; pick one in the Cluster tab if this server uses RWG)" + else + SAVE_BASE="" + log "WARN: CLUSTER_ID set but no CLUSTER_PLAYER_SAVE and GameWorld/GameName unparseable — skipping Player symlink" + fi + if [[ -n "${SAVE_BASE}" ]]; then + PLAYER_DIR="${SAVE_BASE}/Player" + mkdir -p "${SAVE_BASE}" /cluster/Player + # Record the resolved active-world save dir so the agent's auto-provision + # watcher can locate this world's decoration.7dt (to detect a fresh + # native-banded world that needs aligning to the cluster canonical). + printf '%s' "${SAVE_BASE}" > "${SAVE_DIR}/.panel-save-base" 2>/dev/null || true + # One-time migration: when this server has a real (non-symlink) Player + # dir from standalone play, seed the shared cluster dir from it so this + # server's existing progression becomes the cluster source of truth. + # Hardened for irreplaceable saves (e.g. Season 10): + # * only seed when the shared dir is still EMPTY (first member wins — + # never copy into an already-seeded cluster), + # * snapshot the local Player dir before touching it, + # * only delete the local copy once the seeding copy has SUCCEEDED, so + # a partial copy can never destroy the server's only Player files. + do_symlink=1 + if [[ -d "${PLAYER_DIR}" && ! -L "${PLAYER_DIR}" ]]; then + if [[ -z "$(ls -A /cluster/Player 2>/dev/null)" ]]; then + snap="${SAVE_BASE}/Player.preseed-${CLUSTER_ID}" + log "cluster ${CLUSTER_ID}: seeding empty shared Player dir from local saves (snapshot -> ${snap})" + cp -a "${PLAYER_DIR}" "${snap}" 2>/dev/null || log "WARN: pre-seed snapshot failed (continuing)" + if cp -a "${PLAYER_DIR}/." /cluster/Player/ 2>/dev/null; then + rm -rf "${PLAYER_DIR}" + else + log "ERROR: failed to seed /cluster/Player from ${PLAYER_DIR}; KEEPING local Player, NOT symlinking (no data lost)" + do_symlink=0 + fi + else + archive="${SAVE_BASE}/Player.local-pre-cluster" + log "cluster ${CLUSTER_ID}: shared Player dir already populated; archiving local Player -> ${archive}, adopting shared progression" + rm -rf "${archive}" 2>/dev/null || true + mv "${PLAYER_DIR}" "${archive}" 2>/dev/null || rm -rf "${PLAYER_DIR}" + fi + fi + if [[ "${do_symlink}" == "1" ]]; then + ln -sfn /cluster/Player "${PLAYER_DIR}" + chown -R 1000:1000 /cluster/Player 2>/dev/null || true + log "cluster ${CLUSTER_ID}: ${PLAYER_DIR} -> /cluster/Player" + fi + + # --- auto-provision: align a NEW world's native decorations to canonical --- + # A freshly-generated cluster world bakes decoration.7dt / multiblocks.7dt by + # NUMERIC block id using the engine's NATIVE id table at generation. The stamp + # guard below then puts the CANONICAL .nim over the world's .nim, so those + # native-baked deco ids resolve to a null Block -> DecoManager NRE -> the world + # loads but never reaches GameStartDone ("online but unjoinable"). So BEFORE + # stamping, if this world's decorations are still in the NATIVE id band, remap + # them name-by-name to canonical using THIS WORLD'S OWN native blockmappings.nim + # (which the engine wrote at generation and is still on disk this boot, before + # the stamp). Tree-deco band: native < 24000 / canonical >= 24000 (verified). + # STRICT no-op for already-canon worlds (season10/creative/insane/pvp -> deco in + # canon band, never enters the branch), non-cluster servers (outside CLUSTER_ID), + # and steady state (post-remap deco is canon-band). multiblocks.7dt uses the same + # full native table (NOT a deco subset). See nim-freeze/REMAP_RUNBOOK.md. + DECO_F="${SAVE_BASE}/decoration.7dt" + CANON_B="/cluster/canonical/blockmappings.nim" + NAT_NIM="${SAVE_BASE}/blockmappings.nim" + REMAP_TOOL="/opt/panel-nimtools/remap_7dt.py" + MARK="${SAVE_BASE}/.canon-provisioned" + if [[ -f "${DECO_F}" && -f "${CANON_B}" && -f "${NAT_NIM}" && -f "${REMAP_TOOL}" ]] && command -v python3 >/dev/null 2>&1; then + # first decoration block id = u16 @ file offset 17 (hdr 5 + record id-offset 12); mask to 15 bits. + first_raw="$(od -An -tu2 -j17 -N2 "${DECO_F}" 2>/dev/null | tr -d ' ' || true)" + [[ "${first_raw}" =~ ^[0-9]+$ ]] || first_raw=0 + first_deco=$(( first_raw & 0x7FFF )) + if [[ "${first_deco}" -gt 0 && "${first_deco}" -lt 24000 ]]; then + # Native-band deco present. Safety: confirm the on-disk .nim is the world's + # NATIVE table (count != canonical count), so we remap against the right map + # rather than a previously-stamped canonical (which would silently morph). + nat_cnt="$(od -An -tu4 -j4 -N4 "${NAT_NIM}" 2>/dev/null | tr -d ' ' || true)"; [[ "${nat_cnt}" =~ ^[0-9]+$ ]] || nat_cnt=0 + canon_cnt="$(od -An -tu4 -j4 -N4 "${CANON_B}" 2>/dev/null | tr -d ' ' || true)"; [[ "${canon_cnt}" =~ ^[0-9]+$ ]] || canon_cnt=0 + if [[ "${nat_cnt}" -gt 0 && "${nat_cnt}" != "${canon_cnt}" ]]; then + log "cluster ${CLUSTER_ID}: new world '${SAVE_BASE##*/Saves/}' has native-band decorations (first id ${first_deco}); aligning decorations to cluster (remap native->canonical)…" + remap_ok=1 + for f in "${DECO_F}" "${SAVE_BASE}/multiblocks.7dt"; do + [[ -f "${f}" ]] || continue + if python3 "${REMAP_TOOL}" apply "${NAT_NIM}" "${CANON_B}" "${f}" "${f}.canon-tmp" > "/tmp/decoremap.$$" 2>&1; then + cp -f "${f}" "${f}.native-bak" + mv -f "${f}.canon-tmp" "${f}" + chown 1000:1000 "${f}" 2>/dev/null || true + log "cluster ${CLUSTER_ID}: remapped $(basename "${f}") native->canonical ($(grep -oE 'records_rewritten=[0-9]+' "/tmp/decoremap.$$" || true))" + else + log "ERROR: cluster ${CLUSTER_ID}: remap of $(basename "${f}") FAILED — refusing to start (would be unjoinable). remap output:" + while IFS= read -r _l; do log " ${_l}"; done < "/tmp/decoremap.$$" + rm -f "${f}.canon-tmp" "/tmp/decoremap.$$" + remap_ok=0 + break + fi + done + rm -f "/tmp/decoremap.$$" 2>/dev/null || true + if [[ "${remap_ok}" == "1" ]]; then + sha256sum "${CANON_B}" 2>/dev/null | cut -d' ' -f1 > "${MARK}" || true + chown 1000:1000 "${MARK}" 2>/dev/null || true + log "cluster ${CLUSTER_ID}: decorations aligned to cluster canonical — world is now canon-compatible" + else + exit 1 + fi + else + log "WARN: cluster ${CLUSTER_ID}: native-band decorations but world .nim count=${nat_cnt} == canonical (${canon_cnt}); cannot identify the native table to remap against — leaving as-is (may NRE; see nim-freeze/REMAP_RUNBOOK.md to fix manually)" + fi + fi + fi + + # --- cluster canonical .nim freeze guard --- + # Stamp the cluster's canonical block/item id maps over THIS world's .nim + # on every boot, so every member resolves the shared player inventory + # through the SAME id table. This eliminates the per-world id divergence + # that otherwise morphs inventory items (glass block -> car hood) and wipes + # characters to level 1 on transfer: 7DTD stores inventory by NUMERIC id, + # each world bakes its own name<->id table (itemmappings.nim/blockmappings.nim), + # and a shared .ttp decoded through a different table resolves every id to a + # different name. The canonical is the season10-anchored, registry-COMPLETE + # table (every block incl. the full base:shape cross-product, every item + + # every item_modifier), so the engine never has to append a fresh id -> the + # table stays frozen. It lives ONCE in the shared cluster dir + # (/cluster/canonical) and is applied only when the world save dir already + # exists (a freshly-generated world is frozen on its FIRST reboot). + # Idempotent + self-healing: re-stamped every boot, so a recreate/regen or + # any stray append can never silently re-diverge the cluster. + if [[ -f /cluster/canonical/blockmappings.nim && -f /cluster/canonical/itemmappings.nim && -d "${SAVE_BASE}" ]]; then + cp -f /cluster/canonical/itemmappings.nim "${SAVE_BASE}/itemmappings.nim" || log "WARN: canonical item-id-map stamp failed" + cp -f /cluster/canonical/blockmappings.nim "${SAVE_BASE}/blockmappings.nim" || log "WARN: canonical block-id-map stamp failed" + log "cluster ${CLUSTER_ID}: stamped canonical id maps over ${SAVE_BASE} (frozen, divergence-proof)" + fi + fi +else + # Not clustered: if a stale symlink to /cluster lingers from a prior + # membership, drop it so 7DTD recreates a local Player dir. + GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true) + GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true) + if [[ -n "${GW}" && -n "${GN}" ]]; then + PLAYER_DIR="${SAVE_DIR}/.local/share/7DaysToDie/Saves/${GW}/${GN}/Player" + if [[ -L "${PLAYER_DIR}" ]]; then rm -f "${PLAYER_DIR}"; fi + fi +fi + +# --- sync panel-owned mods into /game/Mods/ --- +# /opt/panel-mods/ is baked into the image. We rsync (well, cp -a) each +# subdir into /game/Mods/ on every boot so: +# - a fresh SteamCMD install picks up our mods automatically +# - operator-deleted mods are restored +# - image rebuilds (with newer mod DLLs) propagate without manual work +# We DO NOT remove other operator-installed mods — only overwrite +# panel-prefixed ones (named "Panel*"). +if [[ -d /opt/panel-mods ]]; then + mkdir -p "${GAME_DIR}/Mods" + for src in /opt/panel-mods/*/; do + name=$(basename "${src}") + dest="${GAME_DIR}/Mods/${name}" + log "panel mod sync: ${name} → ${dest}" + rm -rf "${dest}" + cp -a "${src}" "${dest}" + done +fi + +# --- go --- +cd "${GAME_DIR}" +log "starting 7 Days to Die dedicated server" +log " game dir: ${GAME_DIR}" +log " save dir: ${SAVE_DIR}" +log " config: ${CONFIG}" +log " game port: ${GAME_PORT}/udp (+1, +2 implicit)" +log " telnet port: ${TELNET_PORT}/tcp" +log " control port: ${WEBDASH_PORT}/tcp" + +# IMPORTANT: we deliberately do NOT use ./startserver.sh — the shipped +# script hardcodes `-logfile `, routing all server output to a +# filesystem log that the panel can't tail without extra bind-mount +# gymnastics. Invoking the binary directly with `-logfile -` streams +# every line to stdout, which docker logs picks up, which the panel +# Console tab can surface in real time. +# +# LD_LIBRARY_PATH is what startserver.sh set — the game loads local .so +# files, not system ones, so this is mandatory. +export LD_LIBRARY_PATH=. + +exec stdbuf -oL -eL ./7DaysToDieServer.x86_64 \ + -configfile=serverconfig.xml \ + -logfile - \ + -quit -batchmode -nographics -dedicated diff --git a/modules/7dtd/mod-source/PanelBloodMoonAgent/BloodMoonAgent.cs b/modules/7dtd/mod-source/PanelBloodMoonAgent/BloodMoonAgent.cs new file mode 100644 index 00000000..ee1161cc --- /dev/null +++ b/modules/7dtd/mod-source/PanelBloodMoonAgent/BloodMoonAgent.cs @@ -0,0 +1,436 @@ +// PanelBloodMoonAgent — writes a tiny status JSON the panel scheduler +// reads to decide whether to delay a restart through a blood-moon horde. +// +// Status file path: /game-saves/.panel-bloodmoon-status.json +// (lives next to .panel-telnet-password — same volume the entrypoint +// uses for panel-owned state) +// +// Shape: +// { +// "schema": 1, +// "written_at_unix": 1763500000, +// "in_game_day": 14, +// "in_game_hour": 22, +// "in_game_minute": 3, +// "bloodmoon_freq": 7, +// "bloodmoon_range": 0, +// "day_night_length": 60, +// "next_horde_day": 14, +// "active": true, +// "ends_at_unix": 1763500900, // best-effort wall-clock end (0 = unknown) +// "source": "ai_director" // or "math_fallback" +// } +// +// We bind IModApi + Mod at compile time so the 7DTD loader finds our entry +// point through its normal reflection probe. EVERY other game type +// (GamePrefs, World, AIDirector, AIDirectorBloodMoonComponent, GameStats, +// GameUtils) goes through string-keyed reflection — the refugebotserver +// Agent's 2026-04-29 outage taught us that a hard reference to a missing +// EnumGamePrefs member makes Mono refuse to load the whole assembly. +// Type-name lookup keeps us version-tolerant. + +using System; +using System.IO; +using System.Reflection; +using System.Threading; + +namespace Panel.BloodMoonAgent +{ + // IModApi is in Assembly-CSharp. The 7DTD ModManager scans loaded mod + // assemblies for public, non-abstract types implementing this interface + // and instantiates the first one it finds, then calls InitMod(Mod). + public class BloodMoonAgent : IModApi + { + public void InitMod(Mod _modInstance) + { + try + { + if (_instance != null) return; // idempotent — Mod manager only calls once, but be safe + _instance = this; + Start(); + TryLog("InitMod completed; status file: " + OutputPath); + } + catch (Exception ex) + { + TryLog("InitMod failed: " + ex.Message); + } + } + + private static BloodMoonAgent? _instance; + + // Reflection handles — looked up once, cached. Null means "not in + // this game build" and we silently skip that data source. + private Type? _enumPrefsT; + private Type? _gamePrefsT; + private Type? _gameManagerT; + private Type? _gameUtilsT; + private Type? _gameStatsT; + private Type? _enumGameStatsT; + + private Thread? _worker; + private volatile bool _stopRequested; + + private const string OutputPath = "/game-saves/.panel-bloodmoon-status.json"; + private const string OutputPathTmp = "/game-saves/.panel-bloodmoon-status.json.tmp"; + private const int TickSeconds = 5; + + private void Start() + { + _enumPrefsT = ResolveType("EnumGamePrefs"); + _gamePrefsT = ResolveType("GamePrefs"); + _gameManagerT = ResolveType("GameManager"); + _gameUtilsT = ResolveType("GameUtils"); + _gameStatsT = ResolveType("GameStats"); + _enumGameStatsT = ResolveType("EnumGameStats"); + + _worker = new Thread(WorkerLoop) { IsBackground = true, Name = "PanelBloodMoonAgent" }; + _worker.Start(); + } + + private static Type? ResolveType(string name) + { + try + { + var t = Type.GetType(name + ", Assembly-CSharp"); + if (t != null) return t; + t = Type.GetType(name); + if (t != null) return t; + // Fallback: scan loaded assemblies. Some 7DTD builds put types + // in unexpected assemblies after Unity merges. + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + var hit = asm.GetType(name); + if (hit != null) return hit; + } + catch { } + } + } + catch { } + return null; + } + + private void WorkerLoop() + { + // Wait briefly so GameManager.Instance.World has populated. World + // is null during early boot; writing "active=false" too early is + // fine (panel falls back to "treat unknown as not-bloodmoon" = + // restart proceeds normally) but we'd rather get real data ASAP. + while (!_stopRequested) + { + try + { + var status = Snapshot(); + WriteStatus(status); + } + catch (Exception ex) + { + TryLog("tick failed: " + ex.Message); + } + for (int i = 0; i < TickSeconds * 10 && !_stopRequested; i++) + { + Thread.Sleep(100); + } + } + } + + // ── Snapshot ─────────────────────────────────────────────────────── + private Status Snapshot() + { + var s = new Status + { + Schema = 1, + WrittenAtUnix = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Source = "math_fallback", + }; + + // Game prefs (string-keyed, safe on missing members) + s.BloodMoonFreq = PrefInt("BloodMoonFrequency", 7); + s.BloodMoonRange = PrefInt("BloodMoonRange", 0); + s.DayNightLength = PrefInt("DayNightLength", 60); + + // World time → day/hour/minute via GameUtils.WorldTimeToDays/Hours + ulong worldTime = 0; + object? world = GetWorld(); + if (world != null) + { + worldTime = (ulong)(GetFieldValue(world, "worldTime") ?? 0UL); + } + if (worldTime > 0 && _gameUtilsT != null) + { + s.InGameDay = InvokeStatic(_gameUtilsT, "WorldTimeToDays", worldTime); + s.InGameHour = InvokeStatic(_gameUtilsT, "WorldTimeToHours", worldTime); + s.InGameMinute = (int)((worldTime / 60UL) % 60UL); + } + + // Authoritative: ask the AIDirector's BloodMoonComponent. + // Path: GameManager.Instance.World.aiDirector.BloodMoonComponent.BloodMoonActive + // GameStats.GetInt(EnumGameStats.BloodMoonDay) → next horde day + bool gotAuthoritative = false; + try + { + if (world != null) + { + var aiDirector = GetFieldOrProp(world, "aiDirector"); + if (aiDirector != null) + { + var bmComponent = GetFieldOrProp(aiDirector, "BloodMoonComponent"); + if (bmComponent != null) + { + var activeObj = GetFieldOrProp(bmComponent, "BloodMoonActive"); + if (activeObj is bool active) + { + s.Active = active; + gotAuthoritative = true; + s.Source = "ai_director"; + } + } + } + } + } + catch (Exception ex) + { + TryLog("authoritative bloodmoon lookup failed: " + ex.Message); + } + + // Next horde day via GameStats.GetInt(EnumGameStats.BloodMoonDay) + try + { + if (_gameStatsT != null && _enumGameStatsT != null + && Enum.IsDefined(_enumGameStatsT, "BloodMoonDay")) + { + var key = Enum.Parse(_enumGameStatsT, "BloodMoonDay"); + var m = _gameStatsT.GetMethod("GetInt", new[] { _enumGameStatsT }); + if (m != null) + { + var v = m.Invoke(null, new[] { key }); + s.NextHordeDay = Convert.ToInt32(v ?? 0); + } + } + } + catch (Exception ex) + { + TryLog("GameStats.GetInt(BloodMoonDay) failed: " + ex.Message); + } + + // Math fallback for Active if AIDirector wasn't available. + // Window is in-game 22:00 (bloodmoon day) → 04:00 (next day). + // This is wrong when BloodMoonRange > 0 — the authoritative path + // is the correct answer in that case. We treat math as best-effort. + if (!gotAuthoritative && s.BloodMoonFreq > 0 && s.InGameDay > 0) + { + int day = s.InGameDay; + int freq = s.BloodMoonFreq; + bool todayIsBM = (day % freq == 0); + bool yesterdayWasBM = (day > 1) && ((day - 1) % freq == 0); + if (todayIsBM && s.InGameHour >= 22) s.Active = true; + else if (yesterdayWasBM && s.InGameHour < 4) s.Active = true; + // Also derive NextHordeDay from math if we didn't get it from GameStats + if (s.NextHordeDay == 0) + { + int rem = freq - (day % freq); + s.NextHordeDay = day + ((rem == 0) ? freq : rem); + } + } + + // Compute EndsAtUnix when active: time until in-game 04:00 of the + // morning after the bloodmoon day, scaled by DayNightLength. + // DayNightLength = real minutes per in-game 24h day. + if (s.Active && s.DayNightLength > 0 && worldTime > 0) + { + int gameMinutesLeft; + if (s.InGameHour >= 22) + { + // 22:00 → 28:00 (04:00 next day) = up to 6h = 360 game-minutes + gameMinutesLeft = ((28 - s.InGameHour) * 60) - s.InGameMinute; + } + else if (s.InGameHour < 4) + { + gameMinutesLeft = ((4 - s.InGameHour) * 60) - s.InGameMinute; + } + else + { + // Mid-day bloodmoon? Only possible if AIDirector says so + // outside the normal window. Conservative: assume 360 game-min. + gameMinutesLeft = 360; + } + if (gameMinutesLeft < 0) gameMinutesLeft = 0; + // 1440 game-minutes per in-game day → real seconds per game-min: + // realSecPerGameMin = (DayNightLength * 60) / 1440 = DayNightLength / 24 + double realSecondsLeft = (double)gameMinutesLeft * s.DayNightLength / 24.0; + s.EndsAtUnix = s.WrittenAtUnix + (long)Math.Ceiling(realSecondsLeft); + } + + return s; + } + + // ── GamePrefs helpers (mirrors refugebotserver's pattern) ────────── + private int PrefInt(string name, int fallback) + { + try + { + if (_enumPrefsT == null || _gamePrefsT == null) return fallback; + if (!Enum.IsDefined(_enumPrefsT, name)) return fallback; + var key = Enum.Parse(_enumPrefsT, name); + var m = _gamePrefsT.GetMethod("GetInt", new[] { _enumPrefsT }); + if (m == null) return fallback; + return Convert.ToInt32(m.Invoke(null, new[] { key }) ?? fallback); + } + catch { return fallback; } + } + + // ── Reflection helpers ───────────────────────────────────────────── + private object? GetWorld() + { + try + { + if (_gameManagerT == null) return null; + // GameManager.Instance is a static FIELD (not a property) in + // current 7DTD builds. Try field first, then property as a + // safety net for older/newer builds. + object? instance = null; + var instF = _gameManagerT.GetField("Instance", + BindingFlags.Public | BindingFlags.Static); + if (instF != null) instance = instF.GetValue(null); + if (instance == null) + { + var instP = _gameManagerT.GetProperty("Instance", + BindingFlags.Public | BindingFlags.Static); + if (instP != null) instance = instP.GetValue(null); + } + if (instance == null) return null; + return GetFieldOrProp(instance, "World"); + } + catch { return null; } + } + + private static object? GetFieldOrProp(object target, string name) + { + if (target == null) return null; + var t = target.GetType(); + var f = t.GetField(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (f != null) return f.GetValue(target); + var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + if (p != null) return p.GetValue(target); + return null; + } + + private static object? GetFieldValue(object target, string name) + { + return GetFieldOrProp(target, name); + } + + private static T InvokeStatic(Type t, string methodName, params object[] args) + { + try + { + var argTypes = new Type[args.Length]; + for (int i = 0; i < args.Length; i++) argTypes[i] = args[i].GetType(); + var m = t.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static, null, argTypes, null); + if (m == null) return default!; + var v = m.Invoke(null, args); + return v == null ? default! : (T)Convert.ChangeType(v, typeof(T)); + } + catch { return default!; } + } + + // ── Output ───────────────────────────────────────────────────────── + private void WriteStatus(Status s) + { + string json = s.ToJson(); + try + { + // Atomic write: write to .tmp, then rename. Reader sees either + // the old file or the new one, never a torn write. + File.WriteAllText(OutputPathTmp, json); + // File.Replace fails on Mono if destination doesn't exist; use + // a plain Move+Delete fallback. + if (File.Exists(OutputPath)) + { + File.Delete(OutputPath); + } + File.Move(OutputPathTmp, OutputPath); + } + catch (Exception ex) + { + TryLog("write status failed: " + ex.Message); + } + } + + // ── Logging ──────────────────────────────────────────────────────── + // 7DTD's Log.Out is in Assembly-CSharp. We do best-effort reflection + // so logs surface in the game console; fall back to stderr otherwise. + private static Type? _logT; + private static MethodInfo? _logOutM; + private static bool _logResolved; + private static void TryLog(string msg) + { + try + { + if (!_logResolved) + { + _logT = Type.GetType("Log, Assembly-CSharp") ?? Type.GetType("Log"); + if (_logT != null) + { + _logOutM = _logT.GetMethod("Out", new[] { typeof(string) }); + } + _logResolved = true; + } + var line = "[PanelBloodMoonAgent] " + msg; + if (_logOutM != null) + { + _logOutM.Invoke(null, new object[] { line }); + } + else + { + Console.Error.WriteLine(line); + } + } + catch { } + } + + // ── Status DTO ───────────────────────────────────────────────────── + // Hand-rolled JSON — keeps the mod dependency-free (no Newtonsoft + // load, no risk of clashing with 7DTD's bundled copy). + private class Status + { + public int Schema; + public long WrittenAtUnix; + public int InGameDay; + public int InGameHour; + public int InGameMinute; + public int BloodMoonFreq; + public int BloodMoonRange; + public int DayNightLength; + public int NextHordeDay; + public bool Active; + public long EndsAtUnix; + public string Source = ""; + + public string ToJson() + { + return "{" + + "\"schema\":" + Schema + + ",\"written_at_unix\":" + WrittenAtUnix + + ",\"in_game_day\":" + InGameDay + + ",\"in_game_hour\":" + InGameHour + + ",\"in_game_minute\":" + InGameMinute + + ",\"bloodmoon_freq\":" + BloodMoonFreq + + ",\"bloodmoon_range\":" + BloodMoonRange + + ",\"day_night_length\":" + DayNightLength + + ",\"next_horde_day\":" + NextHordeDay + + ",\"active\":" + (Active ? "true" : "false") + + ",\"ends_at_unix\":" + EndsAtUnix + + ",\"source\":\"" + JsonEscape(Source) + "\"" + + "}"; + } + + private static string JsonEscape(string s) + { + if (string.IsNullOrEmpty(s)) return ""; + return s.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + } + } +} diff --git a/modules/7dtd/mod-source/PanelBloodMoonAgent/ModInfo.xml b/modules/7dtd/mod-source/PanelBloodMoonAgent/ModInfo.xml new file mode 100644 index 00000000..49386a51 --- /dev/null +++ b/modules/7dtd/mod-source/PanelBloodMoonAgent/ModInfo.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/modules/7dtd/mod-source/PanelBloodMoonAgent/PanelBloodMoonAgent.csproj b/modules/7dtd/mod-source/PanelBloodMoonAgent/PanelBloodMoonAgent.csproj new file mode 100644 index 00000000..d07c84dc --- /dev/null +++ b/modules/7dtd/mod-source/PanelBloodMoonAgent/PanelBloodMoonAgent.csproj @@ -0,0 +1,35 @@ + + + net48 + 9.0 + enable + Panel.BloodMoonAgent + PanelBloodMoonAgent + Library + + C:\7dtdserver\7DaysToDieServer_Data\Managed\Assembly-CSharp.dll + $(NoWarn);CS8632 + + + + + $(LocalAssemblyCSharp) + false + + + diff --git a/modules/7dtd/module.yaml b/modules/7dtd/module.yaml new file mode 100644 index 00000000..803fb0a3 --- /dev/null +++ b/modules/7dtd/module.yaml @@ -0,0 +1,326 @@ +# 7 Days to Die — panel-native dedicated server module. +# +# Unlike the earlier vinanrra/LGSM-based revision, this module owns the +# whole stack: +# +# 1. A minimal Debian runtime image (see ./Dockerfile) is built locally +# as panel-7dtd:latest. It contains ONLY the libraries the game binary +# needs — no LGSM, no tmux, no GitHub-fetch-on-start, no surprise +# backups. Just the server process. +# 2. Game files are installed + validated into a Docker volume by the +# panel's generic SteamCMD updater (`agent/internal/updater/steamcmd.go`). +# That sidecar uses the official steamcmd/steamcmd:latest image. +# 3. The entrypoint renders our serverconfig.xml template, drops into the +# `panel` user, and execs startserver.sh under tini with line-buffered +# stdout so progress streams live to the panel console. +# +# First-run flow for operators: +# - Create instance in the panel → container spec created, volumes empty +# - Click "Update" on the instance → SteamCMD sidecar downloads ~17 GB +# - Click "Start" → entrypoint launches 7DTD directly +# +# Subsequent starts skip SteamCMD entirely — instant launch. + +id: 7dtd +name: "7 Days to Die" +version: 0.4.0 +authors: + - panel contributors + +supported_modes: + - docker + +# Create the instance but DON'T auto-start it. The operator finishes setup +# (world/RWG settings, cluster join + mod-seed, RefugeBot) and starts it +# manually. This also removes the warm-seed-then-immediate-start race that +# could OOM the agent host. The first SteamCMD update / warm-seed still runs on +# create (auto_update_on_create stays default-true); only the START is +# suppressed — enforced by a shouldAutoStart() guard in the agent's +# onUpdateComplete (the warm-seed completion hook), since that path isn't +# otherwise gated by this flag. +auto_start_on_create: false + +runtime: + docker: + # Built locally from ./Dockerfile. Rebuild with: + # docker build -t panel-7dtd:latest modules/7dtd + image: panel-7dtd:latest + # The Files tab exposes TWO roots — operators can switch between them + # with a dropdown at the top of the tab: + # + # Saves & Configs (/game-saves) — world data, serverconfig.xml, + # admin files. Default view. + # Game Files (/game) — binaries + Mods/ folder + Data/. + # Where you drop a mod tarball to install it. + # + # `browseable_root` is kept for backward compat (single-root pre- + # feature clients) but `browseable_roots` wins if both are set. + # Host networking — the container shares the host's network namespace, + # so any port the game OR a mod binds is directly on the LAN. No + # `-p host:container` publishing required; mods like RefugeBot can + # port-hunt freely (the AMP model — drop the .dll, mod sniffs free + # port, LAN_IP: just works). + # + # Tradeoff: every 7DTD on this agent shares lo. The panel's port + # allocator + the env-mapped ports below keep distinct instances + # from clobbering each other on the canonical 7DTD ports. + network_mode: host + browseable_root: /game + browseable_roots: + - name: "Game Files" + path: /game + hint: "AMP-style flat layout: Data/, Mods/, steamapps/, User/, serverconfig.xml" + - name: "Saves & Configs" + path: /game-saves + hint: "Underlying save volume — telnet password, rendered template, raw .local tree" + # Pre-declare the port env keys so the agent's resolve.go filter lets + # panel-allocated values flow through to the container env (only keys + # already present in docker.env get overridden by config_values). + # Defaults match the manifest port defaults below. + env: + GAME_PORT: "26900" + TELNET_PORT: "8081" + WEBDASH_PORT: "8080" + REFUGEBOT_PORT: "8082" + # Cluster membership marker. Empty for standalone servers. The + # controller's 7dtd cluster manager sets this (via config_values) + # when the server joins a cluster; the entrypoint reads it to + # symlink this world's Player save folder into /cluster/Player. + # Pre-declared here so resolve.go's filter lets the override flow + # through (only keys already present in docker.env get overridden). + CLUSTER_ID: "" + # Operator-selected world whose Player folder is shared into + # /cluster/Player ("/"). Set via the Cluster tab's + # "Shared player world" picker (config_values). Pre-declared here so + # resolve.go's env filter lets it flow through to the container; the + # entrypoint reads it to override the literal GameWorld/GameName symlink + # target — required for RWG worlds whose save folder is named after the + # generated seed (e.g. "North Tuxera Valley"), not the literal "RWG". + CLUSTER_PLAYER_SAVE: "" + volumes: + # Saves, configs, admin files. Never touched by SteamCMD validate. + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + # Game install — populated by SteamCMD sidecar. Differential updates + # on a named volume avoid Windows NTFS bind-mount misery. + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + # Cluster shared dir. Defaults to a private per-instance named volume + # so a standalone server has a harmless empty /cluster. The controller's + # 7dtd cluster manager OVERRIDES this (mount_overrides) to a shared host + # dir when the server joins a cluster, so every member's entrypoint can + # symlink its Player save folder into the same /cluster/Player. + - { type: volume, name: "panel-$INSTANCE_ID-cluster", container: "/cluster" } + # Bind logs to host so operators can tail / rotate. + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + # Bind the panel-rendered serverconfig.xml into the saves volume so + # the entrypoint picks it up. Without this, the entrypoint falls back + # to the stock serverconfig.xml shipped with the game (ServerName= + # "My Game Host" etc.) and the operator's config_values are ignored. + - { target: "$DATA_PATH/serverconfig.xml.rendered", container: "/game-saves/serverconfig.xml.rendered", read_only: true } + +ports: + # 7DTD always binds three contiguous UDP ports starting at ServerPort: + # the game port, then game+1 (LiteNetLib alt) and game+2 (peer-to-peer + # backup). We only inject `game` into the container env (entrypoint + # patches ServerPort in serverconfig.xml). gameA/gameB are declared so + # the allocator records them as taken — preventing a sibling instance + # from grabbing game+1 or game+2 — but their env field is intentionally + # blank (the binary computes those from ServerPort, not env). + - { name: game, proto: udp, default: 26900, required: true, env: GAME_PORT } + - { name: gameA, proto: udp, default: 26901, required: true } + - { name: gameB, proto: udp, default: 26902, required: true } + - { name: telnet, proto: tcp, default: 8081, internal: true, env: TELNET_PORT } + # Vanilla 7DTD's built-in web dashboard. The TFP entrypoint binds + # this when WebDashboardEnabled=true in serverconfig.xml. + - { name: webdash, proto: tcp, default: 8080, required: false, env: WEBDASH_PORT } + # RefugeBot mod's web UI (Admin Panel + Player Shop). Not required — + # only listens when RefugeBot is installed via Settings → Bundled + # mods. Declared here so the panel allocates a unique host port for + # it (otherwise multiple 7DTDs would all collide on 8082) and Docker + # publishes the binding so LAN clients can reach LAN_IP:. + # If a 7DTD instance doesn't have RefugeBot installed, the port is + # still bound but nothing answers — harmless. + - { name: refugebot, proto: tcp, default: 8082, required: false, env: REFUGEBOT_PORT } + +resources: + min_ram_mb: 4096 + recommended_ram_mb: 8192 + +# First-start config: the entrypoint copies /game/serverconfig.xml (the one +# shipped with the game install) into /game-saves on first run. + +rcon: + adapter: telnet + host_port: telnet + auth: password_prompt + # Our entrypoint generates a random TelnetPassword on first boot (7DTD + # only listens on 0.0.0.0 when a password is set — otherwise it's + # container-loopback only and the agent can't reach it). The plaintext + # lands here inside the container for the agent to re-read on each RCON + # dial attempt. + password_from_file: /game-saves/.panel-telnet-password + commands: + list_players: "lp" + say: "say \"{msg}\"" + kick: "kick {player} \"{reason}\"" + ban: "ban add {player} {duration} \"{reason}\"" + shutdown: "shutdown" + save: "saveworld" + +state_sources: + # "lp" (list players) returns "Total of N in the game". + - type: rcon + command: "lp" + every: 30s + parse: + kind: regex + pattern: 'Total of (?P\d+) in the game' + fields: + players_online: players + # 7DTD writes its real game log to /game/output_log_*.txt when invoked + # via startserver.sh. Our entrypoint bypasses that and streams to stdout + # (so docker logs → agent → panel console is the primary path). We still + # watch the log files as a belt-and-braces source for event parsing. + - type: log_tail + files: + - "$DATA_PATH/logs/*.log" + - "/game/output_log_*.txt" + +events: + join: + pattern: "PlayerSpawnedInWorld \\(by .*?\\): PltfmId='(?P[^']+)', CrossId='(?P[^']*)', OwnerID='(?P[^']+)', PlayerName='(?P[^']+)'" + kind: join + leave: + pattern: "Player disconnected: EntityID=\\d+, PltfmId='(?P[^']+)', OwnerID='(?P[^']+)', PlayerName='(?P[^']+)'" + kind: leave + chat: + pattern: "Chat \\(from '.*?', entity id '\\d+', to '.*?'\\): (?:\\[.*?\\])?'(?P[^']+)': (?P.*)" + kind: chat + death: + pattern: "GMSG: Player '(?P[^']+)' died" + kind: death + +# Render serverconfig.xml from templates/serverconfig.xml.tmpl on every +# (re)create, using the merged operator config_values + generated secrets +# as `.Values.*`. The output lands at $DATA_PATH/serverconfig.xml.rendered +# on the agent host and is bind-mounted into /game-saves/ where the +# entrypoint copies it over the live serverconfig.xml. +config_files: + - path: serverconfig.xml.rendered + format: xml + template: templates/serverconfig.xml.tmpl + # V3.0 "Dead Hot Summer" overhauls serverconfig.xml: ~29 gameplay + # properties are removed and folded into one encoded SandboxCode property + # (the 150 in-game Sandbox Options). A 3.0 server therefore needs a + # different template than ≤2.6. Selection is by the instance's normalized + # Steam branch (resolveCreateBranch): the 3.0 experimental branch normalizes + # to "latest_experimental", so it renders serverconfig.v3.xml.tmpl; every + # other branch (v2.6 default, public, historical) keeps the untouched 2.6 + # template. When TFP cuts a frozen `v3.0`/`v3.x` branch near stable, add the + # matching normalized key(s) here — no code change needed (RenderForBranch / + # ConfigFile.TemplateForBranch are data-driven). See + # panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D). + template_by_branch: + latest_experimental: templates/serverconfig.v3.xml.tmpl + +# 7DTD ships per-version LOCKED Steam branches on app 294420 alongside the +# moving `public` branch. Branch names + frozen buildids confirmed live via +# `steamcmd +app_info_print 294420` on 2026-06-14: +# public -> buildid 22422094 (MOVING default; becomes 3.0 on 2026-06-29) +# v2.6 -> buildid 22422094 ("Version 2.6 Stable" — frozen; == public today) +# v2.5/v2.4/v2.3/v2.0/v1.4/alpha21.2 ... -> older frozen stables +# (latest_experimental + the 3.0 experimental live under privatebranches) +# +# ORDER MATTERS. UpdateProviders[0] is the default the agent uses for +# warm-seed install-path resolution AND for the create-time auto-update when +# no provider_id is chosen (pickProviderFromConfigValues). We put the FROZEN +# `v2_6` branch FIRST so a server that doesn't explicitly pick a version stays +# locked to 2.6 and can NEVER drift onto the moving public branch when it flips +# to 3.0. Pinning a server to `-beta v2.6` freezes it at buildid 22422094 +# permanently — the corruption guard the whole V3.0 readiness work is built on. +# +# Operators pick a version at create via the "Game version" dropdown (which +# writes config_values.provider_id); the chosen id maps to the matching +# provider here. See panel/memory/7dtd-v3-version-and-sandboxcode-plan.md. +update_providers: + - id: v2_6 # DEFAULT — Version 2.6 Stable, frozen (buildid 22422094) + kind: steamcmd + app_id: "294420" + beta: "v2.6" + install_path: /game # goes directly into our /game volume + - id: latest_experimental # rolling experimental — V3.0 "Dead Hot Summer" lands here 2026-06-15 + kind: steamcmd + app_id: "294420" + beta: latest_experimental + install_path: /game + - id: current # public/stable — MOVING. == 2.6 today; becomes 3.0 on 2026-06-29. + kind: steamcmd + app_id: "294420" + install_path: /game + - id: stable # back-compat alias for `current` (pre-2026-06-14 rows persisted provider_id=stable) + kind: steamcmd + app_id: "294420" + install_path: /game + # Historical version-locked branches — pin here to run an older client. + - id: v2_5 + kind: steamcmd + app_id: "294420" + beta: "v2.5" + install_path: /game + - id: v2_4 + kind: steamcmd + app_id: "294420" + beta: "v2.4" + install_path: /game + - id: v2_3 + kind: steamcmd + app_id: "294420" + beta: "v2.3" + install_path: /game + - id: v2_0 + kind: steamcmd + app_id: "294420" + beta: "v2.0" + install_path: /game + - id: v1_4 + kind: steamcmd + app_id: "294420" + beta: "v1.4" + install_path: /game + +# Backup scope — capture the whole user folder + server config. +# +# 7DTD writes saves, admin files, and titlestorage into the per-user +# tree at $HOME/.local/share/7DaysToDie/ (our entrypoint pins HOME to +# /game-saves so this becomes /game-saves/.local/share/7DaysToDie/). +# We grab that, the rendered serverconfig.xml, the persisted telnet +# password, and any 7DTD-side config under .config — that's everything +# an operator would lose if the volume vanished. +# +# Excluded paths: +# - logs/ — large, never useful for restore +# - Steam/ — Steam runtime cache, regenerated on next start +backup: + # Saves + admin files live in /game-saves, not /game. The file browser + # defaults to /game so operators can see the binary tree, but the + # backup must target /game-saves where the world data actually lives. + root: /game-saves + include: + - .local/share/7DaysToDie + - .config + - serverconfig.xml + - .panel-telnet-password + exclude: + - logs + - Steam + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/StartGame done|"StartGame" finished|GameStartDone|by Telnet from/i' +appearance: + emoji: "🧟" + grad: "linear-gradient(135deg,#7b3b12,#d97706)" + art: "/game-art/7dtd.jpg" + steam: "251570" diff --git a/modules/7dtd/nim-freeze/REMAP_RUNBOOK.md b/modules/7dtd/nim-freeze/REMAP_RUNBOOK.md new file mode 100644 index 00000000..dbd4bc7a --- /dev/null +++ b/modules/7dtd/nim-freeze/REMAP_RUNBOOK.md @@ -0,0 +1,58 @@ +# 7DTD Cluster — Canonical-Compatible New World Provisioning (RUNBOOK) +# Validated 2026-06-13. Tooling: this dir. Canonical: ~/nimcanon/canonical_final/{block,item}mappings.nim + +## WHERE TO RUN (prerequisites — read first) +Run on **figaro** (`@`) as user **refuge**. Requires `~/nimcanon/{tool,canonical_final,remap}` present (the `.nim` parser, the canonical, the cached native table). The repo `remap_7dt.py` hardcodes `sys.path.insert(0,'/home/refuge/nimcanon/tool')` and imports `nimtool` — it will **NOT** run on the Windows dev box or kaiten. (The in-container copy invoked automatically by the entrypoint is the self-contained `remap_7dt.vendored.py` — a different file.) + +Saves live in a **docker volume**, not a host path — `$W` below is only reachable by mounting the volume. Run file ops inside a throwaway container, e.g.: +```sh +CL=/home/refuge/panel/data/7dtdcluster/cl_37e3a7f72cdc +VOL=panel--saves # e.g. panel-cluster-seed-test-saves +docker run --rm -v $VOL:/sv -v $CL:/cl debian:12-slim sh -c ' + W="/sv/.local/share/7DaysToDie/Saves//"; ' +``` +STOP/START **only via the panel** (agent-owned; sends SIGTERM→tini→7DTD clean shutdown, which flushes the native `.nim`). A `docker kill`/`docker stop` would NOT flush and can corrupt the capture. + +## CORRECTED ROOT CAUSE +7DTD V2.6(b14) auto-assigns block ids at config-load. The cluster's CURRENT runtime +(RefugeBot v1.2.39) puts the tree-decoration band at native ids 21895+, while the +season10-anchored CANONICAL puts the same trees at 24141+ (season10's world was baked +PRE-RefugeBot-rebuild). At world GENESIS the engine bakes decoration.7dt / multiblocks.7dt +BY NUMERIC BLOCK ID using native runtime ids. The entrypoint guard then stamps the +canonical .nim over the world's .nim every boot (so inventory/.ttp decode uniformly → +clean transfers). MISMATCH: native-baked deco (21895) read against stamped canonical +(24141) → null Block → DecoManager.TryAddToOccupiedMap NRE → world load hangs → never +GameStartDone. season10/creative load clean only because their deco was baked when the +runtime == canonical (pre-rebuild). + +## KEY FACTS +- .nim: u32 ver=1, u32 count, recs[u16 id, u16 flag(block=0/item=1), u8 namelen, name]. Parser: ~/nimcanon/tool/nimtool.py +- decoration.7dt / multiblocks.7dt: u8 ver=6, u32 count, count*17B recs; block id = u16@+12 & 0x7FFF; high bit + bytes +14/+16 = rotation/placement (PRESERVE). +- Engine PRESERVES loaded deco ids on save; only writes native ids at GENERATION. So a canon-remapped decoration.7dt stays canon across reboots → STABLE. +- The cluster runtime native table is IDENTICAL across all instances (insane==pvp deco band 33/33). One captured native table is authoritative cluster-wide: ~/nimcanon/remap/cluster_native_deco.nim +- Remapper: remap_7dt.py (apply|analyze|decodecheck). Self-verifies byte-safety + 100% canon resolution. + +## PROVISION A NEW CANONICAL-COMPATIBLE WORLD (do this on a FRESH/0-region world) +CL=/home/refuge/panel/data/7dtdcluster/cl_37e3a7f72cdc ; VOL=panel--saves ; W="/" +1. Gen the world (set seed, recreate/start). It bakes a NATIVE decoration.7dt. +2. CAPTURE the world's native table: + a. Stop the instance (panel Stop — NOT `docker stop`). + b. **First BACK UP the canonical** (`cp $CL/canonical/*.nim ~/canon-backup/`), THEN move the originals aside so the guard skips the stamp: + `mv $CL/canonical/*.nim $CL/canon_test_aside/` + ⚠️ **The canonical is the SHARED stamp source for EVERY cluster member.** While it is moved aside, do **NOT** boot any other member — it would stamp nothing and could append/diverge (cluster-wide inventory risk). Keep this window short; restore in step 5 and confirm by `sha256sum` before starting anything else. + c. printf '\x01\x00\x00\x00\x00\x00\x00\x00' > "$W/blockmappings.nim" ; same for itemmappings.nim + d. Start instance → loads clean on native → engine writes the world's native .nim (covers its deco blocks). Graceful-stop to flush. + e. Copy out "$W/blockmappings.nim" as . +3. REMAP: python3 remap_7dt.py apply ~/nimcanon/canonical_final/blockmappings.nim "$W/decoration.7dt" /tmp/deco.canon.7dt + (repeat for multiblocks.7dt if present). Require: "byte-diff outside id field: 0" and "ids unresolved in canon: 0". +4. Backup native (cp decoration.7dt decoration.7dt.native-bak) then apply the remapped file; chown 1000:1000. +5. mv $CL/canon_test_aside/*.nim $CL/canonical/ # restore the stamp source +6. Start instance → guard stamps canon → loads canon-remapped deco → GameStartDone, NRE=0, on canon. +7. VERIFY: decoration.7dt id band = 24xxx ; GameStartDone ; NRE=0 ; .nim stamped 38626/2719 at boot. + +## REPAIR AN ALREADY-STUCK WORLD (canon stamped, native deco, NRE) — same as above from step 2. + +## CAVEATS +- Region/*.7rg voxel ids + Player/*.ttp item ids are NOT remapped (codecs unsolved). Do the remap on a FRESH 0-region world BEFORE exploration. Existing 87 shared .ttp are fine (written in canon by season10/creative). +- Deco block ids must be < 32768 (15-bit field); the tree-deco band is far under. +- PERMANENT fix (removes the per-world remap step): pin block ids explicitly in a high-priority modlet so the runtime native table == canonical, OR freeze the RefugeBot build that season10 was baked under. Until then each new world needs this one-time remap. diff --git a/modules/7dtd/nim-freeze/rebuild.py b/modules/7dtd/nim-freeze/rebuild.py new file mode 100644 index 00000000..a73c8df9 --- /dev/null +++ b/modules/7dtd/nim-freeze/rebuild.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Rebuild the 7DTD cluster canonical .nim (re-anchor to season10's CURRENT state). + +WHAT THIS DOES + Produces a complete, season10-anchored canonical block/item id map. Every id + the anchor (season10) currently uses is preserved BYTE-FOR-BYTE; every other + name from the "universe" (a prior complete canonical) is appended at a fresh, + non-colliding id above the anchor's max. The result is a superset that decodes + season10's world + all shared inventories identically, and is COMPLETE so the + engine never appends => the table stays frozen forever. + +WHEN TO RUN + * season10's live .nim grew (players placed new shapes) and you need to + re-stamp it — re-anchor so the canonical preserves the new ids. + * You just want to regenerate the deployed canonical from the anchor. + NOTE: if MODS CHANGED (new blocks/items added), the universe won't contain the + new names. Re-harvest first — see CANONICAL_NIM_FREEZE.md "Rebuilding after a + mod change". This script only RE-ANCHORS an existing complete name set. + +USAGE + 1. STOP season10 (panel Stop) so its .nim is final. + 2. Copy season10's CURRENT blockmappings.nim + itemmappings.nim out (the ANCHOR): + docker run --rm -v panel-rg-season-10-saves:/sv:ro -v /tmp/x:/o debian:12-slim \ + sh -c 'cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/blockmappings.nim" /o/anchor_block.nim; + cp "/sv/.local/share/7DaysToDie/Saves/West Apeeni Mountains/MyGame/itemmappings.nim" /o/anchor_item.nim' + 3. python3 rebuild.py anchor_block.nim anchor_item.nim \ + UNIVERSE_block.nim UNIVERSE_item.nim OUT_DIR + (UNIVERSE = a prior complete canonical, e.g. ~/nimcanon/canonical_final/*.nim) + 4. Confirm "GATE PASS: True". Then copy OUT_DIR/*.nim into the cluster canonical + dir and chmod 644: + cp OUT_DIR/*.nim /home/refuge/panel/data/7dtdcluster//canonical/ + chmod 644 /home/refuge/panel/data/7dtdcluster//canonical/*.nim + 5. Rebuild each cluster member (POST /api/instances//rebuild) — the entrypoint + guard re-stamps the new canonical on boot. (If a member was STOPPED, /rebuild + leaves it stopped — Start it after.) + +.nim BINARY FORMAT (little-endian, no trailing bytes): + u32 version(=1), u32 count, then count records of: + u16 id, u16 flag (items=1, blocks=0), u8 namelen, UTF-8 name bytes + Records are stored sorted by ascending id. Ids are SPARSE. +""" +import struct, sys, hashlib, os + + +def read_nim(p): + b = open(p, "rb").read() + ver, cnt = struct.unpack_from("= 65536: + raise SystemExit("FATAL: ran out of u16 id space (>65535)") + recs.append((nid, flag, nm)) + used.add(nid) + nid += 1 + recs.sort(key=lambda r: r[0]) + return recs + + +def main(): + if len(sys.argv) != 6: + print(__doc__) + sys.exit(2) + ab, ai, ub, ui, outdir = sys.argv[1:6] + os.makedirs(outdir, exist_ok=True) + anchor_b, anchor_i = read_nim(ab), read_nim(ai) + univ_b, univ_i = read_nim(ub), read_nim(ui) + fb, fi = build(anchor_b, univ_b, 0), build(anchor_i, univ_i, 1) + db = write_nim(fb, os.path.join(outdir, "blockmappings.nim")) + di = write_nim(fi, os.path.join(outdir, "itemmappings.nim")) + + fbm = {i: nm for i, _, nm in fb} + fim = {i: nm for i, _, nm in fi} + alt_b = sum(1 for i, _, nm in anchor_b if fbm.get(i) != nm) + alt_i = sum(1 for i, _, nm in anchor_i if fim.get(i) != nm) + bids = [i for i, _, _ in fb] + bnm = [nm for _, _, nm in fb] + iids = [i for i, _, _ in fi] + inm = [nm for _, _, nm in fi] + compl_b = {nm for _, _, nm in univ_b} <= set(bnm) + compl_i = {nm for _, _, nm in univ_i} <= set(inm) + + print(f"blocks={len(fb)} maxid={max(bids)} | items={len(fi)} maxid={max(iids)}") + print(f"block sha256 = {hashlib.sha256(db).hexdigest()}") + print(f"item sha256 = {hashlib.sha256(di).hexdigest()}") + print(f"ANCHOR altered (MUST be 0): block={alt_b} item={alt_i}") + print(f"dup ids: block={len(bids)-len(set(bids))} item={len(iids)-len(set(iids))}" + f" | dup names: block={len(bnm)-len(set(bnm))} item={len(inm)-len(set(inm))}") + print(f"complete (universe names all present): block={compl_b} item={compl_i}") + ok = (alt_b == 0 and alt_i == 0 + and len(bids) == len(set(bids)) and len(iids) == len(set(iids)) + and len(bnm) == len(set(bnm)) and len(inm) == len(set(inm)) + and compl_b and compl_i and max(bids) < 65536 and max(iids) < 65536) + print("GATE PASS:", ok) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/modules/7dtd/nim-freeze/remap_7dt.py b/modules/7dtd/nim-freeze/remap_7dt.py new file mode 100644 index 00000000..1f2ea6a5 --- /dev/null +++ b/modules/7dtd/nim-freeze/remap_7dt.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Remap 7DTD decoration.7dt / multiblocks.7dt block ids from a world's NATIVE +table to the cluster CANONICAL table, BY NAME. Preserves all non-id bits. + +7dt format (little-endian, no trailing bytes): + [0] u8 version (=6) + [1:5] u32 count + then count * 17-byte records; block id = u16 @ record offset +12, + low 15 bits (& 0x7FFF) = id, high bit + bytes +14/+16 = rotation/placement. +Rewrite rule: new = (orig & ~0x7FFF) | (canon_id & 0x7FFF). +""" +import sys, struct +sys.path.insert(0, '/home/refuge/nimcanon/tool') +from nimtool import read_nim # -> (records[list of (id,flag,name)], version) + +REC = 17; HDR = 5; IDOFF = 12 + +def load_id2name(path): + if path.endswith('.nim'): + recs, _ = read_nim(path) + return {i: n for i, _, n in recs} + d = {} + for line in open(path, encoding='utf-8', errors='replace'): + line = line.rstrip('\n') + if not line: continue + p = line.split('\t') + if len(p) >= 2 and p[0].isdigit(): + d[int(p[0])] = p[1] + return d + +def load_name2id(path): + recs, _ = read_nim(path) + return {n: i for i, _, n in recs} + +def canon_idset(path): + recs, _ = read_nim(path) + return {i for i, _, n in recs} + +def decode_ids(b): + ver = b[0]; count = struct.unpack_from('>> FULLY REMAPPABLE: {ok}") + return ok + +def decodecheck(table, infile): + b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table)) + ver, count, recs = decode_ids(b) + from collections import Counter + dist = Counter(r[2] for r in recs) + miss = sum(dist[i] for i in dist if i not in ids) + print(f" {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%) [clean world should be ~0%]") + +def apply(native, canon, infile, outfile): + src = open(infile, 'rb').read(); b = bytearray(src) + n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon) + ver, count, recs = decode_ids(b) + changed = 0; bad = [] + for (pos, orig, bid, high) in recs: + name = n2.get(bid) + if name is None: bad.append(('noid', bid)); continue + cid = c2.get(name) + if cid is None: bad.append(('noname', name)); continue + new = high | (cid & 0x7FFF) + if new != orig: + struct.pack_into(' len(data): + raise ValueError("record %d: truncated header at offset %d (len=%d)" % (i, off, len(data))) + rid, flag = struct.unpack_from(" len(data): + raise ValueError("record %d (id=%d): name truncated at offset %d (len=%d)" % (i, rid, off, len(data))) + name = data[off:off + namelen].decode("utf-8") + off += namelen + records.append((rid, flag, name)) + if off != len(data): + raise ValueError("trailing bytes: consumed %d of %d" % (off, len(data))) + return records, version +# --- end inlined --- + + +def load_id2name(path): + if path.endswith('.nim'): + recs, _ = read_nim(path) + return {i: n for i, _, n in recs} + d = {} + for line in open(path, encoding='utf-8', errors='replace'): + line = line.rstrip('\n') + if not line: continue + p = line.split('\t') + if len(p) >= 2 and p[0].isdigit(): + d[int(p[0])] = p[1] + return d + +def load_name2id(path): + recs, _ = read_nim(path) + return {n: i for i, _, n in recs} + +def canon_idset(path): + recs, _ = read_nim(path) + return {i for i, _, n in recs} + +def decode_ids(b): + ver = b[0]; count = struct.unpack_from('>> FULLY REMAPPABLE: {ok}") + return ok + +def decodecheck(table, infile): + b = open(infile, 'rb').read(); ids = canon_idset(table) if table.endswith('.nim') else set(load_id2name(table)) + ver, count, recs = decode_ids(b) + from collections import Counter + dist = Counter(r[2] for r in recs) + miss = sum(dist[i] for i in dist if i not in ids) + print(f" {infile.split('/')[-1]} vs {table.split('/')[-1]}: {count} instances, {len(dist)} distinct ids, MISSING-in-table {miss} ({100*miss/count:.1f}%) [clean world should be ~0%]") + +def apply(native, canon, infile, outfile): + src = open(infile, 'rb').read(); b = bytearray(src) + n2 = load_id2name(native); c2 = load_name2id(canon); cset = canon_idset(canon) + ver, count, recs = decode_ids(b) + changed = 0; bad = [] + for (pos, orig, bid, high) in recs: + name = n2.get(bid) + if name is None: bad.append(('noid', bid)); continue + cid = c2.get(name) + if cid is None: bad.append(('noname', name)); continue + new = high | (cid & 0x7FFF) + if new != orig: + struct.pack_into(' safe default of v2_6 on a plain Update, but NOT gated against a +# deliberate switch). Writing provider_id=v2_6 makes them fully locked: an +# Update re-validates 2.6, and switching versions needs a typed confirm. +# +# SAFETY: metadata-only. This writes a JSON key on the DB row. It does NOT +# restart containers, does NOT run SteamCMD, does NOT touch the running game. +# Only rows that do NOT already have a provider_id are changed (idempotent). +# +# ORDERING (important): run this AFTER the new agent + controller are deployed. +# The agent's module.yaml must contain the `v2_6` provider, otherwise an Update +# that resolves provider_id=v2_6 finds no match and falls back to +# UpdateProviders[0]. (Once the new module.yaml is live, UpdateProviders[0] is +# itself v2_6, so even the fallback is safe — but deploy first to be correct.) +# +# USAGE: +# DATABASE_URL=postgres://user:pass@host:5432/panel ./repin-existing-to-v2_6.sh # dry-run (shows what WOULD change) +# DATABASE_URL=postgres://user:pass@host:5432/panel ./repin-existing-to-v2_6.sh --apply # actually write +# +# DATABASE_URL must be the same connection string the controller uses (on the +# controller host — kaiten — read it from the controller's env/systemd unit). +# Requires `psql` on PATH. +set -euo pipefail + +DB="${DATABASE_URL:-}" +if [[ -z "$DB" ]]; then + echo "ERROR: set DATABASE_URL (same string the controller uses)." >&2 + exit 1 +fi + +APPLY=0 +[[ "${1:-}" == "--apply" ]] && APPLY=1 + +echo "== Current 7DTD instances and their pinned version ==" +psql "$DB" -P pager=off -c " + SELECT instance_id, + COALESCE(config_values->>'provider_id', '(none)') AS provider_id, + status + FROM instances WHERE module_id = '7dtd' ORDER BY instance_id;" + +echo +echo "== Rows that WOULD be re-pinned to v2_6 (no provider_id yet) ==" +psql "$DB" -P pager=off -c " + SELECT instance_id + FROM instances + WHERE module_id = '7dtd' AND NOT (config_values ? 'provider_id') + ORDER BY instance_id;" + +if [[ "$APPLY" -ne 1 ]]; then + echo + echo "DRY-RUN. Re-run with --apply to write provider_id=v2_6 to the rows above." + exit 0 +fi + +echo +echo "== Applying: setting provider_id=v2_6 on un-pinned 7DTD rows ==" +psql "$DB" -P pager=off -c " + UPDATE instances + SET config_values = jsonb_set(COALESCE(config_values, '{}'::jsonb), '{provider_id}', '\"v2_6\"', true), + updated_at = NOW() + WHERE module_id = '7dtd' AND NOT (config_values ? 'provider_id');" + +echo +echo "== Result ==" +psql "$DB" -P pager=off -c " + SELECT instance_id, config_values->>'provider_id' AS provider_id + FROM instances WHERE module_id = '7dtd' ORDER BY instance_id;" + +echo +echo "Done. Existing 7DTD servers are now pinned to v2.6 (frozen)." +echo "No containers were restarted; nothing was downloaded." diff --git a/modules/7dtd/templates/serverconfig.v3.xml.tmpl b/modules/7dtd/templates/serverconfig.v3.xml.tmpl new file mode 100644 index 00000000..a2e2edeb --- /dev/null +++ b/modules/7dtd/templates/serverconfig.v3.xml.tmpl @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/7dtd/templates/serverconfig.xml.tmpl b/modules/7dtd/templates/serverconfig.xml.tmpl new file mode 100644 index 00000000..625a9f0c --- /dev/null +++ b/modules/7dtd/templates/serverconfig.xml.tmpl @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/ark-sa/README.md b/modules/ark-sa/README.md new file mode 100644 index 00000000..faf9def6 --- /dev/null +++ b/modules/ark-sa/README.md @@ -0,0 +1,62 @@ +# ARK: Survival Ascended — panel module + +A panel-compatible module for running ARK: Survival Ascended (SA) dedicated +servers on any agent with Docker available. + +## What this is + +A thin manifest wrapper around `mschnitzer/ark-survival-ascended-server` — +the most actively-maintained community image for ASA on Linux. That image +handles the Proton runtime (ASA has no native Linux server) + SteamCMD + +the Windows binary wrapper. + +## First-run flow + +1. **Create** the server in the panel (pick `ARK: Survival Ascended` from + the module picker). +2. Click **Update** on its card → our generic SteamCMD sidecar downloads + ~18 GB into the instance's `install` volume. +3. Open **Config** → under *Identity*, set the server name and admin + password. Save. +4. Click **Start**. First map load takes 3–5 minutes — watch the **Console** + tab; when you see `Server started on port 7777`, it's ready. + +## RAM + +ASA is a hog. Plan for: + +- **12 GB** minimum for `TheIsland_WP` with up to ~30 players +- **16 GB** comfortable +- **24 GB** if you're running a popular community + +## Changing maps + +Set `SERVER_MAP` in the container env (the mschnitzer image reads it at +boot). Valid values include: + +- `TheIsland_WP` +- `TheCenter_WP` +- `ScorchedEarth_WP` +- `Aberration_WP` +- `Extinction_WP` +- `Astraeos_WP` +- `ClubArk_WP` + +After changing the map, click **Stop → Start**. + +## Using your own image + +Replace the `image:` field in `module.yaml` with your Docker image. Keep +the volume mounts (`/home/steam/ASA/AppFiles` and +`/home/steam/ASA/AppFiles/ShooterGame/Saved`) so SteamCMD's output lands +where ASA expects and saves persist across updates. + +## Known quirks + +- **First start is slow** (3–5 min). This is Proton loading + ARK doing + its world init. Subsequent starts are ~90 seconds. +- **Windows file locks** — don't run Update while the server is running; + SteamCMD will conflict with ARK's open file handles. +- **EAC is enabled by default.** If you want to run mods that EAC blocks, + disable via `-NoBattlEye` in the server args (community image exposes + an env for this). diff --git a/modules/ark-sa/module.yaml b/modules/ark-sa/module.yaml new file mode 100644 index 00000000..0f01a93d --- /dev/null +++ b/modules/ark-sa/module.yaml @@ -0,0 +1,379 @@ +# ARK: Survival Ascended — dedicated server module. +# +# ASA has no native Linux server binary — it runs the Windows build under +# Proton. Rather than bundle our own 500 MB+ Wine/Proton image, this module +# uses `acekorneya/asa_server:latest`, the most actively-maintained +# community image for ASA on Linux. It packages: +# +# - Proton + Wine runtime +# - SteamCMD (handles install + auto-update on start) +# - An entrypoint that reads env vars and assembles the ASA command line +# - BattlEye / RCON / cluster flags +# +# So: the SteamCMD-style "download game via our sidecar" pattern we use for +# 7DTD doesn't apply here — the image downloads ASA itself on first boot +# via its internal SteamCMD. No external update provider needed, which is +# why this module has NO `update_providers:` section (the `Update` button +# won't do anything useful for ASA; just restart the server to pull game +# patches — the image auto-updates when UPDATE_SERVER=true is set). +# +# Operators who want to own the stack can publish their own image (Proton +# + SteamCMD + ASA) and swap `image:` below. Keep the env vars the same — +# the community entrypoint reads `SESSION_NAME`, `SERVER_ADMIN_PASSWORD`, +# `SERVER_MAP` etc. +# +# First-run flow: +# 1. Create the server in the panel → empty container + volume. +# 2. Click Start → the image's entrypoint runs SteamCMD inside the +# container and downloads ~18 GB of ASA into /home/pok/arkserver. +# First boot takes 15–30 minutes depending on bandwidth. +# 3. After the install finishes the server actually starts. Open Console +# and wait for "Server started on port 7777". +# +# Subsequent starts are quick (<= 2 min) since the files are cached on the +# named volume. + +id: ark-sa +name: "ARK: Survival Ascended" +version: 0.3.0 +authors: + - panel contributors + +supported_modes: + - docker + +# The acekorneya image handles its own SteamCMD download on first Start +# (UPDATE_SERVER=TRUE env). Opt out of the panel's auto-update-on-create +# sidecar — its install path doesn't match the image's layout and would +# just waste ~20 minutes creating files the server won't use. +auto_update_on_create: false +# But DO auto-start after create — the image's entrypoint runs its own +# SteamCMD on first boot and then launches the server, so Create → Start +# is the one-gesture flow operators expect. (True is the default across +# all modules; declared here so the interaction with auto_update_on_create +# being false is explicit to anyone reading this file.) +auto_start_on_create: true + +runtime: + docker: + image: acekorneya/asa_server:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + env: + TZ: "Etc/UTC" + # acekorneya's entrypoint expects these. They're defaults — operators + # override per-instance once the config-values layer is wired. + INSTANCE_NAME: "panel-asa" + SERVER_MAP: "TheIsland_WP" + SESSION_NAME: "Panel ARK SA Server" + SERVER_ADMIN_PASSWORD: "changeme-please" + SERVER_PASSWORD: "" + ASA_PORT: "7777" + # Steam query port. The agent rewrites this to the actually-allocated + # host port at create time (see resolve.go's port-env mapping) — for + # the first ARK on an agent this stays 27015; the second gets 27016 + # or whatever the allocator picked. The acekorneya image translates + # this into the launch arg `?QueryPort=` on its end. + QUERY_PORT: "27015" + RCON_PORT: "27020" + MAX_PLAYERS: "70" + # Pull any ASA patches from Steam on every start. Cheap when up to + # date (SteamCMD validate is a few seconds) and means the server is + # always current. + UPDATE_SERVER: "TRUE" + CHECK_FOR_UPDATE_INTERVAL: "24" + UPDATE_WINDOW_MINIMUM_TIME: "12:00 AM" + UPDATE_WINDOW_MAXIMUM_TIME: "11:59 PM" + RESTART_NOTICE_MINUTES: "30" + ENABLE_MOTD: "FALSE" + MOTD: "Welcome!" + MOTD_DURATION: "30" + MAP_NAME: "TheIsland" + NOTIFY_ADMIN_COMMANDS_IN_CHAT: "FALSE" + RANDOM_STARTUP_DELAY: "FALSE" + # BattlEye under Wine/Proton on Linux containers silently crashes the + # server ~2-3 minutes after "Server has successfully started!" (no + # log output between the success line and the process exit — classic + # UE5 silent death on BE handshake failure). Operators can enable it + # per-instance once BE for ASA/Linux gets sorted upstream; for now + # the default is off so servers actually stay up. + BATTLEEYE: "FALSE" + RCON_ENABLED: "TRUE" + DISPLAY_POK_MONITOR_MESSAGE: "FALSE" + MOD_IDS: "" + PASSIVE_MODS: "" + CUSTOM_SERVER_ARGS: "" + CLUSTER_ID: "" + # ----- EOS / session-browser stability knobs (see post_start) ----- + # ASA reports the host's auto-detected adapter IP to Epic Online + # Services when it registers the server session for the in-game + # browser. Under Docker (host-networking, multiple bridges, IPv6 + # scopes) EOS sometimes picks the wrong adapter on a re-registration + # and advertises an unreachable IP. The server stays up + RCON works + # + connect-by-IP works, but it vanishes from the browser. ASA + # exposes `-PublicIPForEpic=` to override the auto-detect. + # Leave empty to keep ASA's default behavior; set to the WAN IPv4 + # players reach (the WG-edge VPS for this fleet) to pin EOS to it. + # Reference: forums.unrealengine.com EOS-DS browser-visibility thread. + PUBLIC_IP_FOR_EPIC: "" + # Run as UID/GID 1000 so writes land on the volume as a normal user. + PUID: "1000" + PGID: "1000" + # The acekorneya image runs ARK under Wine — the live install lives at + # the Wine-virtualized Windows path below. Early module versions mounted + # /home/pok/arkserver thinking it was a symlink; it isn't — that dir + # doesn't exist at image build time, so the volume was essentially + # unused, and saves/configs lived only in the container's ephemeral + # overlay FS. Fixed by mounting DIRECTLY at the Wine path, matching + # acekorneya's own docker-compose example. Saves, configs, logs, and + # admin lists all live under this Saved/ dir. + browseable_root: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved" + volumes: + # Whole ASA install (game binaries + ShooterGame/Saved/{configs, + # world data, logs}). One volume keeps things simple and mirrors + # the upstream docker-compose pattern. + - { type: volume, name: "panel-$INSTANCE_ID-server", + container: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server" } + # Cluster transfer dir — may be overridden at instance create time + # to point at a shared `panel-ark-cluster-` volume for + # multi-server clustering (see the Cluster button in the ARK modal). + - { type: volume, name: "panel-$INSTANCE_ID-cluster", + container: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/clusters" } + +ports: + # `env:` tells the agent which container env var to set with the + # allocated host port. The acekorneya entrypoint reads ASA_PORT / + # QUERY_PORT / RCON_PORT and assembles ARK's launch args from them + # (?Port=N ?QueryPort=N ?RCONPort=N). Without env-mirror, the second + # ARK instance on the same agent gets distinct host bindings but + # ARK still advertises 7777/27015 to Steam, so the browser shows + # only one of them. + - { name: game, proto: udp, default: 7777, required: true, env: ASA_PORT } + - { name: raw, proto: udp, default: 7778, required: true } + # Steam server-browser query port. ASA registers itself with the Steam + # master at this port, and `?QueryPort=` on the ASA launch command + # tells the engine which port to bind + advertise. Without this entry + # in the manifest, the allocator can't pick distinct host ports for + # multiple instances on the same agent — they all clobber 27015 and + # only one shows up in the Steam server list. + - { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT } + - { name: rcon, proto: tcp, default: 27020, internal: true, env: RCON_PORT } + +# post_start: shell commands executed inside the container by the agent +# after a successful Start. Best-effort — failures log a warning but don't +# fail the Start. +# +# Why this hook exists: ASA writes RCONPort= under [ServerSettings] of +# GameUserSettings.ini at first boot, which is enough for the port to bind +# but the server silently refuses to respond to Source-RCON AUTH packets. +# Adding a duplicate RCONPort line under [SessionSettings] fixes it — a +# real, reproducible ASA quirk (hit with raw Python + the image's bundled +# rcon-cli, not panel-specific). Verified working on princess 2026-04-22. +# +# The hook: (a) waits up to 3 min for the ini to exist (cold first-boot +# SteamCMD install can take 15-30 min but the ini is written well before +# ASA launches), (b) bails out if the [SessionSettings] RCONPort already +# exists (idempotent — subsequent starts are no-ops), (c) injects the +# line, (d) signals the monitor_ark_server.sh watchdog to relaunch ASA so +# the server re-reads the patched config on its next boot cycle. The +# relaunch is the clean way — pkill is safe because the image's +# monitor_ark_server.sh is designed to re-spawn ArkAscendedServer.exe +# whenever it dies. +lifecycle: + post_start: + - | + set -e + LAUNCH="/usr/games/scripts/launch_ASA.sh" + INI="/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/Config/WindowsServer/GameUserSettings.ini" + WINEPREFIX_DIR="/usr/games/.wine" + CA_MARKER="$WINEPREFIX_DIR/.panel-amazon-roots-installed-v1" + changed=0 + # ----- Fix 1: launch_ASA.sh URL is missing ?QueryPort= ----- + # The acekorneya image hard-codes "?Port=${ASA_PORT}" in the launch + # URL but never appends "?QueryPort=", so every sibling ARK instance + # defaults to 27015 — only the first to bind wins, the rest have + # NO query port. Without a query port, EOS / Steam server browser + # can't advertise the server, so clients can't connect. + # Idempotent: a second run sees the marker comment and no-ops. + if [ -f "$LAUNCH" ] && ! grep -q 'QueryPort=\${QUERY_PORT' "$LAUNCH"; then + echo "[panel-hook] patching $LAUNCH to add ?QueryPort=\${QUERY_PORT}" + sed -i 's|?Port=${ASA_PORT}|?Port=${ASA_PORT}?QueryPort=${QUERY_PORT}|' "$LAUNCH" + changed=1 + fi + # ----- Fix 2: GameUserSettings.ini RCONPort under [SessionSettings] ----- + # ARK ASA's RCON server silently rejects AUTH unless RCONPort also + # appears under [SessionSettings] (verified on princess 2026-04-22). + # ASA accepts the launch-URL ?RCONPort=N for binding, but the + # adapter handshake also reads from this INI key. + for i in $(seq 1 180); do [ -f "$INI" ] && break; sleep 1; done + if [ -f "$INI" ] && ! awk '/^\[SessionSettings\]/{f=1; next} /^\[/{f=0} f && /^RCONPort=/{found=1} END{exit !found}' "$INI"; then + echo "[panel-hook] injecting RCONPort=${RCON_PORT:-27020} under [SessionSettings]" + awk -v port="${RCON_PORT:-27020}" '/^\[SessionSettings\]/{print; print "RCONPort=" port; next} {print}' "$INI" > "${INI}.new" + mv "${INI}.new" "$INI" + changed=1 + fi + # ----- Fix 3: pin EOS-advertised IP via -PublicIPForEpic ---------------- + # WHY: ASA's EOS session registration auto-detects the host's outbound + # IP each time it re-registers. Under Docker host-net with multiple + # interfaces (docker0, br-*, IPv6 link-local) EOS sometimes picks the + # wrong adapter on a re-register and advertises an unreachable IP to + # the in-game browser → "server invisible but RCON works" + "restart + # fixes it" pattern. ASA exposes -PublicIPForEpic= to override. + # Reference: Epic dev-forum confirmation (multihome → wrong-EOS-IP). + # Idempotent: marker on the wine binary line means we only inject once. + if [ -n "$PUBLIC_IP_FOR_EPIC" ] && [ -f "$LAUNCH" ] && ! grep -q -- '-PublicIPForEpic=' "$LAUNCH"; then + echo "[panel-hook] injecting -PublicIPForEpic=${PUBLIC_IP_FOR_EPIC} into $LAUNCH" + # Insert the flag right after the ArkAscendedServer.exe binary path. + # The launch line ends with a backslash continuation, so the next + # line (the URL + extra args) is unaffected. + sed -i 's|ArkAscendedServer.exe" \\$|ArkAscendedServer.exe" -PublicIPForEpic='"${PUBLIC_IP_FOR_EPIC}"' \\|' "$LAUNCH" + changed=1 + fi + # ----- Fix 4: ensure system CA bundle is up-to-date (idempotent) ------- + # WHY: EOS endpoints (*.epicgames.dev, *.on.epicgames.com) are on AWS + # CloudFront and chain to Amazon Root CA 1-4 + Starfield Services G2. + # Whether the EOS SDK under Wine uses Wine's HKLM root store or the + # statically-linked openssl/curl bundle inside the game, missing CAs + # in the system bundle have been correlated with hours-later EOS + # token-refresh failures (session ages out → server vanishes from + # in-game browser → container restart fixes by replaying cached auth). + # Reference: acekorneya/Ark-Survival-Ascended-Server#8 (open). + # The acekorneya image's Debian base lists the Amazon PEMs as enabled + # in /etc/ca-certificates.conf but may have a stale bundle if + # update-ca-certificates didn't run during image build. Marker file + # makes this once-per-prefix-lifetime — the run itself is ~1s if + # nothing needs rebuilding, so the marker is just for log noise. + if [ ! -f "$CA_MARKER" ]; then + echo "[panel-hook] running update-ca-certificates to refresh system CA bundle" + if update-ca-certificates >/dev/null 2>&1; then + echo "[panel-hook] system CA bundle refreshed" + touch "$CA_MARKER" 2>/dev/null || true + else + echo "[panel-hook] warn: update-ca-certificates failed (non-fatal)" + fi + fi + if [ "$changed" = "1" ]; then + # Relaunch ASA so it picks up the new launch script + INI. The + # image's monitor_ark_server.sh spots the missing PID within ~60s + # and respawns ArkAscendedServer.exe with the fixed config. + pkill -f ArkAscendedServer.exe 2>/dev/null || true + echo "[panel-hook] fixups applied; ASA will relaunch via image monitor" + else + echo "[panel-hook] all fixups already in place — no-op" + fi + +resources: + min_ram_mb: 12288 + recommended_ram_mb: 16384 + +# What gets archived when an operator clicks Back-up-now. Without this +# the agent tarballs the whole 18 GB ASA install (game binaries + +# Proton runtime + saves), which is wasteful — the only files that +# actually need preserving are under ShooterGame/Saved/. Cluster +# transfers live in a sibling clusters/ dir which IS bind-mounted to +# a shared volume on cluster setup, so we exclude that — restoring +# saves shouldn't clobber cluster data the other servers are using. +backup: + # Paths are RELATIVE to browseable_root (which is already + # ".../ShooterGame/Saved"), NOT relative to the container root. The + # earlier "ShooterGame/Saved/SavedArks" form pointed at a doubled path + # that doesn't exist, so every include filtered out and busybox tar + # got an empty stdin → exited non-zero → "sidecar exited 1". + include: + - "SavedArks" # world saves (the main thing) + - "Config" # GameUserSettings.ini, Game.ini + - "Logs" # tribe logs, server logs + exclude: + - "clusters" # shared volume; restoring it would clobber siblings + - "SavedArks/*/Mods" # mod cache; rebuilds on next start + - "Crashes" # crash dumps — never useful in a restore + notes: "Saves + configs + tribe logs (~250 MB typical). Game install + Proton runtime are preserved by SteamCMD on next start; no need to back them up." + +rcon: + adapter: docker_exec_rcon + host_port: rcon + auth: password + # Source-of-truth precedence (highest first, per agent's resolveRCONAddress + tracker): + # 1. config_values["rcon_password"] — explicit override (rare) + # 2. config_values["SERVER_ADMIN_PASSWORD"] via password_secret — + # the env we set at create time. THIS is what actually works + # for the acekorneya image. + # 3. password_from_ini lazy-read at dial time — only effective + # AFTER the operator changes the password via the Config tab + # and the server has restarted to pick it up. + # + # Why both: the acekorneya image takes SERVER_ADMIN_PASSWORD and + # passes it on the launch command line as `?ServerAdminPassword=...`. + # ARK SA accepts the launch-arg version BUT does NOT write it back + # into GameUserSettings.ini's [ServerSettings]ServerAdminPassword + # — that key stays empty until the operator edits it. So the env + # value is the working credential. The INI fallback is for after + # the operator has changed the password via the Config tab (which + # writes to the INI but doesn't propagate back into the env). + password_secret: SERVER_ADMIN_PASSWORD + password_from_ini: + file: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/Config/WindowsServer/GameUserSettings.ini" + section: ServerSettings + key: ServerAdminPassword + commands: + list_players: "ListPlayers" + say: "ServerChat {msg}" + broadcast: "Broadcast {msg}" + kick: "KickPlayer {player}" + ban: "BanPlayer {player}" + save: "SaveWorld" + shutdown: "DoExit" + give_item: "GiveItemToPlayer {player} {item} {qty} 1 0" + set_time: "SetTimeOfDay {time}" + +state_sources: + - type: rcon + command: "ListPlayers" + every: 30s + parse: + kind: regex + pattern: '^(?P\d+)\.\s+(?P.+?),\s+(?P\d+)' + fields: + players_online: index + - type: log_tail + files: + - "$DATA_PATH/logs/*.log" + - "/home/pok/arkserver/ShooterGame/Saved/Logs/ShooterGame.log" + +events: + join: + pattern: "(?P[^ ]+) joined" + kind: join + leave: + pattern: "(?P[^ ]+) left" + kind: leave + chat: + pattern: "Chat: \\[(?P[^\\]]+)\\] (?P.+)" + kind: chat + +# The panel manifest validator requires at least one update_provider. For +# ARK: SA the *real* update path is the image's built-in SteamCMD loop +# (env UPDATE_SERVER=TRUE). The stub below exists so the manifest loads; +# don't rely on it — the install path won't match the image's expected +# layout so running the panel's SteamCMD sidecar against it is a no-op at +# best. Click "Restart" instead; that triggers the image's update cycle. +update_providers: + - id: builtin + kind: steamcmd + app_id: "2430930" + install_path: "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server" + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Server has completed startup and is now advertising/i' +appearance: + emoji: "🦖" + grad: "linear-gradient(135deg,#3a2e1e,#d97706)" + art: "/game-art/ark-sa.jpg" + steam: "2430930" diff --git a/modules/barotrauma/Dockerfile b/modules/barotrauma/Dockerfile new file mode 100644 index 00000000..90255bc7 --- /dev/null +++ b/modules/barotrauma/Dockerfile @@ -0,0 +1,36 @@ +# panel-native Barotrauma runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libicu72 \ + libssl3 \ + zlib1g \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 27015/udp 27016/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/barotrauma/entrypoint.sh b/modules/barotrauma/entrypoint.sh new file mode 100644 index 00000000..ab69b976 --- /dev/null +++ b/modules/barotrauma/entrypoint.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# panel-native Barotrauma entrypoint. +set -euo pipefail +log() { printf '[panel-barotrauma] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +EXE="${GAME_DIR}/DedicatedServer" + +if [[ ! -f "${EXE}" ]]; then + log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Barotrauma via SteamCMD." + exit 78 +fi +[[ -x "${EXE}" ]] || chmod +x "${EXE}" + +# ---- panel port wiring ---------------------------------------------------- +# Barotrauma has no CLI port args; ports live as attributes on the root +# element. Seed a minimal file on first boot, then patch +# port=/queryport= from the panel-allocated env on EVERY boot so a panel +# port change is durable (the server rewrites the full file on exit). +GAME_PORT="${GAME_PORT:-27015}" +QUERY_PORT="${QUERY_PORT:-27016}" +SETTINGS="${SAVE_DIR}/serversettings.xml" +if [[ ! -f "${SETTINGS}" ]]; then + log "seeding serversettings.xml (name=${SERVER_NAME:-panel Barotrauma}, port=${GAME_PORT}, queryport=${QUERY_PORT})" + cat > "${SETTINGS}" < + +EOF +else + sed -i -E "s/\bport=\"[0-9]+\"/port=\"${GAME_PORT}\"/; s/\bqueryport=\"[0-9]+\"/queryport=\"${QUERY_PORT}\"/" "${SETTINGS}" + log "patched serversettings.xml ports (port=${GAME_PORT}, queryport=${QUERY_PORT})" +fi + +# The server resolves config paths relative to the executable dir (/game), +# NOT the CWD — confirmed on first boot ("Could not find file +# '/game/serversettings.xml'"). Symlink the configs onto the save volume so +# they survive reinstalls and are browseable under Saves & Configs. +for f in serversettings.xml clientpermissions.xml; do + ln -sfn "${SAVE_DIR}/${f}" "${GAME_DIR}/${f}" +done + +# Steamworks wants $HOME/.steam/sdk64/steamclient.so. The install ships a +# 64-bit copy at linux64/steamclient.so (the top-level one is 32-bit and +# fails with ELFCLASS32). Without this the server still boots (Lidgren), +# but the A2S query port never answers. +mkdir -p "${HOME}/.steam/sdk64" +ln -sfn "${GAME_DIR}/linux64/steamclient.so" "${HOME}/.steam/sdk64/steamclient.so" + +# Barotrauma reads/writes serversettings.xml, clientpermissions.xml, saves/ +# from CWD by default. Point CWD at save volume so nothing lives in /game. +cd "${SAVE_DIR}" +log "starting Barotrauma dedicated server" + +export LD_LIBRARY_PATH="${GAME_DIR}:${LD_LIBRARY_PATH:-}" +exec stdbuf -oL -eL "${EXE}" diff --git a/modules/barotrauma/module.yaml b/modules/barotrauma/module.yaml new file mode 100644 index 00000000..6ab4e3ba --- /dev/null +++ b/modules/barotrauma/module.yaml @@ -0,0 +1,93 @@ +# Barotrauma — panel-native dedicated server module. +# +# Native Linux binary from SteamCMD app 1026340. NO RCON of any kind: +# verified 2026-07-14 — the server prints "Redirected input is detected but +# is not supported by this application. Input will be ignored." on boot, so +# even the stdio adapter can never deliver commands. Admin = in-game host +# console / clientpermissions.xml only. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (Barotrauma.kvp) + +id: barotrauma +name: "Barotrauma" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-barotrauma:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "serversettings.xml + saves/ + clientpermissions.xml" + - name: "Game Files" + path: /game + hint: "Server binaries, Mods/" + env: + SERVER_NAME: "panel Barotrauma" + MAX_PLAYERS: "16" + SERVER_PASSWORD: "" + # Steam server-browser listing. ALSO gates the Steamworks A2S query + # port: with False the required query port never binds (verified). + # AMP defaults True too. + IS_PUBLIC: "True" + # Pre-declared so the agent's resolve.go filter lets panel-allocated + # ports flow through to the container env. Barotrauma has no CLI port + # args (AMP injects XML attributes too) — the entrypoint seeds/patches + # port= / queryport= in serversettings.xml from these on every boot. + GAME_PORT: "27015" + QUERY_PORT: "27016" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + - { name: game, proto: udp, default: 27015, required: true, env: GAME_PORT } + - { name: query, proto: udp, default: 27016, required: true, env: QUERY_PORT } + +resources: + min_ram_mb: 1024 + recommended_ram_mb: 2048 + +# No rcon block: the dedicated server explicitly ignores redirected stdin +# (see header comment), and there is no network RCON in the game. +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (barotrauma.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: '(?P.+?) has joined the server\.$' + kind: join + leave: + pattern: '(?P.+?) has left the server\.$' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "1026340" + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Server started|Listening to \w+ connections/i' +appearance: + emoji: "🚢" + grad: "linear-gradient(135deg,#1e3a5f,#3b6e8c)" + art: "/game-art/barotrauma.jpg" + steam: "602960" diff --git a/modules/conan-exiles/Dockerfile b/modules/conan-exiles/Dockerfile new file mode 100644 index 00000000..63240419 --- /dev/null +++ b/modules/conan-exiles/Dockerfile @@ -0,0 +1,80 @@ +# panel-native Conan Exiles runtime image. +# +# Conan Exiles Dedicated Server is a Windows-only UE4 build. We bring just +# enough Wine + Xvfb to host it headlessly. SteamCMD is NOT installed here — +# the panel's generic updater sidecar uses the official steamcmd image and +# writes into our /game volume. +# +# Expected runtime layout inside the container: +# /game — SteamCMD install +# /game/ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe — server binary +# /game/ConanSandbox/Saved/Config/WindowsServer/*.ini — config files +# /game/ConanSandbox/Mods/ — workshop paks + modlist.txt +# /game-saves — saves, configs, logs +# /entrypoint.sh — boot Xvfb, launch exe under Wine + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + WINEDEBUG=-all \ + WINEPREFIX=/home/panel/.wine \ + DISPLAY=:99 + +# Wine pulls in ~500 MB of deps. Keep the 64-bit stack (dedicated binary is +# x86_64). cabextract + winbind satisfy Wine's helpers; procps for signal +# handling and pgrep/pkill debugging. +# +# The libfontconfig1 ... libasound2 block is the UE5 dedicated-server runtime +# dep set for the native Linux Conan Enhanced binary at +# /game/ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping. Funcom's +# UE5 ELF dynamically links against these even in headless server mode (UE5 +# pulls in font/X/SDL/audio plumbing during engine init). Kept alongside Wine +# so a single image can host either edition — Wine still drives the legacy +# UE4 (conan-exiles-legacy) Windows-only path. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + xvfb \ + xauth \ + wine64 \ + wine \ + winbind \ + cabextract \ + procps \ + libfontconfig1 \ + libfreetype6 \ + libxcursor1 \ + libxinerama1 \ + libxi6 \ + libxrandr2 \ + libxxf86vm1 \ + libgl1 \ + libvulkan1 \ + libsdl2-2.0-0 \ + libpulse0 \ + libasound2 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves /home/panel + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Conan Exiles default ports (see module.yaml comment for reference). +EXPOSE 7777/tcp 7777/udp 7778/udp 27015/udp +EXPOSE 25575/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/conan-exiles/README.md b/modules/conan-exiles/README.md new file mode 100644 index 00000000..a99dea46 --- /dev/null +++ b/modules/conan-exiles/README.md @@ -0,0 +1,55 @@ +# Conan Exiles — panel-native module + +Panel-native Conan Exiles dedicated server. **Enhanced (UE5)** runs as a native Linux binary; **Legacy (UE4, `conan-exiles-legacy` beta)** still runs under Wine + Xvfb. Both editions share one debian:12-slim image — the entrypoint picks launch mode from the `EDITION` config_value. + +- **Image:** `panel-conan-exiles:latest` — built from `./Dockerfile`. +- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 443030) into the `panel--game` named volume. Edition matrix: **Enhanced = native Linux depot** (443032, ~4.27 GiB; `platform: linux` in `module.yaml`). **Legacy = Windows-only depot** under Wine (`platform: windows`, `beta: conan-exiles-legacy`). +- **Saves:** `panel--saves` at `/game-saves`; entrypoint moves `ConanSandbox/Saved/` from the game install into the save volume on first boot and symlinks back, so SteamCMD validate can't clobber world data. +- **Config:** Enhanced uses `ConanSandbox/Saved/Config/LinuxServer/`; Legacy uses `ConanSandbox/Saved/Config/WindowsServer/`. On the first native boot after Wine, the entrypoint auto-copies `ServerSettings.ini` / `Engine.ini` / `Game.ini` from `WindowsServer/` to `LinuxServer/` so operator-tuned values survive the cut-over. Edit via Files tab. +- **Mods:** drop Workshop pak files + `modlist.txt` into `ConanSandbox/Mods/` via the Files tab. +- **RCON:** real Source-RCON on `25575/tcp` (internal). Password `panel_rcon` (default) — editable in `module.yaml` `env.RCON_PASSWORD`. Tracker dials on start; our agent redials on EOF/broken-pipe automatically. + +## Ports + +| Port | Proto | Purpose | +|------|-------|---------| +| 7777 | TCP+UDP | Game traffic + client mod downloads | +| 7778 | UDP | Pinger (server-browser probe; `game + 1`) | +| 27015 | UDP | Steam query | +| 25575 | TCP | RCON (internal) | + +## RCON commands wired + +- `list` → `listplayers` +- `broadcast {msg}` → `broadcast {msg}` +- `kick {player}` → `kickplayer {player}` +- `ban {player}` → `banplayer {player}` +- `save` → `saveworld` +- `shutdown` → `shutdown` + +## Log events captured + +- **Join:** `LogNet: Join succeeded: #` +- **Leave:** `LogNet: Player disconnected: #` +- **Chat:** `ChatWindow: Character said: ` + +## Environment vars + +| Var | Default | Notes | +|-----|---------|-------| +| `SERVER_NAME` | `panel Conan Exiles` | Displayed in server browser | +| `MAX_PLAYERS` | `40` | Hard cap (AMP also defaults 40) | +| `RCON_PASSWORD` | `panel_rcon` | Source RCON auth | +| `SERVER_MAP` | `/Game/Maps/ConanSandbox/ConanSandbox` | Exiled Lands. For Isle of Siptah: `/Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah` | +| `STEAM_APP_ID` | `440900` | Steamworks client init context — required for Steam master-server listing. Exported as both `STEAM_APP_ID` and `SteamAppId` for cross-build compat. | + +## Known gotchas + +- Edition split: Enhanced (UE5) ships a real Linux depot since 2026-05-05 and runs native (no Wine). Legacy (UE4, `conan-exiles-legacy` beta) is Windows-only and stays on Wine. +- Wine under Xvfb (Legacy only) adds ~30 s to first boot (prefix init) and ~15 s to subsequent boots (X server + Wine cold start). Native Enhanced has no Wine overhead. +- Workshop mod downloads happen to `ConanSandbox/Mods/` — both the pak files AND `modlist.txt` need to be there for mods to load. +- Ready signal: `LogGameState: Match State Changed from WaitingToStart to InProgress`. Expect 1–3 min between docker-start and this line while UE4 loads the world. + +## Reference + +AMP template (CubeCoders): https://github.com/CubeCoders/AMPTemplates — `conan-exiles.kvp`, `conan-exilesports.json`, `conan-exilesupdates.json`, `conan-exilesconfig.json`, `conan-exilesmetaconfig.json`. diff --git a/modules/conan-exiles/entrypoint.sh b/modules/conan-exiles/entrypoint.sh new file mode 100644 index 00000000..e8f68e0e --- /dev/null +++ b/modules/conan-exiles/entrypoint.sh @@ -0,0 +1,399 @@ +#!/bin/bash +# panel-native Conan Exiles entrypoint. +# +# Contract: +# /game — SteamCMD-installed Windows build of Conan Exiles Dedicated +# Server (populated by the panel's SteamCMD updater sidecar). +# If missing, exit with EX_CONFIG and a helpful message. +# /game-saves — ConanSandbox/Saved/ tree (world data) + Config/ INIs. +# +# Stage 1 runs as root: chown volumes (SteamCMD wrote as root), nuke stale +# X lock, symlink /Logs + ConanSandbox/Saved into the save volume, then drop +# to the `panel` user via setpriv. Wine refuses to run as root. +# +# Stage 2 (panel user): stamp config_values env vars into [ServerSettings] +# of ServerSettings.ini, then exec the dedicated server under Wine + Xvfb. + +set -euo pipefail + +log() { printf '[panel-conan] %s\n' "$*"; } + +# ----- stage 1: root-only bootstrap ----- +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + + # UE4/UE5 under Wine writes to Z:\Logs (= /Logs). Pre-create as symlink + # into save volume so logs survive container recreate + show up in Files + # tab. + mkdir -p /game-saves/ConanLogs + chown -R panel:panel /game-saves/ConanLogs + if [[ ! -e /Logs || -L /Logs ]]; then + ln -sfn /game-saves/ConanLogs /Logs + fi + + log "dropping privileges to panel (UID 1000)" + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env \ + HOME=/home/panel \ + WINEPREFIX=/home/panel/.wine \ + WINEDEBUG=-all \ + STEAM_APP_ID="${STEAM_APP_ID:-440900}" \ + SteamAppId="${STEAM_APP_ID:-440900}" \ + SERVER_NAME="${SERVER_NAME:-panel Conan Exiles}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-}" \ + ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" \ + RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \ + MAX_PLAYERS="${MAX_PLAYERS:-40}" \ + SERVER_REGION="${SERVER_REGION:-1}" \ + SERVER_COMMUNITY="${SERVER_COMMUNITY:-0}" \ + MAX_NUDITY="${MAX_NUDITY:-0}" \ + SERVER_MAP="${SERVER_MAP:-/Game/Maps/ConanSandbox/ConanSandbox}" \ + CUSTOM_MAP="${CUSTOM_MAP:-}" \ + ENABLE_BATTLEYE="${ENABLE_BATTLEYE:-False}" \ + ENABLE_VAC="${ENABLE_VAC:-True}" \ + PVP_ENABLED="${PVP_ENABLED:-}" \ + AVATARS_ENABLED="${AVATARS_ENABLED:-}" \ + CONTAINERS_IGNORE_OWNERSHIP="${CONTAINERS_IGNORE_OWNERSHIP:-}" \ + CAN_DAMAGE_PLAYER_OWNED_STRUCTURES="${CAN_DAMAGE_PLAYER_OWNED_STRUCTURES:-}" \ + DYNAMIC_BUILDING_DAMAGE="${DYNAMIC_BUILDING_DAMAGE:-}" \ + FRIENDLY_FIRE_DAMAGE="${FRIENDLY_FIRE_DAMAGE:-}" \ + PLAYER_XP_RATE="${PLAYER_XP_RATE:-}" \ + PLAYER_XP_KILL="${PLAYER_XP_KILL:-}" \ + PLAYER_XP_HARVEST="${PLAYER_XP_HARVEST:-}" \ + PLAYER_XP_CRAFT="${PLAYER_XP_CRAFT:-}" \ + PLAYER_XP_TIME="${PLAYER_XP_TIME:-}" \ + PLAYER_DAMAGE_MULTIPLIER="${PLAYER_DAMAGE_MULTIPLIER:-}" \ + PLAYER_DAMAGE_TAKEN="${PLAYER_DAMAGE_TAKEN:-}" \ + PLAYER_HEALTH_REGEN="${PLAYER_HEALTH_REGEN:-}" \ + PLAYER_STAMINA_COST="${PLAYER_STAMINA_COST:-}" \ + PLAYER_SPRINT_SPEED="${PLAYER_SPRINT_SPEED:-}" \ + PLAYER_MOVEMENT_SPEED="${PLAYER_MOVEMENT_SPEED:-}" \ + PLAYER_ENCUMBRANCE="${PLAYER_ENCUMBRANCE:-}" \ + NPC_DAMAGE_MULTIPLIER="${NPC_DAMAGE_MULTIPLIER:-}" \ + NPC_DAMAGE_TAKEN="${NPC_DAMAGE_TAKEN:-}" \ + NPC_HEALTH_MULTIPLIER="${NPC_HEALTH_MULTIPLIER:-}" \ + NPC_RESPAWN_MULTIPLIER="${NPC_RESPAWN_MULTIPLIER:-}" \ + NPC_MAX_SPAWN_CAP="${NPC_MAX_SPAWN_CAP:-}" \ + STRUCTURE_DAMAGE_MULTIPLIER="${STRUCTURE_DAMAGE_MULTIPLIER:-}" \ + STRUCTURE_HEALTH_MULTIPLIER="${STRUCTURE_HEALTH_MULTIPLIER:-}" \ + LANDCLAIM_RADIUS_MULTIPLIER="${LANDCLAIM_RADIUS_MULTIPLIER:-}" \ + AVATAR_DOME_DURATION="${AVATAR_DOME_DURATION:-}" \ + AVATAR_DOME_DAMAGE="${AVATAR_DOME_DAMAGE:-}" \ + GAME_PORT="${GAME_PORT:-7777}" \ + PINGER_PORT="${PINGER_PORT:-7778}" \ + QUERY_PORT="${QUERY_PORT:-27015}" \ + RCON_PORT="${RCON_PORT:-25575}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves + +# --- edition-aware launch mode --- +# Enhanced (UE5) is the native-Linux depot — direct exec, no Wine. +# Legacy (UE4) is Windows-only — Wine + Xvfb. +# Drives binary path, fallback find pattern, config subdir, prefix bootstrap, +# SIGTERM trap, and the launch line further below. +case "${EDITION:-enhanced}" in + legacy) + LAUNCH_MODE=wine + CANDIDATES=("${GAME_DIR}/ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe") + CONFIG_SUBDIR=WindowsServer + ;; + *) + LAUNCH_MODE=native + CANDIDATES=("${GAME_DIR}/ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping") + CONFIG_SUBDIR=LinuxServer + ;; +esac +BIN_BASENAME=$(basename "${CANDIDATES[0]}") +log "edition: ${EDITION:-enhanced} launch mode: ${LAUNCH_MODE} config subdir: ${CONFIG_SUBDIR}" + +# --- locate the server binary --- +# Enhanced UE5 Linux depot ships the binary at +# /game/ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping (ELF). +# Legacy UE4 Windows depot ships +# /game/ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe (PE). +# Mod loading happens via ServerSettings.ini's ServerModList= key (populated +# by the panel's Mods tab) AND ConanSandbox/Mods/modlist.txt — Conan reads both. +DEDI_EXE="" +for c in "${CANDIDATES[@]}"; do + if [[ -f "$c" ]]; then + DEDI_EXE="$c" + break + fi +done +if [[ -z "${DEDI_EXE}" ]]; then + found=$(find "${GAME_DIR}" -maxdepth 6 -iname "${BIN_BASENAME}" -type f 2>/dev/null | head -1 || true) + if [[ -n "${found}" ]]; then + DEDI_EXE="${found}" + fi +fi +if [[ -z "${DEDI_EXE}" ]]; then + log "ERROR: ${BIN_BASENAME} not found under ${GAME_DIR}." + log " Run 'Update' on this server in the panel — that launches a" + log " SteamCMD sidecar which installs Conan Exiles into this volume" + log " for the selected edition (${EDITION:-enhanced})." + exit 78 # EX_CONFIG +fi +log "found server binary: ${DEDI_EXE}" + +# --- persist ConanSandbox/Saved into /game-saves --- +# UE4/UE5 writes world saves + config INIs under /ConanSandbox/Saved/. +# Move to save volume on first boot + symlink back, so SteamCMD validate +# can't wipe world data. Idempotent on subsequent boots. +mkdir -p "${SAVE_DIR}/ConanSandbox" +if [[ -d "${GAME_DIR}/ConanSandbox/Saved" && ! -L "${GAME_DIR}/ConanSandbox/Saved" ]]; then + if [[ ! -d "${SAVE_DIR}/ConanSandbox/Saved" ]]; then + mv "${GAME_DIR}/ConanSandbox/Saved" "${SAVE_DIR}/ConanSandbox/Saved" + else + rm -rf "${GAME_DIR}/ConanSandbox/Saved" + fi +fi +mkdir -p "${SAVE_DIR}/ConanSandbox/Saved/Config/${CONFIG_SUBDIR}" +ln -sfn "${SAVE_DIR}/ConanSandbox/Saved" "${GAME_DIR}/ConanSandbox/Saved" + +# First-time native boot after Wine: if WindowsServer/ has config and +# LinuxServer/ does not yet, copy ServerSettings.ini + Engine.ini + Game.ini +# across so operator-tuned values survive the Wine -> native cut-over. Wine +# path is untouched; both dirs coexist. +if [[ "${LAUNCH_MODE}" = native ]]; then + WIN_CFG_DIR="${SAVE_DIR}/ConanSandbox/Saved/Config/WindowsServer" + LINUX_CFG_DIR="${SAVE_DIR}/ConanSandbox/Saved/Config/LinuxServer" + mkdir -p "${LINUX_CFG_DIR}" + for f in ServerSettings.ini Engine.ini Game.ini; do + if [[ -f "${WIN_CFG_DIR}/${f}" && ! -f "${LINUX_CFG_DIR}/${f}" ]]; then + log "migrating ${f} from WindowsServer/ to LinuxServer/ (first native boot)" + cp -a "${WIN_CFG_DIR}/${f}" "${LINUX_CFG_DIR}/${f}" + fi + done +fi + +# OSS / Engine.ini: intentionally left alone. +# +# Empirically (see reference_conan_oss_steam_impossible.md): Conan Exiles +# Enhanced (UE5) native Linux dedi cannot be coerced into OSS=Steam without +# breaking the socket bind. AMP's Conan01 instance on figaro runs the same +# build with OSS=Null, no Engine.ini, no steam_appid.txt, and no +# ~/.steam/sdk64/steamclient.so symlink — and we now do the same. Engine +# default is Null, which binds IpNetDriver cleanly. Direct-IP join works; +# Steam server-browser listing is not achievable on this build. +# +# SteamAppId env var is already exported in stage 1 — that's enough for the +# Steamworks Game Server API to identify itself without a steam_appid.txt +# file on disk. + +# --- stamp config_values env into ServerSettings.ini --- +# Each non-empty env value is written under [ServerSettings] (replace if the +# key already exists, append if not). Empty values are SKIPPED so operator +# manual edits to ServerSettings.ini via the Files tab survive boot. The +# panel UI's Config tab → Save → recreate-with-env path is the canonical +# way to mutate INI values; direct file edits are for fields the panel +# doesn't expose. +INI="${SAVE_DIR}/ConanSandbox/Saved/Config/${CONFIG_SUBDIR}/ServerSettings.ini" +if [[ ! -f "${INI}" ]]; then + log "ServerSettings.ini missing — initializing with [ServerSettings] header" + printf '[ServerSettings]\n' > "${INI}" +fi +if ! grep -qE '^\[ServerSettings\]' "${INI}"; then + log "ServerSettings.ini lacked [ServerSettings] header — prepending" + printf '[ServerSettings]\n' > "${INI}.new" + cat "${INI}" >> "${INI}.new" + mv "${INI}.new" "${INI}" +fi + +# set_ini_kv KEY VALUE: write/replace KEY=VALUE under [ServerSettings]. +# Skip on empty value. Idempotent. Tolerates `&` and `\` in values via the +# safe_value escape (sed RHS replacement metachars). +set_ini_kv() { + local key="$1" value="$2" + if [[ -z "${value}" ]]; then return 0; fi + local safe_value + safe_value=$(printf '%s' "${value}" | sed -e 's/[\\&|]/\\&/g') + if grep -qE "^${key}=" "${INI}"; then + sed -i "s|^${key}=.*|${key}=${safe_value}|" "${INI}" + else + sed -i "/^\[ServerSettings\]/a ${key}=${safe_value}" "${INI}" + fi +} + +# Resolve the {{CUSTOM_MAP}} sentinel: if Map is set to "Custom" via the UI +# the panel passes SERVER_MAP="{{CUSTOM_MAP}}" or "{{CustomMap}}" — swap in +# the operator's CUSTOM_MAP value (or fall back to Exiled Lands if blank). +case "${SERVER_MAP}" in + "{{CUSTOM_MAP}}"|"{{CustomMap}}") + if [[ -n "${CUSTOM_MAP}" ]]; then + log "Map set to Custom; resolving to ${CUSTOM_MAP}" + SERVER_MAP="${CUSTOM_MAP}" + else + log "WARNING: Map set to Custom but CUSTOM_MAP is empty; falling back to Exiled Lands" + SERVER_MAP="/Game/Maps/ConanSandbox/ConanSandbox" + fi + ;; +esac + +log "stamping operator config_values into ${INI}" + +# Identity +set_ini_kv "ServerName" "${SERVER_NAME}" +set_ini_kv "ServerPassword" "${SERVER_PASSWORD}" +set_ini_kv "AdminPassword" "${ADMIN_PASSWORD}" +set_ini_kv "MaxPlayers" "${MAX_PLAYERS}" +set_ini_kv "serverRegion" "${SERVER_REGION}" +set_ini_kv "ServerCommunity" "${SERVER_COMMUNITY}" +set_ini_kv "MaxNudity" "${MAX_NUDITY}" + +# Anti-cheat +set_ini_kv "IsBattlEyeEnabled" "${ENABLE_BATTLEYE}" +set_ini_kv "IsVACEnabled" "${ENABLE_VAC}" + +# PvP / world +set_ini_kv "PVPEnabled" "${PVP_ENABLED}" +set_ini_kv "AvatarsEnabled" "${AVATARS_ENABLED}" +set_ini_kv "ContainersIgnoreOwnership" "${CONTAINERS_IGNORE_OWNERSHIP}" +set_ini_kv "CanDamagePlayerOwnedStructures" "${CAN_DAMAGE_PLAYER_OWNED_STRUCTURES}" +set_ini_kv "DynamicBuildingDamage" "${DYNAMIC_BUILDING_DAMAGE}" +set_ini_kv "FriendlyFireDamageMultiplier" "${FRIENDLY_FIRE_DAMAGE}" + +# XP rates +set_ini_kv "PlayerXPRateMultiplier" "${PLAYER_XP_RATE}" +set_ini_kv "PlayerXPKillMultiplier" "${PLAYER_XP_KILL}" +set_ini_kv "PlayerXPHarvestMultiplier" "${PLAYER_XP_HARVEST}" +set_ini_kv "PlayerXPCraftMultiplier" "${PLAYER_XP_CRAFT}" +set_ini_kv "PlayerXPTimeMultiplier" "${PLAYER_XP_TIME}" + +# Player tuning +set_ini_kv "PlayerDamageMultiplier" "${PLAYER_DAMAGE_MULTIPLIER}" +set_ini_kv "PlayerDamageTakenMultiplier" "${PLAYER_DAMAGE_TAKEN}" +set_ini_kv "PlayerHealthRegenSpeedScale" "${PLAYER_HEALTH_REGEN}" +set_ini_kv "PlayerStaminaCostMultiplier" "${PLAYER_STAMINA_COST}" +set_ini_kv "PlayerSprintSpeedScale" "${PLAYER_SPRINT_SPEED}" +set_ini_kv "PlayerMovementSpeedScale" "${PLAYER_MOVEMENT_SPEED}" +set_ini_kv "PlayerEncumbranceMultiplier" "${PLAYER_ENCUMBRANCE}" + +# NPC tuning +set_ini_kv "NPCDamageMultiplier" "${NPC_DAMAGE_MULTIPLIER}" +set_ini_kv "NPCDamageTakenMultiplier" "${NPC_DAMAGE_TAKEN}" +set_ini_kv "NPCHealthMultiplier" "${NPC_HEALTH_MULTIPLIER}" +set_ini_kv "NPCRespawnMultiplier" "${NPC_RESPAWN_MULTIPLIER}" +set_ini_kv "NPCMaxSpawnCapMultiplier" "${NPC_MAX_SPAWN_CAP}" + +# Structures / land claim +set_ini_kv "StructureDamageMultiplier" "${STRUCTURE_DAMAGE_MULTIPLIER}" +set_ini_kv "StructureHealthMultiplier" "${STRUCTURE_HEALTH_MULTIPLIER}" +set_ini_kv "LandClaimRadiusMultiplier" "${LANDCLAIM_RADIUS_MULTIPLIER}" +set_ini_kv "AvatarDomeDurationMultiplier" "${AVATAR_DOME_DURATION}" +set_ini_kv "AvatarDomeDamageMultiplier" "${AVATAR_DOME_DAMAGE}" + +# --- Steam OSS fix — INCOMPLETE, currently disabled --- +# Live experiment on conan-1 (2026-05-10) showed: +# 1. Adding DefaultPlatformService=Steam to Engine.ini alone is a no-op: +# Steam SDK reports "[STEAM] disabled" because the Windows binary can't +# locate steamclient64.dll (Funcom's dedi-server depot ships these at +# /game root, not under ConanSandbox/Binaries/Win64/). +# 2. Copying steamclient*.dll + tier0_s*.dll + vstdlib_s*.dll + +# steamwebrtc*.dll + a steam_appid.txt (440900) into the binary dir +# makes the SDK initialize successfully: +# LogOnline: STEAM: [AppId: 440900] Game Server API initialized 1 +# LogOnline: OSS: Created online subsystem instance for: STEAM +# 3. BUT once Steam OSS comes up under Wine, the engine hangs the +# GameThread within ~80s, never binds the game port (8010 UDP), +# and LogServerStats reports "NetDriver is null". Pre-fix logs had +# none of these symptoms (server bound + responded to A2S fine, just +# couldn't accept real-client joins past encryption handshake). +# Net effect: enabling Steam OSS via Engine.ini + DLL drops trades one +# failure mode (handshake reject) for a worse one (no NetDriver at all). +# Leaving the original Engine.ini untouched until we find the correct +# NetDriverDefinitions / SteamSockets-disable knob that lets Steam OSS +# coexist with a working IpNetDriver under Wine. +# +# See session log 2026-05-10 for full investigation. + +# --- Wine prefix bootstrap (first boot only, legacy/Wine path only). xvfb-run +# gives Wine a guaranteed-fresh DISPLAY without colliding with host Xvfb +# on :99. Native (enhanced) path skips this entirely. --- +if [[ "${LAUNCH_MODE}" = wine ]]; then + if [[ ! -d "${WINEPREFIX}" || ! -f "${WINEPREFIX}/system.reg" ]]; then + log "initializing Wine prefix at ${WINEPREFIX}" + xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true + wineserver -w || true + fi +fi + +# --- go --- +# CLI-arg fallbacks: env values default to empty strings in module.yaml so +# the entrypoint's INI-stamper leaves operator INI edits alone. But Conan's +# launch CLI requires real values for ServerName, MaxPlayers, ports, and +# RconPassword. Resolve those here so empty config_values still produce a +# launchable server. These fallbacks should match the previous module.yaml +# defaults so behaviour is unchanged for fresh installs. +SERVER_NAME_LAUNCH="${SERVER_NAME:-panel Conan Exiles}" +MAX_PLAYERS_LAUNCH="${MAX_PLAYERS:-40}" +RCON_PASSWORD_LAUNCH="${RCON_PASSWORD:-panel_rcon}" + +BIN_DIR="$(dirname "${DEDI_EXE}")" +cd "${BIN_DIR}" +log "starting Conan Exiles dedicated server" +log " game dir: ${GAME_DIR}" +log " save dir: ${SAVE_DIR}" +log " binary: ${DEDI_EXE}" +log " bin dir: ${BIN_DIR}" +log " map: ${SERVER_MAP}" +log " max: ${MAX_PLAYERS_LAUNCH}" +log " game port: ${GAME_PORT}/tcp+udp" +log " query: ${QUERY_PORT}/udp" +log " rcon: ${RCON_PORT}/tcp" + +if [[ "${LAUNCH_MODE}" = wine ]]; then + trap 'log "SIGTERM received; stopping server"; wineserver -k || true; exit 0' TERM INT +else + trap 'log "SIGTERM received; stopping server"; kill -TERM "${SERVER_PID:-0}" 2>/dev/null || true; wait "${SERVER_PID:-0}" 2>/dev/null || true; exit 0' TERM INT +fi + +# Launch flags. Conan reads most settings from ServerSettings.ini (which we +# just stamped) — CLI args set the boot-time essentials. Pinger port is +# `Port + 1` per AMP convention; setting only -Port is enough — Conan +# derives pinger from it automatically. +# +# Native (enhanced) — match Funcom's launcher convention: `ConanSandbox` +# first positional, then the map URL. No -log/-nosound/-SteamServerName +# (those are Wine-era flags). ServerName lives in [ServerSettings] +# ServerName= which our INI stamper already handles. +if [[ "${LAUNCH_MODE}" = native ]]; then + chmod +x "${DEDI_EXE}" || true + exec stdbuf -oL -eL "${DEDI_EXE}" \ + ConanSandbox \ + "${SERVER_MAP}" \ + -stdout \ + -FullStdOutLogOutput \ + "-ServerName=${SERVER_NAME_LAUNCH}" \ + "-MaxPlayers=${MAX_PLAYERS_LAUNCH}" \ + "-Port=${GAME_PORT}" \ + "-QueryPort=${QUERY_PORT}" \ + -RconEnabled=1 \ + "-RconPort=${RCON_PORT}" \ + "-RconPassword=${RCON_PASSWORD_LAUNCH}" +fi + +# Wine (legacy) — xvfb-run -a auto-allocates a free X display (host has its +# own Xvfb on :99 / :100 / :101 — see panel/memory/gotchas.md "network_mode: +# host Xvfb collision"). `ConanSandbox` first positional matches Funcom's +# launcher convention; was missing on the old Wine launch line. +exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ + wine "${DEDI_EXE}" \ + ConanSandbox \ + "${SERVER_MAP}" \ + -log \ + "-SteamServerName=${SERVER_NAME_LAUNCH}" \ + "-ServerName=${SERVER_NAME_LAUNCH}" \ + "-MaxPlayers=${MAX_PLAYERS_LAUNCH}" \ + "-Port=${GAME_PORT}" \ + "-QueryPort=${QUERY_PORT}" \ + -RconEnabled=1 \ + "-RconPort=${RCON_PORT}" \ + "-RconPassword=${RCON_PASSWORD_LAUNCH}" \ + -nosound \ + -stdout \ + -FullStdOutLogOutput diff --git a/modules/conan-exiles/module.yaml b/modules/conan-exiles/module.yaml new file mode 100644 index 00000000..b2c4c56e --- /dev/null +++ b/modules/conan-exiles/module.yaml @@ -0,0 +1,573 @@ +# Conan Exiles dedicated server module. +# +# Conan Exiles Dedicated Server (Steam app 443030) — Funcom rolled out the +# UE5 "Enhanced" remaster as the default branch on 2026-05-05. The UE4 build +# remains available indefinitely as Steam beta branch `conan-exiles-legacy` +# (description: "UE4 version of Conan Exiles Dedicated Server"). Both ride +# the same appid; only the branch differs. Default install pulls Enhanced. +# +# Hosting matrix as of 2026-05-10: +# Enhanced (UE5, public branch) — NATIVE LINUX. Funcom shipped a real Linux +# dedicated-server depot (443032, ~4.27 GiB) alongside the 2026-05-05 +# Enhanced launch. Binary at +# ConanSandbox/Binaries/Linux/ConanSandboxServer-Linux-Shipping. We run +# it directly — no Wine, no Xvfb. Config dir is Config/LinuxServer/. +# Legacy (UE4, conan-exiles-legacy beta) — STILL WINDOWS-ONLY. No Linux +# depot exists for this branch; we keep the Wine + Xvfb path for it. +# Binary at ConanSandbox/Binaries/Win64/ConanSandboxServer-Win64-Shipping.exe, +# config dir Config/WindowsServer/. Same pattern as Empyrion / V Rising / +# Sons of the Forest / Windrose. +# The container ships with both runtimes (Wine + UE5 Linux deps) so a single +# image can host either edition; entrypoint picks the launch mode from the +# EDITION config_value. +# +# Memory footprint: Enhanced UE5 runs ~2-4x heavier than UE4 Legacy on +# populated worlds. Defaults bumped accordingly (see `resources:` below). +# +# RCON is real Source-RCON on 25575/tcp — AMP's template, community +# servers, and the rcon plugin that ships with Conan all speak it. We +# surface it via our source_rcon adapter; the tracker's redial-on-EOF +# logic covers any idle disconnects (same fix landed for Palworld). +# +# Reference: https://github.com/CubeCoders/AMPTemplates (conan-exiles.kvp, +# conan-exilesports.json, conan-exilesupdates.json, conan-exilesconfig.json) +# Reference: https://low.ms/knowledgebase/conan-exiles-enhanced-update + +id: conan-exiles +name: "Conan Exiles" +version: 0.3.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + # Built locally from ./Dockerfile. Rebuild with: + # docker build -t panel-conan-exiles:latest modules/conan-exiles + image: panel-conan-exiles:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "ServerSettings.ini, Game.ini, ConanSandbox/Saved/ world data" + - name: "Game Files" + path: /game + hint: "ConanSandbox/Binaries/Linux/ for Enhanced (UE5 native), ConanSandbox/Binaries/Win64/ for Legacy (UE4 under Wine). Engine/, ConanSandbox/Mods/ (Workshop pak drops). Workshop downloads land at /game/steamapps/workshop/content/440900// via the shared volume mount." + # Pre-declare every env key that any config_value below maps to. The + # agent's resolve.go ONLY propagates config_values whose key is + # already present in `docker.env` — keys not listed here get silently + # filtered out. Defaults below are starting values for fresh instances; + # operator edits in the panel's Config tab override per-instance. + # Empty-string defaults mean "leave the INI alone" (entrypoint stamps + # only non-empty values, so unset fields fall back to game defaults + # or operator's manual ServerSettings.ini edits). + env: + # Steamworks client runtime init context — the dedicated-server app + # (443030) reports itself to Steam as Conan Exiles (440900). AMP + # sets this too; the server refuses to join the Steam master-server + # listing without it. + STEAM_APP_ID: "440900" + + # EDITION — see config_values block below. Default `enhanced` (UE5). + # Pre-declared here so resolve.go propagates the operator's choice + # into the container env (the agent's docker.env filter only forwards + # config_values whose key already appears in this map). The entrypoint + # logs it so operators can grep boot output to confirm what's running. + EDITION: "enhanced" + + # --- Identity --- + # All identity fields default to EMPTY. The entrypoint only stamps + # non-empty values into [ServerSettings] of ServerSettings.ini, so a + # recreate with empty env preserves whatever the operator set + # previously via the Files tab. CLI-launch args have hardcoded + # fallbacks in entrypoint.sh (e.g. ServerName falls back to + # "panel Conan Exiles" for the Steam server-name flag only when + # SERVER_NAME is genuinely unset). + SERVER_NAME: "" + SERVER_PASSWORD: "" + ADMIN_PASSWORD: "" + RCON_PASSWORD: "" + MAX_PLAYERS: "" + SERVER_REGION: "" + MAX_NUDITY: "" + SERVER_COMMUNITY: "" + + # --- Map --- + # /Game/Maps/ConanSandbox/ConanSandbox = Exiled Lands (default) + # /Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah = Isle of Siptah + # SERVER_MAP keeps a default because Conan REQUIRES a map URL on the + # CLI; empty would mean "use last used", which is fine but surprising + # for new instances. + SERVER_MAP: "/Game/Maps/ConanSandbox/ConanSandbox" + CUSTOM_MAP: "" + + # --- Anti-cheat --- + # Empty means "leave INI default" (entrypoint skips stamping). Conan's + # game default is BattlEye=False, VAC=True — same as ours used to be. + ENABLE_BATTLEYE: "" + ENABLE_VAC: "" + + # --- Persistence / online --- + PVP_ENABLED: "" + AVATARS_ENABLED: "" + CONTAINERS_IGNORE_OWNERSHIP: "" + CAN_DAMAGE_PLAYER_OWNED_STRUCTURES: "" + DYNAMIC_BUILDING_DAMAGE: "" + + # --- Gameplay multipliers (empty = use game default) --- + PLAYER_XP_RATE: "" + PLAYER_XP_KILL: "" + PLAYER_XP_HARVEST: "" + PLAYER_XP_CRAFT: "" + PLAYER_XP_TIME: "" + PLAYER_DAMAGE_MULTIPLIER: "" + PLAYER_DAMAGE_TAKEN: "" + PLAYER_HEALTH_REGEN: "" + PLAYER_STAMINA_COST: "" + PLAYER_SPRINT_SPEED: "" + PLAYER_MOVEMENT_SPEED: "" + PLAYER_ENCUMBRANCE: "" + NPC_DAMAGE_MULTIPLIER: "" + NPC_DAMAGE_TAKEN: "" + NPC_HEALTH_MULTIPLIER: "" + NPC_RESPAWN_MULTIPLIER: "" + NPC_MAX_SPAWN_CAP: "" + STRUCTURE_DAMAGE_MULTIPLIER: "" + STRUCTURE_HEALTH_MULTIPLIER: "" + LANDCLAIM_RADIUS_MULTIPLIER: "" + AVATAR_DOME_DURATION: "" + AVATAR_DOME_DAMAGE: "" + FRIENDLY_FIRE_DAMAGE: "" + + # --- Pre-declared so allocator-derived ports survive resolve.go's filter. --- + GAME_PORT: "7777" + PINGER_PORT: "7778" + QUERY_PORT: "27015" + RCON_PORT: "25575" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + # Shared Steam-Workshop content volume — exact same pattern as DayZ + # (modules/dayz/module.yaml mounts panel-dayz-workshop at + # /game/steamapps/workshop). Volume's root contains a flat + # `content///` tree (no extra prefix); mounting at + # /game/steamapps/workshop makes it appear as + # /game/steamapps/workshop/content/440900//.pak + # — under the /game declared root, so FsList/FsSymlink work without + # extra browseable_roots. The SteamCMD sidecar uses a different deep + # path on its end (/conan-workshop/steamapps/workshop) so its + # +force_install_dir matches; see conanmods.go's + # runSteamCMDConanWorkshopDownloadOnceStreaming for the args. Both + # the sidecar and the game container are talking to the same volume, + # they just label the mount point differently. + - { type: volume, name: "panel-conan-workshop", container: "/game/steamapps/workshop" } + +# Conan Exiles ports (matches AMP template): +# 7777/both (tcp+udp) — main game traffic + client mod downloads +# 7778/udp — pinger (game+1 offset; server browser probe) +# 27015/udp — Steam query +# 25575/tcp — RCON (internal) +ports: + # Conan binds the game socket on UDP `-Port=N` and the pinger + # (server-browser probe) on UDP N+1 — internally derived, no override + # flag exists. The panel allocator scans the agent window per-port; with + # no prior holes the first three rows land on consecutive numbers + # N, N+1, N+2 — so PINGER_PORT == GAME_PORT+1 and Conan's auto-derived + # pinger lands on the row we forward. + # + # `game` MUST be UDP — Conan's game socket is UDP-only. (AMP's template + # historically listed it as tcp+udp because of optional in-game mod + # downloads, but the dedicated-server depot never opens a TCP listener + # on -Port; the opnfwd orchestrator forwards by declared proto, so a + # tcp row here ends up forwarding a dead TCP port and leaves the real + # UDP game socket unforwarded — players can't join, server doesn't + # register on the master server.) + - { name: game, proto: udp, default: 7777, required: true, env: GAME_PORT } + - { name: pinger, proto: udp, default: 7778, required: true, env: PINGER_PORT } + - { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT } + - { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT } + +resources: + # Bumped for Enhanced UE5 (was 6144 / 12288 for UE4). Funcom + community + # benchmarks have populated Enhanced worlds at 2-4x the UE4 footprint — + # 8 GB minimum keeps a small server alive, 24 GB recommended for a + # populated long-running build-heavy server. + min_ram_mb: 8192 + recommended_ram_mb: 24576 + +# Per-instance generated RCON secret — overrides env RCON_PASSWORD at +# create so every instance gets a unique password (see pkg/module +# GenerateSecrets). Operators can still override via the RCON Password +# config field. +secrets: + - name: RCON_PASSWORD + description: "Conan Exiles Source-RCON password — generated per instance" + generated: true + +rcon: + adapter: source_rcon + host_port: rcon + auth: source_rcon_login + password_secret: RCON_PASSWORD + # Legacy fallback only (pre-generated-secret instances). + password_literal: "panel_rcon" + commands: + # Conan's RCON commands. `listplayers` is the roster query; `broadcast` + # sends an in-game message; `kick`/`ban` take a player # identifier. + list: "listplayers" + broadcast: "broadcast {msg}" + kick: "kickplayer {player}" + ban: "banplayer {player}" + save: "saveworld" + shutdown: "shutdown" + +state_sources: + - type: rcon + command: "listplayers" + every: 60s + +# Log-line event patterns. AMP's template is the source of these regexes — +# tested against real Conan Exiles boot output. Go regex = RE2; convert +# `(?...)` → `(?P...)`. +events: + join: + pattern: 'LogNet: Join succeeded: (?P.+?#\d+)' + kind: join + leave: + pattern: 'LogNet: Player disconnected: (?P.+?#\d+)' + kind: leave + chat: + pattern: 'ChatWindow: Character (?P.+?) said: (?P.+)' + kind: chat + +# Two update branches on the same appid. Operator picks via the Update +# button in the panel UI — `enhanced` is the default (UE5, public branch); +# `legacy` pins to UE4 via the `conan-exiles-legacy` Steam beta branch +# (Funcom's announced indefinite UE4 archive). +update_providers: + # `id` is also the user-facing label in the UI (no separate label field on + # this struct yet; see pkg/module/manifest.go UpdateProvider). Choose + # short ids that read well in the Update dropdown. + - id: enhanced + kind: steamcmd + app_id: "443030" + platform: linux # Enhanced (UE5) has a real Linux depot (443032, ~4.27 GiB) since 2026-05-05 — run native, no Wine. + install_path: /game + # Conan's `app_update 443030 validate` fails on empty volumes with + # "Missing configuration" (4/4 retries). Plain `app_update 443030` + # succeeds. Skip validate so first install just works; operators can + # trigger a manual Update later to re-validate existing files. + skip_validate: true + - id: legacy + kind: steamcmd + app_id: "443030" + beta: conan-exiles-legacy # confirmed branch name via app_info_print 2026-05-07 + platform: windows # Legacy (UE4) has NO Linux depot — this is the only remaining Wine path in the module. + install_path: /game + skip_validate: true + +# UI-driven configuration. Each `key` here maps 1:1 to an env key declared +# above; the entrypoint reads non-empty env values and stamps them into +# [ServerSettings] in ServerSettings.ini at every boot (idempotent — same +# value re-stamped is a no-op). Empty strings mean "leave the INI alone"; +# operator edits via the Files tab to ServerSettings.ini are preserved for +# any field whose corresponding config_value is left blank. +# +# The most-tuned fields are at the top (no advanced flag); niche / tuning +# multipliers are tagged `advanced: true` so the basic Config tab stays +# scannable on a phone. AMP's official template surfaces ~11 fields; we +# expose a superset (~30) covering identity, anti-cheat, PvP, and the +# common gameplay knobs — beyond that, edit ServerSettings.ini directly. +config_values: + # ===== Edition (UE4 vs UE5) ===== + # Pick at create time. Drives: + # 1. Which Steam branch downloads on first install (auto-update picks + # the matching update_provider via dispatch.go's edition-aware + # auto-update logic — `enhanced` → public branch, `legacy` → + # `conan-exiles-legacy` Steam beta). + # 2. Which Workshop mods the panel's Mods tab surfaces — Steam tags + # every published item as either "Enhanced" or "Legacy"; the panel's + # workshop search adds `requiredtags[]=Enhanced|Legacy` per the + # instance's edition. UE5 servers don't see UE4-only mods (they + # load silently as compatible-format files but don't actually run + # because the .pak ABI changed). + # Operators changing edition after creation: change this value, then + # click Update (which now uses the matching branch). Existing world + # data is preserved. + - key: EDITION + label: "Edition" + description: "Conan Exiles version. Enhanced (UE5, 2026-05+) is Funcom's current default. Legacy (UE4) is the indefinitely-archived prior build — pick this only if your players need UE4 mods that haven't been ported yet. Switching after creation requires clicking Update; saves are preserved across editions." + default: "enhanced" + options: + - { value: "enhanced", label: "Enhanced (UE5, default)" } + - { value: "legacy", label: "Legacy (UE4 — Steam beta branch)" } + + # ===== Identity ===== + - key: SERVER_NAME + label: "Server Name" + description: "Name shown in the server browser and steam community list." + default: "panel Conan Exiles" + - key: SERVER_PASSWORD + label: "Server Password" + description: "Password required to join the server. Leave blank for an open server." + default: "" + - key: ADMIN_PASSWORD + label: "Admin Password" + description: "Lets the operator open the in-game admin panel via 'Make Me Admin' on a logged-in client. Stamped into [ServerSettings] AdminPassword= on every restart. Leave blank to leave the existing INI value alone." + default: "" + - key: RCON_PASSWORD + label: "RCON Password" + description: "Source-RCON password used by the panel for player listing, broadcast, kick, ban, and saveworld. Avoid spaces or quotes. Leave blank to use the per-instance generated secret (recommended)." + default: "" + - key: MAX_PLAYERS + label: "Max Players" + description: "Player slot limit. Conan's official cap is 70; communities run 40-60 for most build densities." + default: "40" + - key: SERVER_REGION + label: "Server Region" + description: "Region tag used for server-browser filtering. Picks up the client's regional preferences." + default: "1" + options: + - { value: "0", label: "Europe" } + - { value: "1", label: "North America" } + - { value: "2", label: "Asia" } + - { value: "3", label: "Australia" } + - { value: "4", label: "South America" } + - { value: "5", label: "Japan" } + + # ===== Map ===== + - key: SERVER_MAP + label: "Map" + description: "Map loaded at server start. For a custom map, choose 'Custom' and fill in the path below." + default: "/Game/Maps/ConanSandbox/ConanSandbox" + options: + - { value: "/Game/Maps/ConanSandbox/ConanSandbox", label: "Exiled Lands (default)" } + - { value: "/Game/DLC_EXT/DLC_Siptah/Maps/DLC_Isle_of_Siptah", label: "Isle of Siptah" } + - { value: "{{CUSTOM_MAP}}", label: "Custom (use Custom Map below)" } + - key: CUSTOM_MAP + label: "Custom Map Path" + description: "Path to a custom map's UE asset. Only used when Map is set to 'Custom'. Example: /Game/Mods/Savage_Wilds/Savage_Wilds" + default: "" + + # ===== Anti-cheat ===== + - key: ENABLE_BATTLEYE + label: "Enable BattlEye" + description: "BattlEye anti-cheat. Off by default — works under Wine but adds a startup handshake that occasionally races on cold boots." + default: "False" + options: + - { value: "True", label: "On" } + - { value: "False", label: "Off" } + - key: ENABLE_VAC + label: "Enable Valve Anti-Cheat" + description: "Steam VAC. Generally safe to leave on for public servers." + default: "True" + options: + - { value: "True", label: "On" } + - { value: "False", label: "Off" } + + # ===== PvP / world ===== + - key: PVP_ENABLED + label: "PvP Enabled" + description: "Master toggle for player-vs-player damage. Time-window restrictions are configured in ServerSettings.ini directly (PVPRestrict* keys); this just flips the global switch." + default: "" + options: + - { value: "", label: "(leave INI default)" } + - { value: "True", label: "On" } + - { value: "False", label: "Off" } + - key: AVATARS_ENABLED + label: "Avatars Enabled" + description: "Allow players to summon god avatars. Off in many private cluster setups to prevent griefing." + default: "" + options: + - { value: "", label: "(leave INI default)" } + - { value: "True", label: "On" } + - { value: "False", label: "Off" } + - key: CONTAINERS_IGNORE_OWNERSHIP + label: "Containers Ignore Ownership" + description: "If true, players can open any container regardless of owner. Useful for co-op-friendly servers; disable for strict PvP." + default: "" + options: + - { value: "", label: "(leave INI default)" } + - { value: "True", label: "On" } + - { value: "False", label: "Off" } + - key: CAN_DAMAGE_PLAYER_OWNED_STRUCTURES + label: "Allow Building Damage" + description: "Master toggle for player-owned structure damage. Combine with the dynamic decay setting for a softer PvP feel." + default: "" + options: + - { value: "", label: "(leave INI default)" } + - { value: "True", label: "On" } + - { value: "False", label: "Off" } + - key: DYNAMIC_BUILDING_DAMAGE + label: "Dynamic Building Damage" + description: "Decays vacant structures over time. Helps clean up abandoned bases on long-running servers." + default: "" + options: + - { value: "", label: "(leave INI default)" } + - { value: "True", label: "On" } + - { value: "False", label: "Off" } + - key: FRIENDLY_FIRE_DAMAGE + label: "Friendly Fire Damage Multiplier" + description: "0.0–1.0+ multiplier on damage between clan members. Default 0.25." + default: "" + + # ===== Niche knobs (advanced) ===== + - key: MAX_NUDITY + label: "Max Nudity" + description: "0=none, 1=partial, 2=full. Affects the default character render plus what skins players can wear." + default: "0" + advanced: true + options: + - { value: "0", label: "None" } + - { value: "1", label: "Partial" } + - { value: "2", label: "Full" } + - key: SERVER_COMMUNITY + label: "Server Community" + description: "0=Purist, 1=Relaxed, 2=Hard, 3=Hardcore, 4=Roleplaying, 5=Experimental. Just a tag in the server browser." + default: "0" + advanced: true + options: + - { value: "0", label: "Purist" } + - { value: "1", label: "Relaxed" } + - { value: "2", label: "Hard" } + - { value: "3", label: "Hardcore" } + - { value: "4", label: "Roleplaying" } + - { value: "5", label: "Experimental" } + + # ===== XP rates (advanced) ===== + - key: PLAYER_XP_RATE + label: "XP — Overall Rate Multiplier" + description: "Overall multiplier applied on top of every XP source. Default 1.0." + default: "" + advanced: true + - key: PLAYER_XP_KILL + label: "XP — Kill Multiplier" + description: "XP multiplier per kill. Default 1.0." + default: "" + advanced: true + - key: PLAYER_XP_HARVEST + label: "XP — Harvest Multiplier" + description: "XP multiplier per harvest tick. Default 1.0." + default: "" + advanced: true + - key: PLAYER_XP_CRAFT + label: "XP — Craft Multiplier" + description: "XP multiplier per crafted item. Default 1.0." + default: "" + advanced: true + - key: PLAYER_XP_TIME + label: "XP — Time Multiplier" + description: "Idle / time-based XP. Default 1.0." + default: "" + advanced: true + + # ===== Player tuning (advanced) ===== + - key: PLAYER_DAMAGE_MULTIPLIER + label: "Player Damage Output" + description: "Damage dealt by players. Default 1.0." + default: "" + advanced: true + - key: PLAYER_DAMAGE_TAKEN + label: "Player Damage Taken" + description: "Damage received by players. Lower = tougher players." + default: "" + advanced: true + - key: PLAYER_HEALTH_REGEN + label: "Player Health Regen Speed" + description: "Default 1.0." + default: "" + advanced: true + - key: PLAYER_STAMINA_COST + label: "Player Stamina Cost" + description: "Multiplier on stamina drained by sprint/jump/swim. Lower = forgiving." + default: "" + advanced: true + - key: PLAYER_SPRINT_SPEED + label: "Player Sprint Speed Scale" + description: "Default 1.0. Bumping past 1.5 makes ranged combat awkward." + default: "" + advanced: true + - key: PLAYER_MOVEMENT_SPEED + label: "Player Movement Speed Scale" + description: "Default 1.0." + default: "" + advanced: true + - key: PLAYER_ENCUMBRANCE + label: "Player Encumbrance Multiplier" + description: "Carry weight cap multiplier. Default 1.0." + default: "" + advanced: true + + # ===== NPC tuning (advanced) ===== + - key: NPC_DAMAGE_MULTIPLIER + label: "NPC Damage Output" + description: "Damage dealt by hostile NPCs. Default 1.0." + default: "" + advanced: true + - key: NPC_DAMAGE_TAKEN + label: "NPC Damage Taken" + description: "Damage NPCs receive. Lower = bullet-sponge enemies." + default: "" + advanced: true + - key: NPC_HEALTH_MULTIPLIER + label: "NPC Health Multiplier" + description: "Base HP scaling. Default 1.0." + default: "" + advanced: true + - key: NPC_RESPAWN_MULTIPLIER + label: "NPC Respawn Multiplier" + description: "How fast world spawns refill. >1.0 = faster respawn." + default: "" + advanced: true + - key: NPC_MAX_SPAWN_CAP + label: "NPC Max Spawn Cap Multiplier" + description: "Cap on simultaneous active world NPCs. Lower = less server CPU under load." + default: "" + advanced: true + + # ===== Structures / land claim (advanced) ===== + - key: STRUCTURE_DAMAGE_MULTIPLIER + label: "Structure Damage Multiplier" + description: "Damage dealt to player-built structures. Default 1.0." + default: "" + advanced: true + - key: STRUCTURE_HEALTH_MULTIPLIER + label: "Structure Health Multiplier" + description: "Base HP of placed pieces. >1.0 = sturdier bases." + default: "" + advanced: true + - key: LANDCLAIM_RADIUS_MULTIPLIER + label: "Landclaim Radius Multiplier" + description: "How big the no-build bubble around each placeable is. <1.0 lets settlements pack tighter." + default: "" + advanced: true + - key: AVATAR_DOME_DURATION + label: "Avatar Dome Duration Multiplier" + description: "How long avatar shields hold. Default 1.0." + default: "" + advanced: true + - key: AVATAR_DOME_DAMAGE + label: "Avatar Dome Damage Multiplier" + description: "Damage vs. avatar shields. Default 1.0." + default: "" + advanced: true + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/LogGameState: Match State Changed from WaitingToStart to InProgress/i' +appearance: + emoji: "⚔️" + grad: "linear-gradient(135deg,#5a1a00,#c2410c)" + art: "/game-art/conan-exiles.jpg" + steam: "440900" diff --git a/modules/core-keeper/Dockerfile b/modules/core-keeper/Dockerfile new file mode 100644 index 00000000..671ca50d --- /dev/null +++ b/modules/core-keeper/Dockerfile @@ -0,0 +1,40 @@ +# panel-native Core Keeper runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + DISPLAY=:99 + +# Core Keeper is Unity headless-on-Linux; Unity still needs an X display +# even with -nographics (some subsystem probes fail otherwise), so Xvfb. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + xvfb \ + xauth \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libxi6 \ + libxrandr2 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 27015/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/core-keeper/entrypoint.sh b/modules/core-keeper/entrypoint.sh new file mode 100644 index 00000000..4fc8dc16 --- /dev/null +++ b/modules/core-keeper/entrypoint.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# panel-native Core Keeper entrypoint. +set -euo pipefail +log() { printf '[panel-core-keeper] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 2>/dev/null || true + mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix && chown root:root /tmp/.X11-unix 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves DISPLAY=:99 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + GAME_ID="${GAME_ID:-}" \ + WORLD_NAME="${WORLD_NAME:-panel}" \ + WORLD_MODE="${WORLD_MODE:-0}" \ + MAX_PLAYERS="${MAX_PLAYERS:-10}" \ + GAME_PORT="${GAME_PORT:-27015}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +EXE="${GAME_DIR}/CoreKeeperServer" + +if [[ ! -x "${EXE}" ]]; then + log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Core Keeper via SteamCMD." + exit 78 +fi + +log "starting Xvfb on :99" +Xvfb :99 -screen 0 1024x768x24 -nolisten tcp & +XVFB_PID=$! +sleep 1 + +# Core Keeper writes saves under $HOME/.config/unity3d/Pugstorm/Core Keeper. +# HOME is /game-saves so these persist. +mkdir -p "${SAVE_DIR}/.config/unity3d/Pugstorm/Core Keeper" + +trap 'log "SIGTERM"; kill "${XVFB_PID}" 2>/dev/null || true; exit 0' TERM INT + +cd "${GAME_DIR}" +log "starting Core Keeper dedicated server" +log " world: ${WORLD_NAME} (mode ${WORLD_MODE}, max ${MAX_PLAYERS}), port ${GAME_PORT}" + +# Steamworks needs the bundled linux64/steamclient.so and the GAME app id +# (1621690, not the dedicated-server app 1963720) — per AMP's +# core-keeper.kvp EnvironmentVariables. +export LD_LIBRARY_PATH="${GAME_DIR}/linux64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +export SteamAppId=1621690 + +# -port makes the server open a direct UDP socket on the panel-allocated +# port (instead of Steam-relay-only). -datapath keeps worlds in the +# persistent save volume. +ARGS=(-batchmode -logfile - + -port "${GAME_PORT}" + -datapath "${SAVE_DIR}/DedicatedServer" + -worldname "${WORLD_NAME}" -worldmode "${WORLD_MODE}" + -maxplayers "${MAX_PLAYERS}") +if [[ -n "${GAME_ID}" ]]; then + ARGS+=(-gameid "${GAME_ID}") +fi +mkdir -p "${SAVE_DIR}/DedicatedServer" + +exec stdbuf -oL -eL "${EXE}" "${ARGS[@]}" diff --git a/modules/core-keeper/module.yaml b/modules/core-keeper/module.yaml new file mode 100644 index 00000000..2288b02c --- /dev/null +++ b/modules/core-keeper/module.yaml @@ -0,0 +1,84 @@ +# Core Keeper — panel-native dedicated server module. +# +# Native Linux (Unity headless) from SteamCMD app 1963720. Unity server +# still touches X so we need Xvfb (same pattern as Empyrion). No RCON — +# admin is via in-game console when you hold the founders key. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (CoreKeeperLinux.kvp) + +id: core-keeper +name: "Core Keeper" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-core-keeper:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Worlds + server.json" + - name: "Game Files" + path: /game + hint: "Server binaries" + env: + GAME_ID: "" # leave empty to generate a new world; set to rejoin + WORLD_NAME: "panel" + WORLD_MODE: "0" # 0 = normal, 1 = hardmode, 2 = creative + MAX_PLAYERS: "10" + # Pre-declared so the agent's env filter lets the panel-allocated + # port flow through to the container (same trap as soulmask). + # The entrypoint passes it as -port; default matches the port default. + GAME_PORT: "27015" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + - { name: game, proto: udp, default: 27015, required: true, env: GAME_PORT } + +resources: + min_ram_mb: 2048 + recommended_ram_mb: 4096 +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (core-keeper.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: '\[userid:(?P[^\]]+)\] player (?P.+?) connected' + kind: join + leave: + pattern: 'Disconnected from userid:(?P\S+?) with reason' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "1963720" + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +# Verified on a live first boot 2026-07-14: the last line before the server +# accepts joins is "Started session with info: ;;;". +ready_pattern: '/Started session with info/i' +appearance: + emoji: "⛏️" + grad: "linear-gradient(135deg,#2e1a47,#6b3fa0)" + art: "/game-art/core-keeper.jpg" + steam: "1621690" diff --git a/modules/dayz/Dockerfile b/modules/dayz/Dockerfile new file mode 100644 index 00000000..b098d750 --- /dev/null +++ b/modules/dayz/Dockerfile @@ -0,0 +1,51 @@ +# panel-native DayZ runtime image. +# +# DayZ ships a native Linux binary (SteamCMD app 223350). No Wine. The +# binary is a static-ish x86_64 ELF that pulls in the usual i386 pile +# (steamclient.so is 32-bit) + a couple of Bohemia's own shared libs +# that live under /. +# +# Expected runtime layout inside the container: +# /game — SteamCMD-installed DayZ Server +# /game/DayZServer — server binary (Bohemia's launcher wrapper) +# /game/serverDZ.cfg — primary config file (symlinked to /game-saves) +# /game/battleye/ — BattlEye config + beserver.cfg (RCON port + pw) +# /game/profiles — logs, bans, RPT crash dumps (symlinked to /game-saves) +# /game-saves/ — operator data volume (configs, profiles, world DB) + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libcurl4 \ + libxml2 \ + procps \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 2302/udp 2303/udp 2304/udp 2305/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/dayz/README.md b/modules/dayz/README.md new file mode 100644 index 00000000..24c8d534 --- /dev/null +++ b/modules/dayz/README.md @@ -0,0 +1,82 @@ +# DayZ — panel-native module + +Panel-native DayZ dedicated server, native-Linux (no Wine). Steam app 223350. + +## The Steam-login gotcha + +**DayZ is paid content.** SteamCMD refuses `+login anonymous` with `No subscription`. The manifest sets `requires_steam_login: true` on the update provider, which tells the controller to gate install/update behind the Steam-login modal. Flow: + +1. Create a DayZ instance in the panel. Auto-update is intentionally skipped (agent sees `requires_steam_login` on the provider and doesn't kick off the first Update). +2. Click **Update** on the instance. +3. Panel returns `{steam_login_required: true}` and the browser pops a login modal. +4. Enter your Steam username + password. If Steam Guard is enabled on your account, also enter the 5-char code from your email / Steam Mobile app. +5. Panel runs a one-shot `docker run steamcmd/steamcmd +login ` to verify, stores the password encrypted (AES-GCM, key HKDF-derived from the panel's CA private key), and caches the SSFN sentry file in a panel-wide volume (`panel-steamcmd-auth`) so future updates skip Steam Guard. +6. Retry Update — panel injects creds into the SteamCMD sidecar and downloads ~5 GB of DayZ server files. + +Your Steam account must own the DayZ base game (app 221100). The dedicated-server app itself (223350) is a free tool; ownership of the base game is what grants download rights. + +## Image + runtime + +- `panel-dayz:latest` — debian:12-slim, 32-bit runtime libs (`libc6:i386`, `lib32gcc-s1`, `lib32stdc++6`) for `steamclient.so`, plus `libcurl4`/`libxml2`. No Wine. +- Binary: `/game/DayZServer` (ELF64, native). +- Config: `/game-saves/serverDZ.cfg` (symlinked to `/game/serverDZ.cfg`). +- Profiles (logs, bans, RPTs): `/game-saves/profiles/` (symlinked to `/game/profiles`). +- BattlEye config: `/game/battleye/beserver_x64.cfg` (port + password written on first boot from `BE_PASSWORD` env var). + +## RCON — BattlEye protocol + +DayZ uses BattlEye's proprietary UDP RCON, not Source RCON. The panel's `be_rcon` adapter handles: + +- Login handshake (password auth → single-byte success/fail) +- Sequenced commands with per-seq ack tracking +- Multi-part response reassembly for long outputs (`players` on busy servers) +- Server-pushed events (chat/join/leave) ack'd automatically +- 30-second keep-alive ping (BE disconnects idle clients after ~45s) + +Commands wired: + +- `list` → `players` +- `broadcast {msg}` → `say -1 {msg}` +- `kick {player}` → `kick {player}` +- `ban {player}` → `ban {player}` +- `shutdown` → `#shutdown` + +## Ports + +| Port | Proto | Purpose | +|------|-------|---------| +| 2302 | UDP | Game + Steam query (same port — Bohemia convention) | +| 2303 | UDP | Additional instance offset (+1) | +| 2304 | UDP | Additional instance offset (+2) | +| 2305 | UDP | Additional instance offset (+3) | +| 2310 | UDP | BattlEye RCON (internal) | + +## Environment vars + +| Var | Default | Notes | +|-----|---------|-------| +| `SERVER_NAME` | `panel DayZ` | Shown in the DayZ server browser | +| `SERVER_PORT` | `2302` | Game + Steam-query UDP port | +| `SERVER_PASSWORD` | *(empty)* | Optional client join password | +| `BE_PASSWORD` | `panel_be` | BattlEye RCON admin password (edit before exposing) | +| `CPU_COUNT` | `2` | `-cpuCount=` arg to the server binary | + +## Log events + +- **Join:** `Player "" is connected` +- **Leave:** `Player "" disconnected` +- **Chat:** `Chat(""): ` + +## Known gotchas + +- **Steam credentials are a one-time ceremony per panel.** Once stored, the cached SSFN blob means subsequent updates don't re-prompt. If Steam invalidates your session (machine change, long inactivity), the modal will pop again with `{need_guard: true}` — enter a fresh code. +- **BattlEye RCON port collides by convention.** 2310 is our default; if you run multiple DayZ instances on the same host, bump via the panel's Network Ports editor. +- **Modded servers need `@` in the launch command.** The entrypoint doesn't wire this yet — drop mod pbos into `/game/` and add `-mod=@` via a manual launcher edit (or wait for a follow-up that exposes `EXTRA_ARGS` as an env var). +- **`steamQueryPort` must equal the game port** in `serverDZ.cfg` or DayZ refuses to register with Steam's master server. Our seeded config follows this. +- **BattlEye can kick you for "kick #0 CreateVehicle Restriction..."** type messages on vanilla — these are server-side integrity checks. Tune `verifySignatures` and the `battleye/bans.txt` / `permissions.txt` files via the Files tab. + +## References + +- [Bohemia Interactive: Hosting a Linux Server (DayZ)](https://community.bistudio.com/wiki/DayZ:Hosting_a_Linux_Server) +- [BattlEye RCON Protocol](https://www.battleye.com/downloads/BERConProtocol.txt) +- [Steam — DayZ Server (app 223350)](https://steamdb.info/app/223350/) diff --git a/modules/dayz/entrypoint.sh b/modules/dayz/entrypoint.sh new file mode 100644 index 00000000..830f159c --- /dev/null +++ b/modules/dayz/entrypoint.sh @@ -0,0 +1,204 @@ +#!/bin/bash +# panel-native DayZ entrypoint. +# +# Contract: +# /game — SteamCMD-installed DayZ Server (app 223350). Populated +# by the panel's SteamCMD updater sidecar, which uses the +# operator-supplied Steam credentials (the module manifest +# sets requires_steam_login: true). +# /game-saves — serverDZ.cfg + profiles/ (logs, bans, RPTs) + optional +# mpmissions/ override tree. + +set -euo pipefail + +log() { printf '[panel-dayz] %s\n' "$*"; } + +# ----- stage 1: root-only bootstrap ----- +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + log "dropping privileges to panel (UID 1000)" + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env \ + HOME=/home/panel \ + SERVER_NAME="${SERVER_NAME:-panel DayZ}" \ + SERVER_PORT="${SERVER_PORT:-2302}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-}" \ + BE_PASSWORD="${BE_PASSWORD:-panel_be}" \ + CPU_COUNT="${CPU_COUNT:-2}" \ + LD_LIBRARY_PATH="/game:${LD_LIBRARY_PATH:-}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +DEDI_EXE="${GAME_DIR}/DayZServer" + +if [[ ! -x "${DEDI_EXE}" ]]; then + log "ERROR: ${DEDI_EXE} not found or not executable." + log " Run 'Update' on this server in the panel — the modal will" + log " prompt for Steam credentials (DayZ is paid content; the" + log " anonymous SteamCMD login won't work)." + exit 78 # EX_CONFIG +fi + +# --- persist profiles into /game-saves --- +# DayZ writes RPT crash logs, ADM admin logs, and BattlEye bans to +# /profiles/. Move out to the save volume + symlink back on +# first boot so SteamCMD validate can't wipe them. +mkdir -p "${SAVE_DIR}/profiles" +if [[ -d "${GAME_DIR}/profiles" && ! -L "${GAME_DIR}/profiles" ]]; then + mv "${GAME_DIR}/profiles"/* "${SAVE_DIR}/profiles/" 2>/dev/null || true + rm -rf "${GAME_DIR}/profiles" +fi +ln -sfn "${SAVE_DIR}/profiles" "${GAME_DIR}/profiles" + +# --- seed serverDZ.cfg on first boot --- +CFG="${SAVE_DIR}/serverDZ.cfg" +if [[ ! -s "${CFG}" ]]; then + log "seeding serverDZ.cfg (first boot)" + cat > "${CFG}" <.cfg` +# files BE writes after first handshake — if the password has changed, the +# active copy will mismatch and BE refuses incoming RCON packets. +# +# Reference: AMP's dayz-experimental.kvp uses `{{$FullBaseDir}}battleye` as +# the BE path and their PreStartStages purge the active file the same way. +BE_DIR="${GAME_DIR}/battleye" +for CFGPATH in "${BE_DIR}/beserver_x64.cfg" "${BE_DIR}/battleye/beserver_x64.cfg"; do + if [[ -d "$(dirname "${CFGPATH}")" ]]; then + log "writing ${CFGPATH} (RCON port 2305, pw from BE_PASSWORD)" + cat > "${CFGPATH}" </dev/null || true +rm -f "${BE_DIR}/battleye/beserver_x64_active_"*.cfg 2>/dev/null || true + +cd "${GAME_DIR}" +log "starting DayZ dedicated server" +log " name: ${SERVER_NAME}" +log " game port: ${SERVER_PORT}/udp" +log " rcon port: 2305/udp (internal, BattlEye)" +log " cfg: ${CFG}" + +trap 'log "SIGTERM received"; kill -TERM "${DZ_PID:-0}" 2>/dev/null || true; wait "${DZ_PID:-0}" 2>/dev/null || true; exit 0' TERM INT + +# --- mod list dynamic build --- +# Panel's mod manager maintains two plain-text files on the save volume: +# /game-saves/panel-mods.txt — one @ModFolder per line → -mod= +# /game-saves/panel-servermods.txt — one @ModFolder per line → -servermod= +# Blank lines and lines starting with '#' are ignored. This lets the mod +# manager edit a human-readable file and have the server pick up the new +# load order on the next start without entrypoint code changes. +build_mod_arg() { + local file="$1" prefix="$2" + [[ -s "$file" ]] || { echo ""; return; } + local joined="" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" # strip trailing CR (Windows edits) + line="${line#"${line%%[![:space:]]*}"}" # ltrim + line="${line%"${line##*[![:space:]]}"}" # rtrim + [[ -z "$line" || "$line" == \#* ]] && continue + if [[ -z "$joined" ]]; then joined="$line" + else joined="${joined};${line}" + fi + done < "$file" + if [[ -n "$joined" ]]; then + echo "${prefix}${joined}" + fi +} +MOD_ARG="$(build_mod_arg "${SAVE_DIR}/panel-mods.txt" '-mod=')" +SERVERMOD_ARG="$(build_mod_arg "${SAVE_DIR}/panel-servermods.txt" '-servermod=')" +[[ -n "${MOD_ARG}" ]] && log "loading mods: ${MOD_ARG}" +[[ -n "${SERVERMOD_ARG}" ]] && log "loading server-mods: ${SERVERMOD_ARG}" + +# DayZ's documented launch command: +# ./DayZServer -config=serverDZ.cfg -port=2302 -BEpath=battleye \ +# -profiles=profiles -dologs -adminlog -netlog \ +# -freezecheck -doScriptLogs -cpuCount=2 +# [-mod=@A;@B] [-servermod=@X] +DZ_ARGS=( + -config=serverDZ.cfg + -port="${SERVER_PORT}" + "-BEpath=${GAME_DIR}/battleye" + -profiles=profiles + -dologs + -adminlog + -netlog + -freezecheck + -doScriptLogs + "-cpuCount=${CPU_COUNT}" +) +[[ -n "${MOD_ARG}" ]] && DZ_ARGS+=("${MOD_ARG}") +[[ -n "${SERVERMOD_ARG}" ]] && DZ_ARGS+=("${SERVERMOD_ARG}") + +exec stdbuf -oL -eL "${DEDI_EXE}" "${DZ_ARGS[@]}" & +DZ_PID=$! +wait "${DZ_PID}" diff --git a/modules/dayz/module.yaml b/modules/dayz/module.yaml new file mode 100644 index 00000000..b552eaf9 --- /dev/null +++ b/modules/dayz/module.yaml @@ -0,0 +1,144 @@ +# DayZ dedicated server module. +# +# Native-Linux binary via SteamCMD app 223350 ("DayZ Server"). App is +# paid content — SteamCMD refuses `+login anonymous` with +# "No subscription". We set `requires_steam_login: true` which makes the +# controller gate updates behind the Steam-login modal; operators sign in +# once with a Steam account that owns DayZ (base game app 221100 — NOT +# the dedicated-server app itself — grants download entitlement). +# +# RCON is BattlEye's proprietary UDP protocol, not Source or WebSocket. +# The panel's new `be_rcon` adapter speaks it natively (MD5-challenge +# login, sequenced commands with ack packets, keep-alive pings). +# +# References: +# https://community.bistudio.com/wiki/DayZ:Hosting_a_Linux_Server +# https://www.battleye.com/downloads/BERConProtocol.txt + +id: dayz +name: "DayZ" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + # Built locally from ./Dockerfile. Rebuild with: + # docker build -t panel-dayz:latest modules/dayz + image: panel-dayz:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "serverDZ.cfg + profiles/ (logs, bans, RPTs) + mpmissions/ world state" + - name: "Game Files" + path: /game + hint: "DayZServer binary + core keys/, shipped mpmissions, BattlEye/" + env: + SERVER_NAME: "panel DayZ" + SERVER_PORT: "2302" + BE_PASSWORD: "panel_be" # BattlEye RCON admin password + SERVER_PASSWORD: "" # optional client join password + CPU_COUNT: "2" # -cpuCount= for the server binary + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + # Shared DayZ Workshop cache — multiple DayZ instances download and use + # the same workshop content. Each instance creates @ModName symlinks in + # its own /game/ pointing at this shared tree, so disk space is saved + # and one download serves N instances. + - { type: volume, name: "panel-dayz-workshop", container: "/game/steamapps/workshop" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +# DayZ ports: +# 2302/udp — primary game + query (Bohemia's convention: game port and +# Steam query are the SAME port, unlike Source-engine games) +# 2303/udp — additional-instance offset (+1) +# 2304/udp — additional-instance offset (+2) +# 2305/udp — additional-instance offset (+3) +# 27016/udp — alternate Steam-query port (some community servers expose) +# 2310/udp — BattlEye RCON (BE_PORT + 1 by convention; configurable) +ports: + - { name: game, proto: udp, default: 2302, required: true, env: SERVER_PORT } # entrypoint reads SERVER_PORT (declared in docker env); query shares this port + - { name: reserved, proto: udp, default: 2303, required: false } + - { name: battleye, proto: udp, default: 2304, required: false } # client BE anti-cheat traffic + - { name: rcon, proto: udp, default: 2305, internal: true } # BE RCON (AMP convention) + +resources: + min_ram_mb: 4096 + recommended_ram_mb: 8192 + +# Per-instance generated RCON secret. The agent generates a random +# 24-byte token at create time (pkg/module GenerateSecrets), which +# overrides the env default for BE_PASSWORD below — so every instance +# gets a unique password. The weak env default only applies to +# containers launched outside the panel. +secrets: + - name: BE_PASSWORD + description: "DayZ BattlEye RCON password — generated per instance" + generated: true + +rcon: + adapter: be_rcon + host_port: rcon + auth: password + password_secret: BE_PASSWORD + # Legacy fallback only (pre-generated-secret instances). New instances + # always get the generated BE_PASSWORD secret above. + password_literal: "panel_be" + commands: + list: "players" + broadcast: "say -1 {msg}" + kick: "kick {player}" + ban: "ban {player}" + save: "#shutdown" # DayZ has no "save" command; use shutdown as a placeholder + shutdown: "#shutdown" + +state_sources: + - type: rcon + command: "players" + every: 60s + +# Log-line event patterns (RE2). Patterns observed from a real DayZ +# 1.24 server log; tighten after smoke test if needed. +events: + join: + pattern: 'Player "(?P[^"]+)" is connected' + kind: join + leave: + pattern: 'Player "(?P[^"]+)" disconnected' + kind: leave + chat: + pattern: 'Chat\("(?P[^"]+)"\): (?P.+)' + kind: chat + +update_providers: + - id: stable + kind: steamcmd + app_id: "223350" + install_path: /game + # DayZ is paid content. Anonymous SteamCMD returns "No subscription". + # Operator logs in once via the panel's Steam-login modal; the cached + # SSFN blob (via the shared panel-steamcmd-auth volume) makes + # subsequent updates skip the 2FA prompt. + requires_steam_login: true + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Mission read from bank|BattlEye Server: Initialized|Server World: Initialized|NetServer: (:\d+)?started|Creating a new world/i' +appearance: + emoji: "🧟" + grad: "linear-gradient(135deg,#2d3e2e,#6b7f56)" + art: "/game-art/dayz.jpg" + steam: "221100" diff --git a/modules/demo/module.yaml b/modules/demo/module.yaml new file mode 100644 index 00000000..b9833577 --- /dev/null +++ b/modules/demo/module.yaml @@ -0,0 +1,56 @@ +# Demo module — tiny alpine container used to validate panel end-to-end +# without pulling multi-GB game server images. The container just sleeps, +# so the runtime exercises pull / create / start / stop / logs / wait without +# needing RCON or any real game behaviour. +id: demo +name: "Demo (alpine sleeper)" +version: 0.1.0 +authors: + - panel contributors +supported_modes: + - docker +runtime: + docker: + image: alpine:3.21 + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + command: + - sh + - -c + - | + echo "demo instance starting"; + i=0; + while true; do + echo "tick $i $(date -Iseconds)"; + i=$((i+1)); + sleep 2; + done +ports: + - { name: dummy, proto: tcp, default: 14000 } +resources: + min_ram_mb: 32 + recommended_ram_mb: 64 +update_providers: + # Exercise the Direct provider with a small, stable URL. Running the + # update places the GPL-3.0 text at /data-ish/license.txt. + - id: gpl + kind: direct + url: "https://www.gnu.org/licenses/gpl-3.0.txt" + target_path: "license.txt" + # GitHub provider: grabs the latest `buf` CLI release note as a demo + # (asset_regex matches any tar.gz). + - id: buf-latest + kind: github + repo: "bufbuild/buf" + asset_regex: "buf-Linux-x86_64.tar.gz" + target_path: "downloads/buf.tar.gz" + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +appearance: + emoji: "🧪" + grad: "linear-gradient(135deg,#4b5563,#9ca3af)" diff --git a/modules/dragonwilds/Dockerfile b/modules/dragonwilds/Dockerfile new file mode 100644 index 00000000..94a21137 --- /dev/null +++ b/modules/dragonwilds/Dockerfile @@ -0,0 +1,55 @@ +# panel-native RuneScape: Dragonwilds runtime image. +# +# Dragonwilds ships a native-Linux UE5 build — no Wine. Minimal debian:12-slim +# + 32-bit runtime libs (steamclient.so dependency) + UE5 dlopen shim libs. +# Same scaffolding as the `rust` module; UE5 servers touch the same set of X/ +# GL libs at boot even in headless mode. +# +# Expected runtime layout inside the container: +# /game — SteamCMD install (panel volume) +# /game/RSDragonwildsServer.sh — Jagex's launcher wrapper +# /game/RSDragonwilds/Binaries/Linux/RSDragonwildsServer-Linux-Shipping — real exe +# /game/RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini — operator config +# /game-saves — save + logs volume + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libgl1 \ + libx11-6 \ + libxcursor1 \ + libxext6 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + procps \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 7777/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/dragonwilds/README.md b/modules/dragonwilds/README.md new file mode 100644 index 00000000..ab7290f1 --- /dev/null +++ b/modules/dragonwilds/README.md @@ -0,0 +1,48 @@ +# RuneScape: Dragonwilds — panel-native module + +Panel-native Dragonwilds dedicated server, native-Linux (no Wine), debian:12-slim base. + +- **Image:** `panel-dragonwilds:latest` — built from `./Dockerfile`. +- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 4019830) into the `panel--game` named volume. Dragonwilds is a multi-depot app (Windows 4019831 + Linux 3501791) — module declares `platform: linux` so SteamCMD's auto-detect doesn't mis-fire on Docker Desktop WSL2. Linux depot is ~1.6 GB compressed, ~5.8 GB on disk. +- **Saves:** `panel--saves` at `/game-saves`. Entrypoint migrates `RSDragonwilds/Saved/` into the save volume on first discovery and symlinks back, so SteamCMD validate can't wipe worlds or config. +- **RCON:** **None.** Jagex has not documented or implemented a remote admin protocol. The Players tab will stay empty until we wire log-tail join/leave regex from observing real player traffic. + +## Ports + +| Port | Proto | Purpose | +|------|-------|---------| +| 7777 | UDP | Game traffic | + +Additional instances on the same host should use 7778, 7779, etc. + +## Required configuration + +Jagex requires 4 mandatory values in `RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini`: + +- `ServerName` +- `DefaultWorldName` +- `OwnerID` — the Steam ID (or Jagex account ID?) of the server owner +- `AdminPassword` + +The entrypoint seeds `DedicatedServer.ini` on first boot from env vars (`SERVER_NAME`, `DEFAULT_WORLD_NAME`, `OWNER_ID`, `ADMIN_PASSWORD`). Operators should set `OWNER_ID` and `ADMIN_PASSWORD` before the first launch or the server will reject client logins. Hand-edit via the Files tab after the first boot is also fine — the entrypoint only seeds when the file is missing. + +## Environment vars + +| Var | Default | Notes | +|-----|---------|-------| +| `SERVER_NAME` | `panel Dragonwilds` | Shown in the server browser | +| `SERVER_PORT` | `7777` | | +| `DEFAULT_WORLD_NAME` | `Gielinor` | World loaded on start | +| `OWNER_ID` | *(empty)* | Operator must set | +| `ADMIN_PASSWORD` | *(empty)* | Operator must set | + +## Known gotchas + +- Multi-depot SteamCMD install — without `platform: linux` in the manifest, SteamCMD exits 8 "Missing configuration" on Docker Desktop WSL2 (same class as Palworld). +- Linux launcher at `/game/RSDragonwildsServer.sh` is a shell wrapper around the real UE5 shipping binary under `RSDragonwilds/Binaries/Linux/`. The entrypoint probes for both and prefers the wrapper when available. +- No RCON → no admin/broadcast/kick from the panel. Use in-game commands via the server operator. + +## Reference + +- [Jagex dedicated server guide](https://dragonwilds.runescape.com/news/how-to-dedicated-servers) +- [RuneScape: Dragonwilds Wiki — Dedicated Servers / Linux](https://dragonwilds.runescape.wiki/w/Dedicated_Servers/Linux) diff --git a/modules/dragonwilds/entrypoint.sh b/modules/dragonwilds/entrypoint.sh new file mode 100644 index 00000000..738c5c9e --- /dev/null +++ b/modules/dragonwilds/entrypoint.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# panel-native RuneScape: Dragonwilds entrypoint. +# +# Contract: +# /game — SteamCMD-installed Linux build of Dragonwilds Dedicated +# Server (app 4019830). Populated by the panel's SteamCMD +# updater sidecar. +# /game-saves — RSDragonwilds/Saved/ config + save tree + logs. + +set -euo pipefail + +log() { printf '[panel-dragonwilds] %s\n' "$*"; } + +# ----- stage 1: root-only bootstrap ----- +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + log "dropping privileges to panel (UID 1000)" + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env \ + HOME=/home/panel \ + SERVER_NAME="${SERVER_NAME:-panel Dragonwilds}" \ + SERVER_PORT="${SERVER_PORT:-7777}" \ + OWNER_ID="${OWNER_ID:-}" \ + ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" \ + DEFAULT_WORLD_NAME="${DEFAULT_WORLD_NAME:-Gielinor}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves + +# --- locate the launcher wrapper --- +# Jagex ships RSDragonwildsServer.sh at the install root; it execs the +# UE5 shipping binary under RSDragonwilds/Binaries/Linux/. +LAUNCHER="${GAME_DIR}/RSDragonwildsServer.sh" +FALLBACK="${GAME_DIR}/RSDragonwilds/Binaries/Linux/RSDragonwildsServer-Linux-Shipping" +if [[ ! -x "${LAUNCHER}" && ! -x "${FALLBACK}" ]]; then + log "ERROR: ${LAUNCHER} not found or not executable." + log " Run 'Update' on this server in the panel — that launches a" + log " SteamCMD sidecar which installs Dragonwilds (app 4019830)" + log " for Linux into this volume via +@sSteamCmdForcePlatformType linux." + exit 78 # EX_CONFIG +fi + +# --- persist RSDragonwilds/Saved into /game-saves --- +# UE5 writes config + save data under /RSDragonwilds/Saved/. Move +# it into the save volume on first discovery and symlink back so SteamCMD +# validate can't wipe world data. Idempotent on re-boot. +mkdir -p "${SAVE_DIR}/RSDragonwilds" "${GAME_DIR}/RSDragonwilds" +if [[ -d "${GAME_DIR}/RSDragonwilds/Saved" && ! -L "${GAME_DIR}/RSDragonwilds/Saved" ]]; then + if [[ ! -d "${SAVE_DIR}/RSDragonwilds/Saved" ]]; then + mv "${GAME_DIR}/RSDragonwilds/Saved" "${SAVE_DIR}/RSDragonwilds/Saved" + else + rm -rf "${GAME_DIR}/RSDragonwilds/Saved" + fi +fi +mkdir -p "${SAVE_DIR}/RSDragonwilds/Saved/Config/LinuxServer" +ln -sfn "${SAVE_DIR}/RSDragonwilds/Saved" "${GAME_DIR}/RSDragonwilds/Saved" + +# --- seed DedicatedServer.ini from env on first boot --- +# Jagex requires OwnerId + Server Name + Default World Name + Admin Password +# to be set. Observed behavior on 0.11: Dragonwilds overwrites an absent +# DedicatedServer.ini on first boot with its OWN random-generated values +# under the `[/Script/Dominion.DedicatedServerSettings]` section ("Dominion" +# is the internal UE5 project name). So the best we can do for first-boot +# seeding is to pre-write the file — if it exists the server reads it and +# leaves it. We only touch the file when it doesn't already exist. +INI="${SAVE_DIR}/RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini" +if [[ ! -s "${INI}" ]]; then + log "seeding DedicatedServer.ini (first boot)" + cat > "${INI}" <}" +log " port: ${SERVER_PORT}/udp" + +trap 'log "SIGTERM received; stopping server"; kill -TERM "${SRV_PID:-0}" 2>/dev/null || true; wait "${SRV_PID:-0}" 2>/dev/null || true; exit 0' TERM INT + +# Use the Jagex-shipped launcher when present; otherwise exec the shipping +# binary directly. `-log` routes UE5 output to stdout. Drop `-NewConsole` +# (Windows-only; harmless but noisy on Linux). +if [[ -x "${LAUNCHER}" ]]; then + exec stdbuf -oL -eL "${LAUNCHER}" -log "-Port=${SERVER_PORT}" & +else + exec stdbuf -oL -eL "${FALLBACK}" -log "-Port=${SERVER_PORT}" & +fi +SRV_PID=$! +wait "${SRV_PID}" diff --git a/modules/dragonwilds/module.yaml b/modules/dragonwilds/module.yaml new file mode 100644 index 00000000..c14932dd --- /dev/null +++ b/modules/dragonwilds/module.yaml @@ -0,0 +1,99 @@ +# RuneScape: Dragonwilds dedicated server module. +# +# Native-Linux UE5 binary via SteamCMD app 4019830 (free-to-download tool; +# Jagex made the dedicated server public on Steam in 2026 with Update 0.11). +# Multi-depot app (Windows 4019831 + Linux 3501791) — so `platform: linux` +# is required, same class of issue as Palworld on Docker Desktop WSL2. +# +# No native RCON protocol — Jagex hasn't documented one. The admin console +# is in-process only. Panel surfaces the UE log in the Console tab; Players +# tab stays empty until Jagex ships an admin protocol (or we wire a log-tail +# based join/leave regex when we observe real player traffic). +# +# References: +# https://dragonwilds.runescape.com/news/how-to-dedicated-servers +# https://dragonwilds.runescape.wiki/w/Dedicated_Servers/Linux + +id: dragonwilds +name: "RuneScape: Dragonwilds" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-dragonwilds:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "RSDragonwilds/Saved/Config/LinuxServer/DedicatedServer.ini + world saves" + - name: "Game Files" + path: /game + hint: "RSDragonwildsServer.sh, RSDragonwilds/Binaries/Linux/" + env: + SERVER_NAME: "panel Dragonwilds" + SERVER_PORT: "7777" + # Operators MUST supply these before the server will accept clients. + # First boot generates a stub DedicatedServer.ini if these are empty; + # the server still launches but refuses logins until populated. + OWNER_ID: "" + ADMIN_PASSWORD: "" + DEFAULT_WORLD_NAME: "Gielinor" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +# Dragonwilds only documents one port: 7777/udp (game). No Steam query +# or RCON ports in the public docs. Additional server instances on the +# same host are expected to increment the port (7778, 7779, ...). +ports: + - { name: game, proto: udp, default: 7777, required: true, env: SERVER_PORT } + +resources: + min_ram_mb: 4096 + recommended_ram_mb: 8192 +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (runescape-dragonwilds.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'LogDomMatcherSession:\s+Player\s+ADDED\s+to\s+session\s+\[[^\]]+\]-\[(?P[^\]]+)\]$' + kind: join + leave: + pattern: 'LogDomMatcherSession:\s+Player\s+Removed\s+from\s+session\s+\[[^\]]+\]-\[(?P[^\]]+)\]$' + kind: leave + chat: + pattern: '^\[[\d\.]+-[\d\.:]+\]\[[\d ]+\]LogChat: (?P.+?): (?P.*)$' + kind: chat + + +update_providers: + - id: stable + kind: steamcmd + app_id: "4019830" + # Multi-depot — explicit platform required or SteamCMD auto-detect + # mis-fires on Docker Desktop WSL2 with "Missing configuration". + platform: linux + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/LogInit: Display: Engine is initialized|LogGlobalStatus:\s*UEngine::LoadMap\s+Load\s+map\s+complete|LogLoad: Game Engine Initialized|LogNet: GameNetDriver/i' +appearance: + emoji: "🐉" + grad: "linear-gradient(135deg,#4c1d95,#f59e0b)" + art: "/game-art/dragonwilds.jpg" + steam: "1374490" diff --git a/modules/empyrion-bridge/Dockerfile b/modules/empyrion-bridge/Dockerfile new file mode 100644 index 00000000..05b57032 --- /dev/null +++ b/modules/empyrion-bridge/Dockerfile @@ -0,0 +1,27 @@ +# panel-empyrion-bridge — .NET 8 sidecar that proxies Empyrion's Mod API +# (TCP/protobuf-net via the EPM mod) to a small REST surface for the panel. +# +# Build: +# docker build -t panel-empyrion-bridge:latest modules/empyrion-bridge +# +# Run: +# docker run --rm -p 8090:8090 panel-empyrion-bridge:latest +# then: +# curl 'http://localhost:8090/api/v1/factions?host=&port=7028' +FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build +WORKDIR /src +COPY bridge/ ./bridge/ +COPY lib/ ./lib/ +RUN dotnet publish bridge/Bridge.csproj -c Release -o /out --no-self-contained \ + -p:DebugType=embedded -p:PublishSingleFile=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine +WORKDIR /app +COPY --from=build /out/ ./ +ENV BRIDGE_URLS=http://0.0.0.0:8090 \ + BRIDGE_REQUEST_TIMEOUT_S=8 \ + DOTNET_RUNNING_IN_CONTAINER=true \ + ASPNETCORE_FORWARDEDHEADERS_ENABLED=true +EXPOSE 8090 +USER 1000:1000 +ENTRYPOINT ["dotnet", "panel-empyrion-bridge.dll"] diff --git a/modules/empyrion-bridge/README.md b/modules/empyrion-bridge/README.md new file mode 100644 index 00000000..a5eabc50 --- /dev/null +++ b/modules/empyrion-bridge/README.md @@ -0,0 +1,97 @@ +# panel-empyrion-bridge + +A small .NET 8 ASP.NET Core sidecar that proxies Empyrion's native Mod API +(TCP/protobuf-net) into a JSON REST surface for the panel dashboard. + +## Why this exists + +Empyrion's only first-class admin tool is a Windows-only desktop app +(Empyrion Admin Helper / EAH) speaking protobuf-net to an in-process mod +called EPM. Re-implementing protobuf-net contract decoding in Go is a +losing battle — every Empyrion update could shift `[ProtoMember]` tags. + +Instead, this bridge: + +- References Eleon's `Mif.dll` directly (the official mod-API SDK). +- Re-uses `EPMConnector` (TCP framing + protobuf-net dispatch) by + compiling its source files in. +- Exposes a thin REST API the panel calls. + +## Build + +``` +docker build -t panel-empyrion-bridge:latest modules/empyrion-bridge +``` + +## Run + +The bridge needs network reach to wherever EPM is listening on each +Empyrion instance. On a single-host setup with `network_mode: host` +empyrion containers, hostname `127.0.0.1` works: + +``` +docker run -d --name panel-bridge --network host \ + -e BRIDGE_URLS=http://127.0.0.1:8090 \ + panel-empyrion-bridge:latest +``` + +For multi-host setups, pass each Empyrion's LAN IP + EAH_API_PORT in the +query string per request. + +## API (Phase 2 — read-only) + +All endpoints take `?host=…&port=…` query parameters identifying the +Empyrion EPM instance. + +| Method | Path | Returns | +|--------|--------------------------------------------|--------------------------------------------------| +| GET | `/healthz` | bridge health + version | +| GET | `/api/v1/factions` | `[{ origin, factionId, name, abbrev }]` | +| GET | `/api/v1/players` | `{ playerIds: [int], count }` | +| GET | `/api/v1/players/{id}` | full `PlayerInfo` (raw protobuf-net contract) | +| GET | `/api/v1/playfields` | `{ playfields: [name] }` | +| GET | `/api/v1/playfields/{name}/entities` | `{ entities: [...] }` | +| POST | `/api/v1/console` | fire-and-forget; body is the console line | + +`POST /api/v1/console` is currently EXPERIMENTAL — see "Known issues" below. + +## Known issues / Phase 3+ work + +- **Console command cmd-byte truncation suspected.** Empyrion's `CmdId` + enum has grown past 255 entries; the legacy `EPMConnector.ModProtocol` + writes `cmd` as a single byte, which means commands with id > 255 alias + to other commands. Symptom seen: sending `Request_ConsoleCommand` shows + up on the EPM side as `Event_ChatMessageEx`. Fix: extend the protocol + encoding to use a UInt16 cmd OR build a tested mapping table from + Mif.dll's CmdId enum values. Tracked for Phase 3. +- **Authentication.** EPM has none. The bridge currently has none either. + Before exposing any port to the WAN, add a shared-secret bearer token + matching the panel's existing service-API pattern. +- **Streaming events.** Right now unsolicited Event_* packages (chat, + player join, etc) are dropped. A `GET /api/v1/stream` SSE endpoint will + fan these out to subscribers in Phase 4. +- **Connection persistence.** The pool reaps idle connections after 5 + minutes. If Empyrion restarts, we don't yet auto-reconnect; the next + request opens a fresh connection but in-flight requests fail. Phase 5. + +## Source layout + +``` +empyrion-bridge/ + bridge/ ASP.NET Core minimal API project + Bridge.csproj + Program.cs endpoint definitions + BridgePool.cs per-(host,port) connection cache + BridgeConnection.cs request/response correlation by (seq, expectedCmd) + EPMConnector/ source files compiled in (from MichaelGoulding/sample-empyrion-mod, MIT) + lib/ + Mif.dll Eleon Modding SDK reference + Dockerfile multi-stage build, alpine runtime +``` + +## Verified end-to-end on 2026-04-30 + +``` +curl 'http://:8090/api/v1/factions?host=127.0.0.1&port=7028' +→ {"factions":[{"origin":1,"factionId":1,"name":"Human","abbrev":"Hum"}]} +``` diff --git a/modules/empyrion-bridge/bridge/.gitignore b/modules/empyrion-bridge/bridge/.gitignore new file mode 100644 index 00000000..ac8d21bd --- /dev/null +++ b/modules/empyrion-bridge/bridge/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +*.user diff --git a/modules/empyrion-bridge/bridge/Bridge.csproj b/modules/empyrion-bridge/bridge/Bridge.csproj new file mode 100644 index 00000000..8759cb6c --- /dev/null +++ b/modules/empyrion-bridge/bridge/Bridge.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + disable + enable + true + EmpyrionBridge + panel-empyrion-bridge + 12 + CS8981;CS0414;CS8632;CS8625;CS8600;CS8601;CS8602;CS8603;CS8604;CS8618;CS8619 + + + + + + + + + ..\lib\Mif.dll + true + + + + diff --git a/modules/empyrion-bridge/bridge/BridgeConnection.cs b/modules/empyrion-bridge/bridge/BridgeConnection.cs new file mode 100644 index 00000000..655c6664 --- /dev/null +++ b/modules/empyrion-bridge/bridge/BridgeConnection.cs @@ -0,0 +1,149 @@ +using System.Collections.Concurrent; +using Eleon.Modding; +using EPMConnector; + +namespace EmpyrionBridge; + +// Wraps an EPMConnector.Client with a request/response correlation layer. +// EPM replies with the same seqNr we sent, so a TaskCompletionSource keyed +// by seqNr resolves on the matching Event_* package. +public class BridgeConnection : IDisposable +{ + public string Host { get; } + public int Port { get; } + public bool IsConnected => _connected; + + readonly Client _client; + readonly ConcurrentDictionary _pending = new(); + readonly ILogger _log; + int _seq; + volatile bool _connected; + + // Subscribers receive every package not consumed by a pending request, + // i.e. EPM's broadcasts of player join/leave, chat, structure changes, + // PDA state, dedi stats heartbeats, etc. Used by the SSE fan-out. + readonly ConcurrentDictionary> _subscribers = new(); + int _subscriberId; + + record Pending(CmdId ExpectedResponse, TaskCompletionSource Tcs); + + public IDisposable Subscribe(Action handler) + { + var id = Interlocked.Increment(ref _subscriberId); + _subscribers[id] = handler; + return new Subscription(this, id); + } + + sealed class Subscription : IDisposable + { + readonly BridgeConnection _conn; + readonly int _id; + public Subscription(BridgeConnection c, int id) { _conn = c; _id = id; } + public void Dispose() => _conn._subscribers.TryRemove(_id, out _); + } + + public BridgeConnection(string host, int port, ILogger log) + { + Host = host; + Port = port; + _log = log; + _client = new Client(clientId: 0); + _client.ClientMessages += msg => _log.LogDebug("EPM[{Host}:{Port}]: {Msg}", host, port, msg); + _client.OnConnected += () => { _connected = true; _log.LogInformation("EPM[{Host}:{Port}] connected", host, port); }; + _client.GameEventReceived += OnPackage; + } + + public Task ConnectAsync(TimeSpan timeout) + { + _client.Connect(Host, Port); + return WaitForConnect(timeout); + } + + async Task WaitForConnect(TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (!_connected && DateTime.UtcNow < deadline) + { + await Task.Delay(100); + } + if (!_connected) throw new TimeoutException($"EPM at {Host}:{Port} did not respond within {timeout.TotalSeconds:F0}s — is the mod loaded?"); + } + + public async Task RequestAsync(CmdId requestCmd, CmdId expectedResponse, object requestData, TimeSpan timeout) where T : class + { + if (!_connected) throw new InvalidOperationException("not connected"); + + var seq = (ushort)(Interlocked.Increment(ref _seq) & 0xFFFF); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[seq] = new Pending(expectedResponse, tcs); + + try + { + _client.Send(requestCmd, seq, requestData); + + var winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout)); + if (winner != tcs.Task) + { + throw new TimeoutException($"no {expectedResponse} response to {requestCmd} within {timeout.TotalSeconds:F0}s"); + } + var pkg = await tcs.Task; + return pkg.data as T; + } + finally + { + _pending.TryRemove(seq, out _); + } + } + + public void SendFireAndForget(CmdId cmd, object data) + { + if (!_connected) throw new InvalidOperationException("not connected"); + var seq = (ushort)(Interlocked.Increment(ref _seq) & 0xFFFF); + _client.Send(cmd, seq, data); + } + + void OnPackage(ModProtocol.Package p) + { + _log.LogDebug("EPM[{Host}:{Port}] <== cmd={Cmd} seq={Seq} dataType={Type}", + Host, Port, p.cmd, p.seqNr, p.data?.GetType()?.Name ?? "null"); + + // Match a pending request by seqNr. Resolve on exact-cmd match + // (success) OR on Event_Error / Event_Ok (server-side failure / + // empty success); otherwise fall through to the SSE subscribers. + if (_pending.TryGetValue(p.seqNr, out var pending)) + { + if (pending.ExpectedResponse == p.cmd) + { + _pending.TryRemove(p.seqNr, out _); + pending.Tcs.TrySetResult(p); + return; + } + if (p.cmd == CmdId.Event_Error || p.cmd == CmdId.Event_Ok) + { + // EPM sends Event_Error for invalid requests (e.g. asking + // for a non-existent player) AND for requests that have + // no data to return (e.g. Request_GlobalStructure_Update + // on a server with no structures). Either way, resolve + // the pending request with a null payload — caller's + // typed cast returns null and they render an empty list. + _pending.TryRemove(p.seqNr, out _); + pending.Tcs.TrySetResult(new ModProtocol.Package { cmd = pending.ExpectedResponse, seqNr = p.seqNr, data = null }); + return; + } + } + // Broadcast to SSE subscribers. Catch failures per subscriber so a + // slow / failing one can't block the others. + foreach (var kv in _subscribers) + { + try { kv.Value(p); } + catch (Exception ex) { _log.LogWarning(ex, "subscriber {Id} threw", kv.Key); } + } + } + + public void Dispose() + { + try { _client.Disconnect(); } catch { /* best effort */ } + foreach (var pending in _pending.Values) pending.Tcs.TrySetCanceled(); + _pending.Clear(); + } +} diff --git a/modules/empyrion-bridge/bridge/BridgePool.cs b/modules/empyrion-bridge/bridge/BridgePool.cs new file mode 100644 index 00000000..8477262f --- /dev/null +++ b/modules/empyrion-bridge/bridge/BridgePool.cs @@ -0,0 +1,85 @@ +using System.Collections.Concurrent; + +namespace EmpyrionBridge; + +// Lazy connection pool keyed by host:port. Reuses an EPM TCP connection +// across HTTP requests; drops it after IdleTtl with no traffic. +public class BridgePool : IAsyncDisposable +{ + public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(8); + public TimeSpan IdleTtl { get; init; } = TimeSpan.FromMinutes(5); + + readonly ConcurrentDictionary _entries = new(); + readonly ILoggerFactory _logFactory; + readonly ILogger _log; + readonly Timer _reaper; + + public BridgePool(ILoggerFactory logFactory) + { + _logFactory = logFactory; + _log = logFactory.CreateLogger(); + _reaper = new Timer(Reap, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)); + } + + public async Task GetAsync(string host, int port) + { + var key = $"{host}:{port}"; + Entry e; + lock (_entries) + { + if (!_entries.TryGetValue(key, out e)) + { + e = new Entry { Conn = new BridgeConnection(host, port, _logFactory.CreateLogger("EPM")) }; + _entries[key] = e; + } + e.LastUsed = DateTime.UtcNow; + } + + if (!e.Conn.IsConnected) + { + await e.ConnectGate.WaitAsync(); + try + { + if (!e.Conn.IsConnected) + { + await e.Conn.ConnectAsync(ConnectTimeout); + } + } + finally + { + e.ConnectGate.Release(); + } + } + return e.Conn; + } + + void Reap(object _) + { + var cutoff = DateTime.UtcNow - IdleTtl; + foreach (var kv in _entries.ToArray()) + { + if (kv.Value.LastUsed < cutoff) + { + if (_entries.TryRemove(kv.Key, out var e)) + { + _log.LogInformation("reaping idle connection {Key}", kv.Key); + e.Conn.Dispose(); + } + } + } + } + + public async ValueTask DisposeAsync() + { + await _reaper.DisposeAsync(); + foreach (var e in _entries.Values) e.Conn.Dispose(); + _entries.Clear(); + } + + class Entry + { + public BridgeConnection Conn = null!; + public DateTime LastUsed = DateTime.UtcNow; + public SemaphoreSlim ConnectGate = new(1, 1); + } +} diff --git a/modules/empyrion-bridge/bridge/EPMConnector/Client.cs b/modules/empyrion-bridge/bridge/EPMConnector/Client.cs new file mode 100644 index 00000000..36ebdfcb --- /dev/null +++ b/modules/empyrion-bridge/bridge/EPMConnector/Client.cs @@ -0,0 +1,103 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using Eleon.Modding; + + +namespace EPMConnector +{ + public class Client + { + ModThreadHelper.Info connectToServerThread; + + public event Action OnConnected; + public event Action ClientMessages; + public event Action GameEventReceived; + + volatile ModProtocol client; + + int clientId; + + string gameServerIp; + int gameServerPort; + + public Client(int clientId) + { + this.clientId = clientId; + } + + public void Connect(string ipAddress, int port) + { + this.gameServerIp = ipAddress; + this.gameServerPort = port; + connectToServerThread = ModThreadHelper.StartThread(ThreadConnectToServer, System.Threading.ThreadPriority.Lowest); + } + + private void ThreadConnectToServer(ModThreadHelper.Info ti) + { + ClientMessages(string.Format("ModInterface: Started connection thread. Connecting to {0}:{1}", this.gameServerIp, this.gameServerPort)); + while (!ti.eventRunning.WaitOne(0)) + { + if (client == null) + { + try + { + TcpClient tcpClient = new TcpClient(this.gameServerIp, this.gameServerPort); + tcpClient.ReceiveBufferSize = 10 * 1024 * 1024; + tcpClient.SendBufferSize = 10 * 1024 * 1024; + + client = new ModProtocol(tcpClient, PackageReceivedDelegate, DisconnectedDelegate); + ClientMessages("ModInterface: Connected with " + client + " over port " + this.gameServerPort); + + OnConnected?.Invoke(); + } + catch (SocketException) + { + // Ignore + } + catch (Exception e) + { + ClientMessages(e.GetType() + ": " + e.Message); + client = null; + } + } + Thread.Sleep(1000); + } + } + + // Called in a thread! + private void PackageReceivedDelegate(ModProtocol con, ModProtocol.Package p) + { + GameEventReceived(p); + } + + // Called in a thread! + private void DisconnectedDelegate(ModProtocol prot) + { + ClientMessages("DisconnectedDelegate called"); + Disconnect(); + } + + public void Send(CmdId cmdId, ushort seqNr, object data) + { + //ClientMessages("Sending request event: c=" + cmdId + " sNr=" + seqNr + " d=" + data + " client="+client); + // Send events of the network + ModProtocol c = client; + if (c != null) + { + ModProtocol.Package p = new ModProtocol.Package(cmdId, clientId, seqNr, data); + c.AddToSendQueue(p); + } + } + + public void Disconnect() + { + if(client != null) + { + client.Close(); + client = null; + } + + } + } +} diff --git a/modules/empyrion-bridge/bridge/EPMConnector/ModLog.cs b/modules/empyrion-bridge/bridge/EPMConnector/ModLog.cs new file mode 100644 index 00000000..b26b884b --- /dev/null +++ b/modules/empyrion-bridge/bridge/EPMConnector/ModLog.cs @@ -0,0 +1,92 @@ +using System; + +namespace EPMConnector +{ + public class ModLoging + { + public static string Pfad = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) + "\\"; + + public static string Datei = "Log.txt"; + public enum eTyp + { + Information, + Warning, + Exception + } + + public static Exception LastException = null; + + public static void Log_Exception(Exception ex, string ZusatzInfo = "", string LogDatei = "") + { + Log("Error: " + ex.ToString() + ": " + (string.IsNullOrEmpty(ZusatzInfo) ? "" : "More Infos: " + ZusatzInfo + " - "), eTyp.Exception, LogDatei); + } + + public static void Log(string Message, eTyp Typ = eTyp.Information, string LogDatei = "") + { + System.IO.FileStream stmFile = default(System.IO.FileStream); + System.IO.StreamWriter binWriter = default(System.IO.StreamWriter); + bool FileExists = false; + + try + { + if (string.IsNullOrEmpty(LogDatei)) + { + if (string.IsNullOrEmpty(Datei)) + Datei = "Log.txt"; + + if (System.IO.Directory.Exists(Pfad) == false) + { + return; + } + LogDatei = Pfad + Datei; + + } + else + { + if (System.IO.Directory.Exists(System.IO.Path.GetDirectoryName(LogDatei)) == false) + { + return; + } + } + + FileExists = System.IO.File.Exists(LogDatei); + + stmFile = new System.IO.FileStream(LogDatei, System.IO.FileMode.Append, System.IO.FileAccess.Write); + binWriter = new System.IO.StreamWriter(stmFile); + + if (FileExists == false) + binWriter.WriteLine("Date" + "\t" + "Time " + "\t" + "Status" + "\t" + "Message"); + + binWriter.WriteLine(Get_LogString(Message, Typ)); + + binWriter.Flush(); + binWriter.Close(); + stmFile.Close(); + } + catch (Exception) + { + } + } + + public static string Get_LogString(string Message, eTyp Typ = eTyp.Information) + { + string functionReturnValue = null; + switch (Typ) + { + case eTyp.Information: + functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "I" + "\t" + Message; + break; + case eTyp.Warning: + functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "W" + "\t" + Message; + break; + case eTyp.Exception: + functionReturnValue = String.Format("{0:dd.MM.yyyy}", DateTime.Now) + "\t" + String.Format("{0:HH:mm:ss.fff}", DateTime.Now) + "\t" + "E" + "\t" + Message; + break; + default: + functionReturnValue = ""; + break; + } + return functionReturnValue; + } + } +} diff --git a/modules/empyrion-bridge/bridge/EPMConnector/ModProtocol.cs b/modules/empyrion-bridge/bridge/EPMConnector/ModProtocol.cs new file mode 100644 index 00000000..d3e6dd13 --- /dev/null +++ b/modules/empyrion-bridge/bridge/EPMConnector/ModProtocol.cs @@ -0,0 +1,776 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using ProtoBuf; +using Eleon.Modding; + +namespace EPMConnector +{ + + public class ModProtocol + { + + public struct Package + { + public CmdId cmd; + public int clientId; + public object data; + public ushort seqNr; + + public Package(CmdId cmdId, int nClientId, ushort nSeqNr, object nData) + { + cmd = cmdId; + clientId = nClientId; + seqNr = nSeqNr; + data = nData; + } + } + + public delegate void DelegatePackageReceived(ModProtocol con, Package p); + private DelegatePackageReceived packageReceivedDelegate; + + public delegate void DelegateDisconnected(ModProtocol con); + private DelegateDisconnected disconnectedDelegate; + + public readonly TcpClient tcpClient; + + private List writingPackages = new List(); + + ModThreadHelper.Info readerThread; + ModThreadHelper.Info writerThread; + + volatile BinaryReader readerStream; + volatile BinaryWriter writerStream; + + AutoResetEvent evWritePackages = new AutoResetEvent(false); + + volatile bool bDisconnected = false; + + public ModProtocol(TcpClient nTcpClient, DelegatePackageReceived nPackageReceivedDelegate, DelegateDisconnected nDisconnectedDelegate) + { + tcpClient = nTcpClient; + + packageReceivedDelegate = nPackageReceivedDelegate; + disconnectedDelegate = nDisconnectedDelegate; + + readerThread = ModThreadHelper.StartThread("Reader-" + tcpClient.Client.RemoteEndPoint, ReaderThread, ThreadPriority.Lowest); + writerThread = ModThreadHelper.StartThread("Writer-" + tcpClient.Client.RemoteEndPoint, WriterThread, ThreadPriority.Lowest); + } + + public void AddToSendQueue(Package p) + { + lock (writingPackages) + { + writingPackages.Add(p); + } + evWritePackages.Set(); + } + + private void ReaderThread(ModThreadHelper.Info ti) + { + + readerStream = new BinaryReader(tcpClient.GetStream()); + + try + { + while (!ti.eventRunning.WaitOne(0)) + { + CmdId cmd = (CmdId)readerStream.ReadByte(); + int clientId = readerStream.ReadInt32(); + ushort seqNr = readerStream.ReadUInt16(); + int len = readerStream.ReadInt32(); + int bytesRead = 0; + byte[] data = null; + if (len > 0) + { + data = new byte[len]; // allocations are bad! maybe use pooled buffers? + do + { + bytesRead += readerStream.Read(data, bytesRead, (len - bytesRead)); + } while (bytesRead < len && !readerThread.eventRunning.WaitOne(0)); + } + + object obj = null; + if (len > 0) + { + MemoryStream ms = new MemoryStream(data); + switch (cmd) + { + + case Eleon.Modding.CmdId.Event_Player_Connected: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Player_Disconnected: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Player_ChangedPlayfield: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Player_Inventory: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Player_List: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Player_Info: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_Info: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_GetInventory: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_SetInventory: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_AddItem: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_Credits: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_SetCredits: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_AddCredits: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Player_Credits: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Entity_PosAndRot: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Entity_PosAndRot: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_InGameMessage_SinglePlayer: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_InGameMessage_AllPlayers: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_ShowDialog_SinglePlayer: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_DialogButtonIndex: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Loaded: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Unloaded: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Playfield_Stats: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Stats: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Dedi_Stats: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Playfield_List: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_GlobalStructure_List: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Entity_Teleport: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_GlobalStructure_Update: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Faction_Changed: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case CmdId.Request_Blueprint_Finish: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Blueprint_Resources: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Player_ChangePlayerfield: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Entity_ChangePlayfield: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Entity_Destroy: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Player_ItemExchange: + case CmdId.Event_Player_ItemExchange: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_Statistics: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_Error: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Structure_Touch: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Get_Factions: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_Get_Factions: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Player_SetPlayerInfo: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_NewEntityId: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Entity_Spawn: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_Player_DisconnectedWaiting: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_ChatMessage: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_ConsoleCommand: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_Structure_BlockStatistics: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_Structure_BlockStatistics: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_AlliancesAll: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Request_AlliancesFaction: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_AlliancesFaction: + obj = Serializer.Deserialize(ms); + break; + + case CmdId.Event_BannedPlayers: + obj = Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_InGameMessage_Faction: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_TraderNPCItemSold: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Player_GetAndRemoveInventory: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Player_GetAndRemoveInventory: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Playfield_Entity_List: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Entity_List: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Entity_Destroy2: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Entity_Export: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_ConsoleCommand: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Request_Entity_SetName: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_PdaStateChange: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + + case Eleon.Modding.CmdId.Event_GameEvent: + obj = ProtoBuf.Serializer.Deserialize(ms); + break; + } + } + + if (packageReceivedDelegate != null && bytesRead == len) + { + packageReceivedDelegate(this, new Package(cmd, clientId, seqNr, obj)); + } + } + } + catch (IOException e) + { + Console.WriteLine(string.Format("Connection closed while reading ({0})", e.Message)); + ModLoging.Log(string.Format("MDP: Connection closed while reading ({0})", e.Message), ModLoging.eTyp.Exception); + CloseConnection(); + } + catch (ObjectDisposedException e) + { + Console.WriteLine(string.Format("ObjectDisposed: Connection closed while reading ({0})", e.Message)); + ModLoging.Log(string.Format("MDP: ObjectDisposed: Connection closed while reading ({0})", e.Message), ModLoging.eTyp.Exception); + CloseConnection(); + } + catch (Exception e) + { + Console.WriteLine(string.Format("Exception while reading ({0})", e.Message)); + ModLoging.Log(string.Format("MDP: Exception while reading ({0})", e.Message), ModLoging.eTyp.Exception); + Console.WriteLine(e.GetType() + ": " + e.Message); + CloseConnection(); + } + } + + private void WriterThread(ModThreadHelper.Info ti) + { + + writerStream = new BinaryWriter(tcpClient.GetStream()); + List writingPackagesCopy = new List(); + + try + { + while (!ti.eventRunning.WaitOne(0)) + { + + evWritePackages.WaitOne(); + + // Do a copy of the list else we need to hold the lock when writing and this could block the main thread + writingPackagesCopy.Clear(); + lock (writingPackages) + { + writingPackagesCopy.AddRange(writingPackages); + writingPackages.Clear(); + } + + for (int i = 0; i < writingPackagesCopy.Count; i++) + { + + Package p = writingPackagesCopy[i]; + MemoryStream ms = new MemoryStream(); + switch (p.cmd) + { + + case Eleon.Modding.CmdId.Event_Player_Connected: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case Eleon.Modding.CmdId.Event_Player_Disconnected: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case Eleon.Modding.CmdId.Event_Player_ChangedPlayfield: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdPlayfield)p.data); + break; + + case Eleon.Modding.CmdId.Event_Player_Inventory: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Inventory)p.data); + break; + + case Eleon.Modding.CmdId.Event_Player_Info: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PlayerInfo)p.data); + break; + + case Eleon.Modding.CmdId.Event_Player_List: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdList)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_Info: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_GetInventory: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_SetInventory: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Inventory)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_AddItem: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdItemStack)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_Credits: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_SetCredits: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdCredits)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_AddCredits: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdCredits)p.data); + break; + + case Eleon.Modding.CmdId.Event_Player_Credits: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdCredits)p.data); + break; + + case Eleon.Modding.CmdId.Request_Entity_PosAndRot: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case Eleon.Modding.CmdId.Event_Entity_PosAndRot: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdPositionRotation)p.data); + break; + + case Eleon.Modding.CmdId.Request_InGameMessage_AllPlayers: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdMsgPrio)p.data); + break; + + case Eleon.Modding.CmdId.Request_InGameMessage_SinglePlayer: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdMsgPrio)p.data); + break; + + case Eleon.Modding.CmdId.Request_ShowDialog_SinglePlayer: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.DialogBoxData)p.data); + break; + + case Eleon.Modding.CmdId.Event_DialogButtonIndex: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdAndIntValue)p.data); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Loaded: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PlayfieldLoad)p.data); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Unloaded: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PlayfieldLoad)p.data); + break; + + case Eleon.Modding.CmdId.Request_Playfield_Stats: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PString)p.data); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Stats: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PlayfieldStats)p.data); + break; + + case Eleon.Modding.CmdId.Event_Dedi_Stats: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.DediStats)p.data); + break; + + case Eleon.Modding.CmdId.Event_Playfield_List: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PlayfieldList)p.data); + break; + + case Eleon.Modding.CmdId.Event_GlobalStructure_List: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.GlobalStructureList)p.data); + break; + + case Eleon.Modding.CmdId.Request_Entity_Teleport: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdPositionRotation)p.data); + break; + + case Eleon.Modding.CmdId.Request_GlobalStructure_Update: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PString)p.data); + break; + + case Eleon.Modding.CmdId.Event_Faction_Changed: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.FactionChangeInfo)p.data); + break; + + case CmdId.Request_Blueprint_Finish: + ProtoBuf.Serializer.Serialize(ms, (Id)p.data); + break; + + case CmdId.Request_Blueprint_Resources: + ProtoBuf.Serializer.Serialize(ms, (BlueprintResources)p.data); + break; + + case CmdId.Request_Player_ChangePlayerfield: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdPlayfieldPositionRotation)p.data); + break; + + case CmdId.Request_Entity_ChangePlayfield: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdPlayfieldPositionRotation)p.data); + break; + + case CmdId.Request_Entity_Destroy: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case CmdId.Request_Player_ItemExchange: + case CmdId.Event_Player_ItemExchange: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.ItemExchangeInfo)p.data); + break; + + case CmdId.Event_Statistics: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.StatisticsParam)p.data); + break; + + case CmdId.Event_Error: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.ErrorInfo)p.data); + break; + + case CmdId.Request_Structure_Touch: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case CmdId.Request_Get_Factions: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case CmdId.Event_Get_Factions: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.FactionInfoList)p.data); + break; + + case CmdId.Request_Player_SetPlayerInfo: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PlayerInfoSet)p.data); + break; + + case CmdId.Event_NewEntityId: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case CmdId.Request_Entity_Spawn: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.EntitySpawnInfo)p.data); + break; + + case CmdId.Event_Player_DisconnectedWaiting: + Serializer.Serialize(ms, (Id)p.data); + break; + + case CmdId.Event_ChatMessage: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.ChatInfo)p.data); + break; + + case CmdId.Request_ConsoleCommand: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PString)p.data); + break; + + case CmdId.Request_Structure_BlockStatistics: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case CmdId.Event_Structure_BlockStatistics: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdStructureBlockInfo)p.data); + break; + + case CmdId.Event_AlliancesAll: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.AlliancesTable)p.data); + break; + + case CmdId.Request_AlliancesFaction: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.AlliancesFaction)p.data); + break; + + case CmdId.Event_AlliancesFaction: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.AlliancesFaction)p.data); + break; + + case CmdId.Event_BannedPlayers: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.BannedPlayerData)p.data); + break; + + case CmdId.Request_InGameMessage_Faction: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdMsgPrio)p.data); + break; + + case Eleon.Modding.CmdId.Event_TraderNPCItemSold: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.TraderNPCItemSoldInfo)p.data); + break; + + case Eleon.Modding.CmdId.Request_Player_GetAndRemoveInventory: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Id)p.data); + break; + + case Eleon.Modding.CmdId.Event_Player_GetAndRemoveInventory: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.Inventory)p.data); + break; + + case Eleon.Modding.CmdId.Request_Playfield_Entity_List: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PString)p.data); + break; + + case Eleon.Modding.CmdId.Event_Playfield_Entity_List: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PlayfieldEntityList)p.data); + break; + + case Eleon.Modding.CmdId.Request_Entity_Destroy2: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdPlayfield)p.data); + break; + + case Eleon.Modding.CmdId.Request_Entity_Export: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.EntityExportInfo)p.data); + break; + + case Eleon.Modding.CmdId.Event_ConsoleCommand: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.ConsoleCommandInfo)p.data); + break; + + case Eleon.Modding.CmdId.Request_Entity_SetName: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.IdPlayfieldName)p.data); + break; + + case Eleon.Modding.CmdId.Event_PdaStateChange: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.PdaStateInfo)p.data); + break; + + case Eleon.Modding.CmdId.Event_GameEvent: + ProtoBuf.Serializer.Serialize(ms, (Eleon.Modding.GameEventData)p.data); + break; + + default: + break; + } + + writerStream.Write((byte)p.cmd); + writerStream.Write((Int32)p.clientId); + writerStream.Write((UInt16)p.seqNr); + byte[] data = ms.GetBuffer(); + + int len = (int)ms.Length; + writerStream.Write((Int32)len); + + int offset = 0; + do + { + int sendLen = Math.Min(8192, len); + writerStream.Write(data, offset, sendLen); + len -= sendLen; + offset += sendLen; + } while (len > 0); + } + writerStream.Flush(); + } + } + catch (IOException) + { + //Console.WriteLine(string.Format("Connection closed while writing ({0})", e.Message)); silent + CloseConnection(); + } + catch (ObjectDisposedException e) + { + Console.WriteLine(string.Format("ObjectDisposed: Connection closed while writing ({0})", e.Message)); + ModLoging.Log(string.Format("MDP: ObjectDisposed: Connection closed while writing ({0})", e.Message), ModLoging.eTyp.Exception); + CloseConnection(); + } + catch (Exception e) + { + Console.WriteLine(string.Format("Connection closed while writing ({0})", e.Message)); + ModLoging.Log(string.Format("MDP: Connection closed while writing ({0})", e.Message), ModLoging.eTyp.Exception); + Console.WriteLine(e.GetType() + ": " + e.Message); + CloseConnection(); + } + } + + void CloseConnection() + { + try + { + if (bDisconnected) + { + return; + } + bDisconnected = true; + + tcpClient.Close(); + + readerStream.Close(); + readerThread.eventRunning.Set(); + writerStream.Close(); + + writerThread.eventRunning.Set(); + evWritePackages.Set(); + + if (disconnectedDelegate != null) + { + disconnectedDelegate(this); + } + } + catch (Exception e) + { + ModLoging.Log_Exception(e, "MDP: Close Connection"); + } + //readerThread.WaitForEnd(); + //writerThread.WaitForEnd(); + } + + public void Close() + { + try + { + tcpClient.Close(); + + readerStream.Close(); + readerThread.eventRunning.Set(); + writerStream.Close(); + writerThread.eventRunning.Set(); + evWritePackages.Set(); + } + catch (Exception e) + { + ModLoging.Log_Exception(e, "MDP: Close"); + } + } + } +} diff --git a/modules/empyrion-bridge/bridge/EPMConnector/ModThreadHelper.cs b/modules/empyrion-bridge/bridge/EPMConnector/ModThreadHelper.cs new file mode 100644 index 00000000..24932472 --- /dev/null +++ b/modules/empyrion-bridge/bridge/EPMConnector/ModThreadHelper.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +public class ModThreadHelper +{ + + public class Info + { + public ThreadFunc threadDelegate; + public string name; + public Thread thread; + public ManualResetEvent eventRunning = new ManualResetEvent(false); + public void WaitForEnd() + { + eventRunning.Set(); + } + } + + public delegate void ThreadFunc(Info ti); + + public static Dictionary RunningThreads = new Dictionary(); + + public static Info StartThread(ThreadFunc nThreadStart, System.Threading.ThreadPriority nThreadPriority) + { + return StartThread(nThreadStart.Method.Name, nThreadStart, nThreadPriority); + } + + public static Info StartThread(string nName, ThreadFunc nThreadFunc, System.Threading.ThreadPriority nThreadPrio) + { + + Thread t = new Thread(new ParameterizedThreadStart(ThreadInvoke)); + t.Priority = nThreadPrio; + + Info threadInfo = new Info(); + threadInfo.threadDelegate = nThreadFunc; + threadInfo.thread = t; + + lock (RunningThreads) + { + while (RunningThreads.ContainsKey(nName)) + { + nName += "[new]"; + } + threadInfo.name = nName; + RunningThreads.Add(nName, threadInfo); + } + + ThreadPool.UnsafeQueueUserWorkItem(ThreadInvoke, threadInfo); + + return threadInfo; + } + + private static void ThreadInvoke(object ti) + { + + Info threadInfo = (Info)ti; + try + { + threadInfo.threadDelegate(threadInfo); + } + catch (Exception e) + { + Console.WriteLine(string.Format("Thread {0} exception:", threadInfo.name)); + Console.WriteLine(e.GetType() + ": " + e.Message); + EPMConnector.ModLoging.Log(string.Format("MTH: Thread {0} exception: {1}", threadInfo.name, e.GetType() + ": " + e.Message)); + + } + finally + { + //Console.WriteLine(string.Format("Thread {0} exited", threadInfo.name)); + lock (RunningThreads) + { + RunningThreads.Remove(threadInfo.name); + } + } + } +} diff --git a/modules/empyrion-bridge/bridge/Endpoints.cs b/modules/empyrion-bridge/bridge/Endpoints.cs new file mode 100644 index 00000000..a8ea052c --- /dev/null +++ b/modules/empyrion-bridge/bridge/Endpoints.cs @@ -0,0 +1,618 @@ +using System.Reflection; +using Eleon.Modding; + +namespace EmpyrionBridge; + +// EAH-parity verbs grouped by the Empyrion Admin Helper tab they map to. +// +// Categories: +// - Players : kick, ban/unban, give-item, give-credits, teleport, info, inventory +// - Factions : list, alliances, message +// - Chat : broadcast (server-wide), faction chat, single-player whisper +// - Structures : list global, set-name, transfer-faction (via console) +// - Playfields : list, load, wipe (via console), entity list +// - Console : raw console line, save, restart-warning +// +// Most actions either go through a typed CmdId (Request_Player_AddItem) or +// fall back to Request_ConsoleCommand with a templated string. Console +// commands are documented at https://empyrion.fandom.com/wiki/Server_commands +// — the wiki is mostly accurate as of Empyrion 1.16.x. +public static class EndpointMaps +{ + public static void MapAll(WebApplication app, BridgePool pool, TimeSpan timeout) + { + MapPlayers(app, pool, timeout); + MapFactions(app, pool, timeout); + MapChat(app, pool, timeout); + MapStructures(app, pool, timeout); + MapStructureOps(app, pool, timeout); + MapPlayfields(app, pool, timeout); + MapConsole(app, pool, timeout); + MapBans(app, pool, timeout); + MapEventStream(app, pool, timeout); + } + + // ---------- SSE event stream ---------- + // GET /api/v1/events?host=&port= — text/event-stream of unsolicited + // Event_* packages (chat, player join/leave, structure changes, etc). + // Each event line: data: { "cmd": "...", "seq": N, "data": {...} }\n\n + static void MapEventStream(WebApplication app, BridgePool pool, TimeSpan timeout) + { + app.MapGet("/api/v1/events", async (HttpContext ctx) => + { + var ep = Endpoint(ctx.Request); + if (ep is null) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsync("missing host or port"); return; } + var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); + + ctx.Response.Headers["Content-Type"] = "text/event-stream"; + ctx.Response.Headers["Cache-Control"] = "no-cache"; + ctx.Response.Headers["X-Accel-Buffering"] = "no"; + + var queue = new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false }; + var ch = System.Threading.Channels.Channel.CreateUnbounded(queue); + + using var sub = conn.Subscribe(p => + { + try + { + var payload = System.Text.Json.JsonSerializer.Serialize(new + { + cmd = p.cmd.ToString(), + cmdId = (int)p.cmd, + seq = (int)p.seqNr, + clientId = p.clientId, + data = p.data, + type = p.data?.GetType()?.Name, + }); + ch.Writer.TryWrite(payload); + } + catch { /* don't let serialization errors take down the connection */ } + }); + + // Initial heartbeat so client knows the stream is live. + await ctx.Response.WriteAsync("event: ready\ndata: {}\n\n"); + await ctx.Response.Body.FlushAsync(); + + var heartbeat = TimeSpan.FromSeconds(15); + using var hbCts = new CancellationTokenSource(); + var hbTask = Task.Run(async () => + { + try + { + while (!hbCts.IsCancellationRequested) + { + await Task.Delay(heartbeat, hbCts.Token); + ch.Writer.TryWrite(":heartbeat"); + } + } + catch (TaskCanceledException) { } + }); + + try + { + await foreach (var msg in ch.Reader.ReadAllAsync(ctx.RequestAborted)) + { + if (msg.StartsWith(":")) await ctx.Response.WriteAsync(msg + "\n\n"); + else await ctx.Response.WriteAsync("data: " + msg + "\n\n"); + await ctx.Response.Body.FlushAsync(ctx.RequestAborted); + } + } + catch (OperationCanceledException) { /* client went away */ } + finally + { + hbCts.Cancel(); + ch.Writer.TryComplete(); + } + }); + } + + static (string host, int port)? Endpoint(HttpRequest r) + { + var h = r.Query["host"].FirstOrDefault(); + var p = r.Query["port"].FirstOrDefault(); + if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null; + return (h, pi); + } + + static async Task<(BridgeConnection conn, IResult? err)> Connect(BridgePool pool, HttpRequest r) + { + var ep = Endpoint(r); + if (ep is null) return (null!, Results.BadRequest(new { error = "missing host or port" })); + return (await pool.GetAsync(ep.Value.host, ep.Value.port), null); + } + + // ---------- Players ---------- + static void MapPlayers(WebApplication app, BridgePool pool, TimeSpan timeout) + { + // Already covered in Program.cs: GET /api/v1/players, /api/v1/players/{id} + + app.MapPost("/api/v1/players/{id:int}/kick", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + using var sr = new StreamReader(r.Body); + var reason = (await sr.ReadToEndAsync()).Trim(); + if (string.IsNullOrEmpty(reason)) reason = "kicked by admin"; + // Use console verb — there is no typed Request_Kick. + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"kick {id} '{reason.Replace("'", "")}'")); + return Results.Json(new { ok = true, sent = $"kick {id} '{reason}'" }); + }); + + app.MapPost("/api/v1/players/{id:int}/ban", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + // Two duration shapes supported: + // ?dur= — passed verbatim to Empyrion (e.g. "30s", "5m", "7h", "30d", "1y", "0" for permanent) + // ?days=N — legacy integer days, kept for back-compat + // dur takes precedence when both are set. + var rawDur = r.Query["dur"].FirstOrDefault(); + string dur; + if (!string.IsNullOrWhiteSpace(rawDur)) + { + // Allow only [0-9smhdy] to keep injection out of the console verb. + var clean = new string(rawDur.Where(c => char.IsDigit(c) || c is 's' or 'm' or 'h' or 'd' or 'y').ToArray()); + if (string.IsNullOrEmpty(clean)) return Results.BadRequest(new { error = "dur must be like 30s / 5m / 7h / 30d / 1y / 0" }); + dur = clean; + } + else + { + var days = int.TryParse(r.Query["days"].FirstOrDefault(), out var d) ? d : 1; + dur = $"{days}d"; + } + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban add {id} {dur}")); + return Results.Json(new { ok = true, sent = $"ban add {id} {dur}" }); + }); + + app.MapPost("/api/v1/players/{id:int}/unban", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban remove {id}")); + return Results.Json(new { ok = true, sent = $"ban remove {id}" }); + }); + + // Set Empyrion's per-player role. Wraps the + // `setrole "" ` console verb — Empyrion's setrole + // looks up by name (or steam id), NOT by entity id, so we resolve + // the entity id → name via a quick PlayerInfo round-trip first. + // Sending `setrole ` silently fails (Empyrion + // returns no error but the permission level never changes). + app.MapPost("/api/v1/players/{id:int}/set-role", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var role = (r.Query["role"].FirstOrDefault() ?? "").Trim().ToLowerInvariant(); + if (role is not ("admin" or "gamemaster" or "moderator" or "player")) + return Results.BadRequest(new { error = "role must be one of: admin, gamemaster, moderator, player" }); + var info = await conn.RequestAsync( + CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout); + var name = (info?.playerName ?? "").Replace("\"", ""); + if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id }); + var cmd = $"setrole \"{name}\" {role}"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, sent = cmd, id, playerName = name, role }); + }); + + app.MapPost("/api/v1/players/{id:int}/give-credits", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + using var sr = new StreamReader(r.Body); + var body = (await sr.ReadToEndAsync()).Trim(); + if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (credits to add; negative subtracts)" }); + conn.SendFireAndForget(CmdId.Request_Player_AddCredits, new IdCredits { id = id, credits = amount }); + return Results.Json(new { ok = true, id, credits = amount }); + }); + + app.MapPost("/api/v1/players/{id:int}/set-credits", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + using var sr = new StreamReader(r.Body); + var body = (await sr.ReadToEndAsync()).Trim(); + if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (absolute credits)" }); + conn.SendFireAndForget(CmdId.Request_Player_SetCredits, new IdCredits { id = id, credits = amount }); + return Results.Json(new { ok = true, id, credits = amount }); + }); + + // Give an item to a player. We use Empyrion's `give "" ` + // console command rather than Request_Player_AddItem because the typed + // RPC requires a slotIdx — without one the API defaults to slot 0 in the + // toolbar, which is normally occupied (starter pickaxe etc), and Empyrion + // silently refuses the overwrite. The console command auto-places into + // the first free bag slot, the natural "give-me-this-item" behavior. + // Caller can still pass slotIdx explicitly to use the typed path. + app.MapPost("/api/v1/players/{id:int}/give-item", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + // Body: { "itemId": 4307, "count": 1, "slotIdx": 0|null, "ammo": 0, "decay": 0 } + var body = await JsonContent.ReadAsync(r); + if (body is null || body.itemId <= 0) return Results.BadRequest(new { error = "itemId required" }); + var count = body.count <= 0 ? 1 : body.count; + + // Path A: caller pinned a specific slot — go through typed RPC. + if (body.slotIdx.HasValue) + { + var stack = new ItemStack + { + id = body.itemId, + count = count, + slotIdx = body.slotIdx.Value, + ammo = body.ammo, + decay = body.decay, + }; + conn.SendFireAndForget(CmdId.Request_Player_AddItem, new IdItemStack { id = id, itemStack = stack }); + return Results.Json(new { ok = true, id, item = stack, via = "typed" }); + } + + // Path B (default): auto-place via console — needs the player NAME. + var info = await conn.RequestAsync( + CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout); + var name = (info?.playerName ?? "").Replace("\"", ""); + if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id }); + var cmd = $"give \"{name}\" {body.itemId} {count}"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, id, playerName = name, itemId = body.itemId, count, sent = cmd, via = "console" }); + }); + + app.MapGet("/api/v1/players/{id:int}/inventory", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var resp = await conn.RequestAsync(CmdId.Request_Player_GetInventory, CmdId.Event_Player_Inventory, new Id(id), timeout); + if (resp is null) return Results.NotFound(new { error = "no inventory for player", id }); + return Results.Json(resp); + }); + + app.MapPost("/api/v1/players/{id:int}/teleport", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + // Body: { "x":0, "y":0, "z":0, "playfield":"optional" } + var body = await JsonContent.ReadAsync(r); + if (body is null) return Results.BadRequest(new { error = "x/y/z required" }); + if (!string.IsNullOrEmpty(body.playfield)) + { + var pkt = new IdPlayfieldPositionRotation + { + id = id, + playfield = body.playfield, + pos = new PVector3 { x = body.x, y = body.y, z = body.z }, + rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz }, + }; + conn.SendFireAndForget(CmdId.Request_Player_ChangePlayerfield, pkt); + return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z }); + } + else + { + var pkt = new IdPositionRotation + { + id = id, + pos = new PVector3 { x = body.x, y = body.y, z = body.z }, + rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz }, + }; + conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt); + return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z }); + } + }); + } + + // ---------- Factions ---------- + static void MapFactions(WebApplication app, BridgePool pool, TimeSpan timeout) + { + // Already covered in Program.cs: GET /api/v1/factions + + app.MapGet("/api/v1/factions/alliances", async (HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var resp = await conn.RequestAsync(CmdId.Request_AlliancesAll, CmdId.Event_AlliancesAll, null, timeout); + return Results.Json(resp ?? new AlliancesTable()); + }); + + // POST /api/v1/factions/alliance-state — set alliance state between two factions + // state mapping: ally=0, neutral=1, hostile=2 (Empyrion console verb values) + app.MapPost("/api/v1/factions/alliance-state", async (HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var body = await JsonContent.ReadAsync(r); + if (body is null) return Results.BadRequest(new { error = "factionA, factionB, and state required" }); + int stateCode; + switch (body.state?.Trim().ToLowerInvariant()) + { + case "ally": stateCode = 0; break; + case "neutral": stateCode = 1; break; + case "hostile": stateCode = 2; break; + default: return Results.BadRequest(new { error = "state must be one of: ally, neutral, hostile" }); + } + var cmd = $"setalliancestate {body.factionA} {body.factionB} {stateCode}"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + } + + // ---------- Chat ---------- + static void MapChat(WebApplication app, BridgePool pool, TimeSpan timeout) + { + app.MapPost("/api/v1/chat/broadcast", async (HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + using var sr = new StreamReader(r.Body); + var msg = (await sr.ReadToEndAsync()).Trim(); + if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" }); + // prio: 0=info, 1=warning, 2=alert. Default to alert for visibility. + var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)2; + var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f; + conn.SendFireAndForget(CmdId.Request_InGameMessage_AllPlayers, new IdMsgPrio { id = 0, msg = msg, prio = prio, time = time }); + return Results.Json(new { ok = true, msg, prio, time }); + }); + + app.MapPost("/api/v1/chat/whisper/{playerId:int}", async (int playerId, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + using var sr = new StreamReader(r.Body); + var msg = (await sr.ReadToEndAsync()).Trim(); + if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" }); + var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)0; + var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f; + conn.SendFireAndForget(CmdId.Request_InGameMessage_SinglePlayer, new IdMsgPrio { id = playerId, msg = msg, prio = prio, time = time }); + return Results.Json(new { ok = true, playerId, msg }); + }); + + app.MapPost("/api/v1/chat/faction/{factionId:int}", async (int factionId, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + using var sr = new StreamReader(r.Body); + var msg = (await sr.ReadToEndAsync()).Trim(); + if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" }); + conn.SendFireAndForget(CmdId.Request_InGameMessage_Faction, new IdMsgPrio { id = factionId, msg = msg, prio = 2, time = 8.0f }); + return Results.Json(new { ok = true, factionId, msg }); + }); + } + + // ---------- Structures ---------- + static void MapStructures(WebApplication app, BridgePool pool, TimeSpan timeout) + { + // Empyrion's pattern: send Request_GlobalStructure_Update, the server + // pushes Event_GlobalStructure_List back. We correlate by expected cmd. + app.MapGet("/api/v1/structures", async (HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var resp = await conn.RequestAsync(CmdId.Request_GlobalStructure_Update, CmdId.Event_GlobalStructure_List, new PString(""), timeout); + return Results.Json(new + { + playfields = resp?.globalStructures?.Keys ?? Enumerable.Empty(), + count = resp?.globalStructures?.Sum(kv => kv.Value?.Count ?? 0) ?? 0, + structures = resp?.globalStructures?.SelectMany(kv => (kv.Value ?? new List()).Select(gs => new + { + playfield = kv.Key, + id = gs.id, + name = gs.name, + type = gs.type, + pos = new[] { gs.pos.x, gs.pos.y, gs.pos.z }, + factionId = gs.factionId, + factionGroup = gs.factionGroup, + coreType = gs.coreType, + classNr = gs.classNr, + blocks = gs.cntBlocks, + devices = gs.cntDevices, + triangles = gs.cntTriangles, + lights = gs.cntLights, + fuel = gs.fuel, + pilotId = gs.pilotId, + powered = gs.powered, + })) ?? Enumerable.Empty(), + }); + }); + + app.MapPost("/api/v1/structures/{id:int}/rename", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + using var sr = new StreamReader(r.Body); + var newName = (await sr.ReadToEndAsync()).Trim(); + if (string.IsNullOrEmpty(newName)) return Results.BadRequest(new { error = "empty name" }); + // The Mod API has Request_Entity_SetName but it requires playfield+name, not just id. + // Easier path: console "rename ID NEW_NAME" + var safe = newName.Replace("\"", ""); + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"rename '{id}' '{safe}'")); + return Results.Json(new { ok = true, id, name = newName }); + }); + + app.MapPost("/api/v1/structures/{id:int}/destroy", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + conn.SendFireAndForget(CmdId.Request_Entity_Destroy, new Id(id)); + return Results.Json(new { ok = true, id }); + }); + } + + // ---------- Structure Operations ---------- + static void MapStructureOps(WebApplication app, BridgePool pool, TimeSpan timeout) + { + // POST /api/v1/structures/{id:int}/faction — transfer structure to a faction + app.MapPost("/api/v1/structures/{id:int}/faction", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var body = await JsonContent.ReadAsync(r); + if (body is null) return Results.BadRequest(new { error = "factionId required" }); + var cmd = $"faction structure {id} {body.factionId}"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + + // POST /api/v1/structures/{id:int}/heal — heal structure to 100% + app.MapPost("/api/v1/structures/{id:int}/heal", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var cmd = $"heal {id} 100"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + + // POST /api/v1/structures/{id:int}/refuel — refuel structure + app.MapPost("/api/v1/structures/{id:int}/refuel", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var cmd = $"refuel {id}"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + + // POST /api/v1/structures/{id:int}/set-core — set core type + app.MapPost("/api/v1/structures/{id:int}/set-core", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var body = await JsonContent.ReadAsync(r); + if (body is null || body.mode is null) return Results.BadRequest(new { error = "mode required (admin|player|hardcore)" }); + var mode = body.mode.Trim().ToLowerInvariant(); + if (mode is not ("admin" or "player" or "hardcore")) + return Results.BadRequest(new { error = "mode must be one of: admin, player, hardcore" }); + var cmd = $"setadmincore {id} {mode}"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + + // POST /api/v1/structures/{id:int}/move — move structure (same playfield or cross-playfield) + app.MapPost("/api/v1/structures/{id:int}/move", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var body = await JsonContent.ReadAsync(r); + if (body is null) return Results.BadRequest(new { error = "x/y/z required" }); + if (!string.IsNullOrEmpty(body.playfield)) + { + var pkt = new IdPlayfieldPositionRotation + { + id = id, + playfield = body.playfield, + pos = new PVector3 { x = body.x, y = body.y, z = body.z }, + rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz }, + }; + conn.SendFireAndForget(CmdId.Request_Entity_ChangePlayfield, pkt); + return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z }); + } + else + { + var pkt = new IdPositionRotation + { + id = id, + pos = new PVector3 { x = body.x, y = body.y, z = body.z }, + rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz }, + }; + conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt); + return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z }); + } + }); + + // POST /api/v1/structures/{id:int}/save-as-blueprint — save structure as blueprint + app.MapPost("/api/v1/structures/{id:int}/save-as-blueprint", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var body = await JsonContent.ReadAsync(r); + var cmd = string.IsNullOrEmpty(body?.name) ? $"bp {id}" : $"bp {id} \"{body.name.Replace("\"", "")}\""; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + + // POST /api/v1/structures/{id:int}/regenerate — regenerate a structure (optionally with faction override) + app.MapPost("/api/v1/structures/{id:int}/regenerate", async (int id, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var body = await JsonContent.ReadAsync(r); + var cmd = string.IsNullOrEmpty(body?.faction) ? $"regenerate {id}" : $"regenerate {id} {body.faction}"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + } + + // ---------- Playfields ---------- + static void MapPlayfields(WebApplication app, BridgePool pool, TimeSpan timeout) + { + // Already covered in Program.cs: GET /api/v1/playfields, /api/v1/playfields/{name}/entities + + app.MapGet("/api/v1/playfields/{name}/stats", async (string name, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var resp = await conn.RequestAsync(CmdId.Request_Playfield_Stats, CmdId.Event_Playfield_Stats, new PString(name), timeout); + return Results.Json(resp ?? new PlayfieldStats { playfield = name }); + }); + + app.MapPost("/api/v1/playfields/{name}/wipe", async (string name, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + // wipe playfield POI/all/terrain/deposit + var what = r.Query["what"].FirstOrDefault() ?? "poi"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"wipe '{name}' {what}")); + return Results.Json(new { ok = true, playfield = name, scope = what }); + }); + + // POST /api/v1/playfields/{name}/load — load a playfield + app.MapPost("/api/v1/playfields/{name}/load", async (string name, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var cmd = $"loadpf '{name}'"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + + // POST /api/v1/playfields/{name}/unload — unload a playfield + app.MapPost("/api/v1/playfields/{name}/unload", async (string name, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var cmd = $"unloadpf '{name}'"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + + // POST /api/v1/playfields/{name}/reset — full playfield reset (aggressive) + app.MapPost("/api/v1/playfields/{name}/reset", async (string name, HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var cmd = $"resetplayfield '{name}'"; + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd)); + return Results.Json(new { ok = true, command = cmd }); + }); + } + + // ---------- Console ---------- + static void MapConsole(WebApplication app, BridgePool pool, TimeSpan timeout) + { + // Already covered in Program.cs: POST /api/v1/console (raw command) + + app.MapPost("/api/v1/save", async (HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + // Empyrion's "saveandexit" command saves and shuts down — we don't want that. + // The "save" alias triggers an immediate save without exit; exists in 1.16+. + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString("save")); + return Results.Json(new { ok = true, sent = "save" }); + }); + + app.MapPost("/api/v1/dedi/stats", async (HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var resp = await conn.RequestAsync(CmdId.Request_Dedi_Stats, CmdId.Event_Dedi_Stats, null, timeout); + return Results.Json(resp ?? new DediStats()); + }); + } + + // ---------- Bans ---------- + static void MapBans(WebApplication app, BridgePool pool, TimeSpan timeout) + { + app.MapGet("/api/v1/banned", async (HttpRequest r) => + { + var (conn, err) = await Connect(pool, r); if (err != null) return err; + var resp = await conn.RequestAsync(CmdId.Request_GetBannedPlayers, CmdId.Event_BannedPlayers, null, timeout); + return Results.Json(resp ?? new BannedPlayerData()); + }); + } +} + +public class GiveItemReq { public int itemId { get; set; } public int count { get; set; } public byte? slotIdx { get; set; } public int ammo { get; set; } public int decay { get; set; } } +public class TeleportReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } } +public class FactionReq { public int factionId { get; set; } } +public class SetCoreReq { public string? mode { get; set; } } +public class MoveStructureReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } } +public class SaveBlueprintReq { public string? name { get; set; } } +public class AllianceStateReq { public int factionA { get; set; } public int factionB { get; set; } public string? state { get; set; } } +public class RegenerateReq { public string? faction { get; set; } } + +internal static class JsonContent +{ + public static async Task ReadAsync(HttpRequest r) where T : class + { + try { return await r.ReadFromJsonAsync(); } + catch { return null; } + } +} diff --git a/modules/empyrion-bridge/bridge/Program.cs b/modules/empyrion-bridge/bridge/Program.cs new file mode 100644 index 00000000..92f5c6af --- /dev/null +++ b/modules/empyrion-bridge/bridge/Program.cs @@ -0,0 +1,169 @@ +using System.Reflection; +using Eleon.Modding; +using EmpyrionBridge; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.Logging.AddSimpleConsole(o => +{ + o.SingleLine = true; + o.TimestampFormat = "HH:mm:ss "; +}); +builder.Services.AddSingleton(); +builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("BRIDGE_URLS") ?? "http://0.0.0.0:8090"); + +// Eleon's protobuf-net contracts (PlayerInfo, FactionInfo, etc.) expose +// their data as PUBLIC FIELDS, not properties. System.Text.Json ignores +// public fields by default, which made `Results.Json(playerInfo)` emit +// `{}` even though the deserialized object had real data inside. Turning +// IncludeFields on (globally for every endpoint that doesn't project +// explicitly) fixes the player-list, player-info, dedi-stats, and any +// future endpoint that returns a raw mod-API DTO. +// +// AllowNamedFloatingPointLiterals lets us serialize `NaN` / `Infinity` +// as the JSON string forms. Empyrion's mod API hands those back for +// uninitialized float fields (heading, posY, etc.) and STJ will throw +// at serialization time without this — breaking the entire response. +builder.Services.ConfigureHttpJsonOptions(o => +{ + o.SerializerOptions.IncludeFields = true; + o.SerializerOptions.NumberHandling = + System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals; +}); + +var app = builder.Build(); +var pool = app.Services.GetRequiredService(); +var requestTimeout = TimeSpan.FromSeconds(double.TryParse(Environment.GetEnvironmentVariable("BRIDGE_REQUEST_TIMEOUT_S"), out var t) ? t : 8); +var apiToken = (Environment.GetEnvironmentVariable("BRIDGE_API_TOKEN") ?? "").Trim(); +if (string.IsNullOrEmpty(apiToken)) +{ + app.Logger.LogWarning("BRIDGE_API_TOKEN is not set — auth is OFF. Anyone reaching the bridge can drive it. Suitable for loopback-only deployments."); +} + +// Bearer-token middleware. Loopback (127.0.0.1, ::1) and unix sockets are +// always allowed without a token, matching the panel's existing pattern. +// Health endpoint always allowed. +app.Use(async (ctx, next) => +{ + if (string.IsNullOrEmpty(apiToken)) { await next(); return; } + if (ctx.Request.Path.StartsWithSegments("/healthz")) { await next(); return; } + var ip = ctx.Connection.RemoteIpAddress; + if (ip is not null && (System.Net.IPAddress.IsLoopback(ip))) { await next(); return; } + var auth = ctx.Request.Headers["Authorization"].FirstOrDefault() ?? ""; + if (auth == $"Bearer {apiToken}") { await next(); return; } + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsJsonAsync(new { error = "unauthorized" }); +}); + +string? Q(HttpRequest r, string k) => r.Query[k].FirstOrDefault(); + +(string host, int port)? Endpoint(HttpRequest r) +{ + var h = Q(r, "host"); + var p = Q(r, "port"); + if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null; + return (h, pi); +} + +app.MapGet("/healthz", () => Results.Json(new +{ + ok = true, + version = typeof(Program).Assembly.GetCustomAttribute()?.InformationalVersion, + pid = Environment.ProcessId, + authMode = string.IsNullOrEmpty(apiToken) ? "none" : "bearer-token", +})); + +app.MapGet("/api/v1/factions", async (HttpRequest r) => +{ + var ep = Endpoint(r); + if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); + var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); + var resp = await conn.RequestAsync(CmdId.Request_Get_Factions, CmdId.Event_Get_Factions, new Id(1), requestTimeout); + return Results.Json(new + { + host = ep.Value.host, + port = ep.Value.port, + factions = resp?.factions?.Select(f => new + { + origin = f.origin, + factionId = f.factionId, + name = f.name, + abbrev = f.abbrev, + }) ?? Enumerable.Empty(), + }); +}); + +app.MapGet("/api/v1/players", async (HttpRequest r) => +{ + var ep = Endpoint(r); + if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); + var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); + var resp = await conn.RequestAsync(CmdId.Request_Player_List, CmdId.Event_Player_List, null, requestTimeout); + return Results.Json(new + { + host = ep.Value.host, + port = ep.Value.port, + playerIds = resp?.list ?? new List(), + count = resp?.list?.Count ?? 0, + }); +}); + +app.MapGet("/api/v1/players/{id:int}", async (int id, HttpRequest r) => +{ + var ep = Endpoint(r); + if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); + var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); + var resp = await conn.RequestAsync(CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), requestTimeout); + if (resp is null) return Results.NotFound(new { error = "no info for player", id }); + return Results.Json(resp); +}); + +app.MapGet("/api/v1/playfields", async (HttpRequest r) => +{ + var ep = Endpoint(r); + if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); + var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); + var resp = await conn.RequestAsync(CmdId.Request_Playfield_List, CmdId.Event_Playfield_List, null, requestTimeout); + return Results.Json(new + { + host = ep.Value.host, + port = ep.Value.port, + playfields = resp?.playfields ?? new List(), + }); +}); + +app.MapGet("/api/v1/playfields/{name}/entities", async (string name, HttpRequest r) => +{ + var ep = Endpoint(r); + if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); + var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); + var resp = await conn.RequestAsync(CmdId.Request_Playfield_Entity_List, CmdId.Event_Playfield_Entity_List, new PString(name), requestTimeout); + return Results.Json(new + { + host = ep.Value.host, + port = ep.Value.port, + playfield = name, + entities = resp?.entities ?? new List(), + }); +}); + +app.MapPost("/api/v1/console", async (HttpRequest r) => +{ + var ep = Endpoint(r); + if (ep is null) return Results.BadRequest(new { error = "missing host or port" }); + using var sr = new StreamReader(r.Body); + var body = await sr.ReadToEndAsync(); + var line = body.Trim(); + if (string.IsNullOrEmpty(line)) return Results.BadRequest(new { error = "empty body" }); + var conn = await pool.GetAsync(ep.Value.host, ep.Value.port); + conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(line)); + return Results.Json(new { sent = line }); +}); + +EndpointMaps.MapAll(app, pool, requestTimeout); + +app.Logger.LogInformation("panel-empyrion-bridge listening; request_timeout={Timeout}s authMode={Mode}", + requestTimeout.TotalSeconds, string.IsNullOrEmpty(apiToken) ? "none" : "bearer"); +app.Run(); + +public partial class Program { } diff --git a/modules/empyrion/Dockerfile b/modules/empyrion/Dockerfile new file mode 100644 index 00000000..ec162d82 --- /dev/null +++ b/modules/empyrion/Dockerfile @@ -0,0 +1,68 @@ +# panel-native Empyrion runtime image. +# +# Empyrion's dedicated server is a Windows-only Unity build, so we bring +# along just enough Wine + Xvfb to host it headlessly. SteamCMD is NOT +# installed here — the panel's generic updater sidecar uses the official +# steamcmd image and writes into our /game volume. +# +# Expected runtime layout inside the container: +# /game — SteamCMD-installed Windows build (panel volume) +# /game/DedicatedServer/DedicatedServer64/ +# EmpyrionDedicated.exe — the dedicated server binary +# /game-saves — saves, dedicated.yaml, admin files +# /entrypoint.sh — boot Xvfb, launch exe under Wine + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + WINEDEBUG=-all \ + WINEPREFIX=/home/panel/.wine \ + DISPLAY=:99 + +# Wine pulls in ~500 MB of deps; keep to the 64-bit stack (Empyrion's +# dedicated binary is x86_64). winetricks isn't strictly required — the +# game's Mono runtime ships with the install — but it's 8 MB and saves +# future debugging headaches. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + xvfb \ + xauth \ + wine64 \ + wine \ + winbind \ + cabextract \ + procps \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Unprivileged user owning /game + /game-saves. The SteamCMD sidecar +# writes as root (official image runs as root); we chown once at start +# time so the long-running game process stays non-root. +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves /home/panel + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Pre-staged EPM mod (TCP relay for the Eleon Mod API). Entrypoint copies +# this into /game/Content/Mods/EPM/ on first boot so every empyrion +# instance comes EAH-bridge-ready out of the box. See modules/empyrion/epm-mod/. +COPY epm-mod/ /opt/panel-epm/ + +# Empyrion default ports (see module.yaml comment for reference). +EXPOSE 30000/udp 30001/udp 30002/udp 30003/udp +EXPOSE 30004/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/empyrion/README.md b/modules/empyrion/README.md new file mode 100644 index 00000000..ad453c2c --- /dev/null +++ b/modules/empyrion/README.md @@ -0,0 +1,35 @@ +# Empyrion - Galactic Survival + +Panel-native dedicated server module for Empyrion. + +## How it works + +- Image: `panel-empyrion:latest` — built from `./Dockerfile` (Debian 12 + Wine + Xvfb + tini). +- Game files: downloaded by the panel's SteamCMD updater sidecar into the `panel--game` named volume. Because Empyrion's dedicated server ships only as a Windows binary, the updater uses `+@sSteamCmdForcePlatformType windows` (declared in `module.yaml` via `platform: windows`). +- Entrypoint: boots Xvfb, initializes a Wine prefix on first run, then runs `EmpyrionDedicated.exe` under `wine64` with `-batchmode -nographics -logFile -` so Unity pipes its log to stdout. + +## First-run flow + +1. Build the image (one-time): + ```bash + docker build -t panel-empyrion:latest modules/empyrion + ``` +2. Create an instance in the panel (defaults are fine). +3. Click **Update** on the instance — the SteamCMD sidecar downloads ~3.5 GB of Windows build into the `game` volume. +4. Click **Start** — the entrypoint seeds `dedicated.yaml` from the shipped default, starts Xvfb, bootstraps Wine, and launches the server. + +First boot is slow (Wine prefix init + Empyrion's own first-run map generation). Subsequent starts are 30–60s. + +## Ports + +| Name | Proto | Default | Notes | +|--------|-------|---------|-----------------------------| +| game | UDP | 30000 | Srv_Port — main traffic | +| query | UDP | 30001 | Steam query | +| client | UDP | 30002 | Client connect | +| eac | UDP | 30003 | EasyAntiCheat | +| csw | TCP | 30004 | CSW API (internal for now) | + +## No RCON (yet) + +Empyrion has no native RCON protocol. The in-game `admin` console commands can only be issued from a connected client. Third-party tools (Empyrion Web Admin / EAH) fill this gap and can be layered on later via plugins — not wired into this module for v1. diff --git a/modules/empyrion/entrypoint.sh b/modules/empyrion/entrypoint.sh new file mode 100644 index 00000000..acb39f92 --- /dev/null +++ b/modules/empyrion/entrypoint.sh @@ -0,0 +1,220 @@ +#!/bin/bash +# panel-native Empyrion entrypoint. +# +# Contract: +# /game — SteamCMD-installed Windows build of Empyrion (volume, +# populated by the panel's SteamCMD updater sidecar). +# If missing, exit with EX_CONFIG and a helpful message. +# /game-saves — dedicated.yaml + Saves/ tree (volume). +# +# Runs as root for its opening stage (fix up ownership after the SteamCMD +# sidecar wrote as root, nuke any stale X lock from a previous crash), +# then drops to the `panel` user for Wine + Xvfb + the game. Wine refuses +# to run as root, so the drop is load-bearing. +# +# On every start we verify the binary is there, start Xvfb, boot a minimal +# Wine prefix if needed, then exec EmpyrionDedicated.exe under Wine with +# stdout/stderr line-buffered so docker logs → agent → panel console +# delivers live output. + +set -euo pipefail + +log() { printf '[panel-empyrion] %s\n' "$*"; } + +# ----- stage 1: root-only bootstrap (runs once per container start) ----- +if [[ "$(id -u)" = "0" ]]; then + # SteamCMD writes as root in its sidecar; chown once so the panel user + # (UID 1000) can read/write the game dir without fiddling with perms. + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + + # Nuke any stale Xvfb state from a previous boot. The container's /tmp + # persists across restarts (same writable layer), so /tmp/.X99-lock plus + # /tmp/.X11-unix/X99 left by a SIGKILL'd Xvfb make the next start die + # with "_XSERVTransMakeAllCOTSServerListeners: server already running". + # Single rm wasn't catching every variant (verified live 2026-04-29); + # nuking the whole dir + re-creating with the right mode is bulletproof. + pkill -9 Xvfb 2>/dev/null || true + rm -rf /tmp/.X11-unix /tmp/.X99-lock 2>/dev/null || true + mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix && chown root:root /tmp/.X11-unix 2>/dev/null || true + + # Empyrion writes build-stamped logs to Z:\Logs\ (= /Logs/... + # because Wine maps Z:\ to /). Without this symlink panel gets an + # UnauthorizedAccessException on first boot. Pointing it into the save + # volume keeps logs persistent + visible in the Files tab. + mkdir -p /game-saves/EmpyrionLogs + chown -R panel:panel /game-saves/EmpyrionLogs + if [[ ! -e /Logs || -L /Logs ]]; then + ln -sfn /game-saves/EmpyrionLogs /Logs + fi + + # Re-exec as `panel` user. setpriv comes with util-linux in debian-slim. + log "dropping privileges to panel (UID 1000)" + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env \ + HOME=/home/panel \ + WINEPREFIX=/home/panel/.wine \ + WINEDEBUG=-all \ + SRV_PORT="${SRV_PORT:-30000}" \ + EAC_PORT="${EAC_PORT:-30003}" \ + CSW_PORT="${CSW_PORT:-30004}" \ + EAH_API_PORT="${EAH_API_PORT:-30007}" \ + SCENARIO="${SCENARIO:-}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +DEDI_DIR="${GAME_DIR}/DedicatedServer" +DEDI_EXE="${DEDI_DIR}/EmpyrionDedicated.exe" +CONFIG="${SAVE_DIR}/dedicated.yaml" +# Empyrion ships its default dedicated.yaml at the game root. +SHIPPED_CONFIG="${GAME_DIR}/dedicated.yaml" + +# --- sanity: Windows build installed? --- +if [[ ! -f "${DEDI_EXE}" ]]; then + log "ERROR: ${DEDI_EXE} not found." + log " Game files haven't been downloaded yet." + log " Run 'Update' on this server in the panel — that launches a" + log " SteamCMD sidecar which installs Empyrion (Windows build) into" + log " this volume via +@sSteamCmdForcePlatformType windows." + exit 78 # EX_CONFIG +fi + +# --- dedicated.yaml: copy shipped default on first run --- +if [[ ! -s "${CONFIG}" && -s "${SHIPPED_CONFIG}" ]]; then + log "no panel config found — seeding ${CONFIG} from ${SHIPPED_CONFIG}" + cp -f "${SHIPPED_CONFIG}" "${CONFIG}" +fi +if [[ ! -s "${CONFIG}" ]]; then + log "WARN: no dedicated.yaml found in game install. Empyrion will create" + log " one with defaults on first boot. Edit via the Files tab." +fi + +# Symlink so the binary finds the config at its expected relative path. +# Empyrion reads dedicated.yaml from the game root (same dir as the .exe's +# -dedicated argument resolves). +ln -sf "${CONFIG}" "${GAME_DIR}/dedicated.yaml" + +# --- patch dedicated.yaml ports from env on every boot --- +# Empyrion reads Srv_Port / EAC_Port / Tel_Port from dedicated.yaml. Without +# this, multiple instances on the same agent all bind 30000-30004 and only +# one is reachable. SRV_PORT comes from the panel allocator via the +# manifest's port→env mapping (`env: SRV_PORT` in module.yaml). yaml is +# whitespace-sensitive but these keys are a flat top-level "Key: NNN" so +# sed is safe. +if [[ -s "${CONFIG}" ]]; then + log "patching dedicated.yaml (Srv_Port=${SRV_PORT}, EAC_Port=${EAC_PORT})" + sed -i -E \ + -e "s|^([[:space:]]*Srv_Port:[[:space:]]*)[0-9]+|\1${SRV_PORT}|" \ + -e "s|^([[:space:]]*EAC_Port:[[:space:]]*)[0-9]+|\1${EAC_PORT}|" \ + "${CONFIG}" +fi + +# --- scenario selection --- +# SCENARIO env names a folder under /game/Content/Scenarios/. The empyrion- +# shipped default is "Default Random". Reforged Eden / Reforged Eden 2 / +# Project Eden need to be present in /game/Content/Scenarios// first +# (drop via the panel's Files tab, or future workshop-subscribe feature). +# This block keeps dedicated.yaml's CustomScenario in sync with the env. +SCENARIO="${SCENARIO:-}" +if [[ -n "${SCENARIO}" && -s "${CONFIG}" ]]; then + if [[ ! -d "${GAME_DIR}/Content/Scenarios/${SCENARIO}" ]]; then + log "WARN: scenario folder '${SCENARIO}' not found at ${GAME_DIR}/Content/Scenarios/${SCENARIO}" + log " The panel UI Files tab can drop scenario contents there. For" + log " Reforged Eden 2 (Workshop ID 3041847672), unzip into:" + log " /game/Content/Scenarios/Reforged Eden 2/" + log " Booting with Default for now." + else + log "selecting scenario: ${SCENARIO}" + if grep -qE '^[[:space:]]*CustomScenario:' "${CONFIG}"; then + sed -i -E "s|^([[:space:]]*CustomScenario:[[:space:]]*).*|\1${SCENARIO}|" "${CONFIG}" + else + # Insert under GameConfig: at the top, ahead of dedicated game block. + printf '\nGameConfig:\n CustomScenario: %s\n' "${SCENARIO}" >> "${CONFIG}" + fi + fi +fi + +# --- stage EPM mod (TCP relay for Eleon Mod API) on first boot --- +# Pre-staged at /opt/panel-epm in the image. Copy into the live game +# Content/Mods/EPM/ if it isn't there yet. This makes the panel-empyrion- +# bridge sidecar work out-of-the-box: every new empyrion instance comes +# with EPM listening on EAH_API_PORT. +EPM_SRC=/opt/panel-epm +EPM_DST="${GAME_DIR}/Content/Mods/EPM" +if [[ -d "${EPM_SRC}" && ! -f "${EPM_DST}/EmpyrionNetworkRelayMod.dll" ]]; then + log "staging EPM mod into ${EPM_DST}" + mkdir -p "${EPM_DST}" + cp -a "${EPM_SRC}/." "${EPM_DST}/" + # If a panel-allocated EAH_API_PORT is set, patch EPM's Settings.yaml + # so its TCP listener binds the right port for this instance. + if [[ -n "${EAH_API_PORT:-}" && -f "${EPM_DST}/Settings.yaml" ]]; then + sed -i -E "s|^GameServerApiPort:[[:space:]]*[0-9]+|GameServerApiPort: ${EAH_API_PORT}|" \ + "${EPM_DST}/Settings.yaml" + fi +fi +# Re-patch port even if EPM was already staged, in case panel reallocated. +if [[ -f "${EPM_DST}/Settings.yaml" && -n "${EAH_API_PORT:-}" ]]; then + sed -i -E "s|^GameServerApiPort:[[:space:]]*[0-9]+|GameServerApiPort: ${EAH_API_PORT}|" \ + "${EPM_DST}/Settings.yaml" +fi + +# --- prep saves dir (Empyrion writes under /game/Saves by default) --- +# Redirect Saves into the save volume via a symlink so steam validate +# can't clobber world data. +mkdir -p "${SAVE_DIR}/Saves" +if [[ -d "${GAME_DIR}/Saves" && ! -L "${GAME_DIR}/Saves" ]]; then + # Steam shipped an empty Saves dir; replace with symlink. + rm -rf "${GAME_DIR}/Saves" +fi +ln -sfn "${SAVE_DIR}/Saves" "${GAME_DIR}/Saves" + +# --- Wine + Xvfb via xvfb-run (auto-allocates a free display) --- +# +# We previously hand-started Xvfb on :99. That broke once the host had +# its OWN Xvfb on :99 — the empyrion container uses network_mode: host, +# which shares the network namespace, and X server abstract Unix sockets +# (`@/tmp/.X11-unix/X99`) are namespaced by network ns. Result: container +# Xvfb fails with "_XSERVTransMakeAllCOTSServerListeners: server already +# running" and Wine then dies "Authorization required, but no auth +# protocol specified" → Unity exits "Failed to create batch mode window: +# Success." (verified 2026-04-29). +# +# `xvfb-run -a` finds a free display number, starts Xvfb with proper +# xauth set up, exports DISPLAY to its child, and cleans up on exit. +# Bulletproof against host's existing Xvfb instances. + +# --- Wine prefix bootstrap (first boot only). xvfb-run handles the X +# server for the actual run; for prefix init we don't strictly need +# X but wineboot complains less when one is available. --- +if [[ ! -d "${WINEPREFIX}" || ! -f "${WINEPREFIX}/system.reg" ]]; then + log "initializing Wine prefix at ${WINEPREFIX}" + xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true + wineserver -w || true +fi + +# --- go --- +cd "${GAME_DIR}" +log "starting Empyrion Dedicated Server" +log " game dir: ${GAME_DIR}" +log " save dir: ${SAVE_DIR}" +log " config: ${CONFIG}" +log " binary: ${DEDI_EXE}" + +# Shutdown handler: forward SIGTERM (xvfb-run forwards to wine; wine's +# Unity hook persists state on quit). +trap 'log "SIGTERM received; stopping server"; wineserver -k 2>/dev/null || true; exit 0' TERM INT + +# -batchmode / -nographics make Unity skip the renderer; -logFile - pipes +# the Unity log to our stdout so the panel console sees everything. +# `xvfb-run -a` picks a free display, sets up xauth, and exports DISPLAY. +# `--server-args="-screen 0 1024x768x24"` matches our previous Xvfb args. +exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ + wine "${DEDI_EXE}" \ + -batchmode \ + -nographics \ + -logFile - \ + -dedicated dedicated.yaml \ + -startDedicated diff --git a/modules/empyrion/epm-mod/EmpyrionNetworkRelayMod_Info.yaml b/modules/empyrion/epm-mod/EmpyrionNetworkRelayMod_Info.yaml new file mode 100644 index 00000000..ecf63d4a --- /dev/null +++ b/modules/empyrion/epm-mod/EmpyrionNetworkRelayMod_Info.yaml @@ -0,0 +1,8 @@ +# Empyrion mod manifest. Tells Empyrion to load this mod into the dedicated +# server process (Game_Start hook), where it opens a TCP listener on the +# port configured in Settings.yaml — 7028 in our case (panel-allocated). +Name: EPM +Description: Empyrion Network Relay - exposes Mod API over TCP for panel/EAH/etc +Author: W3DG (open-source via MichaelGoulding/sample-empyrion-mod) +Version: 1.0 +ModTargets: Dedi diff --git a/modules/empyrion/epm-mod/README.md b/modules/empyrion/epm-mod/README.md new file mode 100644 index 00000000..3997695e --- /dev/null +++ b/modules/empyrion/epm-mod/README.md @@ -0,0 +1,43 @@ +# EPM mod artifacts + +Drop-in `Content/Mods/EPM/` for Empyrion. Loaded by the dedicated server's +mod loader; opens a TCP listener on `Settings.yaml.GameServerApiPort` (we +ship `0.0.0.0:7028`, panel-allocated to whatever `EAH_API_PORT` env says). + +## Files + +- `EmpyrionNetworkRelayMod.dll` — the mod (`ModTargets: Dedi`). Compiled + from `EmpyrionNetworkConnectedMods/sample-empyrion-mod/Empyrion Mod/` + with one tweak: `Configuration.cs` was rewritten to manually parse + `Settings.yaml` (key: value lines) instead of using `YamlDotNet`. Reason: + current Empyrion 1.16.x ships `YamlAssembly.dll` rather than the old + `Assembly-CSharp-firstpass.dll` that bundled `YamlDotNet`, so the + upstream binary fails with `TypeLoadException` at `Game_Start`. +- `EPMConnector.dll` — wire protocol library (TCP framing + protobuf-net + message dispatch). Linked by both EPM and any C# client. +- `EmpyrionNetworkRelayMod_Info.yaml` — manifest. Per-DLL naming is + required because there are two DLLs in the folder; with a single DLL, + `Info.yaml` works, but with multiple Empyrion's loader needs each main + DLL to have a sibling `_Info.yaml`. +- `Settings.yaml` — host+port for the TCP listener. `0.0.0.0` is fine + inside the container (host networking limits exposure to the container + port; opnfwd handles WAN forwarding). + +## Verified end-to-end on 2026-04-30 + +1. EPM loads under Wine: `Loaded Mod 'EmpyrionNetworkRelayMod': "EPM"`. +2. TCP listener opens: `Now listening on port 7028`. +3. C# probe (`/eah_probe/probe/Probe.cs`) connects via `EPMConnector.Client` + and gets typed protobuf-net responses for `Request_Get_Factions`, + `Request_Player_List`, `Request_Playfield_List`. Default Human + faction came back deserialized into `Eleon.Modding.FactionInfoList`. + +## How to rebuild + +1. Open `C:\Users\dbledeez\AppData\Local\Temp\eah_probe\EmpyrionNetworkConnectedMods\sample-empyrion-mod\Empyrion Mod\` (or re-clone from `MichaelGoulding/EmpyrionNetworkConnectedMods`). +2. Replace `Configuration.cs` with the YAML-free version (see + `feedback_empyrion_yaml_dotnet_drop.md` for the snippet). +3. `cd` to that folder and `csc.exe @build.rsp`. Output goes to + `bin\Release\EmpyrionNetworkRelayMod.dll`. +4. Copy `EmpyrionNetworkRelayMod.dll` + `EPMConnector.dll` + the two yamls + into this directory; commit. diff --git a/modules/empyrion/epm-mod/Settings.yaml b/modules/empyrion/epm-mod/Settings.yaml new file mode 100644 index 00000000..02c4a4ea --- /dev/null +++ b/modules/empyrion/epm-mod/Settings.yaml @@ -0,0 +1,3 @@ +--- +GameServerIp: "0.0.0.0" +GameServerApiPort: 7028 diff --git a/modules/empyrion/module.yaml b/modules/empyrion/module.yaml new file mode 100644 index 00000000..28c9ca13 --- /dev/null +++ b/modules/empyrion/module.yaml @@ -0,0 +1,152 @@ +# Empyrion — Galactic Survival dedicated server module. +# +# Same panel-native pattern as 7dtd: a minimal image we own +# (panel-empyrion:latest, Debian 12 + Wine + tini), with game files +# populated by the generic SteamCMD updater sidecar into a named volume. +# +# Empyrion's dedicated server is shipped as a Windows binary only, so the +# updater is told to pretend SteamCMD is running on Windows +# (`platform: windows` → +@sSteamCmdForcePlatformType windows), and the +# entrypoint runs the exe under Wine under Xvfb (Unity server still touches +# X even in -batchmode). +# +# First-run flow: +# - Create instance → container spec + empty volumes +# - Click "Update" → SteamCMD downloads ~3.5 GB Windows build +# - Click "Start" → entrypoint boots Wine + EmpyrionDedicated.exe +# +# Notes for operators: +# - The game server ports must be UDP; Empyrion uses a handful of them. +# - Main config file is dedicated.yaml, lives under /game-saves after +# first boot (entrypoint copies the shipped default on first run). +# - No in-game RCON: Empyrion's admin console is in-process only. The +# "Empyrion Web Admin" (EWA) third-party tool can be layered on top +# later via a plugin; not wired for v1. + +id: empyrion +name: "Empyrion - Galactic Survival" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + # Built locally from ./Dockerfile. Rebuild with: + # docker build -t panel-empyrion:latest modules/empyrion + image: panel-empyrion:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Saves/, dedicated.yaml, admin files" + - name: "Game Files" + path: /game + hint: "Binaries, Content/, Mods/ — drop mod folders here" + # Pre-declare the port env keys so the agent's resolve filter lets the + # panel-allocated values flow through to the container env (resolve.go + # only overrides keys already present in docker.env). Defaults match + # the manifest port defaults below; agent overrides at create time. + env: + SRV_PORT: "30000" + QUERY_PORT: "30001" + CLIENT_PORT: "30002" + EAC_PORT: "30003" + CSW_PORT: "30004" + EAH_API_PORT: "30007" # Empyrion's Mod API TCP port (used by EAH/EPM and our future bridge) + # SCENARIO is declared empty here so the agent's resolver passes the + # config_values.SCENARIO override into the container env. Without + # this pre-declaration, resolve.go would silently drop the value + # (it only overrides keys already in docker.env), the entrypoint + # would see SCENARIO="" and skip the dedicated.yaml patch, and + # Empyrion would fall back to "Default Multiplayer" no matter what + # the operator picked in the dropdown. + SCENARIO: "" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +# Empyrion port reference: +# Srv_Port (UDP) — main game traffic default 30000 +# Srv_Port+1 (UDP) — Steam query default 30001 +# Srv_Port+2 (UDP) — client connect default 30002 +# EAC_Port (UDP) — EasyAntiCheat default 30003 +# CSWApi_Port (TCP) — CSW API (remote admin) default 30004 (internal) +ports: + - { name: game, proto: udp, default: 30000, required: true, env: SRV_PORT } + - { name: query, proto: udp, default: 30001, required: true, env: QUERY_PORT } # Empyrion derives query from Srv_Port + 1; we still allocate it for tracking + - { name: client, proto: udp, default: 30002, required: true, env: CLIENT_PORT } # Srv_Port + 2 by convention + - { name: eac, proto: udp, default: 30003, required: true, env: EAC_PORT } + - { name: csw, proto: tcp, default: 30004, internal: true, env: CSW_PORT } + # Empyrion's native Mod API TCP port. EPM mod listens on this; EAH GUI + # and our future panel-empyrion-bridge will connect here. Public so + # remote EAH GUIs work; auth model not yet validated, see + # panel/memory/research_eah_integration.md before exposing externally. + - { name: eah-api, proto: tcp, default: 30007, required: true, env: EAH_API_PORT } + +resources: + min_ram_mb: 4096 + recommended_ram_mb: 8192 + +# Operator-tunable knobs. Panel renders these as a config form; the entrypoint +# reads them from env on every boot. +config_values: + - key: SCENARIO + label: "Scenario" + description: "Folder name under Content/Scenarios/. Leave blank for game default. Reforged Eden 2 needs the workshop content (ID 3041847672) dropped into Content/Scenarios/Reforged Eden 2/ first — use the Files tab." + default: "" + options: + - { value: "", label: "Default Random (vanilla)" } + - { value: "Reforged Eden 2", label: "Reforged Eden 2 (workshop)" } + - { value: "Reforged Eden", label: "Reforged Eden (legacy)" } + - { value: "Default Akua", label: "Default Akua (vanilla)" } + - { value: "Default Multiplayer Survival", label: "Default Multiplayer Survival" } + - key: EAH_API_PORT + label: "EAH/Mod API port" + description: "TCP port the EPM mod listens on for the panel-empyrion-bridge sidecar. Auto-allocated by panel; rarely needs manual override." + default: "" + advanced: true +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (empyrion-galactic-survival.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: '-LOG-.*Player (?P.+?)/''(?P[^'']+)'' login ok' + kind: join + leave: + pattern: '-LOG-.*, (?P.+?)/=/''(?P[^'']+)'' disconnected$' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "530870" + platform: windows # force Windows depot on Linux hosts — server is Windows-only + install_path: /game + - id: experimental + kind: steamcmd + app_id: "530870" + beta: experimental + platform: windows + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Server started listening on port|Dedi is ready|Ready for connections/i' +appearance: + emoji: "🚀" + grad: "linear-gradient(135deg,#0b2545,#5c7cfa)" + art: "/game-art/empyrion.jpg" + steam: "383120" diff --git a/modules/enshrouded/Dockerfile b/modules/enshrouded/Dockerfile new file mode 100644 index 00000000..b85703d2 --- /dev/null +++ b/modules/enshrouded/Dockerfile @@ -0,0 +1,74 @@ +# panel-native Enshrouded runtime image. +# +# Enshrouded's dedicated server is WINDOWS-ONLY (SteamCMD app 2278520 has no +# Linux depot — "Invalid platform"; AMP runs the exe under Proton GE). We run +# it under Wine + Xvfb, same pattern as the verified windrose module. +# +# Expected runtime layout inside the container: +# /game — SteamCMD-installed Windows build (panel volume) +# /game/enshrouded_server.exe — dedicated server binary +# /game-saves — enshrouded_server.json + savegame/ + logs/ +# /entrypoint.sh — boot Xvfb, launch exe under Wine + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + WINEDEBUG=-all \ + WINEPREFIX=/home/panel/.wine \ + DISPLAY=:99 + +# winehq-stable (Wine 9+) rather than Debian's wine 8.0~repack — matches the +# windrose module's rationale (IOCP fixes, and what CubeCoders' AMP wine +# images ship). cabextract + winbind are commonly needed by Wine helpers. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + gnupg \ + xvfb \ + xauth \ + winbind \ + cabextract \ + procps \ + && mkdir -pm755 /etc/apt/keyrings \ + && curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \ + | gpg --dearmor -o /etc/apt/keyrings/winehq-archive.key \ + && curl -fsSL https://dl.winehq.org/wine-builds/debian/dists/bookworm/winehq-bookworm.sources \ + -o /etc/apt/sources.list.d/winehq-bookworm.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends winehq-stable \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves /home/panel + +# Pre-warm the Wine prefix during the image build (skip Mono/Gecko — the +# server doesn't need .NET/HTML). See windrose Dockerfile for the full +# rationale (interrupted first-boot wineboot leaves a broken prefix that +# crash-loops with kernel32 c0000135). +USER panel +ENV HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all +RUN Xvfb :99 -screen 0 1024x768x24 -nolisten tcp & XVFB_PID=$! \ + && sleep 2 \ + && DISPLAY=:99 WINEDLLOVERRIDES="mscoree=;mshtml=" wineboot --init \ + && wineserver -w \ + && kill "$XVFB_PID" 2>/dev/null || true +USER root + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 15637/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/enshrouded/entrypoint.sh b/modules/enshrouded/entrypoint.sh new file mode 100644 index 00000000..478495fc --- /dev/null +++ b/modules/enshrouded/entrypoint.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# panel-native Enshrouded entrypoint (Windows server under Wine + Xvfb). +set -euo pipefail +log() { printf '[panel-enshrouded] %s\n' "$*"; } + +# ----- stage 1: root-only bootstrap, then drop to panel (Wine refuses root) ----- +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/home/panel \ + WINEPREFIX=/home/panel/.wine \ + WINEDEBUG=-all \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + SERVER_NAME="${SERVER_NAME:-panel Enshrouded}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-letmein}" \ + MAX_PLAYERS="${MAX_PLAYERS:-16}" \ + QUERY_PORT="${QUERY_PORT:-15637}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +EXE="${GAME_DIR}/enshrouded_server.exe" +CONFIG="${SAVE_DIR}/enshrouded_server.json" + +if [[ ! -f "${EXE}" ]]; then + log "ERROR: ${EXE} not found. Run 'Update' in the panel — that launches a" + log " SteamCMD sidecar which installs the Windows build (app 2278520," + log " +@sSteamCmdForcePlatformType windows) into this volume." + exit 78 # EX_CONFIG +fi + +mkdir -p "${SAVE_DIR}/savegame" "${SAVE_DIR}/logs" + +# Seed enshrouded_server.json on first boot. The Steam depot does not ship a +# config (the server generates one), so we write our own — deterministic, and +# the env->json patching below always has keys to patch. Paths are relative +# to the exe's directory; we symlink savegame/ + logs/ into the save volume. +if [[ ! -s "${CONFIG}" ]]; then + log "seeding default ${CONFIG}" + cat > "${CONFIG}" <<'EOF' +{ + "name": "panel Enshrouded", + "password": "", + "saveDirectory": "./savegame", + "logDirectory": "./logs", + "ip": "0.0.0.0", + "queryPort": 15637, + "slotCount": 16 +} +EOF +fi + +# Basic env -> json patching. Enshrouded config is small + flat so a few +# seds are fine without pulling jq in. +sed -i "s|\"name\":\\s*\"[^\"]*\"|\"name\": \"${SERVER_NAME}\"|" "${CONFIG}" +sed -i "s|\"password\":\\s*\"[^\"]*\"|\"password\": \"${SERVER_PASSWORD}\"|" "${CONFIG}" +sed -i "s|\"slotCount\":\\s*[0-9]*|\"slotCount\": ${MAX_PLAYERS}|" "${CONFIG}" +# Panel-allocated port. Modern Enshrouded has a single UDP port (queryPort); +# gamePort no longer exists in the config schema. +sed -i "s|\"queryPort\":\\s*[0-9]*|\"queryPort\": ${QUERY_PORT}|" "${CONFIG}" + +# Config + save/log dirs live in the save volume; symlink them next to the +# exe (the server resolves config + relative paths from its own directory). +ln -sf "${CONFIG}" "${GAME_DIR}/enshrouded_server.json" +for d in savegame logs; do + if [[ -d "${GAME_DIR}/${d}" && ! -L "${GAME_DIR}/${d}" ]]; then rm -rf "${GAME_DIR}/${d}"; fi + ln -sfn "${SAVE_DIR}/${d}" "${GAME_DIR}/${d}" +done + +# --- Wine prefix bootstrap / repair (image pre-warms it; repair if broken) --- +if [[ ! -f "${WINEPREFIX}/system.reg" || ! -f "${WINEPREFIX}/drive_c/windows/system32/kernel32.dll" ]]; then + log "initializing Wine prefix at ${WINEPREFIX}" + rm -rf "${WINEPREFIX}" + xvfb-run -a env WINEDLLOVERRIDES="mscoree=;mshtml=" wineboot --init 2>&1 | sed 's/^/ /' || true + wineserver -w || true +fi + +cd "${GAME_DIR}" +log "starting Enshrouded dedicated server (Wine)" +log " name: ${SERVER_NAME}" +log " slots: ${MAX_PLAYERS}" +log " port: ${QUERY_PORT}/udp" + +# The server writes its real log to ./logs/enshrouded_server.log; mirror it +# to stdout so the panel's log follower + ready_pattern see it even if the +# Wine console output is sparse. tail -F copes with the file not existing yet. +tail -n +1 -F "${SAVE_DIR}/logs/enshrouded_server.log" 2>/dev/null & +TAIL_PID=$! + +trap 'log "SIGTERM received; stopping server"; wineserver -k 2>/dev/null || true; kill "${TAIL_PID:-}" 2>/dev/null || true; exit 0' TERM INT + +exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ + wine "${EXE}" diff --git a/modules/enshrouded/module.yaml b/modules/enshrouded/module.yaml new file mode 100644 index 00000000..5d7a2829 --- /dev/null +++ b/modules/enshrouded/module.yaml @@ -0,0 +1,101 @@ +# Enshrouded — panel-native dedicated server module. +# +# The dedicated server is WINDOWS-ONLY (SteamCMD app 2278520 — no Linux +# depot; a plain Linux install fails with "Invalid platform", verified +# 2026-07-14). An earlier scaffold claimed "native Linux" — wrong. AMP runs +# the exe under Proton GE; we run it under Wine + Xvfb like windrose/empyrion +# (update provider `platform: windows`). +# No traditional RCON — server console is stdin-only. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (enshrouded.kvp) + +id: enshrouded +name: "Enshrouded" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-enshrouded:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "enshrouded_server.json + savegame/" + - name: "Game Files" + path: /game + hint: "Server binaries" + env: + SERVER_NAME: "panel Enshrouded" + SERVER_PASSWORD: "letmein" + MAX_PLAYERS: "16" + # Pre-declared so the agent's resolve.go filter lets the + # panel-allocated port flow through to the container env (same + # pattern as soulmask). Modern Enshrouded (post "Melodies of the + # Mire", 2024) has a SINGLE UDP port — queryPort in + # enshrouded_server.json; gamePort was removed from the config. + QUERY_PORT: "15637" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + # Single UDP port — current Enshrouded merged game+query into one + # "queryPort" (AMP's enshroudedports.json declares only QueryPort). + - { name: query, proto: udp, default: 15637, required: true, env: QUERY_PORT } + +resources: + min_ram_mb: 8192 + recommended_ram_mb: 16384 # Enshrouded is memory-hungry + +rcon: + # Enshrouded admin is stdin-only (no network RCON in-game). + adapter: stdio + commands: + save: "save" + shutdown: "shutdown" +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (enshrouded.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: '\[server\] Player ''(?P[^'']+)'' logged in with Permissions:' + kind: join + leave: + pattern: '\[server\] Remove Player ''(?P[^'']+)''$' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "2278520" + install_path: /game + platform: windows # server is Windows-only; no Linux depot + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +# AMP's Console.AppReadyRegex: ^.*\[Session\] 'HostOnline' \(up\)!$ +# VERIFIED live 2026-07-14 (agent01 smoke test): the exact ready line is +# [Session] 'HostOnline' (up)! +# Install 8.3 GB on disk; boot start->HostOnline ~25 s (warm Wine prefix); +# external A2S_INFO on the single UDP port answered off-host. +ready_pattern: "/\\[Session\\] 'HostOnline' \\(up\\)!/i" +appearance: + emoji: "🌫️" + grad: "linear-gradient(135deg,#4a2c2a,#a45e4a)" + art: "/game-art/enshrouded.jpg" + steam: "1203620" diff --git a/modules/factorio/Dockerfile b/modules/factorio/Dockerfile new file mode 100644 index 00000000..efc449fc --- /dev/null +++ b/modules/factorio/Dockerfile @@ -0,0 +1,31 @@ +# panel-native Factorio runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + xz-utils \ + curl \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 34197/udp 27015/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/factorio/entrypoint.sh b/modules/factorio/entrypoint.sh new file mode 100644 index 00000000..521a1317 --- /dev/null +++ b/modules/factorio/entrypoint.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# panel-native Factorio entrypoint. +set -euo pipefail +log() { printf '[panel-factorio] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \ + GAME_PORT="${GAME_PORT:-34197}" \ + RCON_PORT="${RCON_PORT:-27015}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +# factorio.com's headless tarball extracts into a `factorio/` subdir. Our +# updater unpacks into /game, so we look for both. +BIN="${GAME_DIR}/factorio/bin/x64/factorio" +if [[ ! -x "${BIN}" ]]; then + BIN="${GAME_DIR}/bin/x64/factorio" +fi +SAVE="${SAVE_DIR}/saves/default.zip" + +if [[ ! -x "${BIN}" ]]; then + log "ERROR: Factorio binary not found at /game/{factorio/,}bin/x64/factorio." + log " Run 'Update' in the panel to install Factorio (Direct provider)." + exit 78 +fi + +mkdir -p "${SAVE_DIR}/saves" "${SAVE_DIR}/mods" "${SAVE_DIR}/config" "${SAVE_DIR}/logs" + +# Seed server-settings.json from Factorio's shipped example on first boot. +# Without this Factorio fails fast: "Hosting multiplayer game failed: +# filesystem error: file_size(p): unknown error: No such file or directory +# [/game-saves/config/server-settings.json]" — observed on smoke test. +# The example tree lives next to the binary at /../../data/. +DATA_DIR="$(dirname "${BIN}")/../../data" +if [[ ! -f "${SAVE_DIR}/config/server-settings.json" && -f "${DATA_DIR}/server-settings.example.json" ]]; then + log "seeding server-settings.json from example" + cp "${DATA_DIR}/server-settings.example.json" "${SAVE_DIR}/config/server-settings.json" +fi +# map-gen-settings + map-settings aren't required for boot, but having the +# examples in place means operators can edit them via the Files tab without +# having to dig into the binary tree. +for f in map-gen-settings.example.json map-settings.example.json; do + base="${f%.example.json}.json" + if [[ ! -f "${SAVE_DIR}/config/${base}" && -f "${DATA_DIR}/${f}" ]]; then + cp "${DATA_DIR}/${f}" "${SAVE_DIR}/config/${base}" + fi +done + +# Create a default world on first boot — Factorio can't autostart from nothing. +if [[ ! -s "${SAVE}" ]]; then + log "no existing save — creating default map" + "${BIN}" --create "${SAVE}" +fi + +cd "$(dirname "${BIN}")/../.." +GAME_PORT="${GAME_PORT:-34197}" +RCON_PORT="${RCON_PORT:-27015}" +log "starting Factorio headless server" +log " save: ${SAVE}" +log " settings: ${SAVE_DIR}/config/server-settings.json" +log " game port: ${GAME_PORT}/udp" +log " rcon port: ${RCON_PORT}/tcp" + +exec stdbuf -oL -eL "${BIN}" \ + --start-server "${SAVE}" \ + --server-settings "${SAVE_DIR}/config/server-settings.json" \ + --port "${GAME_PORT}" \ + --rcon-port "${RCON_PORT}" \ + --rcon-password "${RCON_PASSWORD}" \ + --mod-directory "${SAVE_DIR}/mods" \ + --console-log "${SAVE_DIR}/logs/factorio.log" diff --git a/modules/factorio/module.yaml b/modules/factorio/module.yaml new file mode 100644 index 00000000..2440e1e1 --- /dev/null +++ b/modules/factorio/module.yaml @@ -0,0 +1,125 @@ +# Factorio — panel-native dedicated server module. +# +# Native Linux binary. Factorio has two install sources: SteamCMD app 427520 +# (only if you own Factorio on Steam) OR direct download from factorio.com. +# We go direct: no Steam auth needed, and headless versions ship unzipped +# tar.xz archives. Users can swap to SteamCMD if they prefer. +# +# RCON: Factorio ships a source-compatible RCON server (same wire protocol +# as Valve's). Enabled via --rcon-port + --rcon-password flags. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (Factorio.kvp) + +id: factorio +name: "Factorio" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-factorio:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Save games + server-settings.json + mod-list.json" + - name: "Game Files" + path: /game + hint: "Binaries, mods/, data/" + env: + RCON_PASSWORD: "panel_rcon" + FACTORIO_VERSION: "stable" # "stable" or "latest" (experimental) + # Pre-declared so the agent's resolve.go filter lets the panel-allocated + # port through to the container env (only keys ALREADY in docker.env get + # overridden by config_values). + GAME_PORT: "34197" + RCON_PORT: "27015" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + - { name: game, proto: udp, default: 34197, required: true, env: GAME_PORT } + - { name: rcon, proto: tcp, default: 27015, internal: true, env: RCON_PORT } + +resources: + min_ram_mb: 1024 + recommended_ram_mb: 4096 # mods + big bases + +# Per-instance generated RCON secret. The agent generates a random +# 24-byte token at create time (pkg/module GenerateSecrets), which +# overrides the env default for RCON_PASSWORD below — so every instance +# gets a unique password. The weak env default only applies to +# containers launched outside the panel. +secrets: + - name: RCON_PASSWORD + description: "Factorio RCON password — generated per instance" + generated: true + +rcon: + adapter: source_rcon + host_port: rcon + auth: source_rcon_login + password_secret: RCON_PASSWORD + # Legacy fallback only (pre-generated-secret instances). New instances + # always get the generated RCON_PASSWORD secret above. + password_literal: "panel_rcon" + commands: + list: "/players online" + save: "/server-save" + say: "{msg}" # Factorio treats non-/ input as chat to all players + kick: "/kick {player} {reason}" + ban: "/ban {player} {reason}" + +state_sources: + - type: rcon + command: "/players online count" + every: 60s + parse: + kind: regex + pattern: 'Online players \((?P\d+)\)' + fields: + players_online: count + +events: + join: + pattern: "\\[JOIN\\] (?P\\S+) joined the game" + kind: join + leave: + pattern: "\\[LEAVE\\] (?P\\S+) left the game" + kind: leave + chat: + pattern: "\\[CHAT\\] (?P\\S+): (?P.*)" + kind: chat + +update_providers: + - id: stable + kind: direct + url: "https://factorio.com/get-download/stable/headless/linux64" + target_path: /game + - id: experimental + kind: direct + url: "https://factorio.com/get-download/latest/headless/linux64" + target_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Hosting game at IP ADDR|changing state from\(CreatingGame\) to\(InGame\)/i' +appearance: + emoji: "🏭" + grad: "linear-gradient(135deg,#8c6239,#d4a373)" + art: "/game-art/factorio.jpg" + steam: "427520" diff --git a/modules/minecraft-bedrock/module.yaml b/modules/minecraft-bedrock/module.yaml new file mode 100644 index 00000000..47a801fe --- /dev/null +++ b/modules/minecraft-bedrock/module.yaml @@ -0,0 +1,142 @@ +# Minecraft Bedrock Edition module. +# +# itzg/minecraft-bedrock-server is the de-facto community image for the +# Mojang-shipped Linux Bedrock binary. It auto-downloads the binary on +# first container start (license-accepted via EULA=TRUE) and re-pulls on +# version bumps via the VERSION env var. +# +# Bedrock is the iOS / Android / Console / Win10-Edition flavor of +# Minecraft. It speaks RakNet over UDP — TOTALLY DIFFERENT wire protocol +# from Java Edition. Bedrock clients cannot connect to Java servers and +# vice-versa. Run BOTH if your players are mixed-platform; or use the +# GeyserMC translation proxy (out of scope for this module). +# +# No RCON — Bedrock servers don't ship a remote-admin protocol. Admin +# commands go through the in-process console only. Players tab will be +# empty; state tracker has nothing to poll. +# +# Verified scaffolding pattern: +# - same `auto_update_on_create: false` + placeholder update_providers +# combination as minecraft-java (see panel/memory/gotchas.md +# "image-managed module" entry). +# - `env: SERVER_PORT` / `env: SERVER_PORT_V6` on each port wires the +# panel-allocated host ports through to the container env, then +# itzg's entrypoint passes them to the binary. Combined with +# `network_mode: host`, two Bedrock instances on the same agent +# allocate unique ports and bind them directly. + +id: minecraft-bedrock +name: "Minecraft Bedrock Edition" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: itzg/minecraft-bedrock-server:latest + # Host networking — Bedrock's UDP RakNet protocol relies on the + # client speaking directly to the server's published port. Port + # publishing through Docker's bridge would NAT every Bedrock packet, + # which RakNet doesn't tolerate well. AMP-like host-net model keeps + # the packets clean. Multi-instance safety is the panel allocator + # picking distinct SERVER_PORT values per instance. + network_mode: host + browseable_root: /data + browseable_roots: + - name: "Server data" + path: /data + hint: "worlds/, allowlist.json, permissions.json, server.properties" + env: + EULA: "TRUE" + SERVER_NAME: "Refuge Bedrock" + GAMEMODE: "survival" # survival | creative | adventure + DIFFICULTY: "easy" # peaceful | easy | normal | hard + LEVEL_NAME: "level" + LEVEL_TYPE: "DEFAULT" # DEFAULT | FLAT | LEGACY + MAX_PLAYERS: "10" + ONLINE_MODE: "true" + ALLOW_CHEATS: "false" + VIEW_DISTANCE: "10" + TICK_DISTANCE: "4" + DEFAULT_PLAYER_PERMISSION_LEVEL: "member" + TEXTUREPACK_REQUIRED: "false" + VERSION: "LATEST" + # Pre-declared so the agent's resolve.go filter lets allocator-derived + # ports flow through to the container env. itzg honors both SERVER_PORT + # (IPv4) and SERVER_PORT_V6 (IPv6) — defaults match the manifest port + # defaults below; agent overrides at create time. + SERVER_PORT: "19132" + SERVER_PORT_V6: "19133" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-bedrock", container: "/data" } + - { target: "$DATA_PATH/logs", container: "/data/logs" } + +# Bedrock binds two UDP ports — IPv4 and IPv6. Both are required for +# proper dual-stack listing in Bedrock's server browser. itzg defaults +# 19132 / 19133. The panel allocator picks unique ports from the agent's +# window (e.g. 7000-7999 on princess); the agent injects them as +# SERVER_PORT / SERVER_PORT_V6 env at container-create time. +ports: + - { name: game, proto: udp, default: 19132, required: true, env: SERVER_PORT } + - { name: game-v6, proto: udp, default: 19133, required: true, env: SERVER_PORT_V6 } + +resources: + min_ram_mb: 1024 + recommended_ram_mb: 2048 + +# itzg/minecraft-bedrock-server handles install + version pinning via the +# VERSION env var on first container start — no SteamCMD / GitHub / direct +# flow the panel needs to drive. Same shape as minecraft-java: keep a +# placeholder update_providers entry to satisfy the manifest validator, +# pair with auto_update_on_create:false so the agent doesn't actually run +# it (which would crash on the missing target_path — see +# panel/memory/gotchas.md "image-managed module" pattern). +auto_update_on_create: false +update_providers: + - id: image-managed + kind: direct + url: "https://hub.docker.com/r/itzg/minecraft-bedrock-server" + target_path: ".panel-image-managed-marker" # never written; auto-update is suppressed above + +# itzg's bedrock image streams the server log to stdout. The format depends +# on the bundled binary's version — in 1.21+ the ready signal is +# "Server started." and join/leave use "Player connected/disconnected". +# Older builds use slightly different phrasing. We grep the broad shape; +# tighten once we observe the live format. +state_sources: + - type: log_tail + files: + - "$DATA_PATH/logs/*.log" + - "/data/logs/*.log" + +events: + join: + pattern: 'Player connected: (?P[^,]+), xuid: (?P\S+)' + kind: join + leave: + pattern: 'Player disconnected: (?P[^,]+), xuid: (?P\S+)' + kind: leave + +# Backup what's recoverable; everything else (binary, libs) is fetched +# fresh on next start. +backup: + include: + - "worlds" # world saves (one dir per LEVEL_NAME) + - "*.properties" # server.properties + - "*.json" # permissions.json, allowlist.json, etc. + exclude: + - "logs" # rotates infinitely + - "premium_cache" # auto-restored + - "behavior_packs" # auto-fetched (mods landing here are operator-managed via Files tab) + - "resource_packs" # same + notes: "World saves + permissions + server.properties (~10-100 MB typical). Bedrock binary fetched fresh by itzg on next start." + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +appearance: + emoji: "🧱" + grad: "linear-gradient(135deg,#2d6929,#5a9d56)" diff --git a/modules/minecraft-java/module.yaml b/modules/minecraft-java/module.yaml new file mode 100644 index 00000000..7fadf2e7 --- /dev/null +++ b/modules/minecraft-java/module.yaml @@ -0,0 +1,140 @@ +# Minecraft Java Edition module. +# +# Uses the itzg/minecraft-server community image — widely deployed, actively +# maintained, designed for panel/automation use. All configuration is driven +# by env vars (unlike 7DTD, which fights us). Boots in ~60-90s on first run +# (downloads the server jar) and ~10-15s on subsequent runs. +# +# RCON uses Source RCON (binary protocol), which our agent doesn't yet have +# an adapter for — so this module declares the RCON port/password for future +# use but leaves the `rcon:` section out for now. Adding a source_rcon adapter +# is ~200 lines in agent/internal/rcon/ when we get to it. +id: minecraft-java +name: "Minecraft Java Edition" +version: 0.1.0 +authors: + - panel contributors +supported_modes: + - docker +runtime: + docker: + image: itzg/minecraft-server:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + env: + EULA: "TRUE" + TYPE: "VANILLA" + VERSION: "LATEST" + MEMORY: "2G" + ENABLE_RCON: "true" + RCON_PASSWORD: "panel_rcon" # default only — a generated per-instance secret overrides this at create + MOTD: "Running on panel" + ONLINE_MODE: "TRUE" + DIFFICULTY: "normal" + VIEW_DISTANCE: "10" + # Pre-declared so the agent's resolve.go filter lets allocator-derived + # ports flow through to the container env. itzg honors both SERVER_PORT + # and RCON_PORT — defaults match the manifest port defaults below. + SERVER_PORT: "25565" + RCON_PORT: "25575" + volumes: + # /data holds the whole server including worlds, mods, configs. + # Named volume for speed; survives instance recreation unless operator + # explicitly removes it. + - { type: volume, name: "panel-$INSTANCE_ID-mc", container: "/data" } +ports: + - { name: game, proto: tcp, default: 25565, required: true, env: SERVER_PORT } + - { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT } +# Per-instance generated RCON secret — overrides the env default below +# at create so every instance gets a unique password. +secrets: + - name: RCON_PASSWORD + description: "Minecraft RCON password — generated per instance" + generated: true +rcon: + adapter: source_rcon + host_port: rcon + auth: source_rcon_login + password_secret: RCON_PASSWORD + # Legacy fallback only (pre-generated-secret instances). New instances + # always get the generated RCON_PASSWORD secret above. + password_literal: "panel_rcon" + commands: + list: "list" + say: "say {msg}" + kick: "kick {player} {reason}" + stop: "stop" + save: "save-all" +state_sources: + - type: rcon + command: "list" + every: 60s + parse: + kind: regex + pattern: 'There are (?P\d+) of a max of (?P\d+) players online' + fields: + players_online: players + players_max: max +resources: + min_ram_mb: 2048 + recommended_ram_mb: 4096 + +# /data has world saves mixed with downloadable libraries/, mods/ cache, +# and the server jar. Back up the player-state stuff; libraries + +# server jar regenerate from itzg's image on next start. +backup: + include: + - "world" # default world dir (overworld) + - "world_nether" # nether + - "world_the_end" # end + - "*.properties" # server.properties + others + - "*.json" # whitelist, ops, banned-players, banned-ips, usercache + - "plugins" # bukkit/spigot plugins (config + state inside each) + - "mods" # Forge/Fabric mod jars (mod configs in plugins/) + - "config" # mod config files + exclude: + - "logs" # rotates infinitely + - "crash-reports" # never useful in restore + - "libraries" # auto-downloaded by itzg + - "cache" # JVM/itzg fluff + - "tmp" + notes: "World data + plugins + mods + configs (~50-500 MB typical). Server jar + libraries fetched fresh by itzg on next start." +# itzg/minecraft-server handles install + version pinning via the VERSION env +# var on first container start — there's no SteamCMD / GitHub / direct flow +# the panel needs to drive. +# +# But: the manifest validator REQUIRES at least one update_provider. So we +# keep a placeholder. Without `auto_update_on_create: false` the agent kicks +# off this provider on first boot and direct.go errors out on the missing +# target_path — observed 2026-04-29. The combination here (placeholder + +# auto-update opt-out) is the right shape for any image-managed module. +auto_update_on_create: false +update_providers: + - id: image-managed + kind: direct + url: "https://hub.docker.com/r/itzg/minecraft-server" + target_path: ".panel-image-managed-marker" # never written; auto-update is suppressed above +events: + join: + pattern: "(?P[A-Za-z0-9_]+) joined the game" + kind: join + leave: + pattern: "(?P[A-Za-z0-9_]+) left the game" + kind: leave + chat: + pattern: "<(?P[A-Za-z0-9_]+)> (?P.*)" + kind: chat + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: 'Done \([\d.]+s\)! For help' +appearance: + emoji: "🧱" + grad: "linear-gradient(135deg,#4c8b32,#84ce5c)" + art: "/game-art/minecraft-java.png" diff --git a/modules/palworld/Dockerfile b/modules/palworld/Dockerfile new file mode 100644 index 00000000..d93d94a4 --- /dev/null +++ b/modules/palworld/Dockerfile @@ -0,0 +1,33 @@ +# panel-native Palworld runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8211/udp 27015/udp 25575/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/palworld/entrypoint.sh b/modules/palworld/entrypoint.sh new file mode 100644 index 00000000..66b522b2 --- /dev/null +++ b/modules/palworld/entrypoint.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# panel-native Palworld entrypoint. +set -euo pipefail +log() { printf '[panel-palworld] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + SERVER_NAME="${SERVER_NAME:-panel Palworld}" \ + SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-A Palworld server}" \ + MAX_PLAYERS="${MAX_PLAYERS:-32}" \ + RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \ + GAME_PORT="${GAME_PORT:-8211}" \ + QUERY_PORT="${QUERY_PORT:-27015}" \ + RCON_PORT="${RCON_PORT:-25575}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +PAL_SH="${GAME_DIR}/PalServer.sh" +SETTINGS_DIR="${GAME_DIR}/Pal/Saved/Config/LinuxServer" +SETTINGS="${SETTINGS_DIR}/PalWorldSettings.ini" +DEFAULT_SETTINGS="${GAME_DIR}/DefaultPalWorldSettings.ini" + +if [[ ! -x "${PAL_SH}" ]]; then + log "ERROR: ${PAL_SH} not found. Run 'Update' in the panel to install Palworld via SteamCMD." + exit 78 +fi + +# Seed PalWorldSettings.ini on first boot from the shipped default, then +# patch RCON + server name/description/max-players into it. Palworld +# ignores these keys on re-save unless they live in PalWorldSettings.ini +# under [/Script/Pal.PalGameWorldSettings]. +mkdir -p "${SETTINGS_DIR}" +if [[ ! -s "${SETTINGS}" && -s "${DEFAULT_SETTINGS}" ]]; then + log "seeding ${SETTINGS} from shipped default" + cp -f "${DEFAULT_SETTINGS}" "${SETTINGS}" +fi + +# Patch the single OptionSettings=(…) tuple each boot so panel-managed +# values (RCON auth, server identity) track env vars. Uses | as sed +# delimiter since the tuple contains slashes. +if [[ -s "${SETTINGS}" ]]; then + log "patching ${SETTINGS} (RCON + identity + ports)" + sed -i -E \ + -e "s|RCONEnabled=(True\|False)|RCONEnabled=True|" \ + -e "s|RCONPort=[0-9]+|RCONPort=${RCON_PORT}|" \ + -e "s|PublicPort=[0-9]+|PublicPort=${GAME_PORT}|" \ + -e "s|AdminPassword=\"[^\"]*\"|AdminPassword=\"${RCON_PASSWORD}\"|" \ + -e "s|ServerPlayerMaxNum=[0-9]+|ServerPlayerMaxNum=${MAX_PLAYERS}|" \ + -e "s|ServerName=\"[^\"]*\"|ServerName=\"${SERVER_NAME}\"|" \ + -e "s|ServerDescription=\"[^\"]*\"|ServerDescription=\"${SERVER_DESCRIPTION}\"|" \ + "${SETTINGS}" +fi + +# Redirect Pal saves into the save volume so SteamCMD validate can't clobber. +mkdir -p "${SAVE_DIR}/Pal" +if [[ -d "${GAME_DIR}/Pal/Saved" && ! -L "${GAME_DIR}/Pal/Saved" ]]; then + # Migrate any existing shipped saves once, then symlink. + if [[ ! -d "${SAVE_DIR}/Pal/Saved" ]]; then + mv "${GAME_DIR}/Pal/Saved" "${SAVE_DIR}/Pal/Saved" + else + rm -rf "${GAME_DIR}/Pal/Saved" + fi +fi +mkdir -p "${SAVE_DIR}/Pal/Saved" +ln -sfn "${SAVE_DIR}/Pal/Saved" "${GAME_DIR}/Pal/Saved" + +cd "${GAME_DIR}" +log "starting Palworld dedicated server" +log " name: ${SERVER_NAME}" +log " max_players: ${MAX_PLAYERS}" +log " game port: ${GAME_PORT}/udp" +log " query port: ${QUERY_PORT}/udp" +log " rcon port: ${RCON_PORT}/tcp" + +exec stdbuf -oL -eL "${PAL_SH}" \ + -port="${GAME_PORT}" \ + -queryport="${QUERY_PORT}" \ + -players="${MAX_PLAYERS}" \ + -useperfthreads \ + -NoAsyncLoadingThread \ + -UseMultithreadForDS \ + "-servername=${SERVER_NAME}" \ + "-adminpassword=${RCON_PASSWORD}" \ + -RconEnabled=True \ + -RconPort="${RCON_PORT}" \ + "-ServerDescription=${SERVER_DESCRIPTION}" diff --git a/modules/palworld/module.yaml b/modules/palworld/module.yaml new file mode 100644 index 00000000..4d88d4be --- /dev/null +++ b/modules/palworld/module.yaml @@ -0,0 +1,127 @@ +# Palworld — panel-native dedicated server module. +# +# Native Linux binary from SteamCMD app 2394010. Native Source RCON on +# port 25575 when EnableRCON=True in PalWorldSettings.ini. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (PalworldLinux.kvp) + +id: palworld +name: "Palworld" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-palworld:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "World save + PalWorldSettings.ini + admin files" + - name: "Game Files" + path: /game + hint: "PalServer install, binaries" + env: + SERVER_NAME: "panel Palworld" + SERVER_DESCRIPTION: "A Palworld server" + MAX_PLAYERS: "32" + RCON_PASSWORD: "panel_rcon" + # Pre-declared so the agent's resolve.go filter lets panel-allocated + # ports flow through to the container env. Defaults match port defaults. + GAME_PORT: "8211" + QUERY_PORT: "27015" + RCON_PORT: "25575" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + - { name: game, proto: udp, default: 8211, required: true, env: GAME_PORT } + - { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT } + - { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT } + +resources: + min_ram_mb: 6144 + recommended_ram_mb: 16384 # Palworld is memory-hungry + +# Per-instance generated RCON secret. The agent generates a random +# 24-byte token at create time (pkg/module GenerateSecrets), which +# overrides the env default for RCON_PASSWORD below — so every instance +# gets a unique password. The weak env default only applies to +# containers launched outside the panel. +secrets: + - name: RCON_PASSWORD + description: "Palworld RCON password — generated per instance" + generated: true + +rcon: + adapter: source_rcon + host_port: rcon + auth: source_rcon_login + password_secret: RCON_PASSWORD + # Legacy fallback only (pre-generated-secret instances). New instances + # always get the generated RCON_PASSWORD secret above. + password_literal: "panel_rcon" + commands: + list: "ShowPlayers" + save: "Save" + broadcast: "Broadcast {msg}" + kick: "KickPlayer {player}" + ban: "BanPlayer {player}" + shutdown: "Shutdown 30 \"Server going down\"" + +state_sources: + - type: rcon + command: "ShowPlayers" + every: 60s + parse: + kind: regex + pattern: '^(?P\d+)\s*$' # Fallback; real parsing in playerList + fields: + players_online: count +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (palworld.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: '(?P.+?) \S+ connected the server\. \(User id: (?P[^)]+)\)$' + kind: join + leave: + pattern: '(?P.+?) left the server\. \(User id: (?P[^)]+)\)$' + kind: leave + chat: + pattern: '^(?:\[[\d-]+ [\d:]+\] )?\[CHAT\] <(?P.+?)> (?P.+)$' + kind: chat + + +update_providers: + - id: stable + kind: steamcmd + app_id: "2394010" + install_path: /game + # Force Linux depot — SteamCMD's auto-detect mis-fires in Docker + # Desktop on Windows (WSL2 backend), returning "Missing configuration" + # on anonymous app_update. Pinning platform fixes it. + platform: linux + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Running Palworld dedicated server|Setting breakpad minidump AppID/i' +appearance: + emoji: "🐾" + grad: "linear-gradient(135deg,#006d77,#83c5be)" + art: "/game-art/palworld.jpg" + steam: "1623730" diff --git a/modules/project-zomboid/Dockerfile b/modules/project-zomboid/Dockerfile new file mode 100644 index 00000000..cc1bd452 --- /dev/null +++ b/modules/project-zomboid/Dockerfile @@ -0,0 +1,35 @@ +# panel-native Project Zomboid runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +# PZ's start-server.sh expects Java 17+. Debian 12's default-jre is 17. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + default-jre-headless \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 16261/udp 16262/udp 27015/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/project-zomboid/entrypoint.sh b/modules/project-zomboid/entrypoint.sh new file mode 100644 index 00000000..618c5088 --- /dev/null +++ b/modules/project-zomboid/entrypoint.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# panel-native Project Zomboid entrypoint. +set -euo pipefail +log() { printf '[panel-pz] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + SERVER_NAME="${SERVER_NAME:-panel}" \ + ADMIN_PASSWORD="${ADMIN_PASSWORD:-changeme}" \ + RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \ + MEMORY="${MEMORY:-4096m}" \ + GAME_PORT="${GAME_PORT:-16261}" \ + GAME_PORT_B="${GAME_PORT_B:-16262}" \ + RCON_PORT="${RCON_PORT:-27015}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +START_SH="${GAME_DIR}/start-server.sh" + +if [[ ! -x "${START_SH}" ]]; then + log "ERROR: ${START_SH} not found. Run 'Update' in the panel to install Project Zomboid via SteamCMD." + exit 78 +fi + +# PZ writes world data + per-server config under $HOME/Zomboid (Linux) by +# default. HOME is already /game-saves via setpriv; pre-create subdirs. +mkdir -p "${SAVE_DIR}/Zomboid/Server" "${SAVE_DIR}/Zomboid/Saves" + +# Per-server config at ~/Zomboid/Server/.ini. Seed on first +# boot, then ALWAYS enforce the panel-allocated ports + RCON creds so a +# recreate with new ports never leaves stale values in an existing ini. +CFG="${SAVE_DIR}/Zomboid/Server/${SERVER_NAME}.ini" +if [[ ! -s "${CFG}" ]]; then + log "seeding ${CFG}" + cat > "${CFG}" <> "${CFG}" + fi +} +set_ini DefaultPort "${GAME_PORT}" +set_ini UDPPort "${GAME_PORT_B}" +set_ini RCONPort "${RCON_PORT}" +set_ini RCONPassword "${RCON_PASSWORD}" +set_ini UPnP "false" + +# JVM heap: start-server.sh reads vmArgs from ProjectZomboid64.json (the +# MEMORY env is NOT read by the stock script) — patch the -Xmx entry. +PZ_JSON="${GAME_DIR}/ProjectZomboid64.json" +if [[ -f "${PZ_JSON}" ]]; then + sed -i "s|-Xmx[0-9]*[kmgKMG]\?|-Xmx${MEMORY}|" "${PZ_JSON}" || true +fi + +cd "${GAME_DIR}" +log "starting Project Zomboid dedicated server" +log " servername: ${SERVER_NAME}" +log " heap: ${MEMORY}" +log " ports: game=${GAME_PORT}/udp direct=${GAME_PORT_B}/udp rcon=${RCON_PORT}/tcp" + +# start-server.sh forwards program args to zombie.network.GameServer. +# -port/-udpport mirror the ini keys (AMP passes the same pair). +# -cachedir is REQUIRED: pzexe derives its data dir from the passwd home +# (/home/panel), NOT $HOME — without it the ini/saves silently land inside +# the container layer and the seeded ini above is never read (confirmed: +# 'cachedir set to "/home/panel/Zomboid"' on first smoke boot). +exec stdbuf -oL -eL "${START_SH}" \ + "-cachedir=${SAVE_DIR}/Zomboid" \ + -servername "${SERVER_NAME}" \ + -adminpassword "${ADMIN_PASSWORD}" \ + -port "${GAME_PORT}" \ + -udpport "${GAME_PORT_B}" diff --git a/modules/project-zomboid/module.yaml b/modules/project-zomboid/module.yaml new file mode 100644 index 00000000..96302ab5 --- /dev/null +++ b/modules/project-zomboid/module.yaml @@ -0,0 +1,120 @@ +# Project Zomboid — panel-native dedicated server module. +# +# Native Linux binary from SteamCMD app 380870. Java-heavy; the shipped +# start-server.sh wraps java with correct -cp and Dpzexe.* system props. +# Has Source-compatible RCON on port 27015 when enabled in server.ini. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (ProjectZomboid.kvp) + +id: project-zomboid +name: "Project Zomboid" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-project-zomboid:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Server configs + world saves under ~/Zomboid" + - name: "Game Files" + path: /game + hint: "PZ install, binaries" + env: + SERVER_NAME: "panel" # used as name of server folder under ~/Zomboid/Server + ADMIN_PASSWORD: "changeme" + RCON_PASSWORD: "panel_rcon" + MEMORY: "4096m" # JVM heap + # Pre-declared so the agent's resolve.go filter lets panel-allocated + # ports flow through to the container env. The entrypoint passes + # -port/-udpport launch args AND enforces the matching ini keys; + # without these the server binds the hardcoded 16261/16262/27015 and + # ignores the panel allocation (breaks multi-instance + opnfwd). + GAME_PORT: "16261" + GAME_PORT_B: "16262" + RCON_PORT: "27015" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + - { name: game, proto: udp, default: 16261, required: true, env: GAME_PORT } + - { name: gameB, proto: udp, default: 16262, required: true, env: GAME_PORT_B } + - { name: rcon, proto: tcp, default: 27015, internal: true, env: RCON_PORT } + +resources: + min_ram_mb: 4096 + recommended_ram_mb: 8192 + +# Per-instance generated RCON secret. The agent generates a random +# 24-byte token at create time (pkg/module GenerateSecrets), which +# overrides the env default for RCON_PASSWORD below — so every instance +# gets a unique password. The weak env default only applies to +# containers launched outside the panel. +secrets: + - name: RCON_PASSWORD + description: "Project Zomboid RCON password — generated per instance" + generated: true + +rcon: + adapter: source_rcon + host_port: rcon + auth: source_rcon_login + password_secret: RCON_PASSWORD + # Legacy fallback only (pre-generated-secret instances). New instances + # always get the generated RCON_PASSWORD secret above. + password_literal: "panel_rcon" + commands: + list: "players" + save: "save" + kick: "kickuser {player}" + ban: "banuser {player}" + broadcast: "servermsg \"{msg}\"" + +state_sources: + - type: rcon + command: "players" + every: 60s + parse: + kind: regex + pattern: 'Players connected \((?P\d+)\)' + fields: + players_online: count + +events: + join: + pattern: "(?P\\S+) connected with ip" + kind: join + leave: + pattern: "(?P\\S+) disconnected" + kind: leave + +update_providers: + - id: stable + kind: steamcmd + app_id: "380870" + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +# Confirmed on smoke boot 2026-07-14: 'LOG : Network ... > *** SERVER STARTED ****' +ready_pattern: '/\*+ SERVER STARTED \*+/' +appearance: + emoji: "🧟" + grad: "linear-gradient(135deg,#6a4c1e,#b89040)" + steam: "108600" diff --git a/modules/rust/Dockerfile b/modules/rust/Dockerfile new file mode 100644 index 00000000..85419307 --- /dev/null +++ b/modules/rust/Dockerfile @@ -0,0 +1,61 @@ +# panel-native Rust runtime image. +# +# Rust's dedicated server is a native Linux binary (SteamCMD app 258550), +# so no Wine. We need: +# - 32-bit runtime libs (RustDedicated is ELF64 but loads steamclient.so +# which pulls in 32-bit deps). +# - tini for signal forwarding (server does a graceful "Saving..." + exit +# on SIGTERM when its RCON is unreachable). +# - SDL/GL shims — Rust dedicated server does NOT need a display, but it +# dlopens libGL and a couple of X libs at startup; we ship the stubs. +# +# Expected runtime layout inside the container: +# /game — SteamCMD install (panel volume) +# /game/RustDedicated — server binary +# /game/server// — world + save data (symlinked to /game-saves) +# /game-saves — save + logs volume +# /entrypoint.sh — boot script (seeds cfg, launches binary) + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libgdiplus \ + libgl1 \ + libx11-6 \ + libxcursor1 \ + libxext6 \ + libxi6 \ + libxrandr2 \ + libxrender1 \ + procps \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Rust default ports (see module.yaml comment for reference). +EXPOSE 28015/udp 28016/tcp 28082/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/rust/README.md b/modules/rust/README.md new file mode 100644 index 00000000..d49d5b4c --- /dev/null +++ b/modules/rust/README.md @@ -0,0 +1,52 @@ +# Rust — panel-native module + +Panel-native Rust dedicated server, native-Linux (no Wine), debian:12-slim base. + +- **Image:** `panel-rust:latest` — built from `./Dockerfile`. +- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 258550) into the `panel--game` named volume. Fresh install is ~7 GB; first-ever boot additionally generates a ~500 MB map on the save volume depending on `SERVER_WORLD_SIZE`. +- **Saves:** `panel--saves` at `/game-saves`. Entrypoint symlinks `/server` into `/game-saves/server/` so SteamCMD validate can't wipe worlds. +- **RCON:** **WebSocket RCON** (Facepunch dropped Source RCON years ago). Panel speaks this via the `websocket_rcon` adapter — `ws://:28016/` with JSON-framed `{Identifier, Message, Name}` requests. Authenticated via the password in the URL path, not headers. + +## Ports + +| Port | Proto | Purpose | +|------|-------|---------| +| 28015 | UDP | Main game traffic | +| 28016 | TCP | WebSocket RCON (internal) | +| 28082 | TCP | Rust+ push notifications (optional — only needed for the companion app) | + +## RCON commands wired + +- `list` → `playerlist` +- `broadcast {msg}` → `say {msg}` +- `kick {player}` → `kick "{player}"` +- `ban {player}` → `banid {player}` +- `save` → `server.save` +- `shutdown` → `quit` + +## Log events captured + +- **Join:** `[/] has entered the game` +- **Leave:** `/ disconnecting: ` + +## Environment vars + +| Var | Default | Notes | +|-----|---------|-------| +| `SERVER_NAME` | `panel Rust` | Shown in the Rust server browser | +| `SERVER_DESCRIPTION` | `Powered by panel` | Browser description / tooltip | +| `SERVER_IDENTITY` | `panel-rust` | Save-tree subdirectory name | +| `SERVER_SEED` | `12345` | Procedural-gen seed | +| `SERVER_WORLD_SIZE` | `3000` | 1000–6000; smaller = faster | +| `MAX_PLAYERS` | `50` | | +| `RCON_PASSWORD` | `panel_rcon` | RCON auth (URL path) | +| `SERVER_LEVEL` | `Procedural Map` | Map preset | +| `SERVER_URL` | `` | Website URL shown in the browser | +| `SERVER_HEADER_IMAGE` | `` | 512×256 banner image URL | + +## Known gotchas + +- First-ever boot takes 2–5 min to generate the world. No players can connect until generation finishes (log: `Server startup complete` is the signal). +- RCON port is published on the container only (panel talks to it over the Docker bridge). Don't expose 28016 externally — password in URL means anyone who sees the URL has full server control. +- If you change `SERVER_WORLD_SIZE` or `SERVER_SEED` on a running server, the next boot wipes the existing world. Rust ties the world to a seed+size pair per identity. +- `libgl1` + a handful of X libs are installed in the image even though the server runs headless; Rust's Unity runtime `dlopen`s them at startup. diff --git a/modules/rust/entrypoint.sh b/modules/rust/entrypoint.sh new file mode 100644 index 00000000..08b8c4eb --- /dev/null +++ b/modules/rust/entrypoint.sh @@ -0,0 +1,114 @@ +#!/bin/bash +# panel-native Rust entrypoint. +# +# Contract: +# /game — SteamCMD-installed Rust Dedicated Server (app 258550). +# Populated by the panel's SteamCMD updater sidecar. +# /game-saves — world/save data + logs volume. +# +# Stage 1 runs as root: chown volumes, symlink server/ into the +# save volume, then drop to the `panel` user via setpriv. + +set -euo pipefail + +log() { printf '[panel-rust] %s\n' "$*"; } + +# ----- stage 1: root-only bootstrap ----- +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + + log "dropping privileges to panel (UID 1000)" + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env \ + HOME=/home/panel \ + SERVER_NAME="${SERVER_NAME:-panel Rust}" \ + SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-Powered by panel}" \ + SERVER_IDENTITY="${SERVER_IDENTITY:-panel-rust}" \ + SERVER_SEED="${SERVER_SEED:-12345}" \ + SERVER_WORLD_SIZE="${SERVER_WORLD_SIZE:-3000}" \ + MAX_PLAYERS="${MAX_PLAYERS:-50}" \ + RCON_PASSWORD="${RCON_PASSWORD:-panel_rcon}" \ + SERVER_URL="${SERVER_URL:-}" \ + SERVER_HEADER_IMAGE="${SERVER_HEADER_IMAGE:-}" \ + SERVER_LEVEL="${SERVER_LEVEL:-Procedural Map}" \ + GAME_PORT="${GAME_PORT:-28015}" \ + RCON_PORT="${RCON_PORT:-28016}" \ + RUSTPLUS_PORT="${RUSTPLUS_PORT:-28082}" \ + LD_LIBRARY_PATH="/game:/game/RustDedicated_Data/Plugins:/game/RustDedicated_Data/Plugins/x86_64:${LD_LIBRARY_PATH:-}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +DEDI_EXE="${GAME_DIR}/RustDedicated" + +if [[ ! -x "${DEDI_EXE}" ]]; then + log "ERROR: ${DEDI_EXE} not found or not executable." + log " Run 'Update' on this server in the panel — that launches a" + log " SteamCMD sidecar which installs Rust (app 258550) here." + exit 78 # EX_CONFIG +fi + +# --- persist server// into the save volume --- +# RustDedicated writes its world + save + cfg tree to /server/. +# First boot moves it to /game-saves + symlinks back, so SteamCMD validate +# can't clobber world data. Idempotent on re-boot. +mkdir -p "${SAVE_DIR}/server" +if [[ -d "${GAME_DIR}/server" && ! -L "${GAME_DIR}/server" ]]; then + # On fresh install this dir doesn't exist; server creates it on first boot. + # If it does exist (e.g. from an earlier panel image without the symlink), + # migrate and replace. + if [[ ! -d "${SAVE_DIR}/server/${SERVER_IDENTITY}" ]]; then + mv "${GAME_DIR}/server"/* "${SAVE_DIR}/server/" 2>/dev/null || true + fi + rm -rf "${GAME_DIR}/server" +fi +ln -sfn "${SAVE_DIR}/server" "${GAME_DIR}/server" + +# Mirror logs to the bind-mounted logs dir for external access. +mkdir -p "${SAVE_DIR}/logs" + +cd "${GAME_DIR}" +log "starting Rust Dedicated Server" +log " name: ${SERVER_NAME}" +log " identity: ${SERVER_IDENTITY}" +log " level: ${SERVER_LEVEL}" +log " seed: ${SERVER_SEED}" +log " size: ${SERVER_WORLD_SIZE}" +log " max: ${MAX_PLAYERS}" +log " game port: ${GAME_PORT}/udp" +log " rcon port: ${RCON_PORT}/tcp (internal, websocket)" +log " rust+: ${RUSTPLUS_PORT}/tcp (push, optional)" + +trap 'log "SIGTERM received; stopping server"; kill -TERM "${RUST_PID:-0}" 2>/dev/null || true; wait "${RUST_PID:-0}" 2>/dev/null || true; exit 0' TERM INT + +# Rust's own flags — all `+` prefixed. `-batchmode` / `-nographics` come +# before the `+` convars. We intentionally omit `-logFile`: Unity defaults +# to stdout when absent, and passing `/dev/stdout` or `-` makes RustDedicated +# fail with "Unable to open log file, exiting." on Linux-container Unity. +exec stdbuf -oL -eL "${DEDI_EXE}" \ + -batchmode \ + -nographics \ + +server.ip 0.0.0.0 \ + +server.port "${GAME_PORT}" \ + +server.tickrate 10 \ + +server.hostname "${SERVER_NAME}" \ + +server.description "${SERVER_DESCRIPTION}" \ + +server.identity "${SERVER_IDENTITY}" \ + +server.level "${SERVER_LEVEL}" \ + +server.seed "${SERVER_SEED}" \ + +server.worldsize "${SERVER_WORLD_SIZE}" \ + +server.maxplayers "${MAX_PLAYERS}" \ + +server.saveinterval 600 \ + +server.url "${SERVER_URL}" \ + +server.headerimage "${SERVER_HEADER_IMAGE}" \ + +rcon.web 1 \ + +rcon.ip 0.0.0.0 \ + +rcon.port "${RCON_PORT}" \ + +rcon.password "${RCON_PASSWORD}" \ + +app.port "${RUSTPLUS_PORT}" & + +RUST_PID=$! +wait "${RUST_PID}" diff --git a/modules/rust/module.yaml b/modules/rust/module.yaml new file mode 100644 index 00000000..b9874ad3 --- /dev/null +++ b/modules/rust/module.yaml @@ -0,0 +1,131 @@ +# Rust (Facepunch Studios) dedicated server module. +# +# Native Linux binary via SteamCMD app 258550. Uses WebSocket RCON (Facepunch +# dropped Source RCON years ago), which the panel speaks via the +# `websocket_rcon` adapter added in this module's first shipping session. +# +# Reference for flags + ports: Rust dedicated server wiki + widespread +# community configs. No AMP template exists for Rust in CubeCoders' public +# repo (Rust is in AMP's built-in module set, not community-contributed). + +id: rust +name: "Rust" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + # Built locally from ./Dockerfile. Rebuild with: + # docker build -t panel-rust:latest modules/rust + image: panel-rust:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "server// save data, cfg/, user bans + mutes, oxide/" + - name: "Game Files" + path: /game + hint: "RustDedicated binary + Bundles/ + shipped configs" + env: + SERVER_NAME: "panel Rust" + SERVER_DESCRIPTION: "Powered by panel" + SERVER_IDENTITY: "panel-rust" + SERVER_SEED: "12345" + SERVER_WORLD_SIZE: "3000" # smaller = faster load; default 4000 + MAX_PLAYERS: "50" + RCON_PASSWORD: "panel_rcon" + SERVER_URL: "" + SERVER_HEADER_IMAGE: "" + # Level selector: Procedural Map (default), Barren, HapisIsland, SavasIsland, CraggyIsland + SERVER_LEVEL: "Procedural Map" + # Pre-declared so allocator-derived ports survive resolve.go's filter. + GAME_PORT: "28015" + RCON_PORT: "28016" + RUSTPLUS_PORT: "28082" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +# Rust ports: +# 28015/udp — main game traffic (required) +# 28015/tcp — app port for some tooling (optional; not published by default) +# 28016/tcp — WebSocket RCON (internal; panel talks to it) +# 28082/tcp — Rust+ push notifications (optional) +ports: + - { name: game, proto: udp, default: 28015, required: true, env: GAME_PORT } + - { name: rcon, proto: tcp, default: 28016, internal: true, env: RCON_PORT } + - { name: rustplus, proto: tcp, default: 28082, required: false, env: RUSTPLUS_PORT } + +resources: + min_ram_mb: 6144 + recommended_ram_mb: 12288 + +# Per-instance generated RCON secret. The agent generates a random +# 24-byte token at create time (pkg/module GenerateSecrets), which +# overrides the env default for RCON_PASSWORD below — so every instance +# gets a unique password. The weak env default only applies to +# containers launched outside the panel. +secrets: + - name: RCON_PASSWORD + description: "Rust RCON password — generated per instance" + generated: true + +rcon: + adapter: websocket_rcon + host_port: rcon + auth: password_in_url + password_secret: RCON_PASSWORD + # Legacy fallback only (pre-generated-secret instances). New instances + # always get the generated RCON_PASSWORD secret above. + password_literal: "panel_rcon" + commands: + list: "playerlist" + broadcast: "say {msg}" + kick: "kick \"{player}\"" + ban: "banid {player}" + save: "server.save" + shutdown: "quit" + +state_sources: + - type: rcon + command: "playerlist" + every: 60s + +# Log-line event patterns (RE2; named groups use (?P...)). +# Rust prints join/leave to both stdout and the save-side log; stdout is +# what the log pump tails. +events: + join: + pattern: '(?P.+?)\[[0-9]+/(?P\d{17})\] has entered the game' + kind: join + leave: + pattern: '(?P\d{17})/(?P.+?) disconnecting: (?P.+)$' + kind: leave + +update_providers: + - id: stable + kind: steamcmd + app_id: "258550" + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Server startup complete|Listening on 0\.0\.0\.0:28015/i' +appearance: + emoji: "🔧" + grad: "linear-gradient(135deg,#8b4a2b,#d97706)" + art: "/game-art/rust.jpg" + steam: "252490" diff --git a/modules/satisfactory/Dockerfile b/modules/satisfactory/Dockerfile new file mode 100644 index 00000000..b1815aba --- /dev/null +++ b/modules/satisfactory/Dockerfile @@ -0,0 +1,34 @@ +# panel-native Satisfactory runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libatomic1 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 7777/udp 15000/udp 7777/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/satisfactory/entrypoint.sh b/modules/satisfactory/entrypoint.sh new file mode 100644 index 00000000..f440d3b6 --- /dev/null +++ b/modules/satisfactory/entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# panel-native Satisfactory entrypoint. +set -euo pipefail +log() { printf '[panel-satisfactory] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + GAME_PORT="${GAME_PORT:-7777}" RELIABLE_PORT="${RELIABLE_PORT:-8888}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +START_SH="${GAME_DIR}/FactoryServer.sh" + +if [[ ! -x "${START_SH}" ]]; then + log "ERROR: ${START_SH} not found. Run 'Update' in the panel to install Satisfactory via SteamCMD." + exit 78 +fi + +# Satisfactory writes SaveGames / Engine configs to +# $HOME/.config/Epic/FactoryGame. HOME is /game-saves → saves land in the +# save volume without further fiddling. +mkdir -p "${SAVE_DIR}/.config/Epic/FactoryGame/Saved/SaveGames" + +GAME_PORT="${GAME_PORT:-7777}" +RELIABLE_PORT="${RELIABLE_PORT:-8888}" + +cd "${GAME_DIR}" +log "starting Satisfactory dedicated server (game=${GAME_PORT}/udp+tcp reliable=${RELIABLE_PORT}/tcp)" + +# -Port drives both the UDP game socket and the HTTPS API listener (they +# share the number). -ReliablePort is the TCP reliability channel (1.0+). +# -stdout -FullStdOutLogOutput route UE logs to stdout for `docker logs`. +exec stdbuf -oL -eL "${START_SH}" \ + -Port="${GAME_PORT}" \ + -ReliablePort="${RELIABLE_PORT}" \ + -multihome=0.0.0.0 \ + -stdout -FullStdOutLogOutput \ + -unattended diff --git a/modules/satisfactory/module.yaml b/modules/satisfactory/module.yaml new file mode 100644 index 00000000..f08de157 --- /dev/null +++ b/modules/satisfactory/module.yaml @@ -0,0 +1,92 @@ +# Satisfactory — panel-native dedicated server module. +# +# Native Linux binary from SteamCMD app 1690800. Admin is via Coffee Stain's +# HTTPS API on the game port (7777/tcp — separate from the UDP game traffic). +# No traditional RCON in v1; users admin via the in-game UI + API tokens. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (SatisfactoryLinux.kvp) + +id: satisfactory +name: "Satisfactory" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-satisfactory:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "SaveGames/ + Engine/ + Game.ini" + - name: "Game Files" + path: /game + hint: "Server binaries" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + # Port env vars MUST be pre-declared here — the agent's ResolveDocker + # only lets ConfigValues override keys that already exist in this map, + # so the allocator's per-instance ports would otherwise be dropped. + env: + GAME_PORT: "7777" + RELIABLE_PORT: "8888" + +# Satisfactory 1.0+ port model: ONE game port (UDP game traffic + the +# HTTPS admin API on the same number over TCP — the server binds both +# itself under host networking) plus a TCP "reliable" messaging port. +# The pre-1.0 beacon/query ports no longer exist. +ports: + - { name: game, proto: udp, default: 7777, required: true, env: GAME_PORT } + - { name: reliable, proto: tcp, default: 8888, required: true, env: RELIABLE_PORT } + +resources: + min_ram_mb: 6144 + recommended_ram_mb: 12288 +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (satisfactory.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'LogNet: Login request: \?ClientIdentity=.+\?EntryTicket=.+?EncryptionToken=.+\?Name=(?P.+?) userId: .+\(ForeignId=\[Type=.+ Handle=.+ RepData=\[(?P.+)\]\) platform: ' + kind: join + leave: + pattern: 'LogNet: UChannel::Close: Sending CloseBunch\..+\[UNetConnection\] RemoteAddr: .+, Name: .+, Driver: .+, IsServer: YES, PC: .+, Owner: .+, UniqueId: .+\(ForeignId=\[Type=.+ Handle=.+ RepData=\[(?P.+)\]\)' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "1690800" + install_path: /game + - id: experimental + kind: steamcmd + app_id: "1690800" + beta: experimental + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +# Verified against a real boot 2026-07-14 (v1.2.0-CL-495413): the net +# driver line is when the server becomes joinable (~5s after start). +# "Server startup time elapsed" follows at ~30s once level load settles. +ready_pattern: '/IpNetDriver listening on port \d+|Server startup time elapsed/i' +appearance: + emoji: "🏭" + grad: "linear-gradient(135deg,#d97706,#fbbf24)" + art: "/game-art/satisfactory.jpg" + steam: "526870" diff --git a/modules/sons-of-the-forest/Dockerfile b/modules/sons-of-the-forest/Dockerfile new file mode 100644 index 00000000..e8305c8c --- /dev/null +++ b/modules/sons-of-the-forest/Dockerfile @@ -0,0 +1,41 @@ +# panel-native Sons Of The Forest runtime image (Wine). +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + WINEDEBUG=-all \ + WINEPREFIX=/home/panel/.wine \ + DISPLAY=:99 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + xvfb \ + xauth \ + wine64 \ + wine \ + winbind \ + cabextract \ + procps \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves /home/panel + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8766/udp 27016/udp 9700/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/sons-of-the-forest/entrypoint.sh b/modules/sons-of-the-forest/entrypoint.sh new file mode 100644 index 00000000..885547e7 --- /dev/null +++ b/modules/sons-of-the-forest/entrypoint.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# panel-native Sons Of The Forest entrypoint (Wine). +set -euo pipefail +log() { printf '[panel-sotf] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + SERVER_NAME="${SERVER_NAME:-panel Sons Of The Forest}" \ + MAX_PLAYERS="${MAX_PLAYERS:-8}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-}" \ + GAME_PORT="${GAME_PORT:-8766}" \ + QUERY_PORT="${QUERY_PORT:-27016}" \ + BLOBSYNC_PORT="${BLOBSYNC_PORT:-9700}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +EXE="${GAME_DIR}/SonsOfTheForestDS.exe" +# The server looks for dedicatedserver.cfg directly inside -userdatapath. +CONFIG="${SAVE_DIR}/dedicatedserver.cfg" + +if [[ ! -f "${EXE}" ]]; then + log "ERROR: ${EXE} not found. Run 'Update' in the panel to install SOTF (Windows build) via SteamCMD." + exit 78 +fi + +# Seed dedicatedserver.cfg (JSON-ish) on first boot from env. On subsequent +# boots, patch the port keys so panel-allocated ports flow into a config +# the operator may have hand-edited (preserves their other tweaks). +mkdir -p "${SAVE_DIR}" +if [[ ! -s "${CONFIG}" ]]; then + log "seeding ${CONFIG}" + cat > "${CONFIG}" <&1 | sed 's/^/ /' || true + wineserver -w || true +fi + +trap 'log "SIGTERM"; wineserver -k 2>/dev/null || true; exit 0' TERM INT + +cd "${GAME_DIR}" +log "starting Sons Of The Forest dedicated server" +log " name: ${SERVER_NAME}" +log " game/query: ${GAME_PORT}/${QUERY_PORT}/udp" +log " blobsync: ${BLOBSYNC_PORT}/udp" + +# xvfb-run -a auto-allocates a free X display (host has its own Xvfb on +# :99 / :100 / :101 — see panel/memory/gotchas.md). +# Args mirror AMPTemplates sons-of-the-forest.kvp App.CommandLineArgs: +# -dedicatedserver.* CLI overrides beat the cfg, so panel-allocated ports +# always win even if the operator hand-edits dedicatedserver.cfg. +exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ + wine "${EXE}" \ + -dedicatedserver.IpAddress "0.0.0.0" \ + -dedicatedserver.GamePort "${GAME_PORT}" \ + -dedicatedserver.QueryPort "${QUERY_PORT}" \ + -dedicatedserver.BlobSyncPort "${BLOBSYNC_PORT}" \ + -userdatapath "${SAVE_DIR}" diff --git a/modules/sons-of-the-forest/module.yaml b/modules/sons-of-the-forest/module.yaml new file mode 100644 index 00000000..620bee49 --- /dev/null +++ b/modules/sons-of-the-forest/module.yaml @@ -0,0 +1,92 @@ +# Sons Of The Forest — panel-native dedicated server module (Wine). +# +# Windows-only dedicated server. Same pattern as V Rising + Empyrion: +# Debian + Wine + Xvfb + tini, SteamCMD with platform:windows. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (SonsOfTheForest.kvp) + +id: sons-of-the-forest +name: "Sons Of The Forest" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-sons-of-the-forest:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "dedicatedserver.cfg + Saves/" + - name: "Game Files" + path: /game + hint: "Server binaries" + env: + SERVER_NAME: "panel Sons Of The Forest" + MAX_PLAYERS: "8" + SERVER_PASSWORD: "" + # Pre-declared so allocator-derived ports survive resolve.go's filter. + GAME_PORT: "8766" + QUERY_PORT: "27016" + BLOBSYNC_PORT: "9700" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + - { name: game, proto: udp, default: 8766, required: true, env: GAME_PORT } + - { name: query, proto: udp, default: 27016, required: true, env: QUERY_PORT } +# NOTE: name stays "join" (SOTF calls it BlobSync) — the running prod +# controller's in-memory manifest allocates ports by this name, and the +# agent maps allocation→env by matching the same name. Renaming it only +# works after a controller restart; the env var is what the game sees. + - { name: join, proto: udp, default: 9700, required: true, env: BLOBSYNC_PORT } + +resources: + min_ram_mb: 8192 + recommended_ram_mb: 16384 + +# No rcon: AMP's kvp claims AdminMethod=STDIO, but live smoke test +# (2026-07-14) showed the server ignores stdin entirely — `save`, `help` +# and `stop` all produced zero reaction under wine+xvfb-run. Admin is +# in-game only (like empyrion). Graceful stop = SIGTERM via entrypoint trap. +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (sons-of-the-forest.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'Steam auth successful for client \d+ with steam id (?P\S+?)[.,] username (?P.+)$' + kind: join + leave: + pattern: 'Unregistering client \d+ with steam id (?P\S+?)[.,] username (?P.+)$' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "2465200" + platform: windows + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Dedicated server loaded|\[Self-Tests\] Please restart the server/i' +appearance: + emoji: "🌲" + grad: "linear-gradient(135deg,#14532d,#4d7c0f)" + art: "/game-art/sons-of-the-forest.jpg" + steam: "1326470" diff --git a/modules/soulmask/Dockerfile b/modules/soulmask/Dockerfile new file mode 100644 index 00000000..8fe0c553 --- /dev/null +++ b/modules/soulmask/Dockerfile @@ -0,0 +1,34 @@ +# panel-native Soulmask runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libatomic1 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 8777/udp 27015/udp 8888/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/soulmask/entrypoint.sh b/modules/soulmask/entrypoint.sh new file mode 100644 index 00000000..9e8ed4f1 --- /dev/null +++ b/modules/soulmask/entrypoint.sh @@ -0,0 +1,102 @@ +#!/bin/bash +# panel-native Soulmask entrypoint. +set -euo pipefail +log() { printf '[panel-soulmask] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + SERVER_NAME="${SERVER_NAME:-panel Soulmask}" \ + MAX_PLAYERS="${MAX_PLAYERS:-30}" \ + ADMIN_PASSWORD="${ADMIN_PASSWORD:-changeme}" \ + GAME_PORT="${GAME_PORT:-8777}" \ + QUERY_PORT="${QUERY_PORT:-27015}" \ + GAME_MODE="${GAME_MODE:-pve}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-}" \ + SAVING="${SAVING:-600}" \ + BACKUP="${BACKUP:-900}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +EXE="${GAME_DIR}/WS/Binaries/Linux/WSServer-Linux-Shipping" +# Official launcher shipped inside app 3017300. It sets LD_LIBRARY_PATH to +# ./linux64 and passes the required leading "WS" project token before our +# args — WITHOUT it the engine inits then immediately EngineExit()s. Always +# launch through this wrapper, never exec the ELF directly. +SH="${GAME_DIR}/WSServer.sh" + +if [[ ! -x "${EXE}" ]]; then + log "ERROR: Soulmask server binary not found at ${EXE}." + log " Run Update (SteamCMD app 3017300 — the native Linux server)." + exit 78 +fi + +# Saves live under WS/Saved — redirect into the save volume. +mkdir -p "${SAVE_DIR}/WS/Saved" +if [[ -d "${GAME_DIR}/WS/Saved" && ! -L "${GAME_DIR}/WS/Saved" ]]; then + if [[ ! -d "${SAVE_DIR}/WS/Saved" || -z "$(ls -A "${SAVE_DIR}/WS/Saved" 2>/dev/null)" ]]; then + cp -a "${GAME_DIR}/WS/Saved/." "${SAVE_DIR}/WS/Saved/" 2>/dev/null || true + fi + rm -rf "${GAME_DIR}/WS/Saved" +fi +ln -sfn "${SAVE_DIR}/WS/Saved" "${GAME_DIR}/WS/Saved" + +# Deep gameplay settings: the panel renders GameXishu.json from config_values +# and bind-mounts it at /game-saves/GameXishu.json.rendered (read-only). Copy +# it into the path the server reads on boot. Confirmed load line: +# logGameXishu: UHGameXiShuGuanLiQi::LoadFromJsonFile File:.../WS/Saved/GameplaySettings/GameXishu.json Success. +RENDERED="${SAVE_DIR}/GameXishu.json.rendered" +if [[ -f "${RENDERED}" ]]; then + mkdir -p "${SAVE_DIR}/WS/Saved/GameplaySettings" + cp -f "${RENDERED}" "${SAVE_DIR}/WS/Saved/GameplaySettings/GameXishu.json" + log "applied rendered GameXishu.json (deep gameplay settings)" +else + log "no rendered GameXishu.json — server will use built-in defaults" +fi + +cd "${GAME_DIR}" +GAME_PORT="${GAME_PORT:-8777}" +QUERY_PORT="${QUERY_PORT:-27015}" +GAME_MODE="${GAME_MODE:-pve}" +# Normalize game mode to the launch flag the server expects (-pve / -pvp). +case "${GAME_MODE,,}" in + pvp) MODE_FLAG="-pvp" ;; + *) MODE_FLAG="-pve" ;; +esac +# Optional join password (-PSW). Admin password is -adminpsw (NOT -PSW — an +# earlier version passed the admin pw as -PSW, which set the JOIN password to +# the admin secret and left admin unset). +PSW_ARG=() +if [[ -n "${SERVER_PASSWORD:-}" ]]; then + PSW_ARG=(-PSW="${SERVER_PASSWORD}") +fi + +log "starting Soulmask dedicated server" +log " name: ${SERVER_NAME}" +log " slots: ${MAX_PLAYERS}" +log " ports: game=${GAME_PORT} query=${QUERY_PORT}" +log " mode: ${MODE_FLAG} save=${SAVING:-600}s backup=${BACKUP:-900}s" + +# Launch through WSServer.sh (handles LD_LIBRARY_PATH + the leading "WS" +# token). -Port/-QueryPort honor panel-allocated ports; -adminpsw sets admin; +# -PSW (optional) sets the join password; -pve/-pvp + -saving/-backup are the +# server-level knobs. Confirmed boot: world up, match state WaitingToStart. +# IMPORTANT: server name + max players MUST be passed as the -SteamServerName +# and -MaxPlayers FLAGS, not as ?ServerName=?MaxPlayers= URL options on the +# level string. The URL-option form is silently ignored for Steam registration +# — the server registered as "UNNAMED_SERVER" / 0/20 slots. This flag format +# matches the proven jsknnr/soulmask-dedicated-server image. The level is bare +# (no ?listen? / ?ServerName= options); -server -online=Steam -forcepassthrough +# are required for correct dedicated-server + Steam behaviour. +exec stdbuf -oL -eL "${SH}" \ + Level01_Main -server -SILENT \ + -SteamServerName="${SERVER_NAME}" -MaxPlayers="${MAX_PLAYERS}" \ + "${MODE_FLAG}" -saving="${SAVING:-600}" -backup="${BACKUP:-900}" \ + -MULTIHOME=0.0.0.0 -Port="${GAME_PORT}" -QueryPort="${QUERY_PORT}" \ + -online=Steam -forcepassthrough \ + -adminpsw="${ADMIN_PASSWORD}" "${PSW_ARG[@]}" \ + -log -UTF8Output -useperfthreads -NoAsyncLoadingThread diff --git a/modules/soulmask/module.yaml b/modules/soulmask/module.yaml new file mode 100644 index 00000000..0d113569 --- /dev/null +++ b/modules/soulmask/module.yaml @@ -0,0 +1,115 @@ +# Soulmask — panel-native dedicated server module. +# +# Native Linux dedicated server. The Linux server ships under SteamCMD app +# 3017300 (Steam labels it "Soulmask Dedicated Server", and it is the app the +# community jsknnr/soulmask-dedicated-server image installs). It produces a +# native ELF: WS/Binaries/Linux/WSServer-Linux-Shipping + WSServer.sh. +# +# NOTE: app 3017310 is the WINDOWS-only build ("Soulmask Dedicated Server For +# Windows" — verified: its depot has no Linux binaries, only WSServer.exe / +# Win64). Do NOT install it on Linux; it never boots. An earlier version of +# this module led with 3017310 and treated 3017300 as "content", which meant +# the server never got a runnable binary. Fixed 2026-07-08: 3017300 only. +# +# Reference: https://github.com/jsknnr/soulmask-dedicated-server (native Linux, +# no Proton) — installs STEAM_APP_ID 3017300, launches WSServer.sh. + +id: soulmask +name: "Soulmask" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-soulmask:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "WS/Saved/ + GameUserSettings.ini" + - name: "Game Files" + path: /game + hint: "Server binaries, WS/Content/" + env: + SERVER_NAME: "panel Soulmask" + MAX_PLAYERS: "30" + ADMIN_PASSWORD: "changeme" + # Pre-declared so the agent's resolve.go filter lets panel-allocated + # ports flow through to the container env. The entrypoint passes these + # as -Port / -QueryPort; without them the server binds the hardcoded + # 8777/27015 and ignores the panel allocation (breaks multi-instance + + # the opnfwd forward). Defaults match the port defaults. + GAME_PORT: "8777" + QUERY_PORT: "27015" + # Server-level settings passed as launch args by the entrypoint. + GAME_MODE: "pve" # pve | pvp + SERVER_PASSWORD: "" # join password (-PSW); empty = open + SAVING: "600" # world save interval (seconds) + BACKUP: "900" # backup interval (seconds) + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + # Panel-rendered GameXishu.json (deep gameplay settings, all 3 sections). + # The entrypoint copies this into WS/Saved/GameplaySettings/ where the + # server reads it on boot (confirmed: logGameXishu LoadFromJsonFile + # Success). read_only so the template stays authoritative. + - { target: "$DATA_PATH/GameXishu.json.rendered", container: "/game-saves/GameXishu.json.rendered", read_only: true } + +ports: + - { name: game, proto: udp, default: 8777, required: true, env: GAME_PORT } + - { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT } + - { name: admin, proto: tcp, default: 8888, internal: true } # REST admin + +resources: + min_ram_mb: 6144 + recommended_ram_mb: 12288 +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (soulmask.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'logStoreGamemode: player ready\. Addr:.*?, Netuid:(?P\d+), Name:(?P.+)$' + kind: join + leave: + pattern: 'logStoreGamemode: Display: player leave world\. (?P\d+)$' + kind: leave + + +update_providers: + # 3017300 is the native Linux dedicated server (ELF WSServer-Linux-Shipping). + # This single app is the complete install — NOT 3017310 (Windows-only). + - id: stable + kind: steamcmd + app_id: "3017300" + install_path: /game + +# Deep gameplay settings (harvest/xp/decay/damage/consumption ratios) are +# rendered from config_values into GameXishu.json — the 276-key × 3-section +# file the server reads on boot. Same durable-render path 7DTD uses; gamehost +# routes soulmask settings-saves through config-render (DurableConfig=true). +config_files: + - path: GameXishu.json.rendered + format: json + template: templates/GameXishu.json.tmpl + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Starting up WSServer|Network connection saved/i' +appearance: + emoji: "🗿" + grad: "linear-gradient(135deg,#3a2416,#7a5230)" + art: "/game-art/soulmask.jpg" + steam: "2646460" diff --git a/modules/soulmask/templates/GameXishu.json.tmpl b/modules/soulmask/templates/GameXishu.json.tmpl new file mode 100644 index 00000000..9512d23a --- /dev/null +++ b/modules/soulmask/templates/GameXishu.json.tmpl @@ -0,0 +1,836 @@ +{ + "0": { + "ExpRatio": {{ or .Values.gx_exp_ratio "1" }}, + "ZuoWuDropRatio": 1, + "AddRenKeDuRatio": 5, + "ZuoWuShengZhangRatio": {{ or .Values.gx_crop_growth_ratio "1" }}, + "BinSiKaiGuan": 1, + "JianZhuFuLanMul": 1, + "JianZhuXiuLiMul": 1, + "JianZhuFuLanKaiGuan": {{ or .Values.gx_building_decay "1" }}, + "ZhiZuoTimeRatio": 5, + "DamageYeShengRatio": 1, + "BeDamageByYeShengRatio": 1, + "GameWorldDayTimePortion": {{ or .Values.gx_daytime_portion "0.800000011920929" }}, + "GameWorldTimePower": {{ or .Values.gx_day_length "24" }}, + "BaoXiangDropRatio": 1, + "HuXIangShangHaiKaiGuan": {{ or .Values.gx_pvp_damage "1" }}, + "ZhiBeiChongShengRatio": 1, + "ChengZhangExpRatio": 1, + "MJExpRatio": 1, + "CaiJiDiaoLuoRatio": {{ or .Values.gx_harvest_ratio "1" }}, + "FaMuDiaoLuoRatio": {{ or .Values.gx_wood_ratio "1" }}, + "CaiKuangDiaoLuoRatio": {{ or .Values.gx_mining_ratio "1" }}, + "DongWuShiTiDiaoLuoRatio": {{ or .Values.gx_animal_loot_ratio "1" }}, + "DongWuShiTiZhongYaoDiaoLuoRatio": 1, + "CaiJiShengChanJianZhuDiaoLuoRatio": 1, + "PuTongRenDiaoLuoRatio": 1, + "JingYingRenDiaoLuoRatio": 1, + "BossRenDiaoLuoRatio": 1, + "ShuLianDuExpRatio": 1, + "NaiJiuXiShu": 1, + "ReDuXiShu": 1, + "CaiJiExpRatio": {{ or .Values.gx_gather_exp_ratio "1" }}, + "ZhiZuoExpRatio": 1, + "ShaGuaiExpRatio": {{ or .Values.gx_kill_exp_ratio "1" }}, + "QiTaExpRatio": 1, + "BeiDongYiJiShuXingRatio": 1, + "ZhuDongYiJiShuXingRatio": 1, + "ErJiShuXingRatio": 1, + "MaRenBeiDongYiJiShuXingRatio": 1, + "MaRenZhuDongYiJiShuXingRatio": 1, + "MaRenErJiShuXingRatio": 1, + "DongWuBeiDongYiJiShuXingRatio": 1, + "DongWuZhuDongYiJiShuXingRatio": 1, + "DongWuErJiShuXingRatio": 1, + "ShiWuXiaoHaoRatio": {{ or .Values.gx_food_consume_ratio "1" }}, + "ShuiXiaoHaoRatio": {{ or .Values.gx_water_consume_ratio "1" }}, + "QiXiXiaoHaoRatio": 1, + "ShengMingHuiFuRatio": 1, + "TiLiHuiFuRatio": 1, + "QiXiHuiFuRatio": 1, + "DongWuDamageRatio": 1, + "DongWuJianShangRatio": 1, + "MaRenDamageRatio": 1, + "ManRenJianShangRatio": 1, + "JiaSiHuiFuRatio": 1, + "CaiJiDamageRatio": 1, + "ZiYuanShengMingRatio": 1, + "RanLiaoXiaoHaoRatio": 1, + "DongWuShengZhangRatio": {{ or .Values.gx_animal_growth_ratio "1" }}, + "FanZhiJianGeRatio": 1, + "DongWuShengChanJianGeRatio": 1, + "DongWuChanChuRatio": 1, + "DongWuXiaoHaoShiWuRatio": 1, + "DongWuXiaoHaoShuiRatio": 1, + "ZuoWuFeiLiaoXiaoHaoRatio": 1, + "ZuoWuShuiXiaoHaoRatio": 1, + "ZuoWuXiaoHuiRatio": 1, + "GongJiJianZhuDamageRatio": 1, + "DongWuPinZhiRatio": 1, + "ManRenPinZhiRatio": 1, + "WanJiaZiYuanJinShuaBanJing": 1, + "JianZhuZiYuanJinShuaBanJing": 1, + "WuPinFuHuaiRatio": {{ or .Values.gx_item_decay_ratio "1" }}, + "WuPinXiaoHuiTime": 1, + "XiuLiXuYaoCaiLiaoRatio": 1, + "XiuLiJiangNaiJiuShangXianRatio": 1, + "HuiFuChuShiBodyData": 1, + "WanMeiChongSu": 1, + "FuHuoMoveSiWangBaoKaiGuan": {{ or .Values.gx_drop_on_death "0" }}, + "JianZhuChuanSongMenPlusKaiGuan": 0, + "ShuaXinNPCKaiGuan": 0, + "SuiJiRuQinKaiGuan": 0, + "RuQinKaiGuan": 0, + "RuQinGuiMoXiShu": 1, + "RuQinQiangDuXiShu": 1, + "RuQinGuaiCountMin": 8, + "RuQinGuaiCountMax": 128, + "RuQinPerBoGuaiMin": 3, + "RuQinPerBoGuaiMax": 16, + "RuQinGuaiLevelXiShu": 1, + "TanChaMinuteLimit": 20, + "JinGongMinuteLimit": 90, + "LengQueMinuteLimit": 1440, + "ChongsuRatio": 1, + "RuQinMaxChangCiCount": 2, + "RuQinBeginHour": 0, + "RuQinEndHour": 24, + "RuQinShaoChengXiShu": 0.6000000238418579, + "RuQinTuShaXiShu": 0.30000001192092896, + "XiuMianDistance": 10000, + "HuanXingDistance": 9000, + "GongHuiMaxZhaoMuCount": 50, + "GeRenMaxZhaoMuCount": 6, + "GeRenMaxZhaoMuCount_Two": 10, + "GeRenMaxZhaoMuCount_Three": 15, + "XinQingZengZhang": 1, + "XinQingJianShao": 1, + "XiShuWeiLing": 0, + "YeShengHitJianZhuShangHaiRatio": 4, + "PVP_ShangHaiRatio_WithoutP2P_YouFang": 0, + "WanJiaHitJianZhuShangHaiRatio": 1, + "ShaGuaiExpShareRatio": 1, + "YunXuOtherDaKaiGongZuoTai": 0, + "YunXuOtherDaKaiXiangZi": 0, + "PVEOnlyTongGuiShuCanOpenKaiGuan": 1, + "PVP_ShangHaiRatio_JinZhan": 0.4000000059604645, + "PVP_ShangHaiRatio_YuanCheng": 0.4000000059604645, + "GongHuiMaxDongWuCount": 50, + "GeRenMaxDongWuCount": 10, + "PanpaKaiGuan": 1, + "WanJiaBeiXiaoRenRatio": 0.800000011920929, + "WanJiaBeiXiaoTiRatio": 0.800000011920929, + "KaiQiJianZhuHuiXueBuilding": 1, + "PVP_ShangHaiRatio_PlayerToPlayer_DiFang": 1, + "PVP_GAPVPDamageRatio": 1, + "PlayerYouFangShangHaiKaiGuan": 1, + "YouFangShangHaiKaiGuan": 0, + "PVP_ShangHaiRatio_PlayerToPlayer_YouFang": 0.05999999865889549, + "BaoXiangDiaoLuoDengJi": 0, + "KaiQiKuaFu": 0, + "FuHuaSpeed": 1, + "YingHuoRanShaoSuDuRatio": 1, + "HuDongExcludeBetweenCameraCharacter": 1, + "PVPTimeAsiaWorkStartTime": 11, + "PVPTimeAsiaWorkEndTime": 15, + "PVPTimeAsiaNoWorkStartTime": 11, + "PVPTimeAsiaNoWorkEndTime": 15, + "PVPTimeAmericaWorkStartTime": 1, + "PVPTimeAmericaWorkEndTime": 5, + "PVPTimeAmericaNoWorkStartTime": 1, + "PVPTimeAmericaNoWorkEndTime": 5, + "PVPTimeEuropeWorkStartTime": 18, + "PVPTimeEuropeWorkEndTime": 22, + "PVPTimeEuropeNoWorkStartTime": 18, + "PVPTimeEuropeNoWorkEndTime": 22, + "WuLiYouHuaDist": 6666, + "MovementYouHua": 1, + "XiuMianOfflineDays": 7, + "WuLiYouHuaKaiGuan": 1, + "TiaoWuLengQueTime": 4, + "MaxLevel": 60, + "XinXiLuRu": 5, + "TeShuDaoJuDropXiShuJiaChengKaiGuan": 0, + "MaxGenRenYingHuoNumber": 6, + "MaxGongHuiYingHuoNumber": 6, + "MaxChuanSongMenNumber": 10, + "JingShenNoXiaoHao": 0, + "MaxFuZhongRatio": 1, + "RoleBagCapacity": 60, + "JianZhuGaoDuLimit": 1, + "AIDengJi": 1, + "GeRenBiaoJiMaxCount": 20, + "GongHuiBiaoJiMaxCount": 20, + "ManRenChuZhanCount": 1, + "MakeUseAroundRongQiKaiGuan": 1, + "JianZhuBeDamageLimit": 1, + "JianZhuAroundNumLimit": 1, + "GongHuiMaxMember": 10, + "DynamicBossStats": 1, + "ZhaoHuanDisRatio": 1, + "DongWuChuZhanCount": 1, + "SuoDingKaiGuan": 1, + "ZuRenFuZhi": 1, + "TransDoorInterworkKaiGuan": 0, + "ConverPropsSpeedRatio": 5, + "MaxXiuMianCangCount": 50, + "RollingInvincibleTimeRatio": 1, + "MaxConvertCount": 3, + "AsiaWarTimeStart": 10, + "AsiaWarTimeEnd": 14, + "EuropeWarTimeStart": 17, + "EuropeWarTimeEnd": 21, + "AmericaWarTimeStart": 0, + "AmericaWarTimeEnd": 4, + "WarKaiGuan": 0, + "TribalExplorationKaiGuan": 1, + "RuinsExplorationKaiGuan": 1, + "TribalTransportSwitch": 1, + "SpecialBossSwitch": 1, + "RelicChestEventSwitch": 0, + "BossDeathEventSwitch": 0, + "KurmaFuZhongRatio": 1, + "GongHuiMaxSpecDongWuCount": 1, + "GeRenMaxSpecDongWuCount": 1, + "ProtectJianZhuInYingHuoSwitch": 0, + "SpecialEventConfigSwitch": 0, + "SpecialEventTriggerInterval": 3600, + "SpecialEventTriggerPercent": 50, + "SpecialEventTriggetLimitNum": 2, + "SpecialEventServerOpenDay": 1, + "SpecialEventAsiaStartTime": 10, + "SpecialEventAsiaEndTime": 16, + "SpecialEventEuropeStartTime": 17, + "SpecialEventEuropeEndTime": 23, + "SpecialEventAmericaStartTime": 23, + "SpecialEventAmericaEndTime": 5, + "SpecialEventGameDist": 0, + "MaskRepairUpgradeSwitch": 0, + "IsOpenGuideTask": 1, + "NormalEquipDropRatioCorrection": 0.10000000149011612, + "EliteEquipDropRatioCorrection": 0.20000000298023224, + "BossEquipDropRatioCorrection": 0.20000000298023224, + "NormalEquipDurabilityCorrection": 0.10000000149011612, + "EliteEquipDurabilityCorrection": 0.5, + "BossEquipDurabilityCorrection": 0.5, + "InitialDefaultAwarenessLevel": 1, + "FirstDayMaxAwarenessLevel": 29, + "SecondDayMaxAwarenessLevel": 34, + "ThirdDayMaxAwarenessLevel": 39, + "FourthDayMaxAwarenessLevel": 44, + "FifthDayMaxAwarenessLevel": 49, + "SixthDayMaxAwarenessLevel": 54, + "SeventhDayMaxAwarenessLevel": 59, + "EighthDayMaxAwarenessLevel": 60, + "NinthDayMaxAwarenessLevel": 60, + "TenthDayMaxAwarenessLevel": 60, + "MentalRecoveryRate": 1, + "PhysicalRecoveryIntervalRate": 1, + "DungeonReborn": 1, + "ManageModeRuQin": 1, + "ManageModeRuQinCountDownTimeRatio": 1, + "IsPlayBossAppearanceSequence": 1, + "PlayerDeathCantDropItemKaiGuan": 0, + "AnimalFollowerMaxCount": 1, + "DrawDebugDungeon": 0, + "RuQinSucceedPrizeTimes": 2, + "TrainingExpRatio": 1, + "BanGlider": 1, + "JianZhuMirageKaiGuan": 1, + "MaxConveyorCount": 1000, + "MaxDongLiKuangChangCount": 10, + "ManRenTenacityDamageRatio": 1, + "ManRenBossTiLiDamageRatio": 1, + "ManRenBossTenacityDamageRatio": 1, + "DongWuTiLiDamageRatio": 1, + "DongWuTenacityDamageRatio": 1, + "DongWuBossTiLiDamageRatio": 1, + "DongWuBossTenacityDamageRatio": 1, + "ManRenTiLiDamageRatio": 1, + "PingTaiBuildRangeLimit": 1, + "PingTaiJianZhuNumLimit": 1, + "GeRenMaxRaftSpaceCount": 2, + "GongHuiMaxRaftSpaceCount": 10, + "GeRenMaxSpecRaftSpaceCount": 1, + "GongHuiMaxSpecRaftSpaceCount": 2, + "MaxDiCiCount": 400, + "PingTaiAffectNavigation": 1, + "PlayerSweepRangeScale": 1, + "JiQiChuZhanKaiGuan": 0, + "ReboundDifficulty": 2, + "ZuRenDirectCunQu": 1, + "BagRepOptimizeSwitch": 1, + "JinJianQuKaiGuan": 1, + "ChuZhanZuRenShaGuaiExpShareRatio": 1, + "OtherShaGuaiExpShareRatio": 1, + "MaxPingTaiJianZhuNumMul": 3, + "CrewCountRatio": 1, + "IgnoreEnemyJianZhuInSelfYingHuo": 0, + "ShipBlueprintBuildConsumeSwitch": 1, + "ChestDropEquipmentMaxQualitySwitch": 0, + "CurProfInitRatio": 0, + "MainGunUseTimeCD": 2, + "RestartGameForceSpawnMonsterSwitch": 1, + "OpenEscMenuInfJianZao": 1, + "NewYingHuoTimeLenMul": 1, + "JianDuiRuQinKaiGuan": 0, + "ReleaseControlStatusCDRatio": 1 + }, + "1": { + "ExpRatio": {{ or .Values.gx_exp_ratio "1" }}, + "ZuoWuDropRatio": 1.5, + "AddRenKeDuRatio": 5, + "ZuoWuShengZhangRatio": {{ or .Values.gx_crop_growth_ratio "1" }}, + "BinSiKaiGuan": 1, + "JianZhuFuLanMul": 1, + "JianZhuXiuLiMul": 1, + "JianZhuFuLanKaiGuan": {{ or .Values.gx_building_decay "1" }}, + "ZhiZuoTimeRatio": 5, + "DamageYeShengRatio": 1, + "BeDamageByYeShengRatio": 1, + "GameWorldDayTimePortion": {{ or .Values.gx_daytime_portion "0.800000011920929" }}, + "GameWorldTimePower": {{ or .Values.gx_day_length "24" }}, + "BaoXiangDropRatio": 1.5, + "HuXIangShangHaiKaiGuan": {{ or .Values.gx_pvp_damage "1" }}, + "ZhiBeiChongShengRatio": 1, + "ChengZhangExpRatio": 1, + "MJExpRatio": 1, + "CaiJiDiaoLuoRatio": {{ or .Values.gx_harvest_ratio "1" }}, + "FaMuDiaoLuoRatio": {{ or .Values.gx_wood_ratio "1" }}, + "CaiKuangDiaoLuoRatio": {{ or .Values.gx_mining_ratio "1" }}, + "DongWuShiTiDiaoLuoRatio": {{ or .Values.gx_animal_loot_ratio "1" }}, + "DongWuShiTiZhongYaoDiaoLuoRatio": 1.5, + "CaiJiShengChanJianZhuDiaoLuoRatio": 1.5, + "PuTongRenDiaoLuoRatio": 1.5, + "JingYingRenDiaoLuoRatio": 1.5, + "BossRenDiaoLuoRatio": 1.5, + "ShuLianDuExpRatio": 1.5, + "NaiJiuXiShu": 1, + "ReDuXiShu": 1, + "CaiJiExpRatio": {{ or .Values.gx_gather_exp_ratio "1" }}, + "ZhiZuoExpRatio": 1, + "ShaGuaiExpRatio": {{ or .Values.gx_kill_exp_ratio "1" }}, + "QiTaExpRatio": 1, + "BeiDongYiJiShuXingRatio": 1, + "ZhuDongYiJiShuXingRatio": 1, + "ErJiShuXingRatio": 1, + "MaRenBeiDongYiJiShuXingRatio": 1, + "MaRenZhuDongYiJiShuXingRatio": 1, + "MaRenErJiShuXingRatio": 1, + "DongWuBeiDongYiJiShuXingRatio": 1, + "DongWuZhuDongYiJiShuXingRatio": 1, + "DongWuErJiShuXingRatio": 1, + "ShiWuXiaoHaoRatio": {{ or .Values.gx_food_consume_ratio "1" }}, + "ShuiXiaoHaoRatio": {{ or .Values.gx_water_consume_ratio "1" }}, + "QiXiXiaoHaoRatio": 1, + "ShengMingHuiFuRatio": 1, + "TiLiHuiFuRatio": 1, + "QiXiHuiFuRatio": 1, + "DongWuDamageRatio": 1, + "DongWuJianShangRatio": 1, + "MaRenDamageRatio": 1, + "ManRenJianShangRatio": 1, + "JiaSiHuiFuRatio": 1, + "CaiJiDamageRatio": 1, + "ZiYuanShengMingRatio": 1, + "RanLiaoXiaoHaoRatio": 1, + "DongWuShengZhangRatio": {{ or .Values.gx_animal_growth_ratio "1" }}, + "FanZhiJianGeRatio": 1, + "DongWuShengChanJianGeRatio": 1.5, + "DongWuChanChuRatio": 1.5, + "DongWuXiaoHaoShiWuRatio": 1, + "DongWuXiaoHaoShuiRatio": 1, + "ZuoWuFeiLiaoXiaoHaoRatio": 1, + "ZuoWuShuiXiaoHaoRatio": 1, + "ZuoWuXiaoHuiRatio": 1, + "GongJiJianZhuDamageRatio": 1, + "DongWuPinZhiRatio": 1, + "ManRenPinZhiRatio": 1, + "WanJiaZiYuanJinShuaBanJing": 1, + "JianZhuZiYuanJinShuaBanJing": 1, + "WuPinFuHuaiRatio": {{ or .Values.gx_item_decay_ratio "1" }}, + "WuPinXiaoHuiTime": 1, + "XiuLiXuYaoCaiLiaoRatio": 1, + "XiuLiJiangNaiJiuShangXianRatio": 1, + "HuiFuChuShiBodyData": 1, + "WanMeiChongSu": 1, + "FuHuoMoveSiWangBaoKaiGuan": {{ or .Values.gx_drop_on_death "0" }}, + "JianZhuChuanSongMenPlusKaiGuan": 0, + "SuiJiRuQinKaiGuan": 0, + "RuQinKaiGuan": 0, + "ShuaXinNPCKaiGuan": 0, + "RuQinGuiMoXiShu": 1, + "RuQinQiangDuXiShu": 1, + "RuQinGuaiCountMin": 8, + "RuQinGuaiCountMax": 128, + "RuQinPerBoGuaiMin": 3, + "RuQinPerBoGuaiMax": 16, + "RuQinGuaiLevelXiShu": 1, + "TanChaMinuteLimit": 20, + "JinGongMinuteLimit": 90, + "LengQueMinuteLimit": 1440, + "ChongsuRatio": 1, + "RuQinMaxChangCiCount": 2, + "RuQinBeginHour": 0, + "RuQinEndHour": 24, + "RuQinShaoChengXiShu": 0.6000000238418579, + "RuQinTuShaXiShu": 0.30000001192092896, + "XiuMianDistance": 10000, + "HuanXingDistance": 9000, + "GongHuiMaxZhaoMuCount": 40, + "GeRenMaxZhaoMuCount": 6, + "GeRenMaxZhaoMuCount_Two": 10, + "GeRenMaxZhaoMuCount_Three": 15, + "XinQingZengZhang": 1, + "XinQingJianShao": 1, + "XiShuWeiLing": 0, + "YeShengHitJianZhuShangHaiRatio": 4, + "PVP_ShangHaiRatio_WithoutP2P_YouFang": 0, + "WanJiaHitJianZhuShangHaiRatio": 1, + "ShaGuaiExpShareRatio": 1, + "YunXuOtherDaKaiGongZuoTai": 0, + "YunXuOtherDaKaiXiangZi": 0, + "PVEOnlyTongGuiShuCanOpenKaiGuan": 1, + "PVP_ShangHaiRatio_JinZhan": 0.4000000059604645, + "PVP_ShangHaiRatio_YuanCheng": 0.4000000059604645, + "GongHuiMaxDongWuCount": 50, + "GeRenMaxDongWuCount": 10, + "PanpaKaiGuan": 1, + "WanJiaBeiXiaoRenRatio": 0.800000011920929, + "WanJiaBeiXiaoTiRatio": 0.800000011920929, + "KaiQiJianZhuHuiXueBuilding": 1, + "PVP_ShangHaiRatio_PlayerToPlayer_DiFang": 1, + "PVP_GAPVPDamageRatio": 1, + "PlayerYouFangShangHaiKaiGuan": 1, + "YouFangShangHaiKaiGuan": 0, + "PVP_ShangHaiRatio_PlayerToPlayer_YouFang": 0.05999999865889549, + "BaoXiangDiaoLuoDengJi": 0, + "KaiQiKuaFu": 0, + "FuHuaSpeed": 1, + "YingHuoRanShaoSuDuRatio": 1, + "HuDongExcludeBetweenCameraCharacter": 1, + "PVPTimeAsiaWorkStartTime": 11, + "PVPTimeAsiaWorkEndTime": 15, + "PVPTimeAsiaNoWorkStartTime": 11, + "PVPTimeAsiaNoWorkEndTime": 15, + "PVPTimeAmericaWorkStartTime": 1, + "PVPTimeAmericaWorkEndTime": 5, + "PVPTimeAmericaNoWorkStartTime": 1, + "PVPTimeAmericaNoWorkEndTime": 5, + "PVPTimeEuropeWorkStartTime": 18, + "PVPTimeEuropeWorkEndTime": 22, + "PVPTimeEuropeNoWorkStartTime": 18, + "PVPTimeEuropeNoWorkEndTime": 22, + "WuLiYouHuaDist": 6666, + "MovementYouHua": 1, + "XiuMianOfflineDays": 7, + "WuLiYouHuaKaiGuan": 1, + "TiaoWuLengQueTime": 4, + "MaxLevel": 60, + "XinXiLuRu": 5, + "TeShuDaoJuDropXiShuJiaChengKaiGuan": 0, + "MaxGenRenYingHuoNumber": 6, + "MaxGongHuiYingHuoNumber": 6, + "MaxChuanSongMenNumber": 10, + "JingShenNoXiaoHao": 0, + "MaxFuZhongRatio": 1, + "RoleBagCapacity": 60, + "JianZhuGaoDuLimit": 1, + "AIDengJi": 1, + "GeRenBiaoJiMaxCount": 20, + "GongHuiBiaoJiMaxCount": 20, + "ManRenChuZhanCount": 1, + "MakeUseAroundRongQiKaiGuan": 1, + "JianZhuBeDamageLimit": 1, + "JianZhuAroundNumLimit": 1, + "GongHuiMaxMember": 10, + "DynamicBossStats": 1, + "ZhaoHuanDisRatio": 1, + "DongWuChuZhanCount": 1, + "SuoDingKaiGuan": 1, + "ZuRenFuZhi": 1, + "TransDoorInterworkKaiGuan": 0, + "ConverPropsSpeedRatio": 5, + "MaxXiuMianCangCount": 50, + "RollingInvincibleTimeRatio": 1, + "MaxConvertCount": 3, + "AsiaWarTimeStart": 10, + "AsiaWarTimeEnd": 14, + "EuropeWarTimeStart": 17, + "EuropeWarTimeEnd": 21, + "AmericaWarTimeStart": 0, + "AmericaWarTimeEnd": 4, + "WarKaiGuan": 1, + "TribalExplorationKaiGuan": 1, + "RuinsExplorationKaiGuan": 1, + "TribalTransportSwitch": 1, + "SpecialBossSwitch": 1, + "RelicChestEventSwitch": 0, + "BossDeathEventSwitch": 0, + "KurmaFuZhongRatio": 1, + "GongHuiMaxSpecDongWuCount": 1, + "GeRenMaxSpecDongWuCount": 1, + "ProtectJianZhuInYingHuoSwitch": 0, + "SpecialEventConfigSwitch": 0, + "SpecialEventTriggerInterval": 3600, + "SpecialEventTriggerPercent": 50, + "SpecialEventTriggetLimitNum": 2, + "SpecialEventServerOpenDay": 1, + "SpecialEventAsiaStartTime": 10, + "SpecialEventAsiaEndTime": 16, + "SpecialEventEuropeStartTime": 17, + "SpecialEventEuropeEndTime": 23, + "SpecialEventAmericaStartTime": 23, + "SpecialEventAmericaEndTime": 5, + "SpecialEventGameDist": 0, + "MaskRepairUpgradeSwitch": 0, + "IsOpenGuideTask": 1, + "NormalEquipDropRatioCorrection": 0.10000000149011612, + "EliteEquipDropRatioCorrection": 0.20000000298023224, + "BossEquipDropRatioCorrection": 0.20000000298023224, + "NormalEquipDurabilityCorrection": 0.10000000149011612, + "EliteEquipDurabilityCorrection": 0.5, + "BossEquipDurabilityCorrection": 0.5, + "InitialDefaultAwarenessLevel": 1, + "FirstDayMaxAwarenessLevel": 29, + "SecondDayMaxAwarenessLevel": 34, + "ThirdDayMaxAwarenessLevel": 39, + "FourthDayMaxAwarenessLevel": 44, + "FifthDayMaxAwarenessLevel": 49, + "SixthDayMaxAwarenessLevel": 54, + "SeventhDayMaxAwarenessLevel": 59, + "EighthDayMaxAwarenessLevel": 60, + "NinthDayMaxAwarenessLevel": 60, + "TenthDayMaxAwarenessLevel": 60, + "MentalRecoveryRate": 1, + "PhysicalRecoveryIntervalRate": 1, + "DungeonReborn": 1, + "ManageModeRuQin": 0, + "ManageModeRuQinCountDownTimeRatio": 1, + "IsPlayBossAppearanceSequence": 1, + "PlayerDeathCantDropItemKaiGuan": 0, + "AnimalFollowerMaxCount": 1, + "DrawDebugDungeon": 0, + "RuQinSucceedPrizeTimes": 3, + "TrainingExpRatio": 5, + "BanGlider": 1, + "JianZhuMirageKaiGuan": 1, + "MaxConveyorCount": 1000, + "MaxDongLiKuangChangCount": 10, + "ManRenTenacityDamageRatio": 0.5, + "ManRenBossTiLiDamageRatio": 1, + "ManRenBossTenacityDamageRatio": 0.5, + "DongWuTiLiDamageRatio": 1, + "DongWuTenacityDamageRatio": 0.5, + "DongWuBossTiLiDamageRatio": 1, + "DongWuBossTenacityDamageRatio": 0.5, + "ManRenTiLiDamageRatio": 1, + "PingTaiBuildRangeLimit": 1, + "PingTaiJianZhuNumLimit": 1, + "GeRenMaxRaftSpaceCount": 2, + "GongHuiMaxRaftSpaceCount": 10, + "GeRenMaxSpecRaftSpaceCount": 1, + "GongHuiMaxSpecRaftSpaceCount": 2, + "MaxDiCiCount": 400, + "PingTaiAffectNavigation": 1, + "PlayerSweepRangeScale": 1, + "JiQiChuZhanKaiGuan": 0, + "ReboundDifficulty": 2, + "ZuRenDirectCunQu": 1, + "BagRepOptimizeSwitch": 1, + "JinJianQuKaiGuan": 1, + "ChuZhanZuRenShaGuaiExpShareRatio": 1, + "OtherShaGuaiExpShareRatio": 1, + "MaxPingTaiJianZhuNumMul": 0.10000000149011612, + "CrewCountRatio": 1, + "IgnoreEnemyJianZhuInSelfYingHuo": 0, + "ShipBlueprintBuildConsumeSwitch": 1, + "ChestDropEquipmentMaxQualitySwitch": 0, + "CurProfInitRatio": 0, + "MainGunUseTimeCD": 2, + "RestartGameForceSpawnMonsterSwitch": 1, + "OpenEscMenuInfJianZao": 0, + "NewYingHuoTimeLenMul": 1, + "JianDuiRuQinKaiGuan": 0, + "ReleaseControlStatusCDRatio": 1 + }, + "2": { + "ExpRatio": {{ or .Values.gx_exp_ratio "1" }}, + "ZuoWuDropRatio": 1, + "AddRenKeDuRatio": 5, + "ZuoWuShengZhangRatio": {{ or .Values.gx_crop_growth_ratio "1" }}, + "BinSiKaiGuan": 1, + "JianZhuFuLanMul": 1, + "JianZhuXiuLiMul": 1, + "JianZhuFuLanKaiGuan": {{ or .Values.gx_building_decay "1" }}, + "ZhiZuoTimeRatio": 5, + "DamageYeShengRatio": 1, + "BeDamageByYeShengRatio": 1, + "GameWorldDayTimePortion": {{ or .Values.gx_daytime_portion "0.800000011920929" }}, + "GameWorldTimePower": {{ or .Values.gx_day_length "24" }}, + "BaoXiangDropRatio": 1, + "HuXIangShangHaiKaiGuan": {{ or .Values.gx_pvp_damage "1" }}, + "ZhiBeiChongShengRatio": 1, + "ChengZhangExpRatio": 1, + "MJExpRatio": 1, + "CaiJiDiaoLuoRatio": {{ or .Values.gx_harvest_ratio "1" }}, + "FaMuDiaoLuoRatio": {{ or .Values.gx_wood_ratio "1" }}, + "CaiKuangDiaoLuoRatio": {{ or .Values.gx_mining_ratio "1" }}, + "DongWuShiTiDiaoLuoRatio": {{ or .Values.gx_animal_loot_ratio "1" }}, + "DongWuShiTiZhongYaoDiaoLuoRatio": 1, + "CaiJiShengChanJianZhuDiaoLuoRatio": 1, + "PuTongRenDiaoLuoRatio": 1, + "JingYingRenDiaoLuoRatio": 1, + "BossRenDiaoLuoRatio": 1, + "ShuLianDuExpRatio": 1, + "NaiJiuXiShu": 1, + "ReDuXiShu": 1, + "CaiJiExpRatio": {{ or .Values.gx_gather_exp_ratio "1" }}, + "ZhiZuoExpRatio": 1, + "ShaGuaiExpRatio": {{ or .Values.gx_kill_exp_ratio "1" }}, + "QiTaExpRatio": 1, + "BeiDongYiJiShuXingRatio": 1, + "ZhuDongYiJiShuXingRatio": 1, + "ErJiShuXingRatio": 1, + "MaRenBeiDongYiJiShuXingRatio": 1, + "MaRenZhuDongYiJiShuXingRatio": 1, + "MaRenErJiShuXingRatio": 1, + "DongWuBeiDongYiJiShuXingRatio": 1, + "DongWuZhuDongYiJiShuXingRatio": 1, + "DongWuErJiShuXingRatio": 1, + "ShiWuXiaoHaoRatio": {{ or .Values.gx_food_consume_ratio "1" }}, + "ShuiXiaoHaoRatio": {{ or .Values.gx_water_consume_ratio "1" }}, + "QiXiXiaoHaoRatio": 1, + "ShengMingHuiFuRatio": 1, + "TiLiHuiFuRatio": 1, + "QiXiHuiFuRatio": 1, + "DongWuDamageRatio": 1, + "DongWuJianShangRatio": 1, + "MaRenDamageRatio": 1, + "ManRenJianShangRatio": 1, + "JiaSiHuiFuRatio": 1, + "CaiJiDamageRatio": 1, + "ZiYuanShengMingRatio": 1, + "RanLiaoXiaoHaoRatio": 1, + "DongWuShengZhangRatio": {{ or .Values.gx_animal_growth_ratio "1" }}, + "FanZhiJianGeRatio": 1, + "DongWuShengChanJianGeRatio": 1, + "DongWuChanChuRatio": 1, + "DongWuXiaoHaoShiWuRatio": 1, + "DongWuXiaoHaoShuiRatio": 1, + "ZuoWuFeiLiaoXiaoHaoRatio": 1, + "ZuoWuShuiXiaoHaoRatio": 1, + "ZuoWuXiaoHuiRatio": 1, + "GongJiJianZhuDamageRatio": 1, + "DongWuPinZhiRatio": 1, + "ManRenPinZhiRatio": 1, + "WanJiaZiYuanJinShuaBanJing": 1, + "JianZhuZiYuanJinShuaBanJing": 1, + "WuPinFuHuaiRatio": {{ or .Values.gx_item_decay_ratio "1" }}, + "WuPinXiaoHuiTime": 1, + "XiuLiXuYaoCaiLiaoRatio": 1, + "XiuLiJiangNaiJiuShangXianRatio": 1, + "HuiFuChuShiBodyData": 1, + "WanMeiChongSu": 1, + "FuHuoMoveSiWangBaoKaiGuan": {{ or .Values.gx_drop_on_death "0" }}, + "JianZhuChuanSongMenPlusKaiGuan": 0, + "ShuaXinNPCKaiGuan": 0, + "SuiJiRuQinKaiGuan": 0, + "RuQinKaiGuan": 0, + "RuQinGuiMoXiShu": 1, + "RuQinQiangDuXiShu": 1, + "RuQinGuaiCountMin": 8, + "RuQinGuaiCountMax": 128, + "RuQinPerBoGuaiMin": 3, + "RuQinPerBoGuaiMax": 16, + "RuQinGuaiLevelXiShu": 1, + "TanChaMinuteLimit": 20, + "JinGongMinuteLimit": 90, + "LengQueMinuteLimit": 1440, + "ChongsuRatio": 1, + "RuQinMaxChangCiCount": 2, + "RuQinBeginHour": 0, + "RuQinEndHour": 24, + "RuQinShaoChengXiShu": 0.6000000238418579, + "RuQinTuShaXiShu": 0.30000001192092896, + "XiuMianDistance": 10000, + "HuanXingDistance": 9000, + "GongHuiMaxZhaoMuCount": 50, + "GeRenMaxZhaoMuCount": 6, + "GeRenMaxZhaoMuCount_Two": 10, + "GeRenMaxZhaoMuCount_Three": 15, + "XinQingZengZhang": 1, + "XinQingJianShao": 1, + "XiShuWeiLing": 0, + "YeShengHitJianZhuShangHaiRatio": 4, + "PVP_ShangHaiRatio_WithoutP2P_YouFang": 0, + "WanJiaHitJianZhuShangHaiRatio": 1, + "ShaGuaiExpShareRatio": 1, + "YunXuOtherDaKaiGongZuoTai": 0, + "YunXuOtherDaKaiXiangZi": 0, + "PVEOnlyTongGuiShuCanOpenKaiGuan": 1, + "PVP_ShangHaiRatio_JinZhan": 0.4000000059604645, + "PVP_ShangHaiRatio_YuanCheng": 0.4000000059604645, + "GongHuiMaxDongWuCount": 50, + "GeRenMaxDongWuCount": 10, + "PanpaKaiGuan": 1, + "WanJiaBeiXiaoRenRatio": 0.800000011920929, + "WanJiaBeiXiaoTiRatio": 0.800000011920929, + "KaiQiJianZhuHuiXueBuilding": 1, + "PVP_ShangHaiRatio_PlayerToPlayer_DiFang": 1, + "PVP_GAPVPDamageRatio": 1, + "PlayerYouFangShangHaiKaiGuan": 1, + "YouFangShangHaiKaiGuan": 0, + "PVP_ShangHaiRatio_PlayerToPlayer_YouFang": 0.05999999865889549, + "BaoXiangDiaoLuoDengJi": 0, + "KaiQiKuaFu": 0, + "FuHuaSpeed": 1, + "YingHuoRanShaoSuDuRatio": 1, + "HuDongExcludeBetweenCameraCharacter": 1, + "PVPTimeAsiaWorkStartTime": 11, + "PVPTimeAsiaWorkEndTime": 15, + "PVPTimeAsiaNoWorkStartTime": 11, + "PVPTimeAsiaNoWorkEndTime": 15, + "PVPTimeAmericaWorkStartTime": 1, + "PVPTimeAmericaWorkEndTime": 5, + "PVPTimeAmericaNoWorkStartTime": 1, + "PVPTimeAmericaNoWorkEndTime": 5, + "PVPTimeEuropeWorkStartTime": 18, + "PVPTimeEuropeWorkEndTime": 22, + "PVPTimeEuropeNoWorkStartTime": 18, + "PVPTimeEuropeNoWorkEndTime": 22, + "WuLiYouHuaDist": 6666, + "MovementYouHua": 1, + "XiuMianOfflineDays": 7, + "WuLiYouHuaKaiGuan": 1, + "TiaoWuLengQueTime": 4, + "MaxLevel": 60, + "XinXiLuRu": 5, + "TeShuDaoJuDropXiShuJiaChengKaiGuan": 0, + "MaxGenRenYingHuoNumber": 6, + "MaxGongHuiYingHuoNumber": 6, + "MaxChuanSongMenNumber": 10, + "JingShenNoXiaoHao": 0, + "MaxFuZhongRatio": 1, + "RoleBagCapacity": 60, + "JianZhuGaoDuLimit": 1, + "AIDengJi": 1, + "GeRenBiaoJiMaxCount": 20, + "GongHuiBiaoJiMaxCount": 20, + "ManRenChuZhanCount": 1, + "MakeUseAroundRongQiKaiGuan": 1, + "JianZhuBeDamageLimit": 1, + "JianZhuAroundNumLimit": 1, + "GongHuiMaxMember": 10, + "DynamicBossStats": 1, + "ZhaoHuanDisRatio": 1, + "DongWuChuZhanCount": 1, + "SuoDingKaiGuan": 1, + "ZuRenFuZhi": 1, + "TransDoorInterworkKaiGuan": 0, + "ConverPropsSpeedRatio": 1, + "MaxXiuMianCangCount": 50, + "RollingInvincibleTimeRatio": 1, + "MaxConvertCount": 3, + "AsiaWarTimeStart": 10, + "AsiaWarTimeEnd": 14, + "EuropeWarTimeStart": 17, + "EuropeWarTimeEnd": 21, + "AmericaWarTimeStart": 0, + "AmericaWarTimeEnd": 4, + "WarKaiGuan": 1, + "TribalExplorationKaiGuan": 1, + "RuinsExplorationKaiGuan": 1, + "TribalTransportSwitch": 1, + "SpecialBossSwitch": 1, + "RelicChestEventSwitch": 0, + "BossDeathEventSwitch": 0, + "KurmaFuZhongRatio": 1, + "GongHuiMaxSpecDongWuCount": 1, + "GeRenMaxSpecDongWuCount": 1, + "ProtectJianZhuInYingHuoSwitch": 0, + "SpecialEventConfigSwitch": 0, + "SpecialEventTriggerInterval": 3600, + "SpecialEventTriggerPercent": 50, + "SpecialEventTriggetLimitNum": 2, + "SpecialEventServerOpenDay": 1, + "SpecialEventAsiaStartTime": 10, + "SpecialEventAsiaEndTime": 16, + "SpecialEventEuropeStartTime": 17, + "SpecialEventEuropeEndTime": 23, + "SpecialEventAmericaStartTime": 23, + "SpecialEventAmericaEndTime": 5, + "SpecialEventGameDist": 0, + "MaskRepairUpgradeSwitch": 0, + "IsOpenGuideTask": 1, + "NormalEquipDropRatioCorrection": 0.10000000149011612, + "EliteEquipDropRatioCorrection": 0.20000000298023224, + "BossEquipDropRatioCorrection": 0.20000000298023224, + "BossEquipDurabilityCorrection": 0.5, + "EliteEquipDurabilityCorrection": 0.5, + "NormalEquipDurabilityCorrection": 0.10000000149011612, + "InitialDefaultAwarenessLevel": 1, + "FirstDayMaxAwarenessLevel": 29, + "SecondDayMaxAwarenessLevel": 34, + "ThirdDayMaxAwarenessLevel": 39, + "FourthDayMaxAwarenessLevel": 44, + "FifthDayMaxAwarenessLevel": 49, + "SixthDayMaxAwarenessLevel": 54, + "SeventhDayMaxAwarenessLevel": 59, + "EighthDayMaxAwarenessLevel": 60, + "NinthDayMaxAwarenessLevel": 60, + "TenthDayMaxAwarenessLevel": 60, + "MentalRecoveryRate": 1, + "PhysicalRecoveryIntervalRate": 1, + "DungeonReborn": 1, + "ManageModeRuQin": 1, + "ManageModeRuQinCountDownTimeRatio": 1, + "IsPlayBossAppearanceSequence": 1, + "PlayerDeathCantDropItemKaiGuan": 0, + "AnimalFollowerMaxCount": 1, + "DrawDebugDungeon": 0, + "RuQinSucceedPrizeTimes": 2, + "TrainingExpRatio": 5, + "BanGlider": 1, + "JianZhuMirageKaiGuan": 1, + "MaxConveyorCount": 1000, + "MaxDongLiKuangChangCount": 10, + "ManRenTenacityDamageRatio": 1, + "ManRenBossTiLiDamageRatio": 1, + "ManRenBossTenacityDamageRatio": 1, + "DongWuTiLiDamageRatio": 1, + "DongWuTenacityDamageRatio": 1, + "DongWuBossTiLiDamageRatio": 1, + "DongWuBossTenacityDamageRatio": 1, + "ManRenTiLiDamageRatio": 1, + "PingTaiBuildRangeLimit": 1, + "PingTaiJianZhuNumLimit": 1, + "GeRenMaxRaftSpaceCount": 2, + "GongHuiMaxRaftSpaceCount": 10, + "GeRenMaxSpecRaftSpaceCount": 1, + "GongHuiMaxSpecRaftSpaceCount": 2, + "MaxDiCiCount": 400, + "PingTaiAffectNavigation": 1, + "PlayerSweepRangeScale": 1, + "JiQiChuZhanKaiGuan": 0, + "ReboundDifficulty": 2, + "ZuRenDirectCunQu": 1, + "BagRepOptimizeSwitch": 1, + "JinJianQuKaiGuan": 1, + "ChuZhanZuRenShaGuaiExpShareRatio": 1, + "OtherShaGuaiExpShareRatio": 1, + "MaxPingTaiJianZhuNumMul": 3, + "CrewCountRatio": 1, + "IgnoreEnemyJianZhuInSelfYingHuo": 0, + "ShipBlueprintBuildConsumeSwitch": 1, + "ChestDropEquipmentMaxQualitySwitch": 0, + "CurProfInitRatio": 0, + "MainGunUseTimeCD": 2, + "RestartGameForceSpawnMonsterSwitch": 1, + "OpenEscMenuInfJianZao": 1, + "NewYingHuoTimeLenMul": 1, + "JianDuiRuQinKaiGuan": 0, + "ReleaseControlStatusCDRatio": 1 + } +} diff --git a/modules/steamcmd-test/module.yaml b/modules/steamcmd-test/module.yaml new file mode 100644 index 00000000..d7bf4caa --- /dev/null +++ b/modules/steamcmd-test/module.yaml @@ -0,0 +1,42 @@ +# SteamCMD sidecar test module. +# +# Uses Half-Life 1 Dedicated Server (app_id=70, ~200MB) as a small-but-real +# SteamCMD download to verify the panel's sidecar updater end-to-end. +# The "main" container is just an alpine sleeper holding open the volume — +# we never need to start HLDS; we only care that the panel successfully +# spawns a steamcmd sidecar, mounts the shared volume, streams install +# progress, and exits cleanly. +id: steamcmd-test +name: "SteamCMD test (HLDS)" +version: 0.1.0 +supported_modes: + - docker +runtime: + docker: + image: alpine:3.21 + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + command: [sleep, "86400"] + browseable_root: /data + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-data", container: "/data" } +ports: + - { name: p, proto: tcp, default: 14000 } +resources: + min_ram_mb: 64 + recommended_ram_mb: 128 +update_providers: + - id: hlds + kind: steamcmd + app_id: "90" + install_path: "/data" + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +appearance: + emoji: "⚙️" + grad: "linear-gradient(135deg,#1e3a8a,#3b82f6)" diff --git a/modules/terraria/Dockerfile b/modules/terraria/Dockerfile new file mode 100644 index 00000000..8fa873a5 --- /dev/null +++ b/modules/terraria/Dockerfile @@ -0,0 +1,30 @@ +# panel-native Terraria runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + unzip \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 7777/tcp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/terraria/entrypoint.sh b/modules/terraria/entrypoint.sh new file mode 100644 index 00000000..c4fe6a57 --- /dev/null +++ b/modules/terraria/entrypoint.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# panel-native Terraria entrypoint. +set -euo pipefail +log() { printf '[panel-terraria] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + WORLD_NAME="${WORLD_NAME:-DedicatedWorld}" \ + WORLD_SIZE="${WORLD_SIZE:-2}" \ + MAX_PLAYERS="${MAX_PLAYERS:-8}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-}" \ + GAME_PORT="${GAME_PORT:-7777}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves + +# The panel's direct update provider drops the raw zip at +# /game-saves/terraria-server.zip (it does not extract archives). +# Unpack it into /game on boot, then delete the zip. +ZIP="${SAVE_DIR}/terraria-server.zip" +if [[ -s "${ZIP}" ]]; then + log "extracting $(basename "${ZIP}") into ${GAME_DIR}" + unzip -o -q "${ZIP}" -d "${GAME_DIR}" + rm -f "${ZIP}" +fi + +# Terraria's zip extracts into /game//{Linux,Mac,Windows}. Find +# the Linux dir regardless of version folder name. +LINUX_DIR=$(find "${GAME_DIR}" -maxdepth 3 -type d -name Linux 2>/dev/null | head -n1 || true) +if [[ -z "${LINUX_DIR}" ]]; then + log "ERROR: Terraria Linux server binary not found under /game." + log " Run 'Update' in the panel to download Terraria headless server." + exit 78 +fi +EXE="${LINUX_DIR}/TerrariaServer.bin.x86_64" +[[ -x "${EXE}" ]] || chmod +x "${EXE}" 2>/dev/null || true + +# Terraria reads serverconfig.txt if -config is passed. Seed a minimal one +# with our env vars so the server starts without interactive prompts. +CONFIG="${SAVE_DIR}/serverconfig.txt" +if [[ ! -s "${CONFIG}" ]]; then + log "seeding ${CONFIG}" + { + echo "world=${SAVE_DIR}/Worlds/${WORLD_NAME}.wld" + echo "worldname=${WORLD_NAME}" + echo "autocreate=${WORLD_SIZE}" + echo "maxplayers=${MAX_PLAYERS}" + echo "port=${GAME_PORT:-7777}" + echo "motd=Running on panel" + echo "difficulty=1" + [[ -n "${SERVER_PASSWORD}" ]] && echo "password=${SERVER_PASSWORD}" || true + } > "${CONFIG}" +fi +mkdir -p "${SAVE_DIR}/Worlds" + +cd "${LINUX_DIR}" +log "starting Terraria dedicated server" +log " world: ${WORLD_NAME} (size ${WORLD_SIZE})" +log " config: ${CONFIG}" +log " port: ${GAME_PORT:-7777}" + +# -port on the command line overrides serverconfig.txt, so a panel port +# change takes effect even when a stale config was seeded on a prior boot. +exec stdbuf -oL -eL "${EXE}" -config "${CONFIG}" -port "${GAME_PORT:-7777}" diff --git a/modules/terraria/module.yaml b/modules/terraria/module.yaml new file mode 100644 index 00000000..d964adea --- /dev/null +++ b/modules/terraria/module.yaml @@ -0,0 +1,104 @@ +# Terraria — panel-native dedicated server module. +# +# Uses direct download from terraria.org (headless zip). Vanilla Terraria +# has no RCON — admin console is stdin. Ships without RCON/Players tab +# for v1 (same story as Empyrion / Barotrauma). Users running the +# tShock fork add Source RCON and can switch adapters later. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (Terraria.kvp) + +id: terraria +name: "Terraria" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-terraria:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Worlds/, serverconfig.txt" + - name: "Game Files" + path: /game + hint: "Terraria binaries" + env: + WORLD_NAME: "DedicatedWorld" + WORLD_SIZE: "2" # 1=small, 2=medium, 3=large + MAX_PLAYERS: "8" + SERVER_PASSWORD: "" + # Pre-declared so the agent's resolve.go filter lets the panel-allocated + # port flow through to the container env. The entrypoint passes it as + # -port (and seeds serverconfig.txt); without it the server binds the + # hardcoded 7777 and ignores the panel allocation. Default matches the + # port default. + GAME_PORT: "7777" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + - { name: game, proto: tcp, default: 7777, required: true, env: GAME_PORT } + +resources: + min_ram_mb: 1024 + recommended_ram_mb: 2048 + +rcon: + # Vanilla Terraria admin is stdin-only. Commands like "say", "save", + # "exit" go straight to the server process. + adapter: stdio + commands: + save: "save" + say: "say {msg}" + kick: "kick {player}" + shutdown: "exit" +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (terraria.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: '^(?P.+?) has joined\.$' + kind: join + leave: + pattern: '^(?P.+?) has left\.$' + kind: leave + chat: + pattern: '^<(?P.+?)> (?P.+)$' + kind: chat + + +update_providers: + # Terraria ships headless Linux server as a versioned zip from terraria.org. + # AMP hard-codes the latest-known URL; we take the same approach. Bump when + # Re-Logic cuts a new version. NOTE: the direct provider does NOT extract — + # it writes the raw bytes to BrowseableRoot+target_path (so this lands at + # /game-saves/terraria-server.zip). The entrypoint unzips it into /game on + # the next boot and deletes the zip. + - id: stable + kind: direct + url: "https://terraria.org/api/download/pc-dedicated-server/terraria-server-1449.zip" + target_path: /terraria-server.zip + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Server started|Listening on port \d+/i' +appearance: + emoji: "🌳" + grad: "linear-gradient(135deg,#2d7141,#78c87a)" + art: "/game-art/terraria.jpg" + steam: "105600" diff --git a/modules/v-rising/Dockerfile b/modules/v-rising/Dockerfile new file mode 100644 index 00000000..e6d1cfab --- /dev/null +++ b/modules/v-rising/Dockerfile @@ -0,0 +1,41 @@ +# panel-native V Rising runtime image (Wine). +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + WINEDEBUG=-all \ + WINEPREFIX=/home/panel/.wine \ + DISPLAY=:99 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + xvfb \ + xauth \ + wine64 \ + wine \ + winbind \ + cabextract \ + procps \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves /home/panel + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 9876/udp 9877/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/v-rising/entrypoint.sh b/modules/v-rising/entrypoint.sh new file mode 100644 index 00000000..39c00d40 --- /dev/null +++ b/modules/v-rising/entrypoint.sh @@ -0,0 +1,110 @@ +#!/bin/bash +# panel-native V Rising entrypoint (Wine). +# +# CLI flags > JSON in V Rising's override precedence, so we drive every +# operator-facing knob via -flag args from env vars rather than rewriting +# ServerHostSettings.json on each boot. The shipped JSON in StreamingAssets/ +# stays untouched; operators who want individual ServerGameSettings.json +# edits can ssh in / use the Files tab and edit the seeded copy in +# /game-saves/Settings — but only if GAME_SETTINGS_PRESET is empty (a +# non-empty preset overrides individual edits silently — Stunlock gotcha). +set -euo pipefail +log() { printf '[panel-v-rising] %s\n' "$*"; } + +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + # Hand off to panel user. setpriv preserves env, so all the SERVER_* + # / GAME_* / RCON_* knobs the panel manifest sets reach the child. + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +EXE="${GAME_DIR}/VRisingServer.exe" +SETTINGS_SRC="${GAME_DIR}/VRisingServer_Data/StreamingAssets/Settings" +SETTINGS_DST="${SAVE_DIR}/Settings" + +if [[ ! -f "${EXE}" ]]; then + log "ERROR: ${EXE} not found. Run 'Update' in the panel to install V Rising (Windows build) via SteamCMD." + exit 78 +fi + +# Seed ServerHostSettings.json + ServerGameSettings.json into save volume +# the first time only. After that the operator can edit them by hand for +# settings the panel doesn't expose; CLI flags below still override the +# operator-facing keys (Name, Description, ports, password, max players). +mkdir -p "${SETTINGS_DST}" +if [[ ! -s "${SETTINGS_DST}/ServerHostSettings.json" && -d "${SETTINGS_SRC}" ]]; then + log "seeding ${SETTINGS_DST} from shipped defaults" + cp -r "${SETTINGS_SRC}/." "${SETTINGS_DST}/" || true +fi + +if [[ ! -f "${WINEPREFIX}/system.reg" ]]; then + log "initializing Wine prefix at ${WINEPREFIX}" + xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true + wineserver -w || true +fi + +trap 'log "SIGTERM"; wineserver -k 2>/dev/null || true; exit 0' TERM INT + +# ---- Env defaults (mirrors module.yaml's env: block) ---- +SERVER_NAME="${SERVER_NAME:-panel V Rising}" +SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-}" +SERVER_PASSWORD="${SERVER_PASSWORD:-}" +MAX_PLAYERS="${MAX_PLAYERS:-40}" +MAX_ADMINS="${MAX_ADMINS:-4}" +SAVE_NAME="${SAVE_NAME:-panel}" +LIST_ON_STEAM="${LIST_ON_STEAM:-true}" +LIST_ON_EOS="${LIST_ON_EOS:-true}" +SECURE="${SECURE:-true}" +SERVER_FPS="${SERVER_FPS:-30}" +LOWER_FPS_WHEN_EMPTY="${LOWER_FPS_WHEN_EMPTY:-true}" +GAME_SETTINGS_PRESET="${GAME_SETTINGS_PRESET:-}" +GAME_DIFFICULTY_PRESET="${GAME_DIFFICULTY_PRESET:-}" +GAME_PORT="${GAME_PORT:-9876}" +QUERY_PORT="${QUERY_PORT:-9877}" +RCON_ENABLED="${RCON_ENABLED:-false}" +RCON_PORT="${RCON_PORT:-25575}" +RCON_PASSWORD="${RCON_PASSWORD:-}" + +# ---- Build CLI args ---- +ARGS=( + -persistentDataPath "${SAVE_DIR}" + -saveName "${SAVE_NAME}" + -serverName "${SERVER_NAME}" + -gamePort "${GAME_PORT}" + -queryPort "${QUERY_PORT}" + -maxUsers "${MAX_PLAYERS}" + -maxAdmins "${MAX_ADMINS}" + -listOnSteam "${LIST_ON_STEAM}" + -listOnEOS "${LIST_ON_EOS}" + -secure "${SECURE}" + -targetFps "${SERVER_FPS}" + -lowerFPSWhenEmpty "${LOWER_FPS_WHEN_EMPTY}" + -logFile "-" + -batchmode -nographics +) +[[ -n "${SERVER_DESCRIPTION}" ]] && ARGS+=(-description "${SERVER_DESCRIPTION}") +[[ -n "${SERVER_PASSWORD}" ]] && ARGS+=(-password "${SERVER_PASSWORD}") +[[ -n "${GAME_SETTINGS_PRESET}" ]] && ARGS+=(-preset "${GAME_SETTINGS_PRESET}") +[[ -n "${GAME_DIFFICULTY_PRESET}" ]] && ARGS+=(-difficultyPreset "${GAME_DIFFICULTY_PRESET}") +if [[ "${RCON_ENABLED}" == "true" ]]; then + ARGS+=(-rconEnabled true -rconPort "${RCON_PORT}" -rconPassword "${RCON_PASSWORD}") +fi + +cd "${GAME_DIR}" +log "starting V Rising dedicated server" +log " name: ${SERVER_NAME}" +log " ports: ${GAME_PORT}/${QUERY_PORT} (game/query)" +log " slots: ${MAX_PLAYERS}" +log " preset: ${GAME_SETTINGS_PRESET:-(none)}" +log " rcon: ${RCON_ENABLED} (port ${RCON_PORT})" + +# xvfb-run -a auto-allocates a free display — host has its own Xvfb on +# :99 / :100 / :101, see panel/memory/gotchas.md. +exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ + wine "${EXE}" "${ARGS[@]}" diff --git a/modules/v-rising/module.yaml b/modules/v-rising/module.yaml new file mode 100644 index 00000000..9946e428 --- /dev/null +++ b/modules/v-rising/module.yaml @@ -0,0 +1,121 @@ +# V Rising — panel-native dedicated server module (Wine). +# +# Windows-only dedicated server. Same pattern as Empyrion: +# Debian + Wine + Xvfb + tini, SteamCMD with platform:windows. +# +# Reference: https://github.com/StunlockStudios/vrising-dedicated-server-instructions +# CLI/env-var flags override JSON, so the panel ships its config via env → +# entrypoint flags rather than rewriting ServerHostSettings.json. Avoids +# the per-boot JSON-rewrite race that bit ARK SA's INI flow. + +id: v-rising +name: "V Rising" +version: 0.2.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-v-rising:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Settings/ServerHostSettings.json + Saves/" + - name: "Game Files" + path: /game + hint: "Server binaries" + env: + # ---- Host settings (ServerHostSettings.json equivalents) ---- + SERVER_NAME: "panel V Rising" + SERVER_DESCRIPTION: "A V Rising server hosted by panel." + SERVER_PASSWORD: "" # blank = open server + MAX_PLAYERS: "40" + MAX_ADMINS: "4" + SAVE_NAME: "panel" + LIST_ON_STEAM: "true" + LIST_ON_EOS: "true" + SECURE: "true" # VAC + SERVER_FPS: "30" + LOWER_FPS_WHEN_EMPTY: "true" + # ---- Game-rule presets (set GAME_SETTINGS_PRESET to "" if you + # want to edit individual rules in ServerGameSettings.json + # manually — otherwise the preset wins silently) ---- + GAME_SETTINGS_PRESET: "" # e.g. StandardPvP, StandardPvE, Duo_PvP, Solo_PvP, HardcorePvP + GAME_DIFFICULTY_PRESET: "" # e.g. (engine accepts files in StreamingAssets/GameSettingPresets/) + # ---- Port mappings (mirrored by the agent's port allocator) ---- + GAME_PORT: "9876" + QUERY_PORT: "9877" + # ---- RCON (off by default — turn on per-instance once you need it) ---- + RCON_ENABLED: "false" + RCON_PORT: "25575" + RCON_PASSWORD: "" + # ---- Legacy panel default; entrypoint reads the new keys above ---- + ADMIN_PASSWORD: "changeme" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +ports: + # `env:` tells the agent which container env var to set with the + # allocated host port. The entrypoint uses these to assemble VRising's + # `-gamePort` / `-queryPort` launch flags so multiple instances on the + # same agent get distinct, non-colliding sockets. + - { name: game, proto: udp, default: 9876, required: true, env: GAME_PORT } + - { name: query, proto: udp, default: 9877, required: true, env: QUERY_PORT } + +resources: + min_ram_mb: 6144 + recommended_ram_mb: 12288 + +# What gets archived when an operator clicks Back-up-now. The save +# directory contains the world (Saves/) and the per-server settings +# (Settings/). The entrypoint regenerates Settings/ from defaults if +# absent, so technically only Saves/ has to be preserved — but operators +# expect their custom server name + password to come back too. +backup: + include: + - "Saves" # world data + - "Settings" # ServerHostSettings.json + ServerGameSettings.json + exclude: + - "logs" # rotates with operator + entrypoint + notes: "World saves + settings (~MB-scale). Game install regenerates from SteamCMD next start." +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (v-rising.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'User ''\{Steam (?P\d+)\}'' ''\d+'', approvedUserIndex: \d+, Character: ''(?P[^'']*)'' connected' + kind: join + leave: + pattern: 'User ''\{Steam (?P\d+)\}'' disconnected\. approvedUserIndex: \d+ Reason: (?P.+)$' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "1829350" + platform: windows + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/\[Server\]\s*Startup Completed/i' +appearance: + emoji: "🧛" + grad: "linear-gradient(135deg,#3b0a0a,#991b1b)" + art: "/game-art/v-rising.jpg" + steam: "1604030" diff --git a/modules/valheim/Dockerfile b/modules/valheim/Dockerfile new file mode 100644 index 00000000..dbfcc790 --- /dev/null +++ b/modules/valheim/Dockerfile @@ -0,0 +1,35 @@ +# panel-native Valheim runtime image. +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libpulse0 \ + libatomic1 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 2456/udp 2457/udp 2458/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/valheim/entrypoint.sh b/modules/valheim/entrypoint.sh new file mode 100644 index 00000000..418d6519 --- /dev/null +++ b/modules/valheim/entrypoint.sh @@ -0,0 +1,184 @@ +#!/bin/bash +# panel-native Valheim entrypoint. +set -euo pipefail +log() { printf '[panel-valheim] %s\n' "$*"; } + +# Root-stage: chown (SteamCMD sidecar wrote as root) then drop to panel. +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + SERVER_NAME="${SERVER_NAME:-panel Valheim}" \ + SERVER_WORLD="${SERVER_WORLD:-Dedicated}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-secret12}" \ + SERVER_PUBLIC="${SERVER_PUBLIC:-1}" \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves +EXE="${GAME_DIR}/valheim_server.x86_64" +CFG="${SAVE_DIR}/server.cfg" + +if [[ ! -x "${EXE}" ]]; then + log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Valheim via SteamCMD." + exit 78 +fi + +# --- settings source of truth: PANEL ENV (config_values) --- +# The panel is the single editing surface (gamehost Settings page writes +# config_values → these env vars on recreate). The env values ALWAYS win. +# +# We REGENERATE /game-saves/server.cfg from the live env on every boot, purely +# as a human-readable reflection of the active settings — it is NOT sourced +# back in. The previous version seeded it once and then SOURCED it on every +# boot, so a stale server.cfg silently overrode every panel edit (a customer's +# renamed server / new password never took effect, and the Settings page showed +# values that disagreed with the running server). Fixed 2026-06-02. +cat > "${CFG}" < 0. +if [[ "${SERVER_SAVEINTERVAL:-}" =~ ^[0-9]+$ ]] && [[ "${SERVER_SAVEINTERVAL}" -gt 0 ]]; then + EXTRA_ARGS+=(-saveinterval "${SERVER_SAVEINTERVAL}") +fi + +# World preset: one of the vanilla presets (normal/casual/easy/hard/hardcore/ +# immersive/hammer). Empty = leave at the world's existing settings. +if [[ -n "${SERVER_PRESET:-}" ]]; then + EXTRA_ARGS+=(-preset "${SERVER_PRESET}") +fi + +# Per-key world modifiers: SERVER_MODIFIERS holds space-separated "key value" +# pairs, e.g. "combat hard deathpenalty casual resources more". Each pair +# becomes a "-modifier key value". Operator-facing fields write a clean string. +if [[ -n "${SERVER_MODIFIERS:-}" ]]; then + # shellcheck disable=SC2206 + _mods=(${SERVER_MODIFIERS}) + i=0 + while [[ $i -lt ${#_mods[@]} ]]; do + key="${_mods[$i]}"; val="${_mods[$((i+1))]:-}" + if [[ -n "${key}" && -n "${val}" ]]; then + EXTRA_ARGS+=(-modifier "${key}" "${val}") + fi + i=$((i+2)) + done +fi + +# --- Mods: BepInEx (Doorstop) loader --------------------------------------- +# Valheim mods load through BepInEx, injected at launch by Unity Doorstop. The +# panel's mod manager installs BepInEx + plugins into /game/BepInEx (the +# Thunderstore/ValheimPlus zips extract their BepInEx/ tree + doorstop_libs + +# libdoorstop into /game). We enable injection ONLY when: +# - mods are turned on (VALHEIM_MODS_ENABLED=1, set by the panel), AND +# - the loader is actually present on disk (BepInEx/core/BepInEx.Preloader.dll) +# Otherwise we launch vanilla (no perf cost, no risk of a half-install bricking +# the server). cwd is already /game below so the relative paths resolve. +BEPINEX_PRELOADER="${GAME_DIR}/BepInEx/core/BepInEx.Preloader.dll" +MODS_ACTIVE=0 +if [[ "${VALHEIM_MODS_ENABLED:-0}" != "0" && -f "${BEPINEX_PRELOADER}" ]]; then + MODS_ACTIVE=1 +fi + +# ValheimPlus config (valheim_plus.cfg) is NOT generated here. gamehost writes +# the full cfg directly into /game/BepInEx/config/valheim_plus.cfg via the file +# API whenever the customer saves V+ settings (it holds all 345 settings in +# config_values as VP_* and renders the cfg from them). Keeping cfg generation +# out of the container env avoids declaring 345 env vars per instance. + +cd "${GAME_DIR}" +log "starting Valheim dedicated server" +log " name: ${SERVER_NAME}" +log " world: ${SERVER_WORLD}" +log " public: ${SERVER_PUBLIC}" +log " port: ${GAME_PORT} (+ ${GAME_PORT}+1 for query)" +log " extra: ${EXTRA_ARGS[*]:-(none)}" + +if [[ "${MODS_ACTIVE}" = "1" ]]; then + log " mods: BepInEx ENABLED${VALHEIM_PLUS_ENABLED:+ (ValheimPlus)}" + # Doorstop env (from ValheimPlus/BepInEx start_server_bepinex.sh). Paths are + # relative to cwd (=/game). + export DOORSTOP_ENABLED=1 + export DOORSTOP_ENABLE=TRUE + export DOORSTOP_TARGET_ASSEMBLY="./BepInEx/core/BepInEx.Preloader.dll" + export DOORSTOP_INVOKE_DLL_PATH="./BepInEx/core/BepInEx.Preloader.dll" + export LD_LIBRARY_PATH="./doorstop_libs:${GAME_DIR}/linux64:${LD_LIBRARY_PATH:-}" + export LD_PRELOAD="libdoorstop_x64.so:${LD_PRELOAD:-}" +else + log " mods: none (vanilla)" +fi + +# --- Passwordless via ValheimPlus ----------------------------------------- +# disableServerPassword=true in valheim_plus.cfg is NOT enough on its own: +# Valheim still enforces the password if the launch command passes -password, +# and it refuses a passwordless server unless it's also -public 0 (unlisted). +# So when V+ is active AND its cfg has disableServerPassword=true, we OMIT +# -password and force -public 0. (Confirmed: Iron Gate / ServerFlex / multiple +# V+ docker projects all hit exactly this.) +PW_ARGS=(-password "${SERVER_PASSWORD}") +PUBLIC_ARG="${SERVER_PUBLIC}" +VP_CFG="${GAME_DIR}/BepInEx/config/valheim_plus.cfg" +if [[ "${MODS_ACTIVE}" = "1" && "${VALHEIM_PLUS_ENABLED:-0}" != "0" && -f "${VP_CFG}" ]]; then + # Match `disableServerPassword=true` only inside the [Server] section. + if awk ' + /^\[Server\]/ {inserver=1; next} + /^\[/ {inserver=0} + inserver && /^[[:space:]]*disableServerPassword[[:space:]]*=[[:space:]]*true[[:space:]]*$/ {found=1} + END {exit found?0:1} + ' "${VP_CFG}"; then + log " password: DISABLED via ValheimPlus (omitting -password, forcing -public 0)" + PW_ARGS=() + PUBLIC_ARG="0" + fi +fi + +exec stdbuf -oL -eL "${EXE}" \ + -name "${SERVER_NAME}" \ + -port "${GAME_PORT}" \ + -world "${SERVER_WORLD}" \ + "${PW_ARGS[@]}" \ + -public "${PUBLIC_ARG}" \ + -savedir "${SAVE_DIR}" \ + "${EXTRA_ARGS[@]}" \ + -nographics -batchmode diff --git a/modules/valheim/module.yaml b/modules/valheim/module.yaml new file mode 100644 index 00000000..42478e31 --- /dev/null +++ b/modules/valheim/module.yaml @@ -0,0 +1,117 @@ +# Valheim — panel-native dedicated server module. +# +# Native Linux binary from SteamCMD app 896660. No RCON natively — the +# server reads admin commands from stdin and prints to stdout, which is +# a "STDIO adapter" thing we haven't shipped yet. So v1 runs without an +# RCON/Players tab, same story as Empyrion. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (Valheim.kvp) + +id: valheim +name: "Valheim" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + image: panel-valheim:latest + # Host networking -- container shares the host net namespace, so + # any port the game/mod binds is directly on the LAN. AMP-like + # model. Multi-instance safety comes from env-driven per-instance + # ports (panel allocator + env: mapping on each ports entry). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Worlds + adminlist.txt + banlist.txt (via $HOME/.config/unity3d)" + - name: "Game Files" + path: /game + hint: "Binaries, plugins — drop BepInEx/Jotunn here" + env: + SERVER_NAME: "panel Valheim" + SERVER_WORLD: "Dedicated" + SERVER_PASSWORD: "secret12" # Valheim requires ≥5 chars + not part of name/world + SERVER_PUBLIC: "1" # 1 = listed, 0 = unlisted + # Optional curated knobs (surfaced as Settings fields in gamehost). Must + # be pre-declared here so the agent's config_values override is honoured + # by ResolveDocker (it only passes through keys already in this block). + SERVER_CROSSPLAY: "0" # 1 = enable PlayFab crossplay + SERVER_SAVEINTERVAL: "1800" # world autosave interval, seconds + SERVER_PRESET: "" # normal|casual|easy|hard|hardcore|immersive|hammer + SERVER_MODIFIERS: "" # "combat hard deathpenalty casual ..." pairs + VALHEIM_MODS_ENABLED: "0" # 1 = inject BepInEx/Doorstop at launch (mods installed) + VALHEIM_PLUS_ENABLED: "0" # 1 = ValheimPlus is the active mod (drives the V+ settings UI) + # Port env vars MUST be pre-declared here (with the manifest default) so + # the agent's per-port `env:` injection is allowed to override them at + # create time. ResolveDocker only honours config_values for keys that + # already exist in this static env block — otherwise the injected + # PORT_GAME is silently dropped and the entrypoint falls back to 2456. + PORT_GAME: "2456" + PORT_GAMEA: "2457" + PORT_QUERY: "2458" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +# The panel allocator assigns a per-instance host port for each entry and, +# thanks to the `env:` mapping, injects it into the container so the entrypoint +# binds the ALLOCATED port instead of the hardcoded default. Without this the +# game binds 2456/2457 regardless of allocation and every public forward (which +# targets the allocated port) misses — server unreachable / not in the browser. +# Valheim actually opens and +1 for play and +1 for the +# Steam query/browser; we pass PORT_GAME and let the binary take port+1. The +# allocator hands out contiguous ports so gameA/query line up. +ports: + - { name: game, proto: udp, default: 2456, required: true, env: PORT_GAME } + - { name: gameA, proto: udp, default: 2457, required: true, env: PORT_GAMEA } + - { name: query, proto: udp, default: 2458, required: true, env: PORT_QUERY } + +resources: + min_ram_mb: 2048 + recommended_ram_mb: 4096 + +rcon: + # Valheim has no network RCON; admin commands go straight to the server's + # stdin console. The stdio adapter auto-enables OpenStdin on the container. + adapter: stdio + commands: + save: "save" + broadcast: "broadcast \"{msg}\"" + kick: "kick {player}" + ban: "ban {player}" + shutdown: "shutdown" +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (valheim.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'Got character ZDOID from (?P.+?) : (?P-?\d+):\d+' + kind: join + leave: + pattern: 'Destroying abandoned non persistent zdo -?\d+:\d+ owner (?P-?\d+)$' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "896660" + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Game server connected|Dedicated server started/i' +appearance: + emoji: "⚔️" + grad: "linear-gradient(135deg,#3b5998,#8da5d4)" + art: "/game-art/valheim.jpg" + steam: "892970" diff --git a/modules/vein/Dockerfile b/modules/vein/Dockerfile new file mode 100644 index 00000000..054ee0c0 --- /dev/null +++ b/modules/vein/Dockerfile @@ -0,0 +1,63 @@ +# panel-native VEIN runtime image. +# +# UE4-class dedicated server. Per the AMP template the Linux server needs +# libasound2, libatomic1 and libpulse0 in addition to the usual 32-bit +# SteamCMD runtime libs. On debian:12 the ALSA package is `libasound2` +# (the `libasound2t64` rename only happened on Ubuntu 24.04 / Debian trixie). +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + lib32gcc-s1 \ + lib32stdc++6 \ + libc6:i386 \ + libatomic1 \ + libasound2 \ + libpulse0 \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Bake the Steam client SDK into the image. VEIN's depot ships libsteam_api.so +# but NOT steamclient.so — the runtime SDK module SteamAPI_Init dlopen's from +# ~/.steam/sdk64/. The panel's SteamCMD sidecar only installs the GAME into the +# volume and is then removed, so steamclient.so must come from the image. We +# fetch steamcmd and run it once (+quit) to self-bootstrap, which downloads +# linux32/ + linux64/steamclient.so; we stash both at /opt/steamsdk for the +# entrypoint to symlink. (Without this the server runs but never registers with +# Steam — "Steam Dedicated Server API failed to initialize" + no heartbeat.) +RUN mkdir -p /opt/steamcmd /opt/steamsdk/linux64 /opt/steamsdk/linux32 \ + && curl -fsSL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" | tar -xz -C /opt/steamcmd \ + && ( /opt/steamcmd/steamcmd.sh +quit || true ) \ + && for f in $(find /opt/steamcmd /root/.steam /root/.local/share/Steam -name steamclient.so 2>/dev/null); do \ + echo "$f" | grep -qi linux32 && cp -f "$f" /opt/steamsdk/linux32/steamclient.so || cp -f "$f" /opt/steamsdk/linux64/steamclient.so ; \ + done \ + && ls -la /opt/steamsdk/linux64/ /opt/steamsdk/linux32/ \ + && test -f /opt/steamsdk/linux64/steamclient.so + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves /opt/steamsdk + +COPY entrypoint.sh /entrypoint.sh +# Strip any CR (\r) so a Windows-checkout CRLF entrypoint can't make the kernel +# look for "/bin/bash\r" → "exec /entrypoint.sh: No such file or directory" +# (exit 127 crash-loop). Belt-and-suspenders: the source is LF, but a future +# re-checkout on a CRLF-normalizing host shouldn't be able to break the image. +RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh + +EXPOSE 7777/udp 27015/udp 7878/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/vein/entrypoint.sh b/modules/vein/entrypoint.sh new file mode 100644 index 00000000..fa336ef3 --- /dev/null +++ b/modules/vein/entrypoint.sh @@ -0,0 +1,192 @@ +#!/bin/bash +# panel-native VEIN entrypoint. +# +# Contract: +# /game — SteamCMD-installed VEIN Dedicated Server (app 2131400), +# populated by the panel's SteamCMD updater. If the server +# binary is missing, exit EX_CONFIG (78) with a helpful hint. +# /game-saves — Vein/Saved/ tree (savegame + Config/LinuxServer/Game.ini). +# +# Stage 1 (root): chown volumes (SteamCMD wrote as root), wire the Steam +# client SDK (~/.steam/sdk64/steamclient.so) so the Steamworks game-server +# API can register on the master server, then drop to the `panel` user. +# Stage 2 (panel): stamp config_values env into the CORRECT UE sections of +# Game.ini, redirect saves into the volume, then exec the server with the +# panel-allocated ports. + +set -euo pipefail +log() { printf '[panel-vein] %s\n' "$*"; } + +GAME_DIR=/game +SAVE_DIR=/game-saves + +# ----- stage 1: root-only bootstrap ----- +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + + log "dropping privileges to panel (UID 1000)" + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env \ + HOME=/home/panel \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + STEAM_APP_ID="${STEAM_APP_ID:-1857950}" \ + SteamAppId="${STEAM_APP_ID:-1857950}" \ + GAME_PORT="${GAME_PORT:-7777}" \ + QUERY_PORT="${QUERY_PORT:-27015}" \ + RCON_PORT="${RCON_PORT:-7878}" \ + SERVER_NAME="${SERVER_NAME:-}" \ + SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-}" \ + SERVER_PASSWORD="${SERVER_PASSWORD:-}" \ + MAX_PLAYERS="${MAX_PLAYERS:-}" \ + SERVER_PUBLIC="${SERVER_PUBLIC:-}" \ + HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-}" \ + ENABLE_VAC="${ENABLE_VAC:-}" \ + SHOW_SCOREBOARD_BADGES="${SHOW_SCOREBOARD_BADGES:-}" \ + /entrypoint.sh "$@" +fi + +# ----- stage 2: panel user ----- +EXE="${GAME_DIR}/Vein/Binaries/Linux/VeinServer-Linux-Test" +# Fallbacks in case the depot ships a differently-suffixed binary +# (Shipping / DebugGame). The smoke test confirms the real name. +if [[ ! -x "${EXE}" ]]; then + for alt in VeinServer-Linux-Shipping VeinServer-Linux-DebugGame VeinServer-Linux; do + if [[ -x "${GAME_DIR}/Vein/Binaries/Linux/${alt}" ]]; then + EXE="${GAME_DIR}/Vein/Binaries/Linux/${alt}" + break + fi + done +fi + +if [[ ! -x "${EXE}" ]]; then + log "ERROR: VEIN server binary not found under ${GAME_DIR}/Vein/Binaries/Linux/." + log " Run 'Update' in the panel to install VEIN via SteamCMD (app 2131400)." + exit 78 +fi + +# --- Steam client SDK: the Steamworks game-server API dlopen's steamclient.so +# from ~/.steam/sdk64/ (64-bit) and ~/.steam/sdk32/ (32-bit). VEIN's depot does +# NOT ship steamclient.so, and the panel's SteamCMD sidecar (which has it) is +# torn down after install — so we bake it into the image at /opt/steamsdk and +# symlink it here. Without this: "Steam Dedicated Server API failed to +# initialize" + endless "Failed to heartbeat" + the ready-regex never fires. +mkdir -p "${HOME}/.steam/sdk64" "${HOME}/.steam/sdk32" +for pair in "linux64:sdk64" "linux32:sdk32"; do + src="/opt/steamsdk/${pair%%:*}/steamclient.so" + dst="${HOME}/.steam/${pair##*:}/steamclient.so" + if [[ -f "${src}" ]]; then + ln -sf "${src}" "${dst}" + fi +done +# Fallback: if the baked SDK is somehow absent, scan known runtime locations. +if [[ ! -e "${HOME}/.steam/sdk64/steamclient.so" ]]; then + alt="$(find /opt /game "${HOME}/.steam" "${HOME}/.local/share/Steam" -name steamclient.so 2>/dev/null | grep -i 'linux64\|sdk64' | head -1)" + [[ -z "${alt}" ]] && alt="$(find /opt /game "${HOME}/.steam" -name steamclient.so 2>/dev/null | head -1)" + [[ -n "${alt}" ]] && ln -sf "${alt}" "${HOME}/.steam/sdk64/steamclient.so" +fi +if [[ -e "${HOME}/.steam/sdk64/steamclient.so" ]]; then + log "steamclient.so wired → $(readlink -f "${HOME}/.steam/sdk64/steamclient.so")" +else + log "WARNING: steamclient.so not found — Steam server registration will fail (server still LAN-joinable)." +fi +export LD_LIBRARY_PATH="${HOME}/.steam/sdk64:${GAME_DIR}/linux64:${GAME_DIR}/Vein/Binaries/Linux:${LD_LIBRARY_PATH:-}" + +# --- Game.ini: section-aware env → INI stamping ------------------------- +# VEIN's Game.ini is a multi-section UE config. Each tunable lives under a +# SPECIFIC [/Script/...] header — MaxPlayers under [/Script/Engine.GameSession], +# ServerName/Password under [/Script/Vein.VeinGameSession], etc. We stamp +# only NON-EMPTY env values, in place, into the right section — preserving +# operator hand-edits to other keys/sections via the Files tab. +CFG_DIR="${SAVE_DIR}/Vein/Saved/Config/LinuxServer" +INI="${CFG_DIR}/Game.ini" +mkdir -p "${CFG_DIR}" +[[ -f "${INI}" ]] || : > "${INI}" + +# set_ini_kv "[Section]" KEY VALUE +# Replace KEY=... if it already exists *within* its section; else insert it +# immediately after the section header; create the section at EOF if absent. +# Skips on empty VALUE. Section-aware: a same-named key in another section +# is left untouched. Idempotent. Pure awk — no extra runtime deps. +set_ini_kv() { + local section="$1" key="$2" value="$3" + [[ -z "${value}" ]] && return 0 + local tmp="${INI}.tmp.$$" + awk -v sec="${section}" -v key="${key}" -v val="${value}" ' + function isheader(l) { return (l ~ /^[ \t]*\[.*\][ \t]*$/) } + function trim(l) { gsub(/^[ \t]+|[ \t]+$/, "", l); return l } + BEGIN { cur=""; replaced=0; hdr_line=0 } + { + lines[NR]=$0 + t=trim($0) + if (isheader(t)) cur=t + section_of[NR]=cur + if (cur==sec && hdr_line==0 && t==sec) hdr_line=NR + # replace in-place when key matches inside the target section + if (cur==sec && (t ~ ("^" key "[ \t]*=") )) { + lines[NR]=key "=" val + replaced=1 + } + } + END { + if (replaced) { + for (i=1;i<=NR;i++) print lines[i] + } else if (hdr_line>0) { + for (i=1;i<=NR;i++) { + print lines[i] + if (i==hdr_line) print key "=" val + } + } else { + for (i=1;i<=NR;i++) print lines[i] + if (NR>0 && trim(lines[NR])!="") print "" + print sec + print key "=" val + } + } + ' "${INI}" > "${tmp}" && mv "${tmp}" "${INI}" +} + +GS='[/Script/Engine.GameSession]' +VS='[/Script/Vein.VeinGameSession]' +OSS='[OnlineSubsystemSteam]' +SS='[/Script/Vein.ServerSettings]' + +set_ini_kv "$GS" "MaxPlayers" "${MAX_PLAYERS}" +set_ini_kv "$VS" "ServerName" "${SERVER_NAME}" +set_ini_kv "$VS" "ServerDescription" "${SERVER_DESCRIPTION}" +set_ini_kv "$VS" "Password" "${SERVER_PASSWORD}" +set_ini_kv "$VS" "bPublic" "${SERVER_PUBLIC}" +set_ini_kv "$VS" "HeartbeatInterval" "${HEARTBEAT_INTERVAL}" +set_ini_kv "$OSS" "bVACEnabled" "${ENABLE_VAC}" +set_ini_kv "$SS" "GS_ShowScoreboardBadges" "${SHOW_SCOREBOARD_BADGES}" + +# --- Saves: redirect Vein/Saved into the save volume via symlink so world +# data + configs survive container recreate and show in the Files tab. +mkdir -p "${SAVE_DIR}/Vein/Saved" "${SAVE_DIR}/logs" +if [[ -d "${GAME_DIR}/Vein/Saved" && ! -L "${GAME_DIR}/Vein/Saved" ]]; then + if [[ -z "$(ls -A "${SAVE_DIR}/Vein/Saved" 2>/dev/null)" ]]; then + cp -a "${GAME_DIR}/Vein/Saved/." "${SAVE_DIR}/Vein/Saved/" 2>/dev/null || true + fi + rm -rf "${GAME_DIR}/Vein/Saved" +fi +mkdir -p "${GAME_DIR}/Vein" +ln -sfn "${SAVE_DIR}/Vein/Saved" "${GAME_DIR}/Vein/Saved" + +cd "${GAME_DIR}" +log "starting VEIN dedicated server" +log " name: ${SERVER_NAME:-}" +log " slots: ${MAX_PLAYERS:-}" +log " ports: game=${GAME_PORT} query=${QUERY_PORT}" +log " binary: ${EXE}" + +# Launch args mirror the AMP template: +# Vein -Port=N -QueryPort=N -UseGameSaveDir -stdout -FullStdOutLogOutput +# -stdout + -FullStdOutLogOutput are LOAD-BEARING: they make the server log +# to stdout so the panel's Console live-tail (and the ready-regex) work, and +# so the stdio admin adapter's command echoes are visible. +exec stdbuf -oL -eL "${EXE}" Vein \ + -Port="${GAME_PORT}" \ + -QueryPort="${QUERY_PORT}" \ + -UseGameSaveDir \ + -stdout \ + -FullStdOutLogOutput diff --git a/modules/vein/module.yaml b/modules/vein/module.yaml new file mode 100644 index 00000000..5663f689 --- /dev/null +++ b/modules/vein/module.yaml @@ -0,0 +1,126 @@ +# VEIN — panel-native dedicated server module. +# +# Native Linux dedicated server. UE4-class survival game. SteamCMD app +# 2131400 (the DEDICATED SERVER app id — distinct from the game app id +# 1857950, which the server reports itself as to Steam via SteamAppId). +# Anonymous Steam login works. Admin console is stdin-only (no network +# RCON), so the panel drives the console via the stdio adapter — same +# model as Enshrouded / Sons of the Forest. +# +# Reference: https://github.com/CubeCoders/AMPTemplates (vein.kvp) + +id: vein +name: "VEIN" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + # Built locally from ./Dockerfile. Rebuild with: + # docker build -t panel-vein:latest modules/vein + image: panel-vein:latest + # Host networking -- container shares the host net namespace, so + # any port the game binds is directly on the LAN. AMP-like model. + # Multi-instance safety comes from env-driven per-instance ports + # (panel allocator + env: mapping on each ports entry below). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "Vein/Saved/ savegame data + Config/LinuxServer/Game.ini" + - name: "Game Files" + path: /game + hint: "Vein/Binaries/Linux/ server binary + steamclient libs" + # Pre-declare every env key that any config_value below maps to. The + # agent's resolve.go ONLY propagates config_values whose key is already + # present in `docker.env` — keys not listed here get silently filtered + # out. Empty-string defaults mean "leave Game.ini alone" (the entrypoint + # stamps only non-empty values, so a recreate with empty env preserves + # whatever the operator set previously via the Config/Files tab). + env: + # Steamworks client runtime init context — the dedicated-server app + # (2131400) reports itself to Steam as VEIN (1857950). AMP sets this + # too; without it the server's Steam socket init / master-server + # listing fails. + STEAM_APP_ID: "1857950" + + # --- Per-instance ports --- + # MUST be pre-declared here or resolve.go silently drops the allocator's + # injected values (it only overrides docker.env keys that already exist), + # and the entrypoint falls back to these defaults → two VEIN servers on + # one host both bind 7777 and collide. The `env:` on each ports entry + # below maps the panel-allocated HOST port into these vars at start. + GAME_PORT: "7777" + QUERY_PORT: "27015" + RCON_PORT: "7878" + + # --- Identity & access (stamped into Game.ini sections) --- + SERVER_NAME: "" + SERVER_DESCRIPTION: "" + SERVER_PASSWORD: "" + MAX_PLAYERS: "" + SERVER_PUBLIC: "" # bPublic — list on the public browser + HEARTBEAT_INTERVAL: "" + ENABLE_VAC: "" # bVACEnabled under [OnlineSubsystemSteam] + SHOW_SCOREBOARD_BADGES: "" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + + # Multi-instance safety: `env:` tells the agent which container env var to + # set with the allocated host port. The entrypoint reads these to build + # VEIN's `-Port` / `-QueryPort` launch flags so two VEIN servers on the + # same agent get distinct, non-colliding sockets (avoids the Valheim + # hardcoded-port collision class of bug). +ports: + - { name: game, proto: udp, default: 7777, required: true, env: GAME_PORT } + - { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT } + # VEIN declares an RCON port but the admin path is STDIO; we expose it for + # completeness / firewalling but do NOT wire a network rcon adapter to it. + - { name: rcon, proto: udp, default: 7878, internal: true, env: RCON_PORT } + +resources: + min_ram_mb: 4096 + recommended_ram_mb: 8192 + +rcon: + # VEIN admin console is stdin-only (no network RCON). The stdio adapter + # auto-enables container OpenStdin (agent resolve.go) and the Console tab + # "Send" box writes commands to the server process's stdin. Output comes + # back via the docker-logs live tail, not a correlated reply. + adapter: stdio +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (vein.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'LogNet: Login request: (?:\?Password=[^?]*)?\?Name=(?P.+?)\?\?ID=(?P\d+)\?Ticket=' + kind: join + leave: + pattern: 'LogNet: UChannel::CleanUp: ChIndex == \d+\. Closing connection\..*RemoteAddr: (?P\d+):\d+' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "2131400" + install_path: /game + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/LogRamjetNetworking: Steamworks server initialized with SteamID \d+|LogRamjetNetworking: Heartbeating with IP /i' +appearance: + emoji: "🩸" + grad: "linear-gradient(135deg,#2a0a0a,#7a1010)" + art: "/game-art/vein.jpg" + steam: "1857950" diff --git a/modules/windrose/Dockerfile b/modules/windrose/Dockerfile new file mode 100644 index 00000000..36966b3a --- /dev/null +++ b/modules/windrose/Dockerfile @@ -0,0 +1,97 @@ +# panel-native Windrose runtime image. +# +# Windrose's dedicated server is a Windows-only UE5 build, so we bring along +# just enough Wine + Xvfb to host it headlessly. SteamCMD is NOT installed +# here — the panel's generic updater sidecar uses the official steamcmd +# image and writes into our /game volume. +# +# Expected runtime layout inside the container: +# /game — SteamCMD-installed Windows build (panel volume) +# /game/WindroseServer.exe — dedicated server binary (preferred path) +# /game/R5/Binaries/Win64/WindroseServer.exe — fallback path (UE5 convention) +# /game-saves — ServerDescription.json, R5/Saved save tree +# /entrypoint.sh — boot Xvfb, launch exe under Wine + +FROM debian:12-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + TZ=Etc/UTC \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + WINEDEBUG=-all \ + WINEPREFIX=/home/panel/.wine \ + DISPLAY=:99 + +# Wine: install winehq-stable from dl.winehq.org rather than Debian's +# wine 8.0~repack. Reason: Windrose's UE5 game uses gRPC for its R5 coop +# session. gRPC's Windows event engine asserts on partial-transfer IOCP +# completions ("ASSERTION FAILED: result.bytes_transferred == +# buffer_->Length() at windows_endpoint.cc:355"). Wine 8.0's IOCP can +# return partial completions for WSARecv that the assertion rejects, so +# the server *crashes* the moment a player session reaches the gRPC +# transport layer (~12 seconds after a join). Wine 9+ on winehq's stable +# repo has the IOCP fix; matches what CubeCoders' AMP wine-stable image +# ships. cabextract + winbind are commonly needed by Wine's installer +# helpers even though we don't call winetricks directly. +RUN dpkg --add-architecture i386 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + locales \ + tini \ + curl \ + gnupg \ + xvfb \ + xauth \ + winbind \ + cabextract \ + procps \ + && mkdir -pm755 /etc/apt/keyrings \ + && curl -fsSL https://dl.winehq.org/wine-builds/winehq.key \ + | gpg --dearmor -o /etc/apt/keyrings/winehq-archive.key \ + && curl -fsSL https://dl.winehq.org/wine-builds/debian/dists/bookworm/winehq-bookworm.sources \ + -o /etc/apt/sources.list.d/winehq-bookworm.sources \ + && apt-get update \ + && apt-get install -y --no-install-recommends winehq-stable \ + && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -g 1000 panel \ + && useradd -u 1000 -g 1000 -m -s /bin/bash panel \ + && mkdir -p /game /game-saves \ + && chown -R panel:panel /game /game-saves /home/panel + +# Pre-warm the Wine prefix at /home/panel/.wine during the image build. +# +# Why: Wine 11's first-boot wineboot pops `appwiz.cpl install_mono` to +# install Wine Mono. In a headless xvfb container that step blocks +# indefinitely, and any interrupt (SIGINT, container restart) leaves the +# prefix half-built — kernel32.dll never finishes registering, so the +# next launch dies with "could not load kernel32.dll, status c0000135" +# in a tight crash-loop. UE5 dedicated servers don't need .NET, so we +# skip Mono entirely via WINEDLLOVERRIDES=mscoree= and the Gecko HTML +# engine via mshtml=. Result: first boot is fast and idempotent; +# entrypoint can skip wineboot since system.reg is already present. +USER panel +ENV HOME=/home/panel WINEPREFIX=/home/panel/.wine WINEDEBUG=-all +# wineboot --init under xvfb-run hangs on Wine 11 because xvfb-run kills +# its X server the moment wineboot's main thread returns, but wineboot +# spawns explorer.exe / services.exe in the background that keep trying +# to reach :99 and emit "X connection broken" errors that kill the build. +# Run Xvfb directly, give it a moment, point DISPLAY at it, init the +# prefix, then `wineserver -w` to flush state and explicitly stop Xvfb. +RUN Xvfb :99 -screen 0 1024x768x24 -nolisten tcp & XVFB_PID=$! \ + && sleep 2 \ + && DISPLAY=:99 WINEDLLOVERRIDES="mscoree=;mshtml=" wineboot --init \ + && wineserver -w \ + && kill "$XVFB_PID" 2>/dev/null || true +USER root + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 7777/udp 27015/udp + +ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"] diff --git a/modules/windrose/README.md b/modules/windrose/README.md new file mode 100644 index 00000000..8ca854d5 --- /dev/null +++ b/modules/windrose/README.md @@ -0,0 +1,25 @@ +# Windrose — panel-native module + +Panel-native Windrose dedicated server running under Wine + Xvfb. + +- **Image:** `panel-windrose:latest` — built from `./Dockerfile` (Debian 12 + Wine + Xvfb + tini). +- **Game files:** downloaded by the panel's SteamCMD updater sidecar (app 4129620) into the `panel--game` named volume. Windrose ships only as a Windows build, so the updater uses `+@sSteamCmdForcePlatformType windows` (declared in `module.yaml` via `platform: windows`). +- **Saves:** `panel--saves` volume mounted at `/game-saves`; the entrypoint moves `R5/Saved/` from the game install into the save volume on first boot and symlinks it back, so SteamCMD `validate` can't clobber world data. +- **Config:** `ServerDescription.json` lives in the save volume (authoritative) and is symlinked back to the game install path the binary expects. Edit via the Files tab — JSON parsed in-browser; no dedicated Config-tab form yet. +- **RCON:** none. Windrose has no remote admin protocol; the admin console is in-process only. The Console tab streams the UE log. + +## Ports + +- **7777/udp** — Direct-connect game port (docs refer to this as `DirectConnectionServerPort`). +- **27015/udp** — Steam query (UE5 convention). + +Most Windrose peer connectivity runs through Steam NAT punch-through, but the direct port must be reachable for LAN or public-IP clients. + +## Updating + +Click **Update** in the panel. The SteamCMD sidecar re-validates app 4129620 against the `/game` volume. First install is ~900 MB compressed. + +## Known gotchas + +- Server binary path isn't documented — the entrypoint probes `/game/WindroseServer.exe`, `/game/R5/Binaries/Win64/WindroseServer.exe`, and `/game/R5/Binaries/WinGDK/WindroseServer.exe`, then falls back to `find` if none match. Check logs on first boot. +- No RCON means the Players tab will stay empty; start/stop state comes from log-line ready-regex + container state. diff --git a/modules/windrose/entrypoint.sh b/modules/windrose/entrypoint.sh new file mode 100644 index 00000000..8b6279c1 --- /dev/null +++ b/modules/windrose/entrypoint.sh @@ -0,0 +1,293 @@ +#!/bin/bash +# panel-native Windrose entrypoint. +# +# Contract: +# /game — SteamCMD-installed Windows build of Windrose (volume, +# populated by the panel's SteamCMD updater sidecar). +# If missing, exit with EX_CONFIG and a helpful message. +# /game-saves — ServerDescription.json + R5/Saved/ tree (volume). +# +# Runs as root for its opening stage (fix ownership after the SteamCMD +# sidecar wrote as root, nuke stale X lock, symlink /Logs + R5/Saved into +# the save volume), then drops to the `panel` user for Wine + Xvfb + the +# game. Wine refuses to run as root, so the drop is load-bearing. + +set -euo pipefail + +log() { printf '[panel-windrose] %s\n' "$*"; } + +# ----- stage 1: root-only bootstrap ----- +if [[ "$(id -u)" = "0" ]]; then + chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true + + # Remove Debian/Ubuntu's "127.0.1.1 " mapping from the + # container's /etc/hosts (docker manages a per-container copy at + # /var/lib/docker/containers//hosts, so this does NOT touch the + # host file). + # + # Why: Windrose's UE5 / Wine ICE stack does gethostname() + + # gethostbyname(), gets 127.0.1.1, then enumerates it as a host + # ICE candidate. When a player joins via cloud P2P the server + # tries to send STUN from 127.0.1.1 to the peer's relay, the + # Linux kernel rejects with EACCES (loopback can't route + # externally), Windrose retries a few times then crashes the + # *server* process. Removing the mapping makes gethostbyname + # fall through, ICE skips the loopback candidate, and joins work. + # Note: /etc/hosts is a docker bind mount of the per-container + # /var/lib/docker/containers//hosts; `sed -i` fails with + # "Device or resource busy" on rename, so we filter then truncate- + # write in place via grep|tee. + # + # Two-stage fix: + # 1) Strip any "127.0.1.1 " line. + # 2) Pin "127.0.0.1 " so NSS-files wins before DNS — + # otherwise systemd-resolved on the host (which the container's + # resolv.conf points at via 127.0.0.53 under network_mode=host) + # synthesizes =>127.0.1.1 from thin air, undoing + # step 1. With =>127.0.0.1 in /etc/hosts, ICE's + # loopback filter actually catches it and the candidate is + # dropped instead of becoming an EACCES STUN. + if [[ -w /etc/hosts ]]; then + HOSTNAME_VAL="$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo localhost)" + grep -vE '^127\.0\.1\.[0-9]+[[:space:]]' /etc/hosts > /tmp/hosts.new || true + if [[ -s /tmp/hosts.new ]]; then + cat /tmp/hosts.new > /etc/hosts + fi + if ! grep -qE "^127\\.0\\.0\\.1[[:space:]].*\\b${HOSTNAME_VAL}\\b" /etc/hosts; then + printf '127.0.0.1 %s\n' "${HOSTNAME_VAL}" >> /etc/hosts + fi + rm -f /tmp/hosts.new + fi + + # UE5 under Wine writes to Z:\Logs (= /Logs) and Z:\\Saved etc. + # Pre-create /Logs as a symlink into the save volume so logs survive + # container recreate and show up in the Files tab. + mkdir -p /game-saves/WindroseLogs + chown -R panel:panel /game-saves/WindroseLogs + if [[ ! -e /Logs || -L /Logs ]]; then + ln -sfn /game-saves/WindroseLogs /Logs + fi + + log "dropping privileges to panel (UID 1000)" + exec setpriv --reuid=panel --regid=panel --clear-groups \ + --inh-caps=-all --bounding-set=-all \ + env \ + HOME=/home/panel \ + WINEPREFIX=/home/panel/.wine \ + WINEDEBUG=-all \ + SERVER_NAME="${SERVER_NAME:-panel Windrose}" \ + DIRECT_PORT="${DIRECT_PORT:-7777}" \ + USE_DIRECT_CONNECTION="${USE_DIRECT_CONNECTION:-true}" \ + PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + /entrypoint.sh "$@" +fi + +GAME_DIR=/game +SAVE_DIR=/game-saves + +# --- locate the server binary --- +# Windrose's StartServerForeground.bat launches the shipping binary directly: +# start /abovenormal R5\Binaries\Win64\WindroseServer-Win64-Shipping.exe -log +# The WindroseServer.exe at the game root is a ~260KB launcher stub, not the +# real server. Prefer the shipping binary; fall back to any match if Windrose +# changes their layout. +CANDIDATES=( + "${GAME_DIR}/R5/Binaries/Win64/WindroseServer-Win64-Shipping.exe" + "${GAME_DIR}/R5/Binaries/WinGDK/WindroseServer-WinGDK-Shipping.exe" + "${GAME_DIR}/R5/Binaries/Win64/WindroseServer.exe" +) +DEDI_EXE="" +for c in "${CANDIDATES[@]}"; do + if [[ -f "$c" ]]; then + DEDI_EXE="$c" + break + fi +done +if [[ -z "${DEDI_EXE}" ]]; then + # Last-ditch: find any *-Shipping.exe (or WindroseServer.exe in a Binaries dir). + found=$(find "${GAME_DIR}" -maxdepth 6 -iname "WindroseServer*.exe" -type f 2>/dev/null | grep -i "Shipping\|Binaries" | head -1 || true) + if [[ -n "${found}" ]]; then + DEDI_EXE="${found}" + fi +fi +if [[ -z "${DEDI_EXE}" ]]; then + log "ERROR: WindroseServer-Win64-Shipping.exe not found under ${GAME_DIR}/R5/Binaries/." + log " Run 'Update' on this server in the panel — that launches a" + log " SteamCMD sidecar which installs Windrose (Windows build) into" + log " this volume via +@sSteamCmdForcePlatformType windows." + exit 78 # EX_CONFIG +fi +log "found server binary: ${DEDI_EXE}" + +# --- persist Saves into /game-saves so SteamCMD validate can't clobber --- +# UE5 writes to /R5/Saved/. Move the directory on first boot then +# symlink. Rebooting finds the symlink already in place; idempotent. +mkdir -p "${SAVE_DIR}/R5" +if [[ -d "${GAME_DIR}/R5/Saved" && ! -L "${GAME_DIR}/R5/Saved" ]]; then + if [[ ! -d "${SAVE_DIR}/R5/Saved" ]]; then + mv "${GAME_DIR}/R5/Saved" "${SAVE_DIR}/R5/Saved" + else + rm -rf "${GAME_DIR}/R5/Saved" + fi +fi +mkdir -p "${SAVE_DIR}/R5/Saved" +mkdir -p "${GAME_DIR}/R5" +ln -sfn "${SAVE_DIR}/R5/Saved" "${GAME_DIR}/R5/Saved" + +# --- ServerDescription.json: seed → migrate → symlink --- +# Windrose 0.10.x generates ServerDescription.json at /game/R5/ on first +# boot, which races our "migrate to save volume" step and leaves the file +# unmigrated until the next restart (panel's Config tab reads from +# /game-saves/R5/). Fix: seed a minimal stub in the save volume BEFORE +# the game starts. The game opens the existing file, fills in empty +# InviteCode / PersistentServerId / WorldIslandId on first save, and panel +# reads a populated config immediately — no first-boot restart dance. +# Template mirrors cytech-services AMP (windroseserverdescription.json). +mkdir -p "${GAME_DIR}/R5" "${SAVE_DIR}/R5" +DESC_GAME="${GAME_DIR}/R5/ServerDescription.json" +DESC_SAVE="${SAVE_DIR}/R5/ServerDescription.json" + +if [[ -f "${DESC_GAME}" && ! -L "${DESC_GAME}" ]]; then + # Legacy path: a prior boot wrote the file at /game/R5/ without migration. + if [[ ! -s "${DESC_SAVE}" ]]; then + log "migrating existing ServerDescription.json into save volume" + mv "${DESC_GAME}" "${DESC_SAVE}" + else + rm -f "${DESC_GAME}" + fi +fi + +if [[ ! -s "${DESC_SAVE}" ]]; then + log "seeding blank ServerDescription.json (game fills in InviteCode, WorldIslandId, PersistentServerId on first save)" + cat > "${DESC_SAVE}" <<'JSON' +{ + "Version": 1, + "ServerDescription_Persistent": + { + "PersistentServerId": "", + "InviteCode": "", + "IsPasswordProtected": false, + "Password": "", + "ServerName": "panel Windrose", + "Note": "", + "WorldIslandId": "", + "MaxPlayerCount": 8, + "P2pProxyAddress": "127.0.0.1" + } +} +JSON +fi + +# Symlink /game/R5/ServerDescription.json → save-volume copy so the game +# reads + writes the persistent file regardless of which path it opens. +ln -sfn "${DESC_SAVE}" "${DESC_GAME}" + +# --- patch DirectConnectionServerPort + UseDirectConnection from env --- +# Windrose reads its listening port from this JSON (no CLI flag honored). +# We patch on every boot so a panel-allocated DIRECT_PORT flows through. +# UseDirectConnection: true = listen on a public IP, players use IP+port. +# false = use windrose.support cloud P2P (NAT-traversal +# via TURN). Required when the host is on a LAN with +# no port-forward, or for invite-code-only flows. +# Default: true (matches the panel's typical "expose host networking + open +# the port" deployment). Set USE_DIRECT_CONNECTION=false on a per-instance +# basis (panel config_values) to switch to cloud P2P. +USE_DIRECT_CONN_LOWER="$(printf '%s' "${USE_DIRECT_CONNECTION:-true}" | tr '[:upper:]' '[:lower:]')" +case "${USE_DIRECT_CONN_LOWER}" in + false|0|no|off) UDC_VAL="false" ;; + *) UDC_VAL="true" ;; +esac +if [[ -s "${DESC_SAVE}" ]]; then + log "patching ServerDescription.json (DirectConnectionServerPort=${DIRECT_PORT}, UseDirectConnection=${UDC_VAL})" + if grep -q '"DirectConnectionServerPort"' "${DESC_SAVE}"; then + sed -i -E "s|(\"DirectConnectionServerPort\"[[:space:]]*:[[:space:]]*)[0-9]+|\1${DIRECT_PORT}|" "${DESC_SAVE}" + else + # Insert before the closing brace of ServerDescription_Persistent. + sed -i -E "s|(\"P2pProxyAddress\"[[:space:]]*:[[:space:]]*\"[^\"]*\")|\1,\n\t\t\"UseDirectConnection\": ${UDC_VAL},\n\t\t\"DirectConnectionServerPort\": ${DIRECT_PORT}|" "${DESC_SAVE}" + fi + if grep -q '"UseDirectConnection"' "${DESC_SAVE}"; then + sed -i -E "s|(\"UseDirectConnection\"[[:space:]]*:[[:space:]]*)(true\|false)|\1${UDC_VAL}|" "${DESC_SAVE}" + fi +fi + +# Belt-and-suspenders watcher — catches the odd case where a future +# Windrose build writes to /game/R5/ through the symlink's parent dir +# instead of following the link. Exits once things are consistent; +# bounded to 30 min to avoid leaking on crash paths. +( + for _ in $(seq 1 360); do + sleep 5 + if [[ -L "${DESC_GAME}" ]] && [[ -s "${DESC_SAVE}" ]]; then exit 0; fi + if [[ -f "${DESC_GAME}" ]] && [[ ! -L "${DESC_GAME}" ]]; then + log "watcher: re-migrating ServerDescription.json (game wrote through the symlink parent?)" + if [[ ! -s "${DESC_SAVE}" ]]; then + mv "${DESC_GAME}" "${DESC_SAVE}" + else + rm -f "${DESC_GAME}" + fi + ln -sfn "${DESC_SAVE}" "${DESC_GAME}" + exit 0 + fi + done +) & +DESC_WATCHER_PID=$! + +# --- Wine prefix bootstrap (first boot, or repair after a partial init). xvfb-run +# handles X for the real run; for prefix init we still need a display so +# wineboot's installer dialogs auto-dismiss. --- +NEED_INIT=0 +if [[ ! -d "${WINEPREFIX}" || ! -f "${WINEPREFIX}/system.reg" ]]; then + NEED_INIT=1 +fi +# A previous boot can leave a *partially* initialized prefix (system.reg +# present but kernel32.dll missing) if wineboot was killed early — that +# state crashes every subsequent start with "could not load kernel32.dll, +# status c0000135" because our existing-prefix check passes. Detect and +# repair by nuking the broken prefix and re-initing from scratch. +if [[ "${NEED_INIT}" = 0 && ! -f "${WINEPREFIX}/drive_c/windows/system32/kernel32.dll" ]]; then + log "wine prefix at ${WINEPREFIX} is missing kernel32.dll — wiping and reinitializing" + rm -rf "${WINEPREFIX}" + NEED_INIT=1 +fi +if [[ "${NEED_INIT}" = 1 ]]; then + log "initializing Wine prefix at ${WINEPREFIX}" + xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true + wineserver -w || true + if [[ ! -f "${WINEPREFIX}/drive_c/windows/system32/kernel32.dll" ]]; then + log "WARNING: wineboot finished but kernel32.dll still missing — game start will fail" + fi +fi + +# --- go --- +BIN_DIR="$(dirname "${DEDI_EXE}")" +cd "${BIN_DIR}" +log "starting Windrose dedicated server" +log " game dir: ${GAME_DIR}" +log " save dir: ${SAVE_DIR}" +log " binary: ${DEDI_EXE}" +log " bin dir: ${BIN_DIR}" +log " port: ${DIRECT_PORT}" + +trap 'log "SIGTERM received; stopping server"; wineserver -k 2>/dev/null || true; kill "${DESC_WATCHER_PID:-}" 2>/dev/null || true; exit 0' TERM INT + +# Launch flags — matched to cytech-services' AMP template (windrose.kvp): +# R5 project name (required positional for UE5 servers) +# -stdout mirror UE5 log categories to stdout, not just R5.log +# -FullStdOutLogOutput disable stdout filtering — the LogGlobalStatus ready +# line we grep for is category-filtered otherwise and +# only lands in R5.log. This flag is THE reason AMP's +# AppReadyRegex ever matches. +# -log enable the UE5 log file (R5/Saved/Logs/R5.log) too +# -UNATTENDED / -NullRHI standard UE5 headless flags; harmless no-ops on this +# build but documented in other UE5 dedicated patterns. +# Port + server identity come from ServerDescription.json (we patch the +# port above on every boot from $DIRECT_PORT). xvfb-run -a auto-allocates +# a free X display — see panel/memory/gotchas.md for why we can't use :99. +exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ + wine "${DEDI_EXE}" \ + R5 \ + -stdout \ + -FullStdOutLogOutput \ + -log \ + -UNATTENDED \ + -NullRHI diff --git a/modules/windrose/module.yaml b/modules/windrose/module.yaml new file mode 100644 index 00000000..f4348122 --- /dev/null +++ b/modules/windrose/module.yaml @@ -0,0 +1,118 @@ +# Windrose dedicated server module. +# +# Same panel-native Wine pattern as Empyrion / V Rising / Sons of the Forest: +# a minimal Debian 12 image we own (panel-windrose:latest, Debian 12 + Wine +# + Xvfb + tini), with game files populated by the generic SteamCMD updater +# sidecar into a named volume. +# +# Windrose ships as a Windows-only dedicated server (SteamDB app 4129620: +# depot 4129621 is Windows-default, depot 4129622 is a Linux placeholder +# with no manifest). The updater is told to pretend SteamCMD is running on +# Windows (`platform: windows` → +@sSteamCmdForcePlatformType windows), and +# the entrypoint runs WindroseServer.exe under Wine under Xvfb. +# +# There is NO native RCON protocol in Windrose — admin console is in-game +# only, output via StartServerForeground.bat to stdout. We capture that +# stream and surface it in the Console tab; Players tab will be empty until +# a third-party RCON shim is wired in (deferred). +# +# Reference: https://playwindrose.com/dedicated-server-guide/ + +id: windrose +name: "Windrose" +version: 0.1.0 +authors: + - panel contributors + +supported_modes: + - docker + +runtime: + docker: + # Built locally from ./Dockerfile. Rebuild with: + # docker build -t panel-windrose:latest modules/windrose + image: panel-windrose:latest + # Windrose's P2P coop gateway opens a long-lived UDP session to its TURN + # server (coturn-us.windrose.support:3478). Docker's default bridge NAT + # on Windows rebinds UDP source ports mid-session, which breaks ICE + # consent checks and disconnects every joining client (observed: + # "Check consent was failed for IceControlling. Reach timeout 10000 ms" + # ~25s after join, which also crashes the Windrose client). + # + # Host networking shares the host's network namespace directly — no + # NAT, stable 5-tuples, clients can join reliably. On Linux hosts this + # is free and recommended (target deployment: Ubuntu). On Windows Docker + # Desktop it requires "Enable host networking" in Settings → Resources + # → Network (4.29+); if disabled, remove this line to fall back to + # bridge mode (accepting that joins will fail intermittently). + network_mode: host + browseable_root: /game-saves + browseable_roots: + - name: "Saves & Configs" + path: /game-saves + hint: "ServerDescription.json, R5/Saved/SaveProfiles/ world data" + - name: "Game Files" + path: /game + hint: "WindroseServer.exe, R5/ engine files, batch scripts" + env: + SERVER_NAME: "panel Windrose" + DIRECT_PORT: "7777" + # true — server listens on host IP:DIRECT_PORT for direct-connect joins + # (works when host has a public IP or a port-forward). + # false — cloud P2P via windrose.support; players join by InviteCode + # with NAT-traversal via TURN. Required on a LAN host with no + # port-forward. + # ServerDescription.json's UseDirectConnection field is patched to this + # value on every boot. + USE_DIRECT_CONNECTION: "true" + volumes: + - { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" } + - { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" } + - { target: "$DATA_PATH/logs", container: "/game-saves/logs" } + +# Windrose relies on its own R5 coop API gateway for matchmaking / NAT +# punch-through (r5coopapigateway-*.windrose.support). The only port the +# server LISTENS on is the DirectConnectionServerPort (7777 in the shipped +# default ServerDescription.json). No Steam query — publishing 27015/udp +# collides with the default Palworld/ARK query port on multi-server hosts. +ports: + - { name: game, proto: udp, default: 7777, required: true, env: DIRECT_PORT } + +resources: + min_ram_mb: 4096 + recommended_ram_mb: 8192 +# Log-derived player events. Regexes converted from CubeCoders' +# AMPTemplates (windrose.kvp) — .NET (?) syntax -> Go (?P). +# Sourced-from-template; not yet exercised against a live client. +events: + join: + pattern: 'ServerAccount\. AccountName ''(?P[^'']+)''\. AccountId (?P\S+?)\. PlayerState' + kind: join + leave: + pattern: '::MoveAccountToListOfDisconnected +Account disconnected\. AccountId (?P\S+)$' + kind: leave + + +update_providers: + - id: stable + kind: steamcmd + app_id: "4129620" + platform: windows # server is Windows-only — force Windows depot on Linux host + install_path: /game + # SteamCMD's app_update ... validate call fails with "Missing configuration" + # on a fresh empty install dir (same behaviour as Conan Exiles, app 443030). + # Omit the validate keyword so first-install succeeds; operators can run + # Update again later for an integrity pass once the tree exists. + skip_validate: true + +# --- Manifest-driven UI metadata (WI-07) -------------------------- +# Consumed by the CONTROLLER/dashboard only (the controller loads its +# own ./modules copy at startup); the agent ignores these at runtime. +# ready_pattern is a JavaScript regex (slash-delimited when it needs +# flags) moved verbatim from the dashboard's READY_PATTERNS map. +ready_pattern: '/Host server is ready for owner to connect|LogGlobalStatus:\s*UEngine::LoadMap\s+Load\s+map\s+complete\s+\/Game\/Maps\/(?!Lobby)/i' +appearance: + emoji: "🧭" + grad: "linear-gradient(135deg,#1e3a5f,#60a5fa)" + art: "/game-art/windrose.jpg" + steam: "3041230" diff --git a/pkg/dayzxml/merger.go b/pkg/dayzxml/merger.go new file mode 100644 index 00000000..10fc7f3d --- /dev/null +++ b/pkg/dayzxml/merger.go @@ -0,0 +1,490 @@ +// Package dayzxml merges mod XML snippets into a running DayZ server's +// Central Economy mission XMLs (types.xml, events.xml, cfgspawnabletypes.xml, +// cfgeventspawns.xml, cfgrandompresets.xml, messages.xml). +// +// Why this exists: +// +// Mods almost always ship "snippet" XMLs — a types.xml with just the mod's new +// items, or an events.xml with the mod's new dynamic events. Operators are +// expected to merge these into their base mission files. The canonical +// approach Bohemia documents is to register each mod's folder via +// cfgeconomycore.xml and let the engine merge at load time, but most community +// operators still pre-merge into a single file per kind to make debugging, +// diffing, and rolling back easier. That's what this package does. +// +// Merge semantics (per Bohemia wiki "DayZ:Central Economy mission files modding"): +// +// - types.xml, cfgspawnabletypes.xml, cfgeventspawns.xml, cfgrandompresets.xml +// → replace existing entry by `name` attribute; new names appended. +// - events.xml +// → replace the entry's scalar children; APPEND new nodes (events' +// children list is append-only in engine merge behavior — there's no +// "remove a child" via merge; operators must redefine the entire event). +// - messages.xml +// → append-only; messages don't have a unique key and there's no +// replace-by-id semantic in the engine. +// +// We use github.com/beevik/etree rather than Go's encoding/xml so element +// order, attribute order, and comments are preserved verbatim. DayZ's +// engine doesn't care about order, but operators diff these files by hand +// and expect them to stay stable across panel edits. + +package dayzxml + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/beevik/etree" +) + +// FileKind identifies a DayZ CE XML file type whose merge semantics we know. +type FileKind string + +const ( + KindTypes FileKind = "types" // types.xml + KindSpawnableTypes FileKind = "spawnabletypes" // cfgspawnabletypes.xml + KindEvents FileKind = "events" // events.xml + KindEventSpawns FileKind = "eventposdef" // cfgeventspawns.xml + KindRandomPresets FileKind = "randompresets" // cfgrandompresets.xml + KindMessages FileKind = "messages" // messages.xml +) + +// MergeMode controls what happens when a snippet entry collides with an existing one. +type MergeMode int + +const ( + // ReplaceByName overwrites the existing entry wholesale. + ReplaceByName MergeMode = iota + // AppendOnly ignores the existing entry and just appends the snippet's copy. + // Used for files without a unique key (messages.xml). + AppendOnly + // EventsMerge replaces the entry's scalar attributes + non-child descendants, + // and APPENDS nodes from the snippet that don't already exist in + // base (matched by `type=` attribute). Matches engine behavior for events.xml. + EventsMerge +) + +// Spec describes how to identify and merge entries in one file kind. +type Spec struct { + RootTag string // expected root element tag ("types", "events", ...) + EntryTag string // entry element tag ("type", "event"); empty = heterogeneous + KeyAttr string // attribute that identifies an entry ("name"); empty = no key + Mode MergeMode // how to resolve collisions + EntryTags []string // for heterogeneous files (randompresets: "cargo"/"attachments") +} + +// defaultSpecs holds the canonical spec per kind. Callers should treat +// these as read-only. +var defaultSpecs = map[FileKind]Spec{ + KindTypes: {RootTag: "types", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName}, + KindSpawnableTypes: {RootTag: "spawnabletypes", EntryTag: "type", KeyAttr: "name", Mode: ReplaceByName}, + KindEvents: {RootTag: "events", EntryTag: "event", KeyAttr: "name", Mode: EventsMerge}, + KindEventSpawns: {RootTag: "eventposdef", EntryTag: "event", KeyAttr: "name", Mode: ReplaceByName}, + KindRandomPresets: {RootTag: "randompresets", KeyAttr: "name", Mode: ReplaceByName, EntryTags: []string{"cargo", "attachments"}}, + KindMessages: {RootTag: "messages", EntryTag: "message", Mode: AppendOnly}, +} + +// SpecFor returns the default Spec for a file kind, or zero + false if unknown. +func SpecFor(k FileKind) (Spec, bool) { + s, ok := defaultSpecs[k] + return s, ok +} + +// SupportedKinds returns all FileKinds this package knows how to merge, in +// a stable order suitable for UI lists. +func SupportedKinds() []FileKind { + return []FileKind{ + KindTypes, KindSpawnableTypes, KindEvents, KindEventSpawns, + KindRandomPresets, KindMessages, + } +} + +// Plan is the outcome of a (possibly dry-run) merge. +type Plan struct { + Kind FileKind `json:"kind"` + Added []string `json:"added"` // new entry names appended + Replaced []string `json:"replaced"` // existing names overwritten + Unchanged []string `json:"unchanged"` // snippet entries byte-identical to base + AppendedChildren map[string][]string `json:"appended_children,omitempty"` // event name → child types added + Conflicts []string `json:"conflicts,omitempty"` // snippet has same key twice; first wins + Warnings []string `json:"warnings,omitempty"` // non-fatal (BOM stripped, EOL adjusted, etc.) +} + +// Result is returned by Merge. Bytes is the merged XML (empty in dry-run mode). +type Result struct { + Plan Plan + Bytes []byte +} + +// DetectKind classifies a raw snippet by its root element tag. Returns +// the matching FileKind or an error if the root doesn't match any known spec. +func DetectKind(snippet []byte) (FileKind, error) { + doc := etree.NewDocument() + if err := doc.ReadFromBytes(stripBOM(snippet)); err != nil { + return "", fmt.Errorf("parse snippet: %w", err) + } + root := doc.Root() + if root == nil { + return "", fmt.Errorf("snippet has no root element") + } + for k, s := range defaultSpecs { + if s.RootTag == root.Tag { + return k, nil + } + } + return "", fmt.Errorf("unknown root element %q — not a supported DayZ CE XML", root.Tag) +} + +// Merge applies the snippet to the base file. If dryRun is true, only the +// Plan is populated (Bytes is nil). Otherwise Bytes contains the merged +// XML, ready to write. +func Merge(kind FileKind, base, snippet []byte, dryRun bool) (*Result, error) { + spec, ok := defaultSpecs[kind] + if !ok { + return nil, fmt.Errorf("unsupported file kind %q", kind) + } + baseDoc, warnings, err := parseDoc(base, "base") + if err != nil { + return nil, err + } + snipDoc, snipWarnings, err := parseDoc(snippet, "snippet") + if err != nil { + return nil, err + } + warnings = append(warnings, snipWarnings...) + + baseRoot := baseDoc.Root() + if baseRoot == nil || baseRoot.Tag != spec.RootTag { + return nil, fmt.Errorf("base is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(baseDoc)) + } + snipRoot := snipDoc.Root() + if snipRoot == nil || snipRoot.Tag != spec.RootTag { + return nil, fmt.Errorf("snippet is not a <%s> file (got <%s>)", spec.RootTag, safeRootTag(snipDoc)) + } + + plan := Plan{ + Kind: kind, + AppendedChildren: map[string][]string{}, + } + + // Index base entries by key, preserving insertion order. + baseIndex, baseOrder := indexEntries(baseRoot, spec) + // Walk the snippet in order. + seenInSnippet := map[string]bool{} + + for _, child := range snipRoot.ChildElements() { + if !matchesEntry(child, spec) { + continue + } + key := entryKey(child, spec) + if spec.Mode == AppendOnly || key == "" { + // No dedup; append unconditionally. + baseRoot.AddChild(child.Copy()) + plan.Added = append(plan.Added, fmt.Sprintf("%s#%d", child.Tag, len(plan.Added)+1)) + continue + } + if seenInSnippet[key] { + plan.Conflicts = append(plan.Conflicts, key) + continue + } + seenInSnippet[key] = true + existing, exists := baseIndex[key] + if !exists { + baseRoot.AddChild(child.Copy()) + plan.Added = append(plan.Added, key) + continue + } + // Collision — resolve per mode. + switch spec.Mode { + case ReplaceByName: + if elementsEqualish(existing, child) { + plan.Unchanged = append(plan.Unchanged, key) + continue + } + replaceElement(baseRoot, existing, child) + baseIndex[key] = child.Copy() + plan.Replaced = append(plan.Replaced, key) + case EventsMerge: + appended, changed := mergeEventChildren(existing, child) + if !changed { + plan.Unchanged = append(plan.Unchanged, key) + continue + } + plan.Replaced = append(plan.Replaced, key) + if len(appended) > 0 { + plan.AppendedChildren[key] = appended + } + } + _ = baseOrder // preserved via baseRoot ordering + } + + // Stable-sort output slices for deterministic UI. + sort.Strings(plan.Added) + sort.Strings(plan.Replaced) + sort.Strings(plan.Unchanged) + sort.Strings(plan.Conflicts) + plan.Warnings = append(plan.Warnings, warnings...) + + res := &Result{Plan: plan} + if dryRun { + return res, nil + } + baseDoc.Indent(4) + out, err := baseDoc.WriteToBytes() + if err != nil { + return nil, fmt.Errorf("render merged xml: %w", err) + } + // Preserve the base file's line-ending convention. + if bytes.Contains(base, []byte("\r\n")) && !bytes.Contains(out, []byte("\r\n")) { + out = bytes.ReplaceAll(out, []byte("\n"), []byte("\r\n")) + } + res.Bytes = out + return res, nil +} + +// ---------- helpers ---------- + +func parseDoc(raw []byte, label string) (*etree.Document, []string, error) { + warnings := []string{} + clean, bomStripped := stripBOMWithFlag(raw) + if bomStripped { + warnings = append(warnings, label+": stripped UTF-8 BOM") + } + doc := etree.NewDocument() + if err := doc.ReadFromBytes(clean); err != nil { + return nil, warnings, fmt.Errorf("%s: parse: %w", label, err) + } + return doc, warnings, nil +} + +func safeRootTag(d *etree.Document) string { + if r := d.Root(); r != nil { + return r.Tag + } + return "(no-root)" +} + +// matchesEntry reports whether child is one of the entry tags this spec considers. +func matchesEntry(child *etree.Element, spec Spec) bool { + if len(spec.EntryTags) > 0 { + for _, t := range spec.EntryTags { + if child.Tag == t { + return true + } + } + return false + } + return spec.EntryTag != "" && child.Tag == spec.EntryTag +} + +// entryKey returns the merge key for a child element (empty string if no key). +func entryKey(child *etree.Element, spec Spec) string { + if spec.KeyAttr == "" { + return "" + } + return child.SelectAttrValue(spec.KeyAttr, "") +} + +// indexEntries scans root and builds a name→element map of mergeable entries. +// Also returns the insertion-order list (useful for deterministic diffs). +func indexEntries(root *etree.Element, spec Spec) (map[string]*etree.Element, []string) { + m := make(map[string]*etree.Element) + order := make([]string, 0, 64) + for _, child := range root.ChildElements() { + if !matchesEntry(child, spec) { + continue + } + k := entryKey(child, spec) + if k == "" { + continue + } + if _, dup := m[k]; !dup { + order = append(order, k) + } + m[k] = child + } + return m, order +} + +// replaceElement swaps old in-place with a copy of repl, preserving sibling order. +func replaceElement(parent, old, repl *etree.Element) { + newEl := repl.Copy() + // etree doesn't have an "insert at index"; replicate by collecting children + // around `old`, removing all, and re-adding. + children := parent.ChildElements() + idx := -1 + for i, c := range children { + if c == old { + idx = i + break + } + } + if idx < 0 { + parent.AddChild(newEl) + return + } + // Remove old and rebuild tail ordering. + parent.RemoveChild(old) + // If we removed from the end, AddChild appends at the end — correct. + // Otherwise we need to move the trailing children back after newEl. etree's + // AddChild appends, so we re-add newEl then move the original tail past it. + tail := children[idx+1:] + for _, t := range tail { + parent.RemoveChild(t) + } + parent.AddChild(newEl) + for _, t := range tail { + parent.AddChild(t) + } +} + +// elementsEqualish is a lenient equality check: same tag, same attrs +// (set-compare, order-insensitive), same text content, same child-element +// tree by signature dump. +func elementsEqualish(a, b *etree.Element) bool { + if a.Tag != b.Tag { + return false + } + if strings.TrimSpace(a.Text()) != strings.TrimSpace(b.Text()) { + return false + } + if len(a.Attr) != len(b.Attr) { + return false + } + aAttrs := attrMap(a) + bAttrs := attrMap(b) + for k, v := range aAttrs { + if bAttrs[k] != v { + return false + } + } + return childrenSignature(a) == childrenSignature(b) +} + +func attrMap(e *etree.Element) map[string]string { + m := make(map[string]string, len(e.Attr)) + for _, a := range e.Attr { + m[a.Key] = a.Value + } + return m +} + +func childrenSignature(e *etree.Element) string { + var b strings.Builder + for _, c := range e.ChildElements() { + b.WriteString(c.Tag) + b.WriteByte('{') + keys := make([]string, 0, len(c.Attr)) + am := attrMap(c) + for k := range am { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b.WriteString(k) + b.WriteByte('=') + b.WriteString(am[k]) + b.WriteByte(' ') + } + b.WriteString(childrenSignature(c)) + b.WriteByte('}') + t := strings.TrimSpace(c.Text()) + if t != "" { + b.WriteString("#") + b.WriteString(t) + } + } + return b.String() +} + +// mergeEventChildren implements events.xml's special semantics: replace the +// scalar attrs + non-children descendants of `base` with those of `snippet`, +// then append any `` from snippet whose `type` isn't +// already present in base. Returns (appended child types, changed?). +func mergeEventChildren(base, snippet *etree.Element) ([]string, bool) { + changed := false + // 1. Overwrite attributes on the event element. + if !sameAttrs(base, snippet) { + for _, a := range base.Attr { + base.RemoveAttr(a.Key) + } + for _, a := range snippet.Attr { + base.CreateAttr(a.Key, a.Value) + } + changed = true + } + // 2. Replace scalar direct children (non-) wholesale. + for _, sChild := range snippet.ChildElements() { + if sChild.Tag == "children" { + continue + } + // find matching scalar in base (by tag) + if bChild := base.SelectElement(sChild.Tag); bChild != nil { + if !elementsEqualish(bChild, sChild) { + base.RemoveChild(bChild) + base.AddChild(sChild.Copy()) + changed = true + } + } else { + base.AddChild(sChild.Copy()) + changed = true + } + } + // 3. Append new entries from snippet's . + var appended []string + sChildren := snippet.SelectElement("children") + if sChildren == nil { + return appended, changed + } + bChildren := base.SelectElement("children") + if bChildren == nil { + bChildren = base.CreateElement("children") + changed = true + } + existingTypes := map[string]bool{} + for _, c := range bChildren.ChildElements() { + if t := c.SelectAttrValue("type", ""); t != "" { + existingTypes[t] = true + } + } + for _, sChildEl := range sChildren.ChildElements() { + t := sChildEl.SelectAttrValue("type", "") + if t == "" || existingTypes[t] { + continue + } + bChildren.AddChild(sChildEl.Copy()) + existingTypes[t] = true + appended = append(appended, t) + changed = true + } + return appended, changed +} + +func sameAttrs(a, b *etree.Element) bool { + if len(a.Attr) != len(b.Attr) { + return false + } + am := attrMap(a) + for _, at := range b.Attr { + if am[at.Key] != at.Value { + return false + } + } + return true +} + +// stripBOM returns input with a leading UTF-8 BOM removed (if present). +func stripBOM(b []byte) []byte { + out, _ := stripBOMWithFlag(b) + return out +} + +func stripBOMWithFlag(b []byte) ([]byte, bool) { + if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF { + return b[3:], true + } + return b, false +} diff --git a/pkg/dayzxml/merger_test.go b/pkg/dayzxml/merger_test.go new file mode 100644 index 00000000..a2b7f89a --- /dev/null +++ b/pkg/dayzxml/merger_test.go @@ -0,0 +1,170 @@ +package dayzxml + +import ( + "strings" + "testing" +) + +func TestDetectKind(t *testing.T) { + cases := []struct { + in string + want FileKind + }{ + {``, KindTypes}, + {``, KindEvents}, + {``, KindSpawnableTypes}, + {``, KindMessages}, + {``, KindRandomPresets}, + {``, KindEventSpawns}, + } + for _, c := range cases { + got, err := DetectKind([]byte(c.in)) + if err != nil { + t.Errorf("DetectKind(%q) err: %v", c.in, err) + continue + } + if got != c.want { + t.Errorf("DetectKind(%q) = %v, want %v", c.in, got, c.want) + } + } + if _, err := DetectKind([]byte(``)); err == nil { + t.Error("expected error for unknown root") + } +} + +func TestMergeTypes_AddAndReplace(t *testing.T) { + base := []byte(` + 52 + 31 +`) + snippet := []byte(` + 84 + 11 +`) + res, err := Merge(KindTypes, base, snippet, false) + if err != nil { + t.Fatal(err) + } + if len(res.Plan.Added) != 1 || res.Plan.Added[0] != "NewItem" { + t.Errorf("Added = %v, want [NewItem]", res.Plan.Added) + } + if len(res.Plan.Replaced) != 1 || res.Plan.Replaced[0] != "AKM" { + t.Errorf("Replaced = %v, want [AKM]", res.Plan.Replaced) + } + out := string(res.Bytes) + if !strings.Contains(out, `name="NewItem"`) { + t.Error("merged output missing NewItem") + } + if !strings.Contains(out, `8`) { + t.Error("merged output missing updated AKM nominal value") + } + if !strings.Contains(out, `name="M4A1"`) { + t.Error("merged output dropped untouched M4A1 entry") + } +} + +func TestMergeTypes_Unchanged(t *testing.T) { + xml := []byte(` + 5 +`) + res, err := Merge(KindTypes, xml, xml, true) + if err != nil { + t.Fatal(err) + } + if len(res.Plan.Unchanged) != 1 || res.Plan.Unchanged[0] != "AKM" { + t.Errorf("Unchanged = %v, want [AKM]", res.Plan.Unchanged) + } + if len(res.Plan.Replaced) != 0 { + t.Errorf("Replaced = %v, want []", res.Plan.Replaced) + } +} + +func TestMergeEvents_AppendsChildren(t *testing.T) { + base := []byte(` + + 3 + + + + +`) + snippet := []byte(` + + 5 + + + + + +`) + res, err := Merge(KindEvents, base, snippet, false) + if err != nil { + t.Fatal(err) + } + out := string(res.Bytes) + // nominal overridden + if !strings.Contains(out, `5`) { + t.Error("event scalar child not overridden") + } + // new child appended, existing child not duplicated + if strings.Count(out, `type="UAZ_Base"`) != 1 { + t.Errorf("existing child duplicated; output:\n%s", out) + } + if !strings.Contains(out, `type="UAZ_Mod1"`) { + t.Error("new child not appended") + } + if got := res.Plan.AppendedChildren["VehicleSpawn"]; len(got) != 1 || got[0] != "UAZ_Mod1" { + t.Errorf("AppendedChildren[VehicleSpawn] = %v, want [UAZ_Mod1]", got) + } +} + +func TestMergeMessages_AppendOnly(t *testing.T) { + base := []byte(` + 1Welcome +`) + snippet := []byte(` + 2Restart in 10m +`) + res, err := Merge(KindMessages, base, snippet, false) + if err != nil { + t.Fatal(err) + } + if len(res.Plan.Added) != 1 { + t.Errorf("Added count = %d, want 1", len(res.Plan.Added)) + } + if !strings.Contains(string(res.Bytes), "Restart in 10m") { + t.Error("appended message not present") + } +} + +func TestStripBOM(t *testing.T) { + bom := []byte{0xEF, 0xBB, 0xBF} + raw := append(bom, []byte(``)...) + k, err := DetectKind(raw) + if err != nil { + t.Fatal(err) + } + if k != KindTypes { + t.Errorf("kind = %v, want %v", k, KindTypes) + } +} + +func TestRootMismatch(t *testing.T) { + base := []byte(``) + snippet := []byte(``) + if _, err := Merge(KindTypes, base, snippet, true); err == nil { + t.Error("expected error when snippet root doesn't match") + } +} + +func TestPreserveCRLF(t *testing.T) { + base := []byte("\r\n 5\r\n") + snippet := []byte(`1`) + res, err := Merge(KindTypes, base, snippet, false) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(res.Bytes), "\r\n") { + t.Error("output dropped CRLF line endings; base had CRLF") + } +} diff --git a/pkg/empyrionitems/BlocksConfig.ecf b/pkg/empyrionitems/BlocksConfig.ecf new file mode 100644 index 00000000..07d96b36 --- /dev/null +++ b/pkg/empyrionitems/BlocksConfig.ecf @@ -0,0 +1,38344 @@ +# Do not remove legacy items. Set to: 'ShowUser: NoButCSV' +# If legacy items should be removed but cause savegame break, please add a note as reference +# +# Terrain blocks are stored for id=0 to id=255 Do NOT use other blocks there! +# Blocks with IDs go up to #2048 +# You can add up to 2048 more Blocks after the ID-blocks. Instead of 'Block Id: 1234, Name: Thisblock' you use 'Block Name: Thisblock' +# +# We don't use scale parameter anymore: Mass for material building blocks (not 3d models) is given for a 1m block. Thus, the mass is different depending on whether object is used for SS (0.5m block) or MS / Base (2m block) +# - if used for Small Grid (HV,SV): Mass * 0.125 (0.5^3) +# - if used for Large Grid (BA,CV): Mass * 8 (2^3) +# +# AllowPlacingAt: "Base,MS,SS,GV,Terrain", display: true +# Collide: "movement,bullet,rocket,melee" +# EnergyIn: 1, type: int, display: true, formatter: Watt Value is given in kW +# EnergyOut: 1, type: int, display: true, formatter: Watt Value is given in kW +# +# Texture: "102,101,102,102,102,102" /> top, bottom, north, south, west, east +# or: top, bottom, north, west, south, east? see wall block +# +# Explosions: +# 1 = Large Explosion Air +# 2 - 4 = Small Explosion Ground +# 5 - 6 = Small Explosion Air +# 7 = Electric Explosion +# 8 - 9 = Large Explosion Air +# 10 = Electric Explosion +# == REF === +# You can REF an entry that is added BEFORE the entry you are adding the REF to. +# You cannot REF an entry that is placed on a higher ID than the entry that uses the REF! +# ==Properties== +# CustomIcon: = Use with an existing Icon to show it ingame, f.ex. 'CustomIcon: DetectorSVT1' +# PickupTarget = defines block, device OR item that will always (!) be placed in player inventory (count: 1) after picking up (deconstruct or retrieve) the block/device - both (!) for public or private structures! Does NOT work for 'child' groups, but only for parent; eg do not use on HullFullLargeBlocks but only on its parent HullLargeBlocks +# TemplateRoot: = Defines the crafting template; Usecase: Set for child-groups to allow using a particular crafting template when using UPGRADE functionality! Not required on parent (parent = always uses template name in templates.ecf!); IMPORTANT: Never REF to an entry that is the TemplateRoot in the actual entry, eg: REF IndoorPlants and TemplateRoot: Indoorplants cannot be in the SAME entry! This will lead to a stack overflow in the BP Libary otherwise +# UpgradeTo: , display: true = Sets the device/blockgroup to upgrade to (set visibility to true or false). Important: when upgrading a child-group, this child group needs to have a TemplateRoot set, otherwise the upgrade will be 'free'; note: UpgradeTo: can also use the value 'null', eg. set when this block ref's another block that upgrades to this block (avoid loop). +# DowngradeTo: = same as UpgradeTo but in the other direction. +# +# +# Collide: "movement,bullet,rocket,melee,sight" Modifies the collider set for the model to specifically collide only with some entities, eg. removing "movement" will remove collision for movement but still be affected by weapons or tools. Affects both AI and Player! +# Place: NoRotation = device cannot be rotated +# Place: Face = device always snaps to the surface you target an is then orientated correctly. Downside: only rotation around X axis (no Y or Z rotation) +# Place: Free = device is previewed like it is set up in unity. All axis rotation possible. Downside: loses convenience of auto-snapping + auto-orientation (eg. for lights etc) requiring mandadory rotation steps in most cases. (eg. for lamps placed on a ceiling a rotation is required to have the base snapp to the wall/ceiling) +# RemoveOnSI: when structure collapses this block will be immediately removed if set to true +# IsPhysicsColliders: false # ramps are too thin and also might pose problems when SV/HV are on the ramp while physics colliders are on +# OccupySizeInBlocks: true > for block that should not be voxelized because of an animation, specific shader, etc pp.. so the full SizeInBlocks is occupied +# BlockSizeScale: 2 (=SV/HV) or 8 (=CV/BA) with this Mass and HitPoints will be scaled according to its voxels +# Voxelize: determines whether a block is voxelized in small cubes to not occupy a full voxel space (Default Voxelize: true) +# +# IsActivateable: block can be switched on/off +# IsActivateableInCP: block can be accessed via CP (eg turret, fuel tank, cargo box etc) +# IsActivatedOnPlace: determines whether device is switched on when placed +# IsDuplicateable: defines whether block can be used for plane / line drawing tool +# ForceMaxCount: will force the MaxCount even if difficulty level deactivated it (needed for Core, Warp Drive etc but not for weapons) +# BlockSizeScale: 2 (=SV/HV) or 8 (=CV/BA) with this Mass and HitPoints will be scaled according to its voxels +# Voxelize: determines whether a block is voxelized in small cubes to not occupy a full voxel space (Default Voxelize: true) +# IsAntiInfantryWeapon: true = Device properties (DMG, HP) are only computed for Infantry and Entity Attack-Defense values, but not for Artillery AD +# IsKeepContainers: true = ONLY USE FOR CORES. If set to true, pre-placed items will be saved with the blueprint and be available as loot in their specific location. Do ONLY use with NPC cores. +# VolumeCapacity: 125, type: float, display: true, formatter: Liter ; Gives the SU available for this container. +# OccupySizeInBlocks: true > for block that should not be voxelized because of an animation, specific shader, etc pp.. so the full SizeInBlocks is occupied +# ShieldMultiplier: AnyStringPossible > only for Class:ShieldGenerator devices; Allows to set a parameter that can be used in the DamageMultiplier of a weapon in the ItemsConfig.ecf +# RepairToTemplate: false > allow excluding (rare or special) blocks from reparing to template to avoid exploits. +# DropOnDeath: > If set to 'False' this item or block is not dropped on player death. Use for important story items that players shall not lose on death. Default is set to 'True'. +# RepFac: > f.e. use RepFac: 0.1 to allow to scale the reputation loss/gain when destroying a block (f.e. to make some blocks more/less "valuable" for reputation changes) +# DropMeshfile: Entities/Misc/DropContainerNewPrefab > Defines the container visible when dropped; Small variants: BagSmallNewPrefab, BagSmallChristmasPrefab; Large variants: DropContainerNewPrefab; Old: BagSmallPrefab, DropContainerPrefab +# DropInventoryEntity: DropContainer > defines which box variant is dropped when this item/device is dropping from a destroyed cargo container (!) +# ShieldHitCooldown: 11, type: int, display: true = ShieldHitCooldown (int): cooldown in seconds after being hit until the shield starts recharging (default: 5s) +# +# == Special: Dialogues == +# ExecuteOnActivate: YourDialogState # Adding a dialogue state from the dialogues.ecf will allow the block/device to activate that dialogue by facing the block and clicking F; Note: Does not require 'IsActivatable' or other properties; Note: If device already has 'IsActivatable' or other activatable-state/Access (F-button, like containers, levers etc) their Access-function will be executed primarily BUT the dialogue will be run (invisible) never the less! +# ExecuteOnCollide: YourDialogState # Adding a dialogue state from the dialogues.ecf will allow the block/device to activate that dialogue by touching/pressing against that block. (similar to ExecuteOnActivate!) +# DialogueSingleUserAccess: true # Only one player at a time can access the dialogue triggered by the device/blocks +# DialogueState: YourDialogState # Allows to specifying a hard coded target dialog. If this is present, this block can be placed by users on PLAYER owned bases and be also triggered using "F". #ATTENTION! Setting a dialogue state here will overwrite manually set Dialogues for this devices in the blueprints! +# OmitCone: true # default: false; True = hides the yellow dialogue cone (f.ex if the NPC only has a barking dialogue) +# == Special: Skills == +# Use "Player.Skills['skillname']" to set a skill entry in the dialogues.ecf. +# The skill values can be used to modify properties of blocks and items: Damage, BlastDamage, BulletSpread, ReloadDelay, Recoil, RangeAU und RangeLY +# Add to block/item config like this: +# Mod.ReloadDelay: "ReloadDelay + Player.Skill['skillname']" +# == Container Classes === +# Class: Container = used for cargo, container, fridges etc pp; Requires VolumeCapacity to be set (see information how this is used!!) +# Class: ContainerPersonal ; Requires VolumeCapacity to be set (see information how this is used!!) +# Class: ContainerController ; Requires VolumeCapacity to be set (see information how this is used!!) +# Class: ContainerExtension ; Requires VolumeCapacity to be set (see information how this is used!!) +# Class: ArmorLocker ; Requires VolumeCapacity to be set (see information how this is used!!) +# Class: Constructor = Includes all Constructors + Food Processor +# Class: WarpDriveTank ; Requires VolumeCapacity to be set (see information how this is used!!) +# Class: RepairStation ; Requires VolumeCapacity to be set (see information how this is used!!) +# +# +# Symmetry Types +# SymType 1: Shape is symmetrical like a QuarterRound block (points towards z-axis) - DEFAULT +# SymType 2: Shape is symmetrical like a Corner block +# SymType 3: Shape is symmetrical like a WallSloped block +# SymType 4: Shape is symmetrical like a Wedge block but its default rotation is wrong (orthogonal to z-axis), see Wing6x9a, ModularWingAngledTaperedL or generatorMSPrefab +# MirrorTo: Define block which is used when mirrored +# +# +# Shader Modes: +# Device = Device Blocks, old coloring setup (eg constructor etc) +# NoSI = Terrain Placeables/anything that should not have SI, same setup as Device but without SI +# NPC = Colored with material-set coloring, for NPCs (uses old replacement coloring) +# Cutout = same as Device, but with cutout transparency +# DeviceEmissiveOn = Same as Device, but for anything with constant emissive (eg solar panels) +# DeviceNewColoring = New Coloring method for Device Blocks +# NoSINewColoring = New Coloring method for Terrain Placeables +# +# +# Building Blocks: +# +# IMPORTANT: for use in ghe BlockShapesWindow.ecf all BUILDINGblocks need a FIXED ID below the 2048 limit! +# +# BA / CV: +# 396 WoodBlocks Wood Blocks +# 399 ConcreteBlocks Concrete Blocks +# 1322 ConcreteArmoredBlocks Armored Concrete Blocks +# 1481 PlasticLargeBlocks Carbon Composite Blocks L +# 402 HullLargeBlocks Steel Blocks L +# 405 HullArmoredLargeBlocks Hardened Steel Blocks L +# 411 HullCombatLargeBlocks Combat Steel Blocks L +# 1395 AlienLargeBlocks Xeno Steel Blocks L +# 408 AlienBlocks Radioactive; for POI building only; Same Texture range than 1395 +# 865 FleshLargeBlocks BLOCKED FOR DF > Flesh blocks > Symelite Blocks (for DF) +# 866 SymelitArmorLargeBlocks BLOCKED FOR DF > Symelite Armor Blocks (for Player) +# +# SV / HV: +# 1478 PlasticSmallBlocks Carbon Composite Blocks S +# 380 HullSmallBlocks Steel Blocks S +# 393 HullArmoredSmallBlocks Hardened Steel Blocks S +# 1594 HullCombatSmallBlocks Combat Steel Blocks S +# +# +# ================== EXPORTED PROPERTIES ========================== + +## Properties: +## IsAccessible: shows up in Control Panel +## O2Accept: "OxygenBottleLarge,OxygenBottleSmall" > items that can be used to fill a O2 tank. First item in list will be filled when O2 is withdrawn +## FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" > items that can be used to fill a fuel tank. First item in list will be filled when fuel is withdrawn +# +# +# ================== Important Notes / Change info ========================== +# CutCornerB had to be updated with a dedicated left/right mirror, but needs to be kept in its group for backwards compatibilty. New left/right variant are in Extended7 +# =========================================================================== + +{ Block Id: 0, Name: Air + Material: air + Shape: Invisible + Texture: 250 + IsColorable: false +} +{ Block Id: 1, Name: Stone + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 175 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 2, Name: Grass + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 102 + ShowUser: No + Group: grass +} +# double. delete when save game break +{ Block Id: 3, Name: StoneGrass + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "102,175,175,175,175,175" + ShowUser: No +} +{ Block Id: 4, Name: Dirt + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: 101 + ShowUser: No + Group: dirt +} +{ Block Id: 5, Name: Clay + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: 103 + ShowUser: No + Group: dirt +} +{ Block Id: 6, Name: SandYellow03 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 181 + ShowUser: No + Group: sand +} +{ Block Id: 7, Name: Bedrock + Material: bedrock + Shape: Terrain + Mesh: terrain + Texture: 108 + ShowUser: No + XpFactor: 0 + Group: bedrock +} +{ Block Id: 8, Name: AlienGreen + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 109 + ShowUser: No + Group: stone +} +{ Block Id: 9, Name: AlienGreen02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "110,119,119,119,119,119" + ShowUser: No + Group: stone +} +# Used also for Zascosium +{ Block Id: 10, Name: AlienGreen03 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "115,111,111,111,111,111" + ShowUser: No + Group: stone +} +{ Block Id: 11, Name: AlienGreen04 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 117 + ShowUser: No + Group: stone +} +{ Block Id: 12, Name: AlienGreen05 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 118 + ShowUser: No + Group: stone +} + +# delete when save game break. used for Erestrum resource +{ Block Id: 13, Name: AlienGreen06 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 116 + ShowUser: No + # Group: stone +} +{ Block Id: 14, Name: AlienGreen07 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "119,195,195,195,195,195" + ShowUser: No + Group: stone +} +{ Block Id: 15, Name: AlienBlack01 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 120 + ShowUser: No + Group: stone +} +{ Block Id: 16, Name: RockGrey + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 112 + ShowUser: No + Group: snow + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 17, Name: RockGrey02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 127 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 18, Name: RockYellow + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 123 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 19, Name: RockBlack + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "111,202,202,202,202,202" + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 20, Name: RockBlack02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 172 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 21, Name: Snow03 + Material: snow + Shape: Terrain + Mesh: terrain + Texture: 167 + ShowUser: No + Group: snow +} +{ Block Id: 22, Name: Snow08 + Material: snow + Shape: Terrain + Mesh: terrain + Texture: 186 + ShowUser: No + Group: snow +} +# double. delete when save game break +{ Block Id: 23, Name: Snow08Cliff + Material: snow + Shape: Terrain + Mesh: terrain + Texture: "186,188,188,188,188,188" + ShowUser: No +} +{ Block Id: 24, Name: Snow04 + Material: snow + Shape: Terrain + Mesh: terrain + Texture: 168 + ShowUser: No + Group: snow +} +# double. delete when save game break +{ Block Id: 25, Name: Snow08RockBrown03 + Material: snow + Shape: Terrain + Mesh: terrain + Texture: "186,192,192,192,192,192" + ShowUser: No +} +{ Block Id: 26, Name: RockLava + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 113 + ShowUser: No + Group: lava + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } + Temperature: 150 +} +{ Block Id: 27, Name: RockLava02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 114 + ShowUser: No + Group: lava + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } + Temperature: 150 +} +{ Block Id: 28, Name: Grass02 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 143 + ShowUser: No + Group: grass +} +{ Block Id: 29, Name: Grass03 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 163 + ShowUser: No + Group: grass +} +{ Block Id: 30, Name: Grass05 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 146 + ShowUser: No + Group: grass +} +{ Block Id: 31, Name: Grass06 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 159 + ShowUser: No + Group: grass +} +{ Block Id: 32, Name: Grass08 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 183 + ShowUser: No + Group: grass +} +{ Block Id: 33, Name: SandBeach + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 156 + ShowUser: No + Group: sand +} +{ Block Id: 34, Name: SandBeach01 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 157 + ShowUser: No + Group: sand +} +{ Block Id: 35, Name: SandBrown + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 153 + ShowUser: No + Group: sand +} +{ Block Id: 36, Name: SandBrown02 + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: "154,192,192,192,192,192" + ShowUser: No + Group: dirt +} +{ Block Id: 37, Name: SandBrown03 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 155 + ShowUser: No + Group: sand +} +# Double. Delete when save game break +{ Block Id: 38, Name: Grass03Cliff + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "163,188,188,188,188,188" + ShowUser: No +} +{ Block Id: 39, Name: SandOrange + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 125 + ShowUser: No + Group: sand +} +{ Block Id: 40, Name: SandOrange02 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 152 + ShowUser: No + Group: sand +} +{ Block Id: 41, Name: Grass04 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 166 + ShowUser: No + Group: grass +} +{ Block Id: 42, Name: SandYellow02 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 160 + ShowUser: No + Group: sand +} +{ Block Id: 43, Name: StoneBeach + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 158 + ShowUser: No + Group: sand +} +{ Block Id: 44, Name: SandMars + Material: sand + Shape: Terrain + Mesh: terrain + Texture: "165,152,152,152,152,152" + ShowUser: No + Group: sand +} +{ Block Id: 45, Name: GrassBrown + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "144,192,192,192,192,192" + ShowUser: No + Group: grass +} + +# double. delete when save game break +{ Block Id: 46, Name: SnowRock + Material: snow + Shape: Terrain + Mesh: terrain + Texture: 167 + ShowUser: No +} +# double. delete when save game break +{ Block Id: 47, Name: SnowRock04 + Material: snow + Shape: Terrain + Mesh: terrain + Texture: "168,186,186,186,186,186" + ShowUser: No +} + +{ Block Id: 48, Name: RockLava01 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 121 + ShowUser: No + Group: lava + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } + Temperature: 150 +} + +{ Block Id: 49, Name: GrassDirt + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 209 + ShowUser: No + Group: grass +} + +# double. delete when save game break +{ Block Id: 50, Name: Desert02 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: "125,105,105,105,105,105" + ShowUser: No +} +{ Block Id: 51, Name: Granite + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 105 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 52, Name: Grass01 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 142 + ShowUser: No + Group: grass +} + +# Asteroid Resource: Sathium +{ +Block Id: 53, Name: SathiumResource + Material: resourcehard + Shape: Terrain + Mesh: terrain + Texture: 126 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: SathiumOre + Count: "2,4" + Prob: 0.2 + } +} +# Used for iron resource +{ Block Id: 54, Name: RockGrey03 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 195 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# Used for silicon resource +{ Block Id: 55, Name: RockGrey04 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 170 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# Used for copper resource +{ Block Id: 56, Name: RockGrey05 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 171 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} + +# Asteroids +{ Block Id: 57, Name: RockAsteroids + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "194,180,180,180,180,180" + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "2,4" + Prob: 0.2 # higher probability + } +} + +{ Block Id: 58, Name: RockBlack04 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 176 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 59, Name: RockBrown01 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 178 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 60, Name: RockBrown02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 178 # before: 179 + ShowUser: No +} +{ Block Id: 61, Name: RockGreen02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 180 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 62, Name: SandYellow03Clay + Material: sand + Shape: Terrain + Mesh: terrain + Texture: "181,103,103,103,103,103" + ShowUser: No +} +{ Block Id: 63, Name: SandYellow04 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 182 + ShowUser: No + Group: sand +} +# double. delete when save game break +{ Block Id: 64, Name: RockGreen02Dark + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 180 + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 65, Name: Grass09 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 187 + ShowUser: No + Group: grass +} +{ Block Id: 66, Name: GoodDirt + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: 190 + ShowUser: No + Group: dirt +} +# double. delete when save game break +{ Block Id: 67, Name: DirtCliff + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "190,188,188,188,188,188" + ShowUser: No +} +# double. delete when save game break +{ Block Id: 68, Name: GrassCliff + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "142,189,189,189,189,189" + ShowUser: No +} +# double. delete when save game break +{ Block Id: 69, Name: GrassRock + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "187,122,122,122,122,122" + ShowUser: No +} +# double. delete when save game break +{ Block Id: 70, Name: GrassBlack + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "187,175,175,175,175,175" + ShowUser: No +} +{ Block Id: 71, Name: Dirt02 + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: 191 + ShowUser: No + Group: dirt +} +{ Block Id: 72, Name: Gravel + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: 192 + ShowUser: No + Group: dirt +} +{ Block Id: 73, Name: SandPebbles + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 193 + ShowUser: No + Group: sand +} +# double. delete when save game break +{ Block Id: 74, Name: GravelSandBrown + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: "192,154,154,154,154,154" + ShowUser: No +} +{ Block Id: 75, Name: RockRed + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 196 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 76, Name: RockRed02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 197 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 77, Name: SandOrangeBrown + Material: sand + Shape: Terrain + Mesh: terrain + Texture: "152,154,154,154,154,154" + ShowUser: No +} +# double. delete when save game break +{ Block Id: 78, Name: RockGrey06 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 194 + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ +Block Id: 79, Name: CopperResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 177 # rock_grey05 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: CopperOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 80, Name: PromethiumResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 136 # alien_rock_blue01 + AutoMinerFactor: 0.3 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: PromethiumOre + Count: "2,4" + Prob: 0.2 + } + Radiation: 12 +} +{ +Block Id: 81, Name: IronResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 173 # rock_grey03 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: IronOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 82, Name: SiliconResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 174 # rock_grey04 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: SiliconOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 83, Name: NeodymiumResource + Material: resourcemedium + Shape: Terrain + Mesh: terrain + Texture: 179 # rock_brown02 + # AutoMinerFactor: 0.3 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: NeodymiumOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 84, Name: MagnesiumResource + Material: resourcemedium + Shape: Terrain + Mesh: terrain + Texture: 169 # rock_grey + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: MagnesiumOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 85, Name: CobaltResource + Material: resourcemedium + Shape: Terrain + Mesh: terrain + Texture: 129 # rock_grey02 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: CobaltOre + Count: "2,4" + Prob: 0.2 + } +} +{ Block Id: 86, Name: RockGrey08 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 202 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 87, Name: RockLavaBlack + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "113,111,111,111,111,111" + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 88, Name: RockGrey08Lava + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "202,139,139,139,139,139" + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 89, Name: RockLava04 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 134 + ShowUser: No + Group: lava + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } + Temperature: 150 +} + +{ +Block Id: 90, Name: ErestrumResource + Material: resourcehard + Shape: Terrain + Mesh: terrain + Texture: 116 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: ErestrumOre + Count: "2,4" + Prob: 0.2 + } + Radiation: 5 +} + +{ +Block Id: 91, Name: ZascosiumResource + Material: resourcehard + Shape: Terrain + Mesh: terrain + Texture: 128 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: ZascosiumOre + Count: "2,4" + Prob: 0.2 + } +} +# double. delete when save game break +{ Block Id: 92, Name: GravelRockBrown03 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 192 + # Texture: "192,203,203,203,203,203" + ShowUser: No +} +# Filler +{ Block Id: 93, Name: FillerMaterial + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 164 # FillerMaterial + ShowUser: No +} +# Texture name: RockLava04 +{ Block Id: 94, Name: BedrockLava + Material: bedrock + Shape: Terrain + Mesh: terrain + Texture: 134 # RockLava04 + ShowUser: No + XpFactor: 0 + Group: bedrock +} +{ +Block Id: 95, Name: GoldResource + Material: resourcemedium + Shape: Terrain + Mesh: terrain + Texture: 140 + AutoMinerFactor: 0 # Gold has to be a limited asset (inflation!) => no endless production by the virtual AutoMiners + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: GoldOre + Count: "1,3" + Prob: 0.2 + } +} +{ Block Id: 96, Name: SnowyGrass + Material: snow + Shape: Terrain + Mesh: terrain + Texture: 205 + ShowUser: No + Group: snow +} +# double. delete when save game break +{ Block Id: 97, Name: RockGrey08Lava02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "202,114,114,114,114,114" + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 98, Name: Grass02Cliff + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "143,188,188,188,188,188" + ShowUser: No +} +{ Block Id: 99, Name: SeaGround + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 206 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 100, Name: SeaGround02 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 207 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 101, Name: SeaGround03 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 208 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 102, Name: RockLava02LowRelief + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 114 + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 103, Name: SandBeach01Cliff + Material: sand + Shape: Terrain + Mesh: terrain + Texture: "157,188,188,188,188,188" + ShowUser: No +} + +# double. delete when save game break +{ Block Id: 104, Name: Grass01BrownCliff + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "141,189,189,189,189,189" + ShowUser: No +} +{ Block Id: 105, Name: Cliff + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 188 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 106, Name: CliffBrown + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 189 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 107, Name: RockBrown03 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 196 + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 108, Name: RockLava05 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 139 + ShowUser: No + Group: lava + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } + Temperature: 150 +} +# double. delete when save game break +{ Block Id: 109, Name: CliffDark + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 188 + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 110, Name: Dirt02Cliff + Material: dirt + Shape: Terrain + Mesh: terrain + Texture: "191,188,188,188,188,188" + ShowUser: No +} +# double. delete when save game break +{ Block Id: 111, Name: CliffGrassyGrey + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "189,188,188,188,188,188" + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 112, Name: RockGrassCliff + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "124,188,188,188,188,188" + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 113, Name: Grass05Cliff + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "146,188,188,188,188,188" + ShowUser: No +} + +# Warp Crystal Resource: Crystal Blue +{ +Block Id: 114, Name: PentaxidResource + Material: resourcehard + Radiation: 0.2 + Shape: Terrain + Mesh: terrain + Texture: 104 + ShowUser: NoButCSV # reported by HWS + Group: resource + { Child DropOnDestroy + Item: PentaxidOre + Count: "1,2" + Prob: 0.2 + } +} +# double. delete when save game break +{ Block Id: 115, Name: RockLava03RockGrey08 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "121,202,202,202,202,202" + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 116, Name: Lava02RockGrey08 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "114,202,202,202,202,202" + ShowUser: No + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +# double. delete when save game break +{ Block Id: 117, Name: AlienGreen04RockGrey08 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: "117,202,202,202,202,202" + ShowUser: No +} +# double. delete when save game break +{ Block Id: 118, Name: GrassDirtRockGrey09 + Material: grass + Shape: Terrain + Mesh: terrain + Texture: "209,210,210,210,210,210" + ShowUser: No +} +{ Block Id: 119, Name: Cliff2 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 211 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 120, Name: Cliff3 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 212 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 121, Name: RockGrey07 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 201 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 122, Name: RockGrey01 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 122 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 123, Name: RockGrey09 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 210 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 124, Name: SandYellow01 + Material: sand + Shape: Terrain + Mesh: terrain + Texture: 185 + ShowUser: No + Group: sand +} +{ Block Id: 125, Name: Grass01Brown + Material: grass + Shape: Terrain + Mesh: terrain + Texture: 141 + ShowUser: No + Group: grass +} +{ Block Id: 126, Name: Cliff4 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 213 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} +{ Block Id: 127, Name: Cliff5 + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 214 + ShowUser: No + Group: stone + { Child DropOnDestroy + Item: CrushedStone + Count: "1,3" + Prob: 0.08 + } +} + +{ Block Id: 128, Name: TemplateTerrain + Material: stone + Shape: Terrain + Mesh: terrain + Texture: 146 + ShowUser: No +} + +{ Block Id: 129, Name: GenericResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 184 + ShowUser: No + Group: resource +} + +{ Block Id: 130, Name: Snow05 + Material: snow + Shape: Terrain + Mesh: terrain + Texture: 162 + ShowUser: No + Group: snow +} + +{ +Block Id: 131, Name: IceResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 215 # Copy of RockGrey + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: IceBlocks + Count: "2,4" + Prob: 0.2 + } +} + +{ +Block Id: 132, Name: TitanResource + Material: resourcemedium + Shape: Terrain + Mesh: terrain + Texture: 216 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: TitanOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 133, Name: AluminiumResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 217 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: AluminiumOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 134, Name: PlatinResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 218 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: PlatinOre + Count: "2,4" + Prob: 0.2 + } +} +{ +Block Id: 135, Name: CarbonResource + Material: resourcesoft + Shape: Terrain + Mesh: terrain + Texture: 219 + ShowUser: No + Group: resource + { Child DropOnDestroy + Item: PlasticMaterial + Count: "6,8" + Prob: 0.2 + } +} +# BLOCKED > 136 used by DF Scenario (Symelite Resource) + +# 136 - 255 available free BUT NEED TO BE KEPT FREE for TEXTURES only!!! (adding any other block or device will break textures) + +# Block Entity start at id=256 +{ +Block Id: 256, Name: CapacitorMS + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/CapacitorPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsAccessible: false, type: bool + AllowPlacingAt: "Base,MS,SS,GV", display: true + Info: bkiGenNoFunction, display: true + SizeInBlocks: "2,1,1", display: true + SymType: 4 + # ModelOffset: "0,0.5,0" + StackSize: 25 + BlockColor: "110,110,110" + TemplateRoot: DecoBlocks2 + Mass: 500, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Deco Blocks + + BlastParticleIndex: 1 + BlastRadius: 3 + BlastDamage: 100 +} +{ +Block Id: 257, Name: CockpitMS01 + Class: CockpitMS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + ShowBlockName: true + Shape: ModelEntity + Model: @models/Blocks/Cockpits/CockpitCVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metal + Place: Cockpit + TemplateRoot: CockpitBlocksCV + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 100, type: float, display: true, formatter: Kilogram + HitPoints: 150, type: int, display: true + Volume: 100, type: float, display: true, formatter: Liter + IsLockable: true + IsIgnoreLC: true + Info: bkiCockpit, display: true + YawRotation: 150 # how far in degrees head of player can be rotated (default 75°) + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + EnergyIn: 20, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 80 + + # UnlockCost: 7, display: true + # UnlockLevel: 5, display: true + # TechTreeNames: Capital Vessel +} + +{ +Block Id: 258, Name: ConsoleTrading + # Class: TradingStation + Class: NPCDialogue + NPCModelRotation: false + Group: cpgCrewStationAlien + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Mothership/TradingConsolePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiConsoleTrading, display: true + Category: Deco Blocks + # AllowedInBlueprint: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: false, display: true + TemplateRoot: ConsoleBlocks + Mass: 65, type: float, display: true, formatter: Kilogram + Volume: 12, type: float, display: true, formatter: Liter + # ModelOffset: "0,0.5,0" + EnergyIn: 1, type: int, display: true, formatter: Watt + # BlockColor: "0,64,255" + BlockColor: "170,5,5" + + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 +} + + +{ +Block Id: 259, Name: FuelTankMSSmall + Class: FuelTank + IndexName: FuelTank + Group: cpgFuelTank + EssentialCategory: cpgFuelTank + MarketPrice: 850, display: true + # LootList: 22 # 10 slots + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/FuelTankMSSmallPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + # IsActivateable: false --- fuel tanks activate themselves if they contain fuel + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + # IsClosedOnMoving: true + StackSize: 500 + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 1386, type: float, display: true, formatter: Kilogram # should be dynamic + Volume: 99, type: float, display: true, formatter: Liter + Info: bkiFuelTank, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 300, type: int, display: true + HitPoints: 50, type: int, display: true + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + FuelCapacity: 1200, type: int, display: true + BlastParticleIndex: 10 + BlastRadius: 5 + BlastDamage: 500 + + Radiation: 1.25, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 260, Name: FuelTankMSLarge, Ref: FuelTankMSSmall + # LootList: 23 # 20 slots + Model: @models/Blocks/Mothership/FuelTankMSLargePrefab + MarketPrice: 3845, display: true + HitPoints: 80, type: int, display: true + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + FuelCapacity: 2700, type: int, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 2513, type: float, display: true, formatter: Kilogram + Volume: 180, type: float, display: true, formatter: Liter + Info: bkiFuelTank, display: true + CPUIn: 675, type: int, display: true + + Radiation: 1.4, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: FuelTankMSSmall + TechTreeNames: "Base,Capital Vessel" +} + +{ +Block Id: 261, Name: ConsoleMS01 + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/ConsoleMS01Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 800, type: float, display: true, formatter: Liter + BlockColor: "110,110,110" + Category: Deco Blocks + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + + EnergyIn: 1, type: int, display: true, formatter: Watt + TemplateRoot: ConsoleBlocks +} +{ +Block Id: 262, Name: Antenna + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/AntennaPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsAccessible: false, type: bool + Info: bkiGenNoFunction, display: true + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,3,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 2425, type: float, display: true, formatter: Liter + StackSize: 500 + TemplateRoot: AntennaBlocks + Category: Deco Blocks + Mass: 6290, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + EnergyIn: 3, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 +} +{ +Block Id: 263, Name: OxygenTankMS + Class: O2Tank + IndexName: O2Tank + Group: cpgO2Tank + EssentialCategory: cpgO2Tank + MarketPrice: 1078, display: true + # LootList: 24 # 10 slots + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/OxygenTankLargePrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiOxygenTank, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 124, type: float, display: true, formatter: Liter + StackSize: 500 + BlockColor: "110,110,110" + Category: Devices + Mass: 810, type: float, display: true, formatter: Kilogram + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 200, type: int, display: true + HitPoints: 80, type: int, display: true + O2Capacity: 2000, type: int, display: true + O2Accept: "OxygenBottleLarge,OxygenBottleSmall,RespiratorCharge" + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UnlockCost: 7, display: true + UnlockLevel: 10, display: true + TechTreeParent: OxygenTankSmallMS + TechTreeNames: "Base,Capital Vessel" +} + +# old constructor model +{ Block Id: 264, Name: ConstructorMS + AllowedInBlueprint: false, display: true # to avoid exploits + Class: ConstructorBig + IndexName: Constructor + Group: cpgConstructor + ShowUser: No + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # DropScale: 0.5 + TemplateRoot: ConstructorT1V2 + Model: @models/Blocks/Mothership/ConstructorMSPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiConstructorLarge, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,2", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 200, type: float, display: true, formatter: Liter + StackSize: 10 + Category: Devices + Mass: 40000, type: float, display: true, formatter: Kilogram + EnergyIn: 300, type: int, display: true, formatter: Watt + CPUIn: 20000, type: int, display: true + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + UpgradeTo: ConstructorT2, display: true +} + +# Old Food Processor - no advanced capabilities +{ Block Id: 265, Name: FoodProcessorMS + Class: FoodProcessor + IndexName: Constructor + Group: cpgFoodProcessor + DontDecayFood: true # now this loot container is a fridge + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/FoodProcessorV2Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiFoodProcessor, display: true + TemplateRoot: FoodProcessorV2 + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 500, type: float, display: true, formatter: Liter + StackSize: 25 + BlockColor: "110,110,110" + Category: Devices + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 150, type: int, display: true, formatter: Watt + CPUIn: 200, type: int, display: true + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 + + ShowUser: No +} +{ +Block Id: 266, Name: PassengerSeatMS + Class: PassengerSeat + Group: cpgPassengerSeat + Material: metal + Shape: ModelEntity + MarketPrice: 230, display: true + ShowBlockName: true + Model: @models/Blocks/Mothership/passengerSeatMSPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 72, type: float, display: true, formatter: Kilogram + Volume: 32, type: float, display: true, formatter: Liter + IsActivateable: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiPassengerSeat, display: true + YawRotation: 150 # how far in degrees head of player can be rotated (default 75°) + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 7, display: true + UnlockLevel: 10, display: true + TechTreeParent: CockpitBlocksCV + TechTreeNames: Capital Vessel +} +{ +Block Id: 267, Name: CockpitMS02 + Class: CockpitMS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metal + ShowBlockName: true + Shape: ModelEntity + Model: @models/Blocks/Cockpits/cockpitMS02Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: MS, display: true + SizeInBlocks: "2,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 620, type: float, display: true, formatter: Liter + Place: Cockpit + TemplateRoot: CockpitBlocksCV + IsLockable: true + IsIgnoreLC: true + Info: bkiCockpit, display: true + YawRotation: 150 # how far in degrees head of player can be rotated (default 75°) + StackSize: 1000 + Category: Devices + Mass: 284, type: float, display: true, formatter: Kilogram + HitPoints: 300, type: int, display: true + EnergyIn: 20, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 80 +} + +# Obsolete - do not delete +{ Block Id: 268, Name: PassengerSeatSS_Cube + Class: PassengerSeat + Group: cpgPassengerSeat + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/PassengerSeatSS_CubePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,2,2", display: true + AllowPlacingAt: "SS,GV", display: true + Volume: 80, type: float, display: true, formatter: Liter + IsActivateable: true + IsLockable: true + IsOxygenTight: true, display: true + Info: bkiPassengerSeat, display: true + ShowUser: NoButCSV + StackSize: 25 + Category: Devices + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + Mass: 150, type: float, display: true, formatter: Kilogram + HitPoints: 250, type: int, display: true +} + +# Obsolete - do not delete +{ Block Id: 269, Name: PassengerSeatSS_Slope, Ref: PassengerSeatSS_Cube + Model: @models/Blocks/Spaceship/PassengerSeatSS_SlopePrefab + Info: bkiPassengerSeat, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + ShowUser: NoButCSV +} +{ +Block Id: 270, Name: MedicinelabMS + Class: MedicStation + CustomIcon: MedicalStationBlocks + # BuffName: MedicStation + IndexName: SpawnPoint + Group: cpgMedic + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Mothership/MedicStationSmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: MedicalStationBlocks + Place: Free + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiMedicinelabMS, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 750, type: float, display: true, formatter: Liter + StackSize: 250 + BlockColor: "110,110,110" + BuffNameActivate: MedicStation, display: StatusEffect + DebuffNamesActivate: "PoisonFood,DermalBurn,Frostbite,OpenWound,PoisonBit", display: EffectiveAgainst + Category: Devices + Mass: 13500, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + EnergyIn: 15, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 +} +# Now used as PentaxidTank -> deco block UserHidden +{ Block Id: 271, Name: SciencelabMS + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/sciencelabMSPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + IsAccessible: false, type: bool + Info: bkiGenNoFunction, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 500, type: float, display: true, formatter: Liter + StackSize: 500 + Category: Deco Blocks + Mass: 2000, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 + ShowUser: No + TemplateRoot: ConsoleBlocks +} +{ +Block Id: 272, Name: RCSBlockSV + IndexName: RCS + Group: cpgRCS + Material: metal + Shape: ModelEntity + ShowBlockName: true + MarketPrice: 805, display: true + Model: @models/Blocks/Mothership/RCS_BlockPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: SS, display: true + SizeInBlocks: "1,1,1", display: true + Mass: 256, type: float, display: true, formatter: Kilogram + Volume: 100, type: float, display: true, formatter: Liter + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiGyroscope, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + Torque: 100, type: int, display: true, formatter: NewtonMeter + EnergyDynamicGroup: RCS + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 450, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UnlockCost: 4, display: true + UnlockLevel: 5, display: true + TechTreeParent: GeneratorSV + TechTreeNames: Small Vessel +} +# Container Block - Obsolete but do not delete +{ +Block Id: 273, Name: ContainerMS01 + Class: Container + IndexName: Container + Group: cpgCargoBox + LootList: 66 # 35 slots + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/CargoBoxPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + TemplateRoot: ContainerSmallBlocks + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" # scaled small on SV, GV + Mass: 50, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + VolumeCapacity: 125, type: float, display: true, formatter: Liter # low value since placed on Base,MS,SS,GV + BlockColor: "110,110,110" + Info: bkiContainer, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + # IsAccessible: false, type: bool + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 15, type: int, display: true + HitPoints: 100, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 +} +{ Block Id: 274, Name: ContainerRandom, Ref: ContainerMS01 + LootList: 2 # lootcontainer: used for Junk POIs + BlockColor: "10,10,10" + ShowUser: No +} +# Obsolete but dont delete +{ Block Id: 275, Name: WindowVertMS + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/windowVertMS, param1: window_nontransparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: WindowSmallBlocks + Info: bkiWindowShutterNonTrans, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 30, type: float, display: true, formatter: Kilogram + HitPoints: 250, type: int, display: true + IsOxygenTight: true, display: true + # RemoveOnSI: true + Category: BuildingBlocks + Collide: Transparent + ShowUser: NoButCSV + AllowWander: true # used in old prefabs as walkway blocks +} +# Obsolete but dont delete +{ Block Id: 276, Name: WindowSlopedMS + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/windowSlopedMS, param1: window_slope_nontrasparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: WindowSmallBlocks + Info: bkiWindowShutterNonTrans, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 30, type: float, display: true, formatter: Kilogram + HitPoints: 250, type: int, display: true + # RemoveOnSI: true + IsOxygenTight: true, display: true + Category: BuildingBlocks + Collide: Transparent + ShowUser: NoButCSV + AllowWander: true # used in old prefabs as walkway blocks +} +# Obsolete but dont delete +{ Block Id: 277, Name: WindowSloped2MS + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/windowSloped2MS, param1: window_slope_inv_nontrasparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: WindowSmallBlocks + Info: bkiWindowShutterNonTrans, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 30, type: float, display: true, formatter: Kilogram + HitPoints: 250, type: int, display: true + # RemoveOnSI: true + IsOxygenTight: true, display: true + Category: BuildingBlocks + Collide: Transparent + ShowUser: NoButCSV + AllowWander: true # used in old prefabs as walkway blocks +} +{ +Block Id: 278, Name: GravityGeneratorMS + Class: Generator + Info: bkiGravityGeneratorBlocksPreviewInfo, display: true + CustomIcon: GravityGeneratorBlocksBACV + IndexName: Gravity + Group: cpgGravityGenerator + Category: Devices + TemplateRoot: GravityGeneratorBlocksBACV + + Material: metal + + HitPoints: 400, type: int, display: true + EnergyIn: 800, type: int, display: true, formatter: Watt + CPUIn: 50, type: int, display: true + Mass: 22800, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 75, type: int, display: true + + IsActivateable: true + IsOxygenTight: false, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorBACVLargePrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + Shape: ModelEntity + # ModelOffset: "0,0.5,0" + ShowBlockName: true + BlockColor: "110,110,110" + + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,2,2", display: true + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 100 + + Radiation: 5, display: RadiationLevel + Temperature: 32, display: true +} +{ +Block Id: 279, Name: LightSS01 + Class: Light + Group: cpgLight + IndexName: Light + MarketPrice: 85, display: true + CanSetColor: true + Material: metallight + Shape: ModelEntity + Place: Face + Collide: "bullet,rocket" + Model: @models/Blocks/Spaceship/lightSS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "SS,GV", display: true + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.2, type: float, display: true, formatter: Liter + StackSize: 4000 + IsActivateable: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + RemoveOnSI: true + StabilitySupport: false + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + # TechTreeParent: SpotlightBlocks + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 280, Name: LightMS01 + Material: metallight + Class: Light + Group: cpgLight + IndexName: Light + CanSetColor: true + Shape: ModelEntity + Place: Face + Collide: "bullet,rocket" + TemplateRoot: LightLargeBlocks + Model: @models/Blocks/Mothership/LightMS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + IsDuplicateable: false + SizeInBlocks: "1,1,1", display: true + ModelScaleLocked: false + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 4000 + IsActivateable: true + # CanPickup: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + Mass: 0.4, type: float, display: true, formatter: Kilogram + RemoveOnSI: true + StabilitySupport: false +} +{ +Block Id: 281, Name: DoorMS01 + Class: Door + IndexName: Door + Group: cpgDoor + Material: metal + # Shape: Config3DCollider + Shape: ModelEntity + Model: @models/Blocks/Mothership/DoorNewMSPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: DoorBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 250, type: float, display: true, formatter: Liter + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 100, type: int, display: true + Mass: 295, type: float, display: true, formatter: Kilogram + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor + UpgradeTo: DoorArmored, display: true +} + +{ +Block Id: 282, Name: TurretTemplate + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Free # If turret is retractable + Model: @models/Blocks/Turrets/TurretMSMinigunRetractPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + WeaponItem: TurretMSMinigunWeapon + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsActivateable: true + IsActivateableInCP: true + IsRetractable: false + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,2,3", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 6120, type: float, display: true, formatter: Kilogram + Volume: 6150, type: float, display: true, formatter: Liter # for all retractable turrets + StackSize: 500 + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 2500, type: int, display: true + HitPoints: 3450, type: int, display: true + RotSpeed: 150, type: int, display: true + Zoom: 5, type: float + + ShowUser: No +} +{ +Block Id: 283, Name: TurretMSMinigunRetract, Ref: TurretTemplate #TurretPlayer CV Minigun + Model: @models/Blocks/Turrets/TurretMSMinigunRetractPrefab + WeaponItem: TurretMSMinigunWeapon + TemplateRoot: TurretMSProjectileBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Mass: 13510, type: float, display: true, formatter: Kilogram + Volume: 6150, type: float, display: true, formatter: Liter + HitPoints: 4500, type: int, display: true + # MaxCount: 4, type: int, display: true +} +{ +Block Id: 284, Name: TurretMSRocketRetract, Ref: TurretTemplate #TurretPlayer CV Rocket + Model: @models/Blocks/Turrets/TurretMSRocketRetractPrefab + WeaponItem: TurretMSRocketWeapon + Homing: true, display: true + TemplateRoot: TurretMSRocketBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetractSpace, display: true + Mass: 11850, type: float, display: true, formatter: Kilogram + Volume: 6150, type: float, display: true, formatter: Liter + HitPoints: 4500, type: int, display: true + # MaxCount: 4, type: int, display: true + RotSpeed: 120, type: int, display: true + SoundRotate: Weapons/Turret/TurretRotateVeryFast + CPUIn: 9700, type: int, display: true +} + +# Obsolete but dont delete +{ Block Id: 285, Name: WindowVertBase + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/windowVertBase, param1: window_transparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: WindowSmallBlocks + Info: bkiWindowShutterTrans, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 30, type: float, display: true, formatter: Kilogram + # RemoveOnSI: true + HitPoints: 150, type: int, display: false + IsOxygenTight: false, display: true + ShowUser: No + Category: BuildingBlocks + Collide: Transparent +} +# Obsolete but dont delete +{ Block Id: 286, Name: WindowSlopedBase + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/windowSlopedBase, param1: window_slope_transparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: WindowSmallBlocks + Info: bkiWindowShutterTrans, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 30, type: float, display: true, formatter: Kilogram + # RemoveOnSI: true + HitPoints: 150, type: int, display: false + IsOxygenTight: false, display: true + ShowUser: No + Category: BuildingBlocks + Collide: Transparent +} +# Minigun Turret +{ +Block Id: 287, Name: TurretMSMinigun, Ref: TurretMSMinigunRetract + Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + TemplateRoot: TurretMSProjectileBlocks + IsOxygenTight: false, display: true + Place: Face + IsRetractable: false + SizeInBlocks: "3,1,3", display: true + Mass: 6120, type: float, display: true, formatter: Kilogram + Volume: 6150, type: float, display: true, formatter: Liter + HitPoints: 3450, type: int, display: true +} + +{ +Block Id: 288, Name: TurretMSRocket, Ref: TurretMSRocketRetract + Model: @models/Blocks/Turrets/TurretMSRocketPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + TemplateRoot: TurretMSRocketBlocks + IsOxygenTight: false, display: true + Place: Face + IsRetractable: false + SizeInBlocks: "3,1,3", display: true + Mass: 5777, type: float, display: true, formatter: Kilogram + Volume: 5535, type: float, display: true, formatter: Liter + HitPoints: 3450, type: int, display: true +} +{ +Block Id: 289, Name: TurretRadar + # Class: Turret + Material: metal + MarketPrice: 1525, display: true + Shape: ModelEntity + Model: @models/Blocks/Misc/RadarPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "3,2,3", display: true + # SizeInBlocksLocked: "Base,MS" # problem + Mass: 5150, type: float, display: true, formatter: Kilogram + Volume: 3557, type: float, display: true, formatter: Liter + Info: bkiGenNoFunction, display: true + IsAccessible: false, type: bool + Category: Deco Blocks + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + SymType: 4 + StackSize: 500 + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + HitPoints: 120, type: int, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Cannon Turret Base: Old version placeable on BA,MS - now: USER HIDDEN and Forbidden +{ Block Id: 290, Name: TurretBaseCannonOld + AllowedInBlueprint: false, display: true + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + # Model: @models/Blocks/Turrets/ModularTurret1/SinglePlasmaTurretBluePrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateFast + WeaponItem: TurretBaseCannonWeapon + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "3,2,3", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # BlockColor: "26,50,89" + # ModelOffset: "0,0.5,0" + StackSize: 6 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 1440, type: int, display: true + Mass: 1800, type: float, display: true, formatter: Kilogram + HitPoints: 3900, type: int, display: true + + RotSpeed: 150, type: int, display: true + Zoom: 5, type: float + + ShowUser: No +} +{ +Block Id: 291, Name: OxygenStation + Class: O2Station + Group: cpgO2Station + Material: metal + MarketPrice: 1800, display: true + Shape: ModelEntity + # BuffName: OxygenStation IS DONE DIFFERENT NOW + ShowBlockName: true + SfxActivate: UseActions/oxygen_tank_release_air + Model: @models/Blocks/Mothership/OxygenStationSmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiOxygenStation, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 356, type: float, display: true, formatter: Liter + StackSize: 500 + BlockColor: "110,110,110" + Category: Devices + Mass: 595, type: float, display: true, formatter: Kilogram + EnergyIn: 2, type: int, display: true, formatter: Watt + CPUIn: 200, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + TechTreeParent: OxygenTankSmallMS + TechTreeNames: "Base,Capital Vessel" +} + +{ Block Id: 292, Name: WaterGreen + CanPlayersSpawnOn: false + # Class: LiquidStatic + Material: water + Shape: LiquidTerrain + Mesh: water + Texture: 107 + XpFactor: 0 + ShowUser: No + WindSpeed: 4 + Transparent: 5 + Turbidity: 0.05 + WaterColor: "0,255,0,255" + TurbidityColor: "225,255,255,255" + + # Color of water in shader + BlockColor: "0,50,30,252" + { Child Map + # Color of water in map + Color: "15,65,45,252" + # ElevMinMax: "20,30" + Specular: 0.3 + } +} +{ Block Id: 293, Name: RockLava03 + CanPlayersSpawnOn: false + # Class: LiquidStatic + Material: lava + Shape: LiquidTerrain + Mesh: lava + Texture: 121 + XpFactor: 0 + AllowPlacingAt: "Base,MS", display: true + ShowUser: No + { Child Map + # Color: "0,0,110" + # Color2: "0,0,200" + Color: "255,220,70" + Color2: "255,0,0" + ElevMinMax: "15,30" + Specular: 0.8 + Emissive: 1 + } +} +{ Block Id: 294, Name: AlienPlant01 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant01Prefab + ShowUser: No +} +{ Block Id: 295, Name: AlienPlant02, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant02Prefab +} +{ Block Id: 296, Name: AlienPlant03, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant03Prefab +} +{ Block Id: 297, Name: AlienPlant04, Ref: AlienPlant01 + Material: wood + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant04Prefab + SizeInBlocks: "2,5,2" + { Child DropOnDestroy + Item: WoodLogs + Count: "2,4" + Prob: 1 + } +} +{ Block Id: 298, Name: AlienPlant05 + Class: CropsGrown + { Child DropOnHarvest + Item: PhoenixFernFonds + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlant05Harvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + Collide: "bullet,rocket,melee,sight" + IsTerrainDecoration: true + # Model: @models2/Environment/Plants/AlienVegetation/AlienPlant05Prefab + Model: @models2/Environment/Plants/SpeedTrees/AlienFernPrefab + ShowUser: No +} +{ Block Id: 299, Name: AlienPlant07, Ref: AlienPlant01 + Material: plants + IsPlant: true + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant07Prefab + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein + Count: 2 + } + { Child CropsGrown + OnHarvest: AlienPlant07Harvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } +} +{ Block Id: 300, Name: AlienPlant08 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant08Prefab + ShowUser: No +} +{ Block Id: 301, Name: AlienPlant10, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant10Prefab + SizeInBlocks: "1,3,1" +} +{ Block Id: 302, Name: AlienPlant11 + Class: CropsGrown + { Child DropOnHarvest + Item: Varonroot + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlant11Harvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant11Prefab + ShowUser: No +} +{ Block Id: 303, Name: AlienPlant13, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant13Prefab +} +{ Block Id: 304, Name: AlienPlant14, Ref: AlienPlant01 + Material: plants + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant14Prefab +} +{ Block Id: 305, Name: AlienPlant17, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant17Prefab +} +{ Block Id: 306, Name: AlienPlant18, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant18Prefab +} +{ Block Id: 307, Name: AlienPlant19, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant19Prefab +} +{ Block Id: 308, Name: AlienPlant20 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant20Prefab + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + ShowUser: No +} +{ Block Id: 309, Name: AlienPlant21, Ref: AlienPlant01 + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant21Prefab +} +{ Block Id: 310, Name: AlienPlant22, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant22Prefab +} +{ Block Id: 311, Name: AlienPlant23, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant23Prefab +} +{ Block Id: 312, Name: AlienPlant24, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant24Prefab +} +{ Block Id: 313, Name: AlienPlant25, Ref: AlienPlant01 + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant25Prefab +} + +{ Block Id: 314, Name: AlienPlant11Blue + Material: plants + Shape: Deco + Collide: "bullet,rocket,melee,sight" + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant11BluePrefab + ShowUser: No +} + +# Template for growable plants +{ Block Id: 315, Name: PlantGrowingTemplate + AllowedInBlueprint: false, display: true # to avoid exploit: spawn a BP with plants for "Free" (Mantis 04793) + Class: PlantGrowing + IndexName: Plant + Material: plants + Shape: ModelEntity + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Model: @models2/Entities/Farming/ModernFarm/TomatoStage1Prefab + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Place: Face + Mass: 1, type: float, display: true, formatter: Kilogram + Category: Farming + XpFactor: 1 + ShowBlockName: true + ShowUser: NoButCSV # reported by HWS +} +{ Block Id: 316, Name: PlantHarvestTemplate + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Model: @models2/Entities/Farming/ModernFarm/TomatoStage3Prefab + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Place: Face + Mass: 1, type: float, display: true, formatter: Kilogram + XpFactor: 0.25 + ShowUser: NoButCSV # reported by HWS +} + +{ Block Id: 317, Name: AlienPalm + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienPalmPrefab + ShowUser: No + SizeInBlocks: "1,5,1" + HitPoints: 107, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} + +{ Block Id: 318, Name: CrystalIcePlate + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/Crystals/CrystalIcePlatePrefab + SizeInBlocks: "1,1,1" + ShowBlockName: true + ShowUser: No + { Child DropOnDestroy + Item: IceBlocks + Count: "3,10" + Prob: 1 + } +} + +{ Block Id: 319, Name: RockResourceTitan + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/Resources/RockResourceTitanPrefab + # Model: @models2/Environment/Rocks/Resources/RockResourceIronPrefab + SizeInBlocks: "2,1,2" + BlockColor: "180,180,180" + ShowBlockName: true + HitPoints: 60, type: int, display: true + ShowUser: No + { Child DropOnDestroy_1 + Item: TitanOre + Count: "1,3" + Prob: 1 + } +} + +{ +Block Id: 320, Name: TurretDrillTemplate + Class: TurretManual + IndexName: # override to not show up as an AI turret + Group: cpgHarvest + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSDrillRetractPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: TurretMSDrillWeapon + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,1,3", display: true + # SizeInBlocksLocked: "Base,MS" # GV and MS turret are derived from this + IsActivateable: true + IsActivateableInCP: true + IsRetractable: false + Info: bkiDrill, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 50 + EnergyDynamicGroup: DrillTurret + EnergyIn: 3150, type: int, display: true, formatter: Watt + EnergyInIdle: 33, type: int, display: true, formatter: Watt + CPUIn: 3000, type: int, display: true + Mass: 9270, type: float, display: true, formatter: Kilogram + HitPoints: 2250, type: int, display: true + RotSpeed: 100, type: int, display: true + SoundRotate: Weapons/Turret/TurretRotateSlow + Zoom: 5, type: float + AboveTerrainCheck: false + + ShowUser: No +} +{ +Block Id: 321, Name: TurretMSDrillRetract, Ref: TurretDrillTemplate #TurretPlayer CV Drill + Model: @models/Blocks/Turrets/TurretMSDrillRetractPrefab + WeaponItem: TurretMSDrillWeapon + TemplateRoot: TurretMSToolBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + SizeInBlocks: "3,2,3", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 15730, type: float, display: true, formatter: Kilogram + HitPoints: 3250, type: int, display: true + Volume: 5114, type: float, display: true, formatter: Liter + Place: Free +} +{ +Block Id: 322, Name: TurretMSToolRetract, Ref: TurretDrillTemplate #TurretPlayer CV Tool + Model: @models/Blocks/Turrets/TurretMSToolRetractPrefab + WeaponItem: TurretMSToolWeapon + TemplateRoot: TurretMSToolBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Info: bkiTurretTool, display: true + IsRetractable: true + IsOxygenTight: true, display: true + SizeInBlocks: "3,2,3", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 14910, type: float, display: true, formatter: Kilogram + Volume: 4700, type: float, display: true, formatter: Liter + HitPoints: 3250, type: int, display: true + Place: Free + + EnergyIn: 2450, type: int, display: true, formatter: Watt + EnergyInIdle: 26, type: int, display: true, formatter: Watt +} +{ +Block Id: 323, Name: TurretMSPulseLaserRetract, Ref: TurretTemplate #TurretPlayer CV Laser + Model: @models/Blocks/Turrets/TurretMSPulseLaserRetractPrefab + WeaponItem: TurretMSPulseLaserWeapon + TemplateRoot: TurretMSLaserBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetractSpace, display: true + RotSpeed: 100, type: int, display: true + CPUIn: 15100, type: int, display: true + HitPoints: 4850, type: int, display: true + Mass: 15960, type: float, display: true, formatter: Kilogram + Volume: 6825, type: float, display: true, formatter: Liter +} +{ +Block Id: 324, Name: TurretMSPlasmaRetract, Ref: TurretTemplate #TurretPlayer CV Plasma + Model: @models/Blocks/Turrets/TurretMSPlasmaRetractPrefab + WeaponItem: TurretMSPlasmaWeapon + TemplateRoot: TurretMSLaserBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetractSpace, display: true + # MaxCount: 4, type: int, display: true + RotSpeed: 80, type: int, display: true + CPUIn: 20700, type: int, display: true + SoundRotate: Weapons/Turret/TurretRotateSlow + HitPoints: 4250, type: int, display: true + Mass: 16140, type: float, display: true, formatter: Kilogram + Volume: 6825, type: float, display: true, formatter: Liter +} +{ +Block Id: 325, Name: TurretMSFlakRetract, Ref: TurretTemplate #TurretPlayer CV Flak + Model: @models/Blocks/Turrets/TurretMSFlakRetractPrefab + WeaponItem: TurretMSFlakWeapon + TemplateRoot: TurretMSRocketBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetractSpace, display: true + # MaxCount: 4, type: int, display: true + CPUIn: 1300, type: int, display: true + HitPoints: 4500, type: int, display: true + Mass: 13660, type: float, display: true, formatter: Kilogram + Volume: 6250, type: float, display: true, formatter: Liter +} +{ +Block Id: 326, Name: TurretMSCannonRetract, Ref: TurretTemplate #TurretPlayer CV Cannon + Model: @models/Blocks/Turrets/TurretMSCannonRetractPrefab + WeaponItem: TurretMSCannonWeapon + TemplateRoot: TurretMSProjectileBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + # MaxCount: 4, type: int, display: true + RotSpeed: 150, type: int, display: true + CPUIn: 2500, type: int, display: true + SoundRotate: Weapons/Turret/TurretRotateFast + HitPoints: 4500, type: int, display: true + Mass: 13840, type: float, display: true, formatter: Kilogram + Volume: 6250, type: float, display: true, formatter: Liter +} +{ +Block Id: 327, Name: TurretMSArtilleryRetract, Ref: TurretTemplate #TurretPlayer CV Artillery + Model: @models/Blocks/Turrets/TurretMSArtilleryRetractPrefab + WeaponItem: TurretMSArtilleryWeapon + TemplateRoot: TurretMSArtilleryBlocks + + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetractSpace, display: true + + SizeInBlocks: "5,4,5", display: true # disallow on S MS + Mass: 62800, type: float, display: true, formatter: Kilogram + Volume: 7700, type: float, display: true, formatter: Liter # max volume + EnergyIn: 70, type: int, display: true, formatter: Watt + HitPoints: 7500, type: int, display: true + RotSpeed: 60, type: int, display: true + CPUIn: 21600, type: int, display: true + SoundRotate: Weapons/Turret/TurretRotateSlow +} +{ +Block Id: 328, Name: NPCAlienTemplate + Material: human # do not use "alien" because it will show dirt effect + Shape: ModelEntity + Class: NPCDialogue + Model: @models2/Entities/Decorations/HomeSweetHome/BedPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Deco Blocks + TemplateRoot: AlienNPCBlocks + # ModelOffset: "0,0.5,0" + ShowUser: No + XpFactor: 0 # no experience gain when destroyed + HitPoints: 250, type: int, display: false + BlastParticleIndex: 14 + BlastRadius: 1 + BlastDamage: 0 + Mass: 80, type: float, display: true, formatter: Kilogram +} + +{ +Block Id: 329, Name: NPCHumanTemplate + Material: human + Class: NPCDialogue + Shape: ModelEntity + Model: @models2/Entities/Decorations/HomeSweetHome/BedPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + MarketPrice: 5850, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Deco Blocks + TemplateRoot: HumanNPCBlocks + # ModelOffset: "0,0.5,0" + ShowUser: No + XpFactor: 0 # no experience gain when destroyed + HitPoints: 250, type: int, display: false + BlastParticleIndex: 14 + BlastRadius: 1 + BlastDamage: 0 + Mass: 80, type: float, display: true, formatter: Kilogram +} + + +{ +Block Id: 330, Name: AntennaBlocks + Material: metallight + Shape: Invisible + ShowBlockName: true + MarketPrice: 2450, display: true + # Model: @models/Blocks/Mothership/AntennaPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,3,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 2425, type: float, display: true, formatter: Liter + StackSize: 500 + Category: Deco Blocks + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + Mass: 6290, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + ChildBlocks: "Antenna01, Antenna02, Antenna03, Antenna04, Antenna05, Antenna, Antenna06, Antenna07, Antenna08, Antenna09, Antenna10, Antenna11, Antenna12, Antenna13" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Id: 331, Name: ContainerUltraRare, Ref: ContainerMS01 + LootList: 49 # lootcontainer: Ultra Rare - Epic Weapon or AutoMinerCore + # purple: + BlockColor: "96,33,173" +} + +{ Block Id: 332, Name: ChildTest + Material: metal + Texture: 50 + ShowUser: No +} +{ +Block Id: 333, Name: RailingDiagonal + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/RailingDiagonal, param1: RailingDiagonal + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + SymType: 2 + TemplateRoot: WalkwayLargeBlocks + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 500, type: float, display: true, formatter: Liter + Mass: 25, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + Collide: Transparent + ParentBlocks: "WalkwaySmallBlocks, WalkwayLargeBlocks" +} +{ +Block Id: 334, Name: RailingVert, Ref: RailingDiagonal + Model: Mothership/RailingVert, param1: RailingVert + SymType: 1 +} +{ +Block Id: 335, Name: OfflineProtector + Group: cpgEquipment + IndexName: OP + Material: metal + MarketPrice: 34500, display: true + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/OfflineProtectorPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + Info: bkiOfflineProtect, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,2,2", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 7500, type: float, display: true, formatter: Kilogram + Volume: 4015, type: float, display: true, formatter: Liter + StackSize: 500 + Category: Devices + OccupySizeInBlocks: true # block uses an animation, so the full SizeInBlocks needs to be occupied + BlockColor: "26,50,90" + HitPoints: 100, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UnlockCost: 12, display: true + UnlockLevel: 7, display: true + # TechTreeParent: GeneratorBA + TechTreeNames: "Base,Capital Vessel" +} +# Before: WarpDriveTank +{ +Block Id: 336, Name: PentaxidTank + Class: WarpDriveTank + Group: cpgWarp + MarketPrice: 22120, display: true + IndexName: WarpDriveTank + LootList: 3 # lootcontainer Warp Drive Tank + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Devices/PentaxidTankFWNPrefab + # Model: @models/Blocks/Mothership/WarpDriveTankPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsActivateableInCP: true + IsLockable: true + IsDuplicateable: false + BlockColor: "110,110,110" + # IsClosedOnMoving: true + # IsAccessible: false, type: bool + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 2465, type: float, display: true, formatter: Kilogram + Volume: 2250, type: float, display: true, formatter: Liter + VolumeCapacity: 600, type: float, display: true, formatter: Liter + HitPoints: 100, type: int, display: true + StackSize: 500 + Category: Devices + # EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 400, type: int, display: true + BlastParticleIndex: 10 + BlastRadius: 5 + BlastDamage: 300 + + UnlockCost: 13, display: true + UnlockLevel: 10, display: true + TechTreeParent: GravityGeneratorBlocksBACV + TechTreeNames: "Base,Capital Vessel" + + WarpAccept: PentaxidCrystal + + Radiation: 15, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 35, display: true +} + +{ Block Id: 337, Name: DecoTemplate + Material: metallight + Shape: ModelEntity + NPCModelRotation: false + Model: @models2/Entities/Decorations/HomeSweetHome/BedPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Deco Blocks + TemplateRoot: DecoBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 200, type: float, display: true, formatter: Liter + Mass: 100, type: float, display: true, formatter: Kilogram + StackSize: 1000 + ShowUser: No + RepFac: 0.01 +} + +# Still needed? +{ Block Id: 338, Name: OutsideBlock + Material: hull + Texture: 26 + BlockColor: "255,247,0" + Shape: New + Model: Cube + AllowPlacingAt: Base, display: true + Place: Free + ShowUser: No +} + +{ +Block Id: 339, Name: SurvivalTent + Info: bkiSurvivalTent, display: true + Class: Sleeping + Material: woodblock + Shape: Invisible + LifetimeOnDrop: 1200 + MarketPrice: 3.25, display: true + # Model: @models/Blocks/Terrain/SurvivalTent01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "4,2,4" #no need to display info for terrain placeables (removed to reduce hover info density) +# SizeInBlocks: "4,2,4", display: true + Mass: 1.3, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + AllowPlacingAt: Terrain, display: true + Category: Devices + PickupToToolbar: true + CanPickup: true + + BuffNamesActivate: "Rested", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst + + ChildBlocks: "SurvivalTent01, SurvivalTent02, SurvivalTent03" +} + +{ Block Id: 340, Name: RockFormationO + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + CanDecorateOnSlopes: false + Model: @models2/Environment/Rocks/Misc/RockFormationOPrefab + ShowUser: No + HitPoints: 334, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "50,67" + Prob: 1 + } +} + +{ +Block Id: 341, Name: RadarTracker + Info: bkiRadarTracker, display: true + CustomIcon: TurretRadar + IndexName: Radar + Group: cpgEquipment + Category: Devices + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Misc/RadarPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + AllowPlacingAt: Base, display: true + IsActivateable: true + IsActivateableInCP: true + SizeInBlocks: "3,2,3", display: true + # SizeInBlocksLocked: "Base,MS" # problem + IsAccessible: true, type: bool + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + SymType: 4 + StackSize: 500 + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + Mass: 5150, type: float, display: true, formatter: Kilogram + Volume: 3557, type: float, display: true, formatter: Liter + EnergyIn: 500, type: int, display: true, formatter: Watt + HitPoints: 3500, type: int, display: true + CPUIn: 2500, type: int, display: true + + # UnlockCost: 12 + # UnlockLevel: 10 + # TechTreeNames: Base + + ShowUser: No +} + +# { Block Id: 341, Name: RockFormationL, Ref: RockFormationO +# Model: @models2/Environment/Rocks/Misc/RockFormationLPrefab +# } + + + +{ Block Id: 342, Name: RockFormationN, Ref: RockFormationO + Model: @models2/Environment/Rocks/Misc/RockFormationNPrefab + HitPoints: 446, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "67,89" + Prob: 1 + } +} + +{ +Block Id: 343, Name: LadderBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Class: Ladder + Category: BuildingBlocks + Info: bkiLadderBlocks, display: true + Material: hull + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: LadderVert + # TemplateRoot: LadderBlocks + # PickupTarget: LadderBlocks + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 100, type: int, display: true + Volume: 3, type: float, display: true, formatter: Liter + Mass: 30, type: float, display: true, formatter: Kilogram + StackSize: 2000 + # ShowUser: No + BlockSizeScale: 2 + # SymType: 1 + ChildShapes: "LadderVert, LadderVertNoAttachPoints, LadderVertHalfNoAttachPoints" + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: Misc +} + +{ +Block Id: 344, Name: GeneralPurposeConsole, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Mothership/GeneralPurposeConsolePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Group: cpgCrewStationAlien + Category: Deco Blocks + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Place: Free + AllowPlacingAt: "Base,MS", display: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + # AllowedInBlueprint: false, display: true + # CanPickup: true + Volume: 22, type: float, display: true, formatter: Liter + Mass: 300, type: float, display: true, formatter: Kilogram + HitPoints: 150, type: int, display: true + EnergyIn: 0, type: int, display: true, formatter: Watt + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + TemplateRoot: ConsoleBlocks +} + +{ +Block Id: 345, Name: TurretSVSmall #TurretPlayer SV Minigun + Class: TurretManual + IndexName: Turret + Group: cpgTurret + CustomIcon: TurretGVMinigun + MarketPrice: 400, display: true + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretGVMinigunV2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + WeaponItem: TurretGVMinigunWeapon + # TemplateRoot: TurretGVMinigunBlocks + SoundOnEnter: UseActions/DOOROpen02 + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + AllowPlacingAt: SS, display: true + SizeInBlocks: "3,1,3", display: true + Volume: 281.25, type: float, display: true, formatter: Liter + Mass: 500, type: float, display: true, formatter: Kilogram + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 15, type: int, display: true, formatter: Watt + CPUIn: 1100, type: int, display: true + HitPoints: 550, type: int, display: true + RotSpeed: 140, type: int, display: true + Zoom: 5, type: float + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: WeaponSV02 + TechTreeNames: Small Vessel + + # Showuser: No +} + +#{ Block Id: 344, Name: RockFormationESnow, Ref: RockFormationO +# Model: @models2/Environment/Rocks/Misc/RockFormationESnowPrefab +#} +#{ Block Id: 345, Name: RockFormationG, Ref: RockFormationO +# Model: @models2/Environment/Rocks/Misc/RockFormationGPrefab +#} + +{ Block Id: 346, Name: RockFormationI, Ref: RockFormationO + Model: @models2/Environment/Rocks/Misc/RockFormationIPrefab + HitPoints: 226, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "34,45" + Prob: 1 + } +} +{ Block Id: 347, Name: RockFormationJ, Ref: RockFormationO + Model: @models2/Environment/Rocks/Misc/RockFormationJPrefab + HitPoints: 422, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "63,84" + Prob: 1 + } +} +{ Block Id: 348, Name: RockFormationK, Ref: RockFormationO + Model: @models2/Environment/Rocks/Misc/RockFormationKPrefab + HitPoints: 374, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "56,75" + Prob: 1 + } +} +# Not used anymore? +{ Block Id: 349, Name: RockA01 + Material: rock + Shape: ModelEntity + IsTerrainDecoration: true + CanDecorateOnSlopes: false + # SmallDecorationRadius: 2 + # BigDecorationRadius: 2 + Model: @models2/Environment/Rocks/Misc/Rock_A_01Prefab + ShowUser: No +} +# Not used anymore? +{ Block Id: 350, Name: RockB01, Ref: RockA01 + Model: @models2/Environment/Rocks/Misc/Rock_B_01Prefab +} +# Not used anymore? +{ Block Id: 351, Name: RocksmallA02 + Material: rock + Shape: Ext3dModel + Mesh: models + Model: Rocks/Rock_A_02, param1: Rock_A_02 + ShowUser: No + HitPoints: 50, type: int, display: true + IsTerrainDecoration: true +} +# Not used anymore? +{ Block Id: 352, Name: RocksmallC01 + Material: rock + Shape: Ext3dModel + Mesh: models + Model: Rocks/Rock_C_01, param1: Rock_C_01 + ShowUser: No + HitPoints: 50, type: int, display: true + IsTerrainDecoration: true +} +# Save game break: add these prefabs to Eric deco and resize Rocks02 + correct reference of Rocks03-05 +{ Block Id: 353, Name: Rocks02 + Material: rock + Shape: ModelEntity + IsTerrainDecoration: true + CanDecorateOnSlopes: false + ShowUser: No + HitPoints: 100, type: int, display: true + Model: @models2/Environment/Rocks/Misc/Rocks02Prefab +} +# Not used anymore? +{ Block Id: 354, Name: Rocks03, Ref: Rocks02 + Model: @models2/Environment/Rocks/Misc/Rocks02Prefab + # Model: @models2/Environment/Rocks/Misc/Rocks03Prefab +} +# Not used anymore? +{ Block Id: 355, Name: Rocks04, Ref: Rocks02 + Model: @models2/Environment/Rocks/Misc/Rocks02Prefab + # Model: @models2/Environment/Rocks/Misc/Rocks04Prefab +} +# Not used anymore? +{ Block Id: 356, Name: Rocks05, Ref: Rocks02 + Model: @models2/Environment/Rocks/Misc/Rocks02Prefab + # Model: @models2/Environment/Rocks/Misc/Rocks05Prefab +} +{ Block Id: 357, Name: ModRocksBrown01 + Material: rock + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/Misc/Mod_rocks_brown01Prefab + SizeInBlocks: "7,5,6" + ShowUser: No + HitPoints: 151, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "23,30" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "4,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,4" + # Prob: 0.05 + # } +} +{ Block Id: 358, Name: ModRocksBrown02, Ref: ModRocksBrown01 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_brown02Prefab + SizeInBlocks: "9,5,9" + HitPoints: 569, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "85,114" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "7,11" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,9" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,7" + # Prob: 0.05 + # } +} +{ Block Id: 359, Name: Mod_rocks_grey01, Ref: RockFormationO + # CanTerrainDecoXZRotate: true + Model: @models2/Environment/Rocks/Misc/Mod_rocks_grey01Prefab + HitPoints: 192, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,38" + Prob: 1 + } +} +{ Block Id: 360, Name: Mod_rocks_grey02, Ref: RockFormationO + # CanTerrainDecoXZRotate: true + Model: @models2/Environment/Rocks/Misc/Mod_rocks_grey02Prefab + HitPoints: 344, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "52,69" + Prob: 1 + } +} +# Save game break: add these prefabs to Eric deco and resize Rocks02Snow + correct reference of RockA01Snow, RockB01Snow +# Not used anymore? +{ Block Id: 361, Name: RockA01Snow + Material: rock + Shape: ModelEntity + IsTerrainDecoration: true + CanDecorateOnSlopes: false + ShowUser: No + # Model: @models2/Environment/Rocks/Misc/Rock_A_01SnowPrefab + Model: @models2/Environment/Rocks/Misc/Rocks02SnowPrefab +} +# Not used anymore? +{ Block Id: 362, Name: RockB01Snow, Ref: RockA01Snow + # Model: @models2/Environment/Rocks/Misc/Rock_B_01SnowPrefab + Model: @models2/Environment/Rocks/Misc/Rocks02SnowPrefab +} +# Not used anymore? +{ Block Id: 363, Name: Rocks02Snow, Ref: RockA01Snow + Model: @models2/Environment/Rocks/Misc/Rocks02SnowPrefab +} +{ Block Id: 364, Name: Mod_rocks_yellow01, Ref: RockFormationO + # CanTerrainDecoXZRotate: true + Model: @models2/Environment/Rocks/Misc/Mod_rocks_yellow01Prefab + HitPoints: 191, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,38" + Prob: 1 + } +} +{ Block Id: 365, Name: Mod_rocks_yellow02, Ref: Mod_rocks_yellow01 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_yellow02Prefab + HitPoints: 344, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "52,69" + Prob: 1 + } +} +{ Block Id: 366, Name: Mod_rocks_yellow03, Ref: Mod_rocks_yellow01 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_yellow03Prefab + HitPoints: 235, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "35,47" + Prob: 1 + } +} +{ Block Id: 367, Name: Mod_rocks_yellow04, Ref: Mod_rocks_yellow01 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_yellow04Prefab + HitPoints: 192, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,38" + Prob: 1 + } +} +{ Block Id: 368, Name: ModRocksBrown03, Ref: ModRocksBrown02 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_brown03Prefab + SizeInBlocks: "7,5,12" + HitPoints: 563, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "85,113" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "7,11" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,9" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,7" + # Prob: 0.05 + # } +} +{ Block Id: 369, Name: ModRocksBrown04, Ref: ModRocksBrown02 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_brown04Prefab + SizeInBlocks: "7,5,7" + HitPoints: 325, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "49,65" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "4,7" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "3,6" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,4" + # Prob: 0.05 + # } +} +{ Block Id: 370, Name: Mod_rocks_grey03, Ref: Mod_rocks_yellow01 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_grey03Prefab + HitPoints: 235, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "35,47" + Prob: 1 + } +} +{ Block Id: 371, Name: Mod_rocks_grey04, Ref: Mod_rocks_yellow01 + Model: @models2/Environment/Rocks/Misc/Mod_rocks_grey04Prefab + HitPoints: 192, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,38" + Prob: 1 + } +} +{ Block Id: 372, Name: TextureMulti05 + StackSize: 999 + Material: hull + Texture: "69,70,71,72,73,74" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 373, Name: TextureMulti06 + StackSize: 999 + Material: hull + Texture: "75,76,76,76,7,8" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 374, Name: TextureMulti07 + StackSize: 999 + Material: hull + Texture: "27,24,25,28,35,39" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 375, Name: TextureMulti08 + StackSize: 999 + Material: hull + Texture: "64,65,66,67,68,40" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 376, Name: TextureMulti09 + StackSize: 999 + Material: hull + Texture: "41,42,43,44,45,46" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 377, Name: TextureMulti10 + StackSize: 999 + Material: hull + Texture: "47,48,49,257,258,259" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 378, Name: TextureMulti11 + StackSize: 999 + Material: hull + Texture: "5,260,261,262,263,264" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} + +{ Block Id: 379, Name: WaterRed + CanPlayersSpawnOn: false + # Class: LiquidStatic + Material: water + Shape: LiquidTerrain + Mesh: water + Texture: 107 + XpFactor: 0 + ShowUser: No + WindSpeed: 4 + Transparent: 5 + Turbidity: 0.05 + WaterColor: "255,0,0,255" + TurbidityColor: "225,255,255,255" + BlockColor: "100,0,0,180" + { Child Map + Color: "55,10,10,180" + Specular: 0.3 + } +} + +# Steel Blocks (small blocks) - block group +{ +Block Id: 380, Name: HullSmallBlocks + Material: hull + MarketPrice: 14, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # do not change - block used in old structures + Texture: 50 + BlockColor: "110,110,110" + Shape: New # cannot set to Invisible because block ID has been used in older versions + Place: Free + Model: Cube + TemplateRoot: HullSmallBlocks + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "SS,GV", display: true + HitPoints: 50, type: int, display: false + Mass: 8, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + StackSize: 2000 + BlockSizeScale: 2 + Category: BuildingBlocks + ChildBlocks: "HullFullSmall, HullThinSmall, HullExtendedSmall, HullExtendedSmall2, HullExtendedSmall3, HullExtendedSmall4, HullExtendedSmall5, HullExtendedSmall6, HullExtendedSmall7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Two shape families for Steel Blocks (small blocks) +{ +Block Id: 381, Name: HullFullSmall + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hull + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + Info: bkiHull, display: true + TemplateRoot: HullSmallBlocks + AllowPlacingAt: "SS,GV", display: true + HitPoints: 50, type: int, display: true + Mass: 8, type: float, display: true, formatter: Kilogram + BlockSizeScale: 2 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: HullArmoredFullSmall, display: true + DowngradeTo: PlasticFullSmall, display: true +} +{ +Block Id: 382, Name: HullThinSmall, Ref: HullFullSmall + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: HullArmoredThinSmall, display: true + DowngradeTo: PlasticThinSmall, display: true +} + +# ChildBlocks of HullArmoredSmallBlocks +# Two shape families for Hardened Steel Blocks (small blocks) +{ +Block Id: 383, Name: HullArmoredFullSmall + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hull + Texture: 54 + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullArmoredSmallBlocks + AllowPlacingAt: "SS,GV", display: true + HitPoints: 200, type: int, display: true + Mass: 32, type: float, display: true, formatter: Kilogram + BlockSizeScale: 2 + Category: BuildingBlocks + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: HullCombatFullSmall, display: true +} +{ +Block Id: 384, Name: HullArmoredThinSmall, Ref: HullArmoredFullSmall + # HitPoints: 110, type: int, display: true + # Mass: 115, type: float, display: true, formatter: Kilogram + # Volume: 22, type: float, display: true, formatter: Liter + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: HullCombatThinSmall, display: true +} + +{ Block Id: 385, Name: WaterBlue + CanPlayersSpawnOn: false + # Class: LiquidStatic + Material: water + Shape: LiquidTerrain + Mesh: water + Texture: 107 + XpFactor: 0 + ShowUser: No + WindSpeed: 4 + Transparent: 5 + Turbidity: 0.05 + WaterColor: "204,255,255,255" + TurbidityColor: "225,255,255,255" + BlockColor: "15,25,35,255" + { Child Map + # Color: "20,65,95,255" + Color: "20,50,95,255" + # Color: "21,37,55,255" + # Color: "17,33,51,248" + # Color: "25,80,110,248" + ElevMinMax: "20,30" + Specular: 0.3 + } +} + +{ Block Id: 386, Name: WaterBrown + CanPlayersSpawnOn: false + # Class: LiquidStatic + Material: water + Shape: LiquidTerrain + Mesh: water + Texture: 107 + XpFactor: 0 + ShowUser: No + WindSpeed: 4 + Transparent: 5 + Turbidity: 0.05 + WaterColor: "110,100,15,255" + TurbidityColor: "225,255,255,255" + BlockColor: "110,100,15,180" + { Child Map + Color: "110,100,15,180" + # Color: "190,160,25,180" + # Color: "150,125,0,180" + Specular: 0.3 + } +} +# Templatemaster for many blocks +# obsolete but dont delete +{ Block Id: 387, Name: Hull + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hull + Texture: 50 # do not change + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullSmallBlocks + PickupTarget: HullSmallBlocks + AllowPlacingAt: "Base,MS,SS,GV", display: true + HitPoints: 50, type: int, display: false + Info: bkiHull, display: true + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + ShowUser: NoButCSV # reported by HWS + Category: BuildingBlocks +} +# obsolete but dont delete +{ Block Id: 388, Name: HullWedge, Ref: Hull + Model: RampC + Texture: "50,50,50,53,50,53" +} +# obsolete but dont delete +{ Block Id: 389, Name: HullCorner, Ref: Hull + Model: CornerHalfA3 + Texture: "51,52,52,52,52,52" +} +# obsolete but dont delete +{ Block Id: 390, Name: HullWedgeInverse, Ref: Hull + Model: CutCornerE + Texture: "51,50,52,52,50,50" +} + +# Obsolete but dont delete +{ Block Id: 391, Name: HullFull + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hull + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullSmallBlocks + PickupTarget: HullSmallBlocks + AllowPlacingAt: "Base,MS,SS,GV", display: true + Info: bkiHull, display: true + HitPoints: 50, type: int, display: true + Mass: 70, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: HullArmoredFull, display: true + + ShowUser: NoButCSV # reported by HWS +} +# Obsolete but dont delete +{ Block Id: 392, Name: HullThin, Ref: HullFull + HitPoints: 25, type: int, display: true + Mass: 35, type: float, display: true, formatter: Kilogram + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: HullArmoredThin, display: true +} + + +# Hardened Steel Blocks (small blocks) - block group +{ +Block Id: 393, Name: HullArmoredSmallBlocks + Material: hull + MarketPrice: 85, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 54 + BlockColor: "170,170,170" + Shape: New # cannot set to Invisible because block ID has been used in older versions + Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "SS,GV", display: true + HitPoints: 200, type: int, display: false + Mass: 32, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 2 + Category: BuildingBlocks + ChildBlocks: "HullArmoredFullSmall, HullArmoredThinSmall, HullArmoredExtendedSmall, HullArmoredExtendedSmall2, HullArmoredExtendedSmall3, HullArmoredExtendedSmall4, HullArmoredExtendedSmall5, HullArmoredExtendedSmall6, HullArmoredExtendedSmall7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +# Obsolete but dont delete +{ Block Id: 394, Name: HullArmoredFull + Material: hull + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 54 + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullArmoredSmallBlocks + PickupTarget: HullArmoredSmallBlocks + AllowPlacingAt: "Base,MS,SS,GV", display: true + HitPoints: 200, type: int, display: true + Mass: 230, type: float, display: true, formatter: Kilogram + BlockSizeScale: 2 # attention: block is used on Base,MS,SS,GV + Category: BuildingBlocks + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + ShowUser: NoButCSV # reported by HWS +} +# Obsolete but dont delete +{ Block Id: 395, Name: HullArmoredThin, Ref: HullArmoredFull + HitPoints: 100, type: int, display: true + Mass: 115, type: float, display: true, formatter: Kilogram + BlockSizeScale: 2 # attention: block is used on Base,MS,SS,GV + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +} + + +# Standard Wood Block +{ +Block Id: 396, Name: WoodBlocks # rename: WoodFullBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: woodblock + MarketPrice: 1.25, display: true + Texture: 70 + TextureTable: 1 # 1 = wood table + BlockColor: "187,135,90" + Shape: Invisible + Place: Free + # Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: Base, display: true + HitPoints: 100, type: int, display: false + Mass: 360, type: float, display: true, formatter: Kilogram + StackSize: 1000 + BlockSizeScale: 8 + Volume: 80, type: float, display: true, formatter: Liter + Category: BuildingBlocks + ChildBlocks: "WoodFull, WoodThin, WoodExtended, WoodExtended2, WoodExtended3, WoodExtended4, WoodExtended5, WoodExtended6, WoodExtended7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +# Two shape families for wood block +{ +Block Id: 397, Name: WoodFull + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: woodblock + Texture: 70 + TextureTable: 1 # 1 = wood table + BlockColor: "187,135,90" + Shape: New + Place: Free + Model: Cube + TemplateRoot: WoodBlocks + AllowPlacingAt: Base, display: true + HitPoints: 100, type: int, display: true + Mass: 360, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + #max32 ChildShapes reached! + UpgradeTo: ConcreteFull, display: true +} + +{ +Block Id: 398, Name: WoodThin, Ref: WoodFull + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + #max32 ChildShapes reached! + UpgradeTo: ConcreteThin, display: true +} + +# Standard Concrete Block +{ +Block Id: 399, Name: ConcreteBlocks + Material: concrete + MarketPrice: 30, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: Invisible + Place: Free + # Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: Base, display: true + HitPoints: 600, type: int, display: false + Mass: 1100, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "ConcreteFull, ConcreteThin, ConcreteExtended, ConcreteExtended2, ConcreteExtended3, ConcreteExtended4, ConcreteExtended5, ConcreteExtended6, ConcreteExtended7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +# Two shape families for concrete block +{ +Block Id: 400, Name: ConcreteFull + Material: concrete + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 1 = concrete table + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + TemplateRoot: ConcreteBlocks + AllowPlacingAt: Base, display: true + HitPoints: 800, type: int, display: true + Mass: 1100, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: ConcreteArmoredFull, display: true + DowngradeTo: WoodFull, display: true +} + +{ +Block Id: 401, Name: ConcreteThin, Ref: ConcreteFull + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: ConcreteArmoredThin, display: true + DowngradeTo: WoodThin, display: true +} + +# Steel Block (large) - block group +{ +Block Id: 402, Name: HullLargeBlocks + Material: hullarmored + MarketPrice: 130, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: New # cannot set to Invisible because block ID has been used in older versions + Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 500, type: int, display: false + Mass: 1000, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "HullFullLarge, HullThinLarge, HullExtendedLarge, HullExtendedLarge2, HullExtendedLarge3, HullExtendedLarge4, HullExtendedLarge5, HullExtendedLarge6, HullExtendedLarge7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +# Two shape families for Steel Blocks (large) +{ +Block Id: 403, Name: HullFullLarge + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hullarmored + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullLargeBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 500, type: int, display: true + Mass: 1000, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: HullArmoredFullLarge, display: true + DowngradeTo: PlasticFullLarge, display: true +} + +{ +Block Id: 404, Name: HullThinLarge, Ref: HullFullLarge + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: HullArmoredThinLarge, display: true + DowngradeTo: PlasticThinLarge, display: true +} + +# Hardened Steel Block (large) - block group +{ +Block Id: 405, Name: HullArmoredLargeBlocks + Material: hullarmored + MarketPrice: 772, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 54 + BlockColor: "170,170,170" + Shape: New # cannot set to Invisible because block ID has been used in older versions + Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 1000, type: int, display: false + Mass: 2000, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "HullArmoredFullLarge, HullArmoredThinLarge, HullArmoredExtendedLarge, HullArmoredExtendedLarge2, HullArmoredExtendedLarge3, HullArmoredExtendedLarge4, HullArmoredExtendedLarge5, HullArmoredExtendedLarge6, HullArmoredExtendedLarge7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +# Two shape families for Steel Blocks (large) +{ +Block Id: 406, Name: HullArmoredFullLarge + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hullarmored + Texture: 54 + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullArmoredLargeBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 1000, type: int, display: true + Mass: 2000, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: HullCombatFullLarge, display: true +} + +{ +Block Id: 407, Name: HullArmoredThinLarge, Ref: HullArmoredFullLarge + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: HullCombatThinLarge, display: true +} + +# Alien Block in Creative Menu (radioactive) - returns non-radioactive AlienLargeBlocks +{ +Block Id: 408, Name: AlienBlocks + AllowedInBlueprint: false, display: true + Material: alien + Texture: 36 + TextureTable: 3 # 3 = alien table + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Shape: Invisible + # Model: Cube + Info: bkiAlienBlock, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 3500, type: int, display: false + Volume: 80, type: float, display: true, formatter: Liter + Mass: 3910, type: float, display: true, formatter: Kilogram + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "AlienFull, AlienThin, AlienExtended, AlienExtended2, AlienExtended3, AlienExtended4, AlienExtended5, AlienExtended6, AlienExtended7" + Radiation: 9, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} + +{ +Block Id: 409, Name: AlienFull + AllowedInBlueprint: false, display: true + Material: alien + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 36 + TextureTable: 3 # 3 = alien table + Shape: New + Place: Free + Model: Cube + TemplateRoot: AlienLargeBlocks + PickupTarget: AlienLargeBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 3500, type: int, display: true + Mass: 3910, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + Radiation: 9, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} + +{ +Block Id: 410, Name: AlienThin, Ref: AlienFull + AllowedInBlueprint: false, display: true + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + Radiation: 9, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} + + +# Combat Steel Blocks (large blocks) - block group +{ +Block Id: 411, Name: HullCombatLargeBlocks + Material: hullcombat + MarketPrice: 1415, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 56 + BlockColor: "170,170,170" + Shape: Invisible + # Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 2000, type: int, display: false + Mass: 4000, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "HullCombatFullLarge, HullCombatThinLarge, HullCombatExtendedLarge, HullCombatExtendedLarge2, HullCombatExtendedLarge3, HullCombatExtendedLarge4, HullCombatExtendedLarge5, HullCombatExtendedLarge6, HullCombatExtendedLarge7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +# Two shape families for Combat hull (large blocks) +{ +Block Id: 412, Name: HullCombatFullLarge + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hullcombat + Texture: 56 + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullCombatLargeBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 2000, type: int, display: true + Mass: 4000, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" +} + +{ +Block Id: 413, Name: HullCombatThinLarge, Ref: HullCombatFullLarge + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +} + +{ +Block Id: 414, Name: DecalsControlBlock + Info: bkiDecalsControlBlock, display: true + IndexName: DecalControl + Class: DecalControl + Group: cpgLCD + Category: Devices + CustomIcon: LCDScreenBlocks + Material: metallight, display: true + Shape: New + Model: Cube + Texture: 50 + BlockColor: "110,110,110" + # Model: @models/Blocks/Misc/LCDProjectorPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: HullLargeBlocks + PickupTarget: SteelPlate + #TemplateRoot: LCDScreenBlocks + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsAccessible: true, type: bool + IsActivateableInCP: true + IsLockable: true + IsActivateable: true + IsOxygenTight: false, display: true + # HitPoints: 500, type: int, display: false + # Mass: 1000, type: float, display: true, formatter: Kilogram + # Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + # CanPickup: true + #EnergyIn: 20, type: int, display: true, formatter: Watt + # ModelOffset: "0,0.5,0" + SizeInBlocks: "1,1,1", display: true +} + + +{ Block Id: 415, Name: RockResourceCarbon + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/Resources/RockResourceCarbonPrefab + SizeInBlocks: "2,1,2" + BlockColor: "180,180,180" + # ShowBlockName: true + HitPoints: 20, type: int, display: true + ShowUser: No + { Child DropOnDestroy_1 + Item: PlasticMaterial + Count: "3,6" + Prob: 1 + } +} + +{ +Block Id: 416, Name: TrussCube + Material: hullarmored + Shape: New + Place: Free + Model: Truss/TrussCube + Mesh: cutout + Texture: 59 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TrussSmallBlocks + IsAccessible: false, type: bool + IsTextureable: false + IsOxygenTight: false, display: true + Info: bkiTruss, display: true + Category: BuildingBlocks + ParentBlocks: "TrussSmallBlocks, TrussLargeBlocks" + + # Properties given in parent block + HitPoints: 25, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + + # Cannot remove - BP cannot be spawned anymore + AllowPlacingAt: "Base,MS,SS,GV", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" +} + +# retractable landinggear +{ +Block Id: 417, Name: LandinggearSV + Class: LandingGear # for retractable landinggears + IndexName: LandingGear + Group: cpgLandingGear + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + IsPhysicsColliders: false + Model: @models/Blocks/Landinggears/LandinggearLightRetractPrefab + # Model: @models/Blocks/Landinggears/LandinggearLightPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + TemplateRoot: LandinggearBlocksSV + Place: Free + AllowPlacingAt: SS, display: true + SizeInBlocks: "1,1,2", display: true + Mass: 171, type: float, display: true, formatter: Kilogram + Info: bkiLandinggearSV, display: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + IsRetractable: true, display: true # for retractable landinggears + Category: Devices + Hardness: 50, type: float, display: false, formatter: Hardness + HitPoints: 50, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt +} +{ +Block Id: 418, Name: GeneratorSV + IndexName: Generator + MarketPrice: 856, display: true + Class: Generator + Group: cpgGenerator + EssentialCategory: cpgGenerator + LootList: 1 + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/GeneratorSVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,2,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 262, type: float, display: true, formatter: Kilogram + Info: bkiGenerator, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + IsOxygenTight: true, display: true + IsActivateable: true + IsIgnoreLC: true + HitPoints: 200, type: int, display: true + EnergyOut: 1500, type: int, display: true, formatter: Watt + CPUIn: 187, type: int, display: true + Category: Devices + BlastParticleIndex: 2 + BlastRadius: 5 + BlastDamage: 200 + + UnlockCost: 2, display: true + UnlockLevel: 3, display: true + TechTreeParent: GeneratorSVSmall + TechTreeNames: "Small Vessel,Hover Vessel" + + Temperature: 70, display: true + Radiation: 4.5, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} +{ +Block Id: 419, Name: FuelTankSV + Class: FuelTank + IndexName: FuelTank + MarketPrice: 260, display: true + Group: cpgFuelTank + EssentialCategory: cpgFuelTank + # LootList: 26 # 10 slots + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/FuelTankSVPrefab + # Model: @models/Blocks/Spaceship/FuelTankSS01Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + # IsActivateable: false # fuel tanks activate themselves if they contain fuel + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + # IsClosedOnMoving: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,2", display: true + Volume: 15, type: float, display: true, formatter: Liter + Mass: 88, type: float, display: true, formatter: Kilogram + Info: bkiFuelTank, display: true + StackSize: 2000 + BlockColor: "110,110,110" + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 150, type: int, display: true + Category: Devices + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + FuelCapacity: 600, type: int, display: true + BlastParticleIndex: 10 + BlastRadius: 5 + BlastDamage: 500 + + Radiation: 0.75, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + + UnlockCost: 2, display: true + UnlockLevel: 3, display: true + TechTreeNames: "Small Vessel,Hover Vessel" + TechTreeParent: FuelTankSVSmall +} +{ +Block Id: 420, Name: RCSBlockMS + IndexName: RCS + Group: cpgRCS + Material: metal + MarketPrice: 6630, display: true + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/RCS_BlockPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 800, type: float, display: true, formatter: Liter + Mass: 15960, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiGyroscope, display: true + StackSize: 250 + BlockColor: "110,110,110" + Category: Devices + Torque: 10000, type: int, display: true, formatter: NewtonMeter + # Torque: 2000000, type: int, display: true, formatter: NewtonMeter + EnergyDynamicGroup: RCS + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 75000, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + UnlockCost: 5, display: true + UnlockLevel: 7, display: true + TechTreeParent: GeneratorBA + TechTreeNames: Capital Vessel +} + +# not yet used +{ Block Id: 421, Name: SensorSS + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/sensorSSPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + ShowUser: No + AllowPlacingAt: SS, display: true + Info: bkiGenNoFunction, display: true + # ModelOffset: "0,0.5,0" + SizeInBlocks: "3,1,2", display: true + Category: Devices + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 5, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 +} +{ +Block Id: 422, Name: OxygenTankSV + Class: O2Tank + IndexName: O2Tank + Group: cpgO2Tank + MarketPrice: 70, display: true + LootList: 25 # 5 slots + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/OxygenTankSVPrefab + # Model: @models/Blocks/MotherShip/OxygenTankSmallPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + StackSize: 2000 + Place: Free + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 69, type: float, display: true, formatter: Kilogram + Info: bkiOxygenTank, display: true + Category: Devices + EnergyIn: 3, type: int, display: true, formatter: Watt + CPUIn: 40, type: int, display: true + O2Capacity: 400, type: int, display: true + O2Accept: "OxygenBottleLarge,OxygenBottleSmall,RespiratorCharge" + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 423, Name: FridgeSV + Class: Container + IndexName: Fridge + Group: cpgFridge + MarketPrice: 265, display: true + LootList: 1 # empty medium + DontDecayFood: true # now this loot container is a fridge + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/FridgeSVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + # ModelOffset: "0,0.5,0" + StackSize: 2000 + BlockColor: "110,110,110" + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 62, type: float, display: true, formatter: Kilogram + VolumeCapacity: 250, type: float, display: true, formatter: Liter + CPUIn: 30, type: int, display: true + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiFridge, display: true + Category: Devices + EnergyIn: 3, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeParent: ContainerSmallBlocks + TechTreeNames: "Small Vessel,Hover Vessel" +} + +# Obsolete but dont delete +{ Block Id: 424, Name: OxygenStationSS + Class: O2Station + Group: cpgO2Station + Material: metal + Shape: ModelEntity + # BuffName: OxygenStationSS DONE DIFFERENT NOW + ShowBlockName: true + Model: @models/Blocks/Spaceship/OxygenStationSSPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "2,1,2", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + TemplateRoot: OxygenStationSV + Info: bkiOxygenStation, display: true + StackSize: 250 + IsLockable: true + IsOxygenTight: false, display: true + Category: Devices + Mass: 250, type: float, display: true, formatter: Kilogram + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 50, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + ShowUser: No +} +# preview icon used in control panel +{ Block Id: 425, Name: FuelTankPOI + Class: FuelTank + IndexName: FuelTank + Group: cpgFuelTank + EssentialCategory: cpgFuelTank + LootList: 23 + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/FuelTankMSLargePrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + TemplateRoot: FuelTankMSLarge + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + CPUIn: 25, type: int, display: true + Volume: 800, type: float, display: true, formatter: Liter + StackSize: 250 + BlockColor: "110,110,110" + # IsClosedOnMoving: true + Category: Devices + Mass: 40000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + FuelCapacity: 100, type: int, display: true + BlastParticleIndex: 10 + BlastRadius: 5 + BlastDamage: 500 + # IsActivateable: false # fuel tanks activate themself if they contain fuel + + ShowUser: No +} + +# not yet used +{ Block Id: 426, Name: CapacitorSS + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/CapacitorPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsAccessible: false, type: bool + AllowPlacingAt: "SS,GV", display: true + Info: bkiGenNoFunction, display: true + SizeInBlocks: "2,1,1", display: true + SymType: 4 + # ModelOffset: "0,0.5,0" + StackSize: 1000 + BlockColor: "110,110,110" + Mass: 200, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Deco Blocks + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not delete +{ Block Id: 427, Name: CockpitSV + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Cockpits/_Legacy/cockpitSSPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOORMetalCloseGentlyShort + Place: Cockpit + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 650, type: int, display: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "3,3,3", display: true + Volume: 350, type: float, display: true, formatter: Liter + Mass: 500, type: float, display: true, formatter: Kilogram + Info: bkiCockpit, display: true + IsLockable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + ShowUser: No +} +# Railgun +{ +Block Id: 428, Name: WeaponSV01 + Class: Weapon + IndexName: WeaponShip + Group: cpgWeapon + Material: metalhard + MarketPrice: 8245, display: true + Shape: ModelEntity + ShowBlockName: true + WeaponItem: RailgunSSWeapon + Model: @models/Blocks/WeaponsShip/WeaponSSGaussRetractPrefab + # Model: @models/Blocks/WeaponsShip/WeaponSSGaussPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + + Place: WeaponDir + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,6,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 1896, type: float, display: true, formatter: Kilogram + Info: bkiWeaponShip, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 20, type: int, display: true, formatter: Watt + CPUIn: 2490, type: int, display: true + HitPoints: 530, type: int, display: true + # Damage: 160, display: true + # AmmoCapacity: 20, display: true + # AmmoType: RailgunBullet, display: true + # ReloadDelay: 4, display: true + # ROF: 1.5, type: float, display: true, formatter: ROF + + UnlockCost: 17, display: true + UnlockLevel: 15, display: true + TechTreeParent: WeaponSV02 + TechTreeNames: "Hover Vessel,Small Vessel" +} +# Gatling Gun +{ +Block Id: 429, Name: WeaponSV02 + Class: Weapon + IndexName: WeaponShip + Group: cpgWeapon + Material: metalhard + MarketPrice: 3500, display: true + Shape: ModelEntity + ShowBlockName: true + WeaponItem: MinigunSSWeapon + Model: @models/Blocks/WeaponsShip/WeaponSSMinigunRetractPrefab + # Model: @models/Blocks/WeaponsShip/WeaponSSMinigunPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + + Place: WeaponDir + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,4,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 365, type: float, display: true, formatter: Kilogram + Info: bkiWeaponShip, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 720, type: int, display: true + HitPoints: 530, type: int, display: true + # Damage: 7, display: true + # AmmoCapacity: 300, display: true + # AmmoType: 15mmBullet, display: true + # ReloadDelay: 2, display: true + # ROF: 0.1, type: float, display: true, formatter: ROF + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeNames: "Small Vessel,Hover Vessel" +} +# Pulse Laser +{ +Block Id: 430, Name: WeaponSV03 + Class: Weapon + IndexName: WeaponShip + Group: cpgWeapon + Material: metalhard + MarketPrice: 5026, display: true + Shape: ModelEntity + ShowBlockName: true + WeaponItem: PulseLaserSSWeapon + Model: @models/Blocks/WeaponsShip/WeaponSSLaserRetractPrefab + # Model: @models/Blocks/WeaponsShip/WeaponSSLaserPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + + Place: WeaponDir + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,4,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 1645, type: float, display: true, formatter: Kilogram + Info: bkiWeaponShip, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 20, type: int, display: true, formatter: Watt + CPUIn: 2070, type: int, display: true + HitPoints: 530, type: int, display: true + # Damage: 33, display: true + # AmmoCapacity: 150, display: true + # AmmoType: PulseLaserChargeSS, display: true + # ReloadDelay: 1, display: true + # ROF: 0.5, type: float, display: true, formatter: ROF + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: WeaponSV01 + TechTreeNames: "Hover Vessel,Small Vessel" +} +# Plasma Cannon +{ +Block Id: 431, Name: WeaponSV04 + Class: Weapon + IndexName: WeaponShip + Group: cpgWeapon + MarketPrice: 9000, display: true + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + WeaponItem: PlasmaCannonSSWeapon + Model: @models/Blocks/WeaponsShip/WeaponSSPlasmaRetractPrefab + # Model: @models/Blocks/WeaponsShip/WeaponSSPlasmaPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + + Place: WeaponDir + AllowPlacingAt: "SS", display: true + SizeInBlocks: "1,5,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 1682, type: float, display: true, formatter: Kilogram + Info: bkiWeaponShip, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 20, type: int, display: true, formatter: Watt + CPUIn: 750, type: int, display: true + HitPoints: 530, type: int, display: true + # Damage: 54, display: true + # AmmoCapacity: 50, display: true + # AmmoType: PlasmaCannonChargeSS, display: true + # ReloadDelay: 3, display: true + # ROF: 0.8, type: float, display: true, formatter: ROF + + UnlockCost: 20, display: true + UnlockLevel: 17, display: true + TechTreeParent: WeaponSV05Homing + TechTreeNames: Small Vessel +} +# Rocket Launcher +{ +Block Id: 432, Name: WeaponSV05 + Class: Weapon + IndexName: WeaponShip + Group: cpgWeapon + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + MarketPrice: 11500, display: true + WeaponItem: RocketLauncherSSWeapon + Model: @models/Blocks/WeaponsShip/WeaponSSMissileRetractPrefab + # Model: @models/Blocks/WeaponsShip/WeaponSSMissilePrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + + Place: WeaponDir + AllowPlacingAt: SS, display: true + SizeInBlocks: "1,3,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 521, type: float, display: true, formatter: Kilogram + Info: bkiWeaponShip, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 6, type: int, display: true, formatter: Watt + CPUIn: 1050, type: int, display: true + HitPoints: 530, type: int, display: true + # Damage: 211, display: true + # AmmoCapacity: 15, display: true + # AmmoType: FastRocket, display: true + # ReloadDelay: 8, display: true + # ROF: 2, type: float, display: true, formatter: ROF + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + TechTreeParent: WeaponSV02 + TechTreeNames: Small Vessel +} +# Obsolete but dont delete +{ Block Id: 433, Name: CockpitSV_fighter + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Cockpits/_Legacy/cockpitSS_fighterPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOORMetalCloseGentlyShort + Place: Cockpit + AllowPlacingAt: "SS,GV", display: true + Info: bkiCockpit, display: true + IsLockable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + SizeInBlocks: "4,3,5", display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + Category: Devices + Mass: 250, type: float, display: true, formatter: Kilogram + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 650, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + ShowUser: NoButCSV # reported by HWS +} +# Obsolete but dont delete +{ Block Id: 434, Name: CockpitSV_fighter_Noscale + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Cockpits/_Legacy/cockpitSS_fighterNoScalePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOORMetalCloseGentlyShort + Place: Cockpit + AllowedInBlueprint: false + AllowPlacingAt: "SS,GV", display: true + Info: bkiCockpit, display: true + IsLockable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + SizeInBlocks: "5,3,7", display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + Category: Devices + Mass: 250, type: float, display: true, formatter: Kilogram + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 650, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + ShowUser: No +} +# Obsolete but dont delete +{ Block Id: 435, Name: InteriorBlock01, Ref: Hull + Texture: "12,29,32,32,32,32" +} +# Obsolete but dont delete +{ Block Id: 436, Name: InteriorBlock02, Ref: Hull + Texture: "12,29,30,30,30,30" +} +# Obsolete but dont delete +{ Block Id: 437, Name: InteriorBlock, Ref: Hull + Texture: "12,29,31,31,31,31" +} +# Obsolete but dont delete +{ Block Id: 438, Name: InteriorBlock04, Ref: Hull + Texture: "12,29,9,9,9,9" +} +# Obsolete but dont delete +{ Block Id: 439, Name: InteriorBlock05, Ref: Hull + Texture: "12,29,10,10,10,10" +} +# Obsolete but dont delete +{ Block Id: 440, Name: InteriorBlock06, Ref: Hull + Texture: "12,29,11,11,11,11" +} +# Obsolete but dont delete +{ Block Id: 441, Name: InteriorBlock03, Ref: Hull + Texture: "12,29,15,15,15,15" +} +# Obsolete but dont delete +{ Block Id: 442, Name: InteriorLight, Ref: Hull + Texture: 14 + ShowUser: NoButCSV # reported by HWS +} +# Obsolete but dont delete +{ Block Id: 443, Name: RampTop, Ref: Hull + Texture: 13 + Model: RampA +} +# Obsolete but dont delete +{ Block Id: 444, Name: RampBottom, Ref: Hull + Model: RampCMedium + Mass: 60, type: float, display: true, formatter: Kilogram +} +# retractable landinggear +{ +Block Id: 445, Name: LandinggearMSHeavy + Class: LandingGear # for retractable landinggears + IndexName: LandingGear # for retractable landinggears + Group: cpgLandingGear + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Landinggears/LandinggearHeavyRetractPrefab + # Model: @models/Blocks/Landinggears/LandinggearHeavyPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + TemplateRoot: LandinggearBlocksCV + Place: Free + AllowPlacingAt: MS, display: true + SizeInBlocks: "2,1,2", display: true + SizeInBlocksLocked: "Base,MS" + HitPoints: 650, type: int, display: true + Mass: 1830, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + IsRetractable: true, display: true # for retractable landinggears + Category: Devices + Hardness: 50, type: float, display: false, formatter: Hardness + EnergyIn: 1, type: int, display: true, formatter: Watt +} + +{ Block Id: 446, Name: RampSteep, Ref: Hull + Model: RampC + Texture: 12 +} +{ Block Id: 447, Name: SolarBlock, Ref: Hull + Model: RampC + Texture: "50,10,10,10,10,10" # removed SolarPanel texture + AllowPlacingAt: "Base,MS", display: true +} +{ Block Id: 448, Name: SolarPanel, Ref: Hull + Model: Plate + Texture: "50,50,10,10,10,10" # removed SolarPanel texture + AllowPlacingAt: "Base,MS", display: true +} +{ +Block Id: 449, Name: ThrusterSVRoundNormal + Class: Thruster + IndexName: Thruster + Group: cpgThruster + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + StackSize: 1000 + BlockColor: "110,110,110" + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundNormalPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: SS, display: true + SizeInBlocks: "1,2,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 282, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + TemplateRoot: ThrusterSVRoundBlocks + Info: bkiThruster, display: true + HitPoints: 300, type: int, display: true + ThrusterForce: 170, type: int, display: true, formatter: Newton + EnergyIn: 32, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 123, type: int, display: true + Temperature: 287, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + UpgradeTo: ThrusterSVRoundNormalT2, display: true +} +{ +Block Id: 450, Name: ThrusterSVRoundArmored, Ref: ThrusterSVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundArmoredPrefab + UpgradeTo: null, display: false +} +{ +Block Id: 451, Name: ThrusterSVRoundSlant, Ref: ThrusterSVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundSlantPrefab + UpgradeTo: null, display: false +} +# Obsolete but dont delete +{ Block Id: 452, Name: ThrusterSVRoundAtmo, Ref: ThrusterSVRoundNormal + # Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSAtmoPrefab + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundArmoredPrefab + ShowUser: No +} +{ +Block Id: 453, Name: ThrusterMSRoundArmored + Class: Thruster + IndexName: Thruster + Group: cpgThruster + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + StackSize: 1000 + BlockColor: "110,110,110" + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundArmoredPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 1600, type: float, display: true, formatter: Liter #Grouped block; Volume not relevant + Mass: 4407, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + TemplateRoot: ThrusterMSRoundBlocks + Info: bkiThruster, display: true + HitPoints: 933, type: int, display: true + ThrusterForce: 16726, type: int, display: true, formatter: Newton + EnergyIn: 797, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 9659, type: int, display: true + Category: Devices + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 100 + + Radiation: 7, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 807, display: true +} +{ +Block Id: 454, Name: ThrusterMSRoundSlant, Ref: ThrusterMSRoundArmored + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundSlantPrefab +} +{ +Block Id: 455, Name: ThrusterMSRoundNormal, Ref: ThrusterMSRoundArmored + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundNormalPrefab +} +{ +Block Id: 456, Name: ThrusterSVDirectional + Class: Thruster + IndexName: Thruster + Group: cpgThruster + MarketPrice: 685, display: true + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + StackSize: 1000 + BlockColor: "110,110,110" + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSDirectionalPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: SS, display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 184, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + Info: bkiThrusterDirectional, display: true + HitPoints: 150, type: int, display: true + # ThrusterForce: 40, type: int, display: true, formatter: Newton + ThrusterForce: 75, type: int, display: true, formatter: Newton + EnergyIn: 20, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 56, type: int, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + # TechTreeParent: RCSBlockSV + TechTreeNames: Small Vessel + + Temperature: 203, display: true +} +{ +Block Id: 457, Name: ThrusterMSDirectional + Info: bkiThrusterDirectional, display: true + Class: Thruster + IndexName: Thruster + Group: cpgThruster + MarketPrice: 6385, display: true + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + StackSize: 1000 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVDirectionalPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 800, type: float, display: true, formatter: Liter + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + Mass: 2361, type: float, display: true, formatter: Kilogram + HitPoints: 500, type: int, display: true + ThrusterForce: 7200, type: int, display: true, formatter: Newton + EnergyIn: 450, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 5000, type: int, display: true + Category: Devices + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 100 + + BlockColor: "110,110,110" + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + # TechTreeParent: RCSBlockMS + TechTreeNames: Capital Vessel + + Temperature: 757, display: true + Radiation: 6, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} +{ +Block Id: 458, Name: ThrusterMSRoundNormal2x2 + Class: Thruster + IndexName: Thruster + Group: cpgThruster + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + BlockColor: "26,50,89" + StackSize: 250 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundNormal2x2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + SizeInBlocks: "2,4,2", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 2560, type: float, display: true, formatter: Liter #Grouped Block; Volume not relevant here + Mass: 22530, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + TemplateRoot: ThrusterMSRound2x2Blocks + Info: bkiThruster, display: true + HitPoints: 5675, type: int, display: true + ThrusterForce: 209673, type: int, display: true, formatter: Newton + EnergyIn: 4464, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 69644, type: int, display: true + Category: Devices + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 100 + + Radiation: 12, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 962, display: true +} +{ +Block Id: 459, Name: CockpitSV_ShortRange + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Cockpits/CockpitSV_ShortRangePrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOORMetalCloseGentlyShort + Place: Cockpit + TemplateRoot: CockpitBlocksSV + AllowPlacingAt: "SS,GV", display: true + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 900, type: int, display: true + SizeInBlocks: "3,3,5", display: true + # Volume: 4500, type: float, display: true, formatter: Liter + Mass: 845, type: float, display: true, formatter: Kilogram + Info: bkiCockpit, display: true + IsLockable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + # ModelOffset: "0,0.5,0" + StackSize: 10 + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + BlockColor: "110,110,110" + + UpgradeTo: CockpitSV_ShortRangeT2, display: true +} +{ +Block Id: 460, Name: DoorSS01 + Class: Door + IndexName: Door + Group: cpgDoor + Material: metal + Shape: ModelEntity + Info: bkiNotAutomaticDoor, display: true + Model: @models/Blocks/Spaceship/DoorNewSSPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: DoorBlocksSV + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "3,3,1", display: true + Mass: 183, type: float, display: true, formatter: Kilogram + HitPoints: 180, type: int, display: true + # Volume: 450, type: float, display: true, formatter: Liter + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor +} +{ +Block Id: 461, Name: StairsMS #Parent Name: StairsBlocks + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Mothership/StairsNewPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + TemplateRoot: StairsBlocks + IsOxygenTight: false, display: true + IsAccessible: false, type: bool + HitPoints: 130, type: int, display: true + Mass: 585, type: float, display: true, formatter: Kilogram + # Volume: 51, type: float, display: true, formatter: Liter + Category: BuildingBlocks + AllowWander: true +} +{ +Block Id: 462, Name: GrowingPot + Material: fertile + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: "6,31,31,31,31,31" + MarketPrice: 1100, display: true + Shape: New + Place: Free + Model: Cube + BlockColor: "110,110,110" + IsOxygenTight: true, display: true + AllowPlacingAt: "Base,MS", display: true + # SizeInBlocksLocked: "Base,MS" + Mass: 2080, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 500 + Info: bkiGrowingPotSimple, display: true + Category: BuildingBlocks + # IsColorable: false + IsTextureable: false +} +{ Block Id: 463, Name: AlienPepperStage2 # Stage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: AlienPepperStage3 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Peppers/AlienPepperStage2Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Face + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV # reported by HWS +} +{ Block Id: 464, Name: AlienPepperStage3 # Stage3 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: AlienPepperStage4 + GrowthRate: 15 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Peppers/AlienPepperStage3Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV # reported by HWS +} +{ Block Id: 465, Name: AlienPepperStage4 # Stage4 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: AlienPepperStage5 + GrowthRate: 15 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Peppers/AlienPepperStage4Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV # reported by HWS +} +{ Block Id: 466, Name: AlienPepperStage5NoFruit # Stage5 NoFruit + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: AlienPepperStage5 + GrowthRate: 45 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Peppers/AlienPepperStage5NoFruitPrefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV # reported by HWS +} +{ Block Id: 467, Name: AlienPepperStage5 # Stage5 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: Vegetables + Count: 2 + } + { Child CropsGrown + OnHarvest: AlienPepperStage5NoFruit + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Peppers/AlienPepperStage5Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # ShowUser: NoButCSV # reported by HWS + XpFactor: 0.25 +} +{ +Block Id: 468, Name: ElevatorMS + Class: Elevator + Material: metal + Shape: Ext3dModel + MarketPrice: 580, display: true + Mesh: models + Model: Mothership/Elevator, param1: ElevatorBlock + # Model: Mothership/Elevator, param1: Collider_Mesh + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Info: bkiElevator, display: true + Volume: 40, type: float, display: true, formatter: Liter + Mass: 80, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + StackSize: 2000 + # RemoveOnSI: true + Category: Devices + AllowWander: true # this has to be set to true as Elevators are excluded from wandering per default but AI uses them nevertheless. w/o that AI gets stuck in elev. + + UnlockCost: 4, display: true + UnlockLevel: 5, display: true + TechTreeParent: LadderBlocks + TechTreeNames: Misc +} +{ +Block Id: 469, Name: GeneratorMS + IndexName: Generator + Class: Generator + Group: cpgGenerator + MarketPrice: 20845, display: true + EssentialCategory: cpgGenerator + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/generatorMSPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + StackSize: 250 + EnergyOut: 25000, type: int, display: true, formatter: Watt + CPUIn: 3125, type: int, display: true + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "3,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 960, type: float, display: true, formatter: Liter + Mass: 13377, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + SymType: 4 + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiGenerator, display: true + Category: Devices + HitPoints: 250, type: int, display: true + IsActivateable: true + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 200 + + UnlockCost: 14, display: true + UnlockLevel: 10, display: true + TechTreeParent: GeneratorBA + TechTreeNames: "Base,Capital Vessel" + + Temperature: 180, display: true + Radiation: 12, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} + +{ +Block Id: 470, Name: FuelTankSVSmall + Model: @models/Blocks/Spaceship/FuelTankSVSmallPrefab + Class: FuelTank + IndexName: FuelTank + Group: cpgFuelTank + MarketPrice: 120, display: true + EssentialCategory: cpgFuelTank + # LootList: 26 # 10 slots + Material: metallight + Shape: ModelEntity + ShowBlockName: true + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + # IsActivateable: false # fuel tanks activate themselves if they contain fuel + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + # IsClosedOnMoving: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + Volume: 8, type: float, display: true, formatter: Liter + Mass: 42, type: float, display: true, formatter: Kilogram + Info: bkiFuelTank, display: true + StackSize: 2000 + BlockColor: "110,110,110" + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 75, type: int, display: true + Category: Devices + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + FuelCapacity: 300, type: int, display: true + BlastParticleIndex: 10 + BlastRadius: 5 + BlastDamage: 300 + + Radiation: 0.5, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 471, Name: GeneratorSVSmall + Model: @models/Blocks/Spaceship/GeneratorSVSmallPrefab + IndexName: Generator + Class: Generator + Group: cpgGenerator + MarketPrice: 400, display: true + EssentialCategory: cpgGenerator + LootList: 1 + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 148, type: float, display: true, formatter: Kilogram + Info: bkiGenerator, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + IsOxygenTight: true, display: true + IsActivateable: true + IsIgnoreLC: true + HitPoints: 125, type: int, display: true + EnergyOut: 700, type: int, display: true, formatter: Watt + CPUIn: 87, type: int, display: true + Category: Devices + BlastParticleIndex: 2 + BlastRadius: 3 + BlastDamage: 200 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Small Vessel,Hover Vessel" + + Temperature: 50, display: true + Radiation: 1, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} +{ Block Id: 472, Name: OpuntiaCactus + Class: CropsGrown + { Child DropOnHarvest + Item: AlienThorn + Count: 1 + } + { Child CropsGrown + OnHarvest: OpuntiaCactusHarvested + Dead: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + # Model: @models2/Environment/Plants/DesertVegetation/OpuntiaCactusPrefab + Model: @models2/Environment/Plants/SpeedTrees/Cactus4Prefab + ShowUser: No +} + +{ Block Id: 473, Name: DesertPlant11 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/DesertVegetation/DesertPlant11Prefab + ShowUser: No +} + +# Gambling desks +{ +Block Id: 474, Name: GamblingTableTwoPerson, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + Class: NPCDialogue + #DialogueState: BJ_Start #ATTENTION! Setting a dialogue state here will overwrite manually set Dialogues for this devices in the blueprints! + NPCModelRotation: false + Info: bkiGamblingTableTwoPerson, display: true + Group: cpgCrewStationAlien + IsAccessible: true, type: bool + Model: @models2/Entities/Decorations/DecoNPC/ScifiGamblingTableSingleNPCPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 475, Name: GamblingTableMultiplePeople, Ref: NPCAlienTemplate + Class: NPCDialogue + #DialogueState: BJ_Start #ATTENTION! Setting a dialogue state here will overwrite manually set Dialogues for this devices in the blueprints! + NPCModelRotation: false + Info: bkiGamblingTableMultiplePeople, display: true + Group: cpgCrewStationAlien + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ScifiGamblingTableMultipleNPCPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} + +# terrain version of plant +{ Block Id: 476, Name: DesertPlant20 + Class: CropsGrown + { Child DropOnHarvest + Item: HerbalLeaves + Count: 1 + } + { Child CropsGrown + OnHarvest: DesertPlant20Harvested + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsAccessible: false, type: bool + Model: @models2/Environment/Plants/DesertVegetation/DesertPlant20Prefab + ShowUser: No +} +{ Block Id: 477, Name: DesertPlant20Harvested, Ref: DesertPlant20 + Class: PlantGrowing + { Child PlantGrowing + Next: DesertPlant20 + GrowthRate: 23 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/DesertVegetation/DesertPlant20HarvestedPrefab +} + +{ Block Id: 478, Name: DesertPlant12 + Material: plants + Shape: ModelEntity + IsTerrainDecoration: true + Model: @models2/Environment/Plants/DesertVegetation/DesertPlant12Prefab + # SmallDecorationRadius: 1 + # BigDecorationRadius: 1 + ShowUser: No +} + +{ Block Id: 479, Name: TextureMulti16 + StackSize: 999 + Material: hull + Texture: "82,286,33,287,288,289" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 480, Name: DesertPlant21 + Material: plants + Shape: ModelEntity + IsTerrainDecoration: true + Model: @models2/Environment/Plants/DesertVegetation/DesertPlant21Prefab + ShowUser: No +} +{ Block Id: 481, Name: DesertPlant22 + Material: plants + Shape: ModelEntity + IsTerrainDecoration: true + Model: @models2/Environment/Plants/DesertVegetation/DesertPlant22Prefab + # SmallDecorationRadius: 2 + # BigDecorationRadius: 2 + ShowUser: No +} +{ Block Id: 482, Name: Mushroom2 + Class: CropsGrown + { Child DropOnHarvest + Item: MushroomBrown + Count: 1 + } + { Child CropsGrown + OnHarvest: Mushroom2Harvested + OnDeath: PlantDead + } + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/GreenStash/Mushroom2Prefab + ShowUser: No +} +# no growing anymore - small version of plant +{ Block Id: 483, Name: AlienTentacleStage1 + # Class: PlantGrowing + # { Child PlantGrowing + # Next: AlienTentacleStage2 + # GrowthRate: 25 + # FertileLevel: 1 + # } + # Collide: "bullet,rocket,melee,sight" + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienTentacleStage1Prefab + ShowUser: No + SizeInBlocks: "1,3,1" +} +# no harvest anymore +{ Block Id: 484, Name: AlienTentacleStage2 + # Class: CropsGrown + # { Child DropOnHarvest + # Item: AlienParts01 + # Count: 1 + # } + # { Child CropsGrown + # OnHarvest: AlienTentacleStage1 + # OnDeath: PlantDead + # } + # Collide: "bullet,rocket,melee,sight" + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienTentacleStage2Prefab + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,2" + Prob: 1 + } +} +{ Block Id: 485, Name: Hydrilla + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Plants/SpeedTrees/HydrillaPrefab + ShowUser: No +} + +{ Block Id: 486, Name: Hydrilla2 + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein + Count: 1 + } + { Child CropsGrown + OnHarvest: Hydrilla2Harvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Plants/SpeedTrees/Hydrilla2Prefab + ShowUser: No +} +{ Block Id: 487, Name: Hydrilla2Harvested, Ref: Hydrilla2 + Class: PlantGrowing + { Child PlantGrowing + Next: Hydrilla2 + GrowthRate: 23 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/Hydrilla2HarvestedPrefab +} +{ Block Id: 488, Name: ElderberryBushGreen + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/ElderberryBushGreenPrefab + DecoParticle: chinesebanyan + ShowUser: No + SizeInBlocks: "3,4,3" + { Child DropOnDestroy + Item: Fiber + Count: "6,8" + Prob: 1 + } +} + +# Rocket Launcher Homing +{ +Block Id: 489, Name: WeaponSV05Homing, Ref: WeaponSV05 + WeaponItem: RocketLauncherSSHomingWeapon + MarketPrice: 17000, display: true + # Model: @models/Blocks/WeaponsShip/WeaponSSMissileHomingPrefab + Model: @models/Blocks/WeaponsShip/WeaponSSMissileHomingRetractPrefab + SizeInBlocks: "1,3,1", display: true + Volume: 62.50, type: float, display: true, formatter: Liter + Mass: 990, type: float, display: true, formatter: Kilogram + EnergyIn: 8, type: int, display: true, formatter: Watt + CPUIn: 970, type: int, display: true + HitPoints: 530, type: int, display: true + + UnlockCost: 17, display: true + UnlockLevel: 12, display: true + TechTreeParent: WeaponSV05 + TechTreeNames: Small Vessel +} +# Sprout +# { Block Id: 490, Name: AlienPepperStage1 # Stage 1 - better name: save game break +{ Block Id: 490, Name: DurianRoot # Stage 1 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + MarketPrice: 76, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Peppers/AlienPepperStage1Prefab + # Model: @models2/Entities/Farming/Peppers/peppers_rootPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + CropType: Vegetables, display: true + # ModelOffset: "0,0.5,0" + { Child PlantGrowing + Next: AlienPepperStage2 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead + } + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Vegetables, display: true + # CropYield: 2, display: true + GrowthTimeInfo: 50, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: DurianRoot # disassemble + TemplateRoot: AlienPepperStage2 # deconstruct - to avoid exploit +} +# For planetary POIs +{ +Block Id: 491, Name: TurretIONCannon #TurretAlienGeneric Ion OLD + AllowedInBlueprint: false, display: true + Info: bkiAlienBlock, display: true + Class: TurretAuto + Group: cpgAlien + IndexName: Turret + Material: metalhard + Shape: Turret + TemplateRoot: TurretBaseCannon + PickupTarget: TurretBaseCannon + Place: Face + Model: @models/Blocks/Turrets/TurretsEnemy/turretIONcannonPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: TurretIONCannonWeapon + IsActivateable: true + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "3,2,3", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 8500, type: float, display: true, formatter: Kilogram + StackSize: 250 + Category: Weapons/Items + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 100 + + HitPoints: 5000, type: int, display: true + RotSpeed: 120, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 9000, type: int, display: true + XpFactor: 2 + AboveTerrainCheck: false +} + +{ +Block Id: 492, Name: TurretEnemyLaser, Ref: TurretIONCannon #TurretAlienGeneric Laser OLD + Group: cpgTurret + IndexName: Turret + Model: @models/Blocks/Turrets/TurretsEnemy/TurretEnemyLaserPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + SizeInBlocks: "3,2,3", display: true + BlockColor: "110,110,110" + WeaponItem: TurretEnemyLaserWeapon + RotSpeed: 150, type: int, display: true + CPUIn: 8100, type: int, display: true + AboveTerrainCheck: false +} + +{ Block Id: 493, Name: ArtMassBlocks + Material: hull + MarketPrice: 270, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 31 + BlockColor: "110,110,110" + Shape: Invisible + # Model: Cube + Info: bkiArtificialmass, display: true + IsOxygenTight: true, display: true + Category: BuildingBlocks + AllowPlacingAt: "SS,GV", display: true + HitPoints: 25, type: int, display: true + Mass: 250, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + StackSize: 1000 + ChildBlocks: "ArtMassLight, ArtMassMedium, ArtMassHeavy" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ Block Id: 494, Name: ArtMassLight + Material: hull + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 31 + BlockColor: "170,170,170" + Shape: New + Model: Cube + TemplateRoot: ArtMassBlocks + Info: bkiArtificialmass, display: true + AllowPlacingAt: "SS,GV", display: true + Category: BuildingBlocks + HitPoints: 25, type: int, display: true + Mass: 250, type: float, display: true, formatter: Kilogram + Volume: 100, type: float, display: true, formatter: Liter +} +{ Block Id: 495, Name: ArtMassMedium, Ref: ArtMassLight + BlockColor: "110,110,110" + Mass: 500, type: float, display: true, formatter: Kilogram +} +{ Block Id: 496, Name: ArtMassHeavy, Ref: ArtMassLight + BlockColor: "10,10,10" + Mass: 1250, type: float, display: true, formatter: Kilogram +} + +{ +Block Id: 497, Name: ThrusterMSRoundNormal3x3 # Parent: ThrusterMSRound3x3Blocks + Class: Thruster + IndexName: Thruster + Group: cpgThruster + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + BlockColor: "102,0,0" + StackSize: 250 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundNormal3x3Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,6,3", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 62897, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + TemplateRoot: ThrusterMSRound3x3Blocks + Info: bkiThruster, display: true + HitPoints: 16750, type: int, display: true + ThrusterForce: 920287, type: int, display: true, formatter: Newton + EnergyIn: 12442, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 221179, type: int, display: true + Category: Devices + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 100 + + Radiation: 15, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 1080, display: true +} + +{ +Block Id: 498, Name: GeneratorBA + IndexName: Generator + Class: Generator + Group: cpgGenerator + MarketPrice: 3600, display: true + EssentialCategory: cpgGenerator + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/generatorSmallMSPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + StackSize: 1000 + EnergyOut: 2500, type: int, display: true, formatter: Watt + CPUIn: 312, type: int, display: true + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 105, type: float, display: true, formatter: Liter + Mass: 1480, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + SymType: 4 + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiGenerator, display: true + Category: Devices + HitPoints: 250, type: int, display: true + IsActivateable: true + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 200 + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + TechTreeNames: "Base,Capital Vessel" + + Radiation: 6, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 90, display: true +} +# obsolete - do not remove +# preview icon used in control panel +{ Block Id: 499, Name: oxygenTankEscapePod + Class: O2Tank + IndexName: O2Tank + Group: cpgO2Tank + LootList: 1 + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/MotherShip/OxygenTankSmallPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + StackSize: 6 + Place: Free + AllowPlacingAt: SS, display: true + Info: bkiOxygenTank, display: true + Category: Devices + ShowUser: No + Mass: 300, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + CPUIn: 10, type: int, display: true + O2Capacity: 100, type: int, display: true + O2Accept: "OxygenBottleLarge,OxygenBottleSmall,RespiratorCharge" + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 +} + +{ Block Id: 500, Name: AlienPlantWorm1 + Class: CropsGrown + { Child DropOnHarvest + Item: NaturalSweetener + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlantWorm1Harvested + OnDeath: PlantDead + } + Material: wood + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/AlienPlantWorm1Prefab + ShowUser: No + SizeInBlocks: "3,2,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "1" + Prob: 1 + } +} +# Delete +{ Block Id: 501, Name: AlienPlantWorm2 + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/AlienPlantWorm2Prefab + ShowUser: No + SizeInBlocks: "3,2,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "1" + Prob: 1 + } +} +# terrain version of plant +{ Block Id: 502, Name: AlienPlantTube1 + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein + Count: 2 + } + Collide: "bullet,rocket,melee,sight" + { Child CropsGrown + OnHarvest: AlienPlantTube2 + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + IsAccessible: false, type: bool + Model: @models2/Environment/Plants/AlienPlantsAnim/AlienPlantTube1Prefab + # SizeInBlocks: "1,1,1" + ShowUser: No + { Child DropOnDestroy + Count: 0 + } +} +# terrain version of plant +{ Block Id: 503, Name: AlienPlantTube2 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlantTube1 + GrowthRate: 25 + FertileLevel: 1 + } + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + IsAccessible: false, type: bool + Model: @models2/Environment/Plants/AlienPlantsAnim/AlienPlantTube2Prefab + # SizeInBlocks: "1,1,1" + ShowUser: No + { Child DropOnDestroy + Count: 0 + } +} +{ Block Id: 504, Name: AlienPlantThorn1 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlantThorn2 + GrowthRate: 25 + FertileLevel: 1 + } + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_thorn1Prefab + # SizeInBlocks: "1,1,1" + ShowUser: No + { Child DropOnDestroy + Item: WoodLogs + Count: "1" + Prob: 1 + } +} +{ Block Id: 505, Name: AlienPlantThorn2 + Class: CropsGrown + { Child DropOnHarvest + Item: AlienThorn + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlantThorn1 + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_thorn2Prefab + ShowUser: No + { Child DropOnDestroy + Item: WoodLogs + Count: "1" + Prob: 1 + } +} +{ Block Id: 506, Name: AlienPlantPod1 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlantPod2 + GrowthRate: 12 + FertileLevel: 1 + } + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_pod1Prefab + # SizeInBlocks: "1,1,1" + ShowUser: No +} +{ Block Id: 507, Name: AlienPlantPod2 + Class: CropsGrown + { Child DropOnHarvest + Item: AlienParts03 + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlantPod1 + OnDeath: PlantDead + } + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_pod2Prefab + # SizeInBlocks: "1,1,1" + ShowUser: No +} +{ Block Id: 508, Name: AlienPlantReef1 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlantReef2 + GrowthRate: 24 + FertileLevel: 1 + } + { Child DropOnDestroy + Item: WoodLogs + Count: "1" + Prob: 1 + } + Material: woodsoft + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_reef1Prefab + ShowUser: No +} +{ Block Id: 509, Name: AlienPlantReef2 + Class: CropsGrown + { Child DropOnHarvest + Item: AlienParts01 + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlantReef1 + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: WoodLogs + Count: "1" + Prob: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_reef2Prefab + ShowUser: No +} +{ Block Id: 510, Name: AlienPlantSpike1 + Class: CropsGrown + { Child DropOnHarvest + Item: AlienSpikeLemon # not used yet in templates + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlantSpike2 + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_spike1Prefab + ShowUser: No +} +{ Block Id: 511, Name: AlienPlantSpike2 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlantSpike1 + GrowthRate: 23 + FertileLevel: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_spike2Prefab + ShowUser: No +} +{ Block Id: 512, Name: AlienPlantMushroom1 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlantMushroom2 + GrowthRate: 26 + FertileLevel: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_mushroom1Prefab + ShowUser: No +} +{ Block Id: 513, Name: AlienPlantMushroom2 + Class: CropsGrown + { Child DropOnHarvest + Item: MushroomSpiky + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPlantMushroom1 + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/AlienPlantsAnim/alienplant_mushroom2Prefab + ShowUser: No +} + +{ +Block Id: 514, Name: ContainerSpecialEvent, Ref: ContainerMS01 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer1Prefab + SizeInBlocks: "2,1,1", display: true + LootList: 36 # lootcontainer: Special Event + BlockColor: "96,33,173" # purple +} + +{ Block Id: 515, Name: TextureMulti17 + StackSize: 999 + Material: hull + Texture: "290,291,292,293,294,295" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} + +# obsolete - do not remove +{ Block Id: 516, Name: HullQuarterRound, Ref: Hull + Model: EdgeRound +} +# obsolete - do not remove +{ Block Id: 517, Name: HullHalfRound, Ref: Hull + Model: RoundCornerA + Texture: "51,50,50,50,50,50" +} +# obsolete - do not remove +{ Block Id: 518, Name: HullCurveOutSlope, Ref: Hull + Model: CornerRoundB + Texture: "51,50,50,50,50,50" +} +# obsolete - do not remove +{ Block Id: 519, Name: HullCurveQuarter, Ref: Hull + Model: CubeRoundConnectorA + Texture: "51,50,50,50,50,50" +} +# Obsolete - but do not remove +{ Block Id: 520, Name: MarkerBlock + AllowedInBlueprint: false, display: true + IndexName: Marker + Material: hullcombat + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: "51,26,26,26,26,26" + BlockColor: "255,247,0" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullLargeBlocks + PickupTarget: SteelPlate + AllowPlacingAt: "Base,MS", display: true + Mass: 150, type: float, display: true, formatter: Kilogram + Info: bkiAlienMarkerBlock, display: true + MaxCount: 8, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + IsAccessible: true, type: bool + IsTextureable: false + ShowUser: NoButCSV +} +# Obsolete - but do not remove +{ Block Id: 521, Name: MarkerBlock2, Ref: MarkerBlock + IndexName: Marker2 + Info: bkiAlienMarker2Block, display: true + BlockColor: "0,15,100" + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ShowUser: NoButCSV +} + +{ Block Id: 522, Name: ClusterShroom + Class: CropsGrown + { Child DropOnHarvest + Item: FireMossScrapings + Count: 1 + } + { Child CropsGrown + OnHarvest: ClusterShroomHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/ClusterShroomPrefab + ShowUser: No + { Child DropOnDestroy + Count: 0 + } +} +{ Block Id: 523, Name: CobraLeavesPlant + Class: CropsGrown + { Child DropOnHarvest + Item: Spice + Count: 1 + } + { Child CropsGrown + OnHarvest: CobraLeavesPlantHarvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/SpeedTrees/CobraLeavesPlantPrefab + # Model: @models2/Environment/Plants/FractalFlora/CobraLeavesPrefab + ShowUser: No +} +{ Block Id: 524, Name: EggPlant + Class: CropsGrown + { Child DropOnHarvest + Item: Vegetables + Count: 1 + } + { Child CropsGrown + OnHarvest: EggPlantHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/EggPlantPrefab + # SizeInBlocks: "1,1,1" + ShowUser: No +} +{ Block Id: 525, Name: PixieStalks + Class: CropsGrown + { Child DropOnHarvest + Item: HerbalLeaves + Count: 1 + } + { Child CropsGrown + OnHarvest: PixieStalksHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/FractalFlora/PixieStalksPrefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } +} +{ Block Id: 526, Name: ScalyPods + Class: CropsGrown + { Child DropOnHarvest + Item: Egg + Count: 1 + } + { Child CropsGrown + OnHarvest: ScalyPodsHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/ScalyPodsPrefab + ShowUser: No +} +{ Block Id: 527, Name: BulbShroom + Class: CropsGrown + { Child DropOnHarvest + Item: Vegetables + Count: 1 + } + { Child CropsGrown + OnHarvest: BulbShroomYoung + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/BulbShroomPrefab + SizeInBlocks: "2,2,2" + ShowUser: No +} +{ Block Id: 528, Name: BulbShroomYoung + Class: PlantGrowing + { Child PlantGrowing + Next: BulbShroom + GrowthRate: 12 + FertileLevel: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + # Model: @models2/Environment/Plants/FractalFlora/BulbShroomYoungPrefab + Model: @models2/Environment/Plants/FractalFlora/BulbShroomNoFruitPrefab + SizeInBlocks: "2,2,2" + ShowUser: No +} +# terrain version of plant +# DELETE +{ Block Id: 529, Name: AlienPlantTube1a, Ref: AlienPlantTube1 + Model: @models2/Environment/Plants/AlienPlantsAnim/AlienPlantTube1aPrefab +} + + +{ Block Id: 530, Name: DummyBlock + Material: hulllight + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Model: Cube + AllowPlacingAt: "Base,MS,SS,GV", display: false + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 531, Name: TextureMulti01 + StackSize: 999 + Material: hull + Texture: "55,56,57,58,26,16" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 532, Name: TextureMulti02 + StackSize: 999 + Material: wood + Texture: "90,91,92,93,94,95" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 533, Name: TextureMulti03 + StackSize: 999 + Material: hull + Texture: "17,18,19,20,21,22" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ Block Id: 534, Name: TextureMulti04 + StackSize: 999 + Material: hull + Texture: "23,23,96,97,98,99" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} + +# Obsolete but do not delete +{ +Block Id: 535, Name: ContainerBlocks + Material: metal + Shape: Invisible + # Model: @models/Blocks/Mothership/CargoBoxPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS,SS,GV", display: true + Volume: 32.5, type: float, display: true, formatter: Liter # low value since placed on Base,MS,SS,GV + Mass: 50, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + StackSize: 2000 + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + ChildBlocks: "ContainerMS01, ScifiContainer2, ScifiContainer1, ScifiContainerPower" + ShowUser: NoButCSV +} + + +{ +Block Id: 536, Name: ThrusterSVRoundBlocks + IndexName: Thruster + Material: metalhard + Shape: Invisible + Place: Free + MarketPrice: 1045, display: true + # Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundNormalPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + StackSize: 1000 + Category: Devices + ChildBlocks: "ThrusterSVRoundNormal, ThrusterSVRoundSlant, ThrusterSVRoundArmored" + AllowPlacingAt: SS, display: true + SizeInBlocks: "1,2,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 282, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + HitPoints: 300, type: int, display: true + ThrusterForce: 170, type: int, display: true, formatter: Newton + EnergyIn: 32, type: int, display: true, formatter: Watt + CPUIn: 123, type: int, display: true + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + TechTreeParent: ThrusterSVDirectional + TechTreeNames: Small Vessel + + Temperature: 287, display: true +} +{ +Block Id: 537, Name: ThrusterMSRoundSlant2x2, Ref: ThrusterMSRoundNormal2x2 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundSlant2x2Prefab +} +{ +Block Id: 538, Name: ThrusterMSRoundArmored2x2, Ref: ThrusterMSRoundNormal2x2 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundArmored2x2Prefab +} +{ +Block Id: 539, Name: ThrusterMSRoundSlant3x3, Ref: ThrusterMSRoundNormal3x3 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundSlant3x3Prefab +} +{ +Block Id: 540, Name: ThrusterMSRoundArmored3x3, Ref: ThrusterMSRoundNormal3x3 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundArmored3x3Prefab +} + +{ +Block Id: 541, Name: AlienContainer + Class: Container + IndexName: Container + AllowedInBlueprint: false, display: true + Group: cpgAlien + LootList: 38 # grey: common + Info: bkiAlienBlock, display: true + Material: metal + BlockColor: "110,110,110" + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/AlienstoragePrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + TemplateRoot: ContainerLargeBlocks + PickupTarget: ContainerLargeBlocks + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 800, type: float, display: true, formatter: Liter + Mass: 250, type: float, display: true, formatter: Kilogram + IsAccessible: false, type: bool + IsLockable: true + # ModelOffset: "0,0.5,0" + Category: Devices + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + CPUIn: 960, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 100 + # ShowUser: Yes +} +{ +Block Id: 542, Name: AlienContainerRare, Ref: AlienContainer + BlockColor: "255,247,0" + LootList: 39 # yellow: rare +} +{ +Block Id: 543, Name: AlienContainerVeryRare, Ref: AlienContainer + BlockColor: "170,5,5" + LootList: 40 # red: very rare +} +{ +Block Id: 544, Name: AlienContainerUltraRare, Ref: AlienContainer + Model: @models/Blocks/Misc/AlienstorageUltraPrefab + BlockColor: "171,32,161" + LootList: 41 # purple: ultra rare +} + +{ +Block Id: 545, Name: WindowShutterLargeBlocks + Material: hullcombat + Shape: Invisible + Mesh: models + MarketPrice: 1110, display: true + # Model: Mothership/windowVertMS, param1: window_nontransparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiWindowsShutter, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Category: BuildingBlocks + ChildBlocks: "WindowVertShutterArmored, WindowSlopedShutterArmored, WindowSloped2ShutterArmored, WindowVertShutterTransArmored, WindowSlopedShutterTransArmored" + Collide: Transparent + + # Parent properties + TemplateRoot: WindowShutterLargeBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + HitPoints: 1200, type: int, display: true + StackSize: 1000 + Mass: 817, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Id: 546, Name: ThrusterSVRoundNormalT2, Ref: ThrusterSVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterGVRoundNormalT2Prefab + SizeInBlocks: "1,2,1", display: true + MarketPrice: 2227, display: true + HitPoints: 340, type: int, display: true + Mass: 520, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + CPUIn: 168, type: int, display: true + ThrusterForce: 230, type: int, display: true, formatter: Newton + EnergyIn: 36, type: int, display: true, formatter: Watt + Temperature: 584, display: true + Radiation: 8, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + TemplateRoot: ThrusterSVRoundNormalT2 + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: ThrusterSVRoundBlocks + TechTreeNames: Small Vessel + UpgradeTo: null, display: false + DowngradeTo: ThrusterSVRoundNormal, display: true +} +{ +Block Id: 547, Name: ThrusterSVRoundLarge, Ref: ThrusterSVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterGVRoundLargePrefab + SizeInBlocks: "1,3,1", display: true + MarketPrice: 2000, display: true + HitPoints: 350, type: int, display: true + Volume: 93.75, type: float, display: true, formatter: Liter + Mass: 406, type: float, display: true, formatter: Kilogram + ThrusterForce: 275, type: int, display: true, formatter: Newton + EnergyIn: 38, type: int, display: true, formatter: Watt + CPUIn: 203, type: int, display: true + Temperature: 369, display: true + TemplateRoot: ThrusterSVRoundLarge + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: ThrusterSVRoundBlocks + TechTreeNames: Small Vessel + UpgradeTo: ThrusterSVRoundLargeT2, display: true +} +{ +Block Id: 548, Name: ThrusterSVRoundLargeT2, Ref: ThrusterSVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterGVRoundLargeT2Prefab + SizeInBlocks: "1,3,1", display: true + MarketPrice: 3950, display: true + HitPoints: 400, type: int, display: true + Volume: 93.75, type: float, display: true, formatter: Liter + Mass: 842, type: float, display: true, formatter: Kilogram + ThrusterForce: 380, type: int, display: true, formatter: Newton + EnergyIn: 43, type: int, display: true, formatter: Watt + CPUIn: 277, type: int, display: true + Radiation: 12, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 773, display: true + TemplateRoot: ThrusterSVRoundLargeT2 + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeParent: ThrusterSVRoundLarge + TechTreeNames: Small Vessel + UpgradeTo: null, display: false +} + +# 549 Control Field Generator + +{ +Block Id: 549, Name: ControlFieldGenerator + Info: bkiAlienBlock, display: true + # Class: Devices + Group: cpgCore + MarketPrice: 35000, display: true + #IndexName: CFGenerator + Category: Devices + Model: @models/Blocks/Misc/CoreBlockNPCPrefab + BlockColor: "110,110,110" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowedInBlueprint: false, display: true + Material: metal + Shape: ModelEntity + ShowBlockName: true + PickupTarget: SteelPlate + Place: Face + IsActivateable: true + IsDuplicateable: false + IsUsingCPUSystem: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + # SizeInBlocksLocked: "Base,MS" + Volume: 29, type: float, display: true, formatter: Liter + Mass: 400, type: float, display: true, formatter: Kilogram + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 100 + StackSize: 1000 + HitPoints: 200, type: int, display: true + RotSpeed: 120, type: int, display: true + EnergyIn: 50, type: int, display: true, formatter: Watt + AllowWander: true +} + +{ +Block Id: 550, Name: TurretZiraxMSLaser #TurretZirax CV Laser + Class: TurretManual + IndexName: Turret + Group: cpgTurret + MarketPrice: 65000, display: true + Category: Weapons/Items + Info: bkiTurretZiraxMSLaser, display: true + PickupTarget: TurretMSLaserBlocks + AllowedInBlueprint: false, display: true + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretZiraxSpherical_LaserPrefab + BlockColor: "110,110,110" + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # TemplateRoot: TurretMSLaserBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + WeaponItem: TurretEnemyLaserWeapon + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + #IsAccessible: true + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 15100, type: int, display: true + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 11760, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + RotSpeed: 100, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 551, Name: TurretZiraxMSPlasma #TurretZirax CV Plasma + Class: TurretManual + IndexName: Turret + MarketPrice: 65550, display: true + Group: cpgTurret + Category: Weapons/Items + Info: bkiTurretZiraxMSPlasma, display: true + PickupTarget: TurretMSLaserBlocks + AllowedInBlueprint: false, display: true + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretZiraxSpherical_PlasmaPrefab + BlockColor: "110,110,110" + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + WeaponItem: TurretEnemyPlasmaWeapon + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 8490, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + #IsAccessible: true + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 15, type: int, display: true, formatter: Watt + CPUIn: 20700, type: int, display: true + HitPoints: 3450, type: int, display: true + + RotSpeed: 80, type: int, display: true + Zoom: 5, type: float + + # UnlockCost: 15, display: true + # UnlockLevel: 15, display: true + # TechTreeParent: TurretMSMinigun + # TechTreeNames: Capital Vessel + # TechTreeNames: Hidden +} + +{ +Block Id: 552, Name: TurretZiraxMSRocket #TurretZirax CV Rocket + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Category: Weapons/Items + MarketPrice: 92500, display: true + Info: bkiTurretZiraxMSRocket, display: true + PickupTarget: TurretMSRocketBlocks + AllowedInBlueprint: false, display: true + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretZiraxSpherical_RocketPrefab + BlockColor: "110,110,110" + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: "Base,MS", display: true + IsActivateable: true + IsActivateableInCP: true + # IsAccessible: true + # IsOxygenTight: true, display: true + StackSize: 500 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + WeaponItem: TurretEnemyRocketWeapon + Homing: true, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + Mass: 5777, type: float, display: true, formatter: Kilogram + Volume: 5535, type: float, display: true, formatter: Liter + EnergyIn: 25, type: int, display: true, formatter: Watt + HitPoints: 3450, type: int, display: true + + RotSpeed: 150, type: int, display: true + Zoom: 5, type: float + CPUIn: 9700, type: int, display: true +} + +{ +Block Id: 553, Name: StationConsole, Ref: DecoTemplate + Material: metallight + Shape: ModelEntity + Info: bkiStationConsole, display: true + Model: @models2/Entities/Decorations/HolographicAIConsole/HolographicAIConsolePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Class: NPCDialogue + NPCModelRotation: false + Group: cpgCrewStationAlien + Category: Deco Blocks + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Place: Free + AllowPlacingAt: "Base,MS", display: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + # AllowedInBlueprint: false, display: true + # CanPickup: true + Volume: 22, type: float, display: true, formatter: Liter + Mass: 300, type: float, display: true, formatter: Kilogram + HitPoints: 150, type: int, display: true + EnergyIn: 0, type: int, display: true, formatter: Watt + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + TemplateRoot: ConsoleBlocks + + + ShowUser: Yes +} + +{ +Block Id: 554, Name: OxygenGenerator + Class: O2Generator + Material: metal + Shape: ModelEntity + MarketPrice: 325, display: true + Model: @models/Blocks/Terrain/OxygenGeneratorPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + BlockColor: "26,50,90" + IsColorable: false + Info: bkiOxygenGenerator, display: true + IsTerrainDecoration: true + # oxygengenerator should run with 1 energycell (FuelValue 10kWh) => 60min + EnergyIn: 10, type: int, display: true, formatter: Watt + StackSize: 1000 + CanPickup: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + Category: Devices + AllowPlacingAt: Terrain, display: true + # SizeInBlocks: "1,2,3", display: true # removed to avoid problem with "cutting out water" + Volume: 800, type: float, display: true, formatter: Liter + Mass: 500, type: float, display: true, formatter: Kilogram + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + HitPoints: 80, type: int, display: true + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + + # UnlockCost: 2, display: true + # UnlockLevel: 2, display: true + # TechTreeParent: WaterGenerator + # TechTreeNames: Misc + + MapIcon: map_OxyGenerator + MapName: OxygenGenerator + + Temperature: 30, display: true + + ShowUser: No +} + +# For space POIs +{ +Block Id: 555, Name: TurretIONCannon2 #TurretAlienGeneric Ion OLD + AllowedInBlueprint: false, display: true + Info: bkiAlienBlock, display: true + Class: TurretAuto + Group: cpgAlien + IndexName: Turret + Material: metalhard + Shape: Turret + TemplateRoot: TurretBaseCannon + PickupTarget: TurretBaseCannon + Place: Face + Model: @models/Blocks/Turrets/TurretsEnemy/turretIONcannon2Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: TurretIONCannon2Weapon + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "3,2,3", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 2880, type: float, display: true, formatter: Liter + StackSize: 1000 + IsActivateable: true + Category: Weapons/Items + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 100 + HitPoints: 9500, type: int, display: true + RotSpeed: 150, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 5670, type: int, display: true + Mass: 8500, type: float, display: true, formatter: Kilogram + XpFactor: 2 + AboveTerrainCheck: false +} +{ +Block Id: 556, Name: SpotlightSSCube + Class: LightSpot + IndexName: Light + Group: cpgSpotlight + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/spotlightSSCubePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + TemplateRoot: SpotlightBlocks + # ModelOffset: "0,0.5,0" + StackSize: 1000 + AllowPlacingAt: "MS,SS,GV", display: true + IsActivateable: true + IsOxygenTight: true, display: true + MaxCount: 30, type: int, display: true + Info: bkiSpotlight, display: true + Category: Devices + Mass: 85, type: float, display: true, formatter: Kilogram + EnergyIn: 5, type: int, display: true, formatter: Watt + # CanSetColor: true +} + +# Rocket Turret: Old version placeable on BA,MS - now: USER HIDDEN and Forbidden +{ Block Id: 557, Name: TurretBaseRocketOld, Ref: TurretMSRocket #TurretAlienGeneric BA Rocket OLD + + AllowedInBlueprint: false, display: true + Model: @models/Blocks/Turrets/TurretMSRocketPrefab + # Model: @models/Blocks/Turrets/ModularTurret1/SingleMissileTurretBluePrefab + WeaponItem: TurretBaseRocketWeapon + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + CPUIn: 5250, type: int, display: true + IsLockable: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + ShowUser: No +} +{ +Block Id: 558, Name: Core + Class: Core + Group: cpgCore + IndexName: Core + MarketPrice: 1166, display: true + EssentialCategory: cpgCore + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CoreBlockPlayerPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Faction: Player + Place: Free + Mass: 140, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + StackSize: 1000 + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsActivateable: true + IsDuplicateable: false + IsUsingCPUSystem: true + IsIgnoreLC: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCorePlayer, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + EnergyIn: 5, type: int, display: true, formatter: Watt + #CPUOut: 7500, type: int, display: true + HitPoints: 250, type: int, display: true + AllowWander: true + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Small Vessel,Hover Vessel,Capital Vessel,Base" + + # UpgradeTo: CoreNoCPU, display: true +} +# obsolete - do not remove +{ Block Id: 559, Name: Artificialmass, Ref: ArtMassLight + Mass: 2500, type: float, display: true, formatter: Kilogram + Volume: 15, type: float, display: true, formatter: Liter + AllowPlacingAt: "Base,MS,SS,GV", display: true + ShowUser: No +} +# Alien Core +{ +Block Id: 560, Name: CoreNPC + AllowedInBlueprint: false, display: true + Class: Core + Group: cpgCore + EssentialCategory: cpgCore + IsKeepContainers: true + IndexName: Core + Info: bkiAlienBlock, display: true + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Misc/CoreBlockNPCPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "102,0,0" + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsActivateable: true + IsDuplicateable: false + IsUsingCPUSystem: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + ConsumeFuelO2: false + TemplateRoot: Core + PickupTarget: Core + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + EnergyIn: 1, type: int, display: true, formatter: Watt + #CPUOut: 7500, type: int, display: true + HitPoints: 120, type: int, display: true + Mass: 313, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + StackSize: 1000 + XpFactor: 20 + AllowWander: true + RepFac: 750 +} +{ Block Id: 561, Name: RCSBlockEscapePod + IndexName: RCS + Group: cpgRCS + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/RCS_BlockPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: SS, display: true + IsActivateable: true + # ModelOffset: "0,0.5,0" + StackSize: 25 + BlockColor: "110,110,110" + Category: Devices + ShowUser: No + Mass: 1500, type: float, display: true, formatter: Kilogram + Torque: 6, type: int, display: true, formatter: NewtonMeter + # Torque: 2000, type: int, display: true, formatter: NewtonMeter + EnergyDynamicGroup: RCS + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 50, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 +} +{ Block Id: 562, Name: CattailWinter + Class: CropsGrown + { Child DropOnHarvest + # Item: Sugarcane + Item: Grain + Count: 1 + } + { Child CropsGrown + OnHarvest: CattailWinterHarvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/SpeedTrees/CattailWinterPrefab + ShowUser: No +} +{ Block Id: 563, Name: Snakeweed + Class: CropsGrown + { Child DropOnHarvest + Item: Fiber + Count: 2 + } + { Child CropsGrown + OnHarvest: SnakeweedHarvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/SpeedTrees/Snakeweed2Prefab + ShowUser: No +} +{ +Block Id: 564, Name: LightPlant01 + Class: Light + IndexName: Light + Group: cpgGrowingLight + Material: metallight + MarketPrice: 457, display: true + Shape: ModelEntity + Tag: GrowingLight + Place: Face + Collide: "bullet,rocket" + Model: @models/Blocks/Mothership/lightPlant01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + Volume: 0.2, type: float, display: true, formatter: Liter + Mass: 0.2, type: float, display: true, formatter: Kilogram + StackSize: 4000 + ModelScaleLocked: true + IsActivateable: true + Info: bkiLightPlant, display: true + Category: Devices + EnergyIn: 5, type: int, display: true, formatter: Watt + StabilitySupport: false + + UnlockCost: 2, display: true + UnlockLevel: 3, display: true + TechTreeParent: LightLargeBlocks + TechTreeNames: "Base,Capital Vessel" + + Temperature: 25, display: true +} +# Alien sentry gun +{ +Block Id: 565, Name: SentryGun01 + AllowedInBlueprint: false, display: true + Info: bkiAlienBlock, display: true + Class: TurretAuto + Group: cpgAlien + IndexName: Turret + Material: metal + Shape: Turret + Place: Free + BlockColor: "110,110,110" + TemplateRoot: SentryGunBlocks + PickupTarget: SentryGunBlocks + # Model: @models/Blocks/Turrets/SentryGuns/sentryGun01Prefab + Model: @models/Blocks/Turrets/SentryGuns/SentryGunAlienWallPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: sentryGunSingle2 + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsLockable: true + Category: Weapons/Items + # ModelOffset: "0,0.5,0" + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 20 + HitPoints: 75, type: int, display: true + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 140, type: int, display: true + Mass: 200, type: float, display: true, formatter: Kilogram + Volume: 400, type: float, display: true, formatter: Liter + + RotSpeed: 175, type: int, display: true + Zoom: 2, type: float + XpFactor: 2 + AboveTerrainCheck: false + TurretTargetIgnore: true + IsAntiInfantryWeapon: true + RepFac: 100 +} +# Alien sentry gun +{ +Block Id: 566, Name: SentryGun02 + AllowedInBlueprint: false, display: true + Info: bkiAlienBlock, display: true + Class: TurretAuto + Group: cpgAlien + IndexName: Turret + Material: metal + Shape: Turret + Place: Free + BlockColor: "110,110,110" + TemplateRoot: SentryGunBlocks + PickupTarget: SentryGunBlocks + # Model: @models/Blocks/Turrets/SentryGuns/sentryGun02Prefab + Model: @models/Blocks/Turrets/SentryGuns/SentryGunAlienPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: sentryGunSingle2 + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsLockable: true + Category: Weapons/Items + # ModelOffset: "0,0.5,0" + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 20 + HitPoints: 75, type: int, display: true + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 140, type: int, display: true + Mass: 200, type: float, display: true, formatter: Kilogram + Volume: 400, type: float, display: true, formatter: Liter + RotSpeed: 175, type: int, display: true + Zoom: 2, type: float + XpFactor: 2 + AboveTerrainCheck: false + TurretTargetIgnore: true + IsAntiInfantryWeapon: true + RepFac: 100 +} +# Player used Sentry Gun +{ +Block Id: 567, Name: SentryGun03 + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metal + Shape: Turret + Place: Face + BlockColor: "110,110,110" + # Model: @models/Blocks/Turrets/SentryGuns/sentryGun03Prefab + Model: @models/Blocks/Turrets/SentryGuns/SentryGunHumanPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: sentryGunSingle + TemplateRoot: SentryGunBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 400, type: float, display: true, formatter: Liter + Mass: 320, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + IsAccessible: true, type: bool + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + StackSize: 25 + MaxCount: 8, type: int, display: true + # ModelOffset: "0,0.5,0" + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 20 + HitPoints: 75, type: int, display: true + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 1400, type: int, display: true + RotSpeed: 175, type: int, display: true + Zoom: 2, type: float + AboveTerrainCheck: false + TurretTargetIgnore: true + IsAntiInfantryWeapon: true + RepFac: 100 + + # UnlockCost: 5, display: true + # UnlockLevel: 3, display: true + # TechTreeNames: "Base,Capital Vessel" +} +# not used anymore - same model as SentryGun02 +{ Block Id: 568, Name: SentryGun04 + AllowedInBlueprint: false, display: true + Info: bkiAlienBlock, display: true + Class: TurretAuto + Group: cpgAlien + IndexName: Turret + Material: metal + Shape: Turret + Place: Free + BlockColor: "110,110,110" + TemplateRoot: SentryGunBlocks + PickupTarget: SentryGunBlocks + # Model: @models/Blocks/Turrets/SentryGuns/sentryGun04Prefab + Model: @models/Blocks/Turrets/SentryGuns/SentryGunAlienPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: sentryGunSingle3 + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsLockable: true + Category: Weapons/Items + # ModelOffset: "0,0.5,0" + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 20 + HitPoints: 40, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 180, type: int, display: true + Mass: 200, type: float, display: true, formatter: Kilogram + Volume: 400, type: float, display: true, formatter: Liter + RotSpeed: 175, type: int, display: true + Zoom: 2, type: float + XpFactor: 2 + AboveTerrainCheck: false + TurretTargetIgnore: true + IsAntiInfantryWeapon: true + ShowUser: NoButCSV + RepFac: 100 +} +{ +Block Id: 569, Name: LightWork + # Class: Light + IndexName: Light + Material: metallight + MarketPrice: 22, display: true + Shape: ModelEntity + Model: @models/Blocks/Terrain/LightWorkPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTerrainDecoration: true + Place: Free + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + EnergyIn: 1, type: int, display: true, formatter: Watt + AllowPlacingAt: Terrain, display: true + Volume: 5, type: float, display: true, formatter: Liter + Mass: 14, type: float, display: true, formatter: Kilogram + StackSize: 1000 + Info: bkiLightWork, display: true + Category: Devices + + UnlockCost: 2, display: true + UnlockLevel: 3, display: true + TechTreeParent: Flashlight + TechTreeNames: Tools +} +# delete KavaePlantYoung +{ Block Id: 570, Name: KavaePlantYoung + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/KavaePlantYoungPrefab + ShowUser: No + { Child DropOnDestroy + Count: 0 + } +} +{ Block Id: 571, Name: KavaePlant + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/KavaePlantPrefab + ShowUser: No + { Child DropOnDestroy + Count: 0 + } +} +{ Block Id: 572, Name: BrainFungus + Class: CropsGrown + { Child DropOnHarvest + Item: NaturalStimulant + Count: 1 + } + { Child CropsGrown + OnHarvest: BrainFungusHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/BrainFungusPrefab + SizeInBlocks: "2,2,2", display: true + ShowUser: No + HitPoints: 20, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1,1" + Prob: 1 + } +} +{ Block Id: 573, Name: BrainFungusSnow + Class: CropsGrown + { Child DropOnHarvest + Item: NaturalStimulant + Count: 1 + } + { Child CropsGrown + OnHarvest: BrainFungusSnowHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/BrainFungusSnowPrefab + SizeInBlocks: "2,2,2", display: true + ShowUser: No + HitPoints: 20, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1,1" + Prob: 1 + } +} +{ Block Id: 574, Name: ConfettiMoss + Class: CropsGrown + { Child DropOnHarvest + Item: ConfettiMossScrapings + Count: 1 + } + { Child CropsGrown + OnHarvest: ConfettiMossHarvested + OnDeath: PlantDead + } + Material: rock + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/ConfettiMossPrefab + ShowUser: No + { Child DropOnDestroy + Count: 0 + } +} +{ Block Id: 575, Name: CornDogBush + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein + Count: "2" + } + { Child CropsGrown + OnHarvest: CornDogBushHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/FractalFlora/CornDogBushPrefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } +} +{ Block Id: 576, Name: DimpleDome + Class: CropsGrown + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/DimpleDomePrefab + # SizeInBlocks: "6,2,4", display: true + ShowUser: No + HitPoints: 59, type: int, display: false + { Child DropOnHarvest + Item: ConfettiMossScrapings + Count: 1 + } + { Child CropsGrown + OnHarvest: DimpleDomeHarvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 577, Name: DimpleDomeSnow + Class: CropsGrown + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/DimpleDomeSnowPrefab + ShowUser: No + HitPoints: 59, type: int, display: false + { Child DropOnHarvest + Item: ConfettiMossScrapings + Count: 1 + } + { Child CropsGrown + OnHarvest: DimpleDomeSnowHarvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 578, Name: Trumpet + Class: CropsGrown + { Child DropOnHarvest + Item: TrumpetGreens + Count: 1 + } + Collide: "bullet,rocket,melee,sight" + FertileLevel: 1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Plants/GreenStash/TrumpetPrefab + ShowUser: No +} +{ Block Id: 579, Name: PromethiumStone + Material: rockhard + Shape: Deco + IsDeco: true + # Model: @models2/Environment/Rocks/Crystals/CrystalsPyramidOrangePrefab + Model: @models2/Environment/Rocks/Crystals/CrystalTallPrefab + # Model: @models2/Environment/Plants/FractalFlora/StonePlantPrefab + SizeInBlocks: "1,1,1" + ShowBlockName: true + ShowUser: No + { Child DropOnDestroy + Item: PromethiumOre + Count: "3,10" + Prob: 1 + } +} +{ Block Id: 580, Name: PixieStalksSnow + Class: CropsGrown + { Child DropOnHarvest + Item: HerbalLeaves + Count: 1 + } + { Child CropsGrown + OnHarvest: PixieStalksSnowHarvested + OnDeath: PlantDead + } + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Model: @models2/Environment/Plants/FractalFlora/PixieStalksSnowPrefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } +} +# Not used anymore? +{ Block Id: 581, Name: RocksGrey03Snow + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Rocks/Misc/Mod_rocks_grey03SnowPrefab + ShowUser: No +} +{ Block Id: 582, Name: Rocks05Snow + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Rocks/Misc/Rocks05SnowPrefab + ShowUser: No +} + +{ +Block Id: 583, Name: FridgeMS02 + Class: Container + IndexName: Fridge + Group: cpgFridge + LootList: 72 + DontDecayFood: true # now this loot container is a fridge + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/FridgeT2Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 200, type: float, display: true, formatter: Liter + Mass: 150, type: float, display: true, formatter: Kilogram + StackSize: 100 + BlockColor: "110,110,110" + Info: bkiFridge, display: true + Category: Devices + TemplateRoot: FridgeBlocks + VolumeCapacity: 1750, type: float, display: true, formatter: Liter + CPUIn: 120, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 +} +{ +Block Id: 584, Name: FridgeMS, Ref: FridgeMS02 + Model: @models/Blocks/Mothership/FridgeT1Prefab +} +{ Block Id: 585, Name: RockGreySmall01 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + HitPoints: 6, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } + Model: @models2/Environment/Rocks/RocksGrey/RockGrey01SmallPrefab + ShowUser: No +} +{ Block Id: 586, Name: RockGreySmall02 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Rocks/RocksGrey/RockGrey02SmallPrefab + HitPoints: 12, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "2,2" + Prob: 1 + } + ShowUser: No +} +{ Block Id: 587, Name: RockGreySmall03 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Rocks/RocksGrey/RockGrey03SmallPrefab + ShowUser: No + HitPoints: 7, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} +{ +Block Id: 588, Name: WaterGenerator + Class: WaterGenerator + Material: metal + MarketPrice: 530, display: true + Shape: ModelEntity + Model: @models/Blocks/Terrain/WaterGeneratorPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: Terrain, display: true + SizeInBlocks: "1,1,1" #no need to display info for terrain placeables (removed to reduce hover info density) + Volume: 200, type: float, display: true, formatter: Liter + Mass: 185, type: float, display: true, formatter: Kilogram + Info: bkiWaterGenerator, display: true + IsTerrainDecoration: true + EnergyIn: 10, type: int, display: true, formatter: Watt + StackSize: 1000 + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + Category: Devices + BlockColor: "110,110,110" + IsColorable: false + # SizeInBlocks: "1,2,1", display: true # removed to avoid problem with "cutting out water" + # ModelOffset: "0,0.5,0" + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: Misc + + MapIcon: map_WaterGenerator + MapName: WaterGenerator + + # Temperature: 30, display: true +} +{ +Block Id: 589, Name: ThrusterGVDirectional + Class: Thruster + IndexName: Thruster + Group: cpgThruster + MarketPrice: 665, display: true + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + BlockColor: "110,110,110" + StackSize: 1000 + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSDirectionalPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsDuplicateable: false + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + Info: bkiThrusterDirectional, display: true + AllowPlacingAt: GV, display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 183, type: float, display: true, formatter: Kilogram + HitPoints: 80, type: int, display: true + # ThrusterForce: 7, type: int, display: true, formatter: Newton + ThrusterForce: 55, type: int, display: true, formatter: Newton + EnergyIn: 15, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 40, type: int, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + TechTreeParent: RCSBlockGV + TechTreeNames: Hover Vessel + + Temperature: 60, display: true +} +{ +Block Id: 590, Name: ThrusterGVRoundNormal # Parent ThrusterGVRoundBlocks + Class: Thruster + IndexName: Thruster + Group: cpgThruster + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + BlockColor: "110,110,110" + StackSize: 1000 + TemplateRoot: ThrusterGVRoundBlocks + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterGVRoundNormalPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsDuplicateable: false + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + Info: bkiThruster, display: true + AllowPlacingAt: GV, display: true + SizeInBlocks: "1,2,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 268, type: float, display: true, formatter: Kilogram + HitPoints: 180, type: int, display: true + ThrusterForce: 130, type: int, display: true, formatter: Newton + EnergyIn: 30, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 88, type: int, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + + Temperature: 90, display: true + UpgradeTo: ThrusterGVRoundNormalT2, display: true +} +# Sprout +{ Block Id: 591, Name: WheatStage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/WheatStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + CropType: Grain, display: true + { Child PlantGrowing + Next: WheatStage2 + GrowthRate: 20 + FertileLevel: 3 + OnDeath: PlantDead + } + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Grain, display: true + # CropYield: 6, display: true + GrowthTimeInfo: 40, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: WheatStage1 # disassemble + TemplateRoot: WheatStage2 # deconstruct - to avoid exploit +} +{ Block Id: 592, Name: WheatStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/WheatStage2Prefab + PickupTarget: WheatStage1 + { Child PlantGrowing + Next: WheatStage3 + GrowthRate: 20 + FertileLevel: 3 + OnDeath: PlantDead + } + ShowUser: No + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 593, Name: WheatStage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: Grain + Count: 6 + } + { Child CropsGrown + OnHarvest: WheatStage1 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/WheatStage3Prefab + PickupTarget: WheatStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + # ModelOffset: "0,0.5,0" + # ShowUser: NoButCSV # reported by HWS + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + XpFactor: 0.25 +} +# Sprout +{ Block Id: 594, Name: TomatoStage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + # Model: @models2/Entities/Farming/ModernFarm/TomatoStage1Prefab + Model: @models2/Entities/Farming/SpeedTrees/TomatoPlantStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + CropType: Vegetables, display: true + { Child PlantGrowing + Next: TomatoStage2 + GrowthRate: 26 + FertileLevel: 3 + OnDeath: PlantDead2 + } + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Vegetables, display: true + # CropYield: 4, display: true + GrowthTimeInfo: 80, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: TomatoStage1 # disassemble + TemplateRoot: TomatoStage2 # deconstruct - to avoid exploit +} +{ Block Id: 595, Name: TomatoStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + # Model: @models2/Entities/Farming/ModernFarm/TomatoStage2Prefab + Model: @models2/Entities/Farming/SpeedTrees/TomatoPlantStage2Prefab + PickupTarget: TomatoStage1 + { Child PlantGrowing + Next: TomatoStage3 + GrowthRate: 26 + FertileLevel: 3 + OnDeath: PlantDead2 + } + ShowUser: NoButCSV # reported by HWS + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 596, Name: TomatoStage3 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: TomatoStage4 + GrowthRate: 27 + FertileLevel: 3 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + # Model: @models2/Entities/Farming/ModernFarm/TomatoStage3Prefab + Model: @models2/Entities/Farming/SpeedTrees/TomatoPlantStage3Prefab + PickupTarget: TomatoStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + # ModelOffset: "0,0.5,0" + ShowUser: NoButCSV # reported by HWS + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + XpFactor: 0.25 +} +# Sprout +{ Block Id: 597, Name: PumpkinStage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/PumpkinPlantStage1Prefab + # Model: @models2/Entities/Farming/ModernFarm/PumpkinStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + CropType: Vegetables, display: true + { Child PlantGrowing + Next: PumpkinStage2 + GrowthRate: 30 + FertileLevel: 3 + OnDeath: PlantDead + } + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + # CropType: Vegetables, display: true + # CropYield: 8, display: true + GrowthTimeInfo: 145, type: int, display: true, formatter: Minutes + PickupTarget: PumpkinStage1 # disassemble + TemplateRoot: PumpkinStage2 # deconstruct - to avoid exploit +} +{ Block Id: 598, Name: PumpkinStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/PumpkinPlantStage2Prefab + # Model: @models2/Entities/Farming/ModernFarm/PumpkinStage2Prefab + PickupTarget: PumpkinStage1 + { Child PlantGrowing + Next: PumpkinStage3 + GrowthRate: 115 + FertileLevel: 3 + OnDeath: PlantDead + } + ShowUser: NoButCSV # reported by HWS + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 599, Name: PumpkinStage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: Vegetables + Count: 8 + } + { Child CropsGrown + OnHarvest: PumpkinStage3NoFruit + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/PumpkinPlantStage3Prefab + # Model: @models2/Entities/Farming/ModernFarm/PumpkinStage3Prefab + PickupTarget: PumpkinStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + # ModelOffset: "0,0.5,0" + # ShowUser: NoButCSV # reported by HWS + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + XpFactor: 0.25 +} +# Sprout +{ Block Id: 600, Name: CornStage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/CornStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + CropType: Grain, display: true + { Child PlantGrowing + Next: CornStage2 + GrowthRate: 30 + FertileLevel: 3 + OnDeath: PlantDead + } + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Grain, display: true + # CropYield: 12, display: true + GrowthTimeInfo: 70, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: CornStage1 # disassemble + TemplateRoot: CornStage2 # deconstruct - to avoid exploit +} +{ Block Id: 601, Name: CornStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/CornStage2Prefab + PickupTarget: CornStage1 + { Child PlantGrowing + Next: CornStage3 + GrowthRate: 40 + FertileLevel: 3 + OnDeath: PlantDead + } + ShowUser: NoButCSV # reported by HWS + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 602, Name: CornStage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: Grain + Count: 12 + } + { Child CropsGrown + OnHarvest: CornStage1 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/CornStage3Prefab + PickupTarget: CornStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + # ModelOffset: "0,0.5,0" + # ShowUser: NoButCSV # reported by HWS + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + XpFactor: 0.25 +} +{ +Block Id: 603, Name: HoverBooster + IndexName: HoverBooster + Group: cpgHoverEngine + MarketPrice: 2226, display: true + Material: metal + Shape: ModelEntity + ShowBlockName: true + IsPhysicsColliders: false + IsOxygenTight: false, display: true + Model: @models/Blocks/Spaceship/HoverBoosterPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Info: bkiHoverBooster, display: true + Place: FlyDirBottom, display: true + AllowPlacingAt: GV, display: true + SizeInBlocks: "1,1,2", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 260, type: float, display: true, formatter: Kilogram + StackSize: 1000 + HitPoints: 60, type: int, display: true + ThrusterForce: 75, type: int, display: false, formatter: Newton + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 50, type: int, display: true + EnergyDynamicGroup: HoverEngine + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: HoverEngineSmall + TechTreeNames: Hover Vessel +} +{ +Block Id: 604, Name: RCSBlockGV + IndexName: RCS + Group: cpgRCS + Material: metal + MarketPrice: 200, display: true + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/RCS_BlockPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: GV, display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 265, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiGyroscopeGV, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + # Torque: 30, type: int, display: true, formatter: NewtonMeter + Torque: 80, type: int, display: true, formatter: NewtonMeter + EnergyDynamicGroup: RCS + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 350, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UnlockCost: 2, display: true + UnlockLevel: 1, display: true + TechTreeNames: Hover Vessel +} + +# terrain version of plant +{ Block Id: 605, Name: InsanityPepperNoFruit + Class: PlantGrowing + { Child PlantGrowing + Next: InsanityPepper + GrowthRate: 25 + FertileLevel: 1 + } + Collide: "bullet,rocket,melee,sight" + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + IsAccessible: false, type: bool + Model: @models2/Environment/Plants/FractalFlora/InsanityPepperNoFruitPrefab + # SizeInBlocks: "1,1,1" + ShowUser: No +} +{ Block Id: 606, Name: InsanityPepper + Class: CropsGrown + { Child DropOnHarvest + Item: NaturalStimulant + Count: 1 + } + Collide: "bullet,rocket,melee,sight" + { Child CropsGrown + OnHarvest: InsanityPepperNoFruit + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + IsAccessible: false, type: bool + Model: @models2/Environment/Plants/FractalFlora/InsanityPepperPrefab + # SizeInBlocks: "1,1,1" + ShowUser: No +} + +# Sprout +{ Block Id: 607, Name: PearthingStage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Pearthing/PearthingStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + CropType: Fruit, display: true + { Child PlantGrowing + Next: PearthingStage2 + GrowthRate: 30 + FertileLevel: 5 + OnDeath: PlantDead2 + } + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Fruit, display: true + # CropYield: 4, display: true + GrowthTimeInfo: 90, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: PearthingStage1 # disassemble + TemplateRoot: PearthingStage2 # deconstruct - to avoid exploit +} +{ Block Id: 608, Name: PearthingStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: PearthingStage3 + GrowthRate: 30 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Pearthing/PearthingStage2Prefab + PickupTarget: PearthingStage1 + ShowUser: NoButCSV # reported by HWS + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 609, Name: PearthingStage3 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: PearthingStage4 + GrowthRate: 30 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Pearthing/PearthingStage3Prefab + PickupTarget: PearthingStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + ShowUser: NoButCSV # reported by HWS + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 610, Name: PearthingStage4NoFruit # NoFruit version + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: PearthingStage4 + GrowthRate: 85 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Pearthing/PearthingStage4NoFruitPrefab + PickupTarget: PearthingStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + ShowUser: NoButCSV # reported by HWS + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 611, Name: PearthingStage4 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: Fruit + Count: 4 + } + { Child CropsGrown + OnHarvest: PearthingStage4NoFruit + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Pearthing/PearthingStage4Prefab + PickupTarget: PearthingStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + # ShowUser: NoButCSV # reported by HWS + Category: Farming + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + XpFactor: 0.25 +} +{ +Block Id: 612, Name: Bed, Ref: DecoTemplate + Class: Sleeping + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + ShowUser: Yes + BuffNamesActivate: "IncreaseBodyTemp", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst +} +{ +Block Id: 613, Name: Sofa, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + Class: PassengerSeat + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + Model: @models2/Entities/Decorations/HomeSweetHome/SofaPrefab + SizeInBlocks: "2,1,2", display: true + SymType: 2 + ShowUser: Yes +} +{ +Block Id: 614, Name: KitchenCounter, Ref: DecoTemplate + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 63 + Model: @models2/Entities/Decorations/HomeSweetHome/KitchenCounterPrefab + SizeInBlocks: "1,1,2", display: true + SymType: 4 + ShowUser: Yes + VolumeCapacity: 1000, type: float, display: true, formatter: Liter +} +{ +Block Id: 615, Name: KitchenTable, Ref: DecoTemplate + Group: cpgEquipment + Class: PassengerSeat + IsAccessible: true, type: bool + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + Model: @models2/Entities/Decorations/HomeSweetHome/KitchenTablePrefab + ShowUser: Yes +} +{ Block Id: 616, Name: TableTV, Ref: DecoTemplate + # CanPickup: true + Info: bkiDecoModel, display: true + Model: @models2/Entities/Decorations/HomeSweetHome/TableTVPrefab + ShowUser: Yes +} +{ +Block Id: 617, Name: Bookshelf, Ref: DecoTemplate + # CanPickup: true + Info: bkiDecoModel, display: true + Model: @models2/Entities/Decorations/HomeSweetHome/BookshelfPrefab + SymType: 1 + ShowUser: Yes +} +{ +Block Id: 618, Name: ControlStation, Ref: DecoTemplate + # CanPickup: true + Class: PassengerSeat + Group: cpgEquipment + BlockColor: "170,170,170" + Info: bkiDecoModel, display: true + Model: @models2/Entities/Decorations/HomeSweetHome/ControlStationPrefab + ShowUser: Yes + IsAccessible: true, type: bool + IsLockable: true +} +{ +Block Id: 619, Name: BathroomCounter, Ref: DecoTemplate + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 62 + Model: @models2/Entities/Decorations/HomeSweetHome/BathroomCounterPrefab + SizeInBlocks: "1,1,2", display: true + SymType: 4 + ShowUser: Yes + VolumeCapacity: 1000, type: float, display: true, formatter: Liter +} +{ +Block Id: 620, Name: Toilet, Ref: DecoTemplate + Model: @models2/Entities/Decorations/HomeSweetHome/ToiletPrefab + Class: PassengerSeat + BlockColor: "220,220,220" + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + DebuffNamesActivate: "Indigestion,Hangover,BadTrip", display: EffectiveAgainst + SfxBuffActivate: UseActions/BlockToilet + ShowUser: Yes +} +{ +Block Id: 621, Name: Shower, Ref: DecoTemplate + Model: @models2/Entities/Decorations/HomeSweetHome/ShowerPrefab + BlockColor: "220,220,220" + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + # BuffNamesActivate: "ReduceRadiation,DecreaseBodyTemp,IncreaseBodyTemp", display: StatusEffect + BuffNamesActivate: "ReduceRadiation,NeutralBodyTemp", display: StatusEffect + SfxBuffActivate: UseActions/BlockShower + ShowUser: Yes +} +{ +Block Id: 622, Name: LightInterior01, Ref: LightMS01 + # CanPickup: true + Model: @models/Blocks/Mothership/LightInterior01Prefab +} +{ +Block Id: 623, Name: LightInterior02, Ref: LightMS01 + # CanPickup: true + Model: @models/Blocks/Mothership/LightInterior02Prefab +} + +{ Block Id: 624, Name: InteriorKitchen + Material: hulllight + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: "81,81,78,78,78,78" + BlockColor: "110,110,110" + Shape: New + Model: Cube + TemplateRoot: HullSmallBlocks + AllowPlacingAt: "Base,MS", display: true + Info: bkiDecoBlock, display: true + Mass: 50, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ShowUser: No +} +{ Block Id: 625, Name: InteriorKitchen02, Ref: InteriorKitchen + Texture: "78,80,81,81,81,81" +} +{ Block Id: 626, Name: InteriorLiving, Ref: InteriorKitchen + Texture: "90,80,89,89,89,89" + BlockColor: "187,135,90" +} +{ Block Id: 627, Name: InteriorLiving02, Ref: InteriorKitchen + Texture: "90,85,80,80,80,80" + BlockColor: "187,135,90" +} +{ Block Id: 628, Name: InteriorBath, Ref: InteriorKitchen + Texture: "87,85,81,81,83,83" +} + +{ +Block Id: 629, Name: IndoorPlant01 + Material: metallight + Shape: ModelEntity + Model: @models2/Entities/Decorations/IndoorPlants/IndoorPlant01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 5, type: float, display: true, formatter: Liter + Mass: 7, type: float, display: true, formatter: Kilogram + HitPoints: 5, type: int, display: true + Info: bkiDecoModel, display: true + IsAccessible: false, type: bool + # CanPickup: true + # ModelOffset: "0,0.5,0" + Category: Deco Blocks + TemplateRoot: IndoorPlants +} +{ +Block Id: 630, Name: IndoorPlant02, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/IndoorPlants/IndoorPlant02Prefab +} +{ +Block Id: 631, Name: IndoorPlant03, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/IndoorPlants/IndoorPlant03Prefab +} + +# obsolete but do not remove +{ +Block Id: 632, Name: CockpitSV02 + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Cockpits/_Legacy/CockpitSS02Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOORMetalCloseGentlyShort + Place: Cockpit + TemplateRoot: CockpitBlocksSV + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 700, type: int, display: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "3,3,5", display: true + # Volume: 4500, type: float, display: true, formatter: Liter + Mass: 845, type: float, display: true, formatter: Kilogram + Info: bkiCockpit, display: true + IsLockable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + StackSize: 10 + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + BlockColor: "110,110,110" + + ShowUser: NoButCSV +} + +# obsolete but do not remove +{ +Block Id: 633, Name: CockpitSV05, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/_Legacy/CockpitSS05Prefab + SizeInBlocks: "3,2,4", display: true + # Volume: 2400, type: float, display: true, formatter: Liter + Mass: 720, type: float, display: true, formatter: Kilogram + + ShowUser: No +} + +# Old constructor - no advanced capabilities: not in game anymore +{ Block Id: 634, Name: ConstructorSV_Old + Class: ConstructorSmall + IndexName: Constructor + Group: cpgConstructor + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # DropScale: 0.5 + Model: @models/Blocks/Spaceship/ConstructorSVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "SS,GV", display: true + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiConstructorSmall, display: true + TemplateRoot: ConstructorSmallV2 + SizeInBlocks: "1,1,3", display: true + # ModelOffset: "0,0.5,0" + StackSize: 6 + Category: Devices + Mass: 1000, type: float, display: true, formatter: Kilogram + HitPoints: 150, type: int, display: true + EnergyIn: 250, type: int, display: true, formatter: Watt + CPUIn: 2000, type: int, display: true + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + BlockColor: "110,110,110" + + ShowUser: NoButCSV +} +{ +Block Id: 635, Name: ConsoleSmallMS01 + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + Group: cpgEquipment + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Mothership/ConsoleSmallMS01Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 50, type: float, display: true, formatter: Liter + Mass: 100, type: float, display: true, formatter: Kilogram + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + BlockColor: "110,110,110" + Category: Deco Blocks + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + EnergyIn: 1, type: int, display: true, formatter: Watt + TemplateRoot: ConsoleBlocks +} +{ +Block Id: 636, Name: ConsoleLargeMS01 + Class: NPCDialogue + Info: bkiGeneralPurposeConsole, display: true + NPCModelRotation: false + Group: cpgEquipment + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Mothership/ConsoleLargeMS01Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 200, type: float, display: true, formatter: Liter + Mass: 300, type: float, display: true, formatter: Kilogram + IsAccessible: true, type: bool + IsActivateable: true + IsOxygenTight: false, display: true + BlockColor: "110,110,110" + Category: Deco Blocks + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + EnergyIn: 1, type: int, display: true, formatter: Watt + TemplateRoot: ConsoleBlocks +} +{ +Block Id: 637, Name: ConsoleLargeMS02, Ref: ConsoleLargeMS01 + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + Model: @models/Blocks/Mothership/ConsoleLargeMS02Prefab + SizeInBlocks: "2,1,1", display: true + Volume: 100, type: float, display: true, formatter: Liter +} +{ +Block Id: 638, Name: ConsoleMapMS01, Ref: ConsoleLargeMS01 + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + Model: @models/Blocks/Mothership/ConsoleMapMS01Prefab + SizeInBlocks: "1,1,1", display: true + Volume: 50, type: float, display: true, formatter: Liter +} + +# Sprout +# Growable version of plant +# Rename: CoffePlantStage1 +{ Block Id: 639, Name: InsanityPepperStage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: InsanityPepperStage2 + GrowthRate: 16 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + # Model: @models2/Entities/Farming/SpeedTrees/CoffeePlantStage1Prefab + Model: @models2/Entities/Farming/Misc/InsanityPepperStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + CropType: NaturalStimulant, display: true + # CropYield: 2, display: true + GrowthTimeInfo: 33, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiAlienPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: InsanityPepperStage1 # disassemble + TemplateRoot: InsanityPepperStage2 # deconstruct - to avoid exploit +} +# Rename: CoffePlantStage2 +{ Block Id: 640, Name: InsanityPepperStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: InsanityPepperStage3 + GrowthRate: 17 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + # Model: @models2/Entities/Farming/SpeedTrees/CoffeePlantStage2Prefab + Model: @models2/Entities/Farming/Misc/InsanityPepperStage2Prefab + PickupTarget: InsanityPepperStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + ShowUser: No + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + Category: Farming +} + +# Sprout +{ Block Id: 641, Name: DesertPlant20Stage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + CropType: HerbalLeaves, display: true + { Child PlantGrowing + Next: DesertPlant20Stage2 + GrowthRate: 20 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/DesertPlant20Stage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropYield: 1, display: true + GrowthTimeInfo: 40, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiAlienPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: DesertPlant20Stage1 # disassemble + TemplateRoot: DesertPlant20Stage2 # deconstruct - to avoid exploit +} +{ Block Id: 642, Name: DesertPlant20Stage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: DesertPlant20Stage3 + GrowthRate: 20 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/DesertPlant20Stage2Prefab + PickupTarget: DesertPlant20Stage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + ShowUser: No + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + Category: Farming +} +{ Block Id: 643, Name: DesertPlant20Stage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: HerbalLeaves + Count: 1 + } + { Child CropsGrown + OnHarvest: DesertPlant20Stage1 + OnDeath: PlantDead + } + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/DesertPlant20Stage3Prefab + PickupTarget: DesertPlant20Stage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ShowUser: NoButCSV # reported by HWS + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + Category: Farming + XpFactor: 0.25 +} +# Sprout +{ Block Id: 644, Name: AlienPlantTube2Stage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + CropType: PlantProtein, display: true + { Child PlantGrowing + Next: AlienPlantTube2Stage2 + GrowthRate: 35 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Misc/AlienPlantTube2Stage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropYield: 5, display: true + GrowthTimeInfo: 90, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiAlienPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: AlienPlantTube2Stage1 # disassemble + TemplateRoot: AlienPlantTube2Stage2 # deconstruct - to avoid exploit +} +{ Block Id: 645, Name: AlienPlantTube2Stage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: AlienPlantTube1Stage3 + GrowthRate: 55 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Misc/AlienPlantTube2Stage2Prefab + PickupTarget: AlienPlantTube2Stage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + ShowUser: No + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + Category: Farming +} +# Rocket Launcher +{ +Block Id: 646, Name: WeaponMS01 + Class: Weapon + MarketPrice: 85562, display: true + IndexName: WeaponShip + Group: cpgWeapon + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + WeaponItem: RocketLauncherMSWeapon + Model: @models/Blocks/WeaponsShip/WeaponMSArtilleryRetractPrefab + # Model: @models/Blocks/WeaponsShip/WeaponMSArtilleryPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + Place: WeaponDir + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,8,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 1600, type: float, display: true, formatter: Liter + Mass: 2485, type: float, display: true, formatter: Kilogram + Info: bkiWeaponShipSpace, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 4800, type: int, display: true + HitPoints: 3000, type: int, display: true + # Damage: 502, display: true + # AmmoCapacity: 15, display: true + # AmmoType: FastRocketMS, display: true + # ReloadDelay: 8, display: true + # ROF: 4, type: float, display: true, formatter: ROF + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: TurretMSRocketBlocks + TechTreeNames: Capital Vessel +} +# Pulse Laser +{ +Block Id: 647, Name: WeaponMS02 + Class: Weapon + IndexName: WeaponShip + Group: cpgWeapon + MarketPrice: 109244, display: true + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + WeaponItem: PulseLaserMSWeapon + Model: @models/Blocks/WeaponsShip/WeaponMSPlasmaRetractPrefab + # Model: @models/Blocks/WeaponsShip/WeaponMSPlasmaPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + Place: WeaponDir + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,6,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 1600, type: float, display: true, formatter: Liter + Mass: 2715, type: float, display: true, formatter: Kilogram + Info: bkiWeaponShipSpace, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 7200, type: int, display: true + HitPoints: 3000, type: int, display: true + # UpgradeTo: WeaponMSLaserFactionTalonar + # Damage: 53, display: true + # AmmoCapacity: 150, display: true + # AmmoType: PulseLaserChargeMSWeapon, display: true + # ReloadDelay: 1, display: true + # ROF: 0.6, type: float, display: true, formatter: ROF + + UnlockCost: 27, display: true + UnlockLevel: 25, display: true + TechTreeParent: WeaponMS01 + TechTreeNames: Capital Vessel +} +{ +Block Id: 648, Name: TurretGVMinigun #TurretPlayer HV Minigun + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + # Model: @models/Blocks/Turrets/RealisticTurret/TurretGVMinigunPrefab + Model: @models/Blocks/Turrets/TurretGVMinigunV2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + WeaponItem: TurretGVMinigunWeapon + TemplateRoot: TurretGVMinigunBlocks + SoundOnEnter: UseActions/DOOROpen02 + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + AllowPlacingAt: GV, display: true + SizeInBlocks: "3,1,3", display: true + Volume: 281.25, type: float, display: true, formatter: Liter + Mass: 500, type: float, display: true, formatter: Kilogram + StackSize: 500 + # MaxCount: 6, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 15, type: int, display: true, formatter: Watt + CPUIn: 900, type: int, display: true + HitPoints: 550, type: int, display: true + RotSpeed: 140, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 649, Name: TurretGVRocket #TurretPlayer HV Rocket + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + # Model: @models/Blocks/Turrets/RealisticTurret/TurretGVRocketPrefab + Model: @models/Blocks/Turrets/TurretGVRocketV2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + WeaponItem: TurretGVRocketWeapon + TemplateRoot: TurretGVRocketBlocks + SoundOnEnter: UseActions/DOOROpen02 + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + AllowPlacingAt: GV, display: true + SizeInBlocks: "3,1,3", display: true + Volume: 281.25, type: float, display: true, formatter: Liter + Mass: 520, type: float, display: true, formatter: Kilogram + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 15, type: int, display: true, formatter: Watt + CPUIn: 450, type: int, display: true + HitPoints: 550, type: int, display: true + + RotSpeed: 150, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 650, Name: TurretGVPlasma #TurretPlayer HV Plasma + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + # Model: @models/Blocks/Turrets/RealisticTurret/TurretGVPlasmaPrefab + Model: @models/Blocks/Turrets/TurretGVPlasmaV2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + WeaponItem: TurretGVPlasmaWeapon + TemplateRoot: TurretGVPlasmaBlocks + SoundOnEnter: UseActions/DOOROpen02 + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + SizeInBlocks: "3,1,3", display: true + AllowPlacingAt: GV, display: true + Volume: 281.25, type: float, display: true, formatter: Liter + Mass: 1050, type: float, display: true, formatter: Kilogram + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 1560, type: int, display: true + HitPoints: 550, type: int, display: true + + RotSpeed: 120, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 651, Name: BunkBed, Ref: DecoTemplate + Class: Sleeping + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + Model: @models2/Entities/Decorations/Misc/BunkBedPrefab + SizeInBlocks: "1,1,1", display: true + BuffNamesActivate: "IncreaseBodyTemp", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst +} +{ +Block Id: 652, Name: LightWork02 + IndexName: Light + Material: metallight + Shape: ModelEntity + MarketPrice: 43, display: true + Model: @models/Blocks/Terrain/LightWork02Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTerrainDecoration: true + Place: Free + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + AllowPlacingAt: Terrain, display: true + Volume: 4, type: float, display: true, formatter: Liter + Mass: 17, type: float, display: true, formatter: Kilogram + StackSize: 1000 + EnergyIn: 1, type: int, display: true, formatter: Watt + Info: bkiLightWork, display: true + Category: Devices + + UnlockCost: 5, display: true + UnlockLevel: 7, display: true + TechTreeParent: LightWork + TechTreeNames: Tools +} +{ +Block Id: 653, Name: Flare + Class: Light + # Removed so that flare does not count to light count. will not switch of with F key anymore + # IndexName: Light + Group: cpgFlare + Material: metallight + Shape: ModelEntity + Place: Face + Collide: "bullet,rocket" + Model: @models/Blocks/Spaceship/flareSS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "Base,MS,SS,GV", display: true + ModelScaleLocked: true + Volume: 0.1, type: float, display: true, formatter: Liter + Mass: 0.1, type: float, display: true, formatter: Kilogram + StackSize: 4000 + HitPoints: 5, type: int, display: true + IsActivateable: true + IsDuplicateable: false + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + CanSetColor: true + StabilitySupport: false + + UnlockCost: 5, display: true + UnlockLevel: 5, display: true + TechTreeNames: Misc +} +# Obsolete - old building blocks +{ Block Id: 654, Name: AlienInteriorGreen01 + Material: alien + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 36 + Shape: New + Model: Cube + AllowPlacingAt: "Base,MS", display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ShowUser: No +} +{ Block Id: 655, Name: AlienInteriorGreenWedge01, Ref: AlienInteriorGreen01 + Model: RampC + ShowUser: No +} +{ Block Id: 656, Name: AlienInteriorGreen02 + Material: alien + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 38 + Shape: New + Model: Cube + AllowPlacingAt: "Base,MS", display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ShowUser: No +} +# old constructor model +{ Block Id: 657, Name: ConstructorRecursive + AllowedInBlueprint: false, display: true # to avoid exploits + Class: ConstructorRecursive + IndexName: Constructor + Group: cpgConstructor + ShowUser: No + TemplateRoot: ConstructorT2 + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # DropScale: 0.5 + Model: @models/Blocks/Mothership/ConstructorRecursiveMSPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiConstructorAdvanced, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,2", display: true + SizeInBlocksLocked: "Base,MS" + # ModelOffset: "0,0.5,0" + StackSize: 1 + Category: Devices + Mass: 60000, type: float, display: true, formatter: Kilogram + HitPoints: 250, type: int, display: true + EnergyIn: 300, type: int, display: true, formatter: Watt + CPUIn: 40000, type: int, display: true + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 +} + +{ +Block Id: 658, Name: EntitySpawner1 + Class: Spawner + Group: cpgNPCSpawner + IndexName: EntitySpawner + AllowedInBlueprint: false, display: true + Shape: Config3DCollider + Material: metallight + Model: @models/Blocks/Misc/EntitySpawnerPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: DecoBlocks + PickupTarget: SteelPlate + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 50, type: float, display: true, formatter: Liter + Mass: 200, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsAccessible: true, type: bool + BlockColor: "110,110,110" + HitPoints: 50, type: int, display: true + EnergyIn: 1, type: int, display: false, formatter: Watt + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 10 + Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + SpawnClass: ZiraxMinigun + SpawnCount: 2 + Info: bkiEntitySpawner, display: true + AllowWander: true + RemoveOnSI: true # avoid to show the dummy block on SI collapse +} + +# obsolete - do not remove +{ Block Id: 659, Name: EntitySpawner2, Ref: EntitySpawner1 + SpawnClass: Crawler + SpawnCount: 3 + Info: Crawler, display: true + BlockColor: "170,5,5" + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 660, Name: EntitySpawner3, Ref: EntitySpawner1 + SpawnClass: TurretRobot + SpawnCount: 3 + Info: TurretRobot, display: true + BlockColor: "0,40,0" + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 661, Name: EntitySpawner4, Ref: EntitySpawner1 + SpawnClass: TalonGuardian + SpawnCount: 3 + Info: TalonGuardian, display: true + BlockColor: "39,34,178" + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 662, Name: EntitySpawner5, Ref: EntitySpawner1 + SpawnClass: ArmoredGolem + SpawnCount: 1 + Info: ArmoredGolem, display: true + BlockColor: "255,0,150" + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 663, Name: EntitySpawner6, Ref: EntitySpawner1 + SpawnClass: TotalHorror + SpawnCount: 3 + Info: Nightmare, display: true + BlockColor: "0,255,255" + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 664, Name: EntitySpawner7, Ref: EntitySpawner1 + SpawnClass: "AlienAssassinGrey, AlienAssassinRed" + SpawnCount: 3 + Info: AlienAssassin, display: true + BlockColor: "62,30,10" + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 665, Name: EntitySpawner8, Ref: EntitySpawner1 + SpawnClass: AssaultCyborg + SpawnCount: 3 + Info: AssaultCyborg, display: true + BlockColor: "255,247,0" + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 666, Name: EntitySpawnerPlate, Ref: EntitySpawner1 + Texture: 51 + BlockColor: "170,5,5" + Model: @models/Blocks/Misc/EntitySpawnerPlatePrefab + Collide: "movement,bullet,rocket,melee" # consider this block as collide with movements + IsOxygenTight: true, display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 667, Name: EntitySpawnerBlock, Ref: EntitySpawner1 + Texture: 51 + BlockColor: "170,5,5" + Model: @models/Blocks/Misc/EntitySpawnerBlockPrefab + Collide: "movement,bullet,rocket,melee" # consider this block as collide with movements + Place: NoRotation, display: true # disabled rotation + IsOxygenTight: true, display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + ShowUser: No +} +{ +Block Id: 668, Name: EntitySpawnerPlateThin, Ref: EntitySpawner1 + Texture: 51 + BlockColor: "170,5,5" + Model: @models/Blocks/Misc/EntitySpawnerPlateThinPrefab + Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + SpawnClass: ZiraxMinigun + SpawnCount: 1 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + AllowWander: true + RemoveOnSI: true # avoid to show the dummy block on SI collapse + AllowPlacingAt: "Base,MS", display: true +} +{ +Block Id: 669, Name: SawAttachment + LootList: 37 # still used? + IndexName: WeaponShip + Group: cpgHarvest + Material: metalhard + MarketPrice: 1520, display: true + Shape: ModelEntity + ShowBlockName: true + WeaponItem: SawAttachmentWeapon + Model: @models/Blocks/WeaponsShip/SawAttachmentPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: WeaponDir + SizeInBlocks: "5,2,1", display: true + AllowPlacingAt: GV, display: true + Volume: 35, type: float, display: true, formatter: Liter + Mass: 220, type: float, display: true, formatter: Kilogram + Info: bkiHarvester, display: true + Category: Weapons/Items + EnergyDynamicGroup: Drill + EnergyIn: 70, type: int, display: true, formatter: Watt + EnergyInIdle: 1, type: int, display: true, formatter: Watt + HitPoints: 150, type: int, display: true + CPUIn: 180, type: int, display: true + StackSize: 1000 + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + # TechTreeParent: ContainerHarvest + TechTreeNames: Hover Vessel +} + +# obsolete but do not remove +{ +Block Id: 670, Name: CockpitSV03, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/_Legacy/CockpitSS03Prefab + SizeInBlocks: "3,3,5", display: true + # Volume: 4500, type: float, display: true, formatter: Liter + HitPoints: 900, type: int, display: true + Mass: 845, type: float, display: true, formatter: Kilogram + + ShowUser: No +} +# obsolete but do not remove +{ +Block Id: 671, Name: CockpitSV07, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/_Legacy/CockpitSS07Prefab + Mass: 780, type: float, display: true, formatter: Kilogram + HitPoints: 875, type: int, display: true + SizeInBlocks: "2,2,9", display: true + # Volume: 3600, type: float, display: true, formatter: Liter + + ShowUser: No +} +{ +Block Id: 672, Name: StairsWedge + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/Stairs1x1x1, param1: Stair1x1x1 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + TemplateRoot: StairsBlocks + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1" # this is needed so SizeInBlocksLocked takes effect! + SizeInBlocksLocked: "Base,MS" + # Volume: 50, type: float, display: true, formatter: Liter + Mass: 550, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + Category: BuildingBlocks + AllowWander: true +} +{ +Block Id: 673, Name: StairsWedgeLong + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/Stairs1x2x1, param1: Stair1x2x1 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + TemplateRoot: StairsBlocks + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,2", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 100, type: float, display: true, formatter: Liter + Mass: 850, type: float, display: true, formatter: Kilogram + HitPoints: 150, type: int, display: true + Category: BuildingBlocks + AllowWander: true +} +# obsolete - do not remove +{ Block Id: 674, Name: WalkwayVert + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/WalkwayVert, param1: walkway + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: WalkwayLargeBlocks + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + # SizeInBlocks: "1,1,1", display: true + # SizeInBlocksLocked: "Base,MS" + Mass: 15, type: float, display: true, formatter: Kilogram + ShowUser: No + Category: BuildingBlocks +} +# obsolete - do not remove +{ Block Id: 675, Name: WalkwayVert02 + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/WalkwayVert02, param1: walkway + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: WalkwayLargeBlocks + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + # SizeInBlocks: "1,1,1", display: true + # SizeInBlocksLocked: "Base,MS" + Mass: 15, type: float, display: true, formatter: Kilogram + ShowUser: No + Category: BuildingBlocks +} +# obsolete - do not remove +{ +Block Id: 676, Name: WalkwaySlope + Material: metallight + Shape: Ext3dModel + Mesh: models + Model: Mothership/WalkwaySlope, param1: walkway_slope + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + TemplateRoot: WalkwayLargeBlocks + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + # SizeInBlocks: "1,1,1", display: true + # SizeInBlocksLocked: "Base,MS" + Mass: 48, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + AllowWander: true +} +# obsolete - do not remove +{ Block Id: 677, Name: InteriorWall, Ref: Hull + Model: Wall + Texture: "53,53,31,53,31,53" +} +# obsolete - do not remove +{ Block Id: 678, Name: InteriorWallCorner, Ref: Hull + Model: WallLShape + Texture: "53,53,31,53,31,31" +} +# obsolete - do not remove +{ Block Id: 679, Name: HullWall, Ref: Hull + Model: Wall +} +# obsolete - do not remove +{ Block Id: 680, Name: HullWallCorner, Ref: Hull + Model: WallLShape +} +{ +Block Id: 681, Name: RailingL, Ref: RailingDiagonal + Model: Mothership/RailingL, param1: RailingL + SymType: 2 +} +{ +Block Id: 682, Name: RailingRound, Ref: RailingDiagonal + Model: Mothership/RailingRound, param1: RailingRound + SymType: 2 +} +{ +Block Id: 683, Name: DrillAttachment + LootList: 37 # still used? + IndexName: WeaponShip + Group: cpgHarvest + Material: metalhard + MarketPrice: 1655, display: true + Shape: ModelEntity + ShowBlockName: true + WeaponItem: DrillAttachmentWeapon + Model: @models/Blocks/WeaponsShip/DrillAttachmentRetractPrefab + # Model: @models/Blocks/WeaponsShip/DrillAttachmentPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + BlockColor: "110,110,110" + Place: WeaponDir + SizeInBlocks: "1,6,1", display: true + AllowPlacingAt: "GV", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 650, type: float, display: true, formatter: Kilogram + Info: bkiDrill, display: true + Category: Weapons/Items + EnergyDynamicGroup: Drill + EnergyIn: 250, type: int, display: true, formatter: Watt + CPUIn: 200, type: int, display: true + EnergyInIdle: 1, type: int, display: true, formatter: Watt + HitPoints: 500, type: int, display: true + StackSize: 1000 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 9, display: true + UnlockLevel: 7, display: true + TechTreeParent: SawAttachment + TechTreeNames: Hover Vessel +} +{ +Block Id: 684, Name: TurretGVDrill, Ref: TurretDrillTemplate #TurretPlayer HV Drill + Model: @models/Blocks/Turrets/TurretGVDrillPrefab + # Model: @models/Blocks/Turrets/TurretGVDrillPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + WeaponItem: TurretGVDrillWeapon + TemplateRoot: TurretGVToolBlocks + AllowPlacingAt: GV, display: true + SizeInBlocks: "3,1,3", display: true + Volume: 281.25, type: float, display: true, formatter: Liter + Mass: 790, type: float, display: true, formatter: Kilogram + StackSize: 500 + EnergyIn: 688, type: int, display: true, formatter: Watt + EnergyInIdle: 14, type: int, display: true, formatter: Watt + HitPoints: 850, type: int, display: true +} + +{ +Block Id: 685, Name: TurretMSDrill, Ref: TurretDrillTemplate #TurretPlayer CV Drill + Model: @models/Blocks/Turrets/TurretMSDrillPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + TemplateRoot: TurretMSToolBlocks + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "MS" + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 9050, type: float, display: true, formatter: Kilogram + StackSize: 250 + TechTreeNames: Hidden + + ShowUser: No +} + +{ +Block Id: 686, Name: ContainerPersonal + Class: ContainerPersonal + AllowedInBlueprint: false, display: true + Group: cpgCargoBox + LootList: 66 + Material: metal + Shape: ModelEntity + ShowBlockName: true + PickupTarget: ContainerLargeBlocks + TemplateRoot: ContainerLargeBlocks + Model: @models/Blocks/Mothership/ContainerPersonalPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 400, type: float, display: true, formatter: Liter + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + CPUIn: 960, type: int, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + # Info: bkiContainer, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 +} + +# not needed anymore +{ Block Id: 687, Name: MarkerBlock3, Ref: MarkerBlock + IndexName: Marker3 + Info: bkiAlienMarker3Block, display: true + BlockColor: "102,0,0" + MaxCount: 1, type: int, display: true + ShowUser: NoButCSV +} + +{ +Block Id: 688, Name: CockpitSV07New, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/CockpitSS07NewPrefab + SizeInBlocks: "3,2,6", display: true + # Volume: 3600, type: float, display: true, formatter: Liter + Mass: 850, type: float, display: true, formatter: Kilogram + HitPoints: 875, type: int, display: true + UpgradeTo: CockpitSV07NewT2, display: true +} +{ +Block Id: 689, Name: CockpitSV05New, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/CockpitSS05NewPrefab + SizeInBlocks: "3,2,4", display: true + # Volume: 2400, type: float, display: true, formatter: Liter + HitPoints: 550, type: int, display: true + Mass: 550, type: float, display: true, formatter: Kilogram + UpgradeTo: CockpitSV05NewT2, display: true +} +{ +Block Id: 690, Name: CockpitSV02New, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/CockpitSS02NewPrefab + HitPoints: 900, type: int, display: true + Mass: 875, type: float, display: true, formatter: Kilogram + UpgradeTo: CockpitSV02NewT2, display: true +} +{ +Block Id: 691, Name: RailingSlopeLeft, Ref: RailingDiagonal + Model: Mothership/RailingSlopeLeft, param1: RailingSlopeLeft + SymType: 4 + MirrorTo: RailingSlopeRight +} +{ +Block Id: 692, Name: RailingSlopeRight, Ref: RailingDiagonal + Model: Mothership/RailingSlopeRight, param1: RailingSlopeRight + SymType: 4 + MirrorTo: RailingSlopeLeft +} +# obsolete - do not remove +{ Block Id: 693, Name: HullCornerLarge, Ref: Hull + Model: CornerC + Texture: "51,50,52,52,51,51" +} +{ +Block Id: 694, Name: ThrusterJetRound3x7x3 + Class: Thruster + IndexName: Thruster + Group: cpgThruster + MarketPrice: 3255, display: true + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + StackSize: 500 + Model: @models/Blocks/Thrusters/ThrusterJetRound3x7x3Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: "SS", display: true + SizeInBlocks: "3,7,3", display: true + Volume: 1968.75, type: float, display: true, formatter: Liter + Mass: 2430, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + Info: bkiThrusterJetMS, display: true + HitPoints: 3200, type: int, display: true + ThrusterForce: 7240, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.5, type: float, display: true + EnergyIn: 360, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 4680, type: int, display: true + Category: Devices + OccupySizeInBlocks: true # do not allow to place blocks in the middle + + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 100 + + UnlockCost: 15, display: true + UnlockLevel: 15, display: true + TechTreeParent: ThrusterJetRound2x5x2V2 + TechTreeNames: Small Vessel + + Temperature: 855, display: true +} +{ +Block Id: 695, Name: ThrusterJetRound3x10x3, Ref: ThrusterJetRound3x7x3 + Model: @models/Blocks/Thrusters/ThrusterJetRound3x10x3Prefab + MarketPrice: 4349, display: true + SizeInBlocks: "3,10,3", display: true + Volume: 2812.5, type: float, display: true, formatter: Liter + Mass: 3060, type: float, display: true, formatter: Kilogram + HitPoints: 4500, type: int, display: true + ThrusterForce: 11040, type: int, display: true, formatter: Newton + EnergyIn: 425, type: int, display: true, formatter: Watt + CPUIn: 6750, type: int, display: true + + UnlockCost: 15, display: true + UnlockLevel: 17, display: true + TechTreeParent: ThrusterJetRound3x7x3 + TechTreeNames: Small Vessel + + Temperature: 920, display: true +} +{ +Block Id: 696, Name: ThrusterJetRound3x13x3, Ref: ThrusterJetRound3x7x3 + Model: @models/Blocks/Thrusters/ThrusterJetRound3x13x3Prefab + MarketPrice: 5444, display: true + SizeInBlocks: "3,13,3", display: true + Volume: 3656.25, type: float, display: true, formatter: Liter + Mass: 4833, type: float, display: true, formatter: Kilogram + HitPoints: 6000, type: int, display: true + ThrusterForce: 15290, type: int, display: true, formatter: Newton + EnergyIn: 500, type: int, display: true, formatter: Watt + CPUIn: 8870, type: int, display: true + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: ThrusterJetRound3x10x3 + TechTreeNames: Small Vessel + + Temperature: 995, display: true +} +{ +Block Id: 697, Name: ThrusterJetRound1x3x1, Ref: ThrusterJetRound3x7x3 + Model: @models/Blocks/Thrusters/ThrusterJetRound1x3x1Prefab + MarketPrice: 1525, display: true + SizeInBlocks: "1,3,1", display: true + Volume: 93.75, type: float, display: true, formatter: Liter + Mass: 743, type: float, display: true, formatter: Kilogram + HitPoints: 225, type: int, display: true + ThrusterForce: 300, type: int, display: true, formatter: Newton + EnergyIn: 52, type: int, display: true, formatter: Watt + CPUIn: 220, type: int, display: true + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + # TechTreeParent: ThrusterSVRoundBlocks + TechTreeParent: "" + TechTreeNames: Small Vessel + + Temperature: 450, display: true +} +{ +Block Id: 698, Name: ThrusterJetRound2x5x2, Ref: ThrusterJetRound3x7x3 + Model: @models/Blocks/Thrusters/ThrusterJetRound2x5x2Prefab + MarketPrice: 2389, display: true + SizeInBlocks: "2,5,2", display: true + Volume: 625, type: float, display: true, formatter: Liter + Mass: 1240, type: float, display: true, formatter: Kilogram + HitPoints: 850, type: int, display: true + ThrusterForce: 2075, type: int, display: true, formatter: Newton + EnergyIn: 216, type: int, display: true, formatter: Watt + CPUIn: 1470, type: int, display: true + + UnlockCost: 14, display: true + UnlockLevel: 12, display: true + TechTreeParent: ThrusterJetRound1x3x1 + TechTreeNames: Small Vessel + + Temperature: 660, display: true +} + +{ Block Id: 699, Name: CameraBlock #REPOSITORY + AllowedInBlueprint: false, display: true + Class: Light + IndexName: Camera + Group: cpgLight + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/CameraBlockPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + # ModelOffset: "0,0.5,0" + StackSize: 6 + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "MS" + IsActivateable: true + Info: bkiSpotlight, display: true + Category: Devices + Mass: 50, type: float, display: true, formatter: Kilogram + EnergyIn: 2, type: int, display: true, formatter: Watt + ShowUser: No +} + +# Flak Turret +{ +Block Id: 700, Name: TurretBaseFlak #TurretPlayer BA Flak + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSFlakPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretBaseRocketBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateVeryFast + WeaponItem: TurretBaseFlakWeapon + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base" + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 5615, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 1300, type: int, display: true + HitPoints: 3450, type: int, display: true + + RotSpeed: 110, type: int, display: true + Zoom: 5, type: float +} +# Plasma Turret +{ +Block Id: 701, Name: TurretBasePlasma #TurretPlayer BA Plasma + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + # Model: @models/Blocks/Turrets/ModularTurret1/SingleGaussTurretBluePrefab + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretBaseLaserBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + WeaponItem: TurretBasePlasmaWeapon + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 8490, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 22300, type: int, display: true + HitPoints: 3450, type: int, display: true + + RotSpeed: 80, type: int, display: true + Zoom: 5, type: float +} +# Plasma Turret +{ +Block Id: 702, Name: TurretMSPlasma #TurretPlayer BA Plasma + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretMSLaserBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + WeaponItem: TurretMSPlasmaWeapon + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 8490, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurretSpace, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 15, type: int, display: true, formatter: Watt + CPUIn: 20700, type: int, display: true + HitPoints: 3450, type: int, display: true + + RotSpeed: 80, type: int, display: true + Zoom: 5, type: float + + # UnlockCost: 15, display: true + # UnlockLevel: 15, display: true + # TechTreeParent: TurretMSMinigun + # TechTreeNames: Capital Vessel + TechTreeNames: Hidden + + ShowUser: No +} +# Obsolete but dont delete +{ Block Id: 703, Name: ArmorPlate, Ref: Hull + Model: Plate + Texture: "15,15,57,57,57,57" + HitPoints: 50, type: int, display: true +} + +{ Block Id: 704, Name: TrussWedge, Ref: TrussCube + Model: Truss/TrussWedge + Texture: "59,59,59,60,59,60" +} + +{ Block Id: 705, Name: TrussCorner, Ref: TrussCube + Model: Truss/TrussCorner + Texture: 60 +} + +# Produces only hydrogen now +{ +Block Id: 706, Name: OxygenHydrogenGenerator, Ref: OxygenGenerator + Class: O2H2Generator + Model: @models/Blocks/Terrain/OxygenHydrogenGeneratorPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + Info: bkiOxygenHydrogenGenerator, display: true + BlockColor: "45,90,45" + + # UnlockCost: 10, display: true + # UnlockLevel: 7, display: true + # TechTreeParent: OxygenGenerator + # TechTreeNames: Misc + + MapIcon: map_OxyGenerator2 + MapName: OxygenHydrogenGenerator + + Temperature: 30, display: true + + ShowUser: No +} +{ Block Id: 707, Name: InsanityPepperStage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: NaturalStimulant + Count: 2 + } + { Child CropsGrown + OnHarvest: InsanityPepperStageNoFruit + OnDeath: PlantDead2 + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Misc/InsanityPepperStage3Prefab + PickupTarget: InsanityPepperStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ShowUser: NoButCSV # reported by HWS + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + Category: Farming + XpFactor: 0.25 +} +{ Block Id: 708, Name: InsanityPepperStageNoFruit + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: InsanityPepperStage3 + GrowthRate: 29 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Model: @models2/Entities/Farming/Misc/InsanityPepperStageNoFruitPrefab + PickupTarget: InsanityPepperStage1 + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: No + Place: Free + # ModelOffset: "0,0.5,0" +} +{ Block Id: 709, Name: AlienPlantTube1Stage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: PlantProtein + Count: 5 + } + { Child CropsGrown + OnHarvest: AlienPlantTube2NoFruitStage + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Misc/AlienPlantTube1Stage3Prefab + PickupTarget: AlienPlantTube2Stage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ShowUser: NoButCSV # reported by HWS + Place: Free + # ModelOffset: "0,0.5,0" + Category: Farming + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + XpFactor: 0.25 +} +{ Block Id: 710, Name: AlienPlantTube2NoFruitStage + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: AlienPlantTube1Stage3 + GrowthRate: 85 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + Collide: "bullet,rocket,melee,sight" + IsAccessible: false, type: bool + Model: @models2/Entities/Farming/Misc/AlienPlantTube2NoFruitStagePrefab + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + PickupTarget: AlienPlantTube2Stage1 + Place: Free + # ModelOffset: "0,0.5,0" + { Child DropOnDestroy + Count: 0 + } + ShowUser: NoButCSV +} +# Current SurvConstructor - with recursive capabilities: currently used +{ +Block Id: 711, Name: ConstructorSurvival + Class: ConstructorSurvivalV2 + MarketPrice: 100, display: true + Material: metalhard + Shape: ModelEntity + Model: @models/Blocks/Devices/PortableConstructorFWNPrefab #1.11 update + #Model: @models/Blocks/Terrain/ConstructorSurvivalPrefab + #Mesh-Damage-1: Mesh/D0 #PortableConstructorPrefab does not have damage states + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + Info: bkiConstructorSurvival, display: true + IsTerrainDecoration: true + EnergyIn: 10, type: int, display: false, formatter: Watt + AllowPlacingAt: Terrain, display: true + SizeInBlocks: "1,1,1" #no need to display info for terrain placeables (removed to reduce hover info density) + Mass: 40, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + HitPoints: 150, type: int, display: true + StackSize: 1000 + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + BlockColor: "110,110,110" + Category: Devices + IsColorable: false + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + ShowUser: Yes + # UpgradeTo: ConstructorSurvivalV2, display: true + + MapIcon: map_SurvConstructor + MapName: ConstructorSurvival + + UnlockCost: 1, display: true + UnlockLevel: 1, display: true + # TechTreeParent: OxygenGeneratorSmall + TechTreeNames: Misc +} +{ +Block Id: 712, Name: PassengerSeatSV + Class: PassengerSeat + Group: cpgPassengerSeat + Material: metalhard + MarketPrice: 200, display: true + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + Model: @models/Blocks/Spaceship/PassengerSeatSVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "2,3,2", display: true + Volume: 375, type: float, display: true, formatter: Liter + Mass: 705, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsLockable: true + IsOxygenTight: true, display: true + Info: bkiPassengerSeat, display: true + StackSize: 1000 + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + HitPoints: 250, type: int, display: true + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: PassengerSeat2SV + TechTreeNames: "Small Vessel,Hover Vessel" +} +# Old T1 constructor - no advanced capabilities: not in game anymore +{ Block Id: 713, Name: ConstructorT1 # obsolete + Class: ConstructorBig + IndexName: Constructor + Group: cpgConstructor + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/ConstructorT1Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,2,2", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiConstructorLarge, display: true + TemplateRoot: ConstructorT1V2 + # ModelOffset: "0,0.5,0" + StackSize: 10 + BlockColor: "110,110,110" + Category: Devices + Mass: 40000, type: float, display: true, formatter: Kilogram + HitPoints: 250, type: int, display: true + EnergyIn: 300, type: int, display: true, formatter: Watt + CPUIn: 20000, type: int, display: true + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UpgradeTo: ConstructorT2, display: true + ShowUser: No +} +## Please do not rename - referenced in code +{ +Block Id: 714, Name: ConstructorT2 + Class: ConstructorRecursive + IndexName: Constructor + Group: cpgConstructor + MarketPrice: 62387, display: true + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Devices/ConstructorT2FWNPrefab #1.11 update + #Model: @models/Blocks/Mothership/ConstructorT2Prefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,2,2", display: true + Volume: 1280, type: float, display: true, formatter: Liter + Mass: 13800, type: float, display: true, formatter: Kilogram + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiConstructorAdvanced, display: true + # ModelOffset: "0,0.5,0" + StackSize: 500 + BlockColor: "110,110,110" + Category: Devices + HitPoints: 250, type: int, display: true + EnergyIn: 1500, type: int, display: true, formatter: Watt + CPUIn: 40000, type: int, display: true + EnergyInIdle: 5, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: ConstructorT1V2 + TechTreeNames: "Base,Capital Vessel" + DowngradeTo: ConstructorT1V2, display: true +} +# open passenger seat +{ +Block Id: 715, Name: PassengerSeat2SV + Class: PassengerSeat + Group: cpgPassengerSeat + Material: metalhard + MarketPrice: 155, display: true + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/PassengerSeat2SVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,3,2", display: true + Volume: 80, type: float, display: true, formatter: Liter + Mass: 72, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiPassengerSeat, display: true + StackSize: 2000 + BlockColor: "110,110,110" + Category: Devices + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + HitPoints: 150, type: int, display: true + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + TechTreeParent: CockpitBlocksSV + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 716, Name: TurretGVArtillery #TurretPlayer HV Artillery + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretGVArtilleryPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + WeaponItem: TurretGVArtilleryWeapon + TemplateRoot: TurretGVArtilleryBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateSlow + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + SizeInBlocks: "5,2,5", display: true + AllowPlacingAt: GV, display: true + Volume: 1562.5, type: float, display: true, formatter: Liter + Mass: 4350, type: float, display: true, formatter: Kilogram + StackSize: 500 + IsDuplicateable: false + # MaxCount: 1, type: int, display: true + + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 5820, type: int, display: true + HitPoints: 935, type: int, display: true + + RotSpeed: 60, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 717, Name: OxygenTankSmallMS + Class: O2Tank + IndexName: O2Tank + Group: cpgO2Tank + MarketPrice: 210, display: true + EssentialCategory: cpgO2Tank + LootList: 25 # 5 slots + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/OxygenTankSmallPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 80, type: float, display: true, formatter: Liter + Mass: 550, type: float, display: true, formatter: Kilogram + Info: bkiOxygenTank, display: true + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + StackSize: 1000 + Category: Devices + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 100, type: int, display: true + O2Capacity: 1000, type: int, display: true + O2Accept: "OxygenBottleLarge,OxygenBottleSmall,RespiratorCharge" + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Base,Capital Vessel" +} +{ Block Id: 718, Name: AlienPalmTree + Class: CropsGrown + { Child DropOnHarvest + Item: Fruit + Count: 1 + } + { Child CropsGrown + OnHarvest: AlienPalmTreeHarvested + OnDeath: PlantDead + } + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienPalmTreePrefab + # DecoParticle: alienpalmtree # removed particle effect since it triggers when harvested + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 719, Name: AlienPalmTreeHarvested + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPalmTree + GrowthRate: 25 + FertileLevel: 1 + } + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienPalmTreeHarvestedPrefab + # DecoParticle: alienpalmtree # removed particle effect since it triggers when harvested + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ +Block Id: 720, Name: WarpDrive + Class: WarpDrive + Group: cpgWarp + IndexName: WarpDrive + Material: metal + Shape: ModelEntity + MarketPrice: 83216, display: true + ShowBlockName: true + Model: @models/Blocks/Mothership/WarpDriveT1Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,2,5", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 4800, type: float, display: true, formatter: Liter + Mass: 82200, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: false, display: true + # ModelOffset: "0,0.5,0" + # BlockColor: "26,50,89" + StackSize: 250 + BlockColor: "110,110,110" + Category: Devices + UpgradeTo: WarpDriveT2, display: true + Info: bkiWarpDrive, display: true + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 40000, type: int, display: true + HitPoints: 1150, type: int, display: true + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 200 + + ItemPerAU: PentaxidCrystal, display: true + CostPerAU: 2, display: true + CostPerLY: 1, display: true + RangeAU: 24, display: true + RangeLY: 30, display: true + + # Short Range Warp (Microwarp) + CostPerKM: 2, display: true + RangeKM: 20000, display: true + #RangeKMuseDetector: true, display: true + MicroWarpCooldown: 25, dipslay: true # INT in real time seconds; Set to 0 or comment out to have SRW without a cooldown + + Radiation: 25, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 40, display: true + + UnlockCost: 15, display: true + UnlockLevel: 10, display: true + # TechTreeParent: PentaxidTank + TechTreeNames: Capital Vessel +} +{ +Block Id: 721, Name: OxygenStationSV + Class: O2Station + Group: cpgO2Station + Material: metallight + MarketPrice: 220, display: true + Shape: ModelEntity + # BuffName: OxygenStationSS DONE DIFFERENT NOW + ShowBlockName: true + SfxActivate: UseActions/oxygen_tank_release_air + Model: @models/Blocks/Spaceship/OxygenStationSSNewPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsLockable: true + IsOxygenTight: true, display: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,2,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 70, type: float, display: true, formatter: Kilogram + Info: bkiOxygenStation, display: true + # ModelOffset: "0,0.5,0" + StackSize: 2000 + BlockColor: "110,110,110" + HitPoints: 150, type: int, display: true + Category: Devices + + EnergyIn: 2, type: int, display: true, formatter: Watt + CPUIn: 50, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + UnlockCost: 3, display: true + UnlockLevel: 3, display: true + TechTreeParent: OxygenTankSV + TechTreeNames: "Small Vessel,Hover Vessel" +} +# flat docking pad for SVs +{ +Block Id: 722, Name: LandinggearShort + IndexName: LandingGear + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + IsPhysicsColliders: false + Model: @models/Blocks/Landinggears/LandinggearLightShortPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + TemplateRoot: LandinggearBlocksSV + Place: Free + AllowPlacingAt: "SS", display: true + SizeInBlocks: "1,1,2", display: true + Mass: 200, type: float, display: true, formatter: Kilogram + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Devices + # ModelOffset: "0,0.5,0" + Hardness: 50, type: float, display: false, formatter: Hardness + HitPoints: 50, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt +} +# retractable landinggear +{ +Block Id: 723, Name: LandinggearMSLight, Ref: LandinggearMSHeavy + Model: @models/Blocks/Landinggears/LandinggearLightRetractPrefab + # Model: @models/Blocks/Landinggears/LandinggearLightPrefab + SizeInBlocks: "1,1,2", display: true + HitPoints: 350, type: int, display: true + Mass: 1700, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 724, Name: ContainerAmmoLarge + Class: Container + Group: cpgAmmo + IndexName: AmmoCntr + MarketPrice: 1367, display: true + LootList: 70 + Material: metallight + Shape: ModelEntity + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: true, display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 + ShowBlockName: true + Model: @models/Blocks/Mothership/ContainerAmmoPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + BlockColor: "0,40,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,1,1", display: true + SizeInBlocksLocked: "Base,MS" + EnergyIn: 1, type: int, display: true, formatter: Watt + VolumeCapacity: 14000, type: float, display: true, formatter: Liter + CPUIn: 240, type: int, display: true + Volume: 180, type: float, display: true, formatter: Liter + Mass: 1975, type: float, display: true, formatter: Kilogram + Info: bkiContainerAmmo, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + Category: Devices + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 500 + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + TechTreeParent: ContainerLargeBlocks + TechTreeNames: "Base,Capital Vessel" +} +{ Block Id: 725, Name: AkuaBushRed + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaBushRedPrefab + DecoParticle: akuabushred + SizeInBlocks: "1,3,1" + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "1,2" + Prob: 1 + } +} +{ Block Id: 726, Name: AlienBushRedFronds + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienBushRedFrondsPrefab + DecoParticle: alienbushredfronds + SizeInBlocks: "2,5,2" + ShowUser: No + HitPoints: 155, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4,8" + Prob: 1 + } +} +{ +Block Id: 727, Name: ConsoleLargeMS01a, Ref: ConsoleLargeMS01 + Class: NPCDialogue + NPCModelRotation: false + Info: bkiConsoleLargeMS01a, display: true + Group: cpgEquipment + Model: @models/Blocks/Mothership/ConsoleLargeMS01aPrefab + IsAccessible: true, type: bool + SizeInBlocks: "1,1,1", display: true + Volume: 500, type: float, display: true, formatter: Liter +} +# obsolete - but do not remove +{ +Block Id: 728, Name: ContainerAmmoSmall, Ref: ContainerAmmoLarge + Model: @models/Blocks/Mothership/ContainerAmmoSmallPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true # we need this because it extends ammo large + VolumeCapacity: 100, type: float, display: true, formatter: Liter + Volume: 20, type: float, display: true, formatter: Liter # low value since used on SV/HV as well + Mass: 100, type: float, display: true, formatter: Kilogram + LootList: 69 + ShowUser: NoButCSV + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + # UnlockCost: 4, display: true + # UnlockLevel: 3, display: true + # TechTreeNames: Misc +} +# Obsolete but dont delete, same as LandinggearMSLight +{ Block Id: 729, Name: LandinggearMSShort + Material: metalhard + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Landinggears/LandinggearLightPrefab + # Model: @models/Blocks/Landinggears/LandinggearLightShortPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + BlockColor: "110,110,110" + Place: Free + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,2", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 800, type: float, display: true, formatter: Liter + Mass: 1700, type: float, display: true, formatter: Kilogram + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Devices + ShowUser: No + Hardness: 50, type: float, display: false, formatter: Hardness + HitPoints: 250, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt +} + + +{ +Block Id: 730, Name: DockingPad, Ref: LandinggearShort + Info: bkiLandinggearHV, display: true + AllowPlacingAt: "GV", display: true + MarketPrice: 160, display: true + TemplateRoot: DockingPad + Mass: 122, type: float, display: true, formatter: Kilogram + Volume: 32.5, type: float, display: true, formatter: Liter + StackSize: 1000 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Hover Vessel" +} + +{ Block Id: 731, Name: AlaskaCedarBare + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlaskaCedarBarePrefab + DecoParticle: baldcypressfall + SizeInBlocks: "1,5,1" + ShowUser: No + HitPoints: 278, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "8,14" + Prob: 1 + } +} + +# obsolete but do not remove +{ +Block Id: 732, Name: ContainerHarvest + Class: Container + Group: cpgHarvest + IndexName: HarvestCntr + LootList: 48 # large: lootcontainer: Common + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/ContainerHarvestPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + BlockColor: "62,30,10" + Info: bkiContainerHarvest, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + AllowPlacingAt: "MS,GV", display: true + SizeInBlocks: "2,1,1", display: true + VolumeCapacity: 250, type: float, display: true, formatter: Liter # low value since used on MS and GV + CPUIn: 30, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + Volume: 40, type: float, display: true, formatter: Liter # low value since used on HV as well + Mass: 200, type: float, display: true, formatter: Kilogram + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: true, display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 + ShowUser: NoButCSV + + # UnlockCost: 3, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: Misc + +} + +{ Block Id: 733, Name: HollywoodJuniperDesktop, Ref: AlaskaCedarBare + Model: @models2/Environment/Plants/SpeedTrees/HollywoodJuniperPrefab + DecoParticle: hollywoodjuniper + SizeInBlocks: "1,3,1" + Material: woodsoft + HitPoints: 70, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 734, Name: HollywoodJuniperDesktopBare, Ref: AlaskaCedarBare + Model: @models2/Environment/Plants/SpeedTrees/HollywoodJuniperBarePrefab + DecoParticle: hollywoodjuniperbare + SizeInBlocks: "1,3,1" + Material: woodsoft + HitPoints: 69, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 735, Name: RealRock1 + Material: rock + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/RealRockVol1/RealRock1Prefab + SizeInBlocks: "5,4,5" + BlockColor: "180,180,180" + ShowUser: No + HitPoints: 183, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "28,37" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 736, Name: RealRock2, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock2Prefab + SizeInBlocks: "7,7,7" + HitPoints: 398, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "60,80" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "6,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,6" + # Prob: 0.05 + # } +} +{ Block Id: 737, Name: RealRock3, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock3Prefab + SizeInBlocks: "7,7,7" + HitPoints: 283, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "43,57" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "6,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,6" + # Prob: 0.05 + # } +} +{ Block Id: 738, Name: RealRock4, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock4Prefab + SizeInBlocks: "3,3,3" + HitPoints: 103, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "15,21" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "4,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "3,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,4" + # Prob: 0.05 + # } +} +{ Block Id: 739, Name: RealRock5, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock5Prefab + SizeInBlocks: "5,3,3" + HitPoints: 166, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "25,33" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 740, Name: RealRock6, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock6Prefab + SizeInBlocks: "5,3,5" + HitPoints: 152, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "23,30" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 741, Name: RealRock7, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock7Prefab + SizeInBlocks: "5,3,5" + HitPoints: 183, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "27,37" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 742, Name: RealRock8, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock8Prefab + SizeInBlocks: "13,10,13" + HitPoints: 510, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "77,102" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "33,56" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "22,44" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "11,33" + # Prob: 0.05 + # } +} +{ Block Id: 743, Name: RealRock9, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock9Prefab + SizeInBlocks: "13,5,5" + HitPoints: 550, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "83,110" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "5,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,5" + # Prob: 0.05 + # } +} +{ Block Id: 744, Name: RealRock10, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock10Prefab + SizeInBlocks: "5,3,3" + HitPoints: 195, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,39" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 745, Name: CoralBig01 + Material: rock + Class: CropsGrown + { Child DropOnHarvest + Item: AlienParts01 + Count: 1 + } + { Child CropsGrown + OnHarvest: CoralBig01Harvested + OnDeath: PlantDead + } + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/UnderwaterPlants/CoralBig01Prefab + ShowUser: No +} + +{ Block Id: 746, Name: CoralBig08 + Material: rock + Class: CropsGrown + { Child DropOnHarvest + Item: AlienParts03 + Count: 1 + } + { Child CropsGrown + OnHarvest: CoralBig08Harvested + OnDeath: PlantDead + } + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/UnderwaterPlants/CoralBig08Prefab + ShowUser: No +} +{ Block Id: 747, Name: CoralStone03-05 + Material: rock + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/UnderwaterPlants/CoralStone03-05Prefab + ShowUser: No +} +{ Block Id: 748, Name: CoralStone03-06, Ref: CoralStone03-05 + Model: @models2/Environment/Plants/UnderwaterPlants/CoralStone03-06Prefab +} +# Used on land +{ Block Id: 749, Name: CoralStone04-03, Ref: CoralStone03-05 + Model: @models2/Environment/Plants/UnderwaterPlants/CoralStone04-03Prefab + SizeInBlocks: "3,4,3" +} +{ Block Id: 750, Name: SeaWeed02-04, Ref: CoralStone03-05 + Material: plants + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein + Count: 1 + } + { Child CropsGrown + OnHarvest: SeaWeed02-04Harvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "2" + Prob: 1 + } + Model: @models2/Environment/Plants/UnderwaterPlants/SeaWeed02-04Prefab +} +{ Block Id: 751, Name: FingerSponge03-01, Ref: CoralStone03-05 + Model: @models2/Environment/Plants/UnderwaterPlants/FingerSponge03-01Prefab +} +{ Block Id: 752, Name: MoundzTree + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/MoundzTreePrefab + SizeInBlocks: "14,10,14" + ShowUser: No + HitPoints: 124, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4,6" + Prob: 1 + } +} +{ Block Id: 753, Name: PulpitFungus + Class: CropsGrown + { Child DropOnHarvest + Item: NaturalStimulant + Count: 1 + } + { Child CropsGrown + OnHarvest: PulpitFungusHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/PulpitFungusPrefab + SizeInBlocks: "2,1,1" + ShowUser: No + { Child DropOnDestroy + Count: 0 + } +} +{ Block Id: 754, Name: SucculentTree + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/SucculentTreePrefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 755, Name: AntlerTree + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/AntlerTreePrefab + ShowUser: No +} +{ Block Id: 756, Name: GrinShroom + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FractalFlora/GrinShroomPrefab + ShowUser: No +} +{ Block Id: 757, Name: RealRock2Snow + Material: rock + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/RealRockVol1/RealRock2SnowPrefab + SizeInBlocks: "7,7,7" + BlockColor: "180,180,180" + ShowUser: No + HitPoints: 398, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "60,80" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "6,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,6" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "4,6" + # Prob: 0.2 + # } +} +{ Block Id: 758, Name: RealRock3Snow, Ref: RealRock2Snow + Model: @models2/Environment/Rocks/RealRockVol1/RealRock3SnowPrefab + SizeInBlocks: "7,7,7" + HitPoints: 283, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "43,57" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "6,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,6" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "4,6" + # Prob: 0.2 + # } +} +{ Block Id: 759, Name: RealRock6Snow, Ref: RealRock2Snow + Model: @models2/Environment/Rocks/RealRockVol1/RealRock6SnowPrefab + SizeInBlocks: "5,3,5" + HitPoints: 152, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "23,30" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "2,3" + # Prob: 0.2 + # } +} +{ Block Id: 760, Name: RealRock8Snow, Ref: RealRock2Snow + Model: @models2/Environment/Rocks/RealRockVol1/RealRock8SnowPrefab + SizeInBlocks: "13,10,13" + HitPoints: 510, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "77,102" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "33,56" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "22,44" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "11,33" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "22,33" + # Prob: 0.2 + # } +} +{ Block Id: 761, Name: RealRock10Snow, Ref: RealRock2Snow + Model: @models2/Environment/Rocks/RealRockVol1/RealRock10SnowPrefab + SizeInBlocks: "5,3,3" + HitPoints: 195, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,39" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "2,3" + # Prob: 0.2 + # } +} +{ Block Id: 762, Name: RealRock1Snow, Ref: RealRock2Snow + Model: @models2/Environment/Rocks/RealRockVol1/RealRock1SnowPrefab + SizeInBlocks: "5,4,5" + HitPoints: 183, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "28,37" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,2" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "2,3" + # Prob: 0.2 + # } +} +{ Block Id: 763, Name: FantasyPlant1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FantasyPlants/FantasyPlant1Prefab + ShowUser: No +} +{ Block Id: 764, Name: FantasyPlant2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/FantasyPlants/FantasyPlant2Prefab + ShowUser: No +} + +{ Block Id: 765, Name: RealRock2Dark, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock2DarkPrefab + SizeInBlocks: "7,7,7" + HitPoints: 398, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "60,80" + Prob: 1 + } +} +{ Block Id: 766, Name: RealRock4Dark, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock4DarkPrefab + SizeInBlocks: "3,3,3" + HitPoints: 103, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "15,21" + Prob: 1 + } +} +{ Block Id: 767, Name: RealRock10Dark, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock10DarkPrefab + SizeInBlocks: "5,3,3" + HitPoints: 195, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,39" + Prob: 1 + } +} + +{ +Block Id: 768, Name: ThrusterGVRoundSlant, Ref: ThrusterGVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundSlantPrefab +} + +# Artillery Turret +{ +Block Id: 769, Name: TurretMSArtillery, Ref: TurretMSArtilleryRetract + Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + TemplateRoot: TurretMSArtilleryBlocks + IsOxygenTight: false, display: true + Place: Face + IsRetractable: false + SizeInBlocks: "5,2,5", display: true + Volume: 7700, type: float, display: true, formatter: Liter + HitPoints: 4250, type: int, display: true + Mass: 19800, type: float, display: true, formatter: Kilogram + CPUIn: 21600, type: int, display: true + TechTreeNames: Hidden + + ShowUser: No +} +{ +Block Id: 770, Name: Window_v1x1 + Material: hull + Shape: ModelEntity + Model: @models/Blocks/Windows/Standard/Window_v1x1Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiWindowNoShutterSemiTrans, display: true + IsAccessible: false, type: bool + Category: BuildingBlocks + BlockColor: "110,110,110" + # SizeInBlocks: "1,1,1" + # ModelOffset: "0,0.5,0" + # RemoveOnSI: true + IsOxygenTight: true, display: true + # IsGPUInstance: true + UpgradeTo: Window_v1x1Thick, display: true + ParentBlocks: "WindowSmallBlocks, WindowLargeBlocks" + Collide: Transparent + ShowUser: No + + # Properties given in parent block + HitPoints: 25, type: int, display: false + Mass: 10, type: float, display: true, formatter: Kilogram + + # Cannot remove - BP cannot be spawned anymore + AllowPlacingAt: "Base,MS,SS,GV", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" +} +{ +Block Id: 771, Name: Window_s1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_s1x1Prefab + UpgradeTo: Window_s1x1Thick, display: true +} +{ +Block Id: 772, Name: ThrusterMSRoundBlocks + IndexName: Thruster + MarketPrice: 10982, display: true + Material: metalhard + Shape: Invisible + Place: Free + # Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundArmoredPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + StackSize: 500 + Category: Devices + ChildBlocks: "ThrusterMSRoundNormal, ThrusterMSRoundSlant, ThrusterMSRoundArmored" + + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 1216, type: float, display: true, formatter: Liter + Mass: 4407, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + HitPoints: 933, type: int, display: true + ThrusterForce: 16726, type: int, display: true, formatter: Newton + EnergyIn: 797, type: int, display: true, formatter: Watt + CPUIn: 9659, type: int, display: true + Temperature: 807, display: true + Radiation: 7, display: RadiationLevel + + UnlockCost: 10, display: true + UnlockLevel: 10, display: true + TechTreeParent: ThrusterMSDirectional + TechTreeNames: Capital Vessel +} +{ Block Id: 773, Name: ScifiMine1 + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Misc/ScifiMine1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No + Place: Free + AllowPlacingAt: Terrain, display: true + IsTerrainDecoration: true + Mass: 5, type: float, display: true, formatter: Kilogram + Hardness: 1, type: float, display: false, formatter: Hardness + HitPoints: 1, type: int, display: true + Category: Weapons/Items + # ModelOffset: "0,0.5,0" + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 1 +} + +{ Block Id: 774, Name: CrystalFormation + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Rocks/Crystals/CrystalFormationPrefab + ShowUser: No +} +{ Block Id: 775, Name: CrystalTall + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Rocks/Crystals/CrystalTallPrefab + ShowUser: No +} +{ Block Id: 776, Name: CoralStone01-03 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/UnderwaterPlants/CoralStone01-03Prefab + SizeInBlocks: "3,4,3" + ShowUser: No +} +{ Block Id: 777, Name: CoralStone01-05 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/UnderwaterPlants/CoralStone01-05Prefab + SizeInBlocks: "3,3,3" + ShowUser: No +} + +{ +Block Id: 778, Name: ThrusterMSRound2x2Blocks + IndexName: Thruster + Material: metalhard + Shape: Invisible + Place: Free + MarketPrice: 55400, display: true + # Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundNormal2x2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + StackSize: 250 + BlockColor: "26,50,89" + Category: Devices + ChildBlocks: "ThrusterMSRoundNormal2x2, ThrusterMSRoundSlant2x2, ThrusterMSRoundArmored2x2" + + AllowPlacingAt: MS, display: true + SizeInBlocks: "2,4,2", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 4192, type: float, display: true, formatter: Liter + Mass: 22530, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + HitPoints: 5675, type: int, display: true + ThrusterForce: 209673, type: int, display: true, formatter: Newton + EnergyIn: 4464, type: int, display: true, formatter: Watt + CPUIn: 69644, type: int, display: true + Temperature: 962, display: true + Radiation: 12, display: RadiationLevel + + UnlockCost: 17, display: true + UnlockLevel: 15, display: true + TechTreeParent: ThrusterMSRoundBlocks + TechTreeNames: Capital Vessel +} + +# retractable landinggear +{ +Block Id: 779, Name: LandinggearSingle + Class: LandingGear # for retractable landinggears + IndexName: LandingGear + Group: cpgLandingGear + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + IsPhysicsColliders: false + Model: @models/Blocks/Landinggears/LandinggearSingleRetractPrefab + # Model: @models/Blocks/Landinggears/LandinggearSinglePrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + TemplateRoot: LandinggearBlocksSV + Place: Free + Info: bkiLandinggearSV, display: true + AllowPlacingAt: SS, display: true + SizeInBlocks: "1,2,2", display: true + Mass: 150, type: float, display: true, formatter: Kilogram + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + IsRetractable: true, display: true # for retractable landinggears + Category: Devices + Hardness: 50, type: float, display: false, formatter: Hardness + HitPoints: 120, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt +} +# retractable landinggear +{ +Block Id: 780, Name: LandinggearDouble, Ref: LandinggearSingle + Model: @models/Blocks/Landinggears/LandinggearDoubleRetractPrefab + # Model: @models/Blocks/Landinggears/LandinggearDoublePrefab + IsRetractable: true, display: true + SizeInBlocks: "1,2,4", display: true + Mass: 240, type: float, display: true, formatter: Kilogram + HitPoints: 175, type: int, display: false + TemplateRoot: LandinggearBlocksHeavySV +} +{ +Block Id: 781, Name: CloneChamber + IndexName: SpawnPoint + Group: cpgMedic + Material: metal + Shape: ModelEntity + MarketPrice: 700, display: true + ShowBlockName: true + Model: @models/Blocks/Mothership/CloneChamberSmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 320, type: float, display: true, formatter: Liter + Mass: 1065, type: float, display: true, formatter: Kilogram + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiCloneChamber, display: true + # BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + BlockColor: "110,110,110" + Category: Devices + + EnergyIn: 10, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 782, Name: RCSBlockSVLight + IndexName: RCS + CustomIcon: RCSBlockSV + Group: cpgRCS + Material: metal + Shape: ModelEntity + ShowBlockName: true + MarketPrice: 805, display: true + Model: @models/Blocks/Mothership/RCS_BlockPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "SS,GV", display: true + AllowedInBlueprint: false, display: true + SizeInBlocks: "1,1,1", display: true + Mass: 256, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiGyroscope, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + Torque: 50, type: int, display: true, formatter: NewtonMeter + EnergyDynamicGroup: RCS + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 450, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 25 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# free available 783 - 794 + +{ +Block Id: 795, Name: Window_v1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_v1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_v1x1ThickInv, display: true +} +{ +Block Id: 796, Name: Window_v1x2, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_v1x2Prefab + SizeInBlocks: "1,2,1", display: true + UpgradeTo: Window_v1x2Thick, display: true +} +{ +Block Id: 797, Name: Window_v1x2Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_v1x2InvPrefab + ShowUser: NoButCSV + SizeInBlocks: "1,2,1", display: true + UpgradeTo: Window_v1x2ThickInv, display: true +} +{ +Block Id: 798, Name: Window_v2x2, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_v2x2Prefab + SizeInBlocks: "2,2,1", display: true + UpgradeTo: Window_v2x2Thick, display: true +} +{ +Block Id: 799, Name: Window_v2x2Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_v2x2InvPrefab + ShowUser: NoButCSV + SizeInBlocks: "2,2,1", display: true + UpgradeTo: Window_v2x2ThickInv, display: true +} +{ +Block Id: 800, Name: Window_s1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_s1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_s1x1ThickInv, display: true +} +{ +Block Id: 801, Name: Window_s1x2, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_s1x2Prefab + SizeInBlocks: "1,1,2", display: true + UpgradeTo: Window_s1x2Thick, display: true +} +{ +Block Id: 802, Name: Window_s1x2Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_s1x2InvPrefab + ShowUser: NoButCSV + SizeInBlocks: "1,1,2", display: true + UpgradeTo: Window_s1x2ThickInv, display: true +} +{ +Block Id: 803, Name: Window_sd1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_sd1x1Prefab + SymType: 3 + UpgradeTo: Window_sd1x1Thick, display: true +} +{ +Block Id: 804, Name: Window_sd1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_sd1x1InvPrefab + ShowUser: NoButCSV + SymType: 3 + UpgradeTo: Window_sd1x1ThickInv, display: true +} +{ +Block Id: 805, Name: Window_sd1x2, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_sd1x2Prefab + SizeInBlocks: "1,1,2", display: true + MirrorTo: Window_sd1x2V2Inv + UpgradeTo: Window_sd1x2Thick, display: true +} +{ +Block Id: 806, Name: Window_sd1x2Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_sd1x2InvPrefab + ShowUser: NoButCSV + SizeInBlocks: "1,1,2", display: true + MirrorTo: Window_sd1x2V2Inv + UpgradeTo: Window_sd1x2ThickInv, display: true +} +{ +Block Id: 807, Name: Window_c1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_c1x1Prefab + SymType: 2 + UpgradeTo: Window_c1x1Thick, display: true +} +{ +Block Id: 808, Name: Window_c1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_c1x1InvPrefab + ShowUser: NoButCSV + SymType: 2 + UpgradeTo: Window_c1x1ThickInv, display: true +} +{ +Block Id: 809, Name: Window_c1x2, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_c1x2Prefab + SymType: 2 + SizeInBlocks: "1,2,1", display: true + UpgradeTo: Window_c1x2Thick, display: true +} +{ +Block Id: 810, Name: Window_c1x2Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_c1x2InvPrefab + ShowUser: NoButCSV + SymType: 2 + SizeInBlocks: "1,2,1", display: true + UpgradeTo: Window_c1x2ThickInv, display: true +} +{ +Block Id: 811, Name: Window_cr1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_cr1x1Prefab + UpgradeTo: Window_cr1x1Thick, display: true +} +{ +Block Id: 812, Name: Window_cr1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_cr1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_cr1x1ThickInv, display: true +} +{ +Block Id: 813, Name: Window_crc1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crc1x1Prefab + SymType: 2 + UpgradeTo: Window_crc1x1Thick, display: true +} +{ +Block Id: 814, Name: Window_crc1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crc1x1InvPrefab + ShowUser: NoButCSV + SymType: 2 + UpgradeTo: Window_crc1x1ThickInv, display: true +} +{ +Block Id: 815, Name: Window_crsd1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crsd1x1Prefab + SymType: 3 + UpgradeTo: Window_crsd1x1Thick, display: true +} +{ +Block Id: 816, Name: Window_crsd1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crsd1x1InvPrefab + ShowUser: NoButCSV + SymType: 3 + UpgradeTo: Window_crsd1x1ThickInv, display: true +} +{ +Block Id: 817, Name: Window_sd1x2V2, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_sd1x2V2Prefab + SizeInBlocks: "1,1,2", display: true + MirrorTo: Window_sd1x2Inv + UpgradeTo: Window_sd1x2V2Thick, display: true +} +{ +Block Id: 818, Name: Window_sd1x2V2Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_sd1x2V2InvPrefab + ShowUser: NoButCSV + SizeInBlocks: "1,1,2", display: true + UpgradeTo: Window_sd1x2V2ThickInv, display: true +} +{ +Block Id: 819, Name: RampTemplate + Class: Door + IndexName: Door + Group: cpgRamp + Material: hullarmored + Shape: ModelEntity + Model: @models/Blocks/Mothership/Ramps/Ramp3x1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + # TemplateRoot: RampBlocks + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocksLocked: "Base,MS" + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: false, display: true # true: not possible with force field + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + Mass: 200, type: float, display: true, formatter: Kilogram + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + # PlayerTriggersOpen: true + ShowUser: No + DoorClosesOnPower: false + IsPhysicsColliders: false # ramps are too thin and also might pose problems when SV/HV are on the ramp while physics colliders are on + OccupySizeInBlocks: true # do not allow to place blocks inside of bounding box +} + +# obsolete - do not remove +{ Block Id: 820, Name: HullRampTop, Ref: Hull + Model: RampA + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 821, Name: HullRampBottom, Ref: Hull + Model: RampCMedium + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 822, Name: HullInvertedCornerRound, Ref: Hull + Model: CornerRoundADouble + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 823, Name: HullQuarterRoundThin, Ref: Hull + Model: WallSlopedRound + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 824, Name: HullWedgeThin, Ref: Hull + Model: WallSloped + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 825, Name: HullCornerLongA, Ref: Hull + Model: CutCornerB + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 826, Name: HullCornerLongB, Ref: Hull + Model: SlicedCornerA1 + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 827, Name: HullCornerLongC, Ref: Hull + Model: CornerHalfB + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 828, Name: HullCornerLongD, Ref: Hull + Model: CornerSmallC + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 829, Name: HullQuarterRoundInv, Ref: Hull + Model: RampRoundF + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 830, Name: HullCurveOutSlopeInv, Ref: Hull + Model: SmallCornerRoundB + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 831, Name: HullHalfRoundInv, Ref: Hull + Model: RampRoundFTriple + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 832, Name: HullCornerThin, Ref: Hull + Model: WallSloped3Corner + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 833, Name: HullCornerRoundThin, Ref: Hull + Model: WallCornerRoundB + ShowUser: NoButCSV # reported by HWS +} +# obsolete - do not remove +{ Block Id: 834, Name: HullCylinder, Ref: Hull + Model: Cylinder + ShowUser: NoButCSV # reported by HWS +} + +{ +Block Id: 835, Name: ThrusterMSRound3x3Blocks + IndexName: Thruster + Material: metalhard + Shape: Invisible + Place: Free + MarketPrice: 146106, display: true + # Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterCVRoundNormal3x3Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + StackSize: 250 + BlockColor: "102,0,0" + Category: Devices + ChildBlocks: "ThrusterMSRoundNormal3x3, ThrusterMSRoundSlant3x3, ThrusterMSRoundArmored3x3" + + AllowPlacingAt: MS, display: true + SizeInBlocksLocked: "MS" + SizeInBlocks: "3,6,3", display: true + Volume: 8640, type: float, display: true, formatter: Liter # more than max capacity + Mass: 62897, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + HitPoints: 16750, type: int, display: true + ThrusterForce: 920287, type: int, display: true, formatter: Newton + EnergyIn: 12442, type: int, display: true, formatter: Watt + CPUIn: 221179, type: int, display: true + Temperature: 1080, display: true + Radiation: 15, display: RadiationLevel + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: ThrusterMSRound2x2Blocks + TechTreeNames: Capital Vessel +} + +{ +Block Id: 836, Name: WindowSmallBlocks + Material: hull + Shape: Invisible + MarketPrice: 50, display: true + # Model: @models/Blocks/Windows/Standard/Window_v1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiWindows, display: true + IsOxygenTight: true, display: true + Category: BuildingBlocks + ChildBlocks: "Window_v1x1, Window_v1x2, Window_v2x2, Window_s1x1, Window_s1x2, Window_sd1x1, Window_sd1x2, Window_sd1x2V2, Window_c1x1, Window_c1x2, Window_cr1x1, Window_crc1x1, Window_crsd1x1, Window_L1x1, Window_3side1x1, Window_crctw1x1, Window_creA1x1, Window_creB1x1, Window_crl1x1, Window_crse1x1, Window_cc1x1" + Collide: Transparent + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + UpgradeTo: WindowArmoredSmallBlocks, display: true + + # Parent properties + TemplateRoot: WindowSmallBlocks + HitPoints: 25, type: int, display: true + AllowPlacingAt: "SS,GV", display: true + Mass: 24, type: float, display: true, formatter: Kilogram + Volume: 6.25, type: float, display: true, formatter: Liter +} + +{ +Block Id: 837, Name: TrussSmallBlocks + Material: metallight + MarketPrice: 10, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Shape: Invisible + Place: Free + # Model: Truss/TrussCube + Mesh: cutout + Texture: 59 + Info: bkiBlockGroup, display: true + IsOxygenTight: false, display: true + Category: BuildingBlocks + IsTextureable: false + ChildBlocks: "TrussCube, TrussCorner, TrussWedge, TrussCurveOutSlope, TrussHalfRound, TrussQuarterRound, TrussCylinder, TrussQuarterRoundInv, TrussWall, TrussWedgeThin, TrussQuarterRoundThin, TrussCornerThin, TrussCornerRoundThin, TrussCornerRoundThin2" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + # Parent properties + TemplateRoot: TrussSmallBlocks + AllowPlacingAt: "SS,GV", display: true + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + StackSize: 2000 + HitPoints: 25, type: int, display: true +} +# obsolete but do not remove +{ +Block Id: 838, Name: WalkwayBlocks + Material: metallight + MarketPrice: 180, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Shape: Invisible + Place: Free + Mesh: cutout + # Model: Walkway + Texture: 61 + IsAccessible: false, type: bool + Info: bkiBlockGroup, display: true + IsOxygenTight: false, display: true + Category: BuildingBlocks + IsTextureable: false + AllowPlacingAt: "Base,MS,SS,GV", display: true + Mass: 12, type: float, display: true, formatter: Kilogram + StackSize: 2000 + + ChildBlocks: "WalkwayVertNew, WalkwaySlopeNew, WalkwaySlope, RailingVert, RailingDiagonal, RailingSlopeLeft, RailingSlopeRight, RailingL, RailingRound, RailingVertGlass, RailingDiagonalGlass, RailingSlopeGlassLeft, RailingSlopeGlassRight, RailingLGlass, RailingRoundGlass" + + # UnlockCost: 0 + # UnlockLevel: 1 + # TechTreeNames: "" + ShowUser: NoButCSV +} +{ +Block Id: 839, Name: StairsBlocks + Material: metallight + Shape: Invisible + Mesh: models + # Model: Mothership/Stairs1x1x1, param1: StairBlock1x1x1 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Info: bkiStairs, display: true + AllowPlacingAt: "Base,MS", display: true + Mass: 585, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + HideShapesWindow: true + Category: BuildingBlocks + ChildBlocks: "StairsWedge, StairsWedgeLong, StairsMS, StairShapesLong, StairShapes" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ Block Id: 840, Name: ChineseBanyan + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/ChineseBanyanPrefab + DecoParticle: chinesebanyan + ShowUser: No + SizeInBlocks: "2,7,2" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,7" + Prob: 1 + } +} +{ Block Id: 841, Name: BaldCypresFall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/BaldCypressFallPrefab + DecoParticle: baldcypressfall + ShowUser: No + SizeInBlocks: "3,7,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 842, Name: BaldCypres + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/BaldCypressPrefab + DecoParticle: baldcypress + ShowUser: No + SizeInBlocks: "3,7,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 843, Name: OmicronPalm + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/OmicronPalmPrefab + DecoParticle: omicronpalm + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 844, Name: OmicronPlant + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/OmicronPlantPrefab + DecoParticle: omicronplant + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,2" + Prob: 1 + } +} + +# free available 845 - 851 + +{ +Block Id: 852, Name: DrillAttachmentSVT2 + LootList: 37 # still used? + IndexName: WeaponShip + Group: cpgHarvest + MarketPrice: 26273, display: true + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + CustomIcon: DrillAttachmentT2 + WeaponItem: DrillAttachmentSVT2Weapon + Model: @models/Blocks/WeaponsShip/DrillAttachmentT2RetractPrefab + # Model: @models/Blocks/WeaponsShip/DrillAttachmentPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + BlockColor: "110,110,110" + Place: WeaponDir + SizeInBlocks: "1,6,1", display: true + AllowPlacingAt: "SS", display: true + Volume: 82.5, type: float, display: true, formatter: Liter + Mass: 850, type: float, display: true, formatter: Kilogram + Info: bkiDrill, display: true + Category: Weapons/Items + EnergyDynamicGroup: Drill + EnergyIn: 400, type: int, display: true, formatter: Watt + CPUIn: 380, type: int, display: true + EnergyInIdle: 1, type: int, display: true, formatter: Watt + HitPoints: 800, type: int, display: true + StackSize: 1000 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 80 + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: ContainerHarvestControllerSmall + TechTreeNames: Small Vessel +} + +{ Block Id: 853, Name: RockResourcePlatinum + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/Resources/RockResourcePlatinPrefab + SizeInBlocks: "2,1,2" + BlockColor: "180,180,180" + # ShowBlockName: true + HitPoints: 45, type: int, display: true + ShowUser: No + { Child DropOnDestroy_1 + Item: PlatinOre + Count: "1,1" + Prob: 1 + } +} +{ Block Id: 854, Name: RockResourceAluminium + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/Resources/RockResourceAluminiumPrefab + SizeInBlocks: "2,1,2" + BlockColor: "180,180,180" + # ShowBlockName: true + HitPoints: 50, type: int, display: true + ShowUser: No + { Child DropOnDestroy_1 + Item: AluminiumOre + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 855, Name: Butte1 + Material: rockhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + CanDecorateOnSlopes: false + # SmallDecorationRadius: 2 + # BigDecorationRadius: 2 + Model: @models2/Environment/Rocks/ButteRocks/Butte1Prefab + # CanTerrainDecoXZRotate: true + SizeInBlocks: "14,10,14" + HitPoints: 1782, type: int, display: false + ShowUser: No + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "267,356" + Prob: 1 + } +} +{ Block Id: 856, Name: Butte1a, Ref: Butte1 + Model: @models2/Environment/Rocks/ButteRocks/Butte1aPrefab + HitPoints: 1782, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "267,356" + Prob: 1 + } +} +{ Block Id: 857, Name: Butte2, Ref: Butte1 + Model: @models2/Environment/Rocks/ButteRocks/Butte2Prefab + HitPoints: 1102, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "165,220" + Prob: 1 + } +} +{ Block Id: 858, Name: Butte2a, Ref: Butte1 + Model: @models2/Environment/Rocks/ButteRocks/Butte2aPrefab + HitPoints: 1102, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "165,220" + Prob: 1 + } +} + +# obsolete - do not remove +{ Block Id: 859, Name: HullWallSloped, Ref: HullRampTop + Model: WallSlopedC +} +# obsolete - do not remove +{ Block Id: 860, Name: HullWallSlopedBottom, Ref: HullRampTop + Model: WallSlopedCMediumright +} +# obsolete - do not remove +{ Block Id: 861, Name: HullWallSlopedTop, Ref: HullRampTop + Model: WallSlopedAright +} +# obsolete - do not remove +{ Block Id: 862, Name: HullWallSlopedBottom2, Ref: HullRampTop + Model: WallSlopedCMediumleft +} +# obsolete - do not remove +{ Block Id: 863, Name: HullWallSlopedTop2, Ref: HullRampTop + Model: WallSlopedAleft +} +# up to here + +{ Block Id: 864, Name: AkuaBush1 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaBush1Prefab + DecoParticle: akuabush1 + ShowUser: No + SizeInBlocks: "1,2,1" + { Child DropOnDestroy + Item: Fiber + Count: "3,5" + Prob: 1 + } +} + +# BLOCKED > 865, 866 used by DF Scenario (Symelite Blocks) + +{ Block Id: 867, Name: OmicronWaterleaf + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/OmicronWaterleafPrefab + DecoParticle: omicronwaterleaf + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,2" + Prob: 1 + } +} +{ Block Id: 868, Name: DesertRockMedium1 + Material: rockhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + CanDecorateOnSlopes: false + Model: @models2/Environment/Rocks/DesertRocks/DesertRockMedium1Prefab + # CanTerrainDecoXZRotate: true + SizeInBlocks: "12,14,12" # reality: "12,25,12" + HitPoints: 1648, type: int, display: false + ShowUser: No + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "247,330" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "77,128" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "51,103" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "26,77" + # Prob: 0.05 + # } +} +{ Block Id: 869, Name: DesertRockMedium2, Ref: DesertRockMedium1 + Model: @models2/Environment/Rocks/DesertRocks/DesertRockMedium2Prefab + SizeInBlocks: "14,14,14" # reality: "14,30,14" + HitPoints: 2571, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "386,514" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "130,217" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "87,174" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "43,130" + # Prob: 0.05 + # } +} +{ Block Id: 870, Name: DesertRockLarge, Ref: DesertRockMedium1 + Model: @models2/Environment/Rocks/DesertRocks/DesertRockLargePrefab + SizeInBlocks: "14,14,14" # reality: "14,40,14" + HitPoints: 3328, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "499,666" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "176,294" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "118,235" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "59,176" + # Prob: 0.05 + # } +} +{ Block Id: 871, Name: BlueSpruceWinter + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/BlueSpruceWinterPrefab + DecoParticle: bluesprucewinter + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,7" + Prob: 1 + } +} + +# available free 872 873 874 + +{ Block Id: 875, Name: AlienTreeBlue + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienTreeBluePrefab + DecoParticle: alientreeblue + ShowUser: No + SizeInBlocks: "1,7,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "4,6" + Prob: 1 + } +} +{ Block Id: 876, Name: BushWinter01 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/BushWinter01SpeedTreePrefab + # Model: @models2/Environment/Plants/Bushes/BushWinter/BushWinter01Prefab + ShowUser: No + SizeInBlocks: "1,2,1" + { Child DropOnDestroy + Item: Fiber + Count: "2,4" + Prob: 1 + } +} +# delete +{ Block Id: 877, Name: BushWinter02, Ref: BushWinter01 + Model: @models2/Environment/Plants/SpeedTrees/BushWinter01SpeedTreePrefab + # Model: @models2/Environment/Plants/Bushes/BushWinter/BushWinter02Prefab +} +# delete +{ Block Id: 878, Name: BushWinter03, Ref: BushWinter01 + Model: @models2/Environment/Plants/SpeedTrees/BushWinter01SpeedTreePrefab + # Model: @models2/Environment/Plants/Bushes/BushWinter/BushWinter03Prefab +} +{ Block Id: 879, Name: AlienTreeBlue02 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienTreeBlue02Prefab + DecoParticle: alientreeblue02 + ShowUser: No + SizeInBlocks: "3,5,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "4,9" + Prob: 1 + } +} +{ Block Id: 880, Name: AlienBush01 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienBush01Prefab + DecoParticle: alienbush01 + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: Fiber + Count: "1,2" + Prob: 1 + } +} +# obsolete - do not remove +{ Block Id: 881, Name: HullInvertedCornerRoundThin, Ref: HullRampTop + Model: WallEdgeRound +} +{ Block Id: 882, Name: BushGreen01 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/BushGreen01SpeedTreePrefab + # Model: @models2/Environment/Plants/Bushes/BushesGreen/BushGreen01Prefab + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: Fiber + Count: "3,5" + Prob: 1 + } +} +# delete +{ Block Id: 883, Name: BushGreen02, Ref: BushGreen01 + Model: @models2/Environment/Plants/SpeedTrees/BushGreen01SpeedTreePrefab + # Model: @models2/Environment/Plants/Bushes/BushesGreen/BushGreen02Prefab +} +{ +Block Id: 884, Name: WalkwayVertNew + Material: metallight + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Shape: New + Place: Free + Mesh: cutout + Model: Walkway + TemplateRoot: WalkwayLargeBlocks + Texture: 61 + BlockColor: "110,110,110" + IsAccessible: false, type: bool + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + # SizeInBlocksLocked: "Base,MS" + # Volume: 20, type: float, display: true, formatter: Liter + Mass: 20, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + # IsColorable: false + IsTextureable: false + IsOxygenTight: false, display: true + ParentBlocks: "WalkwaySmallBlocks, WalkwayLargeBlocks" +} +{ +Block Id: 885, Name: WalkwaySlopeNew, Ref: WalkwayVertNew + Model: WalkwaySlope +} +{ Block Id: 886, Name: MushroomBellBrown01 + Class: CropsGrown + { Child DropOnHarvest + Item: MushroomBrown + Count: 1 + } + { Child CropsGrown + OnHarvest: MushroomBellBrown01Harvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/MushroomForest/MushroomBellBrown01Prefab + ShowUser: No +} +{ Block Id: 887, Name: MushroomBellGreen01 + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/MushroomForest/MushroomBellGreen01Prefab + ShowUser: No + # SizeInBlocks: "1,1,1" +} +{ Block Id: 888, Name: MushroomTallBlue02, Ref: MushroomBellGreen01 + Model: @models2/Environment/Plants/MushroomForest/MushroomTallBlue02Prefab + ShowUser: No + # SizeInBlocks: "1,1,1" +} +{ Block Id: 889, Name: MushroomTallGreen01, Ref: MushroomBellBrown01 + Class: CropsGrown + { Child DropOnHarvest + Item: MushroomBrown + Count: 1 + } + { Child CropsGrown + OnHarvest: MushroomTallGreen01Harvested + OnDeath: PlantDead + } + Model: @models2/Environment/Plants/MushroomForest/MushroomTallGreen01Prefab + ShowUser: No + # SizeInBlocks: "1,1,1" +} +{ Block Id: 890, Name: DeadTree01 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/DeadTrees/DeadTree01Prefab + ShowUser: No + SizeInBlocks: "2,5,2" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 891, Name: DeadTree02a, Ref: DeadTree01 + Model: @models2/Environment/Plants/DeadTrees/DeadTree02aPrefab + SizeInBlocks: "1,3,1" + ShowUser: No +} +{ Block Id: 892, Name: DeadTree02b, Ref: DeadTree01 + Model: @models2/Environment/Plants/DeadTrees/DeadTree02bPrefab + SizeInBlocks: "2,5,2" + ShowUser: No +} +{ Block Id: 893, Name: DeadTree02c, Ref: DeadTree01 + Model: @models2/Environment/Plants/DeadTrees/DeadTree02cPrefab + SizeInBlocks: "1,3,1" + ShowUser: No +} +{ Block Id: 894, Name: CrystalStraight + Class: CropsGrown + { Child DropOnHarvest + Item: PentaxidOre + Count: 1 + } + { Child CropsGrown + OnHarvest: CrystalStraightHarvested + OnDeath: PlantDead + } + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + Model: @models2/Environment/Rocks/Crystals/CrystalStraightPrefab + ShowUser: No +} +# used for Promethium Stone now +{ Block Id: 895, Name: CrystalsPyramidOrange, Ref: CrystalStraight + Model: @models2/Environment/Rocks/Crystals/CrystalsPyramidOrangePrefab + { Child CropsGrown + OnHarvest: CrystalsPyramidOrangeHarvested + OnDeath: PlantDead + } +} +{ Block Id: 896, Name: CrystalsPyramidBlue, Ref: CrystalStraight + Model: @models2/Environment/Rocks/Crystals/CrystalsPyramidBluePrefab + Class: CropsGrown + { Child DropOnHarvest + Item: PentaxidOre + Count: 2 + } + { Child CropsGrown + OnHarvest: CrystalsPyramidBlueHarvested + OnDeath: PlantDead + } +} +{ Block Id: 897, Name: AridRock01 + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/AridRocks/AridRock01Prefab + SizeInBlocks: "14,14,14" # reality: "14,40,14" + HitPoints: 4188, type: int, display: false + ShowUser: No + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "628,838" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "176,294" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "118,235" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "59,176" + # Prob: 0.05 + # } +} +{ Block Id: 898, Name: AridRock02 + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/AridRocks/AridRock02Prefab + SizeInBlocks: "12,14,12" # reality: "12,20,12" + HitPoints: 2670, type: int, display: false + ShowUser: No + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "400,534" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "60,100" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "40,80" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "20,60" + # Prob: 0.05 + # } +} +{ Block Id: 899, Name: AridRock03 + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/AridRocks/AridRock03Prefab + SizeInBlocks: "12,14,12" # reality: "12,18,12" + HitPoints: 1685, type: int, display: false + ShowUser: No + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "253,337" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "54,89" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "36,72" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "18,54" + # Prob: 0.05 + # } +} +{ Block Id: 900, Name: WeepingPalm1 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/WeepingPalm1Prefab + DecoParticle: weepingpalm1 + ShowUser: No + SizeInBlocks: "1,5,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 901, Name: WeepingPalm3, Ref: WeepingPalm1 + Model: @models2/Environment/Plants/SpeedTrees/WeepingPalm3Prefab + DecoParticle: weepingpalm3 + { Child DropOnDestroy + Item: WoodLogs + Count: "3,6" + Prob: 1 + } +} +{ Block Id: 902, Name: GasEmitter1 + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/GasEmitters/GasEmitter1Prefab + SizeInBlocks: "14,10,14" + ShowUser: No +} +{ Block Id: 903, Name: AridRockBrown01, Ref: AridRock01 + Model: @models2/Environment/Rocks/AridRocks/AridRockBrown01Prefab + SizeInBlocks: "14,14,14" # reality: "14,30,14" + HitPoints: 2988, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "448,598" + Prob: 1 + } +} +{ Block Id: 904, Name: AridRockBrown02, Ref: AridRock01 + Model: @models2/Environment/Rocks/AridRocks/AridRockBrown02Prefab + SizeInBlocks: "12,14,12" # reality: "12,20,12" + HitPoints: 2670, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "400,534" + Prob: 1 + } +} +{ Block Id: 905, Name: AridRockBrown03, Ref: AridRock01 + Model: @models2/Environment/Rocks/AridRocks/AridRockBrown03Prefab + SizeInBlocks: "12,14,12" # reality: "12,18,12" + HitPoints: 1685, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "253,337" + Prob: 1 + } +} +{ Block Id: 906, Name: CrystalStraightHarvested, Ref: CrystalStraight + Class: PlantGrowing + { Child PlantGrowing + Next: CrystalStraight + GrowthRate: 45 + FertileLevel: 1 + } + Model: @models2/Environment/Rocks/Crystals/CrystalStraightHarvestedPrefab +} +{ Block Id: 907, Name: CrystalsPyramidBlueHarvested, Ref: CrystalsPyramidBlue + Class: PlantGrowing + { Child PlantGrowing + Next: CrystalsPyramidBlue + GrowthRate: 45 + FertileLevel: 1 + } + Model: @models2/Environment/Rocks/Crystals/CrystalsPyramidBlueHarvestedPrefab +} +{ Block Id: 908, Name: GasEmitter2, Ref: GasEmitter1 + Model: @models2/Environment/Rocks/GasEmitters/GasEmitter2Prefab + SizeInBlocks: "14,10,14" +} +{ Block Id: 909, Name: AridRockYellow01, Ref: AridRock01 + Model: @models2/Environment/Rocks/AridRocks/AridRockYellow01Prefab + SizeInBlocks: "14,14,14" # reality: "14,30,14" + HitPoints: 2988, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "448,598" + Prob: 1 + } +} +{ Block Id: 910, Name: AridRockYellow02, Ref: AridRock01 + Model: @models2/Environment/Rocks/AridRocks/AridRockYellow02Prefab + SizeInBlocks: "12,14,12" # reality: "12,20,12" + HitPoints: 2670, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "448,598" + Prob: 1 + } +} +{ Block Id: 911, Name: AridRockYellow03, Ref: AridRock01 + Model: @models2/Environment/Rocks/AridRocks/AridRockYellow03Prefab + SizeInBlocks: "12,14,12" # reality: "12,18,12" + HitPoints: 1685, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "253,337" + Prob: 1 + } +} +{ Block Id: 912, Name: CornDogBushHarvested, Ref: CornDogBush + Class: PlantGrowing + { Child PlantGrowing + Next: CornDogBush + GrowthRate: 75 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/CornDogBushHarvestedPrefab +} +{ Block Id: 913, Name: MushroomBellBrown01Harvested, Ref: MushroomBellBrown01 + Class: PlantGrowing + { Child PlantGrowing + Next: MushroomBellBrown01 + GrowthRate: 85 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/MushroomForest/MushroomBellBrown01HarvestedPrefab +} +{ Block Id: 914, Name: EggPlantHarvested, Ref: EggPlant + Class: PlantGrowing + { Child PlantGrowing + Next: EggPlant + GrowthRate: 55 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/EggPlantHarvestedPrefab +} +{ Block Id: 915, Name: SnakeweedHarvested, Ref: Snakeweed + Class: PlantGrowing + { Child PlantGrowing + Next: Snakeweed + GrowthRate: 40 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/Snakeweed2HarvestedPrefab +} +{ Block Id: 916, Name: SeaWeed02-04Harvested, Ref: SeaWeed02-04 + Class: PlantGrowing + { Child PlantGrowing + Next: SeaWeed02-04 + GrowthRate: 60 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/UnderwaterPlants/SeaWeed02-04HarvestedPrefab +} +{ Block Id: 917, Name: CobraLeavesPlantHarvested, Ref: CobraLeavesPlant + Class: PlantGrowing + { Child PlantGrowing + Next: CobraLeavesPlant + GrowthRate: 65 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/CobraLeavesPlantHarvestedPrefab + # Model: @models2/Environment/Plants/FractalFlora/CobraLeavesPlantHarvestedPrefab +} +{ Block Id: 918, Name: PixieStalksHarvested, Ref: PixieStalks + Class: PlantGrowing + { Child PlantGrowing + Next: PixieStalks + GrowthRate: 54 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/PixieStalksHarvestedPrefab +} +{ Block Id: 919, Name: OpuntiaCactusHarvested, Ref: OpuntiaCactus + Class: PlantGrowing + { Child PlantGrowing + Next: OpuntiaCactus + GrowthRate: 125 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/Cactus4HarvestedPrefab + # Model: @models2/Environment/Plants/DesertVegetation/OpuntiaCactusHarvestedPrefab +} +{ Block Id: 920, Name: ClusterShroomHarvested, Ref: ClusterShroom + Class: PlantGrowing + { Child PlantGrowing + Next: ClusterShroom + GrowthRate: 75 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/ClusterShroomHarvestedPrefab +} +{ Block Id: 921, Name: ScalyPodsHarvested, Ref: ScalyPods + Class: PlantGrowing + { Child PlantGrowing + Next: ScalyPods + GrowthRate: 65 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/ScalyPodsHarvestedPrefab +} +{ Block Id: 922, Name: BrainFungusSnowHarvested, Ref: BrainFungusSnow + Class: PlantGrowing + { Child PlantGrowing + Next: BrainFungusSnow + GrowthRate: 95 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/BrainFungusSnowHarvestedPrefab +} +{ Block Id: 923, Name: ConfettiMossHarvested, Ref: ConfettiMoss + Class: PlantGrowing + { Child PlantGrowing + Next: ConfettiMoss + GrowthRate: 29 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/ConfettiMossHarvestedPrefab +} +{ Block Id: 924, Name: CattailWinterHarvested, Ref: CattailWinter + Class: PlantGrowing + { Child PlantGrowing + Next: CattailWinter + GrowthRate: 60 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/CattailWinterHarvestedPrefab +} +{ Block Id: 925, Name: DimpleDomeSnowHarvested, Ref: DimpleDomeSnow + Class: PlantGrowing + { Child PlantGrowing + Next: DimpleDomeSnow + GrowthRate: 60 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/DimpleDomeSnowHarvestedPrefab +} +{ Block Id: 926, Name: Mushroom2Harvested, Ref: Mushroom2 + Class: PlantGrowing + { Child PlantGrowing + Next: Mushroom2 + GrowthRate: 60 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/GreenStash/Mushroom2HarvestedPrefab +} +{ +Block Id: 927, Name: DecoBlocks + Material: metallight + Shape: Invisible + MarketPrice: 10, display: true + # Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiBedPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 40, type: float, display: true, formatter: Liter + Info: bkiBlockGroup, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + StackSize: 1000 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Deco Blocks + ChildBlocks: "ScifiBed, Bed, BunkBed, BunkBed02, Sofa, ScifiSofa, ScifiLargeSofa, KitchenTable, ScifiTable, ScifiTableV2, ReceptionTable, ReceptionTableCorner, ReceptionTableThin, ReceptionTableCornerThin, SmallTable, ControlStation, TableTV, ScifiChair, ScifiNightstand, ScifiStorage, Bookshelf, KitchenCounter, BathroomCounter, Toilet, Shower, ScifiShower, ToiletModern, Closet, ReceptionTableWorkstation" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 928, Name: ConsoleBlocks + Material: metallight + Shape: Invisible + MarketPrice: 810, display: true + ShowBlockName: true + # Model: @models/Blocks/Mothership/ConsoleMS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + Volume: 50, type: float, display: true, formatter: Liter + Mass: 175, type: float, display: true, formatter: Kilogram + StackSize: 1000 + IsOxygenTight: false, display: true + Info: bkiBlockGroup, display: true + # ModelOffset: "0,0.5,0" + Category: Deco Blocks + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 1, type: int, display: false, formatter: Watt + ChildBlocks: "ConsoleMS01, ConsoleSmallMS01, ConsoleLargeMS01, ConsoleLargeMS01a, ConsoleLargeMS02, ConsoleMapMS01, ScifiMediaCenter, ScifiComputerTable, HoloScreen01, HoloScreen02, HoloScreen03, HoloScreen04, HoloScreen05, StationConsole, GeneralPurposeConsole, ConsoleTrading, ComputerStation_C01, ComputerStation_C02, ConsoleDesk_C03, ConsoleDesk_C04, ConsoleDesk_C05, StandConsole_C06, StandConsole_C07, StandConsole_C17, ConsoleStation_C18" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 929, Name: IndoorPlants + Material: metallight + Shape: Invisible + MarketPrice: 4, display: true + # Model: @models2/Entities/Decorations/IndoorPlants/IndoorPlant01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + SizeInBlocks: "1,1,1", display: true + Volume: 8, type: float, display: true, formatter: Liter + Mass: 7, type: float, display: true, formatter: Kilogram + StackSize: 2000 + IsAccessible: false, type: bool + # ModelOffset: "0,0.5,0" + Category: Deco Blocks + ChildBlocks: "IndoorPlant01, IndoorPlant02, IndoorPlant03, ScifiPlant, HollywoodJuniperDeco, ElderberryBushDeco, ElderberryBushBlueDeco, AkuaFernDeco, AlienPalmTreeDeco, AlienTentacleDeco, BallTreeDeco, BallFlower01Deco, GlowTube01Deco, OnionFlowerDeco, FantasyPlant1Deco" #max 32 entries + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ Block Id: 930, Name: CoralBig01Harvested, Ref: CoralBig01 + Class: PlantGrowing + { Child PlantGrowing + Next: CoralBig01 + GrowthRate: 60 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/UnderwaterPlants/CoralBig01HarvestedPrefab +} +{ Block Id: 931, Name: CoralBig08Harvested, Ref: CoralBig08 + Class: PlantGrowing + { Child PlantGrowing + Next: CoralBig08 + GrowthRate: 60 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/UnderwaterPlants/CoralBig08HarvestedPrefab +} +{ Block Id: 932, Name: AlienPlant05Harvested, Ref: AlienPlant05 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlant05 + GrowthRate: 60 + FertileLevel: 1 + } + # Model: @models2/Environment/Plants/AlienVegetation/AlienPlant05HarvestedPrefab + Model: @models2/Environment/Plants/SpeedTrees/AlienFernHarvestedPrefab +} +{ Block Id: 933, Name: AlienPlant11Harvested, Ref: AlienPlant11 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlant11 + GrowthRate: 60 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant11HarvestedPrefab +} +# Strong version of standard RCS +{ +Block Id: 934, Name: RCSBlockMS_T2 + IndexName: RCS + Group: cpgRCS + Material: metal + MarketPrice: 48470, display: true + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/RCS_BlockT2Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "58,24,59" + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 800, type: float, display: true, formatter: Liter + Mass: 19165, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiGyroscope, display: true + # ModelOffset: "0,0.5,0" + StackSize: 250 + Category: Devices + Torque: 150000, type: int, display: true, formatter: NewtonMeter + # Torque: 20000, type: int, display: true, formatter: NewtonMeter + EnergyDynamicGroup: RCS + EnergyIn: 50, type: int, display: true, formatter: Watt + CPUIn: 1250000, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + UnlockCost: 5, display: true + UnlockLevel: 10, display: true + TechTreeParent: RCSBlockMS + TechTreeNames: Capital Vessel +} +# Special blocks for underwater to avoid problem with "cutting out water" +{ Block Id: 935, Name: RealRock1Water + Material: rock + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/RealRockVol1/RealRock1Prefab + ShowUser: No + HitPoints: 183, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "28,37" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "2,3" + # Prob: 0.2 + # } +# SizeInBlocks: "5,4,5" +} +{ Block Id: 936, Name: RealRock5Water, Ref: RealRock1Water + Model: @models2/Environment/Rocks/RealRockVol1/RealRock5Prefab +# SizeInBlocks: "5,3,3" + HitPoints: 166, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "25,33" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "2,3" + # Prob: 0.2 + # } +} +{ Block Id: 937, Name: RealRock8Water, Ref: RealRock1Water + Model: @models2/Environment/Rocks/RealRockVol1/RealRock8Prefab +# SizeInBlocks: "13,10,13" + HitPoints: 510, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "77,102" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "33,56" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "22,44" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "11,33" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "22,33" + # Prob: 0.2 + # } +} +{ Block Id: 938, Name: RealRock9Water, Ref: RealRock1Water + Model: @models2/Environment/Rocks/RealRockVol1/RealRock9Prefab +# SizeInBlocks: "13,5,5" + HitPoints: 550, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "83,110" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "5,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,5" + # Prob: 0.05 + # } + # { Child DropOnDestroy_5 + # Item: SiliconOre + # Count: "4,5" + # Prob: 0.2 + # } +} +{ Block Id: 939, Name: Boulder1 + Material: rock + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/DesertRockFormations/Boulder1Prefab + SizeInBlocks: "7,3,5" + ShowUser: No + HitPoints: 250, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "37,50" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 940, Name: Boulder2, Ref: Boulder1 + Model: @models2/Environment/Rocks/DesertRockFormations/Boulder2Prefab + SizeInBlocks: "8,5,8" + HitPoints: 292, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "44,58" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "5,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "3,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,5" + # Prob: 0.05 + # } +} +{ Block Id: 941, Name: Boulder3, Ref: Boulder1 + Model: @models2/Environment/Rocks/DesertRockFormations/Boulder3Prefab + SizeInBlocks: "6,5,6" + HitPoints: 255, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "38,51" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 942, Name: Boulder4, Ref: Boulder1 + Model: @models2/Environment/Rocks/DesertRockFormations/Boulder4Prefab + SizeInBlocks: "13,3,4" + HitPoints: 556, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "83,111" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 943, Name: Boulder5, Ref: Boulder1 + Model: @models2/Environment/Rocks/DesertRockFormations/Boulder5Prefab + SizeInBlocks: "12,3,6" + HitPoints: 483, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "72,97" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "4,6" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "3,5" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,4" + # Prob: 0.05 + # } +} +{ Block Id: 944, Name: Pillar1 + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/DesertRockFormations/Pillar1Prefab + SizeInBlocks: "7,13,7" + ShowUser: No + HitPoints: 1177, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "177,235" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "11,18" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "7,15" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "4,11" + # Prob: 0.05 + # } +} +{ Block Id: 945, Name: Pillar2, Ref: Pillar1 + Model: @models2/Environment/Rocks/DesertRockFormations/Pillar2Prefab + SizeInBlocks: "7,13,7" + HitPoints: 895, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "134,179" + Prob: 1 + } +} +{ Block Id: 946, Name: Pillar3, Ref: Pillar1 + Model: @models2/Environment/Rocks/DesertRockFormations/Pillar3Prefab + SizeInBlocks: "7,13,7" + HitPoints: 871, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "131,174" + Prob: 1 + } +} +{ Block Id: 947, Name: Pillar4, Ref: Pillar1 + Model: @models2/Environment/Rocks/DesertRockFormations/Pillar4Prefab + SizeInBlocks: "7,13,7" + HitPoints: 965, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "145,193" + Prob: 1 + } +} +{ Block Id: 948, Name: DesertRock1, Ref: Boulder1 + Material: rock + Model: @models2/Environment/Rocks/DesertRockFormations/DesertRock1Prefab + SizeInBlocks: "2,2,2" + HitPoints: 43, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "6,9" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "2,3" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "1,3" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,2" + # Prob: 0.05 + # } +} +{ Block Id: 949, Name: DesertRock2, Ref: Boulder1 + Model: @models2/Environment/Rocks/DesertRockFormations/DesertRock2Prefab + SizeInBlocks: "4,3,3" + HitPoints: 128, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "19,26" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,4" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ +Block Id: 950, Name: HoloScreen01 + Material: metallight + Shape: ModelEntity + Model: @models2/Entities/Decorations/HolographicScreens/HolographicScreen01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 40, type: float, display: true, formatter: Liter + Mass: 20, type: float, display: true, formatter: Kilogram + # CanPickup: true + Info: bkiDecoModel, display: true + IsAccessible: false, type: bool + Category: Deco Blocks + TemplateRoot: ConsoleBlocks + EnergyIn: 1, type: int, display: true, formatter: Watt +} +{ +Block Id: 951, Name: HoloScreen02, Ref: HoloScreen01 + Model: @models2/Entities/Decorations/HolographicScreens/HolographicScreen02Prefab +} +{ +Block Id: 952, Name: HoloScreen03, Ref: HoloScreen01 + Model: @models2/Entities/Decorations/HolographicScreens/HolographicScreen03Prefab + Group: cpgEquipment + IsActivateable: true + IsActivateableInCP: true + IsAccessible: true, type: bool +} +{ +Block Id: 953, Name: HoloScreen04, Ref: HoloScreen01 + Model: @models2/Entities/Decorations/HolographicScreens/HolographicScreen04Prefab +} +{ +Block Id: 954, Name: HoloScreen05, Ref: HoloScreen01 + Model: @models2/Entities/Decorations/HolographicScreens/HolographicScreen05Prefab + Group: cpgEquipment + IsActivateable: true + IsActivateableInCP: true + IsAccessible: true, type: bool + + Class: NPCDialogue + NPCModelRotation: false +} +{ Block Id: 955, Name: Cactus1 + Class: CropsGrown + { Child DropOnHarvest + Item: AlienThorn + Count: 3 + } + { Child CropsGrown + OnHarvest: Cactus1Harvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/Cactus1Prefab + ShowUser: No +} +{ Block Id: 956, Name: Cactus2 + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/Cactus2Prefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } +} +{ Block Id: 957, Name: Cactus3, Ref: Cactus2 + Model: @models2/Environment/Plants/SpeedTrees/Cactus3Prefab +} +{ Block Id: 958, Name: Cactus1Harvested, Ref: Cactus1 + Class: PlantGrowing + { Child PlantGrowing + Next: Cactus1 + GrowthRate: 90 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/Cactus1HarvestedPrefab +} +# Obsolete: Old Constructor for SV + HV (recursive capabilities) +{ Block Id: 959, Name: ConstructorSmallV2 + Class: ConstructorSmallV2 + IndexName: Constructor + Group: cpgConstructor + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Devices/ConstructorMobileFWNPrefab #1.11 update + #Model: @models/Blocks/Spaceship/ConstructorSVPrefab + #Mesh-Damage-1: Mesh/D0 #ConstructorMobilePrefab does not have damage states + #Mesh-Damage-2: Mesh/D1 #ConstructorMobilePrefab does not have damage states + #Mesh-Damage-3: Mesh/D2 #ConstructorMobilePrefab does not have damage states + #Mesh-Damage-4: Mesh/D3 #ConstructorMobilePrefab does not have damage states + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + TemplateRoot: ConstructorSV + Place: Free + AllowPlacingAt: "SS,GV", display: true + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiConstructorSmall, display: true + SizeInBlocks: "1,1,3", display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + Category: Devices + Mass: 1000, type: float, display: true, formatter: Kilogram + CPUIn: 2000, type: int, display: true + HitPoints: 150, type: int, display: true + EnergyIn: 250, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + BlockColor: "110,110,110" + + ShowUser: NoButCSV +} +# Used in game +{ +Block Id: 960, Name: ConstructorT1V2 + Class: ConstructorBigV2 + IndexName: Constructor + Group: cpgConstructor + Material: metalhard + Shape: ModelEntity + MarketPrice: 16522, display: true + ShowBlockName: true + Model: @models/Blocks/Devices/ConstructorT1FWNPrefab #1.11 update + #Model: @models/Blocks/Mothership/ConstructorT1Prefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "2,2,2", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 1280, type: float, display: true, formatter: Liter + Mass: 4114, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + # IsGPUInstance: true + Info: bkiConstructorLarge, display: true + StackSize: 500 + BlockColor: "110,110,110" + Category: Devices + HitPoints: 250, type: int, display: true + EnergyIn: 500, type: int, display: true, formatter: Watt + CPUIn: 20000, type: int, display: true + EnergyInIdle: 10, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + + UpgradeTo: ConstructorT2, display: true + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: ConstructorT0 + TechTreeNames: "Base,Capital Vessel" +} +# Alternative SurvConstructor - without recursive capabilities: NOT in use +{ Block Id: 961, Name: ConstructorSurvivalV2, Ref: ConstructorSurvival + Class: ConstructorSurvival + Model: @models/Blocks/Terrain/ConstructorSurvivalV2Prefab + Info: bkiConstructorSurvivalV2, display: true + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 250, type: int, display: true + BlockColor: "170,5,5" + + UpgradeTo: null + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + ShowUser: NoButCSV +} +# Currently used in game +{ +Block Id: 962, Name: FoodProcessorV2 + Class: FoodProcessorV2 + IndexName: Constructor + MarketPrice: 2894, display: true + Group: cpgFoodProcessor + DontDecayFood: true # now this loot container is a fridge + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/FoodProcessorV2Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 200, type: float, display: true, formatter: Liter + Mass: 310, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiFoodProcessor, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + EnergyIn: 300, type: int, display: true, formatter: Watt + CPUIn: 200, type: int, display: true + EnergyInIdle: 30, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 + + UnlockCost: 3, display: true + UnlockLevel: 3, display: true + TechTreeParent: FridgeBlocks + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 963, Name: CockpitSV01, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/CockpitSS01Prefab + HitPoints: 900, type: int, display: true + Mass: 845, type: float, display: true, formatter: Kilogram + SizeInBlocks: "3,3,5", display: true + # Volume: 4500, type: float, display: true, formatter: Liter + UpgradeTo: CockpitSV01T2, display: true +} +# Terrain Placeable +{ +Block Id: 964, Name: OxygenGeneratorSmall #Water / O2 Condenser + Class: O2GeneratorSmall + Material: metal + MarketPrice: 30, display: true + Shape: ModelEntity + Model: @models/Blocks/Devices/PortableWaterO2CondenserFWNPrefab #1.11 update + #Model: @models/Blocks/Terrain/OxygenGeneratorSmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + Info: bkiOxygenGeneratorSmall, display: true + IsTerrainDecoration: true + EnergyIn: 10, type: int, display: true, formatter: Watt + # oxygengenerator should run with 1 energycell (FuelValue 10kWh) => 60min + StackSize: 2000 + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + Category: Devices + AllowPlacingAt: Terrain, display: true + SizeInBlocks: "1,1,1" #no need to display info for terrain placeables (removed to reduce hover info density) + Mass: 38, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + IsColorable: false + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + HitPoints: 80, type: int, display: true + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: Misc + + MapIcon: map_OxyGenerator + MapName: OxygenGeneratorSmall +} +{ +Block Id: 965, Name: DoorArmored + Class: Door + IndexName: Door + Group: cpgDoor + Material: metalhard + Shape: ModelEntity + Model: @models/Blocks/Mothership/DoorNewArmoredPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: DoorArmoredBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 250, type: float, display: true, formatter: Liter + Mass: 910, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 350, type: int, display: true + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor +} +{ +Block Id: 966, Name: Window_v1x1Thick + Material: hullcombat + Shape: ModelEntity + Model: @models/Blocks/Windows/Standard/Window_v1x1ThickPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + Place: Free + Info: bkiWindowNoShutterSemiTrans, display: true + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: BuildingBlocks + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + ParentBlocks: "WindowArmoredSmallBlocks, WindowArmoredLargeBlocks" + Collide: Transparent + + # Properties given in parent block + HitPoints: 150, type: int, display: false + Mass: 62, type: float, display: true, formatter: Kilogram + + # Cannot remove - BP cannot be spawned anymore + AllowPlacingAt: "Base,MS,SS,GV", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" +} +{ +Block Id: 967, Name: Window_v1x2Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_v1x2ThickPrefab + SizeInBlocks: "1,2,1", display: true +} +{ +Block Id: 968, Name: Window_v2x2Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_v2x2ThickPrefab + SizeInBlocks: "2,2,1", display: true +} +{ +Block Id: 969, Name: WindowVertShutterArmored + Material: hullcombat + Shape: Ext3dModel + Mesh: models + Model: Mothership/windowVertMS, param1: window_nontransparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + Info: bkiWindowShutterNonTrans, display: true + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: BuildingBlocks + ParentBlocks: "WindowShutterSmallBlocks, WindowShutterLargeBlocks" + Collide: Transparent + AllowWander: true # used in old prefabs as walkway blocks + + # Properties given in parent block + HitPoints: 200, type: int, display: false + Mass: 91, type: float, display: true, formatter: Kilogram + + # Cannot remove - BP cannot be spawned anymore + AllowPlacingAt: "Base,MS,SS,GV", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" +} +{ +Block Id: 970, Name: WindowSlopedShutterArmored, Ref: WindowVertShutterArmored + Model: Mothership/windowSlopedMS, param1: window_slope_nontrasparent +} +{ +Block Id: 971, Name: WindowSloped2ShutterArmored, Ref: WindowVertShutterArmored + Model: Mothership/windowSloped2MS, param1: window_slope_inv_nontrasparent +} +{ +Block Id: 972, Name: WindowVertShutterTransArmored, Ref: WindowVertShutterArmored + Model: Mothership/windowVertBase, param1: window_transparent + Info: bkiWindowShutterTrans, display: true + IsOxygenTight: false, display: true + IsBlocksThrusters: false, display: true +} +{ +Block Id: 973, Name: WindowSlopedShutterTransArmored, Ref: WindowVertShutterArmored + Model: Mothership/windowSlopedBase, param1: window_slope_transparent + Info: bkiWindowShutterTrans, display: true + IsOxygenTight: false, display: true + IsBlocksThrusters: false, display: true +} + +{ +Block Id: 974, Name: WindowArmoredSmallBlocks + Material: hullarmored + Shape: Invisible + MarketPrice: 190, display: true + # Model: @models/Blocks/Windows/Standard/Window_v1x1ThickPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiWindows, display: true + IsOxygenTight: true, display: true + IsAccessible: false, type: bool + Category: BuildingBlocks + # ModelOffset: "0,0.5,0" + # RemoveOnSI: true + ChildBlocks: "Window_v1x1Thick, Window_v1x2Thick, Window_v2x2Thick, Window_s1x1Thick, Window_s1x2Thick, Window_sd1x1Thick, Window_sd1x2Thick, Window_sd1x2V2Thick, Window_c1x1Thick, Window_c1x2Thick, Window_cr1x1Thick, Window_crc1x1Thick, Window_crsd1x1Thick, Window_L1x1Thick, Window_3side1x1Thick, Window_crctw1x1Thick, Window_creA1x1Thick, Window_creB1x1Thick, Window_crl1x1Thick, Window_crse1x1Thick, Window_cc1x1Thick" + Collide: Transparent + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + # Parent properties + TemplateRoot: WindowArmoredSmallBlocks + AllowPlacingAt: "SS,GV", display: true + Mass: 62, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + StackSize: 2000 + HitPoints: 150, type: int, display: true +} +{ +Block Id: 975, Name: HangarDoor10x5 + Class: Door + IndexName: Door + Group: cpgHangarShutterDoor + Material: hullarmored + Shape: ModelEntity + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor10x5Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: HangarDoorBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "10,5,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 10000, type: float, display: true, formatter: Liter + Mass: 10830, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 8590, type: int, display: true + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: false +} + + +{ +Block Id: 976, Name: WindowShutterSmallBlocks + Material: hullarmored + Shape: Invisible + Mesh: models + MarketPrice: 230, display: true + # Model: Mothership/windowVertMS, param1: window_nontransparent + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiWindowsShutter, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Category: BuildingBlocks + ChildBlocks: "WindowVertShutterArmored, WindowSlopedShutterArmored, WindowSloped2ShutterArmored, WindowVertShutterTransArmored, WindowSlopedShutterTransArmored" + Collide: Transparent + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + # Parent properties + TemplateRoot: WindowShutterSmallBlocks + AllowPlacingAt: "SS,GV", display: true + Mass: 91, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + StackSize: 2000 + HitPoints: 200, type: int, display: true +} +{ +Block Id: 977, Name: Window_s1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_s1x1ThickPrefab +} +{ +Block Id: 978, Name: Window_s1x2Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_s1x2ThickPrefab + SizeInBlocks: "1,1,2", display: true +} +{ +Block Id: 979, Name: Window_sd1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_sd1x1ThickPrefab + SymType: 3 +} +{ +Block Id: 980, Name: Window_sd1x2Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_sd1x2ThickPrefab + SizeInBlocks: "1,1,2", display: true + MirrorTo: Window_sd1x2V2Thick +} +{ +Block Id: 981, Name: Window_c1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_c1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 982, Name: Window_c1x2Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_c1x2ThickPrefab + SizeInBlocks: "1,2,1", display: true + SymType: 2 +} +{ +Block Id: 983, Name: Window_cr1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_cr1x1ThickPrefab +} +{ +Block Id: 984, Name: Window_crc1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crc1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 985, Name: Window_crsd1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crsd1x1ThickPrefab + SymType: 3 +} +{ +Block Id: 986, Name: Window_sd1x2V2Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_sd1x2V2ThickPrefab + SizeInBlocks: "1,1,2", display: true + MirrorTo: Window_sd1x2Thick +} +{ +Block Id: 987, Name: HangarDoor14x7, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor14x7Prefab + SizeInBlocks: "14,7,1", display: true + HitPoints: 16820, type: int, display: true + Mass: 12000, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 988, Name: HangarDoor6x3, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor6x3Prefab + SizeInBlocks: "6,3,1", display: true + HitPoints: 3090, type: int, display: true + Mass: 9060, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 989, Name: Window_v1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_v1x1ThickInvPrefab + ShowUser: NoButCSV +} +{ +Block Id: 990, Name: Window_v1x2ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_v1x2ThickInvPrefab + SizeInBlocks: "1,2,1", display: true + ShowUser: NoButCSV +} +{ +Block Id: 991, Name: Window_v2x2ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_v2x2ThickInvPrefab + SizeInBlocks: "2,2,1", display: true + ShowUser: NoButCSV +} +{ +Block Id: 992, Name: Window_s1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_s1x1ThickInvPrefab + ShowUser: NoButCSV +} +{ +Block Id: 993, Name: Window_s1x2ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_s1x2ThickInvPrefab + SizeInBlocks: "1,1,2", display: true + ShowUser: NoButCSV +} +{ +Block Id: 994, Name: Window_sd1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_sd1x1ThickInvPrefab + SymType: 3 + ShowUser: NoButCSV +} +{ +Block Id: 995, Name: Window_sd1x2ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_sd1x2ThickInvPrefab + SizeInBlocks: "1,1,2", display: true + ShowUser: NoButCSV +} +{ +Block Id: 996, Name: Window_c1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_c1x1ThickInvPrefab + SymType: 2 + ShowUser: NoButCSV +} +{ +Block Id: 997, Name: Window_c1x2ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_c1x2ThickInvPrefab + SizeInBlocks: "1,2,1", display: true + SymType: 2 + ShowUser: NoButCSV +} +{ +Block Id: 998, Name: Window_cr1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_cr1x1ThickInvPrefab + ShowUser: NoButCSV +} +{ +Block Id: 999, Name: Window_crc1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crc1x1ThickInvPrefab + SymType: 2 + ShowUser: NoButCSV +} +{ +Block Id: 1000, Name: Window_crsd1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crsd1x1ThickInvPrefab + SymType: 3 + ShowUser: NoButCSV +} +{ +Block Id: 1001, Name: Window_sd1x2V2ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_sd1x2V2ThickInvPrefab + SizeInBlocks: "1,1,2", display: true + ShowUser: NoButCSV +} +{ +Block Id: 1002, Name: DoorBlocks + Class: Door + IndexName: Door + Group: cpgDoor + MarketPrice: 250, display: true + Material: metal + Shape: Invisible + # Model: @models/Blocks/Mothership/DoorInterior01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 40, type: float, display: true, formatter: Liter + Mass: 295, type: float, display: true, formatter: Kilogram + StackSize: 1000 + IsAccessible: true, type: bool + IsLockable: true + Info: bkiDoors, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + # HitPoints: 80, type: int, display: false + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor + ChildBlocks: "DoorMS01, DoorInterior01, DoorInterior02, DoorVertical, DoorVerticalGlass, DoorCentered01, DoorCentered02, DoorCorner01, DoorCorner02, DoorRound01, DoorRound02" + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1003, Name: DoorInterior01 + Class: Door + IndexName: Door + Group: cpgDoor + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Mothership/DoorInterior01Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: DoorBlocks + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 250, type: float, display: true, formatter: Liter + Mass: 200, type: float, display: true, formatter: Kilogram + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 120, type: int, display: true + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor + UpgradeTo: DoorInterior01Armored, display: true +} +{ +Block Id: 1004, Name: DoorInterior02, Ref: DoorInterior01 + HitPoints: 40, type: int, display: true + Model: @models/Blocks/Mothership/DoorInterior02Prefab + IsOxygenTight: false, display: true + UpgradeTo: DoorInterior02Armored, display: true +} +{ +Block Id: 1005, Name: HangarDoor5x3, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor5x3Prefab + SizeInBlocks: "5,3,1", display: true + HitPoints: 2580, type: int, display: true + Mass: 8730, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1006, Name: HangarDoor9x5, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor9x5Prefab + SizeInBlocks: "9,5,1", display: true + HitPoints: 7730, type: int, display: true + Mass: 10650, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1007, Name: HangarDoor13x7, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor13x7Prefab + SizeInBlocks: "13,7,1", display: true + HitPoints: 15450, type: int, display: true + Mass: 11880, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1008, Name: HangarDoorBlocks + Class: Door + IndexName: Door + Group: cpgDoor + MarketPrice: 3000, display: true + Material: hullarmored + Shape: Invisible + # Model: @models/Blocks/Mothership/HangarDoor/HangarDoor10x5Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 7200, type: float, display: true, formatter: Liter + Mass: 8000, type: float, display: true, formatter: Kilogram + StackSize: 250 + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: Devices + # EnergyIn: 1, type: int, display: true, formatter: Watt + # HitPoints: 1000, type: int, display: false + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: false + ChildBlocks: "HangarDoor5x3, HangarDoor5x4, HangarDoor6x3, HangarDoor6x5, HangarDoor7x5, HangarDoor7x6, HangarDoor9x5, HangarDoor9x7, HangarDoor10x5, HangarDoor10x7, HangarDoor10x9, HangarDoor13x7, HangarDoor14x7" + + UnlockCost: 9, display: true + UnlockLevel: 7, display: true + TechTreeParent: ShutterDoorLargeBlocks + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1009, Name: CockpitOpenSV + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metalhard + UseCockpitCamera: false + YawRotation: 150 # how far in degrees head of player can be rotated (default 75°) + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Cockpits/CockpitOpenSVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOORMetalCloseGentlyShort + Place: Cockpit + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 220, type: int, display: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,3,3", display: true + # Volume: 900, type: float, display: true, formatter: Liter + Mass: 245, type: float, display: true, formatter: Kilogram + OccupySizeInBlocks: true # Problem with placing hull blocks on top (see Mantis 05555) + IsLockable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiCockpit, display: true + TemplateRoot: CockpitBlocksSV + # ModelOffset: "0,0.5,0" + StackSize: 1000 + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + BlockColor: "110,110,110" +} + +{ Block Id: 1010, Name: ElderberryBush + Class: CropsGrown + { Child DropOnHarvest + Item: Berries + Count: 3 + } + { Child CropsGrown + OnHarvest: ElderberryBushHarvested + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/ElderberryBushFruitPrefab + # DecoParticle: chinesebanyan # removed particle effect since it triggers when harvested + ShowUser: No + SizeInBlocks: "3,4,3" +} +{ +Block Id: 1011, Name: ShutterDoor1x1 + Class: Door + IndexName: Door + Group: cpgHangarShutterDoor + Material: hullarmored + Shape: ModelEntity + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: ShutterDoorLargeBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + # Volume: 250, type: float, display: true, formatter: Liter + Mass: 210, type: float, display: true, formatter: Kilogram + SizeInBlocksLocked: "Base,MS" + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 120, type: int, display: true + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: true + +} +{ +Block Id: 1012, Name: ShutterDoor2x2, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor2x2Prefab + SizeInBlocks: "2,2,1", display: true + # Volume: 1000, type: float, display: true, formatter: Liter + HitPoints: 480, type: int, display: true + Mass: 340, type: float, display: true, formatter: Kilogram + PlayerTriggersOpen: true +} +{ +Block Id: 1013, Name: ShutterDoor3x3, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor3x3Prefab + SizeInBlocks: "3,3,1", display: true + # Volume: 2000, type: float, display: true, formatter: Liter + HitPoints: 1080, type: int, display: true + Mass: 450, type: float, display: true, formatter: Kilogram + PlayerTriggersOpen: true +} +{ +Block Id: 1014, Name: ShutterDoor4x4, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor4x4Prefab + SizeInBlocks: "4,4,1", display: true + # Volume: 4000, type: float, display: true, formatter: Liter + HitPoints: 1910, type: int, display: true + Mass: 560, type: float, display: true, formatter: Kilogram + PlayerTriggersOpen: true +} +{ +Block Id: 1015, Name: ShutterDoor5x5, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor5x5Prefab + SizeInBlocks: "5,5,1", display: true + # Volume: 6000, type: float, display: true, formatter: Liter + HitPoints: 2980, type: int, display: true + Mass: 700, type: float, display: true, formatter: Kilogram + PlayerTriggersOpen: true +} +{ +Block Id: 1016, Name: ShutterDoorLargeBlocks + Class: Door + IndexName: Door + Material: hullarmored + Shape: Invisible + MarketPrice: 1000, display: true + # Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + StackSize: 1000 + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: Devices + # HitPoints: 500, type: int, display: false + AllowPlacingAt: "Base,MS", display: true + Volume: 400, type: float, display: true, formatter: Liter + Mass: 782, type: float, display: true, formatter: Kilogram + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: true + ChildBlocks: "ShutterDoor1x1, ShutterDoor1x2, ShutterDoor1x3, ShutterDoor1x4, ShutterDoor1x5, ShutterDoor2x2, ShutterDoor2x3, ShutterDoor3x3, ShutterDoor4x3, ShutterDoor4x4, ShutterDoor5x3, ShutterDoor5x5" + + UnlockCost: 8, display: true + UnlockLevel: 5, display: true + TechTreeParent: DoorBlocks + TechTreeNames: "Base,Capital Vessel" +} +# not automatic doors for SV / HV +{ +Block Id: 1017, Name: ShutterDoor1x1SV + Class: Door + IndexName: Door + Group: cpgHangarShutterDoor + Material: hullarmored + Shape: ModelEntity + Info: bkiNotAutomaticDoor, display: true + Model: @models/Blocks/Spaceship/ShutterDoor1x1SVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: ShutterDoorSmallBlocks + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + # Volume: 50, type: float, display: true, formatter: Liter + Mass: 25, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 30, type: int, display: true + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: false +} +{ +Block Id: 1018, Name: ShutterDoor2x2SV, Ref: ShutterDoor1x1SV + Model: @models/Blocks/Spaceship/ShutterDoor2x2SVPrefab + SizeInBlocks: "2,2,1", display: true + # Volume: 200, type: float, display: true, formatter: Liter + HitPoints: 100, type: int, display: true + Mass: 80, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1019, Name: ShutterDoor3x3SV, Ref: ShutterDoor1x1SV + Model: @models/Blocks/Spaceship/ShutterDoor3x3SVPrefab + SizeInBlocks: "3,3,1", display: true + # Volume: 400, type: float, display: true, formatter: Liter + HitPoints: 220, type: int, display: true + Mass: 190, type: float, display: true, formatter: Kilogram + ShowUser: No +} +{ +Block Id: 1020, Name: ShutterDoorSmallBlocks + Class: Door + IndexName: Door + Material: hullarmored + Shape: Invisible + MarketPrice: 470, display: true + # Model: @models/Blocks/Spaceship/ShutterDoor1x1SVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + AllowPlacingAt: "SS,GV", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 80, type: float, display: true, formatter: Kilogram + # ModelOffset: "0,0.5,0" + StackSize: 2000 + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: Devices + # HitPoints: 200, type: int, display: false + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: false + ChildBlocks: "ShutterDoor1x1SV, ShutterDoor2x2SV, ShutterDoor3x3SV, ShutterDoor3x4SV" + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeParent: DoorBlocksSV + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 1021, Name: ShutterDoor3x4SV, Ref: ShutterDoor1x1SV + Model: @models/Blocks/Spaceship/ShutterDoor3x4SVPrefab + SizeInBlocks: "3,4,1", display: true + # Volume: 600, type: float, display: true, formatter: Liter + HitPoints: 390, type: int, display: true + Mass: 230, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1022, Name: Ramp3x1x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x1x1Prefab + SizeInBlocks: "3,1,1", display: true + # Volume: 100, type: float, display: true, formatter: Liter + HitPoints: 100, type: int, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1023, Name: Ramp3x2x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x2x1Prefab + SizeInBlocks: "3,2,1", display: true + Volume: 300, type: float, display: true, formatter: Liter + HitPoints: 120, type: int, display: true + Mass: 50, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1024, Name: Ramp3x3x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x3x1Prefab + SizeInBlocks: "3,3,1", display: true + Volume: 400, type: float, display: true, formatter: Liter + HitPoints: 150, type: int, display: true + Mass: 75, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1025, Name: Ramp3x4x2, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x4x2Prefab + SizeInBlocks: "3,4,2", display: true + Volume: 1000, type: float, display: true, formatter: Liter + HitPoints: 200, type: int, display: true + Mass: 125, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1026, Name: Ramp3x5x3, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x5x3Prefab + SizeInBlocks: "3,5,3", display: true + Volume: 2000, type: float, display: true, formatter: Liter + HitPoints: 250, type: int, display: true + Mass: 175, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1027, Name: Ramp1x1x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x1x1Prefab + SizeInBlocks: "1,1,1", display: true + Volume: 50, type: float, display: true, formatter: Liter + HitPoints: 100, type: int, display: true + Mass: 15, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1028, Name: Ramp1x2x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x2x1Prefab + SizeInBlocks: "1,2,1", display: true + Volume: 70, type: float, display: true, formatter: Liter + HitPoints: 110, type: int, display: true + Mass: 20, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1029, Name: Ramp1x3x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x3x1Prefab + SizeInBlocks: "1,3,1", display: true + Volume: 100, type: float, display: true, formatter: Liter + HitPoints: 120, type: int, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1030, Name: Ramp1x4x2, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x4x2Prefab + SizeInBlocks: "1,4,2", display: true + Volume: 400, type: float, display: true, formatter: Liter + HitPoints: 130, type: int, display: true + Mass: 50, type: float, display: true, formatter: Kilogram + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +# RampBlocks > NOT USED ANYMORE! Were split in RampLargeBlocks and RampSmallBlocks +{ +Block Id: 1031, Name: RampBlocks + Class: Door + IndexName: Door + Material: hullarmored + Shape: Invisible + # Model: @models/Blocks/Mothership/Ramps/Ramp3x1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + Info: bkiBlockGroup, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + Volume: 80, type: float, display: true, formatter: Liter # low value since also placed on SV,HV + Mass: 250, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + StackSize: 25 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Devices + HitPoints: 100, type: int, display: false + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + # PlayerTriggersOpen: true + ChildBlocks: "Ramp1x1x1, Ramp1x4x1, Ramp1x5x1, Ramp1x5x2, Ramp1x5x3, Ramp3x1x1, Ramp1x2x1, Ramp3x2x1, Ramp3x4x1, Ramp3x5x1, Ramp1x3x1, Ramp3x3x1, Ramp1x4x2, Ramp3x4x2, Ramp3x5x2, Ramp3x5x3" + + # UnlockCost: 10, display: true + # UnlockLevel: 7, display: true + # TechTreeNames: "Small Vessel,Hover Vessel,Base,Capital Vessel" + ShowUser: NoButCSV +} +{ Block Id: 1032, Name: ElderberryBushRed, Ref: ElderberryBushGreen + Model: @models2/Environment/Plants/SpeedTrees/ElderberryBushRedPrefab + SizeInBlocks: "5,4,5" +} +{ Block Id: 1033, Name: ElderberryBushBlue, Ref: ElderberryBushGreen + Model: @models2/Environment/Plants/SpeedTrees/ElderberryBushBluePrefab + SizeInBlocks: "5,4,5" +} +{ +Block Id: 1034, Name: GeneratorMST2, Ref: GeneratorMS + Model: @models/Blocks/Mothership/generatorMST2Prefab + EnergyOut: 180000, type: int, display: true, formatter: Watt + MarketPrice: 47905, display: true + CPUIn: 22500, type: int, display: true + SizeInBlocks: "6,2,2", display: true + Volume: 2668, type: float, display: true, formatter: Liter + Mass: 37355, type: float, display: true, formatter: Kilogram + HitPoints: 500, type: int, display: true + StackSize: 250 + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + TechTreeParent: GeneratorMS + TechTreeNames: "Base,Capital Vessel" + + Temperature: 350, display: true + Radiation: 24, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} +{ +Block Id: 1035, Name: FuelTankMSLargeT2, Ref: FuelTankMSLarge + LootList: 47 + MarketPrice: 9458, display: true + Model: @models/Blocks/Mothership/FuelTankMSLargeT2Prefab + SizeInBlocks: "2,2,1", display: true + Volume: 255, type: float, display: true, formatter: Liter + CPUIn: 1500, type: int, display: true + Mass: 3570, type: float, display: true, formatter: Kilogram + HitPoints: 80, type: int, display: true + FuelCapacity: 6000, type: int, display: true + + Radiation: 2.8, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + TechTreeParent: FuelTankMSLarge + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1036, Name: ShutterDoor1x2, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor1x2Prefab + SizeInBlocks: "1,2,1", display: true + HitPoints: 240, type: int, display: true + Mass: 360, type: float, display: true, formatter: Kilogram + PlayerTriggersOpen: true +} +{ +Block Id: 1037, Name: ShutterDoor2x3, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor2x3Prefab + SizeInBlocks: "2,3,1", display: true + HitPoints: 720, type: int, display: true + Mass: 640, type: float, display: true, formatter: Kilogram + PlayerTriggersOpen: true +} +{ Block Id: 1038, Name: Rainforest + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaRainforestPrefab + DecoParticle: chinesebanyan + ShowUser: No + SizeInBlocks: "5,7,5" + { Child DropOnDestroy + Item: WoodLogs + Count: "5,12" + Prob: 1 + } +} +# Name used for 3d model: AlienBirch +{ Block Id: 1039, Name: RiverBirch + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + # Model: @models2/Environment/Plants/SpeedTrees/RiverBirchPrefab + Model: @models2/Environment/Plants/SpeedTrees/AlienBirchPrefab + DecoParticle: chinesebanyan + ShowUser: No + SizeInBlocks: "1,7,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +# Name used for 3d model: AlienOak2 +{ Block Id: 1040, Name: WhiteOak + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/Environment/Plants/SpeedTrees/AlienOakPrefab + DecoParticle: chinesebanyan + ShowUser: No + SizeInBlocks: "3,7,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "5,12" + Prob: 1 + } +} +# Name used for 3d model: AlienElm +{ Block Id: 1041, Name: AmericanElm + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + # Model: @models2/Environment/Plants/SpeedTrees/AmericanElmPrefab + Model: @models2/Environment/Plants/SpeedTrees/AlienElmPrefab + DecoParticle: chinesebanyan + ShowUser: No + SizeInBlocks: "3,7,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,9" + Prob: 1 + } +} +{ Block Id: 1042, Name: PixieStalksSnowHarvested, Ref: PixieStalksSnow + Class: PlantGrowing + { Child PlantGrowing + Next: PixieStalksSnow + GrowthRate: 90 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/PixieStalksSnowHarvestedPrefab +} +{ Block Id: 1043, Name: AkuaPalm + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaPalmPrefab + DecoParticle: omicronwaterleaf + ShowUser: No + SizeInBlocks: "5,7,5" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 1044, Name: AkuaFern + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaFernPrefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } +} +{ Block Id: 1045, Name: AkuaPlant + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaPlantPrefab + DecoParticle: omicronwaterleaf + ShowUser: No + SizeInBlocks: "3,7,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 1046, Name: AlienPlantWorm1Harvested, Ref: AlienPlantWorm1 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlantWorm1 + GrowthRate: 45 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/AlienPlantsAnim/AlienPlantWorm1HarvestedPrefab +} +{ Block Id: 1047, Name: ElderberryBushHarvested, Ref: ElderberryBush + Class: PlantGrowing + { Child PlantGrowing + Next: ElderberryBush + GrowthRate: 55 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/ElderberryBushNoFruitPrefab +} +{ +Block Id: 1048, Name: AIDroneBay + Class: AIDroneBay + IndexName: AIDroneBay + Category: Devices + Group: cpgEquipment + LootList: 106 #8 Slots + MarketPrice: 150000, display: true #recalculate + # EssentialCategory: cpgGenerator + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + #Model: @models/Blocks/AIDrone/AIDroneBay2Prefab + Model: @models/Blocks/AIDrone/AIDroneBay3Prefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + StackSize: 50 + AllowPlacingAt: "Base,MS", display: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + SizeInBlocks: "5,5,15", display: true + SizeInBlocksLocked: "Base,MS" + SymType: 4 + IsOxygenTight: true, display: false + Info: bkiAIDroneBay, display: true + IsActivateable: true + + BlockColor: "110,110,110" + + HitPoints: 10750, type: int, display: true + Mass: 46875, type: float, display: true, formatter: Kilogram #recalculate + Volume: 7284, type: float, display: true, formatter: Liter + VolumeCapacity: 6701, type: float, display: true, formatter: Liter + CPUIn: 2500, type: int, display: true + + EnergyIn: 250, type: int, display: true, formatter: Watt + # EnergyDynamicGroup: AIDroneBay + + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 500 + + IsAccessible: true, type: bool + IsActivateableInCP: true + IsLockable: true + + ADBDroneType: DroneLargePlayer01 + ADBMaxSpawner: 2 + ADBMaxActiveDrone: 2 + ADBMaxReserve: 13 + ADBActionRadius: 1000 + ADBDockingSpeedFac: 0.2 + ADBDroneAmmoType: DroneLargePlayer01Unit + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: ContainerLargeBlocks #ContainerExtentions?? + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1049, Name: AIDroneBaySpawner + CustomIcon: DroneSpawner + Category: Devices + Group: cpgNPCSpawner + Info: bkiAIDroneBaySpawner, display: true + IndexName: DroneSpawner + Material: hullcombat + Shape: ModelEntity + Model: @models/Blocks/Misc/EntitySpawnerPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "255,247,0" + Place: Free + # TemplateRoot: HullLargeBlocks + # PickupTarget: SteelPlate + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + #MaxCount: 2, type: int, display: true + #ForceMaxCount: true + IsOxygenTight: false, display: true + + IsAccessible: true, type: bool + IsActivateable: true + IsActivateableInCP: true + + IsLockable: true + + HitPoints: 2800, type: int, display: true + Mass: 1020, type: float, display: true, formatter: Kilogram #recalculate + Volume: 480, type: float, display: true, formatter: Liter + CPUIn: 5000, type: int, display: true + EnergyIn: 500, type: int, display: true, formatter: Watt + + AboveTerrainCheck: true # requires a display: true ! + + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 500 + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: ContainerLargeBlocks (Teleporter?) + TechTreeNames: "Base,Capital Vessel" + +} +# available free 1050 - 1051 + +{ Block Id: 1052, Name: UmbrellaTree + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/UmbrellaTreePrefab + DecoParticle: omicronwaterleaf + ShowUser: No + SizeInBlocks: "2,7,2" + { Child DropOnDestroy + Item: WoodLogs + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 1053, Name: AkuaPlant2 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaPlant2Prefab + DecoParticle: omicronwaterleaf + ShowUser: No + SizeInBlocks: "2,7,2" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1054, Name: ElderberryBushOrange, Ref: ElderberryBushGreen + Model: @models2/Environment/Plants/SpeedTrees/ElderberryBushOrangePrefab + SizeInBlocks: "5,4,5" +} +{ Block Id: 1055, Name: LavaPlant1 + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein # change + Count: 1 + } + { Child CropsGrown + OnHarvest: LavaPlant1NoFruit + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1,2" + Prob: 1 + } + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/LavaPlant1Prefab + ShowUser: No + SizeInBlocks: "2,4,2" +} +{ Block Id: 1056, Name: LavaPlant2 + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein # change + Count: 1 + } + { Child CropsGrown + OnHarvest: LavaPlant2NoFruit + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1,2" + Prob: 1 + } + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/LavaPlant2Prefab + ShowUser: No +} +{ Block Id: 1057, Name: AlienDeadTree + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienDeadTreePrefab + ShowUser: No + SizeInBlocks: "5,4,5" +} +{ Block Id: 1058, Name: SnowTreeRed1 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/SnowTreeRed1Prefab + DecoParticle: snowtreered + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 1059, Name: SnowTreeRed2 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/SnowTreeRed2Prefab + DecoParticle: snowtreered + ShowUser: No + SizeInBlocks: "1,3,1" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,5" + Prob: 1 + } +} +{ Block Id: 1060, Name: LavaPlant1NoFruit, Ref: LavaPlant1 + Class: PlantGrowing + { Child PlantGrowing + Next: LavaPlant1 + GrowthRate: 25 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/LavaPlant1NoFruitsPrefab +} +{ Block Id: 1061, Name: LavaPlant2NoFruit, Ref: LavaPlant2 + Class: PlantGrowing + { Child PlantGrowing + Next: LavaPlant2 + GrowthRate: 25 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/SpeedTrees/LavaPlant2NoFruitsPrefab +} +{ Block Id: 1062, Name: AlienDesertPlant + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienDesertPlantPrefab + ShowUser: No + SizeInBlocks: "3,4,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1063, Name: LeafPlant + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/LeafPlantPrefab + ShowUser: No + SizeInBlocks: "3,4,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "1,3" + Prob: 1 + } +} +# retractable landinggear +{ +Block Id: 1064, Name: LandinggearHeavySV, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandinggearHeavyRetractPrefab + # Model: @models/Blocks/Landinggears/LandinggearHeavyPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + SizeInBlocks: "2,1,2", display: true + Mass: 232, type: float, display: true, formatter: Kilogram + HitPoints: 80, type: int, display: false + TemplateRoot: LandinggearBlocksHeavySV +} +{ Block Id: 1065, Name: SkeletonMammothTorso + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/Skeletons/SkeletonMammothTorsoPrefab + ShowUser: No + SizeInBlocks: "4,3,4" +} +{ Block Id: 1066, Name: SkeletonMammothHead, Ref: SkeletonMammothTorso + Model: @models2/Environment/Plants/Skeletons/SkeletonMammothHeadPrefab + SizeInBlocks: "2,2,4" +} +{ Block Id: 1067, Name: SkeletonWhaleFull, Ref: SkeletonMammothTorso + Model: @models2/Environment/Plants/Skeletons/SkeletonWhaleFullPrefab + SizeInBlocks: "4,2,14" +} +{ Block Id: 1068, Name: SkeletonWhaleHead, Ref: SkeletonMammothTorso + Model: @models2/Environment/Plants/Skeletons/SkeletonWhaleHeadPrefab + SizeInBlocks: "3,2,5" +} +{ Block Id: 1069, Name: SkeletonWhalePart, Ref: SkeletonMammothTorso + Model: @models2/Environment/Plants/Skeletons/SkeletonWhalePartPrefab + SizeInBlocks: "3,3,9" +} +{ Block Id: 1070, Name: SkeletonDragonTorso, Ref: SkeletonMammothTorso + Model: @models2/Environment/Plants/Skeletons/SkeletonDragonTorsoPrefab + SizeInBlocks: "3,3,7" +} +{ Block Id: 1071, Name: SkeletonDragonHead, Ref: SkeletonMammothTorso + Model: @models2/Environment/Plants/Skeletons/SkeletonDragonHeadPrefab + SizeInBlocks: "2,2,6" +} + +{ +Block Id: 1072, Name: ScifiBed, Ref: DecoTemplate + Class: Sleeping + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + BuffNamesActivate: "IncreaseBodyTemp", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst + # CanPickup: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiBedPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1073, Name: ScifiLargeSofa, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + Class: PassengerSeat + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiLargeSofaPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1074, Name: ScifiNightstand, Ref: DecoTemplate + Class: Container + IndexName: Container + IsLockable: true + IsAccessible: true, type: bool + Group: cpgFurniture + IsActivateableInCP: true + DropInventoryEntity: DropContainer + LootList: 65 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiNightstandPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 200, type: float, display: true, formatter: Liter +} + +{ +Block Id: 1075, Name: TrussLargeBlocks + Material: hullarmored + Shape: Invisible + MarketPrice: 70, display: true + Place: Free + # Model: Truss/TrussCube + Mesh: cutout + Texture: 59 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiBlockGroup, display: true + IsOxygenTight: false, display: true + Category: BuildingBlocks + IsTextureable: false + ChildBlocks: "TrussCube, TrussCorner, TrussWedge, TrussCurveOutSlope, TrussHalfRound, TrussQuarterRound, TrussCylinder, TrussQuarterRoundInv, TrussWall, TrussWedgeThin, TrussQuarterRoundThin, TrussCornerThin, TrussCornerRoundThin, TrussCornerRoundThin2" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + # Parent properties + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 250, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + HitPoints: 278, type: int, display: true + StackSize: 1000 + TemplateRoot: TrussLargeBlocks +} + +{ +Block Id: 1076, Name: ScifiSofa, Ref: DecoTemplate + Group: cpgEquipment + Class: PassengerSeat + IsAccessible: true, type: bool + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiSofaPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1077, Name: ScifiStorage, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + # CanPickup: true + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 64 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiStoragePrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 800, type: float, display: true, formatter: Liter +} +{ +Block Id: 1078, Name: ScifiTable, Ref: DecoTemplate + Group: cpgEquipment + Class: PassengerSeat + IsAccessible: true, type: bool + IsLockable: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiTablePrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + ShowUser: Yes +} +{ +Block Id: 1079, Name: ScifiShower, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiShowerPrefab + BlockColor: "170,170,170" + EnergyIn: 0, type: int, display: true, formatter: Watt + # BuffNamesActivate: "ReduceRadiation,DecreaseBodyTemp,IncreaseBodyTemp", display: StatusEffect + BuffNamesActivate: "ReduceRadiation,NeutralBodyTemp", display: StatusEffect + SfxBuffActivate: UseActions/BlockShower + ShowUser: Yes +} +{ +Block Id: 1080, Name: ScifiPlant, Ref: IndoorPlant01 + # CanPickup: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiPlantPrefab + ShowUser: Yes +} +# Container Block - Obsolete but do not delete +{ +Block Id: 1081, Name: ScifiContainer1, Ref: ContainerMS01 + LootList: 68 # 63 slots + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer1Prefab + SizeInBlocks: "2,1,1", display: true + Mass: 130, type: float, display: true, formatter: Kilogram + VolumeCapacity: 250, type: float, display: true, formatter: Liter # low value since placed on Base,MS,SS,GV + CPUIn: 30, type: int, display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} +# Container Block - Obsolete but do not delete +{ +Block Id: 1082, Name: ScifiContainer2, Ref: ContainerMS01 + LootList: 67 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer2Prefab + SizeInBlocks: "2,1,1", display: true + Mass: 66, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 125, type: float, display: true, formatter: Liter # low value since placed on Base,MS,SS,GV + CPUIn: 30, type: int, display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} +{ +Block Id: 1083, Name: ScifiContainerEnergy, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + Group: cpgEquipment + IsAccessible: true, type: bool + LootList: 61 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainerEnergyPrefab + SizeInBlocks: "1,2,1" + BlockColor: "110,110,110" + EnergyIn: 1, type: int, display: true, formatter: Watt + IsOxygenTight: false, display: true + ShowUser: Yes + VolumeCapacity: 2500, type: float, display: true, formatter: Liter # low value for testing +} +# Container Block - Obsolete but do not delete +{ +Block Id: 1084, Name: ScifiContainerPower, Ref: ContainerMS01 + LootList: 68 # 63 slots + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainerPowerPrefab + SizeInBlocks: "2,1,1", display: true + Mass: 130, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 250, type: float, display: true, formatter: Liter # low value since placed on Base,MS,SS,GV + CPUIn: 30, type: int, display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} +{ +Block Id: 1085, Name: ScifiChair, Ref: DecoTemplate + Class: PassengerSeat + Group: cpgEquipment + # YawRotation: 150 # how far in degrees head of player can be rotated (default 75°) + # IsActivateable: true + IsAccessible: true, type: bool + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiChairPrefab + ShowUser: Yes +} +{ +Block Id: 1086, Name: ScifiTableV2, Ref: DecoTemplate + Group: cpgEquipment + Class: PassengerSeat + IsAccessible: true, type: bool + IsLockable: true + BuffNameActivate: StaminaRecover, display: StatusEffect + # CanPickup: true + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiTableV2Prefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1087, Name: ScifiComputerTable, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiComputerTablePrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ +Block Id: 1088, Name: ScifiMediaCenter, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiMediaCenterPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1089, Name: HangarDoor7x5, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor7x5Prefab + SizeInBlocks: "7,5,1", display: true + HitPoints: 6010, type: int, display: true + Mass: 10230, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1090, Name: HangarDoor10x9, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor10x9Prefab + SizeInBlocks: "10,9,1", display: true + HitPoints: 15450, type: int, display: true + Mass: 11850, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1091, Name: CockpitMS03, Ref: CockpitMS01 + Model: @models/Blocks/Cockpits/CockpitMS03Prefab + BlockColor: "110,110,110" +} +{ +Block Id: 1092, Name: CockpitOpen2SV + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + Material: metalhard + UseCockpitCamera: false + YawRotation: 90 # how far in degrees head of player can be rotated (default 75°) + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Cockpits/CockpitOpen2SVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOORMetalCloseGentlyShort + Place: Cockpit + EnergyIn: 10, type: int, display: true, formatter: Watt + Mass: 240, type: float, display: true, formatter: Kilogram + HitPoints: 220, type: int, display: true + AllowPlacingAt: "SS,GV", display: true + OccupySizeInBlocks: true # Problem with placing hull blocks on top (see Mantis 05555) + IsLockable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiCockpit, display: true + SizeInBlocks: "1,2,3", display: true + TemplateRoot: CockpitBlocksSV + # ModelOffset: "0,0.5,0" + StackSize: 10 + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + BlockColor: "110,110,110" +} +{ +Block Id: 1093, Name: CockpitBlocksSV + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + MarketPrice: 1178, display: true + EssentialCategory: cpgCockpit + Material: metalhard + Shape: Invisible + # Model: @models/Blocks/Cockpits/CockpitSS04Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Cockpit + Info: bkiCockpit, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + HitPoints: 820, type: int, display: false + AllowPlacingAt: "SS,GV", display: true + Volume: 300, type: float, display: true, formatter: Liter + Mass: 845, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + StackSize: 500 + Category: Devices + ChildBlocks: "CockpitSV01, CockpitSV_ShortRange, CockpitSV02New, CockpitSV04, CockpitSV05New, CockpitSV06, CockpitSV07New, CockpitSV03New, CockpitOpenSV, CockpitOpen2SV, CockpitSV02, CockpitSV03, CockpitSV05, CockpitSV07" + MaxCount: 2, type: int, display: true + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 1094, Name: CockpitSV04, Ref: CockpitSV02 + Model: @models/Blocks/Cockpits/CockpitSS04Prefab + Mass: 845, type: float, display: true, formatter: Kilogram + HitPoints: 900, type: int, display: true + SizeInBlocks: "3,3,5", display: true + UpgradeTo: CockpitSV04T2, display: true +} +{ +Block Id: 1095, Name: LCDScreenBlocks + Material: metallight + Shape: Invisible + MarketPrice: 475, display: true + # Model: @models/Blocks/Misc/LCDNoFrame1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + HitPoints: 50, type: int, display: false + Mass: 2, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + StackSize: 2000 + + AllowPlacingAt: "Base,MS,SS,GV", display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: false, display: true + Category: Devices + ChildBlocks: "LCDProjector, LCDNoFrame1x2, LCDFrame1x2, LCDNoFrame1x1, LCDFrame1x1, LCDNoFrame05x1, LCDNoFrame05x05, LCDNoFrame02x1, LCDNoFrame02x05" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1096, Name: LCDNoFrame1x1 + Class: Sign + IndexName: LCD + Group: cpgLCD + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Misc/LCDNoFrame1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: LCDScreenBlocks + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsAccessible: true, type: bool + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + # CanPickup: true + IsActivateable: true + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices + # ModelOffset: "0,0.5,0" + SizeInBlocks: "1,1,1", display: true +} +{ +Block Id: 1097, Name: LCDFrame1x1, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDFrame1x1Prefab +} +{ +Block Id: 1098, Name: LCDNoFrame1x2, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDNoFrame1x2Prefab + SizeInBlocks: "2,1,1", display: true +} +{ +Block Id: 1099, Name: LCDFrame1x2, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDFrame1x2Prefab + SizeInBlocks: "2,1,1", display: true +} +{ +Block Id: 1100, Name: LCDNoFrame05x1, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDNoFrame05x1Prefab +} +{ +Block Id: 1101, Name: LCDNoFrame02x1, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDNoFrame02x1Prefab +} +{ +Block Id: 1102, Name: LCDNoFrame05x05, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDNoFrame05x05Prefab +} +{ +Block Id: 1103, Name: LCDNoFrame02x05, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDNoFrame02x05Prefab +} +{ +Block Id: 1104, Name: TurretGVTool, Ref: TurretDrillTemplate #TurretPlayer HV Tool + Model: @models/Blocks/Turrets/TurretGVToolPrefab + # Model: @models/Blocks/Turrets/TurretGVToolPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + WeaponItem: TurretGVToolWeapon + TemplateRoot: TurretGVToolBlocks + AllowPlacingAt: GV, display: true + SizeInBlocks: "3,1,3", display: true + Info: bkiTurretTool, display: true + EnergyIn: 750, type: int, display: true, formatter: Watt + EnergyInIdle: 8, type: int, display: true, formatter: Watt + Volume: 281.25, type: float, display: true, formatter: Liter + Mass: 420, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1105, Name: TurretMSTool, Ref: TurretDrillTemplate #TurretPlayer CV Tool + Model: @models/Blocks/Turrets/TurretMSToolPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + WeaponItem: TurretMSToolWeapon + TemplateRoot: TurretMSToolBlocks + Info: bkiTurretTool, display: true + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 9660, type: float, display: true, formatter: Kilogram + SizeInBlocks: "3,1,3", display: true + + EnergyIn: 2450, type: int, display: true, formatter: Watt + EnergyInIdle: 26, type: int, display: true, formatter: Watt + TechTreeNames: Hidden + + ShowUser: No +} + +{ +Block Id: 1106, Name: ThrusterGVRoundArmored, Ref: ThrusterGVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundArmoredPrefab +} + +{ +Block Id: 1107, Name: ThrusterGVRoundBlocks + IndexName: Thruster + Material: metalhard + Shape: Invisible + Place: Free + MarketPrice: 700, display: true + # Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSRoundNormalPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + StackSize: 1000 + Category: Devices + ChildBlocks: "ThrusterGVRoundNormal, ThrusterGVRoundSlant, ThrusterGVRoundArmored" + + AllowPlacingAt: GV, display: true + SizeInBlocks: "1,2,1", display: true + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 268, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + HitPoints: 200, type: int, display: true + ThrusterForce: 130, type: int, display: true, formatter: Newton + EnergyIn: 30, type: int, display: true, formatter: Watt + CPUIn: 88, type: int, display: true + Temperature: 90, display: true + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: ThrusterGVDirectional + TechTreeNames: Hover Vessel + UpgradeTo: ThrusterGVRoundNormalT2, display: true +} +{ +Block Id: 1108, Name: AutoMiningDeviceT1 + Class: AutoMiner + Material: metalhard + Shape: ModelEntity + MarketPrice: 38367, display: true + Model: @models/Blocks/Devices/AutoMinerT1FWNPrefab #1.11 update + #Model: @models/Blocks/Terrain/AutoMiningDeviceT1Prefab + #Mesh-Damage-1: Mesh/geo/D0 + #Mesh-Damage-2: Mesh/geo/D1 + #Mesh-Damage-3: Mesh/geo/D2 + #Mesh-Damage-4: Mesh/geo/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: NoRotation + Info: bkiAutoMiner, display: true + AllowPlacingAt: Terrain, display: true + IsTerrainDecoration: true + HitPoints: 200, type: int, display: true + EnergyIn: 80, type: int, display: true, formatter: Watt + ItemsPerHour: 100, display: true # Attention: ItemsPerHour max is hard coded and set to 500 + ItemStorageLimit: 1750, display: true + Mass: 1835, type: float, display: true, formatter: Kilogram + Volume: 200, type: float, display: true, formatter: Liter + StackSize: 500 + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + Category: Devices + IsColorable: false + SizeInBlocks: "3,6,3" + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeNames: Misc + UpgradeTo: AutoMiningDeviceT2, display: true + + MapIcon: map_Drill + MapName: AutoMiningDeviceT1 + + # Temperature: 30, display: true +} +{ +Block Id: 1109, Name: AutoMiningDeviceT2, Ref: AutoMiningDeviceT1 + Model: @models/Blocks/Devices/AutoMinerT2FWNPrefab #1.11 update + #Model: @models/Blocks/Terrain/AutoMiningDeviceT2Prefab + HitPoints: 400, type: int, display: true + MarketPrice: 80521, display: true + EnergyIn: 160, type: int, display: true, formatter: Watt + ItemsPerHour: 200, display: true # Attention: ItemsPerHour max is hard coded and set to 500 + ItemStorageLimit: 3500, display: true + Mass: 3370, type: float, display: true, formatter: Kilogram + Volume: 250, type: float, display: true, formatter: Liter + + UnlockCost: 17, display: true + UnlockLevel: 15, display: true + TechTreeParent: AutoMiningDeviceT1 + TechTreeNames: Misc + UpgradeTo: AutoMiningDeviceT3, display: true + + MapName: AutoMiningDeviceT2 + + # Temperature: 35, display: true +} +{ +Block Id: 1110, Name: AutoMiningDeviceT3, Ref: AutoMiningDeviceT1 + Model: @models/Blocks/Devices/AutoMinerT3FWNPrefab #1.11 update + #Model: @models/Blocks/Terrain/AutoMiningDeviceT3Prefab + HitPoints: 600, type: int, display: true + MarketPrice: 127907, display: true + EnergyIn: 320, type: int, display: true, formatter: Watt + ItemsPerHour: 400, display: true # Attention: ItemsPerHour max is hard coded and set to 500 + ItemStorageLimit: 6993, display: true + Mass: 6230, type: float, display: true, formatter: Kilogram + Volume: 300, type: float, display: true, formatter: Liter + + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeParent: AutoMiningDeviceT2 + TechTreeNames: Misc + UpgradeTo: null + + MapName: AutoMiningDeviceT3 + + # Temperature: 40, display: true +} +{ +Block Id: 1111, Name: RepairBayBA + Group: cpgRepair + Class: RepairBay + IndexName: RepairBay + MarketPrice: 81602, display: true + Info: bkiRepairBay, display: true + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/RepairBayPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + # Place: Free + Place: NoRotation, display: true + Category: Devices + IsAccessible: true, type: bool + IsActivateable: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + BlockColor: "110,110,110" + StackSize: 250 + IsOxygenTight: true, display: true + Mass: 12600, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + EnergyIn: 25000, type: int, display: true, formatter: Watt + CPUIn: 25000, type: int, display: true + HitPoints: 500, type: int, display: true + EnergyDynamicGroup: RepairBay + RepairPerSecond: 500, display: true + Repairs: "MS,SS,GV", display: true + IsDuplicateable: false + MaxCount: 1, type: int, display: true + # MaxIndexCount: 1, type: int, display: true + ForceMaxCount: true + UpgradeTo: RepairBayBAT2, display: true + AllowWander: true + + UnlockCost: 14, display: true + UnlockLevel: 12, display: true + TechTreeParent: FieldRepairStation + TechTreeNames: Base +} +{ +Block Id: 1112, Name: DoorArmoredBlocks + Class: Door + IndexName: Door + Group: cpgDoor + MarketPrice: 795, display: true + Material: metal + Shape: Invisible + # Model: @models/Blocks/Mothership/DoorNewArmoredPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + Info: bkiDoors, display: true + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + # HitPoints: 440, type: int, display: false + Mass: 910, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor + ChildBlocks: "DoorSingleLArmored, DoorSingleRArmored, DoorSingleGlassLArmored, DoorSingleGlassRArmored, DoorSingleGlassFullLArmored, DoorSingleGlassFullRArmored, DoorSingleTwoWings, DoorSingleTwoWingsGlass, DoorSingleTwoWingsGlassFull, DoorArmored, DoorInterior01Armored, DoorInterior02Armored, DoorVerticalArmored, DoorVerticalGlassArmored, DoorCenteredArmored01, DoorCenteredArmored02, DoorCornerArmored01, DoorCornerArmored02, DoorRoundArmored01, DoorRoundArmored02" + + UnlockCost: 9, display: true + UnlockLevel: 7, display: true + TechTreeParent: DoorBlocks + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1113, Name: DoorVertical, Ref: DoorMS01 + Model: @models/Blocks/Mothership/DoorVerticalPrefab + HitPoints: 100, type: int, display: false + UpgradeTo: DoorVerticalArmored, display: true +} +{ +Block Id: 1114, Name: DoorVerticalGlass, Ref: DoorInterior01 + Model: @models/Blocks/Mothership/DoorVerticalGlassPrefab + HitPoints: 40, type: int, display: false + IsOxygenTight: false, display: true + UpgradeTo: DoorVerticalGlassArmored, display: true +} +{ +Block Id: 1115, Name: DoorVerticalArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorVerticalArmoredPrefab + HitPoints: 220, type: int, display: false + Mass: 910, type: float, display: true, formatter: Kilogram +} +# retractable landinggear +{ +Block Id: 1116, Name: LandinggearSingleShort, Ref: LandinggearSV + # Model: @models/Blocks/Landinggears/LandinggearSingleShortPrefab + Model: @models/Blocks/Landinggears/LandinggearSingleShortRetractPrefab +} +# retractable landinggear +{ +Block Id: 1117, Name: LandinggearDoubleShort, Ref: LandinggearSV + SizeInBlocks: "1,1,4", display: true + Mass: 200, type: float, display: true, formatter: Kilogram + HitPoints: 80, type: int, display: false + # Model: @models/Blocks/Landinggears/LandinggearDoubleShortPrefab + Model: @models/Blocks/Landinggears/LandinggearDoubleShortRetractPrefab + TemplateRoot: LandinggearBlocksHeavySV +} +{ +Block Id: 1118, Name: LandinggearBlocksSV + IndexName: LandingGear + Material: metalhard + Shape: Invisible + MarketPrice: 145, display: true + ShowBlockName: true + IsPhysicsColliders: false + # Model: @models/Blocks/Landinggears/LandinggearLightPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: SS, display: true + Info: bkiBlockGroup, display: true + IsAccessible: false, type: bool + IsOxygenTight: varies, display: true # not all child blocks are the same + Category: Devices + SizeInBlocks: "1,1,2", display: false + # ModelOffset: "0,0.5,0" + Hardness: 50, type: float, display: false, formatter: Hardness + HitPoints: 50, type: int, display: false + Mass: 171, type: float, display: true, formatter: Kilogram + Volume: 25, type: float, display: true, formatter: Liter + StackSize: 1000 + EnergyIn: 1, type: int, display: false, formatter: Watt + ChildBlocks: "LandingGearSVSingle, LandingGearSVRetDouble, LandingGearSVRetSkid, LandingGearSVSideStrut, LandinggearShort, LandinggearSV, LandinggearSingle, LandinggearSingleShort" + + UnlockCost: 5, display: true + UnlockLevel: 5, display: true + TechTreeNames: Small Vessel +} +{ +Block Id: 1119, Name: LandinggearBlocksHeavySV, Ref: LandinggearBlocksSV + Shape: Invisible + MarketPrice: 340, display: true + # Model: @models/Blocks/Landinggears/LandinggearHeavyPrefab + Info: bkiBlockGroup, display: true + SizeInBlocks: "2,1,2", display: false + Mass: 240, type: float, display: true, formatter: Kilogram + HitPoints: 175, type: int, display: false + Volume: 50, type: float, display: true, formatter: Liter + ChildBlocks: "LandingGearSVRetLargeSingleV3, LandingGearSVRetLargeSingleV2, LandingGearSVRetLargeDoubleV3, LandingGearSVRetLargeDoubleV2, LandinggearDouble, LandinggearDoubleShort, LandinggearHeavySV" + + UnlockCost: 8, display: true + UnlockLevel: 10, display: true + TechTreeParent: LandinggearBlocksSV + TechTreeNames: Small Vessel +} +{ +Block Id: 1120, Name: LandinggearBlocksCV + Material: metalhard + Shape: Invisible + ShowBlockName: true + MarketPrice: 1534, display: true + # Model: @models/Blocks/Landinggears/LandinggearLightPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: MS, display: true + Info: bkiBlockGroup, display: true + SizeInBlocks: "1,1,2", display: false + # ModelOffset: "0,0.5,0" + IsAccessible: false, type: bool + IsOxygenTight: varies, display: true # not all child blocks are the same + Category: Devices + Hardness: 50, type: float, display: false, formatter: Hardness + HitPoints: 350, type: int, display: false + Mass: 1700, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + StackSize: 500 + EnergyIn: 1, type: int, display: false, formatter: Watt + ChildBlocks: "LandingGearCVRetLargeSingle, LandingGearCVRetLargeDouble, LandinggearMSLight, LandinggearSingleCV, LandinggearSingleShortCV, LandinggearDoubleCV, LandinggearDoubleShortCV, LandinggearMSHeavy" + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + TechTreeNames: Capital Vessel +} +# retractable landinggear +{ +Block Id: 1121, Name: LandinggearSingleCV, Ref: LandinggearMSHeavy + # Model: @models/Blocks/Landinggears/LandinggearSinglePrefab + Model: @models/Blocks/Landinggears/LandinggearSingleRetractPrefab + SizeInBlocks: "1,2,2", display: true + HitPoints: 350, type: int, display: true + Mass: 1700, type: float, display: true, formatter: Kilogram +} +# retractable landinggear +{ +Block Id: 1122, Name: LandinggearSingleShortCV, Ref: LandinggearMSHeavy + # Model: @models/Blocks/Landinggears/LandinggearSingleShortPrefab + Model: @models/Blocks/Landinggears/LandinggearSingleShortRetractPrefab + SizeInBlocks: "1,1,2", display: true + HitPoints: 350, type: int, display: true + Mass: 1700, type: float, display: true, formatter: Kilogram +} +# retractable landinggear +{ +Block Id: 1123, Name: LandinggearDoubleCV, Ref: LandinggearMSHeavy + # Model: @models/Blocks/Landinggears/LandinggearDoublePrefab + Model: @models/Blocks/Landinggears/LandinggearDoubleRetractPrefab + SizeInBlocks: "1,2,4", display: true + HitPoints: 650, type: int, display: true + Mass: 2800, type: float, display: true, formatter: Kilogram +} +# retractable landinggear +{ +Block Id: 1124, Name: LandinggearDoubleShortCV, Ref: LandinggearMSHeavy + # Model: @models/Blocks/Landinggears/LandinggearDoubleShortPrefab + Model: @models/Blocks/Landinggears/LandinggearDoubleShortRetractPrefab + SizeInBlocks: "1,1,4", display: true + HitPoints: 650, type: int, display: true + Mass: 2800, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1125, Name: StairShapes + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metal + Texture: "17,53,53,53,53,53" + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Stairs1x1x1 + TemplateRoot: StairsBlocks + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + Mass: 585, type: float, display: true, formatter: Kilogram + HitPoints: 130, type: int, display: true + StackSize: 1000 + Category: BuildingBlocks + ChildShapes: "Stairs1x1x1, StairsCurved, StairsCurvedLeft" +} +{ +Block Id: 1126, Name: StairShapesLong + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metal + Texture: "17,53,53,53,53,53" + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Stairs1x2x1 + TemplateRoot: StairsBlocks + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + Mass: 715, type: float, display: true, formatter: Kilogram + # Volume: 51, type: float, display: true, formatter: Liter + HitPoints: 175, type: int, display: true + SizeInBlocks: "1,1,2", display: true + Category: BuildingBlocks + ChildShapes: "Stairs1x2x1" +} +{ +Block Id: 1127, Name: HoverEngineSmall + IndexName: HoverEngineV2 + Group: cpgHoverEngine + EssentialCategory: cpgHoverEngine + Material: metal + MarketPrice: 665, display: true + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + Model: @models/Blocks/Spaceship/HoverEngineSmallPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: GV, display: true + Place: FlyDirBottom, display: true + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + EnergyDynamicGroup: HoverEngine + Category: Devices + Info: bkiHoverengine, display: true + Mass: 140, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + EnergyIn: 10, type: int, display: true, formatter: Watt + StackSize: 1000 + CPUIn: 50, type: int, display: true + # ThrusterForce: 1000, type: int, display: true, formatter: Newton + # HVEngineDampPow: 0.85, type: float, display: false, formatter: Hardness + # HVEngineDampCoef: 200, type: float, display: false, formatter: Hardness + + HVEngineForce: 200, type: int, display: true, formatter: Newton + HVEngineDampCoef: 30, type: float, display: false, formatter: Hardness + HVEngineDampPow: 1.5, type: float, display: false, formatter: Hardness + + HitPoints: 100, type: int, display: true + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 3, display: true + UnlockLevel: 3, display: true + TechTreeParent: HoverEngineThruster + TechTreeNames: Hover Vessel +} + +{ +Block Id: 1128, Name: WindowLargeBlocks + Material: hull + Shape: Invisible + MarketPrice: 390, display: true + # Model: @models/Blocks/Windows/Standard/Window_v1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiWindows, display: true + IsOxygenTight: true, display: true + IsAccessible: false, type: bool + Category: BuildingBlocks + # SizeInBlocks: "1,1,1" + # ModelOffset: "0,0.5,0" + # RemoveOnSI: true + ChildBlocks: "Window_v1x1, Window_v1x2, Window_v2x2, Window_s1x1, Window_s1x2, Window_sd1x1, Window_sd1x2, Window_sd1x2V2, Window_c1x1, Window_c1x2, Window_cr1x1, Window_crc1x1, Window_crsd1x1, Window_L1x1, Window_3side1x1, Window_crctw1x1, Window_creA1x1, Window_creB1x1, Window_crl1x1, Window_crse1x1, Window_cc1x1" + Collide: Transparent + + # Parent properties + TemplateRoot: WindowLargeBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + HitPoints: 250, type: int, display: true + Mass: 330, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + StackSize: 1000 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + UpgradeTo: WindowArmoredLargeBlocks, display: true +} +{ +Block Id: 1129, Name: WindowArmoredLargeBlocks + Material: hullcombat + Shape: Invisible + MarketPrice: 1046, display: true + # Model: @models/Blocks/Windows/Standard/Window_v1x1ThickPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiWindows, display: true + IsOxygenTight: true, display: true + IsAccessible: false, type: bool + Category: BuildingBlocks + # SizeInBlocks: "1,1,1" + # ModelOffset: "0,0.5,0" + # RemoveOnSI: true + ChildBlocks: "Window_v1x1Thick, Window_v1x2Thick, Window_v2x2Thick, Window_s1x1Thick, Window_s1x2Thick, Window_sd1x1Thick, Window_sd1x2Thick, Window_sd1x2V2Thick, Window_c1x1Thick, Window_c1x2Thick, Window_cr1x1Thick, Window_crc1x1Thick, Window_crsd1x1Thick, Window_L1x1Thick, Window_3side1x1Thick, Window_crctw1x1Thick, Window_creA1x1Thick, Window_creB1x1Thick, Window_crl1x1Thick, Window_crse1x1Thick, Window_cc1x1Thick" + Collide: Transparent + + TemplateRoot: WindowArmoredLargeBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 1000, type: int, display: true + Mass: 600, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + StackSize: 1000 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Id: 1130, Name: HoverEngineLarge, Ref: HoverEngineSmall + SizeInBlocks: "2,1,2", display: true + MarketPrice: 1298, display: true + Model: @models/Blocks/Spaceship/HoverEngineLargePrefab + Mass: 340, type: float, display: true, formatter: Kilogram + Volume: 125, type: float, display: true, formatter: Liter + EnergyIn: 40, type: int, display: true, formatter: Watt + CPUIn: 200, type: int, display: true + HVEngineForce: 800, type: int, display: true, formatter: Newton + HVEngineDampCoef: 100, type: float, display: false, formatter: Hardness + HVEngineDampPow: 1.5, type: float, display: false, formatter: Hardness + HitPoints: 350, type: int, display: true + + UnlockCost: 10, display: true + UnlockLevel: 8, display: true + TechTreeParent: HoverEngineSmall + TechTreeNames: Hover Vessel +} + +{ +Block Id: 1131, Name: RepairBayCV, Ref: RepairBayBA + AllowPlacingAt: MS, display: true + MarketPrice: 94888, display: true + EnergyIn: 20000, type: int, display: true, formatter: Watt + HitPoints: 500, type: int, display: true + Repairs: "SS,GV", display: true + RepairPerSecond: 200, display: true + UpgradeTo: RepairBayCVT2, display: true + Mass: 9225, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + StackSize: 250 + + UnlockCost: 12, display: true + UnlockLevel: 12, display: true + TechTreeParent: FieldRepairStation + TechTreeNames: Capital Vessel + +} + +{ +Block Id: 1132, Name: Furnace + Class: Furnace + IndexName: Constructor + Group: cpgConstructor + Material: metal + MarketPrice: 22712, display: true + Shape: ModelEntity + Model: @models/Blocks/Mothership/FurnacePrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + Info: bkiFurnace, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,3,3", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + # ModelOffset: "0,0.5,0" + SymType: 4 + StackSize: 250 + BlockColor: "110,110,110" + Category: Devices + Mass: 31825, type: float, display: true, formatter: Kilogram + Volume: 4320, type: float, display: true, formatter: Liter + EnergyIn: 2500, type: int, display: true, formatter: Watt + CPUIn: 10000, type: int, display: true + EnergyInIdle: 50, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + HitPoints: 1500, type: int, display: true + + BlastParticleIndex: 11 + BlastRadius: 5 + BlastDamage: 500 + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeParent: ConstructorT2 + TechTreeNames: "Base" + + Temperature: 250, display: true +} +{ +Block Id: 1133, Name: TradingStation + Class: TradingStation + Group: cpgTrading + Material: metallight + Shape: ModelEntity + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiMediaCenterTradingPrefab + # Model: @models/Blocks/Mothership/ConsoleMS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiTradingStation, display: true + Category: Devices + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: false, display: true + TemplateRoot: ConsoleBlocks + Mass: 165, type: float, display: true, formatter: Kilogram + Volume: 400, type: float, display: true, formatter: Liter + StackSize: 1000 + # ModelOffset: "0,0.5,0" + EnergyIn: 1, type: int, display: true, formatter: Watt + # BlockColor: "0,64,255" + BlockColor: "170,5,5" + + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 +} +{ +Block Id: 1134, Name: ATM + Class: ATM + Group: cpgTrading + MarketPrice: 1774, display: true + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/ATMPrefab + # Model: @models/Blocks/Mothership/ConsoleSmallMS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiATM, display: true + Category: Devices + IsAccessible: true, type: bool + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + Mass: 505, type: float, display: true, formatter: Kilogram + Volume: 400, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: true, formatter: Watt + StackSize: 1000 + Accept: "MoneyCard, GoldCoins" # the first one is payed out when withdrawing + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeNames: "Base" +} +{ +Block Id: 1135, Name: WingBlocks + Material: metallight + Texture: 50 + MarketPrice: 150, display: true + BlockColor: "110,110,110" + Shape: Invisible + # Model: Wings/Wing6x9a + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + HitPoints: 100, type: int, display: false + Mass: 800, type: float, display: true, formatter: Kilogram + Volume: 250, type: float, display: true, formatter: Liter + StackSize: 1000 + AllowPlacingAt: "SS", display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: false, display: true + SymType: 4 + Category: BuildingBlocks + ChildBlocks: "Wing6x5a, Wing6x5b, Wing6x5c, Wing6x5d, Wing6x5e, Wing6x9a, Wing6x9b, Wing6x9c, Wing6x9d, Wing6x9e, Wing9x6a, Wing9x6b, Wing9x6c, Wing9x6d, Wing9x6e, Wing12x9a, Wing12x9b, Wing12x9c, Wing12x9d, Wing12x9e" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Id: 1136, Name: BlastDoorLargeBlocks + Class: Door + MarketPrice: 11615, display: true + CustomIcon: BlastShutterDoors1x1x1 + IndexName: Door + Material: hullcombat + Shape: Invisible + # Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + StackSize: 500 + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + Category: Devices + AllowPlacingAt: "Base,MS", display: true + Volume: 1600, type: float, display: true, formatter: Liter + Mass: 4860, type: float, display: true, formatter: Kilogram + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: true + AllowWander: true + ChildBlocks: "BlastShutterDoors1x1x1, BlastShutterDoors2x1x1, BlastShutterDoors3x1x1, BlastShutterDoors4x1x1, BlastShutterDoors5x1x1, BlastShutterDoors6x1x1, BlastShutterDoors7x1x1, BlastShutterDoors8x1x1, BlastShutterDoors9x1x1, BlastShutterDoors10x1x1, BlastShutterDoors11x1x1, BlastShutterDoors12x1x1, BlastShutterDoors13x1x1" + + UnlockCost: 13, display: true + UnlockLevel: 12, display: true + TechTreeParent: HangarDoorBlocks + TechTreeNames: "Base,Capital Vessel" + + # ShowUser: No +} +{ Block Id: 1137, Name: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors1x1x1Prefab + Class: Door + IndexName: Door + Group: cpgHangarShutterDoor + Material: hullcombat + Shape: ModelEntity + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: BlastDoorLargeBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,2", display: true + Mass: 4860, type: float, display: true, formatter: Kilogram + SizeInBlocksLocked: "Base,MS" + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: false, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 2200, type: int, display: true + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + PlayerTriggersOpen: true +} +{ Block Id: 1138, Name: BlastShutterDoors2x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors2x1x1Prefab + SizeInBlocks: "1,1,3", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} + +{ +Block Id: 1139, Name: Wing6x9a + Material: metal + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Model: Wings/Wing6x9a + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: WingBlocks + IsOxygenTight: false, display: true + SymType: 4 + AllowPlacingAt: "SS", display: true + Mass: 941, type: float, display: true, formatter: Kilogram + HitPoints: 960, type: int, display: true + Category: BuildingBlocks + SizeInBlocks: "6,9,1", display: true +} +{ +Block Id: 1140, Name: Wing6x5a, Ref: Wing6x9a + Model: Wings/Wing6x5a + SizeInBlocks: "6,5,1", display: true + Mass: 549, type: float, display: true, formatter: Kilogram + HitPoints: 570, type: int, display: true +} +{ +Block Id: 1141, Name: Wing12x9a, Ref: Wing6x9a + Model: Wings/Wing12x9a + SizeInBlocks: "12,9,1", display: true + Mass: 1882, type: float, display: true, formatter: Kilogram + HitPoints: 1150, type: int, display: true +} + +# NEW +{ +Block Id: 1142, Name: TurretMSPulseLaser #TurretPlayer CV Laser + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretMSLaserBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + WeaponItem: TurretMSPulseLaserWeapon + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurretSpace, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 15100, type: int, display: true + Volume: 2880, type: float, display: true, formatter: Liter + Mass: 11760, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + RotSpeed: 100, type: int, display: true + Zoom: 5, type: float +} + +{ +Block Id: 1143, Name: TurretBaseCannon #TurretPlayer BA Cannon + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + # Model: @models/Blocks/Turrets/ModularTurret1/SinglePlasmaTurretBluePrefab + Model: @models/Blocks/Turrets/TurretMSCannonPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretBaseProjectileBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateFast + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + WeaponItem: TurretBaseCannonWeapon + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 8600, type: int, display: true + Mass: 5980, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + RotSpeed: 150, type: int, display: true + Zoom: 5, type: float +} + +{ +Block Id: 1144, Name: TurretBaseRocket #TurretPlayer BA Rocket + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + # Model: @models/Blocks/Turrets/ModularTurret1/SingleMissileTurretBluePrefab + Model: @models/Blocks/Turrets/TurretMSRocketPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretBaseRocketBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + WeaponItem: TurretBaseRocketWeapon + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + # ModelOffset: "0,0.5,0" + StackSize: 500 + MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 10500, type: int, display: true + Mass: 5777, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + Homing: true, display: true + RotSpeed: 120, type: int, display: true + Zoom: 5, type: float +} + +{ +Block Id: 1145, Name: TurretMSCannon #TurretPlayer CV Cannon + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSCannonPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateFast + WeaponItem: TurretMSCannonWeapon + TemplateRoot: TurretMSProjectileBlocks + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 2500, type: int, display: true + Mass: 6150, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + RotSpeed: 150, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 1146, Name: TurretMSFlak #TurretPlayer CV Flak + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: hullcombat + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSFlakPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretMSRocketBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + WeaponItem: TurretMSFlakWeapon + IsActivateable: true + IsActivateableInCP: true + Info: bkiTurretSpace, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + # MaxCount: 4, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 1300, type: int, display: true + Mass: 5777, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + RotSpeed: 120, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 1147, Name: TurretBaseMinigun #TurretPlayer BA Minigun + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + # Model: @models/Blocks/Turrets/TurretBAMinigunPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretBaseProjectileBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + WeaponItem: TurretBaseMinigunWeapon + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 2800, type: int, display: true + Mass: 6000, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + RotSpeed: 120, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 1148, Name: TurretBasePulseLaser #TurretPlayer BA Laser + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + # Model: @models/Blocks/Turrets/TurretBAPulseLaserPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretBaseLaserBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,1,3", display: true + SizeInBlocksLocked: "Base,MS" + WeaponItem: TurretBasePulseLaserWeapon + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 15100, type: int, display: true + Mass: 11760, type: float, display: true, formatter: Kilogram + HitPoints: 3450, type: int, display: true + + RotSpeed: 100, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 1149, Name: TurretBaseArtillery #TurretPlayer BA Artillery + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + # Model: @models/Blocks/Turrets/TurretBAArtilleryPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + # Mesh-Damage-5: Mesh/D4 + # Mesh-Damage-6: Mesh/D5 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: TurretBaseArtilleryBlocks + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: Base, display: true + SizeInBlocks: "5,2,5", display: true + SizeInBlocksLocked: "Base,MS" + WeaponItem: TurretBaseArtilleryWeapon + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + Info: bkiTurret, display: true + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 250 + MaxCount: 2, type: int, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 22600, type: int, display: true + Mass: 19800, type: float, display: true, formatter: Kilogram + HitPoints: 5500, type: int, display: true + + RotSpeed: 90, type: int, display: true + Zoom: 5, type: float +} +{ +Block Id: 1150, Name: Wing12x9b, Ref: Wing6x9a + Model: Wings/Wing12x9b + SizeInBlocks: "12,9,1", display: true + Mass: 1587, type: float, display: true, formatter: Kilogram + HitPoints: 1150, type: int, display: true +} +{ +Block Id: 1151, Name: Wing12x9c, Ref: Wing6x9a + Model: Wings/Wing12x9c + SizeInBlocks: "12,9,1", display: true + Mass: 1855, type: float, display: true, formatter: Kilogram + HitPoints: 1150, type: int, display: true +} +{ +Block Id: 1152, Name: Wing9x6a, Ref: Wing6x9a + Model: Wings/Wing9x6a + SizeInBlocks: "9,6,1", display: true +} +{ +Block Id: 1153, Name: Wing9x6b, Ref: Wing6x9a + Model: Wings/Wing9x6b + SizeInBlocks: "9,6,1", display: true +} +{ +Block Id: 1154, Name: Wing9x6c, Ref: Wing6x9a + Model: Wings/Wing9x6c + SizeInBlocks: "9,6,1", display: true +} +{ +Block Id: 1155, Name: Wing6x9b, Ref: Wing6x9a + Model: Wings/Wing6x9b + SizeInBlocks: "6,9,1", display: true +} +{ +Block Id: 1156, Name: Wing6x9c, Ref: Wing6x9a + Model: Wings/Wing6x9c + SizeInBlocks: "6,9,1", display: true +} +{ +Block Id: 1157, Name: Wing6x5b, Ref: Wing6x9a + Model: Wings/Wing6x5b + SizeInBlocks: "6,5,1", display: true + Mass: 443, type: float, display: true, formatter: Kilogram + HitPoints: 570, type: int, display: true +} +{ +Block Id: 1158, Name: Wing6x5c, Ref: Wing6x9a + Model: Wings/Wing6x5c + SizeInBlocks: "6,5,1", display: true + Mass: 457, type: float, display: true, formatter: Kilogram + HitPoints: 570, type: int, display: true +} +{ +Block Id: 1159, Name: Wing6x5d, Ref: Wing6x9a + Model: Wings/Wing6x5d + SizeInBlocks: "6,5,1", display: true + Mass: 554, type: float, display: true, formatter: Kilogram + HitPoints: 570, type: int, display: true + SymType: 1 +} +{ +Block Id: 1160, Name: Wing6x5e, Ref: Wing6x9a + Model: Wings/Wing6x5e + SizeInBlocks: "6,5,1", display: true + Mass: 538, type: float, display: true, formatter: Kilogram + HitPoints: 570, type: int, display: true + SymType: 1 +} +{ +Block Id: 1161, Name: Wing6x9d, Ref: Wing6x9a + Model: Wings/Wing6x9d + SizeInBlocks: "6,9,1", display: true + SymType: 1 +} +{ +Block Id: 1162, Name: Wing6x9e, Ref: Wing6x9a + Model: Wings/Wing6x9e + SizeInBlocks: "6,9,1", display: true + SymType: 1 +} +{ +Block Id: 1163, Name: Wing12x9d, Ref: Wing6x9a + Model: Wings/Wing12x9d + SizeInBlocks: "12,9,1", display: true + Mass: 1981, type: float, display: true, formatter: Kilogram + HitPoints: 1150, type: int, display: true + SymType: 1 +} +{ +Block Id: 1164, Name: Wing12x9e, Ref: Wing6x9a + Model: Wings/Wing12x9e + SizeInBlocks: "12,9,1", display: true + Mass: 1969, type: float, display: true, formatter: Kilogram + HitPoints: 1150, type: int, display: true + SymType: 1 +} +{ +Block Id: 1165, Name: Wing9x6d, Ref: Wing6x9a + Model: Wings/Wing9x6d + SizeInBlocks: "9,6,1", display: true + SymType: 1 +} +{ +Block Id: 1166, Name: Wing9x6e, Ref: Wing6x9a + Model: Wings/Wing9x6e + SizeInBlocks: "9,6,1", display: true + SymType: 1 +} +# Sprout +{ Block Id: 1167, Name: ElderberryStage1 + Class: PlantGrowing + MarketPrice: 78, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/ElderberryBushStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + CropType: Berries, display: true + { Child PlantGrowing + Next: ElderberryStage2 + GrowthRate: 34 + FertileLevel: 5 + OnDeath: PlantDead2 + } + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropYield: 4, display: true + GrowthTimeInfo: 74, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: ElderberryStage1 # disassemble + TemplateRoot: ElderberryStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1168, Name: ElderberryStage2, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/ElderberryBushStage2Prefab + { Child PlantGrowing + Next: ElderberryStage3 + GrowthRate: 44 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: NoButCSV # reported by HWS + PickupTarget: ElderberryStage1 +} +{ Block Id: 1169, Name: ElderberryStage3, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/ElderberryBushStage3Prefab + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: Berries + Count: 4 + } + { Child CropsGrown + OnHarvest: ElderberryStage3NoFruit + OnDeath: PlantDead2 + } + { Child DropOnDestroy + Item: Fiber + Count: "4" + Prob: 1 + } + # ShowUser: NoButCSV # reported by HWS + PickupTarget: ElderberryStage1 + XpFactor: 0.25 +} +{ Block Id: 1170, Name: ElderberryStage3NoFruit, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/ElderberryBushStage3NoFruitPrefab + { Child PlantGrowing + Next: ElderberryStage3 + GrowthRate: 25 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: NoButCSV # reported by HWS + PickupTarget: ElderberryStage1 +} +# Sprout +{ Block Id: 1171, Name: AlienPalmTreeStage1, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/AlienPalmTreeStage1Prefab + MarketPrice: 78, display: true + CropType: Fruit, display: true + { Child PlantGrowing + Next: AlienPalmTreeStage2 + GrowthRate: 35 + FertileLevel: 5 + OnDeath: PlantDead2 + } + # CropYield: 8, display: true + GrowthTimeInfo: 135, type: int, display: true, formatter: Minutes + + PickupTarget: AlienPalmTreeStage1 # disassemble + TemplateRoot: AlienPalmTreeStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1172, Name: AlienPalmTreeStage2, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/AlienPalmTreeStage2Prefab + { Child PlantGrowing + Next: AlienPalmTreeStage3 + GrowthRate: 100 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: AlienPalmTreeStage1 + TemplateRoot: AlienPalmTreeStage2 +} +{ Block Id: 1173, Name: AlienPalmTreeStage3, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/AlienPalmTreeStage3Prefab + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: Fruit + Count: 8 + } + { Child CropsGrown + OnHarvest: AlienPalmTreeStage3NoFruit + OnDeath: PlantDead2 + } + { Child DropOnDestroy + Item: WoodLogs + Count: "1" + Prob: 1 + } + # ShowUser: NoButCSV # reported by HWS + PickupTarget: AlienPalmTreeStage1 + TemplateRoot: AlienPalmTreeStage2 + XpFactor: 0.25 +} +{ Block Id: 1174, Name: AlienPalmTreeStage3NoFruit, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/AlienPalmTreeStage3NoFruitPrefab + { Child PlantGrowing + Next: AlienPalmTreeStage3 + GrowthRate: 130 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: AlienPalmTreeStage1 + TemplateRoot: AlienPalmTreeStage2 +} +# Sprout +{ Block Id: 1175, Name: AlienplantWormStage1, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/AlienplantWormStage1Prefab + MarketPrice: 78, display: true + CropType: NaturalSweetener, display: true + { Child PlantGrowing + Next: AlienplantWormStage2 + GrowthRate: 27 + FertileLevel: 5 + OnDeath: PlantDead + } + # CropYield: 2, display: true + GrowthTimeInfo: 62, type: int, display: true, formatter: Minutes + PickupTarget: AlienplantWormStage1 # disassemble + TemplateRoot: AlienplantWormStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1176, Name: AlienplantWormStage2, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/AlienplantWormStage2Prefab + { Child PlantGrowing + Next: AlienplantWormStage3 + GrowthRate: 35 + FertileLevel: 5 + OnDeath: PlantDead + } + ShowUser: No + PickupTarget: AlienplantWormStage1 + TemplateRoot: AlienplantWormStage2 +} +{ Block Id: 1177, Name: AlienplantWormStage3, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/AlienplantWormStage3Prefab + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: NaturalSweetener + Count: 2 + } + { Child CropsGrown + OnHarvest: AlienplantWormStage3Harvested + OnDeath: PlantDead + } + # ShowUser: NoButCSV # reported by HWS + PickupTarget: AlienplantWormStage1 + TemplateRoot: AlienplantWormStage2 + XpFactor: 0.25 +} +{ Block Id: 1178, Name: AlienplantWormStage3Harvested, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/AlienplantWormStage3HarvestedPrefab + { Child PlantGrowing + Next: AlienplantWormStage3 + GrowthRate: 62 + FertileLevel: 5 + OnDeath: PlantDead + } + ShowUser: No + PickupTarget: AlienplantWormStage1 + TemplateRoot: AlienplantWormStage2 +} +# Sprout +{ Block Id: 1179, Name: BulbShroomYoungStage1, Ref: ElderberryStage1 + # Model: @models2/Entities/Farming/Misc/BulbShroomYoungStage1Prefab + Model: @models2/Entities/Farming/Misc/BulbShroomStage1Prefab + MarketPrice: 78, display: true + CropType: Vegetables, display: true + { Child PlantGrowing + Next: BulbShroomYoungStage2 + GrowthRate: 15 + FertileLevel: 5 + OnDeath: PlantDead2 + } + # CropYield: 1, display: true + GrowthTimeInfo: 31, type: int, display: true, formatter: Minutes + PickupTarget: BulbShroomYoungStage1 # disassemble + TemplateRoot: BulbShroomYoungStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1180, Name: BulbShroomYoungStage2, Ref: ElderberryStage1 + # Model: @models2/Entities/Farming/Misc/BulbShroomYoungStage2Prefab + Model: @models2/Entities/Farming/Misc/BulbShroomStage2Prefab + { Child PlantGrowing + Next: BulbShroomStage3 + GrowthRate: 15 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: BulbShroomYoungStage1 + TemplateRoot: BulbShroomYoungStage2 +} +{ Block Id: 1181, Name: BulbShroomStage3, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/BulbShroomStage3Prefab + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: Vegetables + Count: 1 + } + { Child CropsGrown + OnHarvest: BulbShroomYoungStage3 + OnDeath: PlantDead2 + } + # ShowUser: NoButCSV # reported by HWS + PickupTarget: BulbShroomYoungStage1 + TemplateRoot: BulbShroomYoungStage2 + XpFactor: 0.25 +} +# Harvested model +{ Block Id: 1182, Name: BulbShroomYoungStage3, Ref: ElderberryStage1 + # Model: @models2/Entities/Farming/Misc/BulbShroomYoungStage3Prefab + Model: @models2/Entities/Farming/Misc/BulbShroomStage3NoFruitPrefab + { Child PlantGrowing + Next: BulbShroomStage3 + GrowthRate: 29 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: BulbShroomYoungStage1 + TemplateRoot: BulbShroomYoungStage2 +} +{ +Block Id: 1183, Name: Window_3side1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_3side1x1Prefab + UpgradeTo: Window_3side1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1184, Name: Window_3side1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_3side1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_3side1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1185, Name: Window_L1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_L1x1Prefab + UpgradeTo: Window_L1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1186, Name: Window_L1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_L1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_L1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1187, Name: Window_3side1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_3side1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1188, Name: Window_3side1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_3side1x1ThickInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1189, Name: Window_L1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_L1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1190, Name: Window_L1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_L1x1ThickInvPrefab + ShowUser: NoButCSV # reported by HWS + SymType: 2 +} +{ +Block Id: 1191, Name: RailingVertGlass + Material: hull + Shape: ModelEntity + Model: @models/Blocks/Windows/Railing/RailingVertGlassPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + Place: Free + TemplateRoot: WalkwayLargeBlocks + BlockColor: "110,110,110" + IsAccessible: false, type: bool + Category: BuildingBlocks + # ModelOffset: "0,0.5,0" + HitPoints: 25, type: int, display: false + Mass: 24, type: float, display: true, formatter: Kilogram + AllowPlacingAt: "Base,MS,SS,GV", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Collide: Transparent + ParentBlocks: "WalkwaySmallBlocks, WalkwayLargeBlocks" +} +{ +Block Id: 1192, Name: RailingVertGlassInv, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingVertGlassInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1193, Name: RailingRoundGlass, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingRoundGlassPrefab + SymType: 2 +} +{ +Block Id: 1194, Name: RailingRoundGlassInv, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingRoundGlassInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1195, Name: RailingLGlass, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingLGlassPrefab + SymType: 2 +} +{ +Block Id: 1196, Name: RailingLGlassInv, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingLGlassInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1197, Name: Window_crctw1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crctw1x1Prefab + UpgradeTo: Window_crctw1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1198, Name: Window_creA1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_creA1x1Prefab + UpgradeTo: Window_creA1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1199, Name: Window_creB1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_creB1x1Prefab + UpgradeTo: Window_creB1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1200, Name: Window_crl1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crl1x1Prefab + UpgradeTo: Window_crl1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1201, Name: Window_crse1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crse1x1Prefab + UpgradeTo: Window_crse1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1202, Name: Window_cc1x1, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_cc1x1Prefab + UpgradeTo: Window_cc1x1Thick, display: true + SymType: 2 +} +{ +Block Id: 1203, Name: Window_crctw1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crctw1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1204, Name: Window_creA1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_creA1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1205, Name: Window_creB1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_creB1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1206, Name: Window_crl1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crl1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1207, Name: Window_crse1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crse1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1208, Name: Window_cc1x1Thick, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_cc1x1ThickPrefab + SymType: 2 +} +{ +Block Id: 1209, Name: Window_creA1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_creA1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_creA1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1210, Name: Window_crctw1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crctw1x1InvPrefab + ShowUser: No + UpgradeTo: Window_crctw1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1211, Name: Window_creB1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_creB1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_creB1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1212, Name: Window_crl1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crl1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_crl1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1213, Name: Window_crse1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_crse1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_crse1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1214, Name: Window_cc1x1Inv, Ref: Window_v1x1 + Model: @models/Blocks/Windows/Standard/Window_cc1x1InvPrefab + ShowUser: NoButCSV + UpgradeTo: Window_cc1x1ThickInv, display: true + SymType: 2 +} +{ +Block Id: 1215, Name: Window_crctw1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crctw1x1ThickInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1216, Name: Window_creA1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_creA1x1ThickInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1217, Name: Window_creB1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_creB1x1ThickInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1218, Name: Window_crl1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crl1x1ThickInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1219, Name: Window_crse1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_crse1x1ThickInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1220, Name: Window_cc1x1ThickInv, Ref: Window_v1x1Thick + Model: @models/Blocks/Windows/Standard/Window_cc1x1ThickInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1221, Name: RailingSlopeGlassRight, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingSlopeGlassRPrefab + ShowUser: No + SymType: 4 + MirrorTo: RailingSlopeGlassLeft +} +{ +Block Id: 1222, Name: RailingSlopeGlassRightInv, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingSlopeGlassRInvPrefab + ShowUser: NoButCSV + SymType: 4 + MirrorTo: RailingSlopeGlassLeftInv +} +{ +Block Id: 1223, Name: RailingSlopeGlassLeft, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingSlopeGlassLPrefab + ShowUser: No + SymType: 4 + MirrorTo: RailingSlopeGlassRight +} +{ +Block Id: 1224, Name: RailingSlopeGlassLeftInv, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingSlopeGlassLInvPrefab + ShowUser: NoButCSV + SymType: 4 + MirrorTo: RailingSlopeGlassRightInv +} +{ +Block Id: 1225, Name: RailingDiagonalGlass, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingDiagonalGlassPrefab + ShowUser: No + SymType: 2 +} +{ +Block Id: 1226, Name: RailingDiagonalGlassInv, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingDiagonalGlassInvPrefab + ShowUser: NoButCSV + SymType: 2 +} +{ +Block Id: 1227, Name: LeverSV + Class: Switch + Group: cpgSignal + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Misc/LeverSVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocksSV + PickupTarget: SensorTriggerBlocksSV + Place: Face + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + IsActivateable: true + IsLockable: true + Mass: 0.4, type: float, display: true, formatter: Kilogram + AllowPlacingAt: "SS,GV", display: true + Sound: UseActions/Lever + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 3, type: int, display: true +} +{ +Block Id: 1228, Name: LightBarrierSV + Class: LightBarrier + Group: cpgSignal + Material: metallight + Shape: LightBarrier + Model: @models/Blocks/Misc/LightBarrierSVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocksSV + PickupTarget: SensorTriggerBlocksSV + Place: Face + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + IsActivateable: true + IsLockable: true + Mass: 0.4, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 3, type: int, display: true + AllowPlacingAt: "SS,GV", display: true + Sound: UseActions/LightBarrier +} +{ +Block Id: 1229, Name: MotionSensorSV + Class: MotionSensor + Group: cpgSignal + Material: metallight + Shape: Config3DCollider + Model: @models/Blocks/Misc/MotionSensorSVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocksSV + PickupTarget: SensorTriggerBlocksSV + Place: Face + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + IsActivateable: true + IsLockable: true + Mass: 0.4, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 3, type: int, display: true + AllowPlacingAt: "SS,GV", display: true + Sound: UseActions/MotionSensor +} + +{ +Block Id: 1230, Name: SensorTriggerBlocksSV + Material: metal + Shape: Invisible + MarketPrice: 127, display: true + # Model: @models/Blocks/Misc/LeverPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "SS,GV", display: false + Info: bkiBlockGroup, display: true + Mass: 0.4, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: false, formatter: Watt + StackSize: 4000 + Category: Devices + ChildBlocks: "LeverSV, MotionSensorSV, LightBarrierSV" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Id: 1231, Name: RepairBayBAT2, Ref: RepairBayBA + Model: @models/Blocks/Misc/RepairBayT2Prefab + MarketPrice: 123376, display: true + + IsRepairToBlueprint: true + Mass: 14556, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + + Info: bkiRepairBayT2, display: true + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: RepairBayBA + TechTreeNames: Base + + UpgradeTo: null, display: false +} +{ +Block Id: 1232, Name: Closet, Ref: DecoTemplate + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 64 + Model: @models2/Entities/Decorations/Misc/ClosetPrefab + SymType: 4 + ShowUser: No + VolumeCapacity: 1250, type: float, display: true, formatter: Liter +} +{ +Block Id: 1233, Name: DoorVerticalGlassArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorVerticalGlassArmoredPrefab + HitPoints: 100, type: int, display: false + Mass: 820, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true +} +{ +Block Id: 1234, Name: DoorInterior01Armored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorInterior01ArmoredPrefab + HitPoints: 220, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1235, Name: DoorInterior02Armored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorInterior02ArmoredPrefab + HitPoints: 100, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true +} +{ +Block Id: 1236, Name: DrillAttachmentT2, Ref: DrillAttachment + Model: @models/Blocks/WeaponsShip/DrillAttachmentT2RetractPrefab + # Model: @models/Blocks/WeaponsShip/DrillAttachmentT2Prefab + WeaponItem: DrillAttachmentT2Weapon + Damage: 30, display: true + MarketPrice: 14411, display: true + + Volume: 62.5, type: float, display: true, formatter: Liter + Mass: 1165, type: float, display: true, formatter: Kilogram + StackSize: 500 + + EnergyIn: 650, type: int, display: true, formatter: Watt + EnergyInIdle: 6, type: int, display: true, formatter: Watt + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: TurretGVToolBlocks + TechTreeNames: Hover Vessel +} +# actually it new version of CockpitSV07 (=CockpitSS07Prefab) +{ +Block Id: 1237, Name: CockpitSV06, Ref: CockpitSV07 + Model: @models/Blocks/Cockpits/CockpitSS06Prefab + Mass: 835, type: float, display: true, formatter: Kilogram + HitPoints: 800, type: int, display: true + UpgradeTo: CockpitSV06T2, display: true +} +{ +Block Id: 1238, Name: GrowingPotConcrete, Ref: GrowingPot + Info: bkiGrowingPotSimple, display: true + MarketPrice: 748, display: true + Texture: "6,93,93,93,93,93" + TextureTable: 2 # 2 = concrete table + BlockColor: "110,110,110" + AllowPlacingAt: Base, display: true + Mass: 1880, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + UpgradeTo: GrowingPot, display: true +} +{ +Block Id: 1239, Name: GrowingPotWood, Ref: GrowingPot + # Material: fertilelow + MarketPrice: 430, display: true + Info: bkiGrowingPotSimple, display: true + Texture: "6,70,70,70,70,70" + TextureTable: 1 # 1 = wood table + BlockColor: "123,73,46" + AllowPlacingAt: Base, display: true + Mass: 795, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + UpgradeTo: GrowingPotConcrete, display: true +} +{ +Block Id: 1240, Name: ScifiTableNPC2, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + Class: PassengerSeat + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + NPCModelRotation: false + + Model: @models2/Entities/Decorations/DecoNPC/ScifiTableNPC2Prefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1241, Name: ScifiTableNPC3, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + NPCModelRotation: false + Model: @models2/Entities/Decorations/DecoNPC/ScifiTableNPC3Prefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1242, Name: ScifiLargeSofaNPC, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + NPCModelRotation: false + Model: @models2/Entities/Decorations/DecoNPC/ScifiLargeSofaNPCPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1243, Name: ConsoleSmallNPC, Ref: NPCAlienTemplate + Group: cpgCrewStationAlien + IsAccessible: true, type: bool + NPCModelRotation: false + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ConsoleSmallNPCPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1244, Name: ScifiTableV2NPC, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + Class: PassengerSeat + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + Model: @models2/Entities/Decorations/DecoNPC/ScifiTableV2NPCPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1245, Name: SofaNPC, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + Class: PassengerSeat + Group: cpgEquipment + Model: @models2/Entities/Decorations/DecoNPC/SofaNPCPrefab + IsAccessible: true, type: bool + IsLockable: true + SizeInBlocks: "2,1,2", display: true + ShowUser: Yes +} +{ +Block Id: 1246, Name: StandingNPC, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + BlockColor: "170,5,5" + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/StandingNPCPrefab + ShowUser: Yes +} +{ +Block Id: 1247, Name: ControlStationNPC, Ref: NPCAlienTemplate + Group: cpgCrewStationAlien + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + BlockColor: "170,170,170" + NPCModelRotation: false + Model: @models2/Entities/Decorations/DecoNPC/ControlStationNPCPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1248, Name: ReceptionTableNPC, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ReceptionTableNPCPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + IsAccessible: true, type: bool +} +{ +Block Id: 1249, Name: ScifiSofaNPC, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # Group: cpgEquipment + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ScifiSofaNPCPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1250, Name: ScifiTableNPC, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + # Group: cpgEquipment + # CanPickup: true + NPCModelRotation: false + Model: @models2/Entities/Decorations/DecoNPC/ScifiTableNPCPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1251, Name: StandingNPC2, Ref: NPCAlienTemplate + # AllowedInBlueprint: false, display: true + BlockColor: "255,247,0" + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/StandingNPC2Prefab + ShowUser: Yes +} +{ +Block Id: 1252, Name: ConsoleSmallHuman, Ref: NPCHumanTemplate + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + NPCModelRotation: false + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ConsoleSmallHumanPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1253, Name: CockpitBlocksCV + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + MarketPrice: 18081, display: true + EssentialCategory: cpgCockpit + Material: metal + Shape: Invisible + ShowBlockName: true + # Model: @models/Blocks/Cockpits/CockpitCVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Cockpit + Info: bkiCockpit, display: true + Mass: 800, type: float, display: true, formatter: Kilogram + Volume: 365, type: float, display: true, formatter: Liter + HitPoints: 700, type: int, display: false + AllowPlacingAt: MS, display: true + IsIgnoreLC: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + Category: Devices + ChildBlocks: "CaptainChair01, CockpitMS01, CockpitMS02, CockpitMS03" + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + TechTreeNames: Capital Vessel +} +{ +Block Id: 1254, Name: AlienDeviceBlocks + AllowedInBlueprint: false, display: true + Material: metal + Shape: Invisible + # Model: @models/Blocks/Misc/CoreBlockV2EnemyPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + Info: bkiBlockGroup, display: true + Mass: 1, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: false, formatter: Watt + Category: Devices + ChildBlocks: "CoreNPC, CoreNPCAdmin, TurretIONCannon, TurretIONCannon2, TurretEnemyLaser, TurretEnemyRocket, TurretEnemyArtillery, TurretEnemyEMP, SentryGun01, SentryGun01Retract, SentryGun02, SentryGun02Retract, AlienContainer, AlienContainerRare, AlienContainerVeryRare, AlienContainerUltraRare, EntitySpawner1, EntitySpawnerPlateThin, DroneSpawner, DroneSpawner2, Trader, StandingAlienAssassin, StandingHexapod, HoverEngineSmallDeco, HoverEngineLargeDeco, DancingAlien3, DancingAlien4, TalonChief, StandingTalon, ControlFieldGenerator,StandingHishkal" +} +# Obsolete Block (has been used during EXP 6 phase) +{ Block Id: 1255, Name: HullDestroyed + Material: hull + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: Invisible + # Model: Cube + TemplateRoot: HullDestroyed + Info: bkiBlockGroup, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + HitPoints: 50, type: int, display: false + Mass: 1000, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildBlocks: "HullDestroyedShapes" + HideShapesWindow: true + IsOxygenTight: false, display: true + ShowUser: NoButCSV # reported by HWS +} +# Obsolete Block (has been used during EXP 6 phase) +{ Block Id: 1256, Name: HullDestroyedShapes + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hullarmored + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullDestroyed + AllowPlacingAt: "Base,MS,SS,GV", display: true + HitPoints: 50, type: int, display: true + Mass: 1000, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CubeHalfDestroyed, RampCDestroyed, RampADestroyed, EdgeRoundDestroyed, RoundCornerADestroyed, CylinderDestroyed, CylinderThinDestroyed, WallDestroyed, RampCMediumDestroyed" + IsOxygenTight: false, display: true + ShowUser: NoButCSV # reported by HWS +} +{ +Block Id: 1257, Name: SensorTriggerBlocks + Material: metal + Shape: Invisible + MarketPrice: 260, display: true + # Model: @models/Blocks/Misc/LeverPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + Info: bkiBlockGroup, display: true + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 0.2, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: false, formatter: Watt + StackSize: 4000 + Category: Devices + ChildBlocks: "Lever, MotionSensor, LightBarrier, TrapDoor, TrapDoorAnim, TriggerPlate" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1258, Name: TrapDoor + Class: TrapDoorOneTime # explodes on contact + Group: cpgSignal + Material: metal + Shape: New + Model: Trapdoor + Texture: 26 + BlockColor: "26,50,89" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocks + PickupTarget: SensorTriggerBlocks + AllowPlacingAt: "Base,MS", display: true + IsActivateable: true + Place: Free + IsAccessible: true, type: bool + # IsLockable: true + IsOxygenTight: true, display: true + Mass: 122, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 1, type: int, display: true + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1 + BlastDamage: 1 +} +{ +Block Id: 1259, Name: LightBarrier + Class: LightBarrier + Group: cpgSignal + Material: metallight + Shape: LightBarrier + Model: @models/Blocks/Misc/LightBarrierPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocks + PickupTarget: SensorTriggerBlocks + Place: Face + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + IsActivateable: true + IsLockable: true + Mass: 1, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 10, type: int, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + ModelScaleLocked: true + Sound: UseActions/LightBarrier +} +{ +Block Id: 1260, Name: MotionSensor + Class: MotionSensor + Group: cpgSignal + Material: metallight + Shape: Config3DCollider + Model: @models/Blocks/Misc/MotionSensorPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocks + PickupTarget: SensorTriggerBlocks + Place: Face + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + IsActivateable: true + IsLockable: true + Mass: 1, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 10, type: int, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + ModelScaleLocked: true + Sound: UseActions/MotionSensor +} +{ Block Id: 1261, Name: SpringGun + Class: Weapon + Group: cpgSignal + IndexName: WeaponShip + Material: hull + Shape: ModelEntity + Model: @models/Blocks/Turrets/SentryGuns/SentryGunAlienWallPrefab + # Model: @models/Blocks/Turrets/SentryGuns/sentryGun01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + AllowPlacingAt: "Base,MS", display: true + ShowUser: No +} +{ +Block Id: 1262, Name: Lever + Class: Switch + Group: cpgSignal + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Misc/LeverPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocks + PickupTarget: SensorTriggerBlocks + Place: Face + # ModelOffset: "0,0.5,0" + # SizeInBlocks: "1,1,1" + IsActivateable: true + IsLockable: true + Mass: 1, type: float, display: true, formatter: Kilogram + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + ModelScaleLocked: true + Sound: UseActions/Lever + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 10, type: int, display: true +} +# Wallblast (only self destruction) +{ +Block Id: 1263, Name: ExplosiveBlocks # We must keep the definition of ID: 1263 like that because POIs have been built with this block + Class: Explosive + Group: cpgSignal + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metallight + Texture: 46 + BlockColor: "102,0,0" + # Shape: Invisible # cannot set to invisible because id=1263 is used in several POIs + Shape: New + Model: Cube + Place: Free + TemplateRoot: HullLargeBlocks + PickupTarget: HullLargeBlocks + AllowPlacingAt: "Base,MS", display: true + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + HitPoints: 125, type: int, display: false + CPUIn: 10, type: int, display: true + Mass: 500, type: float, display: true, formatter: Kilogram + StackSize: 1000 + HideShapesWindow: true + Category: Devices + ChildBlocks: "ExplosiveBlockFull, ExplosiveBlockThin" + + BlastParticleIndex: 8 #4 + BlastRadius: 1, display: Radius + BlastDamage: 5000 +} + +{ +Block Id: 1264, Name: TrapDoorAnim + Class: Door + Group: cpgSignal + IndexName: Door + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Misc/TrapDoorPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocks + PickupTarget: SensorTriggerBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsTextureable: true # !!! + IsGPUInstance: false # !!! + IsTrapDoor: true + Texture: 51 + BlockColor: "110,110,110" + IsActivateable: true + Place: Free + IsAccessible: true, type: bool + # IsLockable: true + IsOxygenTight: true, display: true + Mass: 122, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 1, type: int, display: true + Category: Devices + SoundOpen: UseActions/TrapDoor + SoundClose: UseActions/TrapDoor +} +{ +Block Id: 1265, Name: TriggerPlate + Class: Sensor + Group: cpgSignal + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Misc/TriggerPlatePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + TemplateRoot: SensorTriggerBlocks + PickupTarget: SensorTriggerBlocks + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 26 + BlockColor: "170,5,5" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsLockable: true + Place: Free + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + Mass: 122, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 10, type: int, display: true + Category: Devices + Sound: UseActions/TriggerPlate +} + +{ Block Id: 1266, Name: RealRock1Dark, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock1DarkPrefab + SizeInBlocks: "5,4,5" + HitPoints: 183, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "28,37" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "3,5" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "2,4" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,3" + # Prob: 0.05 + # } +} +{ Block Id: 1267, Name: RealRock3Dark, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock3DarkPrefab + SizeInBlocks: "7,7,7" + HitPoints: 283, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "43,57" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "6,9" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "4,7" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "2,6" + # Prob: 0.05 + # } +} +{ Block Id: 1268, Name: RealRock8Dark, Ref: RealRock1 + Model: @models2/Environment/Rocks/RealRockVol1/RealRock8DarkPrefab + SizeInBlocks: "13,10,13" + HitPoints: 510, type: int, display: true + # DecoParticle: rocks + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "77,102" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "33,56" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "22,44" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "11,33" + # Prob: 0.05 + # } +} + +{ +Block Id: 1269, Name: XenoLargeDestroyedBlocks + Material: alien + CustomIcon: AlienLargeBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 36 + TextureTable: 3 # 3 = alien table + Shape: Invisible + PickupTarget: XenoSubstrate + Info: bkiXenoLargeDestroyedBlocks, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 1300, type: int, display: false + Mass: 1200, type: float, display: true, formatter: Kilogram + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + HideShapesWindow: true + ChildBlocks: "XenoFullLargeDestroyed, XenoThinLargeDestroyed" + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1270, Name: XenoFullLargeDestroyed + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: alien + Texture: 36 + TextureTable: 3 # 3 = alien table + Shape: New + Place: Free + Model: Cube + TemplateRoot: XenoLargeDestroyedBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 1300, type: int, display: true + Mass: 1200, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + UpgradeTo: AlienFullLarge, display: true + IsOxygenTight: false, display: true +} +{ +Block Id: 1271, Name: XenoThinLargeDestroyed, Ref: XenoFullLargeDestroyed + HitPoints: 750, type: int, display: true + Mass: 650, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + UpgradeTo: AlienThinLarge, display: true + IsOxygenTight: false, display: true +} + +{ +Block Id: 1272, Name: LightLantern, Ref: LightMS01 + Model: @models/Blocks/Mothership/LightLanternPrefab +} +{ +Block Id: 1273, Name: LightMS01Corner, Ref: LightMS01 + Model: @models/Blocks/Mothership/LightMS01CornerPrefab +} +{ +Block Id: 1274, Name: LightMS01Offset, Ref: LightMS01 + Model: @models/Blocks/Mothership/LightMS01OffsetPrefab + SymType: 4 +} +{ +Block Id: 1275, Name: LightMS02, Ref: LightMS01 + Model: @models/Blocks/Mothership/LightMS02Prefab +} +{ +Block Id: 1276, Name: LightMS03, Ref: LightMS01 + Model: @models/Blocks/Mothership/LightMS03Prefab +} +{ +Block Id: 1277, Name: LightMS04, Ref: LightMS01 + Model: @models/Blocks/Mothership/LightMS04Prefab +} +{ +Block Id: 1278, Name: LightLargeBlocks #Lamps and lights that are usually smaller than a block high or would typically be placed on ceilings indoors + Material: metallight + Shape: Invisible + MarketPrice: 255, display: true + # Model: @models/Blocks/Mothership/lightMS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + Info: bkiBlockGroup, display: true + # ModelOffset: "0,0.5,0" + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + Mass: 0.4, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 4000 + ChildBlocks: "LightMS01, LightMS01Offset, LightMS01Corner, LightCorner, LightCorner02, LightMS02, LightMS03, LightMS04, LightInterior01, LightInterior02, LightLantern, CeilingLamp01, CeilingLamp01Corner, LampRing, LampRingB, FloorLamp04 " #15 of 32 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeParent: "" + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1279, Name: ReceptionTable, Ref: DecoTemplate + # CanPickup: true + Model: @models2/Entities/Decorations/Misc/ReceptionTablePrefab + SymType: 1 + ShowUser: Yes +} +{ +Block Id: 1280, Name: SmallTable, Ref: DecoTemplate + # CanPickup: true + Model: @models2/Entities/Decorations/Misc/SmallTablePrefab + ShowUser: Yes +} +{ +Block Id: 1281, Name: DecoBlocks2, Ref: DecoBlocks + Shape: Invisible + MarketPrice: 308, display: true + # Model: @models2/Entities/Decorations/LaboratoryPack/Scanner3Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "1,1,1", display: true + BlockColor: "110,110,110" + Volume: 150, type: float, display: true, formatter: Liter + Mass: 250, type: float, display: true, formatter: Kilogram + StackSize: 1000 + ChildBlocks: "LabTable1, LabTable2, LabTable3, LockerWShelves, OperationTableWDrawers, Props6BoxLarge1, Props6BoxLarge2, Props6BoxMedium1, Level5FreezerOpened, Tank1, Tank2, Level4Prop2, Level4Prop3, Level4Prop4, ScifiContainerEnergy, CapacitorMS" +} +{ +Block Id: 1282, Name: Level4Prop2, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 57 + Model: @models2/Entities/Decorations/LaboratoryPack/Level4Prop2Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 500, type: float, display: true, formatter: Liter +} +{ +Block Id: 1283, Name: Level4Prop3, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 58 + Model: @models2/Entities/Decorations/LaboratoryPack/Level4Prop3Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 500, type: float, display: true, formatter: Liter +} +# no damage states yet +{ +Block Id: 1284, Name: Freezer, Ref: FridgeMS02 + Model: @models2/Entities/Decorations/LaboratoryPack/FreezerPrefab +} +{ +Block Id: 1285, Name: Level5FreezerOpened, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + DropInventoryEntity: DropContainer + LootList: 59 + Model: @models2/Entities/Decorations/LaboratoryPack/Level5FreezerOpenedPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 1500, type: float, display: true, formatter: Liter +} +{ +Block Id: 1286, Name: LabTable1, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 51 + Model: @models2/Entities/Decorations/LaboratoryPack/LabTable1Prefab + SizeInBlocks: "2,1,1", display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 500, type: float, display: true, formatter: Liter +} +{ +Block Id: 1287, Name: LabTable2, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 51 + Model: @models2/Entities/Decorations/LaboratoryPack/LabTable2Prefab + SizeInBlocks: "2,1,1", display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 200, type: float, display: true, formatter: Liter +} +{ +Block Id: 1288, Name: LabTable3, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 51 + Model: @models2/Entities/Decorations/LaboratoryPack/LabTable3Prefab + SizeInBlocks: "2,1,1", display: true + ShowUser: Yes + VolumeCapacity: 200, type: float, display: true, formatter: Liter +} +{ +Block Id: 1289, Name: LockerWShelves, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 52 + Model: @models2/Entities/Decorations/LaboratoryPack/LockerWShelvesPrefab + ShowUser: Yes + VolumeCapacity: 1000, type: float, display: true, formatter: Liter +} +{ +Block Id: 1290, Name: OperationTableWDrawers, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 53 + Model: @models2/Entities/Decorations/LaboratoryPack/OperationTableWDrawersPrefab + ShowUser: Yes + VolumeCapacity: 100, type: float, display: true, formatter: Liter +} +{ +Block Id: 1291, Name: Props6BoxLarge1, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 56 + Model: @models2/Entities/Decorations/LaboratoryPack/Props6BoxLarge1Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 2500, type: float, display: true, formatter: Liter +} +{ +Block Id: 1292, Name: Props6BoxLarge2, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 56 + Model: @models2/Entities/Decorations/LaboratoryPack/Props6BoxLarge2Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 2500, type: float, display: true, formatter: Liter +} +{ +Block Id: 1293, Name: Props6BoxMedium1, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 54 + Model: @models2/Entities/Decorations/LaboratoryPack/Props6BoxMedium1Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 1500, type: float, display: true, formatter: Liter +} +{ +Block Id: 1294, Name: ScannerBase1, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + DebuffNamesActivate: "OpenWound,Frostbite,DermalBurn,Necrosis,InfectedWound,Sepsis", display: EffectiveAgainst + # DebuffNamesActivate: "OpenWound,Frostbite,DermalBurn,Necrosis,InfectedWound,Sepsis,Test", display: EffectiveAgainst + TemplateRoot: MedicalStationBlocks + Model: @models2/Entities/Decorations/LaboratoryPack/ScannerBase1Prefab + EnergyIn: 10, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1295, Name: Scanner2, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + DebuffNamesActivate: "FractureClosed,FractureOpen,Mutilation", display: EffectiveAgainst + TemplateRoot: MedicalStationBlocks + Model: @models2/Entities/Decorations/LaboratoryPack/Scanner2Prefab + SizeInBlocks: "2,1,1", display: true + SymType: 4 + EnergyIn: 10, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1296, Name: Scanner3, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + DebuffNamesActivate: "DermalParasite,EndoParasite,EndoParasite2,EndoParasite3,AlienParasite,RadiationPoisoning,FeverAttack,RadiationBurn,RadiationSickness,RadiationOverload", display: EffectiveAgainst + TemplateRoot: MedicalStationBlocks + Model: @models2/Entities/Decorations/LaboratoryPack/Scanner3Prefab + SizeInBlocks: "2,1,1", display: true + SymType: 4 + EnergyIn: 10, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1297, Name: Tank1, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + Group: cpgCargoBox + IsAccessible: true, type: bool + IsActivateableInCP: true + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 60 + Model: @models2/Entities/Decorations/LaboratoryPack/Tank1Prefab + SizeInBlocks: "2,1,1", display: true + SymType: 4 + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 1000, type: float, display: true, formatter: Liter +} +{ +Block Id: 1298, Name: Tank2, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + IsActivateableInCP: true + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 60 + Model: @models2/Entities/Decorations/LaboratoryPack/Tank2Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 1000, type: float, display: true, formatter: Liter +} +{ +Block Id: 1299, Name: Console4, Ref: DecoTemplate + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + DebuffNamesActivate: "Indigestion,PoisonFood,PoisonBit,PoisonBit2,Intoxication,FeverAttack,OrganFailure,DermalParasite", display: EffectiveAgainst + TemplateRoot: MedicalStationBlocks + Model: @models2/Entities/Decorations/LaboratoryPack/Console4Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} + +{ +Block Id: 1300, Name: DecoStoneTemplate + Material: concrete + Shape: ModelEntity + Model: @models2/Entities/Decorations/AncientTemple/RunestonePrefab + Category: Deco Blocks + Mass: 1000, type: float, display: true, formatter: Kilogram + Place: Free + IsAccessible: false, type: bool + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,3,1", display: true + # ModelOffset: "0,0.5,0" + ShowUser: No +} +{ +Block Id: 1301, Name: StoneBarbarian, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/StoneBarbarianPrefab + SizeInBlocks: "1,2,1", display: true + ShowUser: Yes +} +{ +Block Id: 1302, Name: CelticCross, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/CelticCrossPrefab + SizeInBlocks: "1,2,1", display: true + ShowUser: Yes +} +{ +Block Id: 1303, Name: DemonHead, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/DemonHeadPrefab + SizeInBlocks: "2,2,2", display: true + HitPoints: 300, type: int, display: true + ShowUser: Yes +} + +{ +Block Id: 1304, Name: DetectorCV + Info: bkiDetector, display: true + WeaponItem: DetectorCVWeapon + WeaponItemRotation: false + MarketPrice: 10195, display: true + IndexName: WeaponShip + Group: cpgEquipment + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Spaceship/DetectorCVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + AllowPlacingAt: "MS", display: true + IsAccessible: true, type: bool + BlockColor: "110,110,110" + SizeInBlocks: "1,1,1", display: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + # SymType: 4 + StackSize: 1000 + UpgradeTo: DetectorCVEpic, display: true + EnergyIn: 320, type: int, display: true, formatter: Watt + HitPoints: 300, type: int, display: true + Mass: 3520, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + Range: 6000, type: int, display: true + RangeSpace: 24000, type: int, display: true + CPUIn: 1500, type: int, display: true + Category: Devices + + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + # TechTreeParent: GeneratorSV + TechTreeNames: Capital Vessel + + # ShowUser: NoButCSV +} + +{ +Block Id: 1305, Name: DemonicStatue, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/DemonicStatuePrefab + SizeInBlocks: "4,6,4", display: true + HitPoints: 1000, type: int, display: true + ShowUser: Yes +} +{ +Block Id: 1306, Name: GothicFountain, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/GothicFountainPrefab + SizeInBlocks: "3,1,1", display: true + HitPoints: 200, type: int, display: true + ShowUser: Yes +} +{ +Block Id: 1307, Name: GreekHead, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/GreekHeadPrefab + SizeInBlocks: "2,2,2", display: true + HitPoints: 300, type: int, display: true + ShowUser: Yes +} +{ +Block Id: 1308, Name: MayanStatueSnake, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/MayanStatueSnakePrefab + SizeInBlocks: "3,4,3", display: true + HitPoints: 500, type: int, display: true + ShowUser: Yes +} +{ +Block Id: 1309, Name: SnakeStatue, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/SnakeStatuePrefab + SizeInBlocks: "4,7,4", display: true + HitPoints: 1000, type: int, display: true + ShowUser: Yes +} +{ +Block Id: 1310, Name: StatueSkull, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/StatueSkullPrefab + SizeInBlocks: "2,2,2", display: true + HitPoints: 300, type: int, display: true + ShowUser: Yes +} +{ +Block Id: 1311, Name: TigerStatue, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/TigerStatuePrefab + SizeInBlocks: "2,4,3", display: true + HitPoints: 400, type: int, display: true + ShowUser: Yes +} +{ +Block Id: 1312, Name: AncientStatue, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/AncientStatuePrefab + SizeInBlocks: "2,3,2", display: true + HitPoints: 300, type: int, display: true + ShowUser: Yes +} +# Renaming possible? +{ +Block Id: 1313, Name: PlantDead + Class: PlantGrowing # to ensure that it can only be placed in growing plots + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/PlantDeadSmallPrefab + # Model: @models2/Entities/Farming/ModernFarm/PlantDeadPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + ShowBlockName: true + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 1, type: float, display: true, formatter: Kilogram + Category: Farming + XpFactor: 0 + ShowUser: NoButCSV +} +{ +Block Id: 1314, Name: Trader + AllowedInBlueprint: false, display: true + Class: TraderNPC + Group: cpgTrading + TraderEntity: Trader1 + TraderType: TraderDefault + Material: human + Shape: ModelEntity + Model: @models2/Entities/Enemies/Traders/AlienTraderBlockPrefab + Place: Free + Info: bkiTraderBlock, display: true + BlockColor: "110,110,110" + IsAccessible: true, type: bool + Mass: 80, type: float, display: true, formatter: Kilogram + AllowPlacingAt: Base, display: true + HitPoints: 100, type: int, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + TemplateRoot: AlienNPCBlocks +} + +{ +Block Id: 1315, Name: TeleporterCV + Class: Teleporter + Group: cpgTransportation + Category: Devices + MarketPrice: 42012, display: true + IndexName: Teleporter + Info: bkiTeleporterCV, display: true + # AllowedInBlueprint: false, display: true + Shape: ModelEntity + ShowBlockName: true + Material: metalhard + Model: @models/Blocks/Misc/TeleporterPadPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + #TemplateRoot: HullLargeBlocks + #PickupTarget: SteelPlate + BlockColor: "110,110,110" + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsLockable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + StackSize: 500 + Mass: 2500, type: float, display: true, formatter: Kilogram + EnergyIn: 6000, type: int, display: true, formatter: Watt + CPUIn: 20000, type: int, display: true + Volume: 360, type: float, display: true, formatter: Liter + HitPoints: 350, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 100 + RangeLY: 0 # a value of 0 allows to teleport only within the current sector + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + # TechTreeParent: PentaxidTank + TechTreeNames: Capital Vessel + + # ShowUser: Yes +} + +{ +Block Id: 1316, Name: PlantDead2, Ref: PlantDead + Model: @models2/Entities/Farming/SpeedTrees/PlantDeadLargePrefab +} +{ Block Id: 1317, Name: AkuaBushGreen + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AkuaBushGreenPrefab + SizeInBlocks: "1,3,1" + ShowUser: No +} +{ +Block Id: 1318, Name: ExplosiveBlocks2, Ref: ExplosiveBlocks + BlockColor: "255,0,0" + Mass: 1000, type: float, display: true, formatter: Kilogram + + ChildBlocks: "ExplosiveBlock2Full, ExplosiveBlock2Thin" + + BlastParticleIndex: 8 #4 + BlastRadius: 4, display: Radius #4 + BlastDamage: 30000 +} + +{ +Block Id: 1319, Name: SpotlightSlope, Ref: SpotlightSSCube + IsPhysicsColliders: false + Model: @models/Blocks/Spaceship/SpotlightSlopePrefab + Mass: 42, type: float, display: true, formatter: Kilogram + + # UnlockCost: 5, display: true + # UnlockLevel: 7, display: true + # TechTreeParent: SpotlightBlocks + # TechTreeNames: "Capital Vessel,Small Vessel,Hover Vessel" +} +{ +Block Id: 1320, Name: SpotlightSlopeHorizontal, Ref: SpotlightSSCube + IsPhysicsColliders: false + Model: @models/Blocks/Spaceship/SpotlightSlopeHorizontalPrefab + Mass: 42, type: float, display: true, formatter: Kilogram + + # UnlockCost: 5 + # UnlockLevel: 7 + # TechTreeParent: SpotlightBlocks + # TechTreeNames: "Capital Vessel,Small Vessel,Hover Vessel" +} +{ +Block Id: 1321, Name: SpotlightBlocks, Ref: SpotlightSSCube + Shape: Invisible + MarketPrice: 145, display: true + # Model: @models/Blocks/Spaceship/spotlightSSCubePrefab + Mass: 85, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + StackSize: 2000 + Info: bkiBlockGroup, display: true + ChildBlocks: "SpotlightSSCube, SpotlightSlope, SpotlightSlopeHorizontal" + + UnlockCost: 1, display: true + UnlockLevel: 1, display: true + # TechTreeParent: LightSS01 + TechTreeNames: "Capital Vessel,Small Vessel,Hover Vessel" +} + +# Armored Concrete Block +{ +Block Id: 1322, Name: ConcreteArmoredBlocks + Material: concretearmored + MarketPrice: 115, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 93 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: Base, display: true + HitPoints: 2200, type: int, display: false + Mass: 4000, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "ConcreteArmoredFull, ConcreteArmoredThin, ConcreteArmoredExtended, ConcreteArmoredExtended2, ConcreteArmoredExtended3, ConcreteArmoredExtended4, ConcreteArmoredExtended5, ConcreteArmoredExtended6, ConcreteArmoredExtended7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Two shape families for concrete block +{ +Block Id: 1323, Name: ConcreteArmoredFull + Material: concretearmored + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 93 + TextureTable: 2 # 1 = concrete table + BlockColor: "170,170,170" + Shape: Invisible + Place: Free + TemplateRoot: ConcreteArmoredBlocks + AllowPlacingAt: Base, display: true + HitPoints: 2200, type: int, display: true + Mass: 4000, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + # UpgradeTo: HullFullLarge, display: true +} + +{ +Block Id: 1324, Name: ConcreteArmoredThin, Ref: ConcreteArmoredFull + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + # UpgradeTo: HullThinLarge, display: true +} + +{ Block Id: 1325, Name: OmicronPalmGreen, Ref: OmicronPalm + Model: @models2/Environment/Plants/SpeedTrees/OmicronPalmGreenPrefab +} + +{ Block Id: 1326, Name: CornDogBushSnowHarvested, Ref: CornDogBush + Class: PlantGrowing + { Child PlantGrowing + Next: CornDogBushSnow + GrowthRate: 25 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/CornDogBushSnowHarvestedPrefab +} +{ Block Id: 1327, Name: PumpkinStage3NoFruit + Class: PlantGrowing + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/PumpkinPlantStage3NoFruitPrefab + PickupTarget: PumpkinStage1 + { Child PlantGrowing + Next: PumpkinStage3 + GrowthRate: 140 + FertileLevel: 3 + OnDeath: PlantDead2 + } + ShowUser: NoButCSV # reported by HWS + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 1328, Name: TomatoStage4NoFruit + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: TomatoStage4 + GrowthRate: 75 + FertileLevel: 3 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/TomatoPlantStage4NoFruitPrefab + PickupTarget: TomatoStage1 + ShowUser: NoButCSV # reported by HWS + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true +} +{ Block Id: 1329, Name: TomatoStage4 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: Vegetables + Count: 4 + } + { Child CropsGrown + OnHarvest: TomatoStage4NoFruit + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/TomatoPlantStage4Prefab + PickupTarget: TomatoStage1 + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + # ShowUser: NoButCSV # reported by HWS + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + XpFactor: 0.25 +} +# Renaming possible? +{ +Block Id: 1330, Name: SproutDead + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/SproutDeadPrefab + # Model: @models2/Entities/Farming/Misc/SproutDeadPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + # ModelOffset: "0,0.5,0" + ShowBlockName: true + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 1, type: float, display: true, formatter: Kilogram + Category: Farming + XpFactor: 0 + ShowUser: No +} +{ +Block Id: 1331, Name: DemonicStatueSmall, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/DemonicStatueSmallPrefab + SizeInBlocks: "1,1,1", display: true + ShowUser: Yes +} +{ +Block Id: 1332, Name: MayanStatueSnakeSmall, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/MayanStatueSnakeSmallPrefab + SizeInBlocks: "1,1,1", display: true + ShowUser: Yes +} +{ +Block Id: 1333, Name: SnakeStatueSmall, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/SnakeStatueSmallPrefab + SizeInBlocks: "1,1,1", display: true + ShowUser: Yes +} +{ +Block Id: 1334, Name: TigerStatueSmall, Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/TigerStatueSmallPrefab + SizeInBlocks: "1,1,1", display: true + ShowUser: Yes +} +{ +Block Id: 1335, Name: Runestone , Ref: DecoStoneTemplate + Model: @models2/Entities/Decorations/AncientTemple/RunestonePrefab + SizeInBlocks: "1,3,1", display: true + ShowUser: Yes +} +{ +Block Id: 1336, Name: DecoStoneBlocks, Ref: DecoBlocks + AllowPlacingAt: Base, display: true + SizeInBlocksLocked: "Base,MS" + Shape: Invisible + # Model: @models2/Entities/Decorations/AncientTemple/RunestonePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiBlockGroup, display: true + Mass: 1200, type: float, display: true, formatter: Kilogram + StackSize: 500 + SizeInBlocks: "1,3,1", display: true + ChildBlocks: "Runestone, StoneBarbarian, CelticCross, DemonHead, DemonicStatue, GothicFountain, GreekHead, MayanStatueSnake, SnakeStatue, StatueSkull, TigerStatue, AncientStatue, DemonicStatueSmall, MayanStatueSnakeSmall, SnakeStatueSmall, TigerStatueSmall" +} +{ Block Id: 1337, Name: CornDogBushSnow + Class: CropsGrown + { Child DropOnHarvest + Item: PlantProtein + Count: 2 + } + { Child CropsGrown + OnHarvest: CornDogBushSnowHarvested + OnDeath: PlantDead + } + Material: plants + Shape: Deco + IsDeco: true + IsPlant: true + IsTerrainDecoration: true + Collide: "bullet,rocket,melee,sight" + Model: @models2/Environment/Plants/FractalFlora/CornDogBushSnowPrefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } +} +{ +Block Id: 1338, Name: ReceptionTableCorner, Ref: DecoTemplate + # CanPickup: true + Info: bkiDecoModel, display: true + Model: @models2/Entities/Decorations/Misc/ReceptionTableCornerPrefab + SymType: 2 + ShowUser: Yes +} +{ Block Id: 1339, Name: Mushroom01 + Material: woodsoft + Shape: Deco + Model: @models2/Environment/Plants/AlienFlora/Mushroom01Prefab + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + ShowUser: No + # SizeInBlocks: "1,1,1" +} +{ Block Id: 1340, Name: Mushroom02, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/Mushroom02Prefab + SizeInBlocks: "2,3,2" +} +{ Block Id: 1341, Name: Mushroom03, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/Mushroom03Prefab + # SizeInBlocks: "1,1,1" +} +{ Block Id: 1342, Name: MushroomBigSky, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/MushroomBigSkyPrefab + SizeInBlocks: "3,8,3" +} +{ Block Id: 1343, Name: CoconutTree, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/CoconutTreePrefab + SizeInBlocks: "1,2,1" +} +{ Block Id: 1344, Name: CthulhuTree01, Ref: Mushroom01 + Material: wood + IsPlant: true + Model: @models2/Environment/Plants/AlienFlora/CthulhuTree01Prefab + SizeInBlocks: "3,7,3" +} +{ Block Id: 1345, Name: CthulhuTree02, Ref: Mushroom01 + Material: wood + IsPlant: true + Model: @models2/Environment/Plants/AlienFlora/CthulhuTree02Prefab + SizeInBlocks: "3,6,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,8" + Prob: 1 + } +} +{ Block Id: 1346, Name: BigFlower, Ref: Mushroom01 + Class: CropsGrown + { Child DropOnHarvest + Item: Buds + Count: 2 + } + { Child CropsGrown + OnHarvest: BigFlowerHarvested + OnDeath: PlantDead + } + Model: @models2/Environment/Plants/AlienFlora/BigFlowerPrefab + SizeInBlocks: "3,4,3" + { Child DropOnDestroy + Item: Fiber + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 1347, Name: OnionFlower, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/OnionFlowerPrefab + SizeInBlocks: "3,4,3" +} +{ Block Id: 1348, Name: GlowTube01, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/GlowTube01Prefab + SizeInBlocks: "1,5,4" +} +{ Block Id: 1349, Name: BallFlower01, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/BallFlower01Prefab + SizeInBlocks: "2,4,2" + { Child DropOnDestroy + Item: WoodLogs + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 1350, Name: BallFlower02, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/BallFlower02Prefab + SizeInBlocks: "12,14,12" + { Child DropOnDestroy + Item: WoodLogs + Count: "6,15" + Prob: 1 + } +} +{ Block Id: 1351, Name: BallFlower03, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/BallFlower03Prefab + SizeInBlocks: "3,4,3" + { Child DropOnDestroy + Item: WoodLogs + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 1352, Name: BallTree, Ref: Mushroom01 + Model: @models2/Environment/Plants/AlienFlora/BallTreePrefab + SizeInBlocks: "5,6,5" + { Child DropOnDestroy + Item: WoodLogs + Count: "2,5" + Prob: 1 + } +} + +# delete when we break save games +{ Block Id: 1353, Name: Bush01 + Material: plants + Shape: Deco + Collide: "bullet,rocket,melee,sight" + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + CanDecorateOnSlopes: false + # SmallDecorationRadius: 1 + # BigDecorationRadius: 2 + Model: @models2/Environment/Plants/FreeTerrainAssets/Bush01Prefab + ShowUser: No + { Child DropOnDestroy + Item: Fiber + Count: "4,5" + Prob: 1 + } +} +{ Block Id: 1354, Name: Bush02, Ref: Bush01 + Model: @models2/Environment/Plants/FreeTerrainAssets/Bush02Prefab +} +{ Block Id: 1355, Name: Bush03, Ref: Bush01 + Model: @models2/Environment/Plants/FreeTerrainAssets/Bush03Prefab +} +{ Block Id: 1356, Name: Bush04, Ref: Bush01 + Model: @models2/Environment/Plants/FreeTerrainAssets/Bush04Prefab +} +{ Block Id: 1357, Name: Fern, Ref: Bush01 + Model: @models2/Environment/Plants/FreeTerrainAssets/FernPrefab +} +{ Block Id: 1358, Name: BananaBush, Ref: Bush01 + Model: @models2/Environment/Plants/FreeTerrainAssets/BananaBushPrefab +} +{ Block Id: 1359, Name: Bamboo, Ref: Bush01 + Model: @models2/Environment/Plants/FreeTerrainAssets/BambooPrefab +} + +# NPCAdminCore +{ +Block Id: 1360, Name: CoreNPCAdmin, Ref: CoreNPC + Model: @models/Blocks/Misc/CoreBlockNPCAdminPrefab + IsDestructible: false + IsKeepContainers: true + BlockColor: "171,32,161" + RepFac: 750 +} +{ +Block Id: 1361, Name: CorePlayerAdmin, Ref: Core + Model: @models/Blocks/Misc/CoreBlockPlayerAdminPrefab + Info: bkiCore, display: true + Faction: Admin + IsDestructible: false + IsKeepContainers: true + IsUsingCPUSystem: false + AllowedInBlueprint: false, display: true + BlockColor: "0,15,100" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Block Id: 1362, Name: Antenna01, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna01Prefab + SizeInBlocks: "1,5,1", display: true +} +{ +Block Id: 1363, Name: Antenna02, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna02Prefab + SizeInBlocks: "1,3,1", display: true +} +{ +Block Id: 1364, Name: Antenna03, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna03Prefab + SizeInBlocks: "1,4,1", display: true +} +{ +Block Id: 1365, Name: Antenna04, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna04Prefab + SizeInBlocks: "1,3,1", display: true +} +{ +Block Id: 1366, Name: Antenna05, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna05Prefab + SizeInBlocks: "3,6,3", display: true +} +# Sprout +{ Block Id: 1367, Name: SnakeweedStage1 + Class: PlantGrowing + MarketPrice: 76, display: true + AllowedInBlueprint: false, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/SpeedTrees/SnakeweedStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + { Child PlantGrowing + Next: SnakeweedStage2 + GrowthRate: 19 + FertileLevel: 5 + OnDeath: PlantDead + } + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Free + # ModelOffset: "0,0.5,0" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + CropType: Fiber, display: true + # CropYield: 8, display: true + GrowthTimeInfo: 43, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: SnakeweedStage1 # disassemble + TemplateRoot: SnakeweedStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1368, Name: SnakeweedStage2, Ref: SnakeweedStage1 + Model: @models2/Entities/Farming/SpeedTrees/SnakeweedStage2Prefab + PickupTarget: SnakeweedStage1 + { Child PlantGrowing + Next: SnakeweedStage3 + GrowthRate: 24 + FertileLevel: 5 + OnDeath: PlantDead + } + ShowUser: NoButCSV # reported by HWS +} +{ Block Id: 1369, Name: SnakeweedStage3, Ref: SnakeweedStage1 + Model: @models2/Entities/Farming/SpeedTrees/SnakeweedStage3Prefab + PickupTarget: SnakeweedStage1 + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: Fiber + Count: 2 + } + { Child CropsGrown + OnHarvest: SnakeweedStage1 + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + # ShowUser: NoButCSV # reported by HWS + XpFactor: 0.25 +} +{ +Block Id: 1370, Name: ArmorLocker + Class: ArmorLocker + Group: cpgEquipment + GridSize: "5,3" + MarketPrice: 1762, display: true + Material: metal + Shape: ModelEntity + ShowBlockName: true + # Model: @models2/Entities/Decorations/LaboratoryPack/LockerWShelvesPrefab + # Model: @models/Blocks/Mothership/ArmorLockerPrefab + Model: @models/Blocks/Mothership/ArmorLockerBAPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + Info: bkiArmorLocker, display: true + Mass: 405, type: float, display: false, formatter: Kilogram + Volume: 200, type: float, display: true, formatter: Liter + VolumeCapacity: 1000, type: float, display: true, formatter: Liter + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 1000 + Category: Devices + IsAccessible: true, type: bool + IsActivateableInCP: true + IsOxygenTight: false, display: true + IsLockable: true + EnergyIn: 5, type: int, display: true, formatter: Watt + SoundOpen: UseActions/BarrelOpen + SoundClose: UseActions/BarrelClose + + BlockColor: "110,110,110" + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1371, Name: Deconstructor + Class: Deconstructor + Group: cpgConstructor + IndexName: Constructor + MarketPrice: 35498, display: true + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Devices/DeconstructorFWNPrefab #1.11 update + #Model: @models/Blocks/Mothership/DeconstructorPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: Base, display: true + SizeInBlocks: "2,2,2", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + ReturnFactor: 1.0, display: true + StackSize: 500 + Category: Devices + Mass: 14700, type: float, display: true, formatter: Kilogram + Volume: 1280, type: float, display: true, formatter: Liter + HitPoints: 250, type: int, display: true + EnergyIn: 825, type: int, display: true, formatter: Watt + CPUIn: 20000, type: int, display: true + EnergyInIdle: 10, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + + BlockColor: "110,110,110" + + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 500 + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeParent: ConstructorT2 + TechTreeNames: "Base" + # ShowUser: No +} +{ +Block Id: 1372, Name: RepairStation + Class: RepairStation + IndexName: Container + Group: cpgEquipment + MarketPrice: 12538, display: true + GridSize: "4,4" + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Mothership/RepairStationPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Info: bkiRepairStation, display: true + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + # IsActivateable: true + # IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + StackSize: 1000 + Category: Devices + Mass: 1070, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + VolumeCapacity: 200, type: float, display: true, formatter: Liter + CPUIn: 2500, type: int, display: true + HitPoints: 250, type: int, display: true + EnergyIn: 150, type: int, display: true, formatter: Watt + SoundOpen: UseActions/BarrelOpen + SoundClose: UseActions/BarrelClose + + BlockColor: "110,110,110" + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + TechTreeParent: ArmorLocker + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1373, Name: Portal + Class: Portal + Group: cpgTransportation + IndexName: Portal + Shape: ModelEntity + Material: metalhard + Model: @models/Blocks/Misc/PortalPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,2,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + ShowBlockName: true + StackSize: 1000 + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices +} +{ +Block Id: 1374, Name: PlayerSpawner + IndexName: StartPoint + Group: cpgTransportation + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/PlayerSpawnerPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowedInBlueprint: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsOxygenTight: false, display: true + # Info: bkiCloneChamber, display: true + # ModelOffset: "0,0.5,0" + BlockColor: "0,15,100" + StackSize: 500 + Category: Devices + # Mass: 200, type: float, display: true, formatter: Kilogram + # EnergyIn: 10, type: int, display: true, formatter: Watt + RemoveOnSI: true # avoid to show the dummy block on SI collapse + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 +} + +{ +Block Id: 1375, Name: DoorBlocksSV + Class: Door + IndexName: Door + Material: metal + MarketPrice: 220, display: true + Shape: Invisible + # Model: @models/Blocks/Spaceship/DoorNewSSPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowPlacingAt: "SS,GV", display: true + # AllowPlacingAt: "GV", display: true # only on GV when we introduce S,M,L CV blocks + IsAccessible: true, type: bool + IsLockable: true + Info: bkiDoors, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + # HitPoints: 80, type: int, display: false + Mass: 183, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + ChildBlocks: "DoorSS01, DoorSS01Slim, DoorInterior01SV, DoorInterior01SlimSV" + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Small Vessel,Hover Vessel" + # TechTreeNames: "Hover Vessel" # only on GV when we introduce S,M,L CV blocks +} +{ +Block Id: 1376, Name: DoorInterior01SV, Ref: DoorSS01 + Model: @models/Blocks/Spaceship/DoorInteriorSVPrefab + Mass: 183, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + IsOxygenTight: false, display: true +} +{ +Block Id: 1377, Name: TeleporterBA + Info: bkiTeleporterBA, display: true + Class: Teleporter + MarketPrice: 66446, display: true + Group: cpgTransportation + Category: Devices + IndexName: Teleporter + # AllowedInBlueprint: false, display: true + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/TeleporterPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + # TemplateRoot: HullLargeBlocks + # PickupTarget: SteelPlate + BlockColor: "110,110,110" + IsActivateable: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 1000 + UpgradeTo: TeleporterBAEpic, display: true + Mass: 3200, type: float, display: true, formatter: Kilogram + EnergyIn: 12000, type: int, display: true, formatter: Watt + CPUIn: 35000, type: int, display: true + Volume: 480, type: float, display: true, formatter: Liter + HitPoints: 350, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + # TechTreeParent: PentaxidTank + TechTreeNames: Base + + # ShowUser: NoButCSV +} +# obsolete - do not remove +{ Block Id: 1378, Name: PlayerSpawnerPlate, Ref: PlayerSpawner + Texture: 51 + BlockColor: "0,15,100" + Model: @models/Blocks/Misc/PlayerSpawnerPlatePrefab + IsOxygenTight: true, display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + ShowUser: No +} +# obsolete - do not remove +{ Block Id: 1379, Name: PlayerSpawnerBlock, Ref: PlayerSpawner + Texture: 51 + BlockColor: "0,15,100" + Model: @models/Blocks/Misc/PlayerSpawnerBlockPrefab + Place: NoRotation, display: true # disabled rotation + IsOxygenTight: true, display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + ShowUser: No +} +{ +Block Id: 1380, Name: ArmorLockerSV + Class: ArmorLocker + Group: cpgEquipment + MarketPrice: 1040, display: true + GridSize: "5,1" + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/ArmorLockerSVPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + Info: bkiArmorLocker, display: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,2,1" + StackSize: 1000 + Category: Devices + IsAccessible: true, type: bool + IsActivateableInCP: true + IsOxygenTight: false, display: true + IsLockable: true + Mass: 120, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + VolumeCapacity: 150, type: float, display: true, formatter: Liter + EnergyIn: 5, type: int, display: true, formatter: Watt + SoundOpen: UseActions/BarrelOpen + SoundClose: UseActions/BarrelClose + BlockColor: "110,110,110" + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: OxygenStationSV + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ Block Id: 1381, Name: ArtMassCVBlocks + Material: hull + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 31 + BlockColor: "110,110,110" + Shape: Invisible + # Model: Cube + Info: bkiArtificialmass, display: true + Category: BuildingBlocks + AllowPlacingAt: MS, display: true + HitPoints: 500, type: int, display: true + Mass: 60000, type: float, display: true, formatter: Kilogram + Volume: 1000, type: float, display: true, formatter: Liter + ChildBlocks: "ArtMassLightCV, ArtMassMediumCV, ArtMassHeavyCV" + + # UnlockCost: 0 + # UnlockLevel: 1 + # TechTreeNames: "" + + ShowUser: No +} +{ Block Id: 1382, Name: ArtMassLightCV + Material: hull + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 31 + BlockColor: "170,170,170" + Shape: New + Model: Cube + TemplateRoot: ArtMassBlocks + Info: bkiArtificialmass, display: true + AllowPlacingAt: MS, display: true + Category: BuildingBlocks + HitPoints: 500, type: int, display: true + Mass: 60000, type: float, display: true, formatter: Kilogram + Volume: 1000, type: float, display: true, formatter: Liter + ShowUser: No +} +{ Block Id: 1383, Name: ArtMassMediumCV, Ref: ArtMassLightCV + BlockColor: "110,110,110" + Mass: 180000, type: float, display: true, formatter: Kilogram +} +{ Block Id: 1384, Name: ArtMassHeavyCV, Ref: ArtMassLightCV + BlockColor: "10,10,10" + Mass: 560000, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1385, Name: PlayerSpawnerPlateThin, Ref: PlayerSpawner + Texture: 51 + BlockColor: "0,15,100" + Model: @models/Blocks/Misc/PlayerSpawnerPlateThinPrefab + IsTextureable: true # !!! + IsGPUInstance: false # !!! +} + +# Destroyed Steel Block (large) - block group +{ +Block Id: 1386, Name: HullLargeDestroyedBlocks + Material: hullarmored + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: Invisible + PickupTarget: SteelPlate + Info: bkiBlockGroup, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 500, type: int, display: false + Mass: 1000, type: float, display: true, formatter: Kilogram + StackSize: 1000 + Category: BuildingBlocks + HideShapesWindow: true + ChildBlocks: "HullFullLargeDestroyed, HullThinLargeDestroyed" + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Two shape families for Destroyed Steel Blocks (large) +{ +Block Id: 1387, Name: HullFullLargeDestroyed + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hullarmored + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullLargeDestroyedBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 500, type: int, display: true + Mass: 1000, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + UpgradeTo: HullFullLarge, display: true + IsOxygenTight: false, display: true +} +{ +Block Id: 1388, Name: HullThinLargeDestroyed, Ref: HullFullLargeDestroyed + HitPoints: 250, type: int, display: true + Mass: 209, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + UpgradeTo: HullThinLarge, display: true + IsOxygenTight: false, display: true +} + +# Destroyed Steel Block (small) - block group +{ +Block Id: 1389, Name: HullSmallDestroyedBlocks + Material: hullarmored + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Model: Cube + PickupTarget: SteelPlate + Info: bkiBlockGroup, display: true + AllowPlacingAt: "SS,GV", display: true + HitPoints: 25, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + StackSize: 2000 + Category: BuildingBlocks + ChildBlocks: "HullFullSmallDestroyed, HullThinSmallDestroyed" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Two shape families for Destroyed Steel Blocks (small) +{ +Block Id: 1390, Name: HullFullSmallDestroyed + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hullarmored + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullSmallDestroyedBlocks + AllowPlacingAt: "SS,GV", display: true + HitPoints: 25, type: int, display: true + Mass: 15, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + UpgradeTo: HullFullSmall, display: true + IsOxygenTight: false, display: true +} +{ +Block Id: 1391, Name: HullThinSmallDestroyed, Ref: HullFullSmallDestroyed + HitPoints: 12, type: int, display: true + Mass: 8, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + UpgradeTo: HullThinSmall, display: true + IsOxygenTight: false, display: true +} + +# Destroyed Concrete Block +{ +Block Id: 1392, Name: ConcreteDestroyedBlocks + Material: concrete + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: Invisible + # Model: Cube + PickupTarget: Cement + Info: bkiBlockGroup, display: true + AllowPlacingAt: Base, display: true + HitPoints: 400, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + Category: BuildingBlocks + ChildBlocks: "ConcreteFullDestroyed, ConcreteThinDestroyed" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Two shape families for concrete destroyed block +{ +Block Id: 1393, Name: ConcreteFullDestroyed + Material: concrete + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + TemplateRoot: ConcreteDestroyedBlocks + AllowPlacingAt: Base, display: true + HitPoints: 400, type: int, display: true + Mass: 300, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + UpgradeTo: ConcreteFull, display: true + IsOxygenTight: false, display: true +} +{ +Block Id: 1394, Name: ConcreteThinDestroyed, Ref: ConcreteFullDestroyed + HitPoints: 200, type: int, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + UpgradeTo: ConcreteThin, display: true + IsOxygenTight: false, display: true +} + +{ +Block Id: 1395, Name: AlienLargeBlocks, Ref: AlienBlocks #Xeno Steel, not radioactive + Material: alien + MarketPrice: 865, display: true + Info: bkiBlockGroup, display: true + AllowedInBlueprint: true, display: false + HitPoints: 2750, type: int, display: false + Mass: 2600, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + BlockSizeScale: 8 + ChildBlocks: "AlienFullLarge, AlienThinLarge, AlienExtendedLarge, AlienExtendedLarge2, AlienExtendedLarge3, AlienExtendedLarge4, AlienExtendedLarge5, AlienExtendedLarge6, AlienExtendedLarge7" + Radiation: 0 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1396, Name: AlienFullLarge, Ref: AlienFull + Material: alien + AllowedInBlueprint: true, display: false + HitPoints: 2750, type: int, display: true + Mass: 2600, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + TemplateRoot: AlienLargeBlocks + PickupTarget: AlienLargeBlocks + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + Radiation: 0 +} +{ +Block Id: 1397, Name: AlienThinLarge, Ref: AlienFullLarge + Material: alien + AllowedInBlueprint: true, display: false + TemplateRoot: AlienLargeBlocks + PickupTarget: AlienLargeBlocks + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + Radiation: 0 +} + +{ +Block Id: 1398, Name: ReceptionTableThin, Ref: DecoTemplate + # CanPickup: true + Model: @models2/Entities/Decorations/Misc/ReceptionTableThinPrefab + SymType: 1 + ShowUser: Yes +} +{ +Block Id: 1399, Name: ReceptionTableCornerThin, Ref: DecoTemplate + # CanPickup: true + Info: bkiDecoModel, display: true + Model: @models2/Entities/Decorations/Misc/ReceptionTableCornerThinPrefab + SymType: 2 + ShowUser: Yes +} +{ +Block Id: 1400, Name: LCDProjector, Ref: LCDNoFrame1x1 + Model: @models/Blocks/Misc/LCDProjectorPrefab +} + +{ +Block Id: 1401, Name: CoreNPCcpu, Ref: CoreNPC + #Model: @models/Blocks/Misc/CoreBlockNPCPrefab + IsUsingCPUSystem: true + BlockColor: "220,212,0" + IsKeepContainers: true + +} + +{ +Block Id: 1402, Name: CoreNoCPU, Ref: Core + MarketPrice: 10000000, display: true + #Model: @models/Blocks/Misc/CoreBlockPlayerPrefab + IsUsingCPUSystem: false + Info: bkiCoreNoCPU, display: true + # AllowedInBlueprint: false, display: true + BlockColor: "0,220,220" + TechTreeNames: "" + +} + + +{ Block Id: 1403, Name: LavaBlock + DropMeshfile: Entities/Misc/DropContainerNewPrefab + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + IsOxygenTight: true, display: true + IsTextureable: false + IsColorable: false + Category: BuildingBlocks + CanPlayersSpawnOn: false + Info: bkiLavaBlock, display: true + # Class: LiquidStatic + Material: lava + Shape: LiquidBlock + Model: Cube + Mesh: lava + PickupTarget: RockDust + Texture: 121 + XpFactor: 0 + Place: Free + Mass: 1, type: float, display: true, formatter: Kilogram + StackSize: 1000 + Temperature: 900 + AllowPlacingAt: "Base,MS", display: true + ShowUser: Yes + HitPoints: 1200, type: int, display: true + { Child Map + Color: "255,220,70" + Color2: "255,0,0" + ElevMinMax: "15,30" + Specular: 0.8 + Emissive: 1 + } +} +{ Block Id: 1404, Name: WaterBlock + DropMeshfile: Entities/Misc/DropContainerNewPrefab + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + IsOxygenTight: true, display: true + IsTextureable: false + IsColorable: false + Category: BuildingBlocks + CanPlayersSpawnOn: false + Info: bkiWaterBlock, display: true + # Class: LiquidStatic + Material: water + Shape: LiquidBlock + Model: Cube + Mesh: water + Texture: 107 + XpFactor: 0 + PickupTarget: WaterBottle + Place: Free + Mass: 1000, type: float, display: true, formatter: Kilogram + HitPoints: 1, type: int, display: true + Temperature: 8, display: true + StackSize: 1000 + AllowPlacingAt: "Base,MS", display: true + ShowUser: Yes + BlockColor: "27,44,65,255" + { Child Map + Color: "27,44,65,255" + Specular: 0.3 + } +} +{ +Block Id: 1405, Name: Ventilator + Class: Vent + IndexName: Vent + Group: cpgO2Ventilator + Material: hull + Shape: ModelEntity + TemplateRoot: VentilatorBlocks + PickupTarget: VentilatorBlocks + Model: @models/Blocks/Mothership/VentilatorCubePrefab + Mesh-Damage-1: Mesh/LOD0/D0 + Mesh-Damage-2: Mesh/LOD0/D1 + Mesh-Damage-3: Mesh/LOD0/D2 + Mesh-Damage-4: Mesh/LOD0/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "Base,MS,SS,GV", display: true + StackSize: 1000 + BlockColor: "110,110,110" + ShowBlockName: true + IsAccessible: true, type: bool + IsActivateable: true + IsOxygenTight: true, display: true + Info: bkiVentilator, display: true + HitPoints: 50, type: int, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + EnergyIn: 20, type: int, display: true, formatter: Watt + Category: Devices + AllowWander: true + + # UnlockCost: 3, display: true + # UnlockLevel: 3, display: true + # TechTreeparent: Core + # TechTreeNames: Misc +} + +{ +Block Id: 1406, Name: TrussWall, Ref: TrussCube + Model: Truss/TrussWall + Texture: 59 +} +{ +Block Id: 1407, Name: TrussCylinder, Ref: TrussCube + Model: Truss/TrussCylinder + Texture: 59 +} +{ +Block Id: 1408, Name: TrussHalfRound, Ref: TrussCube + Model: Truss/TrussHalfRound + Texture: "63,60,60,60,60,60" +} +{ +Block Id: 1409, Name: TrussQuarterRound, Ref: TrussCube + Model: Truss/TrussQuarterRound + Texture: "59,59,59,60,59,60" +} +{ +Block Id: 1410, Name: TrussQuarterRoundInv, Ref: TrussCube + Model: Truss/TrussQuarterRoundInv + Texture: "59,59,59,60,59,60" +} +{ +Block Id: 1411, Name: TrussCurveOutSlope, Ref: TrussCube + Model: Truss/TrussCurveOutSlope + Texture: "62,60,60,60,60,60" +} +{ +Block Id: 1412, Name: TrussWedgeThin, Ref: TrussCube + Model: Truss/TrussWedgeThin + Texture: "59,59,59,62,59,62" +} +{ +Block Id: 1413, Name: TrussQuarterRoundThin, Ref: TrussCube + Model: Truss/TrussQuarterRoundThin + Texture: "59,59,59,63,59,63" +} +{ +Block Id: 1414, Name: TrussCornerThin, Ref: TrussCube + Model: Truss/TrussCornerThin + Texture: "62,62,62,62,62,62" +} +{ +Block Id: 1415, Name: TrussCornerRoundThin, Ref: TrussCube + Model: Truss/TrussCornerRoundThin + Texture: 63 +} +{ +Block Id: 1416, Name: TrussCornerRoundThin2, Ref: TrussCube + Model: Truss/TrussCornerRoundThin2 + Texture: "62,63,62,62,62,62" +} +{ +Block Id: 1417, Name: ThrusterGVJetRound1x3x1, Ref: ThrusterGVRoundNormal + Info: bkiThrusterJetMS, display: true + Model: @models/Blocks/Thrusters/ThrusterJetRound1x3x1Prefab + MarketPrice: 1315, display: true + Mass: 743, type: float, display: true, formatter: Kilogram + Volume: 93.75, type: float, display: true, formatter: Liter + HitPoints: 300, type: int, display: true + ThrusterForce: 250, type: int, display: true, formatter: Newton + EnergyIn: 54, type: int, display: true, formatter: Watt + CPUIn: 180, type: int, display: true + Temperature: 150, display: true + SizeInBlocks: "1,3,1", display: true + TemplateRoot: ThrusterGVJetRound1x3x1 + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeParent: ThrusterGVRoundNormalT2 + TechTreeNames: Hover Vessel + UpgradeTo: null, display: false +} + +{ +Block Id: 1418, Name: ElderberryBushDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/ElderberryBushDecoPrefab +} +{ +Block Id: 1419, Name: ElderberryBushBlueDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/ElderberryBushBlueDecoPrefab +} +{ +Block Id: 1420, Name: AlienPalmTreeDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/AlienPalmTreeDecoPrefab + BlockColor: "10,10,10" +} +{ +Block Id: 1421, Name: AlienTentacleDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/AlienTentacleDecoPrefab + BlockColor: "110,110,110" +} +{ +Block Id: 1422, Name: HollywoodJuniperDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/HollywoodJuniperDecoPrefab +} +{ +Block Id: 1423, Name: BallTreeDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/BallTreeDecoPrefab + BlockColor: "26,50,89" +} +{ +Block Id: 1424, Name: BallFlower01Deco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/BallFlower01DecoPrefab + BlockColor: "26,50,89" +} +{ +Block Id: 1425, Name: OnionFlowerDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/OnionFlowerDecoPrefab + BlockColor: "110,110,110" +} +{ +Block Id: 1426, Name: FantasyPlant1Deco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/FantasyPlant1DecoPrefab + BlockColor: "10,10,10" +} +{ +Block Id: 1427, Name: AkuaFernDeco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/AkuaFernDecoPrefab +} +{ +Block Id: 1428, Name: GlowTube01Deco, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/GlowTube01DecoPrefab + BlockColor: "10,10,10" +} + +{ +Block Id: 1429, Name: DoorInterior01SlimSV, Ref: DoorSS01 + Model: @models/Blocks/Spaceship/DoorInteriorSlimSVPrefab + Mass: 123, type: float, display: true, formatter: Kilogram + HitPoints: 70, type: int, display: true + IsOxygenTight: false, display: true + SizeInBlocks: "2,3,1", display: true +} +{ +Block Id: 1430, Name: DoorSS01Slim, Ref: DoorSS01 + Model: @models/Blocks/Spaceship/DoorNewSlimSSPrefab + Mass: 148, type: float, display: true, formatter: Kilogram + HitPoints: 120, type: int, display: true + IsOxygenTight: true, display: true + SizeInBlocks: "2,3,1", display: true +} + +{ Block Id: 1431, Name: AlienWillow + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + Model: @models2/Environment/Plants/SpeedTrees/AlienWillowPrefab + ShowUser: No + SizeInBlocks: "5,7,5" + { Child DropOnDestroy + Item: WoodLogs + Count: "3,7" + Prob: 1 + } +} + +{ Block Id: 1432, Name: TextureMulti12 + StackSize: 999 + Material: hull + Texture: "265,266,267,268,269,270" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} + +{ Block Id: 1433, Name: TextureMulti13 + StackSize: 999 + Material: hull + Texture: "271,272,273,274,275,276" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} + +{ Block Id: 1434, Name: TextureMulti14 + StackSize: 999 + Material: hull + Texture: "277,36,37,38,278,279" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} + + +{ +Block Id: 1435, Name: WarpDriveSV, Ref: WarpDrive + Model: @models/Blocks/Spaceship/WarpDriveSVPrefab + AllowPlacingAt: SS, display: true + MarketPrice: 12410, display: true + SizeInBlocks: "3,2,5", display: true + Mass: 1490, type: float, display: true, formatter: Kilogram + Volume: 937.5, type: float, display: true, formatter: Liter + CPUIn: 3000, type: int, display: true + HitPoints: 250, type: int, display: true + Info: bkiWarpDriveSV, display: true + + ItemPerAU: PentaxidCrystal, display: true + CostPerAU: 1, display: true + CostPerLY: 0, display: true + RangeAU: 15, display: true + RangeLY: 0, display: true + + # Short Range Warp + CostPerKM: 1, display: true + RangeKM: 15000, display: true + #RangeKMuseDetector: true, display: true + MicroWarpCooldown: 25, dipslay: true + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + # TechTreeParent: PentaxidTankSV + TechTreeNames: Small Vessel +} + +{ +Block Id: 1436, Name: LandClaimDevice + Class: LandClaimDevice + Group: cpgEquipment + MarketPrice: 29615, display: true + IndexName: LCDevice + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/LandClaimDevicePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiLandClaimDevice, display: true + IsAccessible: true, type: bool + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "2,2,2", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 500 + Category: Devices + BlockColor: "110,110,110" + Mass: 2710, type: float, display: true, formatter: Kilogram + Volume: 1280, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: true, formatter: Watt + + UnlockCost: 15, display: true + UnlockLevel: 10, display: true + TechTreeParent: OfflineProtector + TechTreeNames: "Base" +} + +# Before: WarpDriveTankSV +{ +Block Id: 1437, Name: PentaxidTankSV + Class: WarpDriveTank + Group: cpgWarp + MarketPrice: 2737, display: true + IndexName: WarpDriveTank + LootList: 13 # lootcontainer Warp Drive Tank SV + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/WarpDriveTankSVPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + BlockColor: "110,110,110" + Place: Free + IsActivateableInCP: true + IsLockable: true + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + AllowPlacingAt: "SS,GV", display: true + # ModelOffset: "0,0.5,0" + SizeInBlocks: "1,1,2", display: true + Category: Devices + Mass: 328, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + StackSize: 1000 + VolumeCapacity: 200, type: float, display: true, formatter: Liter + HitPoints: 100, type: int, display: true + CPUIn: 200, type: int, display: true + BlastParticleIndex: 10 + BlastRadius: 5 + BlastDamage: 300 + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + TechTreeParent: FuelTankSV + TechTreeNames: "Small Vessel,Hover Vessel" + + WarpAccept: PentaxidCrystal + + Radiation: 5, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 35, display: true +} + +{ Block Id: 1438, Name: RockGrey01Large + Material: rock + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/RocksGrey/RockGrey01LargePrefab + SizeInBlocks: "1,1,1" + ShowUser: No + HitPoints: 20, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "3,4" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "2,3" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "1,2" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,2" + # Prob: 0.05 + # } +} +{ Block Id: 1439, Name: RockGrey02Large, Ref: RockGrey01Large + Model: @models2/Environment/Rocks/RocksGrey/RockGrey02LargePrefab + SizeInBlocks: "2,1,2" + HitPoints: 55, type: int, display: true + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "8,11" + Prob: 1 + } + # { Child DropOnDestroy_2 + # Item: IronOre + # Count: "2,3" + # Prob: 0.5 + # } + # { Child DropOnDestroy_3 + # Item: CopperOre + # Count: "1,2" + # Prob: 0.3 + # } + # { Child DropOnDestroy_4 + # Item: CobaltOre + # Count: "1,2" + # Prob: 0.05 + # } +} + +{ +Block Id: 1440, Name: StairsBlocksConcrete, Ref: StairsBlocks + Material: concrete + Info: bkiStairs, display: true + MarketPrice: 30, display: true + AllowPlacingAt: Base, display: true + Mass: 200, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + HitPoints: 175, type: int, display: true + ChildBlocks: "StairShapesLongConcrete, StairShapesShortConcrete" +} +{ +Block Id: 1441, Name: StairShapesShortConcrete, Ref: StairShapes + Material: concrete + Texture: "5,93,93,93,93,93" + BlockColor: "110,110,110" + TextureTable: 2 # 2 = concrete table + TemplateRoot: StairsBlocksConcrete + AllowPlacingAt: Base, display: true + Mass: 200, type: float, display: true, formatter: Kilogram + # Volume: 25, type: float, display: true, formatter: Liter + HitPoints: 175, type: int, display: true + ChildShapes: "Stairs1x1x1, StairsCurved, StairsCurvedLeft" +} +{ +Block Id: 1442, Name: StairShapesLongConcrete, Ref: StairShapes + Material: concrete + Texture: "5,93,93,93,93,93" + BlockColor: "110,110,110" + TextureTable: 2 # 2 = concrete table + TemplateRoot: StairsBlocksConcrete + AllowPlacingAt: Base, display: true + Mass: 200, type: float, display: true, formatter: Kilogram + # Volume: 25, type: float, display: true, formatter: Liter + HitPoints: 200, type: int, display: true + SizeInBlocks: "1,1,2", display: true + ChildShapes: "Stairs1x2x1" +} + +{ +Block Id: 1443, Name: StairsBlocksWood, Ref: StairsBlocks + Material: woodblock + Info: bkiStairs, display: true + MarketPrice: 15, display: true + AllowPlacingAt: Base, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + HitPoints: 50, type: int, display: true + ChildBlocks: "StairShapesLongWood, StairShapesShortWood" +} +{ +Block Id: 1444, Name: StairShapesShortWood, Ref: StairShapes + Material: woodblock + Texture: "70,87,87,87,87,87" + BlockColor: "123,73,46" + TextureTable: 1 # 1 = wood table + TemplateRoot: StairsBlocksWood + AllowPlacingAt: Base, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + # Volume: 9, type: float, display: true, formatter: Liter + HitPoints: 50, type: int, display: true + ChildShapes: "Stairs1x1x1, StairsCurved, StairsCurvedLeft" +} +{ +Block Id: 1445, Name: StairShapesLongWood, Ref: StairShapes + Material: woodblock + Texture: "70,87,87,87,87,87" + BlockColor: "123,73,46" + TextureTable: 1 # 1 = wood table + TemplateRoot: StairsBlocksWood + AllowPlacingAt: Base, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + # Volume: 9, type: float, display: true, formatter: Liter + HitPoints: 75, type: int, display: true + SizeInBlocks: "1,1,2", display: true + ChildShapes: "Stairs1x2x1" +} +{ +Block Id: 1446, Name: ConstructorSV, Ref: ConstructorSmallV2 + Class: ConstructorSmallV2 + MarketPrice: 1220, display: true + AllowPlacingAt: "SS", display: true + TemplateRoot: ConstructorSV + + ShowUser: Yes + + Mass: 507, type: float, display: true, formatter: Kilogram + Volume: 93.75, type: float, display: true, formatter: Liter + EnergyIn: 420, type: int, display: true, formatter: Watt + CPUIn: 2000, type: int, display: true + EnergyInIdle: 9, type: int, display: true, formatter: Watt + UnlockCost: 5, display: true + UnlockLevel: 7, display: true + TechTreeParent: GeneratorSV + TechTreeNames: "Small Vessel" +} + +{ +Block Id: 1447, Name: ConstructorHV, Ref: ConstructorSmallV2 + Class: ConstructorHover + MarketPrice: 1150, display: true + AllowPlacingAt: "GV", display: true + TemplateRoot: ConstructorHV + Mass: 390, type: float, display: true, formatter: Kilogram + Volume: 93.75, type: float, display: true, formatter: Liter + + ShowUser: Yes + + EnergyIn: 420, type: int, display: true, formatter: Watt + CPUIn: 2000, type: int, display: true + EnergyInIdle: 9, type: int, display: true, formatter: Watt + UnlockCost: 7 + UnlockLevel: 5 + TechTreeParent: GeneratorSV + TechTreeNames: "Hover Vessel" +} + +{ Block Id: 1448, Name: TextureMulti15 + StackSize: 999 + Material: hull + Texture: "280,281,282,283,284,285" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +{ +Block Id: 1449, Name: PlayerSpawnerPlateThinSVHV, Ref: PlayerSpawner + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "2,3,2", display: true + Texture: 51 + BlockColor: "0,15,100" + Model: @models/Blocks/Misc/PlayerSpawnerPlateThinPrefab + IsTextureable: true # !!! + IsGPUInstance: false # !!! +} +{ +Block Id: 1450, Name: StandingHishkal, Ref: NPCAlienTemplate + Group: cpgCrewStationAlien + #CustomIcon: StandingHexapod #Icon required + # DialogueState: Barking_Military_Random_Init #create matching barking text + Class: NPCDialogue + NPCModelRotation: true + AllowedInBlueprint: false, display: true + IsAccessible: true, type: bool + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/HishkalElderStandingIcePrefab +} + +#{ +Block Id: 1451, Name: ZiraxNPCBlocks +# CustomIcon: ZiraxCommander +# Material: human +# AllowedInBlueprint: false, display: true +# RepairToTemplate: false, display: true +# Class: NPCDialogue +# Shape: ModelEntity +# MarketPrice: 5850, display: true +# DropMeshfile: Entities/Misc/DropContainerNewPrefab # Large container box; Available variants: see start of this document! +# ModelOffset: "0,0.5,0" +# BlockColor: "110,110,110" +# SizeInBlocks: "1,1,1", display: true + # SizeInBlocksLocked: "Base,MS" +# Place: Free +# IsAccessible: false, type: bool +# IsOxygenTight: false, display: true +# Category: Deco Blocks +# AllowPlacingAt: "Base,MS", display: false +# Info: bkiZiraxNPCBlocks, display: true +# Volume: 40, type: float, display: true, formatter: Liter +# StackSize: 1000 +# EnergyIn: 1, type: int, display: false, formatter: Watt +# HitPoints: 250, type: int, display: true +# XpFactor: 0 +# BlastParticleIndex: 14 +# BlastRadius: 1 +# BlastDamage: 0 +# Mass: 80, type: float, display: true, formatter: Kilogram +# ChildBlocks: "ZiraxCommander, SecurityGuardZirax" +# +#} +{ +Block Id: 1452, Name: ReceptionTableWorkstation, Ref: DecoTemplate + # CanPickup: true + Class: PassengerSeat + Group: cpgEquipment + CustomIcon: ReceptionTable #Icon required + Model: @models2/Entities/Decorations/Misc/ReceptionTableWorkstationPrefab + IsAccessible: true, type: bool + IsLockable: true + SymType: 1 + ShowUser: Yes +} +{ +Block Id: 1453, Name: StandingHuman, Ref: NPCHumanTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + #HitPoints: 500, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/StandingHumanPrefab + ShowUser: Yes +} +{ +Block Id: 1454, Name: StandingHuman2, Ref: NPCHumanTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + #HitPoints: 500, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/StandingHuman2Prefab + ShowUser: Yes +} +{ +Block Id: 1455, Name: ControlStationHuman, Ref: NPCHumanTemplate + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + BlockColor: "170,170,170" + Model: @models2/Entities/Decorations/DecoNPC/ControlStationHumanPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1456, Name: ReceptionTableHuman, Ref: NPCHumanTemplate + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ReceptionTableHumanPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1457, Name: ControlStationHuman2, Ref: NPCHumanTemplate + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + BlockColor: "170,170,170" + Model: @models2/Entities/Decorations/DecoNPC/ControlStationHuman2Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1458, Name: ScifiTableHuman, Ref: NPCHumanTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ScifiTableHumanPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1459, Name: ScifiLargeSofaHuman, Ref: NPCHumanTemplate + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/ScifiLargeSofaHumanPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1460, Name: TacticalOfficer, Ref: NPCHumanTemplate + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + # Info: bkiTacticalOfficer, display: true + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/TacticalOfficerPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1461, Name: CommandingOfficer, Ref: NPCHumanTemplate + NPCModelRotation: true + Group: cpgCrewStationHuman + Info: bkiCommandingOfficer, display: true + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/CommandingOfficerPrefab + ShowUser: Yes +} +{ +Block Id: 1462, Name: SecurityGuard, Ref: NPCHumanTemplate + NPCModelRotation: true + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/SecurityGuardPrefab + ShowUser: Yes +} +{ +Block Id: 1463, Name: OperatorPilot, Ref: NPCHumanTemplate + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + BlockColor: "110,110,110" + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/OperatorPilotPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1464, Name: EngineerMainStation, Ref: NPCHumanTemplate + Group: cpgCrewStationHuman + IsAccessible: true, type: bool + BlockColor: "110,110,110" + # AllowedInBlueprint: false, display: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/EngineerMainStationPrefab + EnergyIn: 1, type: int, display: true, formatter: Watt + SizeInBlocks: "2,1,1", display: true + ShowUser: Yes +} +{ +Block Id: 1465, Name: AlienNPCBlocks + Material: human + Class: NPCDialogue + MarketPrice: 5850, display: true + Shape: Invisible + # Model: @models2/Entities/Decorations/DecoNPC/ControlStationNPCPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + Info: bkiBlockGroup, display: true + Mass: 80, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: false, formatter: Watt + HitPoints: 250, type: int, display: true + StackSize: 1000 + Category: Devices + ChildBlocks: "CommandingOfficerAlien, SecurityGuardAlien, ControlStationNPC, ReceptionTableNPC, ScifiTableV2NPC, ScifiSofaNPC, ScifiTableNPC, ConsoleSmallNPC, SofaNPC, StandingNPC, StandingNPC2, ScifiLargeSofaNPC, ScifiTableNPC2, ScifiTableNPC3, DancingAlien1, DancingAlien2, GamblingTableTwoPerson, GamblingTableMultiplePeople, StandingNPCAlienCivilian1, ZiraxCommander, SecurityGuardZirax" +} +{ +Block Id: 1466, Name: HumanNPCBlocks + Material: human + Class: NPCDialogue + Shape: Invisible + MarketPrice: 5850, display: true + # Model: @models2/Entities/Decorations/DecoNPC/ConsoleSmallHumanPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + Info: bkiBlockGroup, display: true + Mass: 80, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + StackSize: 1000 + EnergyIn: 1, type: int, display: false, formatter: Watt + Category: Devices + HitPoints: 250, type: int, display: true + ChildBlocks: "ConsoleSmallHuman, StandingHuman, StandingHuman2, ControlStationHuman, ControlStationHuman2, ReceptionTableHuman, ScifiTableHuman, ScifiLargeSofaHuman, TacticalOfficer, CommandingOfficer, CommandingOfficer2, SecurityGuard, SecurityGuard2, OperatorPilot, EngineerMainStation, DancingHuman1, DancingHuman2, DancingHuman3, CommandingOfficerMale" +} +{ +Block Id: 1467, Name: CommandingOfficer2, Ref: NPCHumanTemplate + Class: NPCDialogue + NPCModelRotation: false + Group: cpgCrewStationHuman + Info: bkiCommandingOfficer2, display: true + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + # HitPoints: 500, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/CommandingOfficer2Prefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1468, Name: SecurityGuard2, Ref: NPCHumanTemplate + NPCModelRotation: true + Class: NPCDialogue + Group: cpgCrewStationHuman + Info: bkiSecurityGuard2, display: true + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + # HitPoints: 500, type: int, display: true + # HitPoints: 15, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/SecurityGuard2Prefab + ShowUser: Yes +} +{ +Block Id: 1469, Name: CommandingOfficerAlien, Ref: NPCAlienTemplate + Class: NPCDialogue + NPCModelRotation: true + Group: cpgCrewStationAlien + Info: bkiCommandingOfficerAlien, display: true + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + # HitPoints: 15, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/CommandingOfficerAlienPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} +{ +Block Id: 1470, Name: SecurityGuardAlien , Ref: NPCAlienTemplate + Class: NPCDialogue + NPCModelRotation: true + Group: cpgCrewStationAlien + Info: bkiSecurityGuardAlien, display: true + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + #HitPoints: 250, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/SecurityGuardAlienPrefab + EnergyIn: 0, type: int, display: true, formatter: Watt + ShowUser: Yes +} + +{ +Block Id: 1471, Name: StandingTalon, Ref: NPCAlienTemplate + Class: NPCDialogue + NPCModelRotation: true + Group: cpgCrewStationAlien + Info: bkiStandingTalon, display: true + AllowedInBlueprint: false, display: true + IsAccessible: true, type: bool + # CanPickup: true + HitPoints: 250, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/StandingTalonPrefab + ShowUser: Yes +} +{ +Block Id: 1472, Name: StandingAlienAssassin, Ref: NPCAlienTemplate + Group: cpgCrewStationAlien + AllowedInBlueprint: false, display: true + IsAccessible: true, type: bool + # CanPickup: true + HitPoints: 250, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/StandingAlienAssassinPrefab +} +{ +Block Id: 1473, Name: StandingHexapod, Ref: NPCAlienTemplate + Group: cpgCrewStationAlien + AllowedInBlueprint: false, display: true + IsAccessible: true, type: bool + # CanPickup: true + HitPoints: 250, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/StandingHexapodPrefab +} +{ +Block Id: 1474, Name: DancingHuman1, Ref: NPCHumanTemplate + Model: @models2/Entities/Decorations/DecoNPC/DancingHuman1Prefab +} +{ +Block Id: 1475, Name: DancingHuman2, Ref: NPCHumanTemplate + Model: @models2/Entities/Decorations/DecoNPC/DancingHuman2Prefab +} +{ +Block Id: 1476, Name: DancingHuman3, Ref: NPCHumanTemplate + Model: @models2/Entities/Decorations/DecoNPC/DancingHuman3Prefab +} +{ +Block Id: 1477, Name: DancingAlien1, Ref: NPCAlienTemplate + Model: @models2/Entities/Decorations/DecoNPC/DancingAlien1Prefab +} + +# Standard Plastic Small Block +{ +Block Id: 1478, Name: PlasticSmallBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: plastic + Texture: 77 + MarketPrice: 5, display: true + BlockColor: "10,10,10" + Shape: Invisible + Place: Free + # Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "SS,GV", display: true + HitPoints: 20, type: int, display: false + Mass: 3, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + StackSize: 2000 + BlockSizeScale: 2 + Category: BuildingBlocks + ChildBlocks: "PlasticFullSmall, PlasticThinSmall, PlasticExtendedSmall, PlasticExtendedSmall2, PlasticExtendedSmall3, PlasticExtendedSmall4, PlasticExtendedSmall5, PlasticExtendedSmall6, PlasticExtendedSmall7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Two shape families for Plastic Small Block +{ +Block Id: 1479, Name: PlasticFullSmall + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: plastic + Texture: 77 + BlockColor: "10,10,10" + Shape: New + Place: Free + Model: Cube + TemplateRoot: PlasticSmallBlocks + AllowPlacingAt: "SS,GV", display: true + HitPoints: 20, type: int, display: true + Mass: 3, type: float, display: true, formatter: Kilogram + BlockSizeScale: 2 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: HullFullSmall, display: true +} + +{ +Block Id: 1480, Name: PlasticThinSmall, Ref: PlasticFullSmall + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: HullThinSmall, display: true +} +# Standard Plastic Large Block +{ +Block Id: 1481, Name: PlasticLargeBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: plastic + Texture: 77 + MarketPrice: 8, display: true + BlockColor: "10,10,10" + Shape: New + Place: Free + Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "Base,MS", display: true + HitPoints: 200, type: int, display: false + Mass: 100, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "PlasticFullLarge, PlasticThinLarge, PlasticExtendedLarge, PlasticExtendedLarge2, PlasticExtendedLarge3, PlasticExtendedLarge4, PlasticExtendedLarge5, PlasticExtendedLarge6, PlasticExtendedLarge7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +# Two shape families for Plastic Large Block +{ +Block Id: 1482, Name: PlasticFullLarge + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: plastic + Texture: 77 + BlockColor: "10,10,10" + Shape: New + Place: Free + Model: Cube + TemplateRoot: PlasticLargeBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 200, type: int, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + BlockSizeScale: 8 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + UpgradeTo: HullFullLarge, display: true +} + +{ +Block Id: 1483, Name: PlasticThinLarge, Ref: PlasticFullLarge + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" + UpgradeTo: HullThinLarge, display: true +} +{ +Block Id: 1484, Name: HoverEngineThruster + IndexName: HoverEngineV2 + Group: cpgHoverEngine + MarketPrice: 285, display: true + EssentialCategory: "cpgHoverEngine, cpgThruster" + Material: metal + Shape: ModelEntity + ShowBlockName: true + BlockColor: "110,110,110" + Model: @models/Blocks/Spaceship/HoverEngineThrusterPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: GV, display: true + Place: FlyDirBottom, display: true + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Category: Devices + Info: bkiHoverEngineThruster, display: true + Mass: 74, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + StackSize: 2000 + MaxCount: 4, type: int, display: true + ForceMaxCount: true + EnergyDynamicGroup: HoverEngine + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 400, type: int, display: true + + Torque: 10, type: int, display: true, formatter: NewtonMeter + ThrusterForce: 20, type: int, display: true, formatter: Newton + HVEngineForce: 15, type: int, display: true, formatter: Newton + HVEngineDampCoef: 8, type: float, display: false, formatter: Hardness + HVEngineDampPow: 1.5, type: float, display: false, formatter: Hardness + + HitPoints: 100, type: int, display: true + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: Hover Vessel +} +{ +Block Id: 1485, Name: MobileAirCon + Material: metalhard + Shape: ModelEntity + MarketPrice: 15, display: true + Model: @models/Blocks/Devices/PortableHeaterCoolerFWNPrefab #1.11 update + #Model: @models/Blocks/Terrain/MobileAirConPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + Info: bkiMobileAirCon, display: true + IsTerrainDecoration: true + AllowPlacingAt: Terrain, display: true + StackSize: 2000 + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + # Info: bkiConstructorSurvival, display: true + EnergyIn: 10, type: int, display: true, formatter: Watt + Mass: 35, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + HitPoints: 150, type: int, display: true + BlockColor: "110,110,110" + Category: Devices + IsColorable: false + SizeInBlocks: "1,1,1" #no need to display info for terrain placeables (removed to reduce hover info density) + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + Temperature: 25, display: false # Target temperature that device tries to attain + TemperatureGain: 30, display: true # Max temperature that device is able to cool down or heat up + + # MapIcon: map_SurvConstructor + # MapName: ConstructorSurvival + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeParent: OxygenGeneratorSmall + TechTreeNames: Misc +} +{ +Block Id: 1486, Name: RepairBayCVT2, Ref: RepairBayCV + Model: @models/Blocks/Misc/RepairBayT2Prefab + MarketPrice: 143088, display: true + + Mass: 11770, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + IsRepairToBlueprint: true + Info: bkiRepairBayT2, display: true + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: RepairBayCV + TechTreeNames: Capital Vessel + UpgradeTo: null, display: false + CPUIn: 50000, type: int, display: true +} + +{ +Block Id: 1487, Name: CaptainChair01 + Class: CockpitMS + IndexName: Cockpit + Group: cpgCockpit + EssentialCategory: cpgCockpit + ShowBlockName: true + Shape: ModelEntity + Model: @models/Blocks/Cockpits/CockpitCVCptChair01Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + Material: metal + Place: Cockpit + TemplateRoot: CockpitBlocksCV + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsLockable: true + IsIgnoreLC: true + Info: bkiCockpit, display: true + YawRotation: 150 # how far in degrees head of player can be rotated (default 75°) + StackSize: 25 + Category: Devices + Mass: 100, type: float, display: true, formatter: Kilogram + HitPoints: 150, type: int, display: true + EnergyIn: 20, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 80 +} +{ Block Id: 1488, Name: LightSensor, Ref: MotionSensor + Model: @models/Blocks/Misc/LightSensorPrefab + BlockColor: "102,0,0" + ShowUser: No +} +{ Block Id: 1489, Name: ChristmasTree, Ref: IndoorPlant01 + Model: @models2/Entities/Decorations/Plants/ChristmasTreePrefab + SizeInBlocks: "2,2,2", display: true +} +{ +Block Id: 1490, Name: RepairBayConsole + Group: cpgRepair + Class: RepairBayConsole + Material: metallight + MarketPrice: 97615, display: true + Shape: ModelEntity + ShowBlockName: true + Info: bkiRepairBayConsole, display: true + Model: @models/Blocks/Mothership/RepairConsolePrefab + Mesh-Damage-1: Mesh/D0 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Category: Devices + StackSize: 1000 + IsLockable: true + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 800, type: float, display: true, formatter: Liter + Mass: 630, type: float, display: true, formatter: Kilogram + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 2500, type: int, display: true + BlockColor: "110,110,110" + HitPoints: 500, type: int, display: true + + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeParent: RepairStation + TechTreeNames: "Base,Capital Vessel" +} + +{ +Block Id: 1491, Name: LightCorner, Ref: LightMS01 + # CanPickup: true + Model: @models/Blocks/Mothership/LightCornerPrefab + Place: Free +} +{ +Block Id: 1492, Name: LightCorner02, Ref: LightMS01 + # CanPickup: true + Model: @models/Blocks/Mothership/LightCorner02Prefab + Place: Free +} +{ +Block Id: 1493, Name: BunkBed02, Ref: DecoTemplate + Class: Sleeping + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + BuffNamesActivate: "IncreaseBodyTemp", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst + # CanPickup: true + Model: @models2/Entities/Decorations/Misc/BunkBed02Prefab + EnergyIn: 0, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" +} + +{ +Block Id: 1494, Name: SolarPanelBlocks + Class: SolarPanel + IndexName: SolarPanel + Group: cpgSolar + MarketPrice: 268, display: true + PanelAngle: 30 + SolarPanelEfficiency: 1.8, display: true + EssentialCategory: cpgFuelTank + Material: metallight + Shape: Invisible + Place: Free + # Model: @models/Blocks/Misc/SolarPanelSlopePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: Base, display: true + Category: Devices + Mass: 730, type: float, display: true, formatter: Kilogram + Volume: 55, type: float, display: true, formatter: Liter + StackSize: 500 + MaxCount: 15, type: int, display: true + CPUIn: 45, type: int, display: true + ChildBlocks: "SolarPanelSlope, SolarPanelSlope2, SolarPanelSlope3, SolarPanelHorizontal, SolarPanelHorizontal2, SolarPanelHorizontalMount, SolarPanelHorizontalStand" + Info: bkiSolarPanel, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeParent: SolarPanelSmallBlocks + TechTreeNames: "Base" +} +{ +Block Id: 1495, Name: SolarGenerator + Class: SolarGenerator + IndexName: SolarGenerator + Group: cpgSolar + MarketPrice: 380, display: true + EssentialCategory: cpgGenerator + IsSolarPanel: true + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/SolarGeneratorPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + StackSize: 500 + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + SymType: 4 + IsOxygenTight: false, display: true + Info: bkiSolarGenerator, display: true + Category: Devices + HitPoints: 150, type: int, display: true + EnergyDynamicGroup: SolarGenerator + IsActivateable: true + + BlockColor: "110,110,110" + + FuelCapacity: 1500, type: int, display: true + EnergyOut: 5000, type: int, display: false, formatter: Watt # do not display for solar generator + Mass: 2050, type: float, display: true, formatter: Kilogram + Volume: 150, type: float, display: true, formatter: Liter + CPUIn: 50, type: int, display: true + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 200 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Base" +} +{ +Block Id: 1496, Name: SolarPanelSlope + Class: SolarPanel + IndexName: SolarPanel + Group: cpgSolar + PanelAngle: 30 + SolarPanelEfficiency: 1.8, display: true + EssentialCategory: cpgFuelTank + Material: metallight + Shape: ModelEntity + Place: Free + Model: @models/Blocks/Misc/SolarPanelSlopePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: Base, display: true + SizeInBlocks: "5,2,3", display: true + SizeInBlocksLocked: "Base,MS" + Category: Devices + HitPoints: 150, type: int, display: true + Mass: 730, type: float, display: true, formatter: Kilogram + MaxCount: 15, type: int, display: true + CPUIn: 45, type: int, display: true + TemplateRoot: SolarPanelBlocks + IsOxygenTight: false, display: true + BlockColor: "110,110,110" +} +{ +Block Id: 1497, Name: SolarPanelHorizontal, Ref: SolarPanelSlope + PanelAngle: 0 + Model: @models/Blocks/Misc/SolarPanelHorizontalPrefab + SizeInBlocks: "5,1,3", display: true + SymType: 4 + IsOxygenTight: true, display: true +} +{ +Block Id: 1498, Name: SolarPanelHorizontal2, Ref: SolarPanelSlope + PanelAngle: 0 + Model: @models/Blocks/Misc/SolarPanelHorizontal2Prefab + SizeInBlocks: "5,1,3", display: true + SymType: 4 + IsOxygenTight: true, display: true +} +{ +Block Id: 1499, Name: SolarPanelHorizontalMount, Ref: SolarPanelSlope + PanelAngle: 0 + Model: @models/Blocks/Misc/SolarPanelHorizontalMountPrefab + SizeInBlocks: "5,1,3", display: true + SymType: 4 + IsOxygenTight: true, display: true +} + +{ +Block Id: 1500, Name: ForcefieldEmitterBlocks + Material: metallight + #Group: cpgForcefieldEmitter + ShowBlockName: true + Shape: Invisible + MarketPrice: 1578, display: true + # Model: @models/Blocks/Mothership/ForcefieldEmitter1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsOxygenTight: true, display: true + # IsActivateable: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + StackSize: 2000 + BlockColor: "110,110,110" + ChildBlocks: "ForcefieldEmitter1x1, ForcefieldEmitter1x2, ForcefieldEmitter1x3, ForcefieldEmitter3x5, ForcefieldEmitter3x9, ForcefieldEmitter5x11, ForcefieldEmitter7x12" + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + # TechTreeNames: "Small Vessel,Hover Vessel,Base,Capital Vessel" + TechTreeNames: Misc +} +{ +Block Id: 1501, Name: ForcefieldEmitter1x1 + # Group: cpgForcefieldEmitter + Material: metallight + ShowBlockName: true + Shape: ModelEntity + Model: @models/Blocks/Mothership/ForcefieldEmitter1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: ForcefieldEmitterBlocks + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsOxygenTight: true, display: true + # IsActivateable: true + IsAccessible: false, type: bool # force field blocks are still airtight even if switched off, so we do not let them be switched off + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + #OccupySizeInBlocks: true # shape has no collider so don't voxelize it NEW: we added a special O2 collider so voxelize it now +} +{ +Block Id: 1502, Name: ForcefieldEmitter1x2, Ref: ForcefieldEmitter1x1 + Model: @models/Blocks/Mothership/ForcefieldEmitter1x2Prefab + SizeInBlocks: "1,2,1", display: true +} +{ +Block Id: 1503, Name: ForcefieldEmitter1x3, Ref: ForcefieldEmitter1x1 + Model: @models/Blocks/Mothership/ForcefieldEmitter1x3Prefab + SizeInBlocks: "1,3,1", display: true +} +{ +Block Id: 1504, Name: ForcefieldEmitter3x5, Ref: ForcefieldEmitter1x1 + Model: @models/Blocks/Mothership/ForcefieldEmitter3x5Prefab + SizeInBlocks: "3,5,1", display: true +} +{ +Block Id: 1505, Name: ForcefieldEmitter3x9, Ref: ForcefieldEmitter1x1 + Model: @models/Blocks/Mothership/ForcefieldEmitter3x9Prefab + SizeInBlocks: "3,9,1", display: true +} +{ +Block Id: 1506, Name: ForcefieldEmitter5x11, Ref: ForcefieldEmitter1x1 + Model: @models/Blocks/Mothership/ForcefieldEmitter5x11Prefab + SizeInBlocks: "5,11,1", display: true +} +# De-activated since problem with SI - Mantis 6413 +{ +Block Id: 1507, Name: ForcefieldEmitter7x14, Ref: ForcefieldEmitter1x1 + Model: @models/Blocks/Mothership/ForcefieldEmitter7x14Prefab + SizeInBlocks: "7,14,1", display: true + ShowUser: NoButCSV + +} +{ +Block Id: 1508, Name: ForcefieldEmitter7x12, Ref: ForcefieldEmitter1x1 + Model: @models/Blocks/Mothership/ForcefieldEmitter7x12Prefab + SizeInBlocks: "7,12,1", display: true +} + +{ +Block Id: 1509, Name: VentilatorThin, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorWallPrefab + HitPoints: 20, type: int, display: false + Mass: 50, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + EnergyIn: 20, type: int, display: true, formatter: Watt + + # UnlockCost: 3, display: true + # UnlockLevel: 3, display: true + # TechTreeparent: Core + # TechTreeNames: Misc +} + +{ +Block Id: 1510, Name: SolarPanelSlope2, Ref: SolarPanelSlope + PanelAngle: 33 + Model: @models/Blocks/Misc/SolarPanelSlope2Prefab + SizeInBlocks: "5,2,3", display: true + IsOxygenTight: true, display: true +} + +{ +Block Id: 1511, Name: SolarPanelSlope3, Ref: SolarPanelSlope + PanelAngle: 27 + Model: @models/Blocks/Misc/SolarPanelSlope3Prefab + SizeInBlocks: "5,2,3", display: true + IsOxygenTight: true, display: true +} +{ +Block Id: 1512, Name: SolarPanelHorizontalStand, Ref: SolarPanelSlope + PanelAngle: 0 + Model: @models/Blocks/Misc/SolarPanelHorizontalStandPrefab + SizeInBlocks: "5,1,3", display: true + IsOxygenTight: false, display: true +} + +# Small solar panels +{ +Block Id: 1513, Name: SolarPanelSmallBlocks + Class: SolarPanel + IndexName: SolarPanel + Group: cpgSolar + MarketPrice: 180, display: true + PanelAngle: 30 + SolarPanelEfficiency: 1.0, display: true + EssentialCategory: cpgFuelTank + Material: metallight + Shape: Invisible + Place: Free + # Model: @models/Blocks/Misc/SolarPanelSlopeSmallPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "3,1,2", display: true + AllowPlacingAt: Base, display: true + Category: Devices + HitPoints: 100, type: int, display: true + Mass: 350, type: float, display: true, formatter: Kilogram + Volume: 50, type: float, display: true, formatter: Liter + StackSize: 1000 + MaxCount: 15, type: int, display: true + CPUIn: 20, type: int, display: true + ChildBlocks: "SolarPanelSlopeSmall, SolarPanelSlope3Small, SolarPanelHorizontalSmall" + Info: bkiSolarPanel, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Base" +} +{ +Block Id: 1514, Name: SolarPanelSlope3Small + Class: SolarPanel + IndexName: SolarPanel + Group: cpgSolar + PanelAngle: 27 + SolarPanelEfficiency: 1.0, display: true + EssentialCategory: cpgFuelTank + Material: metallight + Shape: ModelEntity + Place: Free + Model: @models/Blocks/Misc/SolarPanelSlope3SmallPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "3,1,2", display: true + AllowPlacingAt: Base, display: true + Category: Devices + HitPoints: 100, type: int, display: true + Mass: 350, type: float, display: true, formatter: Kilogram + MaxCount: 15, type: int, display: true + CPUIn: 20, type: int, display: true + TemplateRoot: SolarPanelSmallBlocks + IsOxygenTight: true, display: true + BlockColor: "110,110,110" +} +{ +Block Id: 1515, Name: SolarPanelSlopeSmall, Ref: SolarPanelSlope3Small + PanelAngle: 30 + Model: @models/Blocks/Misc/SolarPanelSlopeSmallPrefab + IsOxygenTight: false, display: true +} +{ +Block Id: 1516, Name: SolarPanelHorizontalSmall, Ref: SolarPanelSlope3Small + PanelAngle: 0 + Model: @models/Blocks/Misc/SolarPanelHorizontalSmallPrefab + SymType: 4 +} + +# New AI weapons for POI +{ +Block Id: 1517, Name: TurretEnemyRocket, Ref: TurretIONCannon #TurretAlienGeneric BA Rocket + Group: cpgTurret + IndexName: Turret + Model: @models/Blocks/Turrets/TurretsEnemy/TurretEnemyLaserPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + SizeInBlocks: "3,2,3", display: true + BlockColor: "102,0,0" + WeaponItem: TurretEnemyRocketWeapon + RotSpeed: 150, type: int, display: true + CPUIn: 3160, type: int, display: true + AboveTerrainCheck: false +} +{ +Block Id: 1518, Name: TurretEnemyArtillery, Ref: TurretIONCannon #TurretAlienGeneric BA Artillery + Group: cpgTurret + IndexName: Turret + Model: @models/Blocks/Turrets/TurretsEnemy/TurretEnemyLaserPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + SizeInBlocks: "3,2,3", display: true + BlockColor: "0,15,100" + WeaponItem: TurretEnemyArtilleryWeapon + RotSpeed: 150, type: int, display: true + CPUIn: 3390, type: int, display: true + HitPoints: 15800, type: int, display: true + AboveTerrainCheck: false +} +{ +Block Id: 1519, Name: CommandingOfficerMale, Ref: NPCHumanTemplate + Class: NPCDialogue + NPCModelRotation: true + # CustomIcon: CommandingOfficer + Group: cpgCrewStationHuman + Info: bkiCommandingOfficer, display: true + IsAccessible: true, type: bool + # AllowedInBlueprint: false, display: true + # CanPickup: true + # HitPoints: 15, type: int, display: true + Model: @models2/Entities/Decorations/DecoNPC/CommandingOfficerMalePrefab + ShowUser: Yes +} +{ +Block Id: 1520, Name: SecurityGuardZirax # Ref: ZiraxNPCBlocks #Replace Icon + Model: @models2/Entities/Decorations/DecoNPC/SecurityGuardZiraxPrefab + Info: bkiSecurityGuardZirax, display: true + Class: NPCDialogue + NPCModelRotation: true + #DialogueState: Barking_Zirax_Anywhere_Init + #OmitCone: true + Group: cpgCrewStationAlien + Category: Deco Blocks + Material: human + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + Shape: ModelEntity + MarketPrice: 5850, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Place: Free + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: false + Volume: 40, type: float, display: true, formatter: Liter + StackSize: 1 + EnergyIn: 1, type: int, display: false, formatter: Watt + HitPoints: 250, type: int, display: true + XpFactor: 0 + BlastParticleIndex: 14 + BlastRadius: 1 + BlastDamage: 0 + Mass: 80, type: float, display: true, formatter: Kilogram + ShowBlockName: true + # TemplateRoot: SecurityGuardZirax + # PickupTarget: SecurityGuardZirax + CanPickup: true + RpFactor: 0 + #ShowUser: Yes +} +# available free 1521 - 1526 +# Sprout +{ Block Id: 1527, Name: MushroomBellBrown01Stage1, Ref: PlantGrowingTemplate + Model: @models2/Entities/Farming/Misc/MushroomBellBrown01Stage1Prefab + MarketPrice: 77, display: true + { Child PlantGrowing + Next: MushroomBellBrown01Stage2 + GrowthRate: 15 + FertileLevel: 3 + OnDeath: PlantDead + } + CropType: MushroomBrown, display: true + # CropYield: 4, display: true + GrowthTimeInfo: 110, type: int, display: true, formatter: Minutes + Info: bkiPlantSprout, display: true + Category: Farming + PickupTarget: MushroomBellBrown01Stage1 # disassemble + TemplateRoot: MushroomBellBrown01Stage2 # deconstruct - to avoid exploit + ShowUser: Yes +} +{ Block Id: 1528, Name: MushroomBellBrown01Stage2, Ref: PlantGrowingTemplate + Model: @models2/Entities/Farming/Misc/MushroomBellBrown01Stage2Prefab + { Child PlantGrowing + Next: MushroomBellBrown01Stage3 + GrowthRate: 95 + FertileLevel: 3 + OnDeath: PlantDead + } + PickupTarget: MushroomBellBrown01Stage1 +} +{ Block Id: 1529, Name: MushroomBellBrown01Stage3, Ref: PlantHarvestTemplate + Model: @models2/Entities/Farming/Misc/MushroomBellBrown01Stage3Prefab + { Child DropOnHarvest + Item: MushroomBrown + Count: 4 + } + { Child CropsGrown + OnHarvest: MushroomBellBrown01Stage3NoFruit + OnDeath: PlantDead + } + PickupTarget: MushroomBellBrown01Stage1 + ShowUser: Yes +} +{ Block Id: 1530, Name: MushroomBellBrown01Stage3NoFruit, Ref: PlantGrowingTemplate + Model: @models2/Entities/Farming/Misc/MushroomBellBrown01Stage3NoFruitPrefab + { Child PlantGrowing + Next: MushroomBellBrown01Stage3 + GrowthRate: 100 + FertileLevel: 3 + OnDeath: PlantDead + } + PickupTarget: MushroomBellBrown01Stage1 +} + + +# Sprout +{ Block Id: 1531, Name: CobraLeavesPlantStage1, Ref: PlantGrowingTemplate + Model: @models2/Entities/Farming/SpeedTrees/CobraLeavesPlantStage1Prefab + MarketPrice: 77, display: true + { Child PlantGrowing + Next: CobraLeavesPlantStage2 + GrowthRate: 25 + FertileLevel: 3 + OnDeath: PlantDead + } + CropType: Spice, display: true + # CropYield: 3, display: true + GrowthTimeInfo: 50, type: int, display: true, formatter: Minutes + Info: bkiPlantSprout, display: true + Category: Farming + PickupTarget: CobraLeavesPlantStage1 # disassemble + TemplateRoot: CobraLeavesPlantStage2 # deconstruct - to avoid exploit + ShowUser: Yes +} +{ Block Id: 1532, Name: CobraLeavesPlantStage2, Ref: PlantGrowingTemplate + Model: @models2/Entities/Farming/SpeedTrees/CobraLeavesPlantStage2Prefab + { Child PlantGrowing + Next: CobraLeavesPlantStage3 + GrowthRate: 25 + FertileLevel: 3 + OnDeath: PlantDead + } + PickupTarget: CobraLeavesPlantStage1 +} +{ Block Id: 1533, Name: CobraLeavesPlantStage3, Ref: PlantHarvestTemplate + Model: @models2/Entities/Farming/SpeedTrees/CobraLeavesPlantStage3Prefab + { Child DropOnHarvest + Item: Spice + Count: 3 + } + { Child CropsGrown + OnHarvest: CobraLeavesPlantStage1 + OnDeath: PlantDead + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + PickupTarget: CobraLeavesPlantStage1 + ShowUser: Yes +} +{ Block Id: 1534, Name: AlienPlant07Harvested, Ref: AlienPlant01 + Class: PlantGrowing + { Child PlantGrowing + Next: AlienPlant07 + GrowthRate: 25 + FertileLevel: 1 + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + Model: @models2/Environment/Plants/AlienVegetation/AlienPlant07HarvestedPrefab +} + +{ +Block Id: 1535, Name: SurvivalTent01 + Class: SurvivalTent + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Terrain/SurvivalTent01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: Terrain, display: true + IsTerrainDecoration: true + StackSize: 2000 + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + Mass: 1.3, type: float, display: true, formatter: Kilogram + HitPoints: 50, type: int, display: true + SizeInBlocks: "4,2,4", display: true + Category: Devices + IsColorable: false + TemplateRoot: SurvivalTent + + MapIcon: map_Tent + MapName: SurvivalTent + + BuffNamesActivate: "NeutralBodyTemp", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst +} +{ Block Id: 1536, Name: SurvivalTent02, Ref: SurvivalTent01 + Model: @models/Blocks/Terrain/SurvivalTent02Prefab +} +{ Block Id: 1537, Name: SurvivalTent03, Ref: SurvivalTent01 + Model: @models/Blocks/Terrain/SurvivalTent03Prefab +} + +{ Block Id: 1538, Name: RockResourceIron + Material: rockhard + Shape: Deco + IsDeco: true + Model: @models2/Environment/Rocks/Resources/RockResourceIronPrefab + # Model: @models2/Environment/Rocks/Resources/RockResourceTitanPrefab + SizeInBlocks: "2,1,2" + BlockColor: "180,180,180" + ShowUser: No + HitPoints: 60, type: int, display: true + { Child DropOnDestroy_1 + Item: IronOre + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 1539, Name: RockResourceCobalt, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceCobaltPrefab + { Child DropOnDestroy_1 + Item: CobaltOre + Count: "1,2" + Prob: 1 + } +} +{ Block Id: 1540, Name: RockResourceCopper, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceCopperPrefab + { Child DropOnDestroy_1 + Item: CopperOre + Count: "2,3" + Prob: 1 + } +} +{ Block Id: 1541, Name: RockResourceSilicon, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceSiliconPrefab + { Child DropOnDestroy_1 + Item: SiliconOre + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1542, Name: RockResourceMagnesium, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceMagnesiumPrefab + { Child DropOnDestroy_1 + Item: MagnesiumOre + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1543, Name: RockResourceNeodymium, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceNeodymiumPrefab + { Child DropOnDestroy_1 + Item: NeodymiumOre + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1544, Name: RockResourceGold, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceGoldPrefab + { Child DropOnDestroy_1 + Item: GoldOre + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1545, Name: RockResourceErestrum, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceErestrumPrefab + { Child DropOnDestroy_1 + Item: ErestrumOre + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1546, Name: RockResourceSathium, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceSathiumPrefab + { Child DropOnDestroy_1 + Item: SathiumOre + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1547, Name: RockResourceZascosium, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourceZascosiumPrefab + { Child DropOnDestroy_1 + Item: ZascosiumOre + Count: "1,3" + Prob: 1 + } +} +{ Block Id: 1548, Name: RockResourcePromethium, Ref: RockResourceIron + Model: @models2/Environment/Rocks/Resources/RockResourcePromethiumPrefab + { Child DropOnDestroy_1 + Item: PromethiumOre + Count: "1,3" + Prob: 1 + } +} + +{ +Block Id: 1549, Name: HeavyWindowBlocks + Material: hullcombat + Shape: Invisible + MarketPrice: 2155, display: true + # Model: @models/Blocks/Windows/Heavy/HeavyWindowAPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiWindows, display: true + IsOxygenTight: true, display: true + IsAccessible: false, type: bool + Category: BuildingBlocks + ChildBlocks: "HeavyWindowA, HeavyWindowB, HeavyWindowC, HeavyWindowD, HeavyWindowM, HeavyWindowK, HeavyWindowE, HeavyWindowF, HeavyWindowG, HeavyWindowH, HeavyWindowI, HeavyWindowJ, HeavyWindowL, HeavyWindowN, HeavyWindowO, HeavyWindowP, HeavyWindowQ, HeavyWindowV, HeavyWindowS, HeavyWindowT, HeavyWindowU, HeavyWindowPConnectLeft, HeavyWindowPConnectRight, HeavyWindowPConnectTwo, HeavyWindowQConnect, HeavyWindowQConnectTwo, HeavyWindowQConnectThree, HeavyWindowQConnectFour, HeavyWindowSConnect, HeavyWindowTConnect, HeavyWindowUConnect, HeavyWindowVConnect, HeavyWindowQConnectTwoDiagonal, HeavyWindowEGlass, HeavyWindowFGlass, HeavyWindowGGlass, HeavyWindowHGlass, HeavyWindowIGlass, HeavyWindowJGlass, HeavyWindowEBottom, HeavyWindowFBottom, HeavyWindowGBottom, HeavyWindowHBottom, HeavyWindowIBottom, HeavyWindowJBottom" + Collide: Transparent + + TemplateRoot: HeavyWindowBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 1500, type: int, display: true + Mass: 880, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1550, Name: HeavyWindowA + Material: hullcombat + Shape: ModelEntity + Model: @models/Blocks/Windows/Heavy/HeavyWindowAPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: BuildingBlocks + BlockColor: "110,110,110" + Collide: Transparent + TemplateRoot: HeavyWindowBlocks + AllowPlacingAt: "Base,MS", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + HitPoints: 1500, type: int, display: true + Mass: 880, type: float, display: true, formatter: Kilogram + ShowUser: NoButCSV # inverted versions should not be display in H menu but should show up in EAH tool +} +{ +Block Id: 1551, Name: HeavyWindowB, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowBPrefab + MirrorTo: HeavyWindowC +} +{ +Block Id: 1552, Name: HeavyWindowC, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowCPrefab + MirrorTo: HeavyWindowB +} +{ +Block Id: 1553, Name: HeavyWindowD, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDPrefab +} +{ +Block Id: 1554, Name: HeavyWindowE, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowEPrefab + SymType: 2 +} +{ +Block Id: 1555, Name: HeavyWindowF, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowFPrefab + SymType: 2 +} +{ +Block Id: 1556, Name: HeavyWindowAInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowAInvPrefab +} +{ +Block Id: 1557, Name: HeavyWindowBInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowBInvPrefab + MirrorTo: HeavyWindowCInv +} +{ +Block Id: 1558, Name: HeavyWindowCInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowCInvPrefab + MirrorTo: HeavyWindowBInv +} +{ +Block Id: 1559, Name: HeavyWindowDInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDInvPrefab +} +{ +Block Id: 1560, Name: HeavyWindowEInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowEInvPrefab + SymType: 2 +} +{ +Block Id: 1561, Name: HeavyWindowFInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowFInvPrefab + SymType: 2 +} + +# available free 1562 - 1565 + +{ Block Id: 1566, Name: BrainFungusHarvested, Ref: BrainFungus + Class: PlantGrowing + { Child PlantGrowing + Next: BrainFungus + GrowthRate: 25 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/BrainFungusHarvestedPrefab +} + +# Destroyed Wood Blocks (large) - block group +{ +Block Id: 1567, Name: WoodLargeDestroyedBlocks + CustomIcon: WoodBlocks + Material: woodblock + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 70 + TextureTable: 1 # 1 = wood table + BlockColor: "187,135,90" + BlockSizeScale: 8 + Shape: Invisible + PickupTarget: WoodPlanks + Info: bkiWoodLargeDestroyedBlocks, display: true + AllowPlacingAt: Base, display: true + HitPoints: 100, type: int, display: false + Mass: 360, type: float, display: true, formatter: Kilogram + StackSize: 1000 + Category: BuildingBlocks + HideShapesWindow: true + ChildBlocks: "WoodFullLargeDestroyed, WoodThinLargeDestroyed" + IsOxygenTight: false, display: true + AllowedInBlueprint: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1568, Name: WoodFullLargeDestroyed + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: woodblock + Texture: 70 + TextureTable: 1 # 1 = wood table + BlockColor: "187,135,90" + BlockSizeScale: 8 + Shape: New + Place: Free + Model: Cube + TemplateRoot: WoodLargeDestroyedBlocks + AllowPlacingAt: Base, display: true + HitPoints: 100, type: int, display: true + Mass: 360, type: float, display: true, formatter: Kilogram + Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + UpgradeTo: WoodFull, display: true + IsOxygenTight: false, display: true + AllowedInBlueprint: false, display: true +} +{ +Block Id: 1569, Name: WoodThinLargeDestroyed, Ref: WoodFullLargeDestroyed + HitPoints: 75, type: int, display: true + Mass: 250, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + UpgradeTo: WoodThin, display: true + IsOxygenTight: false, display: true +} +{ Block Id: 1570, Name: DimpleDomeHarvested, Ref: DimpleDome + Class: PlantGrowing + { Child PlantGrowing + Next: DimpleDome + GrowthRate: 25 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/DimpleDomeHarvestedPrefab +} +{ +Block Id: 1571, Name: MedicalStationBlocks + Material: metallight + Shape: Invisible + MarketPrice: 2975, display: true + # Model: @models/Blocks/Mothership/MedicStationPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + Info: bkiBlockGroup, display: true + Mass: 1350, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + StackSize: 250 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Devices + ChildBlocks: "MedicinelabMS, Console4, ScannerBase1, Scanner2, Scanner3" + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: CloneChamber + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1572, Name: ToiletModern, Ref: DecoTemplate + Model: @models2/Entities/Decorations/HomeSweetHome/BathroomToiletNewPrefab + Class: PassengerSeat + BlockColor: "220,220,220" + Group: cpgEquipment + IsAccessible: true, type: bool + IsLockable: true + DebuffNamesActivate: "Indigestion,Hangover,BadTrip", display: EffectiveAgainst + SfxBuffActivate: UseActions/BlockToilet + ShowUser: Yes +} +{ Block Id: 1573, Name: PulpitFungusHarvested, Ref: PulpitFungus + Class: PlantGrowing + { Child PlantGrowing + Next: PulpitFungus + GrowthRate: 25 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/FractalFlora/PulpitFungusHarvestedPrefab +} +{ Block Id: 1574, Name: BigFlowerHarvested, Ref: BigFlower + Class: PlantGrowing + { Child PlantGrowing + Next: BigFlower + GrowthRate: 25 + FertileLevel: 1 + } + { Child DropOnDestroy + Item: Fiber + Count: "1,2" + Prob: 1 + } + Model: @models2/Environment/Plants/AlienFlora/BigFlowerHarvestedPrefab +} +{ +Block Id: 1575, Name: DetectorHVT1 + Info: bkiDetector, display: true + WeaponItem: DetectorHVWeapon + MarketPrice: 110, display: true + WeaponItemRotation: false + CustomIcon: DetectorSVT1 + IndexName: WeaponShip + Group: cpgEquipment + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Spaceship/DetectorSVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + AllowPlacingAt: "GV", display: true + IsAccessible: true, type: bool + BlockColor: "110,110,110" + SizeInBlocks: "1,1,1", display: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + # SymType: 4 + StackSize: 1000 + EnergyIn: 5, type: int, display: true, formatter: Watt + HitPoints: 120, type: int, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Volume: 93.75, type: float, display: true, formatter: Liter + Range: 2500, type: int, display: true + RangeSpace: 10000, type: int, display: true + CPUIn: 50, type: int, display: true + Category: Devices + UpgradeTo: DetectorHVEpic, display: true + + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + # TechTreeParent: GeneratorSV + TechTreeNames: Hover Vessel +} +{ +Block Id: 1576, Name: DetectorHVT2, Ref: DetectorHVT1 + Range: 3000, type: int, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + ShowUser: No +} + +{ +Block Id: 1577, Name: ExplosiveBlockFull + Class: Explosive + Group: cpgSignal + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metallight + Texture: 46 + BlockColor: "102,0,0" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullLargeBlocks + PickupTarget: HullLargeBlocks + AllowPlacingAt: "Base,MS", display: true + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + HitPoints: 150, type: int, display: false + CPUIn: 10, type: int, display: true + Mass: 500, type: float, display: true, formatter: Kilogram + Category: Devices + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + BlastParticleIndex: 8 + BlastRadius: 1, display: Radius + BlastDamage: 5000 +} +{ +Block Id: 1578, Name: ExplosiveBlockThin, Ref: ExplosiveBlockFull + HitPoints: 75, type: int, display: true + Mass: 250, type: float, display: true, formatter: Kilogram + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +} +{ +Block Id: 1579, Name: ExplosiveBlock2Full + Class: Explosive + Group: cpgSignal + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metallight + Texture: 46 + BlockColor: "255,0,0" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullLargeBlocks + PickupTarget: HullLargeBlocks + AllowPlacingAt: "Base,MS", display: true + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + HitPoints: 150, type: int, display: false + CPUIn: 10, type: int, display: true + Mass: 1000, type: float, display: true, formatter: Kilogram + Category: Devices + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + BlastParticleIndex: 4 + BlastRadius: 4, display: Radius + BlastDamage: 30000 + BlastShieldDamage: 30000 +} +{ +Block Id: 1580, Name: ExplosiveBlock2Thin, Ref: ExplosiveBlock2Full + Mass: 500, type: float, display: true, formatter: Kilogram + + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +} + +{ Block Id: 1581, Name: MushroomTallGreen01Harvested, Ref: MushroomTallGreen01 + Class: PlantGrowing + { Child PlantGrowing + Next: MushroomTallGreen01 + GrowthRate: 25 + FertileLevel: 1 + } + Model: @models2/Environment/Plants/MushroomForest/MushroomTallGreen01HarvestedPrefab +} +{ +Block Id: 1582, Name: DrillAttachmentCV + LootList: 37 # still used? + IndexName: WeaponShip + Group: cpgHarvest + Material: metalhard + MarketPrice: 62079, display: true + Shape: ModelEntity + ShowBlockName: true + WeaponItem: DrillAttachmentCVWeapon + Model: @models/Blocks/WeaponsShip/DrillAttachmentCVRetractPrefab + # Model: @models/Blocks/WeaponsShip/DrillAttachmentT2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + BlockColor: "110,110,110" + Place: WeaponDir + IsActivateableInCP: true + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,6,1", display: true + SizeInBlocksLocked: "Base,MS" + Info: bkiDrill, display: true + Category: Weapons/Items + EnergyDynamicGroup: Drill + EnergyIn: 2220, type: int, display: true, formatter: Watt + CPUIn: 4000, type: int, display: true + EnergyInIdle: 16, type: int, display: true, formatter: Watt + Mass: 3650, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + HitPoints: 500, type: int, display: true + Damage: 30, display: true + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 20, display: true + UnlockLevel: 25, display: true + TechTreeParent: TurretMSToolBlocks + TechTreeNames: Capital Vessel +} +{ +Block Id: 1583, Name: CloneChamberHV + IndexName: SpawnPoint + Group: cpgMedic + Material: metal + Shape: ModelEntity + ShowBlockName: true + MarketPrice: 300, display: true + Model: @models/Blocks/Spaceship/CloneChamberHVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "GV", display: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiCloneChamber, display: true + SizeInBlocks: "2,3,2", display: true + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + + Mass: 890, type: float, display: true, formatter: Kilogram + Volume: 375, type: float, display: true, formatter: Liter + EnergyIn: 10, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: OxygenStationSV + TechTreeNames: Hover Vessel +} +# Do not change name of block since it is referenced in code (WdwVesselStatistics.cs) +{ +Block Id: 1584, Name: MedicStationHV + Class: MedicStation + # BuffName: MedicStation + # IndexName: SpawnPoint # no spawning in medic station HV + MarketPrice: 330, display: true + Group: cpgMedic + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Spaceship/MedicStationHVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: "GV", display: true + Info: bkiMedicStationHV, display: true + # ModelOffset: "0,0.5,0" + SizeInBlocks: "2,2,1", display: true + StackSize: 1000 + BlockColor: "110,110,110" + BuffNameActivate: MedicStationMobile, display: StatusEffect + DebuffNamesActivate: "PoisonFood,DermalBurn,Frostbite,OpenWound,PoisonBit", display: EffectiveAgainst + Category: Devices + Mass: 130, type: float, display: true, formatter: Kilogram + Volume: 125, type: float, display: true, formatter: Liter + HitPoints: 50, type: int, display: true + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 95, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 100 + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + TechTreeParent: CloneChamberHV + TechTreeNames: Hover Vessel +} + +{ +Block Id: 1585, Name: ThrusterJetRound2x5x2V2, Ref: ThrusterJetRound2x5x2 + Info: bkiThrusterJetMSvariant, display: true + Model: @models/Blocks/Thrusters/ThrusterJetRound2x5x2V2Prefab + MarketPrice: 2390, display: true + + UnlockCost: 14, display: true + UnlockLevel: 12, display: true + TechTreeParent: ThrusterJetRound1x3x1 + TechTreeNames: Small Vessel +} +{ +Block Id: 1586, Name: DroneSpawner + AllowedInBlueprint: false, display: true + IndexName: DroneSpawner + Material: hullcombat + Shape: ModelEntity + Model: @models/Blocks/Misc/EntitySpawnerPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "255,247,0" + Place: Free + TemplateRoot: HullLargeBlocks + PickupTarget: SteelPlate + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 150, type: float, display: true, formatter: Kilogram + Info: bkiAlienMarkerBlock, display: true + MaxCount: 8, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + IsAccessible: true, type: bool +} +{ +Block Id: 1587, Name: DroneSpawner2, Ref: DroneSpawner + IndexName: DroneSpawner2 + Info: bkiAlienMarker2Block, display: true + BlockColor: "0,15,100" + IsDuplicateable: false + MaxCount: 1, type: int, display: true +} +{ +Block Id: 1588, Name: FridgeBlocks + Material: metal + Shape: Invisible + MarketPrice: 923, display: true + # Model: @models/Blocks/Mothership/FridgeT1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + Info: bkiBlockGroup, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + Volume: 200, type: float, display: true, formatter: Liter + StackSize: 1000 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Devices + ChildBlocks: "FridgeMS, FridgeMS02, Freezer" + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Base,Capital Vessel" +} + +{ +Block Id: 1589, Name: HoverEngineSmallDeco, Ref: DecoTemplate + AllowedInBlueprint: false, display: true + Model: @models/Blocks/Spaceship/HoverEngineSmallPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: Base, display: true + SizeInBlocksLocked: "Base,MS" + EnergyIn: 10, type: int, display: false, formatter: Watt + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 +} +{ +Block Id: 1590, Name: HoverEngineLargeDeco, Ref: DecoTemplate + AllowedInBlueprint: false, display: true + SizeInBlocks: "2,1,2", display: true + Model: @models/Blocks/Spaceship/HoverEngineLargePrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: Base, display: true + SizeInBlocksLocked: "Base,MS" + EnergyIn: 10, type: int, display: false, formatter: Watt + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 +} +{ +Block Id: 1591, Name: ThrusterJetRound3x10x3V2, Ref: ThrusterJetRound3x10x3 + Info: bkiThrusterJetMSvariant, display: true + Model: @models/Blocks/Thrusters/ThrusterJetRound3x10x3V2Prefab + MarketPrice: 4350, display: true + + UnlockCost: 15, display: true + UnlockLevel: 17, display: true + TechTreeParent: ThrusterJetRound3x7x3 + TechTreeNames: Small Vessel +} +{ +Block Id: 1592, Name: ThrusterJetRound3x13x3V2, Ref: ThrusterJetRound3x13x3 + Info: bkiThrusterJetMSvariant, display: true + Model: @models/Blocks/Thrusters/ThrusterJetRound3x13x3V2Prefab + MarketPrice: 5400, display: true + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: ThrusterJetRound3x10x3V2 + TechTreeNames: Small Vessel +} +{ Block Id: 1593, Name: GasEmitter2Red, Ref: GasEmitter1 + Model: @models2/Environment/Rocks/GasEmitters/GasEmitter2RedPrefab + SizeInBlocks: "14,10,14" +} + +# Combat Steel Blocks (small blocks) - block group +{ +Block Id: 1594, Name: HullCombatSmallBlocks + Material: hullcombat + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 56 + MarketPrice: 155, display: true + BlockColor: "170,170,170" + Shape: Invisible + # Model: Cube + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + AllowPlacingAt: "GV", display: true + HitPoints: 400, type: int, display: false + Mass: 64, type: float, display: true, formatter: Kilogram + Volume: 12.5, type: float, display: true, formatter: Liter + StackSize: 1000 + BlockSizeScale: 2 + Category: BuildingBlocks + ChildBlocks: "HullCombatFullSmall, HullCombatThinSmall, HullCombatExtendedSmall, HullCombatExtendedSmall2, HullCombatExtendedSmall3, HullCombatExtendedSmall4, HullCombatExtendedSmall5, HullCombatExtendedSmall6, HullCombatExtendedSmall7" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +# Two shape families for Combat hull (small blocks) +{ +Block Id: 1595, Name: HullCombatFullSmall + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: hullcombat + Texture: 56 + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullCombatSmallBlocks + AllowPlacingAt: "GV", display: true + HitPoints: 400, type: int, display: true + Mass: 64, type: float, display: true, formatter: Kilogram + BlockSizeScale: 2 + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" +} + +{ +Block Id: 1596, Name: HullCombatThinSmall, Ref: HullCombatFullSmall + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +} + + +# Sprout +{ Block Id: 1597, Name: CoffeePlantStage1, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/CoffeePlantStage1Prefab + MarketPrice: 77, display: true + { Child PlantGrowing + Next: CoffeePlantStage2 + GrowthRate: 36 + FertileLevel: 5 + OnDeath: PlantDead2 + } + CropType: NaturalStimulant, display: true + # CropYield: 6, display: true + GrowthTimeInfo: 91, type: int, display: true, formatter: Minutes + PickupTarget: CoffeePlantStage1 # disassemble + TemplateRoot: CoffeePlantStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1598, Name: CoffeePlantStage2, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/CoffeePlantStage2Prefab + { Child PlantGrowing + Next: CoffeePlantStage3 + GrowthRate: 55 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: CoffeePlantStage1 # disassemble + TemplateRoot: CoffeePlantStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1599, Name: CoffeePlantStage3, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/CoffeePlantStage3Prefab + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: NaturalStimulant + Count: 6 + } + { Child CropsGrown + OnHarvest: CoffeePlantStage4 + OnDeath: PlantDead2 + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + # ShowUser: No + PickupTarget: CoffeePlantStage1 # disassemble + TemplateRoot: CoffeePlantStage2 # deconstruct - to avoid exploit + XpFactor: 0.25 +} +# Harvested model +{ Block Id: 1600, Name: CoffeePlantStage4, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/SpeedTrees/CoffeePlantStage4Prefab + { Child PlantGrowing + Next: CoffeePlantStage3 + GrowthRate: 84 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: CoffeePlantStage1 # disassemble + TemplateRoot: CoffeePlantStage2 # deconstruct - to avoid exploit +} + +# Sprout +{ Block Id: 1601, Name: BigFlowerStage1, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/BigFlowerStage1Prefab + MarketPrice: 77, display: true + { Child PlantGrowing + Next: BigFlowerStage2 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead2 + } + CropType: Buds, display: true + # CropYield: 1, display: true + GrowthTimeInfo: 27, type: int, display: true, formatter: Minutes + PickupTarget: BigFlowerStage1 # disassemble + TemplateRoot: BigFlowerStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1602, Name: BigFlowerStage2, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/BigFlowerStage2Prefab + { Child PlantGrowing + Next: BigFlowerStage3 + GrowthRate: 17 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: BigFlowerStage1 # disassemble + TemplateRoot: BigFlowerStage2 # deconstruct - to avoid exploit +} +{ Block Id: 1603, Name: BigFlowerStage3, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/BigFlowerStage3Prefab + Class: CropsGrown + AllowedInBlueprint: false, display: true + { Child DropOnHarvest + Item: Buds + Count: 1 + } + { Child CropsGrown + OnHarvest: BigFlowerStage4 + OnDeath: PlantDead2 + } + { Child DropOnDestroy + Item: Fiber + Count: "1" + Prob: 1 + } + # ShowUser: No + PickupTarget: BigFlowerStage1 # disassemble + TemplateRoot: BigFlowerStage2 # deconstruct - to avoid exploit + XpFactor: 0.25 +} +# Harvested model +{ Block Id: 1604, Name: BigFlowerStage4, Ref: ElderberryStage1 + Model: @models2/Entities/Farming/Misc/BigFlowerStage4Prefab + { Child PlantGrowing + Next: BigFlowerStage3 + GrowthRate: 25 + FertileLevel: 5 + OnDeath: PlantDead2 + } + ShowUser: No + PickupTarget: BigFlowerStage1 # disassemble + TemplateRoot: BigFlowerStage2 # deconstruct - to avoid exploit +} + + +{ +Block Id: 1605, Name: ModularWingTaperedL + Material: metal + Shape: ModelEntity + Model: @models/Blocks/WingsModular/ModWingTaperLPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + TemplateRoot: ModularWingBlocks + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "SS", display: true + Mass: 330, type: float, display: true, formatter: Kilogram + HitPoints: 390, type: int, display: true + EnergyIn: 1, type: int, display: false, formatter: Watt + Category: BuildingBlocks + SizeInBlocks: "1,2,7", display: true +} +{ +Block Id: 1606, Name: ModularWingTaperedM, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingTaperMPrefab + SizeInBlocks: "1,2,5", display: true + Mass: 245, type: float, display: true, formatter: Kilogram + HitPoints: 280, type: int, display: true +} +{ +Block Id: 1607, Name: ModularWingTaperedS, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingTaperSPrefab + SizeInBlocks: "1,2,3", display: true + Mass: 135, type: float, display: true, formatter: Kilogram + HitPoints: 145, type: int, display: true +} +{ +Block Id: 1608, Name: ModularWingStraightL, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingStraightLPrefab + SizeInBlocks: "1,2,7", display: true + Mass: 330, type: float, display: true, formatter: Kilogram + HitPoints: 390, type: int, display: true +} +{ +Block Id: 1609, Name: ModularWingStraightM, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingStraightMPrefab + SizeInBlocks: "1,2,5", display: true + Mass: 245, type: float, display: true, formatter: Kilogram + HitPoints: 280, type: int, display: true +} +{ +Block Id: 1610, Name: ModularWingStraightS, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingStraightSPrefab + SizeInBlocks: "1,2,3", display: true + Mass: 135, type: float, display: true, formatter: Kilogram + HitPoints: 145, type: int, display: true +} +{ +Block Id: 1611, Name: ModularWingDeltaL, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingDeltaLPrefab + SizeInBlocks: "1,2,7", display: true + Mass: 330, type: float, display: true, formatter: Kilogram + HitPoints: 390, type: int, display: true +} +{ +Block Id: 1612, Name: ModularWingDeltaM, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingDeltaMPrefab + SizeInBlocks: "1,2,5", display: true + Mass: 245, type: float, display: true, formatter: Kilogram + HitPoints: 280, type: int, display: true +} +{ +Block Id: 1613, Name: ModularWingDeltaS, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingDeltaSPrefab + SizeInBlocks: "1,2,3", display: true + Mass: 135, type: float, display: true, formatter: Kilogram + HitPoints: 145, type: int, display: true +} +{ +Block Id: 1614, Name: ModularWingSweptL, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingSweptLPrefab + SizeInBlocks: "1,2,8", display: true + Mass: 330, type: float, display: true, formatter: Kilogram + HitPoints: 445, type: int, display: true +} +{ +Block Id: 1615, Name: ModularWingSweptM, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingSweptMPrefab + SizeInBlocks: "1,2,6", display: true + Mass: 240, type: float, display: true, formatter: Kilogram + HitPoints: 190, type: int, display: true +} +{ +Block Id: 1616, Name: ModularWingSweptS, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingSweptSPrefab + SizeInBlocks: "1,2,4", display: true + Mass: 135, type: float, display: true, formatter: Kilogram + HitPoints: 140, type: int, display: true +} +{ +Block Id: 1617, Name: ModularWingLongL, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingLongLPrefab + SizeInBlocks: "1,4,7", display: true + Mass: 685, type: float, display: true, formatter: Kilogram + HitPoints: 780, type: int, display: true +} +{ +Block Id: 1618, Name: ModularWingLongM, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingLongMPrefab + SizeInBlocks: "1,4,5", display: true + Mass: 440, type: float, display: true, formatter: Kilogram + HitPoints: 490, type: int, display: true +} +{ +Block Id: 1619, Name: ModularWingLongS, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingLongSPrefab + SizeInBlocks: "1,4,3", display: true + Mass: 207, type: float, display: true, formatter: Kilogram + HitPoints: 210, type: int, display: true +} +{ +Block Id: 1620, Name: ModularWingAngledTaperedL, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingAngleTaperLPrefab + SizeInBlocks: "2,2,7", display: true + Mass: 330, type: float, display: true, formatter: Kilogram + HitPoints: 390, type: int, display: true + SymType: 4 +} +{ +Block Id: 1621, Name: ModularWingAngledTaperedM, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingAngleTaperMPrefab + SizeInBlocks: "2,2,5", display: true + Mass: 245, type: float, display: true, formatter: Kilogram + HitPoints: 280, type: int, display: true + SymType: 4 +} +{ +Block Id: 1622, Name: ModularWingAngledTaperedS, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingAngleTaperSPrefab + SizeInBlocks: "2,2,3", display: true + Mass: 135, type: float, display: true, formatter: Kilogram + HitPoints: 145, type: int, display: true + SymType: 4 +} +{ +Block Id: 1623, Name: ModularWingTConnectorL, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingTConnectorLPrefab + SizeInBlocks: "1,1,8", display: true + Mass: 295, type: float, display: true, formatter: Kilogram + HitPoints: 280, type: int, display: true +} +{ +Block Id: 1624, Name: ModularWingTConnectorM, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingTConnectorMPrefab + SizeInBlocks: "1,1,4", display: true + Mass: 175, type: float, display: true, formatter: Kilogram + HitPoints: 140, type: int, display: true +} +{ +Block Id: 1625, Name: ModularWingPylon, Ref: ModularWingTaperedL + Model: @models/Blocks/WingsModular/ModWingPylonPrefab + SizeInBlocks: "1,1,2", display: true + Mass: 60, type: float, display: true, formatter: Kilogram + HitPoints: 70, type: int, display: true + ShowUser: NoButCSV +} +{ +Block Id: 1626, Name: ModularWingBlocks + MarketPrice: 134, display: true + Material: metal + Shape: Invisible + # Model: @models/Blocks/WingsModular/ModWingTaperLPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Category: BuildingBlocks + BlockColor: "110,110,110" + Place: Free + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "SS", display: true + Info: bkiBlockGroup, display: true + EnergyIn: 1, type: int, display: false, formatter: Watt + HitPoints: 250, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + Volume: 150, type: float, display: true, formatter: Liter + StackSize: 1000 + + ChildBlocks: "ModularWingTaperedL, ModularWingTaperedM, ModularWingTaperedS, ModularWingStraightL, ModularWingStraightM, ModularWingStraightS, ModularWingDeltaL, ModularWingDeltaM, ModularWingDeltaS, ModularWingSweptL, ModularWingSweptM, ModularWingSweptS, ModularWingLongL, ModularWingLongM, ModularWingLongS, ModularWingAngledTaperedL, ModularWingAngledTaperedM, ModularWingAngledTaperedS, ModularWingTConnectorL, ModularWingTConnectorM" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Id: 1627, Name: RemoteConnection + Class: RemoteConnector + IndexName: Connector + MarketPrice: 341, display: true + Group: cpgEquipment + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/RemoteConnectionPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + MaxCount: 10, type: int, display: true + ForceMaxCount: true + EnergyIn: 10, type: int, display: true, formatter: Watt + HitPoints: 100, type: int, display: false + Mass: 160, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + StackSize: 2000 + Range: 100, type: int, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 100 + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeNames: Misc +} + +# "Base Constructor" for Bases and CV's (below Large Constructor) +{ +Block Id: 1628, Name: ConstructorT0, Ref: ConstructorT1V2 + Class: ConstructorT0 + MarketPrice: 8310, display: true + Model: @models/Blocks/Devices/ConstructorT0FWNPrefab #1.11 update + #Model: @models/Blocks/Mothership/ConstructorT0Prefab + SizeInBlocks: "1,1,1", display: true + Mass: 1660, type: float, display: true, formatter: Kilogram + Volume: 200, type: float, display: true, formatter: Liter + StackSize: 1000 + + EnergyIn: 250, type: int, display: true, formatter: Watt + CPUIn: 10000, type: int, display: true + EnergyInIdle: 5, type: int, display: true, formatter: Watt + + UpgradeTo: null + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeParent: "" + TechTreeNames: "Base,Capital Vessel" +} + +{ +Block Id: 1629, Name: HeavyWindowG, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowGPrefab + SymType: 2 +} +{ +Block Id: 1630, Name: HeavyWindowGInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowGInvPrefab + SymType: 2 +} +{ +Block Id: 1631, Name: HeavyWindowH, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowHPrefab + SymType: 2 +} +{ +Block Id: 1632, Name: HeavyWindowHInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowHInvPrefab + SymType: 2 +} +{ +Block Id: 1633, Name: HeavyWindowI, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowIPrefab + SymType: 2 +} +{ +Block Id: 1634, Name: HeavyWindowIInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowIInvPrefab + SymType: 2 +} +{ +Block Id: 1635, Name: HeavyWindowJ, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowJPrefab + SymType: 2 +} +{ +Block Id: 1636, Name: HeavyWindowJInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowJInvPrefab + SymType: 2 +} + +{ +Block Id: 1637, Name: TurretMSProjectileBlocks #TurretPlayer CV GROUP Projectile + Class: TurretManual + IndexName: Turret + Group: cpgTurret + MarketPrice: 19255, display: true + Material: metalhard + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretMSCannon, TurretMSCannonRetract, TurretMSMinigun, TurretMSMinigunRetract" + MaxCount: 12, type: int, display: true + StackSize: 500 + + AllowPlacingAt: MS, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 6120, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 2880, type: float, display: true, formatter: Liter + CPUIn: 2500, type: int, display: true + + UnlockCost: 20, display: true + UnlockLevel: 7, display: true + TechTreeParent: SentryGunBlocks + TechTreeNames: Capital Vessel +} +{ +Block Id: 1638, Name: TurretMSRocketBlocks #TurretPlayer CV GROUP Rocket + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 79149, display: true + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSRocketPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretMSRocket, TurretMSRocketRetract, TurretMSFlak, TurretMSFlakRetract" + MaxCount: 10, type: int, display: true + StackSize: 500 + + AllowPlacingAt: MS, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 5777, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 2880, type: float, display: true, formatter: Liter + #CPUIn: 4860, type: int, display: true + + UnlockCost: 30, display: true + UnlockLevel: 12, display: true + TechTreeParent: TurretMSProjectileBlocks + TechTreeNames: Capital Vessel +} +{ +Block Id: 1639, Name: TurretMSLaserBlocks #TurretPlayer CV GROUP Laser + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 57350, display: true + Shape: Turret + Place: Face + Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretMSPulseLaser, TurretMSPulseLaserRetract, TurretMSPlasma, TurretMSPlasmaRetract" + MaxCount: 10, type: int, display: true + StackSize: 500 + + AllowPlacingAt: MS, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 8490, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 2880, type: float, display: true, formatter: Liter + #CPUIn: 10380, type: int, display: true + + UnlockCost: 40, display: true + UnlockLevel: 20, display: true + TechTreeParent: TurretMSRocketBlocks + TechTreeNames: Capital Vessel +} +{ +Block Id: 1640, Name: TurretMSToolBlocks #TurretPlayer CV GROUP Tool + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 44456, display: true + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretMSTool, TurretMSToolRetract, TurretMSDrill, TurretMSDrillRetract" + MaxCount: 8, type: int, display: true + StackSize: 500 + + AllowPlacingAt: MS, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 9050, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 2880, type: float, display: true, formatter: Liter + #CPUIn: 1500, type: int, display: true + + UnlockCost: 40, display: true + UnlockLevel: 20, display: true + # TechTreeParent: TurretMSRocketBlocks + TechTreeNames: Capital Vessel +} + +{ +Block Id: 1641, Name: TurretMSArtilleryBlocks #TurretPlayer CV GROUP Artillery + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Invisible + MarketPrice: 156704, display: true + Place: Face + # Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretMSArtillery, TurretMSArtilleryRetract" + MaxCount: 2, type: int, display: true + StackSize: 250 + + AllowPlacingAt: MS, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 19800, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 7700, type: float, display: true, formatter: Liter + CPUIn: 21600, type: int, display: true + + UnlockCost: 50, display: true + UnlockLevel: 25, display: true + TechTreeParent: TurretMSLaserBlocks + TechTreeNames: Capital Vessel +} + +{ +Block Id: 1642, Name: DetectorSVT1, Ref: DetectorHVT1 + Model: @models/Blocks/Spaceship/DetectorSVPrefab + Info: bkiDetector, display: true + WeaponItem: DetectorSVWeapon + WeaponItemRotation: false + MarketPrice: 208, display: true + SizeInBlocks: "1,1,1", display: true + AllowPlacingAt: "SS", display: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + Mass: 55, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + Range: 3500, type: int, display: true + RangeSpace: 14000, type: int, display: true + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: GeneratorSV + TechTreeNames: Small Vessel +} + +{ Block Id: 1643, Name: TalonChief, Ref: NPCAlienTemplate + Class: NPCDialogue + NPCModelRotation: true + Group: cpgCrewStationAlien + Info: bkiTalonChief, display: true + AllowedInBlueprint: false, display: true + IsAccessible: true, type: bool + ShowBlockName: true + # CanPickup: true + Model: @models2/Entities/Decorations/DecoNPC/TalonChiefDecoPrefab + RpFactor: 2 + ShowUser: Yes + HitPoints: 250, type: int, display: true +} + +{ Block Id: 1644, Name: ZiraxCommander # Ref: ZiraxNPCBlocks + Model: @models2/Entities/Decorations/DecoNPC/ZiraxCommanderDecoPrefab + Info: bkiZiraxCommander, display: true + Class: NPCDialogue + NPCModelRotation: true + #DialogueState: Barking_Zirax_Anywhere_Init + #OmitCone: true + Category: Deco Blocks + Group: cpgCrewStationAlien + Material: human + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + Shape: ModelEntity + MarketPrice: 5850, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Place: Free + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: false + Volume: 40, type: float, display: true, formatter: Liter + StackSize: 1 + EnergyIn: 1, type: int, display: false, formatter: Watt + HitPoints: 250, type: int, display: true + XpFactor: 0 + BlastParticleIndex: 14 + BlastRadius: 1 + BlastDamage: 0 + Mass: 80, type: float, display: true, formatter: Kilogram + ShowBlockName: true +# TemplateRoot: ZiraxCommander + # PickupTarget: ZiraxCommander + CanPickup: true + RpFactor: 0 + #ShowUser: Yes +} +{ +Block Id: 1645, Name: DancingAlien2, Ref: NPCAlienTemplate + Model: @models2/Entities/Decorations/DecoNPC/DancingAlien2Prefab +} +{ +Block Id: 1646, Name: DancingAlien3, Ref: NPCAlienTemplate + Model: @models2/Entities/Decorations/DecoNPC/DancingAlien3Prefab +} +{ +Block Id: 1647, Name: DancingAlien4, Ref: NPCAlienTemplate + Model: @models2/Entities/Decorations/DecoNPC/DancingAlien4Prefab +} + +# BA Turret Groups +{ +Block Id: 1648, Name: TurretBaseProjectileBlocks #TurretPlayer BA GROUP Projectile + Class: TurretManual + IndexName: Turret + Group: cpgTurret + MarketPrice: 16530, display: true + Material: metalhard + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretBaseCannon, TurretBaseCannonRetract, TurretBaseMinigun, TurretBaseMinigunRetract" + MaxCount: 12, type: int, display: true + StackSize: 500 + AllowPlacingAt: Base, display: true + Volume: 2880, type: float, display: true, formatter: Liter + #CPUIn: 1440, type: int, display: true + Mass: 6120, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 12, display: true + UnlockLevel: 5, display: true + TechTreeParent: SentryGunBlocks + TechTreeNames: "Base" +} +{ +Block Id: 1649, Name: TurretBaseRocketBlocks #TurretPlayer BA GROUP Rocket + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Invisible + MarketPrice: 59302, display: true + Place: Face + # Model: @models/Blocks/Turrets/TurretMSRocketPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretBaseRocket, TurretBaseRocketRetract, TurretBaseFlak, TurretBaseFlakRetract" + MaxCount: 10, type: int, display: true + StackSize: 500 + AllowPlacingAt: Base, display: true + Volume: 2880, type: float, display: true, formatter: Liter + #CPUIn: 5280, type: int, display: true + Mass: 5777, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 22, display: true + UnlockLevel: 10, display: true + TechTreeParent: TurretBaseProjectileBlocks + TechTreeNames: "Base" +} +{ +Block Id: 1650, Name: TurretBaseLaserBlocks #TurretPlayer BA GROUP Laser + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 44302, display: true + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretBasePulseLaser, TurretBasePulseLaserRetract, TurretBasePlasma, TurretBasePlasmaRetract" + MaxCount: 10, type: int, display: true + StackSize: 500 + AllowPlacingAt: Base, display: true + Volume: 2880, type: float, display: true, formatter: Liter + #CPUIn: 11190, type: int, display: true + Mass: 11760, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 40, display: true + UnlockLevel: 15, display: true + TechTreeParent: TurretBaseRocketBlocks + TechTreeNames: "Base" +} + +{ +Block Id: 1651, Name: TurretBaseArtilleryBlocks #TurretPlayer BA GROUP Artillery + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + Shape: Invisible + MarketPrice: 147188, display: true + Place: Face + # Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretBaseArtillery, TurretBaseArtilleryRetract" + MaxCount: 2, type: int, display: true + StackSize: 250 + AllowPlacingAt: Base, display: true + Volume: 7700, type: float, display: true, formatter: Liter + CPUIn: 22600, type: int, display: true + Mass: 19800, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 30, display: true + UnlockLevel: 20, display: true + TechTreeParent: TurretBaseLaserBlocks + TechTreeNames: "Base" +} + + +# BA Retractable Turrets +{ +Block Id: 1652, Name: TurretBaseFlakRetract, Ref: TurretBaseFlak + Model: @models/Blocks/Turrets/TurretMSFlakRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 4500, type: int, display: true + Mass: 13660, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1653, Name: TurretBasePlasmaRetract, Ref: TurretBasePlasma + Model: @models/Blocks/Turrets/TurretMSPlasmaRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 4250, type: int, display: true + Mass: 16140, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1654, Name: TurretBaseCannonRetract, Ref: TurretBaseCannon + Model: @models/Blocks/Turrets/TurretMSCannonRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 4500, type: int, display: true + Mass: 13840, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1655, Name: TurretBaseRocketRetract, Ref: TurretBaseRocket + Model: @models/Blocks/Turrets/TurretMSRocketRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 4500, type: int, display: true + Mass: 11850, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1656, Name: TurretBaseMinigunRetract, Ref: TurretBaseMinigun + Model: @models/Blocks/Turrets/TurretMSMinigunRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 4500, type: int, display: true + Mass: 11850, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1657, Name: TurretBasePulseLaserRetract, Ref: TurretBasePulseLaser + Model: @models/Blocks/Turrets/TurretMSPulseLaserRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 4850, type: int, display: true + Mass: 15960, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1658, Name: TurretBaseArtilleryRetract, Ref: TurretBaseArtillery + Model: @models/Blocks/Turrets/TurretMSArtilleryRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "5,4,5", display: true + HitPoints: 7500, type: int, display: true + Mass: 62800, type: float, display: true, formatter: Kilogram +} + +# HV Turret Groups +{ +Block Id: 1659, Name: TurretGVMinigunBlocks #TurretPlayer HV GROUP Minigun + Class: TurretManual + IndexName: Turret + Group: cpgTurret + MarketPrice: 481, display: true + Material: metalhard + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretGVMinigunV2Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretGVMinigun, TurretGVMinigunRetract" + MaxCount: 6, type: int, display: true + StackSize: 1000 + AllowPlacingAt: GV, display: true + Volume: 281.25, type: float, display: true, formatter: Liter + CPUIn: 900, type: int, display: true + Mass: 500, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: WeaponSV02 + TechTreeNames: Hover Vessel +} +{ +Block Id: 1660, Name: TurretGVRocketBlocks #TurretPlayer HV GROUP Rocket + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 1010, display: true + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSRocketPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretGVRocket, TurretGVRocketRetract" + MaxCount: 4, type: int, display: true + StackSize: 1000 + AllowPlacingAt: GV, display: true + Volume: 281.25, type: float, display: true, formatter: Liter + CPUIn: 450, type: int, display: true + Mass: 530, type: float, display: true, formatter: Kilogram + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: TurretGVMinigunBlocks + TechTreeNames: Hover Vessel +} +{ +Block Id: 1661, Name: TurretGVPlasmaBlocks #TurretPlayer HV GROUP Plasma + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 6330, display: true + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretGVPlasma, TurretGVPlasmaRetract" + MaxCount: 4, type: int, display: true + StackSize: 1000 + + AllowPlacingAt: GV, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 1050, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 281.25, type: float, display: true, formatter: Liter + CPUIn: 1560, type: int, display: true + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + TechTreeParent: TurretGVMinigunBlocks + TechTreeNames: Hover Vessel +} + +{ +Block Id: 1662, Name: TurretGVArtilleryBlocks #TurretPlayer HV GROUP Artillery + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 9500, display: true + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretGVArtillery, TurretGVArtilleryRetract" + MaxCount: 1, type: int, display: true + StackSize: 500 + + AllowPlacingAt: GV, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 4350, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 1562.5, type: float, display: true, formatter: Liter + CPUIn: 5820, type: int, display: true + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: TurretGVPlasmaBlocks + TechTreeNames: Hover Vessel +} +{ +Block Id: 1663, Name: TurretGVToolBlocks #TurretPlayer HV GROUP Tool + Class: TurretManual + IndexName: Turret + Group: cpgTurret + Material: metalhard + MarketPrice: 2800, display: true + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "TurretGVTool, TurretGVToolRetract, TurretGVDrill, TurretGVDrillRetract" + MaxCount: 2, type: int, display: true + StackSize: 1000 + + AllowPlacingAt: GV, display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 790, type: float, display: true, formatter: Kilogram # do not display mass, otherwise mass from material will be displayed + Volume: 281.25, type: float, display: true, formatter: Liter + CPUIn: 3000, type: int, display: true + + UnlockCost: 30, display: true + UnlockLevel: 15, display: true + TechTreeParent: DrillAttachmentLarge + TechTreeNames: Hover Vessel +} + +# HV Retractable Turrets +{ +Block Id: 1664, Name: TurretGVMinigunRetract, Ref: TurretGVMinigun + Model: @models/Blocks/Turrets/TurretMSMinigunRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 950, type: int, display: true + Mass: 800, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1665, Name: TurretGVRocketRetract, Ref: TurretGVRocket + Model: @models/Blocks/Turrets/TurretMSRocketRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 950, type: int, display: true + Mass: 820, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1666, Name: TurretGVPlasmaRetract, Ref: TurretGVPlasma + Model: @models/Blocks/Turrets/TurretMSPlasmaRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 950, type: int, display: true + Mass: 1350, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1667, Name: TurretGVArtilleryRetract, Ref: TurretGVArtillery + Model: @models/Blocks/Turrets/TurretMSArtilleryRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "5,4,5", display: true + HitPoints: 1930, type: int, display: true + Mass: 5300, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1668, Name: TurretGVDrillRetract, Ref: TurretGVDrill + Model: @models/Blocks/Turrets/TurretGVDrillRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + # Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 1200, type: int, display: true + Mass: 1090, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1669, Name: TurretGVToolRetract, Ref: TurretGVTool + Model: @models/Blocks/Turrets/TurretGVToolRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + # Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "3,2,3", display: true + HitPoints: 1200, type: int, display: true + Mass: 1090, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1670, Name: SentryGun03Retract, Ref: SentryGun03 + Model: @models/Blocks/Turrets/SentryGuns/SentryGunHumanRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + # Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "1,2,1", display: true + HitPoints: 175, type: int, display: true +} +{ +Block Id: 1671, Name: SentryGun05, Ref: SentryGun03 #Sentry NOT USED + Model: @models/Blocks/Turrets/SentryGuns/SentryGunHumanWallPrefab + Place: Free +} +# obsolete but do not remove +{ Block Id: 1672, Name: SentryGun05RetractV2, Ref: SentryGun03 #Sentry NOT USED + Model: @models/Blocks/Turrets/SentryGuns/SentryGunHumanWallRetractV2Prefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + # Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "1,1,2", display: true + ShowUser: NoButCSV +} +# Player used Sentry Guns +{ +Block Id: 1673, Name: SentryGunBlocks #SentryPlayer GROUP Sentry + Class: TurretManual + IndexName: Turret + Group: cpgTurret + MarketPrice: 1373, display: true + Material: metal + Shape: Invisible + Place: Face + # Model: @models/Blocks/Turrets/SentryGuns/SentryGunHumanPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Category: Weapons/Items + ChildBlocks: "SentryGun03, SentryGun03Retract, SentryGun05, SentryGun05Retract" + MaxCount: 8, type: int, display: true + StackSize: 1000 + + AllowPlacingAt: "Base,MS", display: true + Info: bkiBlockGroup, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + Mass: 320, type: float, display: true, formatter: Kilogram + Volume: 400, type: float, display: true, formatter: Liter # do not display mass, otherwise mass from material will be displayed + CPUIn: 1400, type: int, display: true + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeNames: "Base,Capital Vessel" +} +# obsolete but do not remove +{ Block Id: 1674, Name: SentryGun01RetractV2, Ref: SentryGun01 #Sentry NOT USED + Model: @models/Blocks/Turrets/SentryGuns/SentryGunAlienWallRetractV2Prefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + # Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "1,1,2", display: true + ShowUser: NoButCSV +} +{ +Block Id: 1675, Name: SentryGun02Retract, Ref: SentryGun02 + Model: @models/Blocks/Turrets/SentryGuns/SentryGunAlienRetractPrefab + + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsRetractable: true + IsOxygenTight: true, display: true + # Info: bkiTurretRetract, display: true + Place: Free + SizeInBlocks: "1,2,1", display: true + HitPoints: 175, type: int, display: true +} + +# New Container Blocks +{ +Block Id: 1676, Name: CargoContainerSmall + Class: Container + IndexName: Container + Group: cpgCargoBox + LootList: 76 # 128 slots + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Mothership/CargoContainerSmallPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 600, type: float, display: true, formatter: Kilogram + Volume: 43, type: float, display: true, formatter: Liter + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + BlockColor: "110,110,110" + Info: bkiContainer, display: true + StackSize: 1000 + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 100, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 + CPUIn: 960, type: int, display: true + + # UnlockCost: 3, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1677, Name: CargoContainerMedium, Ref: CargoContainerSmall + Model: @models/Blocks/Mothership/CargoContainerMediumPrefab + SizeInBlocks: "2,1,1", display: true + LootList: 76 # 128 slots + Mass: 1000, type: float, display: true, formatter: Kilogram + + Volume: 72, type: float, display: true, formatter: Liter + VolumeCapacity: 16000, type: float, display: true, formatter: Liter + HitPoints: 500, type: int, display: true + IsOxygenTight: false, display: true + CPUIn: 1920, type: int, display: true + + # UnlockCost: 5, display: true + # UnlockLevel: 3, display: true + # TechTreeParent: CargoContainerSmall + # TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1678, Name: CargoContainerSV, Ref: CargoContainerSmall + Model: @models/Blocks/Spaceship/CargoContainerSVPrefab + AllowPlacingAt: "SS,GV", display: true + TemplateRoot: ContainerSmallBlocks + Mass: 45, type: float, display: true, formatter: Kilogram + Volume: 8, type: float, display: true, formatter: Liter + VolumeCapacity: 125, type: float, display: true, formatter: Liter + HitPoints: 40, type: int, display: true + StackSize: 2000 + IsOxygenTight: false, display: true + CPUIn: 15, type: int, display: true + + # UnlockCost: 3, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: "Small Vessel,Hover Vessel" + ShowUser: NoButCSV +} +{ +Block Id: 1679, Name: TurretAlien #TurretFaction CVBA AntiProton LEGACY + Model: @models/Blocks/Turrets/TurretAlienPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + AllowedInBlueprint: false, display: true + Info: bkiTurretAlien, display: true + RepairToTemplate: false, display: true + Class: TurretAuto + Group: cpgTurret + IndexName: Turret + Material: metalhard + Shape: Turret + # TemplateRoot: TurretBaseCannon + PickupTarget: TurretBaseLaserBlocks + Place: Face + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SoundOnEnter: UseActions/DOOROpen02 + WeaponItem: TurretEnemyLaserWeaponT2 + IsActivateable: true + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 4500, type: float, display: true, formatter: Liter + Mass: 8500, type: float, display: true, formatter: Kilogram + StackSize: 250 + Category: Weapons/Items + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 100 + + HitPoints: 5000, type: int, display: true + RotSpeed: 160, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + CPUIn: 90000, type: int, display: true + XpFactor: 2 + AboveTerrainCheck: false + # ShowUser: No +} + +{ +Block Id: 1680, Name: TurretEnemyEMP, Ref: TurretIONCannon #TurretAlien Generic EMP + Group: cpgTurret + IndexName: Turret + Model: @models/Blocks/Turrets/TurretsEnemy/TurretEnemyLaserPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + SizeInBlocks: "3,2,3", display: true + BlockColor: "255,247,0" + WeaponItem: TurretEnemyEMPWeapon + RotSpeed: 100, type: int, display: true + CPUIn: 5400, type: int, display: true + AboveTerrainCheck: false +} + +{ Block Id: 1681, Name: FillerBlock + Material: dirt + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 85 + BlockColor: "26,50,89" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: "Base", display: true + Mass: 150, type: float, display: true, formatter: Kilogram + StackSize: 1000 + IsTextureable: false + # ShowUser: No +} +{ +Block Id: 1682, Name: ContainerControllerLarge + Class: ContainerController + IndexName: Container + Group: cpgCargoBox + MarketPrice: 258, display: true + LootList: 76 # 128 slots + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Mothership/ContainerControllerLargePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + Mass: 625, type: float, display: true, formatter: Kilogram + Volume: 52, type: float, display: true, formatter: Liter + StackSize: 1000 + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + MaxVolumeCapacity: 320000, type: float, display: true, formatter: Liter # 40 Containers + Info: bkiContainerController, display: true # adapt + ShowBlockName: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: true, display: true + IsDuplicateable: false + Category: Devices + EnergyIn: 10, type: int, display: true, formatter: Watt + CPUIn: 960, type: int, display: true + HitPoints: 250, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 + # ShowUser: No + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: ContainerLargeBlocks + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1683, Name: ContainerExtensionLarge + Class: ContainerExtension + Group: cpgCargoBox + LootList: 68 # 63 slots + Material: metal + Shape: New + MarketPrice: 155, display: true + Place: Free + Model: Cube + # Texture: 15 + # Texture: 20 + Texture: 286 + TextureTable: 4 # 4 = container extension table + BlockColor: "110,110,110" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + AllowPlacingAt: "Base,MS", display: true + Mass: 616, type: float, display: true, formatter: Kilogram + Volume: 160, type: float, display: true, formatter: Liter + StackSize: 1000 + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + Info: bkiContainerExtension, display: true # adapt + ShowBlockName: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: true, display: true + IsDuplicateable: false # keep false to avoid exploits when placing several blocks in a line and connect two or more CCs + # IsTextureable: false + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 960, type: int, display: true + HitPoints: 500, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 50 + # ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA, CubeHalf, RampBMedium, RampD, CutCornerEMedium, SlopeHalf, CornerSmallA" + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA, CubeHalf, RampBMedium, RampD, CutCornerEMedium" + + # ShowUser: No + + UnlockCost: 8, display: true + UnlockLevel: 7, display: true + TechTreeParent: ContainerControllerLarge + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1684, Name: ContainerControllerSmall, Ref: ContainerControllerLarge + Model: @models/Blocks/Spaceship/ContainerControllerSmallPrefab + Mass: 55, type: float, display: true, formatter: Kilogram + Volume: 11, type: float, display: true, formatter: Liter + MarketPrice: 78, display: true + HitPoints: 100, type: int, display: true + StackSize: 2000 + VolumeCapacity: 125, type: float, display: true, formatter: Liter + CPUIn: 15, type: int, display: true + MaxVolumeCapacity: 32000, type: float, display: true, formatter: Liter # 256 Containers + EnergyIn: 3, type: int, display: true, formatter: Watt + AllowPlacingAt: "SS,GV", display: true + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeParent: ContainerSmallBlocks + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 1685, Name: ContainerExtensionSmall, Ref: ContainerExtensionLarge + Mass: 52, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + MarketPrice: 15, display: true + Volume: 11, type: float, display: true, formatter: Liter + StackSize: 2000 + VolumeCapacity: 125, type: float, display: true, formatter: Liter + CPUIn: 15, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + AllowPlacingAt: "SS,GV", display: true + # ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA, CubeHalf, RampBMedium, RampD, CutCornerEMedium, SlopeHalf, CornerSmallA" + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA, CubeHalf, RampBMedium, RampD, CutCornerEMedium" + + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: ContainerControllerSmall + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 1686, Name: ContainerHarvestControllerSmall, Ref: ContainerControllerLarge + Class: ContainerController + Group: cpgHarvest + MarketPrice: 75, display: true + IndexName: HarvestCntr + Model: @models/Blocks/Spaceship/ContainerHarvestControllerSmallPrefab + BlockColor: "62,30,10" + Info: bkiContainerHarvest, display: true # adapt + Mass: 52, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + Volume: 11, type: float, display: true, formatter: Liter + VolumeCapacity: 125, type: float, display: true, formatter: Liter + CPUIn: 15, type: int, display: true + MaxVolumeCapacity: 32000, type: float, display: true, formatter: Liter # 256 Containers + EnergyIn: 3, type: int, display: true, formatter: Watt + AllowPlacingAt: "SS,GV", display: true + + UnlockCost: 5, display: true + UnlockLevel: 3, display: true + TechTreeParent: ContainerSmallBlocks + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 1687, Name: ContainerHarvestControllerLarge, Ref: ContainerControllerLarge + Class: ContainerController + Group: cpgHarvest + IndexName: HarvestCntr + MarketPrice: 258, display: true + Model: @models/Blocks/Mothership/ContainerHarvestControllerLargePrefab + BlockColor: "62,30,10" + Info: bkiContainerHarvest, display: true # adapt + EnergyIn: 10, type: int, display: true, formatter: Watt + AllowPlacingAt: "MS", display: true + + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeParent: ContainerExtensionLarge + TechTreeNames: Capital Vessel +} +{ +Block Id: 1688, Name: ContainerAmmoControllerSmall, Ref: ContainerControllerLarge + Class: ContainerController + Group: cpgAmmo + IndexName: AmmoCntr + MarketPrice: 75, display: true + Model: @models/Blocks/Spaceship/ContainerAmmoControllerSmallPrefab + BlockColor: "0,40,0" + Info: bkiContainerAmmo, display: true # adapt + Mass: 52, type: float, display: true, formatter: Kilogram + HitPoints: 100, type: int, display: true + Volume: 20, type: float, display: true, formatter: Liter + VolumeCapacity: 125, type: float, display: true, formatter: Liter + CPUIn: 15, type: int, display: true + MaxVolumeCapacity: 32000, type: float, display: true, formatter: Liter # 256 Containers + EnergyIn: 3, type: int, display: true, formatter: Watt + AllowPlacingAt: "SS,GV", display: true + + UnlockCost: 4, display: true + UnlockLevel: 3, display: true + TechTreeParent: ContainerSmallBlocks + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 1689, Name: ContainerAmmoControllerLarge, Ref: ContainerControllerLarge + Class: ContainerController + Group: cpgAmmo + IndexName: AmmoCntr + MarketPrice: 610, display: true + Model: @models/Blocks/Mothership/ContainerAmmoControllerLargePrefab + BlockColor: "0,40,0" + Info: bkiContainerAmmo, display: true # adapt + EnergyIn: 10, type: int, display: true, formatter: Watt + AllowPlacingAt: "Base,MS", display: true + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: "ContainerAmmoLarge" + TechTreeNames: "Base,Capital Vessel" +} + + +{ +Block Id: 1690, Name: WalkwaySmallBlocks + Material: metallight + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Shape: Invisible + Place: Free + MarketPrice: 15, display: true + Mesh: cutout + # Model: Walkway + Texture: 61 + IsAccessible: false, type: bool + Info: bkiBlockGroup, display: true + IsOxygenTight: false, display: true + Category: BuildingBlocks + IsTextureable: false + + ChildBlocks: "WalkwayVertNew, WalkwaySlopeNew, WalkwaySlope, RailingVert, RailingDiagonal, RailingSlopeLeft, RailingSlopeRight, RailingL, RailingRound, RailingVertGlass, RailingDiagonalGlass, RailingSlopeGlassLeft, RailingSlopeGlassRight, RailingLGlass, RailingRoundGlass" + + # Parent properties + TemplateRoot: WalkwaySmallBlocks + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "SS,GV" + # HitPoints: 250, type: int, display: true + Mass: 12, type: float, display: true, formatter: Kilogram + Volume: 4, type: float, display: true, formatter: Liter + StackSize: 2000 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1691, Name: WalkwayLargeBlocks + Material: metallight + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Shape: Invisible + Place: Free + MarketPrice: 65, display: true + Mesh: cutout + # Model: Walkway + Texture: 61 + IsAccessible: false, type: bool + Info: bkiBlockGroup, display: true + IsOxygenTight: false, display: true + Category: BuildingBlocks + IsTextureable: false + + ChildBlocks: "WalkwayVertNew, WalkwaySlopeNew, WalkwaySlope, RailingVert, RailingDiagonal, RailingSlopeLeft, RailingSlopeRight, RailingL, RailingRound, RailingVertGlass, RailingDiagonalGlass, RailingSlopeGlassLeft, RailingSlopeGlassRight, RailingLGlass, RailingRoundGlass, RailingVertMetal, RailingDiagonalMetal, RailingLMetal, RailingRoundMetal, RailingSlopeMetalLeft, RailingSlopeMetalRight, RailingVertGlassModular, RailingDiagonalGlassModular, RailingLGlassModular, RailingRoundGlassModular, RailingSlopeGlassLeftModular, RailingSlopeGlassRightModular, RailingDoubleVert, RailingDoubleVertGlass, RailingDoubleVertMetal, RailingDoubleVertGlassModular" + + # Parent properties + # TemplateRoot: WalkwayLargeBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + HitPoints: 100, type: int, display: true + Mass: 195, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + StackSize: 1000 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Id: 1692, Name: RampLargeBlocks + Class: Door + IndexName: Door + Material: hullarmored + Shape: Invisible + MarketPrice: 1428, display: true + # Model: @models/Blocks/Mothership/Ramps/Ramp3x1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + Info: bkiBlockGroup, display: true + BlockColor: "110,110,110" + StackSize: 1000 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Devices + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + # PlayerTriggersOpen: true + ChildBlocks: "Ramp1x1x1, Ramp3x1x1, Ramp1x2x1, Ramp3x2x1, Ramp1x3x1, Ramp3x3x1, Ramp1x4x2, Ramp3x4x2, Ramp3x5x3, Ramp1x1x1Airtight, Ramp3x1x1Airtight, Ramp1x2x1Airtight, Ramp3x2x1Airtight, Ramp1x3x1Airtight, Ramp3x3x1Airtight, Ramp1x4x2Airtight, Ramp3x4x2Airtight, Ramp3x5x3Airtight" + + # Parent properties + TemplateRoot: RampLargeBlocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + HitPoints: 250, type: int, display: true + Volume: 800, type: float, display: true, formatter: Liter + Mass: 790, type: float, display: true, formatter: Kilogram + + UnlockCost: 10, display: true + UnlockLevel: 5, display: true + TechTreeParent: DoorBlocks + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1693, Name: RampSmallBlocks + Class: Door + IndexName: Door + Material: hullarmored + MarketPrice: 395, display: true + Shape: Invisible + # Model: @models/Blocks/Mothership/Ramps/Ramp3x1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + Info: bkiBlockGroup, display: true + BlockColor: "110,110,110" + StackSize: 1000 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Devices + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + # PlayerTriggersOpen: true + ChildBlocks: "Ramp1x1x1, Ramp3x1x1, Ramp1x2x1, Ramp3x2x1, Ramp1x3x1, Ramp3x3x1, Ramp1x4x2, Ramp3x4x2, Ramp3x5x3, Ramp1x1x1Airtight, Ramp3x1x1Airtight, Ramp1x2x1Airtight, Ramp3x2x1Airtight, Ramp1x3x1Airtight, Ramp3x3x1Airtight, Ramp1x4x2Airtight, Ramp3x4x2Airtight, Ramp3x5x3Airtight" + + # Parent properties + TemplateRoot: RampSmallBlocks + AllowPlacingAt: "SS,GV", display: true + HitPoints: 100, type: int, display: true + Volume: 93.75, type: float, display: true, formatter: Liter + Mass: 150, type: float, display: true, formatter: Kilogram + + UnlockCost: 8, display: true + UnlockLevel: 5, display: true + TechTreeParent: DoorBlocksSV + TechTreeNames: "Small Vessel,Hover Vessel" +} + +# retractable landinggear +{ +Block Id: 1694, Name: LandingGearSVRetDouble, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetDoublePrefab + SizeInBlocks: "1,2,2", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} +# retractable landinggear +{ +Block Id: 1695, Name: LandingGearSVSideStrut, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVSideStrutPrefab + SizeInBlocks: "1,3,1", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SymType: 4 +} +# retractable landinggear +{ +Block Id: 1696, Name: LandingGearSVSingle, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVSinglePrefab + SizeInBlocks: "1,3,3", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} +# retractable landinggear +{ +Block Id: 1697, Name: LandingGearSVRetSkid, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetSkidPrefab + SizeInBlocks: "1,1,2", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 +} +# retractable landinggear +# too high - temporarily deactivated +{ +Block Id: 1698, Name: LandingGearSVRetLargeDouble, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetLargeDoublePrefab + SizeInBlocks: "2,5,4", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true + + ShowUser: No +} +# retractable landinggear +{ +Block Id: 1699, Name: LandingGearSVRetLargeDoubleV2, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetLargeDoubleV2Prefab + TemplateRoot: LandinggearBlocksHeavySV + SizeInBlocks: "1,3,2", display: true + Mass: 300, type: float, display: true, formatter: Kilogram + HitPoints: 175, type: int, display: false + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} +# retractable landinggear +{ +Block Id: 1700, Name: LandingGearSVRetLargeDoubleV3, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetLargeDoubleV3Prefab + TemplateRoot: LandinggearBlocksHeavySV + SizeInBlocks: "1,2,2", display: true + Mass: 300, type: float, display: true, formatter: Kilogram + HitPoints: 175, type: int, display: false + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} +# retractable landinggear +# too high - temporarily deactivated +{ +Block Id: 1701, Name: LandingGearSVRetLargeSingle, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetLargeSinglePrefab + SizeInBlocks: "2,5,2", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true + + ShowUser: No +} +# retractable landinggear +{ +Block Id: 1702, Name: LandingGearSVRetLargeSingleV2, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetLargeSingleV2Prefab + TemplateRoot: LandinggearBlocksHeavySV + SizeInBlocks: "1,3,1", display: true + Mass: 150, type: float, display: true, formatter: Kilogram + HitPoints: 120, type: int, display: false + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} +# retractable landinggear +{ +Block Id: 1703, Name: LandingGearSVRetLargeSingleV3, Ref: LandinggearSV + Model: @models/Blocks/Landinggears/LandingGearSVRetLargeSingleV3Prefab + TemplateRoot: LandinggearBlocksHeavySV + SizeInBlocks: "1,2,1", display: true + Mass: 200, type: float, display: true, formatter: Kilogram + HitPoints: 120, type: int, display: false + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} +# retractable landinggear +{ +Block Id: 1704, Name: LandingGearCVRetLargeDouble, Ref: LandinggearMSHeavy + Model: @models/Blocks/Landinggears/LandingGearCVRetLargeDoublePrefab + SizeInBlocks: "1,2,2", display: true + HitPoints: 945, type: int, display: true + Mass: 2980, type: float, display: true, formatter: Kilogram + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} +# retractable landinggear +{ +Block Id: 1705, Name: LandingGearCVRetLargeSingle, Ref: LandinggearMSHeavy + Model: @models/Blocks/Landinggears/LandingGearCVRetLargeSinglePrefab + SizeInBlocks: "1,2,1", display: true + HitPoints: 644, type: int, display: true + Mass: 1830, type: float, display: true, formatter: Kilogram + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + IsOxygenTight: true, display: true +} + +{ +Block Id: 1706, Name: BoardingRampBlocks + Class: Door + IndexName: Door + Group: cpgRamp + Material: hullarmored + Shape: Invisible + MarketPrice: 1910, display: true + # Model: @models/Blocks/Mothership/HangarDoor/HangarDoor10x5Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 1920, type: float, display: true, formatter: Liter + Mass: 3400, type: float, display: true, formatter: Kilogram + StackSize: 500 + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: Devices + # EnergyIn: 1, type: int, display: true, formatter: Watt + SoundOpen: UseActions/HangarDoor + SoundClose: UseActions/HangarDoor + # ChildBlocks: "BoardingRamp1x2x3, BoardingRamp2x2x3, BoardingRamp3x2x3, BoardingRamp3x3x5" + ChildBlocks: "BoardingRamp1x2x3, BoardingRamp2x2x3, BoardingRamp3x2x3, BoardingRamp1x4x6, BoardingRamp2x4x6, BoardingRamp3x4x6, BoardingRamp5x4x6, BoardingRamp5x4x9, BoardingRamp8x6x12" + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + TechTreeParent: RampLargeBlocks + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1707, Name: BoardingRamp1x2x3, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp1x2x3Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 430, type: int, display: true + Mass: 2830, type: float, display: true, formatter: Kilogram + SizeInBlocks: "1,2,3", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight +} +{ +Block Id: 1708, Name: BoardingRamp2x2x3, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp2x2x3Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 860, type: int, display: true + Mass: 5660, type: float, display: true, formatter: Kilogram + SizeInBlocks: "2,2,3", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} +{ +Block Id: 1709, Name: BoardingRamp3x2x3, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp3x2x3Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 1290, type: int, display: true + Mass: 8500, type: float, display: true, formatter: Kilogram + SizeInBlocks: "3,2,3", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} +# height of 3 blocks is problematic to integrate into BA/CV +{ Block Id: 1710, Name: BoardingRamp3x3x5, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp3x3x5Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 2140, type: int, display: true + Mass: 10590, type: float, display: true, formatter: Kilogram + SizeInBlocks: "3,3,5", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks + + ShowUser: No +} + +{ +Block Id: 1711, Name: ContainerLargeBlocks + Material: metal + Shape: Invisible + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + MarketPrice: 155, display: true + AllowPlacingAt: "Base,MS", display: true + Mass: 1250, type: float, display: true, formatter: Kilogram + Volume: 90, type: float, display: true, formatter: Liter + HitPoints: 500, type: int, display: true + Info: bkiContainer, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + StackSize: 1000 + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + ChildBlocks: "ContainerMS01Large, ScifiContainer1Large, ScifiContainer2Large, ScifiContainerPowerLarge, CargoContainerSmall, CargoContainerMedium, ContainerMS02Large, ContainerMS03Large, ContainerMS04Large, ContainerMS05Large, CargoPalette01, CargoPalette02, CargoPalette03, CargoPalette04" + + UnlockCost: 3, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Base,Capital Vessel" +} +{ +Block Id: 1712, Name: ContainerSmallBlocks + Material: metal + Shape: Invisible + MarketPrice: 15, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "SS,GV", display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Volume: 15, type: float, display: true, formatter: Liter + HitPoints: 100, type: int, display: true + Info: bkiContainer, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + StackSize: 2000 + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + ChildBlocks: "ContainerMS01Small, ScifiContainer1Small, ScifiContainer2Small, ScifiContainerPowerSmall, CargoContainerSV" + + UnlockCost: 3, display: true + UnlockLevel: 1, display: true + TechTreeNames: "Small Vessel,Hover Vessel" + + # UnlockCost: 3, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: "Base,Capital Vessel" +} + +# # Container Blocks +{ +Block Id: 1713, Name: ContainerMS01Large, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/CargoBoxPrefab + Mass: 250, type: float, display: true, formatter: Kilogram + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + CPUIn: 960, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 + +}# Container Blocks +{ +Block Id: 1714, Name: ScifiContainer1Large, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer1Prefab + # Model: @models/Blocks/Mothership/ScifiContainer1LargePrefab + SizeInBlocks: "2,1,1", display: true + Mass: 500, type: float, display: true, formatter: Kilogram + VolumeCapacity: 16000, type: float, display: true, formatter: Liter + CPUIn: 1920, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} +{ +Block Id: 1715, Name: ScifiContainer2Large, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer2Prefab + # Model: @models/Blocks/Mothership/ScifiContainer2LargePrefab + SizeInBlocks: "2,1,1", display: true + Mass: 250, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + CPUIn: 960, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} +{ +Block Id: 1716, Name: ScifiContainerPowerLarge, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainerPowerPrefab + # Model: @models/Blocks/Mothership/ScifiContainerPowerLargePrefab + SizeInBlocks: "2,1,1", display: true + Mass: 500, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 16000, type: float, display: true, formatter: Liter + CPUIn: 1920, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} + +# Same as SentryGun01RetractV2 but rotated correctly +{ +Block Id: 1717, Name: SentryGun01Retract, Ref: SentryGun01RetractV2 + Model: @models/Blocks/Turrets/SentryGuns/SentryGunAlienWallRetractPrefab +} +# Same as SentryGun05RetractV2 but rotated correctly +{ +Block Id: 1718, Name: SentryGun05Retract, Ref: SentryGun05RetractV2 + Model: @models/Blocks/Turrets/SentryGuns/SentryGunHumanWallRetractPrefab +} + +{ +Block Id: 1719, Name: DecoVesselBlocks + Material: metal + Shape: Invisible + MarketPrice: 150, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + Info: bkiBlockGroup, display: true + AllowPlacingAt: "Base,MS,SS,GV", display: true + Volume: 40, type: float, display: true, formatter: Liter + Mass: 115, type: float, display: true, formatter: Kilogram + StackSize: 1000 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Deco Blocks + ChildBlocks: "SVDecoAeroblister01, SVDecoAirbrake01, SVDecoAntenna01, SVDecoAntenna02, SVDecoArmor1x, SVDecoArmor2x, SVDecoFin01, SVDecoFin02, SVDecoFin03, SVDecoGreeble01, SVDecoGreeble02, SVDecoGreeble03, SVDecoIntake01, SVDecoIntake02New, SVDecoLightslot2x, SVDecoLightslot3x, SVDecoStrake01, SVDecoStrake02, SVDecoVent01, SVDecoVent02" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Id: 1720, Name: SVDecoAeroblister01 + Material: metal + Shape: ModelEntity + Model: @models/Blocks/SpaceshipParts/SVDecoAeroblister01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Category: Deco Blocks + Place: Free + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + TemplateRoot: DecoVesselBlocks + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS,SS,GV", display: true + SizeInBlocks: "1,3,1", display: true + EnergyIn: 1, type: int, display: false, formatter: Watt + ShowUser: No +} +{ +Block Id: 1721, Name: SVDecoAirbrake01, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoAirbrake01Prefab + SizeInBlocks: "2,2,2", display: true +} +{ +Block Id: 1722, Name: SVDecoAntenna01, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoAntenna01Prefab + SizeInBlocks: "1,1,6", display: true +} +{ +Block Id: 1723, Name: SVDecoAntenna02, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoAntenna02Prefab + SizeInBlocks: "1,4,2", display: true + # Place: Face +} +{ +Block Id: 1724, Name: SVDecoArmor1x, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoArmor1xPrefab + SizeInBlocks: "1,1,1", display: true + # Place: Face +} +{ +Block Id: 1725, Name: SVDecoArmor2x, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoArmor2xPrefab + SizeInBlocks: "2,1,2", display: true + # Place: Face +} +{ +Block Id: 1726, Name: SVDecoFin01, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoFin01Prefab + SizeInBlocks: "1,3,2", display: true +} +{ +Block Id: 1727, Name: SVDecoFin02, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoFin02Prefab + SizeInBlocks: "1,3,2", display: true +} +{ +Block Id: 1728, Name: SVDecoFin03, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoFin03Prefab + SizeInBlocks: "1,4,3", display: true +} +{ +Block Id: 1729, Name: SVDecoGreeble01, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoGreeble01Prefab + SizeInBlocks: "1,1,1", display: true + # Place: Face +} +{ +Block Id: 1730, Name: SVDecoGreeble02, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoGreeble02Prefab + SizeInBlocks: "1,1,2", display: true + # Place: Face +} +{ +Block Id: 1731, Name: SVDecoGreeble03, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoGreeble03Prefab + SizeInBlocks: "1,1,2", display: true + # Place: Face +} +{ +Block Id: 1732, Name: SVDecoIntake01, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoIntake01Prefab + SizeInBlocks: "1,1,2", display: true + # Place: Face +} +# Obsolete - wrong rotation +{ +Block Id: 1733, Name: SVDecoIntake02a, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoIntake02aPrefab + SizeInBlocks: "1,1,2", display: true +} +{ +Block Id: 1734, Name: SVDecoLightslot2x, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoLightslot2xPrefab + SizeInBlocks: "1,1,2", display: true + # Place: Face +} +{ +Block Id: 1735, Name: SVDecoLightslot3x, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoLightslot3xPrefab + SizeInBlocks: "1,1,3", display: true + # Place: Face +} +{ +Block Id: 1736, Name: SVDecoStrake01, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoStrake01Prefab + SizeInBlocks: "1,1,3", display: true + # Place: Face +} +{ +Block Id: 1737, Name: SVDecoStrake02, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoStrake02Prefab + SizeInBlocks: "1,1,3", display: true + # Place: Face +} +{ +Block Id: 1738, Name: SVDecoVent01, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoVent01Prefab + SizeInBlocks: "2,1,2", display: true + # Place: Face + IsBlocksThrusters: false, display: true +} + + +{ +Block Id: 1739, Name: DecoTribalBlocks, Ref: DecoBlocks + AllowPlacingAt: Base, display: true + SizeInBlocksLocked: "Base,MS" + Shape: Invisible + # Model: @models2/Entities/Decorations/AncientTemple/RunestonePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiBlockGroup, display: true + SizeInBlocks: "1,1,1", display: true + ChildBlocks: "TribalBarrels, TribalBox, TribalBarrow, TribalBoat, TribalBaskets, TribalBed1, TribalBed2, TribalBookcase1, TribalBookcase2, TribalBuckets, TribalCabinet1, TribalCabinet2, TribalCauldron, TribalCauldron2, TribalTub, TribalDryFish, TribalLoom, TribalOven, TribalHearth, TribalSacks, TribalTable1, TribalTable2, TribalChair, TribalWoodSaw, TribalFirewood, TribalTrunkAxe, TribalTorch, TribalFirepit, TribalAnvil" #29 of 32 +} + +{ +Block Id: 1740, Name: TribalBarrels, Ref: DecoTemplate + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + Material: woodblock + DropInventoryEntity: DropContainer + LootList: 56 + VolumeCapacity: 750, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBarrelsPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1741, Name: TribalBarrow, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalBarrowPrefab + SizeInBlocks: "1,1,2", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1742, Name: TribalBaskets, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 203 + VolumeCapacity: 500, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBasketsPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1743, Name: TribalBed1, Ref: DecoTemplate + Class: Sleeping + # Group: cpgEquipment + # IsAccessible: true, type: bool + # IsLockable: true + BuffNamesActivate: "IncreaseBodyTemp", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalBed1Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1744, Name: TribalBed2, Ref: DecoTemplate + Class: Sleeping + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalBed2Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks + # Group: cpgEquipment + # IsAccessible: true, type: bool + # IsLockable: true + BuffNamesActivate: "IncreaseBodyTemp", display: StatusEffect + DebuffNamesActivate: "Hangover,BadTrip", display: EffectiveAgainst +} +{ +Block Id: 1745, Name: TribalBookcase1, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 62 + VolumeCapacity: 800, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBookcase1Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1746, Name: TribalBookcase2, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 62 + VolumeCapacity: 400, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBookcase2Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1747, Name: TribalBuckets, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 204 + VolumeCapacity: 350, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBucketsPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1748, Name: TribalCabinet1, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 59 + VolumeCapacity: 500, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalCabinet1Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1749, Name: TribalCabinet2, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 59 + VolumeCapacity: 500, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalCabinet2Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1750, Name: TribalCauldron, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + VolumeCapacity: 500, type: float, display: true, formatter: Liter + LootList: 200 + Model: @models2/Entities/Decorations/TribalAssets/TribalCauldronPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1751, Name: TribalDryFish, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalDryFishPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1752, Name: TribalLoom, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalLoomPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1753, Name: TribalOven, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalOvenPrefab + SizeInBlocks: "1,2,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1754, Name: TribalSacks, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 202 + VolumeCapacity: 500, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalSacksPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1755, Name: TribalTable1, Ref: DecoTemplate + Material: woodblock + # Model: @models2/Entities/Decorations/TribalAssets/TribalTable1Prefab + Model: @models2/Entities/Decorations/TribalAssets/TribalTable4Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1756, Name: TribalTable2, Ref: DecoTemplate + Material: woodblock + # Model: @models2/Entities/Decorations/TribalAssets/TribalTable2Prefab + Model: @models2/Entities/Decorations/TribalAssets/TribalTable3Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1757, Name: TribalWoodSaw, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalWoodSawPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1758, Name: TribalTrunkAxe, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalTrunkAxePrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1759, Name: TribalTorch, Ref: DecoTemplate + IndexName: Light + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalTorchPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks + Temperature: 40, display: true +} +{ +Block Id: 1760, Name: TribalFirepit, Ref: DecoTemplate + IndexName: Light + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalFirepitPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks + Temperature: 45, display: true +} +{ +Block Id: 1761, Name: TribalFirewood, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalFirewoodPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1762, Name: TribalBoat, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalBoatPrefab + SizeInBlocks: "1,1,2", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1763, Name: TribalChair, Ref: DecoTemplate + Material: woodblock + # Model: @models2/Entities/Decorations/TribalAssets/TribalChairPrefab + Model: @models2/Entities/Decorations/TribalAssets/TribalChair2Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1764, Name: TribalAnvil, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalAnvilPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1765, Name: TribalCauldron2, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + VolumeCapacity: 500, type: float, display: true, formatter: Liter + LootList: 200 + Model: @models2/Entities/Decorations/TribalAssets/TribalCauldron2Prefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1766, Name: TribalHearth, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalHearthPrefab + SizeInBlocks: "1,1,2", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1767, Name: TribalTub, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + VolumeCapacity: 500, type: float, display: true, formatter: Liter + LootList: 201 + Model: @models2/Entities/Decorations/TribalAssets/TribalTubPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +{ +Block Id: 1768, Name: TribalBox, Ref: DecoTemplate + Material: woodblock + Class: Container + IsAccessible: true, type: bool + IndexName: Container + Group: cpgCargoBox + Category: Devices + IsLockable: true + DropInventoryEntity: DropContainer + LootList: 56 + VolumeCapacity: 100, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBoxPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: DecoTribalBlocks +} +# # Container Blocks +{ +Block Id: 1769, Name: ContainerMS01Small, Ref: ContainerMS01 + TemplateRoot: ContainerSmallBlocks + AllowPlacingAt: "SS,GV", display: true + Mass: 45, type: float, display: true, formatter: Kilogram + VolumeCapacity: 125, type: float, display: true, formatter: Liter + HitPoints: 40, type: int, display: true + CPUIn: 15, type: int, display: true +} +{ +Block Id: 1770, Name: ScifiContainer1Small, Ref: ContainerMS01 + LootList: 68 + # Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer1Prefab + Model: @models/Blocks/Spaceship/ScifiContainer1SmallPrefab + SizeInBlocks: "2,1,1", display: true + Mass: 100, type: float, display: true, formatter: Kilogram + VolumeCapacity: 250, type: float, display: true, formatter: Liter + CPUIn: 30, type: int, display: true + TemplateRoot: ContainerSmallBlocks + AllowPlacingAt: "SS,GV", display: true +} +{ +Block Id: 1771, Name: ScifiContainer2Small, Ref: ContainerMS01 + LootList: 67 + # Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer2Prefab + Model: @models/Blocks/Spaceship/ScifiContainer2SmallPrefab + SizeInBlocks: "2,1,1", display: true + Mass: 90, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 219, type: float, display: true, formatter: Liter + CPUIn: 30, type: int, display: true + TemplateRoot: ContainerSmallBlocks + AllowPlacingAt: "SS,GV", display: true +} +{ +Block Id: 1772, Name: ScifiContainerPowerSmall, Ref: ContainerMS01 + LootList: 68 + # Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainerPowerPrefab + Model: @models/Blocks/Spaceship/ScifiContainerPowerSmallPrefab + SizeInBlocks: "2,1,1", display: true + Mass: 90, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 235, type: float, display: true, formatter: Liter + CPUIn: 30, type: int, display: true + TemplateRoot: ContainerSmallBlocks + AllowPlacingAt: "SS,GV", display: true +} +# New AI weapons for POI +{ +Block Id: 1773, Name: TurretEnemyBallista, Ref: TurretIONCannon #TurretAlien Talon Ballista + Group: cpgTurret + IndexName: Turret +# { +Block Id: 1773, Name: TurretEnemyBallista, Ref: TurretBaseFlak + Model: @models/Blocks/Turrets/TurretsEnemy/TurretBallistaPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + SizeInBlocks: "2,1,2", display: true + WeaponItem: TurretEnemyBallistaWeapon + RotSpeed: 80, type: int, display: true + SoundRotate: Weapons/Turret/TurretRotateSlow + + BlastParticleIndex: 13 + BlastRadius: 3 + BlastDamage: 100 +} +{ +Block Id: 1774, Name: ThrusterGVRoundNormalT2, Ref: ThrusterGVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterGVRoundNormalT2Prefab + SizeInBlocks: "1,2,1", display: true + MarketPrice: 1425, display: true + HitPoints: 220, type: int, display: true + Mass: 485, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + CPUIn: 120, type: int, display: true + ThrusterForce: 185, type: int, display: true, formatter: Newton + EnergyIn: 58, type: int, display: true, formatter: Watt + Temperature: 150, display: true + TemplateRoot: ThrusterGVRoundNormalT2 + + UnlockCost: 10, display: true + UnlockLevel: 10, display: true + TechTreeParent: ThrusterGVRoundBlocks + TechTreeNames: Hover Vessel + UpgradeTo: null, display: false + DowngradeTo: ThrusterGVRoundNormal, display: true +} +{ +Block Id: 1775, Name: ThrusterGVRoundLarge, Ref: ThrusterGVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterGVRoundLargePrefab + MarketPrice: 1307, display: true + SizeInBlocks: "1,3,1", display: true + HitPoints: 350, type: int, display: true + Volume: 96.75, type: float, display: true, formatter: Liter + Mass: 390, type: float, display: true, formatter: Kilogram + ThrusterForce: 235, type: int, display: true, formatter: Newton + EnergyIn: 45, type: int, display: true, formatter: Watt + CPUIn: 145, type: int, display: true + Temperature: 120, display: true + TemplateRoot: ThrusterGVRoundLarge + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + TechTreeParent: ThrusterGVRoundBlocks + TechTreeNames: Hover Vessel + UpgradeTo: ThrusterGVRoundLargeT2, display: true +} +{ +Block Id: 1776, Name: ThrusterGVRoundLargeT2, Ref: ThrusterGVRoundNormal + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterGVRoundLargeT2Prefab + MarketPrice: 2865, display: true + SizeInBlocks: "1,3,1", display: true + HitPoints: 400, type: int, display: true + Volume: 96.75, type: float, display: true, formatter: Liter + Mass: 473, type: float, display: true, formatter: Kilogram + ThrusterForce: 345, type: int, display: true, formatter: Newton + EnergyIn: 80, type: int, display: true, formatter: Watt + CPUIn: 198, type: int, display: true + Temperature: 180, display: true + TemplateRoot: ThrusterGVRoundLargeT2 + + UnlockCost: 12, display: true + UnlockLevel: 12, display: true + TechTreeParent: ThrusterGVRoundLarge + TechTreeNames: Hover Vessel + UpgradeTo: null, display: false +} +{ +Block Id: 1777, Name: CargoPalette01, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/CargoPalette01Prefab + Mass: 300, type: float, display: true, formatter: Kilogram + VolumeCapacity: 3880, type: float, display: true, formatter: Liter + CPUIn: 480, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 + IsOxygenTight: false, display: true +} +{ +Block Id: 1778, Name: CargoPalette02, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/CargoPalette02Prefab + Mass: 300, type: float, display: true, formatter: Kilogram + VolumeCapacity: 5380, type: float, display: true, formatter: Liter + CPUIn: 480, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 + IsOxygenTight: false, display: true +} +{ +Block Id: 1779, Name: CargoPalette03, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/CargoPalette03Prefab + Mass: 300, type: float, display: true, formatter: Kilogram + VolumeCapacity: 5130, type: float, display: true, formatter: Liter + CPUIn: 480, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 + IsOxygenTight: false, display: true +} +{ +Block Id: 1780, Name: CargoPalette04, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/CargoPalette04Prefab + Mass: 300, type: float, display: true, formatter: Kilogram + VolumeCapacity: 4880, type: float, display: true, formatter: Liter + CPUIn: 480, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 + IsOxygenTight: false, display: true +} +{ Block Id: 1781, Name: TextureMulti18 + StackSize: 999 + Material: hull + Texture: "79,79,79,79,79,79" + AllowPlacingAt: "Base,MS,SS,GV", display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowUser: No +} +# BA / CV +{ +Block Id: 1782, Name: WoodExtended, Ref: WoodFull + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + #max32 ChildShapes reached! + UpgradeTo: ConcreteExtended, display: true +} + +{ +Block Id: 1783, Name: ConcreteExtended, Ref: ConcreteFull + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + UpgradeTo: ConcreteArmoredExtended, display: true +} +{ +Block Id: 1784, Name: ConcreteArmoredExtended, Ref: ConcreteArmoredFull + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" +} +{ +Block Id: 1785, Name: PlasticExtendedLarge, Ref: PlasticFullLarge + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + UpgradeTo: HullExtendedLarge, display: true +} +{ +Block Id: 1786, Name: HullExtendedLarge, Ref: HullFullLarge + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + UpgradeTo: HullArmoredExtendedLarge, display: true + DowngradeTo: PlasticExtendedLarge, display: true +} +{ +Block Id: 1787, Name: HullArmoredExtendedLarge, Ref: HullArmoredFullLarge + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + UpgradeTo: HullCombatExtendedLarge, display: true +} +{ +Block Id: 1788, Name: HullCombatExtendedLarge, Ref: HullCombatFullLarge + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" +} +{ +Block Id: 1789, Name: AlienExtended, Ref: AlienFull + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" +} +# SV / HV +{ +Block Id: 1790, Name: PlasticExtendedSmall, Ref: PlasticFullSmall + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + UpgradeTo: HullExtendedSmall, display: true +} +{ +Block Id: 1791, Name: HullExtendedSmall, Ref: HullFullSmall + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + UpgradeTo: HullArmoredExtendedSmall, display: true + DowngradeTo: PlasticExtendedSmall, display: true +} +{ +Block Id: 1792, Name: HullArmoredExtendedSmall, Ref: HullArmoredFullSmall + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" + UpgradeTo: HullCombatExtendedSmall, display: true +} +{ +Block Id: 1793, Name: HullCombatExtendedSmall, Ref: HullCombatFullSmall + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" +} +# BA / CV +{ +Block Id: 1794, Name: AlienExtendedLarge, Ref: AlienFullLarge + ChildShapes: "CornerCMedium, CornerSmallCMedium, CornerSmallA, SmallCornerRoundC, RampRoundDDouble, NotchedC, NotchedA, NotchedCMedium, CubeQuarter, CylinderThinXJoint, CornerDoubleA1, CutCornerA, CornerDoubleB3, SlicedCornerD, EdgeRoundMedium, RampB, CornerRoundBMedium, CornerRoundBLow, CornerRoundAMedium, CornerRoundALow, EdgeRoundMediumHalf, EdgeRoundLow, PipesFenceDiagonal, FenceTopDiagonal, CubeRoundConnectorBleft, CubeRoundConnectorBright, CylinderRoundTransition, WallEdge, RampRoundConnectorBleft, RampRoundConnectorBright, RampRoundConnectorAleft, RampRoundConnectorAright" +} + +# Container Blocks +{ +Block Id: 1795, Name: ContainerMS02Large, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/ContainerMS02LargePrefab + SizeInBlocks: "2,1,1", display: true + Mass: 600, type: float, display: true, formatter: Kilogram + VolumeCapacity: 16000, type: float, display: true, formatter: Liter + CPUIn: 1920, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} +{ +Block Id: 1796, Name: ContainerMS03Large, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/ContainerMS03LargePrefab + SizeInBlocks: "2,1,1", display: true + Mass: 300, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + CPUIn: 960, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} +{ +Block Id: 1797, Name: ContainerMS04Large, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/ContainerMS04LargePrefab + SizeInBlocks: "2,1,1", display: true + Mass: 600, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + VolumeCapacity: 16000, type: float, display: true, formatter: Liter + CPUIn: 1920, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} + +{ +Block Id: 1798, Name: SVDecoVent02, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoVent02Prefab + SizeInBlocks: "1,1,1", display: true + # Place: Face + IsBlocksThrusters: false, display: true +} +{ +Block Id: 1799, Name: Level4Prop4, Ref: DecoTemplate + TemplateRoot: DecoBlocks2 + Class: Container + IndexName: Container + DropInventoryEntity: DropContainer + LootList: 58 + Model: @models2/Entities/Decorations/LaboratoryPack/Level4Prop4Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + ShowUser: Yes + VolumeCapacity: 600, type: float, display: true, formatter: Liter +} + +{ +Block Id: 1800, Name: CockpitBlocksSVT2 + Class: CockpitSS + IndexName: Cockpit + Group: cpgCockpit + MarketPrice: 2910, display: true + EssentialCategory: cpgCockpit + Material: metalhard + Shape: Invisible + # Model: @models/Blocks/Cockpits/CockpitSS04Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Cockpit + Info: bkiCockpit, display: true + IsOxygenTight: varies, display: true # not all child blocks are the same + HitPoints: 1600, type: int, display: false + AllowPlacingAt: "SS,GV", display: true + Volume: 400, type: float, display: true, formatter: Liter + Mass: 1390, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + StackSize: 500 + Category: Devices + ChildBlocks: "CockpitSV01T2, CockpitSV_ShortRangeT2, CockpitSV02NewT2, CockpitSV04T2, CockpitSV05NewT2, CockpitSV06T2, CockpitSV07NewT2, CockpitSV03NewT2" + MaxCount: 2, type: int, display: true + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: CockpitBlocksSV + TechTreeNames: "Small Vessel,Hover Vessel" +} +{ +Block Id: 1801, Name: CockpitSV01T2, Ref: CockpitSV01 + Model: @models/Blocks/Cockpits/CockpitSS01T2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1390, type: float, display: true, formatter: Kilogram + HitPoints: 1800, type: int, display: true + UpgradeTo: null +} +{ +Block Id: 1802, Name: CockpitSV_ShortRangeT2, Ref: CockpitSV_ShortRange + Model: @models/Blocks/Cockpits/CockpitSV_ShortRangeT2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1390, type: float, display: true, formatter: Kilogram + HitPoints: 1800, type: int, display: true + UpgradeTo: null +} +{ +Block Id: 1803, Name: CockpitSV02NewT2, Ref: CockpitSV02New + Model: @models/Blocks/Cockpits/CockpitSS02NewT2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1390, type: float, display: true, formatter: Kilogram + HitPoints: 1800, type: int, display: true + UpgradeTo: null +} +{ +Block Id: 1804, Name: CockpitSV04T2, Ref: CockpitSV04 + Model: @models/Blocks/Cockpits/CockpitSS04T2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1390, type: float, display: true, formatter: Kilogram + HitPoints: 1800, type: int, display: true + UpgradeTo: null +} +{ +Block Id: 1805, Name: CockpitSV05NewT2, Ref: CockpitSV05New + Model: @models/Blocks/Cockpits/CockpitSS05NewT2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 775, type: float, display: true, formatter: Kilogram + HitPoints: 1110, type: int, display: true + UpgradeTo: null +} +{ +Block Id: 1806, Name: CockpitSV06T2, Ref: CockpitSV06 + Model: @models/Blocks/Cockpits/CockpitSS06T2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1140, type: float, display: true, formatter: Kilogram + HitPoints: 1700, type: int, display: true + UpgradeTo: null +} +{ +Block Id: 1807, Name: CockpitSV07NewT2, Ref: CockpitSV07New + Model: @models/Blocks/Cockpits/CockpitSS07NewT2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1250, type: float, display: true, formatter: Kilogram + HitPoints: 1600, type: int, display: true + UpgradeTo: null +} + +{ +Block Id: 1808, Name: ShieldGeneratorBA + Info: bkiShieldGenerator, display: true + Class: ShieldGenerator + IndexName: ShieldGenerator + EnergyDynamicGroup: Shield + MarketPrice: 165345, display: true + #Group: cpgEquipment + Group: cpgForcefieldEmitter + Material: metal + Shape: ModelEntity + ShowBlockName: true + #Model: @models/Blocks/Misc/ShieldGeneratorLargePrefab #Old T1 model; round rotating + #Model: @models/Blocks/Devices/ShieldGeneratorT0FWNPrefab #Full block type T1 + Model: @models/Blocks/Devices/ShieldGeneratorT1_v2_FWNPrefab #New T1 + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + IsActivateable: true + IsActivatedOnPlace: false + IsAccessible: true, type: bool + IsDuplicateable: false + OccupySizeInBlocks: true # do not allow to place blocks inside the bounding box + MaxCount: 1, type: int, display: true + ForceMaxCount: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,3,3", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 250 + Category: Devices + BlockColor: "110,110,110" + HitPoints: 1000, type: int, display: true + Mass: 12920, type: float, display: true, formatter: Kilogram + Volume: 4320, type: float, display: true, formatter: Liter + EnergyIn: 25000, type: int, display: true, formatter: Watt + CPUIn: 24700, type: int, display: true + EnergyInIdle: 1000, type: int, display: true, formatter: Watt + ShieldCapacity: 25000, type: int, display: true + ShieldRecharge: 150, type: int, display: true # per second + ShieldCooldown: 15, type: int, display: true # in seconds + ShieldPerCrystal: 1000, type: int, display: true + UpgradeTo: ShieldGeneratorBAT2, display: true + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + # TechTreeParent: PentaxidTank + TechTreeNames: "Base" +} +{ +Block Id: 1809, Name: ShieldGeneratorCV, Ref: ShieldGeneratorBA + #Model: @models/Blocks/Misc/ShieldGeneratorLargePrefab #Old T1 model; round rotating + #Model: @models/Blocks/Devices/ShieldGeneratorT0FWNPrefab #Full block type T1 + Model: @models/Blocks/Devices/ShieldGeneratorT1_v2_FWNPrefab #New T1 + MarketPrice: 186675, display: true + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "MS", display: true + HitPoints: 500, type: int, display: true + Mass: 12920, type: float, display: true, formatter: Kilogram + Volume: 4320, type: float, display: true, formatter: Liter + EnergyIn: 18000, type: int, display: true, formatter: Watt + CPUIn: 18100, type: int, display: true + EnergyInIdle: 800, type: int, display: true, formatter: Watt + ShieldCapacity: 18000, type: int, display: true + ShieldRecharge: 120, type: int, display: true # recharge rate per sec + UpgradeTo: ShieldGeneratorCVT2, display: true + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + # TechTreeParent: PentaxidTank + TechTreeNames: "Capital Vessel" +} +{ +Block Id: 1810, Name: ShieldGeneratorSV, Ref: ShieldGeneratorBA + Model: @models/Blocks/Misc/ShieldGeneratorSmallPrefab + MarketPrice: 22823, display: true + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,1,3", display: true + AllowPlacingAt: "SS", display: true + HitPoints: 250, type: int, display: true + Mass: 1630, type: float, display: true, formatter: Kilogram + Volume: 187.5, type: float, display: true, formatter: Liter + EnergyIn: 3600, type: int, display: true, formatter: Watt + CPUIn: 16300, type: int, display: true + EnergyInIdle: 360, type: int, display: true, formatter: Watt + ShieldCapacity: 4000, type: int, display: true + ShieldRecharge: 500, type: int, display: true # recharge rate per sec + UpgradeTo: null + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + # TechTreeParent: FuelTankSV + TechTreeNames: "Small Vessel" +} +{ +Block Id: 1811, Name: ShieldGeneratorCVT2, Ref: ShieldGeneratorCV + #Model: @models/Blocks/Misc/ShieldGeneratorLargeT2Prefab #old T2 model + #Model: @models/Blocks/Devices/ShieldGeneratorT1FWNPrefab #cubic T2 model + Model: @models/Blocks/Devices/ShieldGeneratorT0_v2_FWNPrefab #T2 (bulkier version of T1) + MarketPrice: 260796, display: true + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiShieldGeneratorT2, display: true + Mass: 14635, type: float, display: true, formatter: Kilogram + Volume: 4320, type: float, display: true, formatter: Liter + EnergyIn: 36000, type: int, display: true, formatter: Watt + CPUIn: 33100, type: int, display: true + EnergyInIdle: 1600, type: int, display: true, formatter: Watt + ShieldCapacity: 36000, type: int, display: true + ShieldRecharge: 120, type: int, display: true # recharge rate per sec + PickupTarget: ShieldGeneratorCV # Avoid getting back T2 and then be able to place T1 and T2 + UpgradeTo: null + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + TechTreeParent: ShieldGeneratorCV + TechTreeNames: "Capital Vessel" + # TechTreeNames: Hidden +} +{ +Block Id: 1812, Name: ShieldGeneratorBAT2, Ref: ShieldGeneratorBA + #Model: @models/Blocks/Misc/ShieldGeneratorLargeT2Prefab #old T2 model + #Model: @models/Blocks/Devices/ShieldGeneratorT1FWNPrefab #cubic T2 model + Model: @models/Blocks/Devices/ShieldGeneratorT0_v2_FWNPrefab #T2 (bulkier version of T1) + MarketPrice: 229245, display: true + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Info: bkiShieldGeneratorT2, display: true + Mass: 14635, type: float, display: true, formatter: Kilogram + Volume: 4320, type: float, display: true, formatter: Liter + EnergyIn: 50000, type: int, display: true, formatter: Watt + CPUIn: 45500, type: int, display: true + EnergyInIdle: 1500, type: int, display: true, formatter: Watt + ShieldCapacity: 50000, type: int, display: true + ShieldRecharge: 150, type: int, display: true # recharge rate per sec + PickupTarget: ShieldGeneratorBA # Avoid getting back T2 and then be able to place T1 and T2 + UpgradeTo: null + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + TechTreeParent: ShieldGeneratorBA + TechTreeNames: "Base" + # TechTreeNames: Hidden +} +{ +Block Id: 1813, Name: ShieldGeneratorPOI, Ref: ShieldGeneratorBA + #Model: @models/Blocks/Misc/ShieldGeneratorLargeT2Prefab #old T2 model + #Model: @models/Blocks/Devices/ShieldGeneratorT1FWNPrefab #cubic T2 model + Model: @models/Blocks/Devices/ShieldGeneratorT0_v2_FWNPrefab #T2 (bulkier version of T1) + AllowedInBlueprint: false, display: true + AllowPlacingAt: "Base,MS", display: true + PickupTarget: ShieldGeneratorBA # Avoid getting back POI shield generator + TemplateRoot: ShieldGeneratorBA + UpgradeTo: null + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeParent: "" + TechTreeNames: "" +} +{ +Block Id: 1814, Name: ContainerMS05Large, Ref: ContainerMS01 + LootList: 76 # 128 slots + Model: @models/Blocks/Mothership/ContainerMS05LargePrefab + Mass: 600, type: float, display: true, formatter: Kilogram + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + CPUIn: 960, type: int, display: true + TemplateRoot: ContainerLargeBlocks + AllowPlacingAt: "Base,MS", display: true + # IsDuplicateable: false # problem with mirror Mantis: 03845 +} + +{ +Block Id: 1815, Name: DoorSingleLArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleLPrefab + HitPoints: 210, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + MirrorTo: DoorSingleRArmored +} +{ +Block Id: 1816, Name: DoorSingleGlassLArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleGlassLPrefab + HitPoints: 190, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + MirrorTo: DoorSingleGlassRArmored +} +{ +Block Id: 1817, Name: DoorSingleGlassFullLArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleGlassFullLPrefab + HitPoints: 170, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + MirrorTo: DoorSingleGlassFullRArmored +} + +{ +Block Id: 1818, Name: BoardingRamp1x4x6, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp1x4x6Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 860, type: int, display: true + Mass: 5160, type: float, display: true, formatter: Kilogram + SizeInBlocks: "1,4,6", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} +{ +Block Id: 1819, Name: BoardingRamp2x4x6, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp2x4x6Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 1710, type: int, display: true + Mass: 10330, type: float, display: true, formatter: Kilogram + SizeInBlocks: "2,4,6", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} +{ +Block Id: 1820, Name: BoardingRamp3x4x6, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp3x4x6Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 2570, type: int, display: true + Mass: 15500, type: float, display: true, formatter: Kilogram + SizeInBlocks: "3,4,6", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} +{ +Block Id: 1821, Name: BoardingRamp5x4x6, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp5x4x6Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 4280, type: int, display: true + Mass: 25830, type: float, display: true, formatter: Kilogram + SizeInBlocks: "5,4,6", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} +{ +Block Id: 1822, Name: BoardingRamp5x4x9, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp5x4x9Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 6410, type: int, display: true + Mass: 32250, type: float, display: true, formatter: Kilogram + SizeInBlocks: "5,4,9", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} +{ +Block Id: 1823, Name: BoardingRamp8x6x12, Ref: RampTemplate + Model: @models/Blocks/Mothership/BoardingRamp/BoardingRamp8x6x12Prefab + AllowPlacingAt: "Base,MS", display: true + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + HitPoints: 13680, type: int, display: true + Mass: 65000, type: float, display: true, formatter: Kilogram + SizeInBlocks: "8,6,12", display: true + IsOxygenTight: true, display: true + Voxelize: false # need this if the ramp should be oxygen tight + TemplateRoot: BoardingRampBlocks +} + +# ============================ Extended 2 ============================ +# max32 ChildShapes reached! +# ==================================================================== +# BA / CV +{ +Block Id: 1824, Name: WoodExtended2, Ref: WoodFull + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: ConcreteExtended2, display: true +} +{ +Block Id: 1825, Name: ConcreteExtended2, Ref: ConcreteFull + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: ConcreteArmoredExtended2, display: true +} +{ +Block Id: 1826, Name: ConcreteArmoredExtended2, Ref: ConcreteArmoredFull + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" +} +{ +Block Id: 1827, Name: PlasticExtendedLarge2, Ref: PlasticFullLarge + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: HullExtendedLarge2, display: true +} +{ +Block Id: 1828, Name: HullExtendedLarge2, Ref: HullFullLarge + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: HullArmoredExtendedLarge2, display: true + DowngradeTo: PlasticExtendedLarge2, display: true +} +{ +Block Id: 1829, Name: HullArmoredExtendedLarge2, Ref: HullArmoredFullLarge + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: HullCombatExtendedLarge2, display: true +} +{ +Block Id: 1830, Name: HullCombatExtendedLarge2, Ref: HullCombatFullLarge + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" +} +{ +Block Id: 1831, Name: AlienExtended2, Ref: AlienFull + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" +} +# SV / HV +{ +Block Id: 1832, Name: PlasticExtendedSmall2, Ref: PlasticFullSmall + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: HullExtendedSmall2, display: true +} +{ +Block Id: 1833, Name: HullExtendedSmall2, Ref: HullFullSmall + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: HullArmoredExtendedSmall2, display: true + DowngradeTo: PlasticExtendedSmall2, display: true +} +{ +Block Id: 1834, Name: HullArmoredExtendedSmall2, Ref: HullArmoredFullSmall + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" + UpgradeTo: HullCombatExtendedSmall2, display: true +} +{ +Block Id: 1835, Name: HullCombatExtendedSmall2, Ref: HullCombatFullSmall + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" +} +# BA / CV +{ +Block Id: 1836, Name: AlienExtendedLarge2, Ref: AlienFullLarge + ChildShapes: "WallLShapeMedium, WallLShapeLow, RampDLow, RampE, RampCMediumQuarter, RampConnectorBleft, RampConnectorBright, CubeEighth, SlicedCornerA2, SlicedCornerA1Medium, SlicedCornerDMedium, NotchedB, BeamQuarter, WallCornerSloped, RampADoubleHalf, RampBDoubleHalf, WallUShape, WallDouble, WallSlopedCDoubleMedium, WallSlopedCDoubleLow, CorridorPillarD, CubeDummy, RampCMediumHalfright, RampCMediumHalfleft, WallSlopedCDouble, WallSlopedBDouble, WallSlopedBDoubleMedium, WallSlopedADouble, RampAHalfright, RampAHalfleft, CylinderFramed, CubeFramed" +} + +# ============================ Extended 3 ============================ +# BA / CV +{ +Block Id: 1837, Name: WoodExtended3, Ref: WoodFull + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: ConcreteExtended3, display: true +} +{ +Block Id: 1838, Name: ConcreteExtended3, Ref: ConcreteFull + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: ConcreteArmoredExtended3, display: true +} +{ +Block Id: 1839, Name: ConcreteArmoredExtended3, Ref: ConcreteArmoredFull + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" +} +{ +Block Id: 1840, Name: PlasticExtendedLarge3, Ref: PlasticFullLarge + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: HullExtendedLarge3, display: true +} +{ +Block Id: 1841, Name: HullExtendedLarge3, Ref: HullFullLarge + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: HullArmoredExtendedLarge3, display: true + DowngradeTo: PlasticExtendedLarge3, display: true +} +{ +Block Id: 1842, Name: HullArmoredExtendedLarge3, Ref: HullArmoredFullLarge + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: HullCombatExtendedLarge3, display: true +} +{ +Block Id: 1843, Name: HullCombatExtendedLarge3, Ref: HullCombatFullLarge + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" +} +{ +Block Id: 1844, Name: AlienExtended3, Ref: AlienFull + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" +} +# SV / HV +{ +Block Id: 1845, Name: PlasticExtendedSmall3, Ref: PlasticFullSmall + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: HullExtendedSmall3, display: true +} +{ +Block Id: 1846, Name: HullExtendedSmall3, Ref: HullFullSmall + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: HullArmoredExtendedSmall3, display: true + DowngradeTo: PlasticExtendedSmall3, display: true +} +{ +Block Id: 1847, Name: HullArmoredExtendedSmall3, Ref: HullArmoredFullSmall + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" + UpgradeTo: HullCombatExtendedSmall3, display: true +} +{ +Block Id: 1848, Name: HullCombatExtendedSmall3, Ref: HullCombatFullSmall + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" +} +# BA / CV +{ +Block Id: 1849, Name: AlienExtendedLarge3, Ref: AlienFullLarge + ChildShapes: "CubeSliced, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" +} +# ============================ Extended 4 ============================ +# BA / CV +{ +Block Id: 1850, Name: WoodExtended4, Ref: WoodFull + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: ConcreteExtended4, display: true +} +{ +Block Id: 1851, Name: ConcreteExtended4, Ref: ConcreteFull + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: ConcreteArmoredExtended4, display: true +} +{ +Block Id: 1852, Name: ConcreteArmoredExtended4, Ref: ConcreteArmoredFull + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" +} +{ +Block Id: 1853, Name: PlasticExtendedLarge4, Ref: PlasticFullLarge + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: HullExtendedLarge4, display: true +} +{ +Block Id: 1854, Name: HullExtendedLarge4, Ref: HullFullLarge + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: HullArmoredExtendedLarge4, display: true + DowngradeTo: PlasticExtendedLarge4, display: true +} +{ +Block Id: 1855, Name: HullArmoredExtendedLarge4, Ref: HullArmoredFullLarge + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: HullCombatExtendedLarge4, display: true +} +{ +Block Id: 1856, Name: HullCombatExtendedLarge4, Ref: HullCombatFullLarge + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" +} +{ +Block Id: 1857, Name: AlienExtended4, Ref: AlienFull + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" +} +# SV / HV +{ +Block Id: 1858, Name: PlasticExtendedSmall4, Ref: PlasticFullSmall + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: HullExtendedSmall4, display: true +} +{ +Block Id: 1859, Name: HullExtendedSmall4, Ref: HullFullSmall + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: HullArmoredExtendedSmall4, display: true + DowngradeTo: PlasticExtendedSmall4, display: true +} +{ +Block Id: 1860, Name: HullArmoredExtendedSmall4, Ref: HullArmoredFullSmall + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" + UpgradeTo: HullCombatExtendedSmall4, display: true +} +{ +Block Id: 1861, Name: HullCombatExtendedSmall4, Ref: HullCombatFullSmall + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" +} +# BA / CV +{ +Block Id: 1862, Name: AlienExtendedLarge4, Ref: AlienFullLarge + ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" +} +# ============================ Extended 5 ============================ +# max32 ChildShapes reached! +# ================================= +# BA / CV +{ +Block Id: 1863, Name: WoodExtended5, Ref: WoodFull + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: ConcreteExtended5, display: true +} +{ +Block Id: 1864, Name: ConcreteExtended5, Ref: ConcreteFull + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: ConcreteArmoredExtended5, display: true +} +{ +Block Id: 1865, Name: ConcreteArmoredExtended5, Ref: ConcreteArmoredFull + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" +} +{ +Block Id: 1866, Name: PlasticExtendedLarge5, Ref: PlasticFullLarge + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: HullExtendedLarge5, display: true +} +{ +Block Id: 1867, Name: HullExtendedLarge5, Ref: HullFullLarge + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: HullArmoredExtendedLarge5, display: true + DowngradeTo: PlasticExtendedLarge5, display: true +} +{ +Block Id: 1868, Name: HullArmoredExtendedLarge5, Ref: HullArmoredFullLarge + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: HullCombatExtendedLarge5, display: true +} +{ +Block Id: 1869, Name: HullCombatExtendedLarge5, Ref: HullCombatFullLarge + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" +} +{ +Block Id: 1870, Name: AlienExtended5, Ref: AlienFull + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" +} +# SV / HV +{ +Block Id: 1871, Name: PlasticExtendedSmall5, Ref: PlasticFullSmall + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: HullExtendedSmall5, display: true +} +{ +Block Id: 1872, Name: HullExtendedSmall5, Ref: HullFullSmall + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: HullArmoredExtendedSmall5, display: true + DowngradeTo: PlasticExtendedSmall5, display: true +} +{ +Block Id: 1873, Name: HullArmoredExtendedSmall5, Ref: HullArmoredFullSmall + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" + UpgradeTo: HullCombatExtendedSmall5, display: true +} +{ +Block Id: 1874, Name: HullCombatExtendedSmall5, Ref: HullCombatFullSmall + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" +} +# BA / CV +{ +Block Id: 1875, Name: AlienExtendedLarge5, Ref: AlienFullLarge + ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" +} + +{ +Block Id: 1876, Name: DrillAttachmentLarge, Ref: DrillAttachment + Model: @models/Blocks/WeaponsShip/DrillAttachmentLargePrefab + MarketPrice: 2710, display: true + IsRetractable: false, display: true + WeaponItem: DrillAttachmentLargeWeapon + SizeInBlocks: "3,8,3", display: true + MaxCount: 2, type: int, display: true + CPUIn: 1200, type: int, display: true + Mass: 2600, type: float, display: true, formatter: Kilogram + Volume: 843.75, type: float, display: true, formatter: Liter + StackSize: 500 + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: DrillAttachment + TechTreeNames: Hover Vessel +} + +{ +Block Id: 1877, Name: Antenna06, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna06Prefab + SizeInBlocks: "1,5,1", display: true +} +{ +Block Id: 1878, Name: Antenna07, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna07Prefab + SizeInBlocks: "1,5,1", display: true +} +{ +Block Id: 1879, Name: Antenna08, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna08Prefab + SizeInBlocks: "1,8,1", display: true +} +{ +Block Id: 1880, Name: Antenna09, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna09Prefab + SizeInBlocks: "1,5,1", display: true +} +{ +Block Id: 1881, Name: Antenna10, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna10Prefab + SizeInBlocks: "1,8,1", display: true +} +{ +Block Id: 1882, Name: Antenna11, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna11Prefab + SizeInBlocks: "1,5,1", display: true +} +{ +Block Id: 1883, Name: Antenna12, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna12Prefab + SizeInBlocks: "1,5,1", display: true +} +{ +Block Id: 1884, Name: Antenna13, Ref: Antenna + Model: @models/Blocks/Mothership/Antenna13Prefab + SizeInBlocks: "1,5,1", display: true +} +{ +Block Id: 1885, Name: DoorSingleRArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleRPrefab + HitPoints: 210, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + MirrorTo: DoorSingleLArmored +} +{ +Block Id: 1886, Name: DoorSingleGlassRArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleGlassRPrefab + HitPoints: 190, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + MirrorTo: DoorSingleGlassLArmored +} +{ +Block Id: 1887, Name: DoorSingleGlassFullRArmored, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleGlassFullRPrefab + HitPoints: 170, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + MirrorTo: DoorSingleGlassFullLArmored +} +{ +Block Id: 1888, Name: ShieldGeneratorHV, Ref: ShieldGeneratorBA + Model: @models/Blocks/Misc/ShieldGeneratorSmallPrefab + MarketPrice: 17755, display: true + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,1,3", display: true + AllowPlacingAt: "GV", display: true + HitPoints: 250, type: int, display: true + Mass: 1630, type: float, display: true, formatter: Kilogram + Volume: 187.5, type: float, display: true, formatter: Liter + EnergyIn: 3600, type: int, display: true, formatter: Watt + CPUIn: 15400, type: int, display: true + EnergyInIdle: 360, type: int, display: true, formatter: Watt + ShieldCapacity: 6000, type: int, display: true + ShieldRecharge: 400, type: int, display: true # recharge rate per sec + UpgradeTo: null + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + # TechTreeParent: FuelTankSV + TechTreeNames: Hover Vessel +} +{ +Block Id: 1889, Name: HangarDoor10x7, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor10x7Prefab + SizeInBlocks: "10,7,1", display: true + HitPoints: 12020, type: int, display: true + Mass: 11430, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1890, Name: HangarDoor5x4, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor5x4Prefab + SizeInBlocks: "5,4,1", display: true + HitPoints: 3440, type: int, display: true + Mass: 9240, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1891, Name: HangarDoor6x5, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor6x5Prefab + SizeInBlocks: "6,5,1", display: true + HitPoints: 5150, type: int, display: true + Mass: 9960, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1892, Name: HangarDoor7x6, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor7x6Prefab + HitPoints: 7210, type: int, display: true + Mass: 10530, type: float, display: true, formatter: Kilogram + SizeInBlocks: "7,6,1", display: true +} +{ +Block Id: 1893, Name: HangarDoor9x7, Ref: HangarDoor10x5 + Model: @models/Blocks/Mothership/HangarDoor/HangarDoor9x7Prefab + HitPoints: 10820, type: int, display: true + Mass: 11250, type: float, display: true, formatter: Kilogram + SizeInBlocks: "9,7,1", display: true +} +{ +Block Id: 1894, Name: ShutterDoor1x3, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor1x3Prefab + SizeInBlocks: "1,3,1", display: true + HitPoints: 360, type: int, display: true + Mass: 510, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1895, Name: ShutterDoor1x4, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor1x4Prefab + SizeInBlocks: "1,4,1", display: true + HitPoints: 480, type: int, display: true + Mass: 660, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1896, Name: ShutterDoor4x3, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor4x3Prefab + SizeInBlocks: "4,3,1", display: true + HitPoints: 1430, type: int, display: true + Mass: 2330, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1897, Name: ShutterDoor1x5, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor1x5Prefab + SizeInBlocks: "1,5,1", display: true + HitPoints: 600, type: int, display: true + Mass: 810, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1898, Name: ShutterDoor5x3, Ref: ShutterDoor1x1 + TemplateRoot: ShutterDoorLargeBlocks + Model: @models/Blocks/Mothership/ShutterDoor/ShutterDoor5x3Prefab + SizeInBlocks: "5,3,1", display: true + HitPoints: 1790, type: int, display: true + Mass: 2680, type: float, display: true, formatter: Kilogram +} +{ +Block Id: 1899, Name: Ramp1x4x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x4x1Prefab + SizeInBlocks: "1,4,1", display: true + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1900, Name: Ramp3x4x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x4x1Prefab + SizeInBlocks: "3,4,1", display: true + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1901, Name: Ramp1x5x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x5x1Prefab + SizeInBlocks: "1,5,1", display: true + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1902, Name: Ramp3x5x1, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x5x1Prefab + SizeInBlocks: "3,5,1", display: true + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1903, Name: Ramp1x5x2, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x5x2Prefab + SizeInBlocks: "1,5,2", display: true + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1904, Name: Ramp3x5x2, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x5x2Prefab + SizeInBlocks: "3,5,2", display: true + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1905, Name: Ramp1x5x3, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x5x3Prefab + SizeInBlocks: "1,5,3", display: true + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Id: 1906, Name: HeavyWindowK, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowKPrefab + SymType: 1 +} +{ +Block Id: 1907, Name: HeavyWindowKInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowKInvPrefab + SymType: 1 +} +{ +Block Id: 1908, Name: HeavyWindowL, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowLPrefab + SymType: 1 +} +{ +Block Id: 1909, Name: HeavyWindowLInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowLInvPrefab + SymType: 1 +} +{ +Block Id: 1910, Name: HeavyWindowM, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowMPrefab + SymType: 1 +} +{ +Block Id: 1911, Name: HeavyWindowMInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowMInvPrefab + SymType: 1 +} +{ +Block Id: 1912, Name: HeavyWindowN, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowNPrefab + SymType: 1 +} +{ +Block Id: 1913, Name: HeavyWindowNInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowNInvPrefab + SymType: 1 +} +{ +Block Id: 1914, Name: HeavyWindowO, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowOPrefab + SymType: 1 +} +{ +Block Id: 1915, Name: HeavyWindowOInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowOInvPrefab + SymType: 1 +} +{ +Block Id: 1916, Name: HeavyWindowP, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPPrefab + SymType: 1 +} +{ +Block Id: 1917, Name: HeavyWindowPInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPInvPrefab + SymType: 1 +} +{ +Block Id: 1918, Name: HeavyWindowQ, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQPrefab + SymType: 1 +} +{ +Block Id: 1919, Name: HeavyWindowQInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQInvPrefab + SymType: 1 +} +{ +Block Id: 1920, Name: HeavyWindowV, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowVPrefab + SymType: 2 +} +{ +Block Id: 1921, Name: HeavyWindowVInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowVInvPrefab + SymType: 2 +} +{ +Block Id: 1922, Name: HeavyWindowS, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowSPrefab + SymType: 2 +} +{ +Block Id: 1923, Name: HeavyWindowSInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowSInvPrefab + SymType: 2 +} +{ +Block Id: 1924, Name: HeavyWindowT, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowTPrefab + SymType: 2 +} +{ +Block Id: 1925, Name: HeavyWindowTInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowTInvPrefab + SymType: 2 +} +{ +Block Id: 1926, Name: HeavyWindowU, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowUPrefab + SymType: 2 +} +{ +Block Id: 1927, Name: HeavyWindowUInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowUInvPrefab + SymType: 2 +} +# Connect windows + low tint +{ +Block Id: 1928, Name: HeavyWindowPConnectLeft, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPConnectLeftPrefab + MirrorTo: HeavyWindowPConnectRight +} +{ +Block Id: 1929, Name: HeavyWindowPConnectTwo, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPConnectTwoPrefab + SymType: 1 +} +{ +Block Id: 1930, Name: HeavyWindowQConnect, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectPrefab + SymType: 2 +} +{ +Block Id: 1931, Name: HeavyWindowQConnectTwo, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectTwoPrefab + SymType: 1 +} +{ +Block Id: 1932, Name: HeavyWindowQConnectThree, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectThreePrefab + SymType: 2 +} +{ +Block Id: 1933, Name: HeavyWindowSConnect, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowSConnectPrefab + SymType: 2 +} +{ +Block Id: 1934, Name: HeavyWindowTConnect, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowTConnectPrefab + SymType: 2 +} +{ +Block Id: 1935, Name: HeavyWindowUConnect, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowUConnectPrefab + SymType: 2 +} +{ +Block Id: 1936, Name: HeavyWindowPConnectLeftInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPConnectLeftInvPrefab + MirrorTo: HeavyWindowPConnectRightInv +} +{ +Block Id: 1937, Name: HeavyWindowPConnectTwoInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPConnectTwoInvPrefab + SymType: 1 +} +{ +Block Id: 1938, Name: HeavyWindowQConnectInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectInvPrefab + SymType: 2 +} +{ +Block Id: 1939, Name: HeavyWindowQConnectTwoInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectTwoInvPrefab + SymType: 1 +} +{ +Block Id: 1940, Name: HeavyWindowQConnectThreeInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectThreeInvPrefab + SymType: 2 +} +{ +Block Id: 1941, Name: HeavyWindowSConnectInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowSConnectInvPrefab + SymType: 2 +} +{ +Block Id: 1942, Name: HeavyWindowTConnectInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowTConnectInvPrefab + SymType: 2 +} +{ +Block Id: 1943, Name: HeavyWindowUConnectInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowUConnectInvPrefab + SymType: 2 +} +{ +Block Id: 1944, Name: HeavyWindowQConnectFourInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectFourInvPrefab + SymType: 2 +} +{ +Block Id: 1945, Name: HeavyWindowPConnectRight, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPConnectRightPrefab + MirrorTo: HeavyWindowPConnectLeft +} +{ +Block Id: 1946, Name: HeavyWindowPConnectRightInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowPConnectRightInvPrefab + MirrorTo: HeavyWindowPConnectLeftInv +} +{ +Block Id: 1947, Name: HeavyWindowVConnect, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowVConnectPrefab + SymType: 2 +} +{ +Block Id: 1948, Name: HeavyWindowVConnectInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowVConnectInvPrefab + SymType: 2 +} +{ +Block Id: 1949, Name: HeavyWindowQConnectFour, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectFourPrefab + SymType: 2 +} +{ +Block Id: 1950, Name: CockpitSV03New, Ref: CockpitSV03 + Model: @models/Blocks/Cockpits/CockpitSS03NewPrefab + TemplateRoot: CockpitBlocksSV + Mass: 845, type: float, display: true, formatter: Kilogram + HitPoints: 900, type: int, display: true + UpgradeTo: CockpitSV03NewT2, display: true +} +{ +Block Id: 1951, Name: CockpitSV03NewT2, Ref: CockpitSV03New + Model: @models/Blocks/Cockpits/CockpitSS03NewT2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1390, type: float, display: true, formatter: Kilogram + HitPoints: 1800, type: int, display: true + UpgradeTo: null +} +{ +Block Id: 1952, Name: SVDecoIntake02New, Ref: SVDecoAeroblister01 + Model: @models/Blocks/SpaceshipParts/SVDecoIntake02Prefab + SizeInBlocks: "1,1,2", display: true +} +{ +Block Id: 1953, Name: DoorSingleTwoWings, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleTwoWingsPrefab + HitPoints: 410, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + SizeInBlocks: "2,1,1", display: true +} +{ +Block Id: 1954, Name: DoorSingleTwoWingsGlass, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleTwoWingsGlassPrefab + HitPoints: 380, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + SizeInBlocks: "2,1,1", display: true +} +{ +Block Id: 1955, Name: DoorSingleTwoWingsGlassFull, Ref: DoorArmored + Model: @models/Blocks/Mothership/DoorSingleTwoWingsGlassFullPrefab + HitPoints: 340, type: int, display: false + Mass: 900, type: float, display: true, formatter: Kilogram + SizeInBlocks: "2,1,1", display: true +} +{ +Block Id: 1956, Name: VentilatorBlocks + Material: hull + Shape: Invisible + MarketPrice: 210, display: true + # Model: @models/Blocks/Mothership/VentilatorCubePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "Base,MS,SS,GV", display: true + StackSize: 1000 + BlockColor: "110,110,110" + IsOxygenTight: true, display: true + Info: bkiVentilator, display: true + Volume: 31.25, type: float, display: true, formatter: Liter + EnergyIn: 20, type: int, display: true, formatter: Watt + Category: Devices + ChildBlocks: "Ventilator, VentilatorCubeHalf, VentilatorThin, VentilatorRampC, VentilatorCubeQuarter, VentilatorCylinder, VentilatorCylinderThin, VentilatorEdgeRoundLowHalf, VentilatorEdgeRoundMediumHalf, VentilatorEdgeRound, VentilatorRampD" + + UnlockCost: 3, display: true + UnlockLevel: 3, display: true + # TechTreeparent: Core + TechTreeNames: Misc +} +{ +Block Id: 1957, Name: VentilatorCubeHalf, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorCubeHalfPrefab + HitPoints: 30, type: int, display: false + Mass: 25, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt +} +{ +Block Id: 1958, Name: VentilatorRampC, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorRampCPrefab + HitPoints: 30, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + SymType: 4 + # ShowUser: No +} + +{ Block Id: 1959, Name: HeavyWindowQConnectTwoDiagonalMirror, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectTwoDiagonalMirrorPrefab + MirrorTo: HeavyWindowQConnectTwoDiagonal + ShowUser: No +} + +{ +Block Id: 1960, Name: VentilatorCubeQuarter, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorCubeQuarterPrefab + HitPoints: 30, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + SymType: 4 + # ShowUser: No +} +{ +Block Id: 1961, Name: VentilatorCylinder, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorCylinderPrefab + HitPoints: 30, type: int, display: false + Mass: 40, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + # ShowUser: No +} +{ +Block Id: 1962, Name: VentilatorCylinderThin, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorCylinderThinPrefab + HitPoints: 30, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + # ShowUser: No +} +{ +Block Id: 1963, Name: VentilatorEdgeRoundLowHalf, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorEdgeRoundLowHalfPrefab + HitPoints: 30, type: int, display: false + Mass: 30, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + SymType: 4 +# ShowUser: No +} +{ +Block Id: 1964, Name: VentilatorEdgeRoundMediumHalf, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorEdgeRoundMediumHalfPrefab + HitPoints: 30, type: int, display: false + Mass: 20, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + SymType: 4 + # ShowUser: No +} +{ +Block Id: 1965, Name: VentilatorEdgeRound, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorEdgeRoundPrefab + HitPoints: 30, type: int, display: false + Mass: 10, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + SymType: 4 + # ShowUser: No +} +{ +Block Id: 1966, Name: VentilatorRampD, Ref: Ventilator + Model: @models/Blocks/Mothership/VentilatorRampDPrefab + HitPoints: 30, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + EnergyIn: 20, type: int, display: true, formatter: Watt + SymType: 4 + #ShowUser: No +} + +## ========== Double Railings ======= + +{ +Block Id: 1967, Name: RailingDoubleVert, Ref: RailingDiagonal + Model: Mothership/RailingDoubleVert, param1: RailingDoubleVert + SymType: 1 +} +{ +Block Id: 1968, Name: RailingDoubleVertGlass, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingDoubleVertGlassPrefab + SymType: 1 +} +{ +Block Id: 1969, Name: RailingDoubleVertMetal + Material: hull + Shape: ModelEntity + Model: @models/Blocks/Windows/Railing/RailingDoubleVertMetalPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + IsAccessible: false, type: bool + Category: BuildingBlocks + HitPoints: 25, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + AllowPlacingAt: "Base,MS,SS,GV", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Collide: Transparent + ParentBlocks: "WalkwayLargeBlocks" + SymType: 1 +} +{ +Block Id: 1970, Name: RailingDoubleVertGlassModular, Ref: RailingDoubleVertMetal + Model: @models/Blocks/Windows/Railing/RailingDoubleVertGlassModularPrefab + SymType: 1 +} + +# ============= RAILINGS ================= + + { +Block Id: 1971, Name: RailingDiagonalGlassModular + Material: hull + Shape: ModelEntity + Model: @models/Blocks/Windows/Railing/RailingDiagonalGlassModularPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + IsAccessible: false, type: bool + Category: BuildingBlocks + HitPoints: 25, type: int, display: false + Mass: 15, type: float, display: true, formatter: Kilogram + AllowPlacingAt: "Base,MS,SS,GV", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Collide: Transparent + ParentBlocks: "WalkwayLargeBlocks" + # ShowUser: No + SymType: 2 + } + { +Block Id: 1972, Name: RailingDiagonalMetal, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingDiagonalMetalPrefab + SymType: 2 + } + { +Block Id: 1973, Name: RailingLGlassModular, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingLGlassModularPrefab + SymType: 2 + } + { +Block Id: 1974, Name: RailingLMetal, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingLMetalPrefab + SymType: 2 + } + { +Block Id: 1975, Name: RailingRoundGlassModular, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingRoundGlassModularPrefab + SymType: 2 + } + { +Block Id: 1976, Name: RailingRoundMetal, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingRoundMetalPrefab + SymType: 2 + } + { +Block Id: 1977, Name: RailingSlopeGlassLeftModular, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingSlopeGlassLeftPrefab + SymType: 4 + MirrorTo: RailingSlopeGlassRightModular + } + { +Block Id: 1978, Name: RailingSlopeMetalLeft, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingSlopeMetalLeftPrefab + SymType: 4 + MirrorTo: RailingSlopeMetalRight + } + { +Block Id: 1979, Name: RailingSlopeGlassRightModular, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingSlopeGlassRightPrefab + SymType: 4 + MirrorTo: RailingSlopeGlassLeftModular + } + { +Block Id: 1980, Name: RailingSlopeMetalRight, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingSlopeMetalRightPrefab + SymType: 4 + MirrorTo: RailingSlopeMetalLeft + } + { +Block Id: 1981, Name: RailingVertGlassModular, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingVertGlassModularPrefab + SymType: 1 + } + { +Block Id: 1982, Name: RailingVertMetal, Ref: RailingDiagonalGlassModular + Model: @models/Blocks/Windows/Railing/RailingVertMetalPrefab + SymType: 1 + } + +## ============= Heavy Window full glass and bottom socket ===== + + { +Block Id: 1983, Name: HeavyWindowEBottom, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowEBottomPrefab + SymType: 2 + } + { Block Id: 1984, Name: HeavyWindowEBottomInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowEBottomInvPrefab + SymType: 2 + } + { +Block Id: 1985, Name: HeavyWindowFBottom, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowFBottomPrefab + SymType: 2 + } + { Block Id: 1986, Name: HeavyWindowFBottomInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowFBottomInvPrefab + SymType: 2 + } + { +Block Id: 1987, Name: HeavyWindowGBottom, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowGBottomPrefab + SymType: 2 + } + { Block Id: 1988, Name: HeavyWindowGBottomInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowGBottomInvPrefab + SymType: 2 + } + { +Block Id: 1989, Name: HeavyWindowHBottom, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowHBottomPrefab + SymType: 2 + } + { Block Id: 1990, Name: HeavyWindowHBottomInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowHBottomInvPrefab + SymType: 2 + } + { +Block Id: 1991, Name: HeavyWindowIBottom, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowIBottomPrefab + SymType: 2 + } + { Block Id: 1992, Name: HeavyWindowIBottomInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowIBottomInvPrefab + SymType: 2 + } + { +Block Id: 1993, Name: HeavyWindowJBottom, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowJBottomPrefab + SymType: 2 + } + { Block Id: 1994, Name: HeavyWindowJBottomInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowJBottomInvPrefab + SymType: 2 + } + { +Block Id: 1995, Name: HeavyWindowEGlass, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowEGlassPrefab + SymType: 2 + } + { Block Id: 1996, Name: HeavyWindowEGlassInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowEGlassInvPrefab + SymType: 2 + } + { +Block Id: 1997, Name: HeavyWindowFGlass, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowFGlassPrefab + SymType: 2 + } + { Block Id: 1998, Name: HeavyWindowFGlassInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowFGlassInvPrefab + SymType: 2 + } + { +Block Id: 1999, Name: HeavyWindowGGlass, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowGGlassPrefab + SymType: 2 + } + { Block Id: 2000, Name: HeavyWindowGGlassInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowGGlassInvPrefab + SymType: 2 + } + { +Block Id: 2001, Name: HeavyWindowHGlass, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowHGlassPrefab + SymType: 2 + } + { Block Id: 2002, Name: HeavyWindowHGlassInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowHGlassInvPrefab + SymType: 2 + } + { +Block Id: 2003, Name: HeavyWindowIGlass, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowIGlassPrefab + SymType: 2 + } + { Block Id: 2004, Name: HeavyWindowIGlassInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowIGlassInvPrefab + SymType: 2 + } + { +Block Id: 2005, Name: HeavyWindowJGlass, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowJGlassPrefab + SymType: 2 + } + { Block Id: 2006, Name: HeavyWindowJGlassInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowJGlassInvPrefab + SymType: 2 + } + { +Block Id: 2007, Name: HeavyWindowQConnectTwoDiagonal, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectTwoDiagonalPrefab + MirrorTo: HeavyWindowQConnectTwoDiagonalMirror + } + { Block Id: 2008, Name: HeavyWindowQConnectTwoDiagonalInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectTwoDiagonalInvPrefab + MirrorTo: HeavyWindowQConnectTwoDiagonalMirrorInv + } + +# ============== ARMORED DOORS Round Vertical Centered Variants ======= + +{ +Block Id: 2009, Name: DoorCenteredArmored01 + Model: @models/Blocks/Mothership/DoorCenteredArmored01Prefab + Class: Door + IndexName: Door + Group: cpgDoor + Material: metalhard + Shape: ModelEntity + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: DoorArmoredBlocks + BlockColor: "110,110,110" + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 250, type: float, display: true, formatter: Liter + HitPoints: 220, type: int, display: false + Mass: 1600, type: float, display: true, formatter: Kilogram + SymType: 1 + ShowUser: No +} +{ +Block Id: 2010, Name: DoorCenteredArmored02, Ref: DoorCenteredArmored01 + Model: @models/Blocks/Mothership/DoorCenteredArmored02Prefab + HitPoints: 220, type: int, display: false + Mass: 1600, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + SymType: 1 +} +{ +Block Id: 2011, Name: DoorRoundArmored01, Ref: DoorCenteredArmored01 + Model: @models/Blocks/Mothership/DoorRoundArmored01Prefab + Mass: 900, type: float, display: true, formatter: Kilogram + HitPoints: 220, type: int, display: true + SymType: 2 +} +{ +Block Id: 2012, Name: DoorRoundArmored02, Ref: DoorCenteredArmored01 + Model: @models/Blocks/Mothership/DoorRoundArmored02Prefab + HitPoints: 220, type: int, display: false + Mass: 1000, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + SymType: 2 +} +{ +Block Id: 2013, Name: DoorCornerArmored01, Ref: DoorCenteredArmored01 + Model: @models/Blocks/Mothership/DoorCornerArmored01Prefab + HitPoints: 220, type: int, display: false + Mass: 1000, type: float, display: true, formatter: Kilogram + SymType: 2 +} +{ +Block Id: 2014, Name: DoorCornerArmored02, Ref: DoorCenteredArmored01 + Model: @models/Blocks/Mothership/DoorCornerArmored02Prefab + HitPoints: 220, type: int, display: false + Mass: 700, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + SymType: 2 +} + +# ============== DOORS Round Vertical Centered Variants ======= + +{ +Block Id: 2015, Name: DoorCentered01 + Class: Door + IndexName: Door + Group: cpgDoor + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Mothership/DoorCentered01Prefab + + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: DoorBlocks + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Volume: 250, type: float, display: true, formatter: Liter + Mass: 300, type: float, display: true, formatter: Kilogram + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: true, display: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + SoundOpen: Items/automaticdoor + SoundClose: Items/automaticdoor + SymType: 1 + UpgradeTo: DoorCenteredArmored01, display: true + # ShowUser: No +} +{ +Block Id: 2016, Name: DoorCentered02, Ref: DoorCentered01 + Model: @models/Blocks/Mothership/DoorCentered02Prefab + HitPoints: 220, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + SymType: 1 + UpgradeTo: DoorCenteredArmored02, display: true +} +{ +Block Id: 2017, Name: DoorCorner01, Ref: DoorCentered01 + Model: @models/Blocks/Mothership/DoorCorner01Prefab + HitPoints: 220, type: int, display: false + Mass: 350, type: float, display: true, formatter: Kilogram + SymType: 2 + UpgradeTo: DoorCornerArmored01, display: true +} +{ +Block Id: 2018, Name: DoorCorner02, Ref: DoorCentered01 + Model: @models/Blocks/Mothership/DoorCorner02Prefab + HitPoints: 220, type: int, display: false + Mass: 350, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + SymType: 2 + UpgradeTo: DoorCornerArmored02, display: true +} +{ +Block Id: 2019, Name: DoorRound01, Ref: DoorCentered01 + Model: @models/Blocks/Mothership/DoorRound01Prefab + HitPoints: 220, type: int, display: false + Mass: 350, type: float, display: true, formatter: Kilogram + SymType: 2 + UpgradeTo: DoorRoundArmored01, display: true +} +{ +Block Id: 2020, Name: DoorRound02, Ref: DoorCentered01 + Model: @models/Blocks/Mothership/DoorRound02Prefab + HitPoints: 220, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + SymType: 2 + UpgradeTo: DoorRoundArmored02, display: true +} +{ Block Id: 2021, Name: RailingDoubleVertGlassInv, Ref: RailingVertGlass + Model: @models/Blocks/Windows/Railing/RailingDoubleVertGlassInvPrefab + SymType: 1 + ShowUser: NoButCSV +} + +{ Block Id: 2022, Name: HeavyWindowQConnectTwoDiagonalMirrorInv, Ref: HeavyWindowA + Model: @models/Blocks/Windows/Heavy/HeavyWindowQConnectTwoDiagonalMirrorInvPrefab + MirrorTo: HeavyWindowQConnectTwoDiagonalInv + ShowUser: No +} + +## ======= ID 2023-2034 NEW Core Extender Blocks ========= + +{ +Block Id: 2023, Name: CPUExtenderHVT2 + # Class: CPUExtender + Group: cpgCore + IndexName: CPUExtender + # EssentialCategory: cpgCPUExtender + Material: metal + MarketPrice: 4048, display: true + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderSmallT2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Faction: Player + Place: Free + AllowPlacingAt: "GV", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 250, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + StackSize: 1000 + EnergyIn: 5, type: int, display: true, formatter: Watt + # CPUOut: 3000, type: int, display: true + CPUExtenderLevel: 2, type: int, display: true + HitPoints: 200, type: int, display: true + AllowWander: true + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + TechTreeNames: Hover Vessel + TechTreeParent: Core + + # ShowUser: No +} +{ +Block Id: 2024, Name: CPUExtenderHVT3 + # Class: CPUExtender + Group: cpgCore + MarketPrice: 59618, display: true + IndexName: CPUExtender + # EssentialCategory: cpgCPUExtender + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderSmallT3Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,1,1", display: true + Faction: Player + Place: Free + AllowPlacingAt: "GV", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 2, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 500, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + StackSize: 500 + EnergyIn: 7, type: int, display: true, formatter: Watt + # CPUOut: 3000, type: int, display: true + CPUExtenderLevel: 3, type: int, display: true + HitPoints: 300, type: int, display: true + AllowWander: true + UpgradeTo: CPUExtenderHVT4, display: true + + UnlockCost: 12, display: true + UnlockLevel: 12, display: true + TechTreeNames: Hover Vessel + TechTreeParent: CPUExtenderHVT2 + + # ShowUser: No +} + +{ +Block Id: 2025, Name: CPUExtenderHVT4, Ref: CPUExtenderHVT3 + Model: @models/Blocks/Misc/CPUExtenderSmallT4Prefab + SizeInBlocks: "2,1,1", display: true + MarketPrice: 155390, display: true + Mass: 750, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + MaxCount: 4, type: int, display: true + ForceMaxCount: true + EnergyIn: 10, type: int, display: true, formatter: Watt + # CPUOut: 3000, type: int, display: true + CPUExtenderLevel: 4, type: int, display: true + HitPoints: 330, type: int, display: true + + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeNames: Hover Vessel + TechTreeParent: CPUExtenderHVT3 + UpgradeTo: null, display: false # set when this this block ref's a block that upgrades to this block + + # ShowUser: No +} +{ +Block Id: 2026, Name: CPUExtenderSVT2 + # Class: CPUExtender + Group: cpgCore + IndexName: CPUExtender + MarketPrice: 6020, display: true + # EssentialCategory: cpgCPUExtender + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderSmallT2Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Faction: Player + Place: Free + AllowPlacingAt: "SS", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 250, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + StackSize: 1000 + EnergyIn: 5, type: int, display: true, formatter: Watt + # CPUOut: 6000, type: int, display: true + CPUExtenderLevel: 2, type: int, display: true + HitPoints: 200, type: int, display: true + AllowWander: true + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + TechTreeNames: Small Vessel + TechTreeParent: Core + + # ShowUser: No +} +{ +Block Id: 2027, Name: CPUExtenderSVT3 + # Class: CPUExtender + Group: cpgCore + MarketPrice: 144180, display: true + IndexName: CPUExtender + # EssentialCategory: cpgCPUExtender + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderSmallT3Prefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,1,1", display: true + Faction: Player + Place: Free + AllowPlacingAt: "SS", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 2, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 500, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + StackSize: 500 + EnergyIn: 7, type: int, display: true, formatter: Watt + # CPUOut: 6000, type: int, display: true + CPUExtenderLevel: 3, type: int, display: true + HitPoints: 300, type: int, display: true + AllowWander: true + UpgradeTo: CPUExtenderSVT4, display: true + + UnlockCost: 12, display: true + UnlockLevel: 12, display: true + TechTreeNames: Small Vessel + TechTreeParent: CPUExtenderSVT2 + + # ShowUser: No +} + +{ +Block Id: 2028, Name: CPUExtenderSVT4, Ref: CPUExtenderSVT3 + Model: @models/Blocks/Misc/CPUExtenderSmallT4Prefab + SizeInBlocks: "2,1,1", display: true + MarketPrice: 267950, display: true + Mass: 750, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + MaxCount: 4, type: int, display: true + ForceMaxCount: true + EnergyIn: 10, type: int, display: true, formatter: Watt + # CPUOut: 6000, type: int, display: true + CPUExtenderLevel: 4, type: int, display: true + HitPoints: 330, type: int, display: true + + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeNames: Small Vessel + TechTreeParent: CPUExtenderSVT3 + UpgradeTo: null, display: false # set when this this block ref's a block that upgrades to this block + + # ShowUser: No +} +{ +Block Id: 2029, Name: CPUExtenderCVT2 + MarketPrice: 12750, display: true + # Class: CPUExtender + Group: cpgCore + IndexName: CPUExtender + # EssentialCategory: cpgCPUExtender + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderAPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Faction: Player + Place: Free + AllowPlacingAt: "MS", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 1075, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + StackSize: 500 + EnergyIn: 50, type: int, display: true, formatter: Watt + # CPUOut: 200000, type: int, display: true + CPUExtenderLevel: 2, type: int, display: true + HitPoints: 650, type: int, display: true + AllowWander: true + + UnlockCost: 10, display: true + UnlockLevel: 10, display: true + TechTreeNames: Capital Vessel + TechTreeParent: Core + + # ShowUser: No +} +{ +Block Id: 2030, Name: CPUExtenderCVT3 + # Class: CPUExtender + MarketPrice: 543600, display: true + Group: cpgCore + IndexName: CPUExtender + # EssentialCategory: cpgCPUExtender + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderBPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,1,1", display: true + Faction: Player + Place: Free + AllowPlacingAt: "MS", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 2, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 2150, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + StackSize: 500 + EnergyIn: 85, type: int, display: true, formatter: Watt + # CPUOut: 200000, type: int, display: true + CPUExtenderLevel: 3, type: int, display: true + HitPoints: 900, type: int, display: true + AllowWander: true + UpgradeTo: CPUExtenderCVT4, display: true + + UnlockCost: 15, display: true + UnlockLevel: 15, display: true + TechTreeNames: Capital Vessel + TechTreeParent: CPUExtenderCVT2 + + # ShowUser: No +} + +{ +Block Id: 2031, Name: CPUExtenderCVT4, Ref: CPUExtenderCVT3 + Model: @models/Blocks/Misc/CPUExtenderCPrefab + SizeInBlocks: "2,1,1", display: true + MarketPrice: 1074250, display: true + Mass: 3225, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + MaxCount: 4, type: int, display: true + ForceMaxCount: true + EnergyIn: 100, type: int, display: true, formatter: Watt + # CPUOut: 200000, type: int, display: true + CPUExtenderLevel: 4, type: int, display: true + HitPoints: 950, type: int, display: true + + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeNames: Capital Vessel + TechTreeParent: CPUExtenderCVT3 + UpgradeTo: null, display: false # set when this this block ref's a block that upgrades to this block + + # ShowUser: No +} +{ +Block Id: 2032, Name: CPUExtenderBAT2 + MarketPrice: 11050, display: true + # Class: CPUExtender + Group: cpgCore + IndexName: CPUExtender + # EssentialCategory: cpgCPUExtender + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderAPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Faction: Player + Place: Free + AllowPlacingAt: "Base", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 1150, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + StackSize: 500 + EnergyIn: 50, type: int, display: true, formatter: Watt + # CPUOut: 300000, type: int, display: true + CPUExtenderLevel: 2, type: int, display: true + HitPoints: 650, type: int, display: true + AllowWander: true + + UnlockCost: 7, display: true + UnlockLevel: 7, display: true + TechTreeNames: Base + TechTreeParent: Core + + # ShowUser: No +} +{ +Block Id: 2033, Name: CPUExtenderBAT3 + # Class: CPUExtender + MarketPrice: 471150, display: true + Group: cpgCore + IndexName: CPUExtender + # EssentialCategory: cpgCPUExtender + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/CPUExtenderBPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,1,1", display: true + Faction: Player + Place: Free + AllowPlacingAt: "Base", display: true + IsActivateable: false + IsDuplicateable: false + IsIgnoreLC: true + MaxCount: 2, type: int, display: true + ForceMaxCount: true + IsOxygenTight: true, display: true + Info: bkiCore, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 300 + # ModelOffset: "0,0.5,0" + BlockColor: "0,40,0" + Mass: 2150, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + StackSize: 500 + EnergyIn: 85, type: int, display: true, formatter: Watt + # CPUOut: 300000, type: int, display: true + CPUExtenderLevel: 3, type: int, display: true + HitPoints: 900, type: int, display: true + AllowWander: true + UpgradeTo: CPUExtenderBAT4, display: true + + UnlockCost: 12, display: true + UnlockLevel: 12, display: true + TechTreeNames: Base + TechTreeParent: CPUExtenderBAT2 + + # ShowUser: No +} + +{ +Block Id: 2034, Name: CPUExtenderBAT4, Ref: CPUExtenderBAT3 + Model: @models/Blocks/Misc/CPUExtenderCPrefab + SizeInBlocks: "2,1,1", display: true + MarketPrice: 934750, display: true + Mass: 3225, type: float, display: true, formatter: Kilogram + Volume: 1600, type: float, display: true, formatter: Liter + MaxCount: 4, type: int, display: true + ForceMaxCount: true + EnergyIn: 100, type: int, display: true, formatter: Watt + # CPUOut: 300000, type: int, display: true + CPUExtenderLevel: 4, type: int, display: true + HitPoints: 950, type: int, display: true + + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeNames: Base + TechTreeParent: CPUExtenderBAT3 + UpgradeTo: null, display: false # set when this this block ref's a block that upgrades to this block + + # ShowUser: No +} + +========================= Material Extended 6 ============================ +# BA / CV +{ +Block Id: 2035, Name: WoodExtended6, Ref: WoodFull + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: ConcreteExtended6, display: true +} +{ +Block Id: 2036, Name: ConcreteExtended6, Ref: ConcreteFull + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: ConcreteArmoredExtended6, display: true +} +{ +Block Id: 2037, Name: ConcreteArmoredExtended6, Ref: ConcreteArmoredFull + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" +} +{ +Block Id: 2038, Name: PlasticExtendedLarge6, Ref: PlasticFullLarge + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: HullExtendedLarge6, display: true +} +{ +Block Id: 2039, Name: HullExtendedLarge6, Ref: HullFullLarge + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: HullArmoredExtendedLarge6, display: true + DowngradeTo: PlasticExtendedLarge6, display: true +} +{ +Block Id: 2040, Name: HullArmoredExtendedLarge6, Ref: HullArmoredFullLarge + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: HullCombatExtendedLarge6, display: true +} +{ +Block Id: 2041, Name: HullCombatExtendedLarge6, Ref: HullCombatFullLarge + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" +} +{ +Block Id: 2042, Name: AlienExtended6, Ref: AlienFull + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" +} +{ +Block Id: 2043, Name: AlienExtendedLarge6, Ref: AlienFullLarge + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" +} +# SV / HV - no corridor shapes +{ +Block Id: 2044, Name: PlasticExtendedSmall6, Ref: PlasticFullSmall + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: HullExtendedSmall6, display: true +} +{ +Block Id: 2045, Name: HullExtendedSmall6, Ref: HullFullSmall + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: HullArmoredExtendedSmall6, display: true + DowngradeTo: PlasticExtendedSmall6, display: true +} +{ +Block Id: 2046, Name: HullArmoredExtendedSmall6, Ref: HullArmoredFullSmall + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" + UpgradeTo: HullCombatExtendedSmall6, display: true +} +{ +Block Id: 2047, Name: HullCombatExtendedSmall6, Ref: HullCombatFullSmall + ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector, CubeHalfEdge, CutCornerFL, CutCornerFR, RoundedRampBLeft, RoundedRampBRight, HalfCylinder90Degree, HalfCylinderConnectorA, Cylinder90Degree, HalfCylinderEdge, HalfCylinderConnectorB, RampRoundMediumTransitionLeft, RampRoundMediumTransitionRight, RampRoundConnectorCLeft, RampRoundConnectorCRight, NotchedDright, NotchedDleft" +} +# ================================================================================= +# ================================ Non-ID Blocks ================================== +# ================================================================================= + +# ========================= Material Extended 7 ============================ +# +# ========================================================================== +# BA / CV +{ Block Name: WoodExtended7, Ref: WoodFull + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: ConcreteExtended7, display: true +} +{ Block Name: ConcreteExtended7, Ref: ConcreteFull + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: ConcreteArmoredExtended7, display: true +} +{ Block Name: ConcreteArmoredExtended7, Ref: ConcreteArmoredFull + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" +} +{ Block Name: PlasticExtendedLarge7, Ref: PlasticFullLarge + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: HullExtendedLarge7, display: true +} +{ Block Name: HullExtendedLarge7, Ref: HullFullLarge + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: HullArmoredExtendedLarge7, display: true + DowngradeTo: PlasticExtendedLarge7, display: true +} +{ Block Name: HullArmoredExtendedLarge7, Ref: HullArmoredFullLarge + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: HullCombatExtendedLarge7, display: true +} +{ Block Name: HullCombatExtendedLarge7, Ref: HullCombatFullLarge + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" +} +{ Block Name: AlienExtended7, Ref: AlienFull + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" +} +{ Block Name: AlienExtendedLarge7, Ref: AlienFullLarge + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" +} +# SV / HV - no corridor shapes +{ Block Name: PlasticExtendedSmall7, Ref: PlasticFullSmall + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: HullExtendedSmall7, display: true +} +{ Block Name: HullExtendedSmall7, Ref: HullFullSmall + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: HullArmoredExtendedSmall7, display: true + DowngradeTo: PlasticExtendedSmall7, display: true +} +{ Block Name: HullArmoredExtendedSmall7, Ref: HullArmoredFullSmall + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" + UpgradeTo: HullCombatExtendedSmall7, display: true +} +{ Block Name: HullCombatExtendedSmall7, Ref: HullCombatFullSmall + ChildShapes: "RoundedA, RoundedRampA, RoundedEdge, RoundedB, RampABDouble, RampRoundDTriple, CutCone, CutCornerBLeft, CutCornerBRight, RampConnectorF2Left, RampConnectorF2Right, NotchedELeft, NotchedERight, CornerHalfDLeft, CornerHalfDRight" +} +#=========================== + + +{ Block Name: BlastShutterDoors3x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors3x1x1Prefab + SizeInBlocks: "1,1,4", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors4x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors4x1x1Prefab + SizeInBlocks: "1,1,5", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors5x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors5x1x1Prefab + SizeInBlocks: "1,1,6", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors6x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors6x1x1Prefab + SizeInBlocks: "1,1,7", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors7x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors7x1x1Prefab + SizeInBlocks: "1,1,8", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors8x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors8x1x1Prefab + SizeInBlocks: "1,1,9", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors9x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors9x1x1Prefab + SizeInBlocks: "1,1,10", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors10x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors10x1x1Prefab + SizeInBlocks: "1,1,11", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors11x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors11x1x1Prefab + SizeInBlocks: "1,1,12", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors12x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors12x1x1Prefab + SizeInBlocks: "1,1,13", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: BlastShutterDoors13x1x1, Ref: BlastShutterDoors1x1x1 + Model: @models/Blocks/Mothership/BlastShutterDoors/BlastShutterDoors13x1x1Prefab + SizeInBlocks: "1,1,14", display: true + Mass: 7290, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 3200, type: int, display: true +} +{ Block Name: TalonFoodProcessor + Material: woodblock + AllowedInBlueprint: false, display: true + CustomIcon: TribalCauldron + Model: @models2/Entities/Decorations/TribalAssets/TribalCauldronPrefab + SizeInBlocks: "1,1,1", display: true + Class: FoodProcessorV2 + IndexName: Constructor + MarketPrice: 2894, display: true + Group: cpgFoodProcessor + DontDecayFood: true # now this loot container is a fridge + Shape: ModelEntity + ShowBlockName: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "Base", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 80, type: float, display: true, formatter: Liter + Mass: 120, type: float, display: true, formatter: Kilogram + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Info: bkiFoodProcessor, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 0, type: int, display: true + EnergyInIdle: 0, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + HitPoints: 125, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 30 + ShowUser: No +} +{ Block Name: TalonBarrels + Material: woodblock + AllowedInBlueprint: false, display: true + CustomIcon: TribalBarrels + DropInventoryEntity: DropContainer + LootList: 56 + VolumeCapacity: 750, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBarrelsPrefab + SizeInBlocks: "1,1,1", display: true + Mass: 200, type: float, display: true, formatter: Kilogram + CPUIn: 0, type: int, display: true + AllowPlacingAt: "Base", display: true + Class: Container + IndexName: Container + Group: cpgCargoBox + Shape: ModelEntity + ShowBlockName: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + SizeInBlocksLocked: "Base,MS" # scaled small on SV, GV + Volume: 20, type: float, display: true, formatter: Liter + BlockColor: "110,110,110" + Info: bkiContainer, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + # IsAccessible: false, type: bool + IsActivateableInCP: true + IsLockable: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 125, type: int, display: true + BlastParticleIndex: 4 + BlastDamage: 10 + ShowUser: No +} + +{ Block Name: TalonBox, Ref: TalonBarrels + Material: woodblock + AllowedInBlueprint: false, display: true + CustomIcon: TribalBox + DropInventoryEntity: DropContainer + LootList: 56 + VolumeCapacity: 100, type: float, display: true, formatter: Liter + Model: @models2/Entities/Decorations/TribalAssets/TribalBoxPrefab + SizeInBlocks: "1,1,1", display: true + ShowUser: No +} +{ Block Name: TalonFurnace + Material: woodblock + AllowedInBlueprint: false, display: true + CustomIcon: TribalHearth + Model: @models2/Entities/Decorations/TribalAssets/TribalHearthPrefab + SizeInBlocks: "1,1,2", display: true + Class: Furnace + IndexName: Constructor + Group: cpgConstructor + MarketPrice: 7500, display: true + Shape: ModelEntity + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + Info: bkiFurnace, display: true + AllowPlacingAt: Base, display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + # ModelOffset: "0,0.5,0" + SymType: 4 + StackSize: 250 + BlockColor: "110,110,110" + Category: Devices + Mass: 31825, type: float, display: true, formatter: Kilogram + Volume: 4320, type: float, display: true, formatter: Liter + EnergyIn: 2500, type: int, display: true, formatter: Watt + CPUIn: 0, type: int, display: true + EnergyInIdle: 1, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Constructor + HitPoints: 350, type: int, display: true + + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + + Temperature: 150, display: true + ShowUser: No +} +#===== Alternative Player Respawn +{ Block Name: PlayerRespawnPOI + CustomIcon: EntitySpawnerPlateThin + Info: bkiNoInfo, display: true + Model: @models/Blocks/Misc/EntitySpawnerPlateThinPrefab + # Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IndexName: SpawnPoint + Group: cpgTransportation + Category: Devices + Material: wood + Shape: ModelEntity + MarketPrice: 700, display: true + ShowBlockName: true + Place: Free + AllowPlacingAt: "Base", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base" + Volume: 1, type: float, display: true, formatter: Liter + Mass: 1, type: float, display: true, formatter: Kilogram + IsLockable: true + IsOxygenTight: false, display: true + StackSize: 1000 + BlockColor: "110,110,110" + + EnergyIn: 0, type: int, display: true, formatter: Watt + BlastParticleIndex: 0 + BlastRadius: 0 + BlastDamage: 0 + + #BuffNamesActivate: "Resurrected", display: Activates + #DebuffNamesActivate: "WellFed,Rested", display: Deactivates + + ShowUser: Yes + AllowedInBlueprint: false, display: true +} +{ Block Name: PlayerRespawnSV + CustomIcon: EntitySpawnerPlateThin + Info: bkiNoInfo, display: true + Model: @models/Blocks/Misc/EntitySpawnerPlateThinPrefab + # Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IndexName: SpawnPoint + Group: cpgTransportation + Category: Devices + Material: wood + Shape: ModelEntity + MarketPrice: 700, display: true + ShowBlockName: true + Place: Free + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "2,1,2", display: true + SizeInBlocksLocked: "Base" + Volume: 1, type: float, display: true, formatter: Liter + Mass: 1, type: float, display: true, formatter: Kilogram + # IsLockable: true + IsOxygenTight: false, display: true + StackSize: 1000 + BlockColor: "110,110,110" + + EnergyIn: 0, type: int, display: true, formatter: Watt + BlastParticleIndex: 0 + BlastRadius: 0 + BlastDamage: 0 + + #BuffNamesActivate: "Resurrected", display: Activates + #DebuffNamesActivate: "WellFed,Rested", display: Deactivates + + ShowUser: No + AllowedInBlueprint: false, display: true +} +# ===== Posters Deco ======== +{ Block Name: PosterARest + Model: @models2/Entities/Decorations/Posters/aRestPosterPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + RepairToTemplate: false, display: true + MarketPrice: 1899, display: true + Material: metallight + Shape: ModelEntity + ShowBlockName: true + Place: Free + #CanPickup: true #no CanPickup because text is on poster when looking at it! + PickupTarget: PosterARest + Category: Deco Blocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Info: bkiPosterARest, display: true + IsOxygenTight: false, display: true + StackSize: 1000 + Mass: 0, type: float, display: true, formatter: Kilogram + Volume: 0, type: float, display: true, formatter: Liter + CPUIn: 0, type: int, display: true + RpFactor: 0 + XpFactor: 0 +} +{ Block Name: PosterBiker, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/BikerPosterPrefab + PickupTarget: PosterBiker + Info: bkiPosterBiker, display: true +} +{ Block Name: PosterBoyband, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/BoybandPosterPrefab + PickupTarget: PosterBoyband + Info: bkiNoInfo, display: true + ShowUser: No +} +{ Block Name: PosterDontHide, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/DontHidePosterPrefab + PickupTarget: PosterDontHide + Info: bkiPosterDontHide, display: true +} +{ Block Name: PosterForeignWorld, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/ForeignWorldPosterPrefab + PickupTarget: PosterForeignWorld + Info: bkiPosterForeignWorld, display: true +} +{ Block Name: PosterJump, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/JumpPosterPrefab + PickupTarget: PosterJump + Info: bkiPosterJump, display: true +} +{ Block Name: PosterNewWorld, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/NewWorldPosterPrefab + PickupTarget: PosterNewWorld + Info: bkiPosterNewWorld, display: true +} +{ Block Name: PosterSoleDesert, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/SoleDesertPosterPrefab + PickupTarget: PosterSoleDesert + Info: bkiPosterSoleDesert, display: true +} +{ Block Name: PosterStranger, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/StrangerPosterPrefab + PickupTarget: PosterStranger + Info: bkiPosterStranger, display: true +} +{ Block Name: PosterSurvivor, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/SurvivorPosterPrefab + PickupTarget: PosterSurvivor + Info: bkiPosterSurvivor, display: true +} +{ Block Name: PosterTakingABreak, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/TakeingaBreakPosterPrefab + PickupTarget: PosterTakingABreak + Info: bkiPosterTakingABreak, display: true +} +{ Block Name: PosterTalon, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/TalonPosterPrefab + PickupTarget: PosterTalon + Info: bkiPosterTalon, display: true +} +{ Block Name: PosterTrader, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/TraderPosterPrefab + PickupTarget: PosterTrader + Info: bkiPosterTrader, display: true +} +{ Block Name: PosterZiraxAlienWorld, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/ZiraxAlienWorldPosterPrefab + PickupTarget: PosterZiraxAlienWorld + Info: bkiPosterZiraxAlienWorld, display: true +} +{ Block Name: PosterCrystalMoon, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/CrystalMoonPosterPrefab + PickupTarget: PosterCrystalMoon + Info: bkiPosterZiraxAlienWorld, display: true +} +{ Block Name: PosterOffIntoTheUnknown, Ref: PosterARest + Model: @models2/Entities/Decorations/Posters/OffIntoTheUnknownPosterPrefab + PickupTarget: PosterOffIntoTheUnknown + Info: bkiPosterZiraxAlienWorld, display: true +} +# === Heavy Windows with ornaments === +{ Block Name: HeavyWindowDetailedBlocks + Material: hullcombat + CustomIcon: HeavyWindowDetailedCT2COR + Shape: Invisible + MarketPrice: 2155, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiBlockGroup, display: true + IsOxygenTight: true, display: true + IsAccessible: false, type: bool + Category: BuildingBlocks + ChildBlocks: "HeavyWindowDetailedA,HeavyWindowDetailedACL,HeavyWindowDetailedACR,HeavyWindowDetailedAO,HeavyWindowDetailedAT,HeavyWindowDetailedATCL,HeavyWindowDetailedATCR,HeavyWindowDetailedATO,HeavyWindowDetailedBT,HeavyWindowDetailedBX,HeavyWindowDetailedBXT,HeavyWindowDetailedBXCL,HeavyWindowDetailedCL,HeavyWindowDetailedCCL,HeavyWindowDetailedCCR,HeavyWindowDetailedCOL,HeavyWindowDetailedCTL,HeavyWindowDetailedCTOL,HeavyWindowDetailedCTCOL,HeavyWindowDetailedCTCOR,HeavyWindowDetailedCT2L,HeavyWindowDetailedCT2OL,HeavyWindowDetailedCT2COL,HeavyWindowDetailedCT2COR" + Collide: Transparent + + TemplateRoot: HeavyWindowDetailedBlocks + AllowPlacingAt: "Base,MS", display: true + HitPoints: 1500, type: int, display: true + Mass: 880, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ Block Name: HeavyWindowDetailedA + Material: hullcombat + Shape: ModelEntity + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedAPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + Place: Free + IsAccessible: false, type: bool + IsOxygenTight: true, display: true + Category: BuildingBlocks + BlockColor: "110,110,110" + Collide: Transparent + TemplateRoot: HeavyWindowDetailedBlocks + AllowPlacingAt: "Base,MS", display: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + HitPoints: 1500, type: int, display: true + Mass: 880, type: float, display: true, formatter: Kilogram + # ShowUser: NoButCSV # inverted versions should not be display in H menu but should show up in EAH tool +} +{ Block Name: HeavyWindowDetailedACL, Ref: HeavyWindowDetailedA #Requires Mirror R + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedACLPrefab + MirrorTo: HeavyWindowDetailedACR +} +{ Block Name: HeavyWindowDetailedACR, Ref: HeavyWindowDetailedA #Requires Mirror L + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedACRPrefab + MirrorTo: HeavyWindowDetailedACL +} +{ Block Name: HeavyWindowDetailedAO, Ref: HeavyWindowDetailedA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedAOPrefab + SymType: 1 +} +{ Block Name: HeavyWindowDetailedAT, Ref: HeavyWindowDetailedA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedATPrefab + SymType: 1 +} +{ Block Name: HeavyWindowDetailedATCL, Ref: HeavyWindowDetailedA #Requires Mirror R + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedATCLPrefab + MirrorTo: HeavyWindowDetailedATCR +} +{ Block Name: HeavyWindowDetailedATCR, Ref: HeavyWindowDetailedA #Requires Mirror L + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedATCRPrefab + MirrorTo: HeavyWindowDetailedATCL +} +{ Block Name: HeavyWindowDetailedATO, Ref: HeavyWindowDetailedA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedATOPrefab + SymType: 1 +} +{ Block Name: HeavyWindowDetailedBT, Ref: HeavyWindowDetailedA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedBTPrefab + SymType: 1 +} +{ Block Name: HeavyWindowDetailedBX, Ref: HeavyWindowDetailedA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedBXPrefab + SymType: 1 +} +{ Block Name: HeavyWindowDetailedBXT, Ref: HeavyWindowDetailedA + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedBXTPrefab + SymType: 1 +} +{ Block Name: HeavyWindowDetailedBXCL, Ref: HeavyWindowDetailedA #Requires Mirror R + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedBXCLPrefab + MirrorTo: HeavyWindowDetailedBXCR +} +{ Block Name: HeavyWindowDetailedBXCR, Ref: HeavyWindowDetailedA #only mirror; not required for selection + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedBXCRPrefab + MirrorTo: HeavyWindowDetailedBXCL + ShowUser: No +} +{ Block Name: HeavyWindowDetailedCL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCLPrefab + MirrorTo: HeavyWindowDetailedCR +} +{ Block Name: HeavyWindowDetailedCR, Ref: HeavyWindowDetailedA #only mirror; not required for selection + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCRPrefab + MirrorTo: HeavyWindowDetailedCL + ShowUser: No +} +{ Block Name: HeavyWindowDetailedCCL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCCLPrefab + MirrorTo: HeavyWindowDetailedCCR +} +{ Block Name: HeavyWindowDetailedCCR, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCCRPrefab + MirrorTo: HeavyWindowDetailedCCL +} +{ Block Name: HeavyWindowDetailedCOL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCOLPrefab + MirrorTo: HeavyWindowDetailedCOR +} +{ Block Name: HeavyWindowDetailedCOR, Ref: HeavyWindowDetailedA #only mirror; not required for selection + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCORPrefab + MirrorTo: HeavyWindowDetailedCOL + ShowUser: No +} +{ Block Name: HeavyWindowDetailedCTL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCTLPrefab + MirrorTo: HeavyWindowDetailedCTR +} +{ Block Name: HeavyWindowDetailedCTR, Ref: HeavyWindowDetailedA #only mirror; not required for selection + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCTRPrefab + MirrorTo: HeavyWindowDetailedCTL + ShowUser: No +} +{ Block Name: HeavyWindowDetailedCTOL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCTOLPrefab + MirrorTo: HeavyWindowDetailedCTOR +} +{ Block Name: HeavyWindowDetailedCTOR, Ref: HeavyWindowDetailedA #only mirror; not required for selection + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCTORPrefab + MirrorTo: HeavyWindowDetailedCTOL + ShowUser: No +} +{ Block Name: HeavyWindowDetailedCTCOL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCTCOLPrefab + MirrorTo: HeavyWindowDetailedCTCOR +} +{ Block Name: HeavyWindowDetailedCTCOR, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCTCORPrefab + MirrorTo: HeavyWindowDetailedCTCOL +} +{ Block Name: HeavyWindowDetailedCT2L, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCT2LPrefab + MirrorTo: HeavyWindowDetailedCT2R +} +{ Block Name: HeavyWindowDetailedCT2R, Ref: HeavyWindowDetailedA #only mirror; not required for selection + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCT2RPrefab + MirrorTo: HeavyWindowDetailedCT2L + ShowUser: No +} +{ Block Name: HeavyWindowDetailedCT2OL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCT2OLPrefab + MirrorTo: HeavyWindowDetailedCT2OR +} +{ Block Name: HeavyWindowDetailedCT2OR, Ref: HeavyWindowDetailedA #only mirror; not required for selection + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCT2ORPrefab + MirrorTo: HeavyWindowDetailedCT2OL + ShowUser: No +} +{ Block Name: HeavyWindowDetailedCT2COL, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCT2COLPrefab + MirrorTo: HeavyWindowDetailedCT2COR +} +{ Block Name: HeavyWindowDetailedCT2COR, Ref: HeavyWindowDetailedA #Requires Mirror + Model: @models/Blocks/Windows/Heavy/HeavyWindowDetailedCT2CORPrefab + MirrorTo: HeavyWindowDetailedCT2COL +} +# === High Tier sphere turret types (generic)=== + +{ +Block Name: TurretEnemyBlack, Ref: TurretAlien #Random; not assigned to faction yet + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyColored, display: true + Model: @models/Blocks/Turrets/TurretEnemyBlackPrefab + WeaponItem: TurretEnemyLaserWeaponBlack +} +{ +Block Name: TurretEnemyBlue, Ref: TurretAlien #Plasma; not assigned to faction yet + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyColored, display: true + Model: @models/Blocks/Turrets/TurretEnemyBluePrefab + WeaponItem: TurretEnemyLaserWeaponBlue +} +{ +Block Name: TurretEnemyCyan, Ref: TurretAlien #Random; not assigned to faction yet + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyColored, display: true + Model: @models/Blocks/Turrets/TurretEnemyCyanPrefab + WeaponItem: TurretEnemyLaserWeaponCyan +} +{ +Block Name: TurretEnemyGreen, Ref: TurretAlien #Random; Tresari faction + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyColored, display: true + Model: @models/Blocks/Turrets/TurretEnemyGreenPrefab + WeaponItem: TurretEnemyLaserWeaponGreen +} +{ +Block Name: TurretEnemyOrange, Ref: TurretAlien #Random; not assigned to faction yet + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyColored, display: true + Model: @models/Blocks/Turrets/TurretEnemyOrangePrefab + WeaponItem: TurretEnemyLaserWeaponOrange +} +{ +Block Name: TurretEnemyPurple, Ref: TurretAlien #Turret Faction CVBA DarkMatter TESCH + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyTESCH, display: true + Model: @models/Blocks/Turrets/TurretEnemyPurplePrefab + WeaponItem: TurretEnemyLaserWeaponPurple +} +{ +Block Name: TurretEnemyRed, Ref: TurretAlien #Laser; not assigned to faction yet + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyColored, display: true + Model: @models/Blocks/Turrets/TurretEnemyRedPrefab + WeaponItem: TurretEnemyLaserWeaponRed +} +{ +Block Name: TurretEnemyYellow, Ref: TurretAlien #EMP; not assigned to faction yet + Group: cpgTurret + IndexName: Turret + Info: bkiTurretEnemyColored, display: true + Model: @models/Blocks/Turrets/TurretEnemyYellowPrefab + WeaponItem: TurretEnemyLaserWeaponYellow +} +# Tier-ed Teleporters +{ +Block Name: TeleporterBAT1 + Info: bkiTeleporterBAT1, display: true + Class: Teleporter + MarketPrice: 2200, display: true + Group: cpgTransportation + Category: Devices + IndexName: Teleporter + CustomIcon: TeleporterCV + # AllowedInBlueprint: false, display: true + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/TeleporterPadPrefab #CV Model to indicate low range. + # Model: @models/Blocks/Misc/TeleporterPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + #TemplateRoot: TeleporterCV + #PickupTarget: TeleporterBAT1 + BlockColor: "110,110,110" + IsActivateable: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 1000 + Mass: 320, type: float, display: true, formatter: Kilogram + EnergyIn: 1200, type: int, display: true, formatter: Watt + CPUIn: 350, type: int, display: true + Volume: 200, type: float, display: true, formatter: Liter + HitPoints: 350, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + RangeLY: 0 # a value of 0 allows to teleport only within the current sector + + #UnlockCost: 20, display: true + #UnlockLevel: 15, display: true + # TechTreeParent: PentaxidTank + #TechTreeNames: Base + + # ShowUser: No +} + +# ================================================================================= +# ================================ EPIC Devices ================================== +# ================================================================================= + +{ +Block Name: TeleporterBAEpic + Info: bkiTeleporterBAEpic, display: true + Class: Teleporter + # MarketPrice: 2200, display: true + Group: cpgTransportation + Category: Devices + IndexName: Teleporter + # RepairToTemplate: false #Blocks can be placed, and used for R2T when destroyed + AllowedInBlueprint: false, display: true #Struct with these blocks cannot be blueprinted to avoid easy multiplication of these blocks! + CustomIcon: TeleporterBA + # AllowedInBlueprint: false, display: true + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/TeleporterPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + # TemplateRoot: HullLargeBlocks + # PickupTarget: SteelPlate + BlockColor: "110,110,110" + IsActivateable: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 1000 + DowngradeTo: TeleporterBA, display: true + PickupTarget: TeleporterBAEpicUpgradeKit + IsDuplicateable: false + Mass: 320, type: float, display: true, formatter: Kilogram + EnergyIn: 1200, type: int, display: true, formatter: Watt + CPUIn: 350, type: int, display: true + Volume: 200, type: float, display: true, formatter: Liter + HitPoints: 350, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + RangeLY: 300 # a value of 0 allows to teleport only within the current sector + + #UnlockCost: 20, display: true + #UnlockLevel: 15, display: true + # TechTreeParent: PentaxidTank + #TechTreeNames: Base + + ShowUser: No +} +{ +Block Name: WarpDriveCVEpic + Info: bkiWarpDriveCVEpic, display: true + Class: WarpDrive + Group: cpgWarp + IndexName: WarpDrive + Material: metal + Shape: ModelEntity + MarketPrice: 83216, display: true + ShowBlockName: true + RepairToTemplate: false #Blocks can NOT be placed, and used for R2T when destroyed + AllowedInBlueprint: false, display: true #Struct with these blocks cannot be blueprinted to avoid easy multiplication of these blocks! + Model: @models/Blocks/Mothership/WarpDriveT2Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,2,5", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 4800, type: float, display: true, formatter: Liter + Mass: 82200, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + PickupTarget: WarpDriveCVEpicUpgradeKit + IsOxygenTight: false, display: true + # ModelOffset: "0,0.5,0" + # BlockColor: "26,50,89" + StackSize: 250 + BlockColor: "110,110,110" + Category: Devices + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 40000, type: int, display: true + HitPoints: 1150, type: int, display: true + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 200 + + ItemPerAU: PentaxidCrystal, display: true + CostPerAU: 2, display: true + CostPerLY: 1, display: true + RangeAU: 250, display: true + RangeLY: 150, display: true + + # Short Range Warp + CostPerKM: 4, display: true + RangeKM: 60000, display: true + #RangeKMuseDetector: true, display: true + MicroWarpCooldown: 25, dipslay: true + + Radiation: 25, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 40, display: true + + # UnlockCost: 15, display: true + # UnlockLevel: 10, display: true + # TechTreeParent: PentaxidTank + # TechTreeNames: Capital Vessel + + DowngradeTo: WarpDriveT2, display: true + + ShowUser: No +} +{ +Block Name: DetectorHVEpic + Info: bkiDetectorHVEpic, display: true + WeaponItem: DetectorHVEpicWeapon + MarketPrice: 110, display: true + WeaponItemRotation: false + RepairToTemplate: false #Blocks can be placed, and used for R2T when destroyed + AllowedInBlueprint: false, display: true #Struct with these blocks cannot be blueprinted to avoid easy multiplication of these blocks! + CustomIcon: DetectorSVT1 + IndexName: WeaponShip + Group: cpgEquipment + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Spaceship/DetectorSVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + AllowPlacingAt: "GV", display: true + IsAccessible: true, type: bool + BlockColor: "110,110,110" + SizeInBlocks: "1,1,1", display: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + DowngradeTo: DetectorHVT1, display: true + IsDuplicateable: false + PickupTarget: DetectorHVEpicUpgradeKit + # SymType: 4 + StackSize: 1000 + EnergyIn: 5, type: int, display: true, formatter: Watt + HitPoints: 120, type: int, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Volume: 93.75, type: float, display: true, formatter: Liter + Range: 2500, type: int, display: true + RangeSpace: 10000, type: int, display: true + CPUIn: 50, type: int, display: true + Category: Devices + + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + + # UnlockCost: 5, display: true + # UnlockLevel: 3, display: true + # TechTreeParent: GeneratorSV + # TechTreeNames: Hover Vessel + + ShowUser: No +} +{ +Block Name: DetectorCVEpic + CustomIcon: DetectorCV + Info: bkiDetectorCVEpic, display: true + WeaponItem: DetectorCVEpicWeapon + WeaponItemRotation: false + RepairToTemplate: false #Blocks can be placed, and used for R2T when destroyed + AllowedInBlueprint: false, display: true #Struct with these blocks cannot be blueprinted to avoid easy multiplication of these blocks! + MarketPrice: 10195, display: true + IndexName: WeaponShip + Group: cpgEquipment + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Spaceship/DetectorCVPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Face + AllowPlacingAt: "MS", display: true + IsAccessible: true, type: bool + BlockColor: "110,110,110" + SizeInBlocks: "1,1,1", display: true + MaxCount: 1, type: int, display: true + ForceMaxCount: true + DowngradeTo: DetectorCV, display: true + IsDuplicateable: false + PickupTarget: DetectorCVEpicUpgradeKit + # SymType: 4 + StackSize: 1000 + EnergyIn: 320, type: int, display: true, formatter: Watt + HitPoints: 300, type: int, display: true + Mass: 3520, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + Range: 6000, type: int, display: true + RangeSpace: 100000, type: int, display: true + CPUIn: 1500, type: int, display: true + Category: Devices + + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + + # UnlockCost: 10, display: true + # UnlockLevel: 7, display: true + # TechTreeParent: GeneratorSV + # TechTreeNames: Capital Vessel + + ShowUser: No +} +{ +Block Name: AutoMiningDeviceEpic + Model: @models/Blocks/Devices/AutoMinerT3FWNPrefab #1.11 update + #Model: @models/Blocks/Terrain/AutoMiningDeviceT3Prefab + CustomIcon: AutoMiningDeviceT3 + Class: AutoMiner + Material: metalhard + Shape: ModelEntity + MarketPrice: 38367, display: true + #Mesh-Damage-1: Mesh/geo/D0 + #Mesh-Damage-2: Mesh/geo/D1 + #Mesh-Damage-3: Mesh/geo/D2 + #Mesh-Damage-4: Mesh/geo/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: NoRotation + Info: bkiAutoMinerEpic, display: true + AllowPlacingAt: Terrain, display: true + IsTerrainDecoration: true + HitPoints: 1500, type: int, display: true + EnergyIn: 20, type: int, display: true, formatter: Watt + ItemsPerHour: 500, display: true # Attention: ItemsPerHour max is hard coded and set to 500 + ItemStorageLimit: 16000, display: true + Mass: 5000, type: float, display: true, formatter: Kilogram + Volume: 300, type: float, display: true, formatter: Liter + StackSize: 1 + CanPickup: true + PickupToToolbar: true + XpFactor: 0 # to avoid farming of xp through placement/pickup cycle + ForceNoPvEDamage: true + Category: Devices + IsColorable: false + SizeInBlocks: "3,6,3" + # ModelOffset: "0,0.5,0" + BlockColor: "110,110,110" + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + # UnlockCost: 15, display: true + #UnlockLevel: 12, display: true + #TechTreeNames: Misc + #UpgradeTo: AutoMiningDeviceT2, display: true + + MapIcon: map_Drill + MapName: AutoMiningDeviceT3 + + # Temperature: 30, display: true +} +# ================================================================================= +# ================================ Faction Turrets ================================== +# ================================================================================= +# +# ========================================== +# ===== Faction Turrets BA CV ==== +# ===== Faction turrets cannot be bought or crafted by the player. He might get them through missions or lucky salvaging, though. +# ===== Faction turrets are 20% better compared to DPS/damage/other params compared to player turrets +# ===== Faction turrets cannot be R2T and not be added to blueprints to avoid repro exploits and make them more valuable). +# ========================================== +{ Block Name: TurretTemplateFaction #Used for any FACTION turret + # Info: bkiTurret, display: true + Model: @models/Blocks/Turrets/TurretMSMinigunRetractPrefab # REQUIRED entry for a TEMPLATE (otherwise CoQ on loading) + # WeaponItem: TurretMSMinigunWeapon + Class: TurretManual # TurretAuto ? + IndexName: Turret + Group: cpgTurret # Indicates non-player origin; default cpgTurret + Category: Weapons/Items + RepairToTemplate: false, display: true #Blocks can be placed, but not R2T when destroyed + AllowedInBlueprint: false, display: true #Struct with these blocks cannot be blueprinted to avoid easy multiplication of these blocks! + Place: Face # If turret is retractable: Free; other: Face + # AllowPlacingAt: MS, display: true + Material: metalhard + Shape: Turret + BlockColor: "110,110,110" + SizeInBlocks: "3,1,3", display: true # Needs to be adjusted in config of larger turrets, like Artillery! + SizeInBlocksLocked: "Base,MS" + # IsTextureable: true # Required for RETRACTABLE turrets so their 'base' can be textured + colored + # Texture: 50 # Use with RETRACTABLE turrets for their base + IsGPUInstance: false # Do not let this be handled by GPU (possible issues with code not applying to this unity feature?) + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsActivateable: true + IsActivateableInCP: true + # IsOxygenTight: false, display: true + # IsRetractable: true + # AboveTerrainCheck: false # Required? + StackSize: 500 + # MaxCount: 4, type: int, display: true + # MarketPrice: 2200, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + Mass: 6150, type: float, display: true, formatter: Kilogram + Volume: 2880, type: float, display: true, formatter: Liter + HitPoints: 3450, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + # CPUIn: 2500, type: int, display: true + # RotSpeed: 150, type: int, display: true + Zoom: 5, type: float + XpFactor: 2 # XP given on destroying turret (also when player placed?) + ShowUser: No +} +# == UCH (Minigun, Flak) +{ Block Name: TurretMSMinigunFaction, Ref: TurretTemplateFaction #TurretFaction UCH CV Minigun + Info: bkiTurretMSMinigunFaction, display: true + CustomIcon: TurretMSMinigun + Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + WeaponItem: TurretMinigunFactionWeapon + IsGPUInstance: false # !!! + AllowPlacingAt: MS, display: true + CPUIn: 2800, type: int, display: true + RotSpeed: 150, type: int, display: true +} +{ Block Name: TurretBaseMinigunFaction, Ref: TurretTemplateFaction #TurretFaction UCH BA Minigun + Info: bkiTurretBaseMinigunFaction, display: true + CustomIcon: TurretBaseMinigun + Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + WeaponItem: TurretMinigunFactionWeapon + AllowPlacingAt: Base, display: true + CPUIn: 2800, type: int, display: true + RotSpeed: 120, type: int, display: true +} +{ Block Name: TurretMSFlakFaction, Ref: TurretTemplateFaction #TurretFaction UCH CV Flak + Info: bkiTurretMSFlakFaction, display: true + CustomIcon: TurretMSFlak + Model: @models/Blocks/Turrets/TurretMSFlakPrefab + WeaponItem: TurretFlakFactionWeapon + AllowPlacingAt: MS, display: true + SoundRotate: Weapons/Turret/TurretRotateVeryFast + CPUIn: 1300, type: int, display: true + RotSpeed: 120, type: int, display: true +} + +{ Block Name: TurretBaseFlakFaction, Ref: TurretTemplateFaction #TurretFaction UCH BA Flak + Info: bkiTurretBaseFlakFaction, display: true + CustomIcon: TurretBaseFlak + Model: @models/Blocks/Turrets/TurretMSFlakPrefab + WeaponItem: TurretFlakFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: Base, display: true + CPUIn: 1300, type: int, display: true + RotSpeed: 110, type: int, display: true +} +# Zirax (Cannon, Rocket, Artillery) + +{ Block Name: TurretMSCannonFaction, Ref: TurretTemplateFaction #TurretFaction CV Cannon + Info: bkiTurretMSCannonFaction, display: true + CustomIcon: TurretZiraxMSPlasma + Model: @models/Blocks/Turrets/TurretZiraxSpherical_PlasmaPrefab + WeaponItem: TurretCannonFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateFast + AllowPlacingAt: MS, display: true + CPUIn: 8600, type: int, display: true + RotSpeed: 150, type: int, display: true +} + +{ Block Name: TurretBaseCannonFaction, Ref: TurretTemplateFaction #TurretFaction BA Cannon + Info: bkiTurretBaseCannonFaction, display: true + CustomIcon: TurretBaseCannon + Model: @models/Blocks/Turrets/TurretMSCannonPrefab + WeaponItem: TurretCannonFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateFast + AllowPlacingAt: Base, display: true + CPUIn: 8600, type: int, display: true + RotSpeed: 150, type: int, display: true +} + +{ Block Name: TurretMSRocketFaction, Ref: TurretTemplateFaction #TurretFaction CV Rocket + Info: bkiTurretMSRocketFaction, display: true + Model: @models/Blocks/Turrets/TurretZiraxSpherical_RocketPrefab + CustomIcon: TurretZiraxMSRocket + WeaponItem: TurretRocketFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: MS, display: true + CPUIn: 10500, type: int, display: true + RotSpeed: 120, type: int, display: true +} + +{ Block Name: TurretBaseRocketFaction, Ref: TurretTemplateFaction #TurretFaction BA Rocket + Info: bkiTurretBaseRocketFaction, display: true + Model: @models/Blocks/Turrets/TurretMSRocketPrefab + CustomIcon: TurretBaseRocket + WeaponItem: TurretRocketFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: Base, display: true + CPUIn: 10500, type: int, display: true + RotSpeed: 120, type: int, display: true +} + +{ Block Name: TurretMSArtilleryFaction, Ref: TurretTemplateFaction #TurretFaction CV Artillery + Info: bkiTurretMSArtilleryFaction, display: true + Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + SizeInBlocks: "5,2,5", display: true + CustomIcon: TurretMSArtillery + WeaponItem: TurretArtilleryFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: MS, display: true + CPUIn: 22600, type: int, display: true + Mass: 6000, type: float, display: true, formatter: Kilogram + Volume: 6000, type: float, display: true, formatter: Liter + HitPoints: 4250, type: int, display: true + RotSpeed: 60, type: int, display: true +} + +{ Block Name: TurretBaseArtilleryFaction, Ref: TurretTemplateFaction #TurretFaction BA Artillery + Info: bkiTurretBaseArtilleryFaction, display: true + Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + SizeInBlocks: "5,2,5", display: true + CustomIcon: TurretBaseArtillery + WeaponItem: TurretArtilleryFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: Base, display: true + CPUIn: 22600, type: int, display: true + Mass: 6000, type: float, display: true, formatter: Kilogram + Volume: 6000, type: float, display: true, formatter: Liter + HitPoints: 4250, type: int, display: true + RotSpeed: 60, type: int, display: true +} +# Kriel (Railgun) + +{ Block Name: TurretRailgunFaction, Ref: TurretTemplateFaction #TurretFaction CVBA Railgun + Info: bkiTurretRailgunFaction, display: true + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + CustomIcon: TurretBasePlasma + WeaponItem: TurretRailgunFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: "Base,MS", display: true + CPUIn: 20700, type: int, display: true + RotSpeed: 120, type: int, display: true +} +# Polaris (Laser) + +{ Block Name: TurretMSPulseLaserFaction, Ref: TurretTemplateFaction #TurretFaction CV Laser + Info: bkiTurretMSPulseLaserFaction, display: true + Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + CustomIcon: TurretMSPulseLaser + WeaponItem: TurretPulseLaserFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: MS, display: true + CPUIn: 15000, type: int, display: true + RotSpeed: 100, type: int, display: true +} + +{ Block Name: TurretBasePulseLaserFaction, Ref: TurretTemplateFaction #TurretFaction BA Laser + Info: bkiTurretBasePulseLaserFaction, display: true + Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + CustomIcon: TurretBasePulseLaser + WeaponItem: TurretPulseLaserFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: Base, display: true + CPUIn: 15000, type: int, display: true + RotSpeed: 100, type: int, display: true +} + +# Traders (Plasma) + +{ Block Name: TurretMSPlasmaFaction, Ref: TurretTemplateFaction #TurretFaction CV Plasma + Info: bkiTurretMSPlasmaFaction, display: true + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + CustomIcon: TurretMSPlasma + WeaponItem: TurretPlasmaFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: MS, display: true + CPUIn: 20700, type: int, display: true + RotSpeed: 80, type: int, display: true +} + +{ Block Name: TurretBasePlasmaFaction, Ref: TurretTemplateFaction #TurretFaction BA Plasma + Info: bkiTurretBasePlasmaFaction, display: true + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + CustomIcon: TurretBasePlasma + WeaponItem: TurretPlasmaFactionWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: Base, display: true + CPUIn: 20700, type: int, display: true + RotSpeed: 80, type: int, display: true +} + +# Legacy (AntiProton) +# see: Block Id: 1679, Name: TurretAlien >> TurretFaction CVBA AntiProton + +# Tesch (DarkMatter) +# see: TurretEnemyPurple >> TurretFaction CVBA DarkMatter + +# ==================================================================================== +# ===== Faction: Upgrade Turrets BA CV =============================================== +# ===== UPGRADE turrets are NOT use by the MAIN Factions BUT by AUX factions: Colonists, Pirates, Warlords etc pp, while the AUX factions do NOT use the FACTION turrets! Player MIGHT gain UPGRADE turrets from MAIN-factions traders, though! +# ===== Upgrade turrets are 10% better compared to DPS/damage/other params compared to player turrets +# ===== Upgrade turrets CAN be R2T but not be added to blueprints to avoid repro exploits and make them more valuable). +# ==================================================================================== +{ Block Name: TurretTemplateUpgrade #Used for any UPGRADE turret + # Info: bkiTurret, display: true + Model: @models/Blocks/Turrets/TurretMSMinigunRetractPrefab # REQUIRED entry for a TEMPLATE (otherwise CoQ on loading) + # WeaponItem: TurretMSMinigunWeapon + Class: TurretManual # TurretAuto ? + IndexName: Turret + Group: cpgAlien # Indicates non-player origin; default cpgTurret + Category: Weapons/Items + #RepairToTemplate: false #Blocks can be placed, but not R2T when destroyed + AllowedInBlueprint: false, display: true #Struct with these blocks cannot be blueprinted to avoid easy multiplication of these blocks! + Place: Face # If turret is retractable: Free; other: Face + # AllowPlacingAt: MS, display: true + Material: metalhard + Shape: Turret + BlockColor: "110,110,110" + SizeInBlocks: "3,1,3", display: true # Needs to be adjusted in config of larger turrets, like Artillery! + SizeInBlocksLocked: "Base,MS" + # IsTextureable: true # Required for RETRACTABLE turrets so their 'base' can be textured + colored + # Texture: 50 # Use with RETRACTABLE turrets for their base + IsGPUInstance: false # Do not let this be handled by GPU (possible issues with code not applying to this unity feature?) + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + SoundOnEnter: UseActions/DOOROpen02 + SoundRotate: Weapons/Turret/TurretRotateNormal + SoundOpen: UseActions/TurretOpen + SoundClose: UseActions/TurretClose + IsActivateable: true + IsActivateableInCP: true + # IsOxygenTight: false, display: true + # IsRetractable: true + # AboveTerrainCheck: false # Required? + StackSize: 500 + # MaxCount: 4, type: int, display: true + # MarketPrice: 2200, display: true + BlastParticleIndex: 11 + BlastRadius: 3 + BlastDamage: 80 + Mass: 6150, type: float, display: true, formatter: Kilogram + Volume: 2880, type: float, display: true, formatter: Liter + HitPoints: 3450, type: int, display: true + EnergyIn: 5, type: int, display: true, formatter: Watt + # CPUIn: 2500, type: int, display: true + # RotSpeed: 150, type: int, display: true + Zoom: 5, type: float + XpFactor: 2 # XP given on destroying turret (also when player placed?) + ShowUser: No +} +# == UCH (Minigun, Flak) +{ Block Name: TurretMSMinigunUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade UCH CV Minigun + Info: bkiTurretMSMinigunUpgrade, display: true + CustomIcon: TurretMSMinigun + Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + WeaponItem: TurretMinigunUpgradeWeapon + IsGPUInstance: false # !!! + AllowPlacingAt: MS, display: true + CPUIn: 2800, type: int, display: true + RotSpeed: 150, type: int, display: true +} +{ Block Name: TurretBaseMinigunUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade UCH BA Minigun + Info: bkiTurretBaseMinigunUpgrade, display: true + CustomIcon: TurretBaseMinigun + Model: @models/Blocks/Turrets/TurretMSMinigunPrefab + WeaponItem: TurretMinigunUpgradeWeapon + AllowPlacingAt: Base, display: true + CPUIn: 2800, type: int, display: true + RotSpeed: 120, type: int, display: true +} +{ Block Name: TurretMSFlakUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade UCH CV Flak + Info: bkiTurretMSFlakUpgrade, display: true + CustomIcon: TurretMSFlak + Model: @models/Blocks/Turrets/TurretMSFlakPrefab + WeaponItem: TurretFlakUpgradeWeapon + AllowPlacingAt: MS, display: true + SoundRotate: Weapons/Turret/TurretRotateVeryFast + CPUIn: 1300, type: int, display: true + RotSpeed: 120, type: int, display: true +} + +{ Block Name: TurretBaseFlakUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade UCH BA Flak + Info: bkiTurretBaseFlakUpgrade, display: true + CustomIcon: TurretBaseFlak + Model: @models/Blocks/Turrets/TurretMSFlakPrefab + WeaponItem: TurretFlakUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: Base, display: true + CPUIn: 1300, type: int, display: true + RotSpeed: 110, type: int, display: true +} +# Zirax (Cannon, Rocket, Artillery) + +{ Block Name: TurretMSCannonUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade CV Cannon + Info: bkiTurretMSCannonUpgrade, display: true + CustomIcon: TurretZiraxMSLaser + Model: @models/Blocks/Turrets/TurretZiraxSpherical_LaserPrefab + WeaponItem: TurretCannonUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateFast + AllowPlacingAt: MS, display: true + CPUIn: 8600, type: int, display: true + RotSpeed: 150, type: int, display: true +} + +{ Block Name: TurretBaseCannonUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade BA Cannon + Info: bkiTurretBaseCannonUpgrade, display: true + CustomIcon: TurretBaseCannon + Model: @models/Blocks/Turrets/TurretMSCannonPrefab + WeaponItem: TurretCannonUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateFast + AllowPlacingAt: Base, display: true + CPUIn: 8600, type: int, display: true + RotSpeed: 150, type: int, display: true +} + +{ Block Name: TurretMSRocketUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade CV Rocket + Info: bkiTurretMSRocketUpgrade, display: true + Model: @models/Blocks/Turrets/TurretZiraxSpherical_RocketPrefab + CustomIcon: TurretZiraxMSRocket + WeaponItem: TurretRocketUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: MS, display: true + CPUIn: 10500, type: int, display: true + RotSpeed: 120, type: int, display: true +} + +{ Block Name: TurretBaseRocketUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade BA Rocket + Info: bkiTurretBaseRocketUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSRocketPrefab + CustomIcon: TurretBaseRocket + WeaponItem: TurretRocketUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateVeryFast + AllowPlacingAt: Base, display: true + CPUIn: 10500, type: int, display: true + RotSpeed: 120, type: int, display: true +} + +{ Block Name: TurretMSArtilleryUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade CV Artillery + Info: bkiTurretMSArtilleryUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + SizeInBlocks: "5,2,5", display: true + CustomIcon: TurretMSArtillery + WeaponItem: TurretArtilleryUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: MS, display: true + CPUIn: 22600, type: int, display: true + Mass: 6000, type: float, display: true, formatter: Kilogram + Volume: 6000, type: float, display: true, formatter: Liter + HitPoints: 4250, type: int, display: true + RotSpeed: 60, type: int, display: true +} + +{ Block Name: TurretBaseArtilleryUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade BA Artillery + Info: bkiTurretBaseArtilleryUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSArtilleryPrefab + SizeInBlocks: "5,2,5", display: true + CustomIcon: TurretBaseArtillery + WeaponItem: TurretArtilleryUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: Base, display: true + CPUIn: 22600, type: int, display: true + Mass: 6000, type: float, display: true, formatter: Kilogram + Volume: 6000, type: float, display: true, formatter: Liter + HitPoints: 4250, type: int, display: true + RotSpeed: 60, type: int, display: true +} +# Kriel (Railgun) + +{ Block Name: TurretRailgunUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade CVBA Railgun + Info: bkiTurretRailgunUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + CustomIcon: TurretBasePlasma + WeaponItem: TurretRailgunUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: "Base,MS", display: true + CPUIn: 20700, type: int, display: true + RotSpeed: 120, type: int, display: true +} +# Polaris (Laser) + +{ Block Name: TurretMSPulseLaserUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade CV Laser + Info: bkiTurretMSPulseLaserUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + CustomIcon: TurretMSPulseLaser + WeaponItem: TurretPulseLaserUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: MS, display: true + CPUIn: 15000, type: int, display: true + RotSpeed: 100, type: int, display: true +} + +{ Block Name: TurretBasePulseLaserUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade BA Laser + Info: bkiTurretBasePulseLaserUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSPulseLaserPrefab + CustomIcon: TurretBasePulseLaser + WeaponItem: TurretPulseLaserUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: Base, display: true + CPUIn: 15000, type: int, display: true + RotSpeed: 100, type: int, display: true +} + +# Traders (Plasma) + +{ Block Name: TurretMSPlasmaUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade CV Plasma + Info: bkiTurretMSPlasmaUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + CustomIcon: TurretMSPlasma + WeaponItem: TurretPlasmaUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: MS, display: true + CPUIn: 20700, type: int, display: true + RotSpeed: 80, type: int, display: true +} + +{ Block Name: TurretBasePlasmaUpgrade, Ref: TurretTemplateUpgrade #TurretUpgrade BA Plasma + Info: bkiTurretBasePlasmaUpgrade, display: true + Model: @models/Blocks/Turrets/TurretMSPlasmaPrefab + CustomIcon: TurretBasePlasma + WeaponItem: TurretPlasmaUpgradeWeapon + SoundRotate: Weapons/Turret/TurretRotateSlow + AllowPlacingAt: Base, display: true + CPUIn: 20700, type: int, display: true + RotSpeed: 80, type: int, display: true +} +# ===================================== +# ======== EVENT ===================== +# ===================================== + +{ Block Name: WarpDriveSVEvent + Class: WarpDrive + CustomIcon: WarpDriveSV + Group: cpgWarp + IndexName: WarpDrive + Material: metal + Shape: ModelEntity + Model: @models/Blocks/Spaceship/WarpDriveSVPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowBlockName: true + Place: Free + AllowPlacingAt: SS, display: true + MarketPrice: 999999999, display: true + SizeInBlocks: "3,2,5", display: true + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: false, display: true + Mass: 1490, type: float, display: true, formatter: Kilogram + Volume: 937.5, type: float, display: true, formatter: Liter + CPUIn: 1, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 1000, type: int, display: true + Info: bkiWarpDriveSV, display: true + PickupTarget: Presents + StackSize: 250 + BlockColor: "110,110,110" + Category: Devices + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 200 + + ItemPerAU: Presents, display: true + CostPerAU: 1, display: true + CostPerLY: 1, display: true + RangeAU: 1000, display: true + RangeLY: 1000, display: true + + Radiation: 1, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 40, display: true + + RepairToTemplate: false + AllowedInBlueprint: false, display: true + ShowUser: No + +} +{ Block Name: PentaxidTankSVEvent + Class: WarpDriveTank + CustomIcon: PentaxidTankSV + Group: cpgWarp + MarketPrice: 2737, display: true + IndexName: WarpDriveTank + LootList: 13 # lootcontainer Warp Drive Tank SV + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Spaceship/WarpDriveTankSVPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + BlockColor: "110,110,110" + Place: Free + IsActivateableInCP: true + IsLockable: true + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + AllowPlacingAt: "SS,GV", display: true + # ModelOffset: "0,0.5,0" + SizeInBlocks: "1,1,2", display: true + Category: Devices + PickupTarget: Presents + Mass: 328, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + StackSize: 1000 + VolumeCapacity: 1000, type: float, display: true, formatter: Liter + HitPoints: 1000, type: int, display: true + CPUIn: 1, type: int, display: true + EnergyIn: 1, type: int, display: true, formatter: Watt + BlastParticleIndex: 10 + BlastRadius: 5 + BlastDamage: 300 + + WarpAccept: Presents + + Radiation: 1, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 35, display: true + + RepairToTemplate: false + AllowedInBlueprint: false, display: true + ShowUser: No +} + +{ Block Name: ShieldGeneratorSVEvent + Model: @models/Blocks/Misc/ShieldGeneratorSmallPrefab + Class: ShieldGenerator + CustomIcon: ShieldGeneratorSV + IndexName: ShieldGenerator + EnergyDynamicGroup: Shield + MarketPrice: 22823, display: true + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + Group: cpgEquipment + Material: metal + Shape: ModelEntity + ShowBlockName: true + Place: Free + RepairToTemplate: false + PickupTarget: Presents + AllowedInBlueprint: false, display: true + IsActivateable: true + IsActivatedOnPlace: false + IsAccessible: true, type: bool + IsDuplicateable: false + OccupySizeInBlocks: true # do not allow to place blocks inside the bounding box + MaxCount: 1, type: int, display: true + ForceMaxCount: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + SizeInBlocks: "2,1,3", display: true + AllowPlacingAt: "SS", display: true + StackSize: 1 + Category: Devices + BlockColor: "110,110,110" + HitPoints: 1000, type: int, display: true + Mass: 1630, type: float, display: true, formatter: Kilogram + Volume: 187.5, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 1, type: int, display: true + EnergyInIdle: 1, type: int, display: true, formatter: Watt + ShieldCapacity: 250000, type: int, display: true + ShieldRecharge: 5000, type: int, display: true # recharge rate per sec + ShieldCooldown: 15, type: int, display: true # in seconds + ShieldPerCrystal: 250000, type: int, display: true + ShowUser: No + +} +{ +Block Name: RCSBlockSVEvent # low-performance 'broken' variant for special setups + IndexName: RCS + CustomIcon: RCSBlockSV + Group: cpgRCS + Material: metal + Shape: ModelEntity + ShowBlockName: true + MarketPrice: 805, display: true + Model: @models/Blocks/Mothership/RCS_BlockPrefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "SS,GV", display: true + AllowedInBlueprint: false, display: true + TemplateRoot: PlasticSmallBlocks + PickupTarget: PlasticSmallBlocks + SizeInBlocks: "1,1,1", display: true + Mass: 256, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + IsActivateable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + Info: bkiRCBlockSVEvent, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + BlockColor: "110,110,110" + Category: Devices + Torque: 17, type: int, display: true, formatter: NewtonMeter #RCSlight: 50 + EnergyDynamicGroup: RCS + EnergyIn: 50, type: int, display: true, formatter: Watt #original: 10; damaged = facto 5 + CPUIn: 450, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 25 + + UpgradeTo: RCSBlockSV, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: ThrusterSVSmallEvent # low-performance 'broken' variant for special setups + Class: Thruster + CustomIcon: ThrusterSVRoundNormal + IndexName: Thruster + Group: cpgThruster + MarketPrice: 685, display: true + AllowedInBlueprint: false, display: true + TemplateRoot: PlasticSmallBlocks + PickupTarget: PlasticSmallBlocks + EssentialCategory: cpgThruster + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + StackSize: 1000 + BlockColor: "110,110,110" + Model: @models/Blocks/Thrusters/RoundThrusters/ThrusterSSDirectionalPrefab + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 184, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + Info: bkiThrusterSVSmallEvent, display: true + HitPoints: 75, type: int, display: true + ThrusterForce: 25, type: int, display: true, formatter: Newton #Default 75 + EnergyIn: 100, type: int, display: true, formatter: Watt #original: 20; damaged = facto 5 + EnergyDynamicGroup: Thruster + CPUIn: 56, type: int, display: true + Category: Devices + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + UpgradeTo: ThrusterSVDirectional, display: true + + Temperature: 203, display: true +} +{ +Block Name: FuelTankSVSmallEvent # low-performance 'broken' variant for special setups + Model: @models/Blocks/Spaceship/FuelTankSVSmallPrefab + CustomIcon: FuelTankSVSmall + Class: FuelTank + IndexName: FuelTank + Group: cpgFuelTank + MarketPrice: 120, display: true + AllowedInBlueprint: false, display: true + TemplateRoot: PlasticSmallBlocks + PickupTarget: PlasticSmallBlocks + EssentialCategory: cpgFuelTank + # LootList: 26 # 10 slots + Material: metallight + Shape: ModelEntity + ShowBlockName: true + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + # IsActivateable: false # fuel tanks activate themselves if they contain fuel + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + IsIgnoreLC: true + # IsClosedOnMoving: true + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + Volume: 8, type: float, display: true, formatter: Liter + Mass: 42, type: float, display: true, formatter: Kilogram + Info: bkiFuelTankSVSmallEvent, display: true + StackSize: 2000 + BlockColor: "110,110,110" + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 75, type: int, display: true + Category: Devices + FuelAccept: "EnergyCell, EnergyCellLarge, EnergyCellHydrogen, FusionCell, BioFuel, EnergyCellSmall" + FuelCapacity: 100, type: int, display: true #Default: 300 + BlastParticleIndex: 10 + BlastRadius: 3 + BlastDamage: 150 + + Radiation: 0.5, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + + UpgradeTo: FuelTankSVSmall, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: GeneratorSVSmallEvent # low-performance 'broken' variant for special setups to quickly overload + Model: @models/Blocks/Spaceship/GeneratorSVSmallPrefab + CustomIcon: GeneratorSVSmall + IndexName: Generator + Class: Generator + Group: cpgGenerator + MarketPrice: 400, display: true + AllowedInBlueprint: false, display: true + TemplateRoot: PlasticSmallBlocks + PickupTarget: PlasticSmallBlocks + EssentialCategory: cpgGenerator + LootList: 1 + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "1,1,1", display: true + Volume: 31.25, type: float, display: true, formatter: Liter + Mass: 148, type: float, display: true, formatter: Kilogram + Info: bkiGeneratorSVSmallEvent, display: true + # ModelOffset: "0,0.5,0" + StackSize: 1000 + IsOxygenTight: true, display: true + IsActivateable: true + IsIgnoreLC: true + HitPoints: 125, type: int, display: true + EnergyOut: 50, type: int, display: true, formatter: Watt #Original: 750 + CPUIn: 87, type: int, display: true + Category: Devices + BlastParticleIndex: 2 + BlastRadius: 3 + BlastDamage: 200 + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" + + UpgradeTo: GeneratorSVSmall, display: true + + Temperature: 50, display: true + Radiation: 1, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor +} +{ +Block Name: TeleporterBAEvent + Info: bkiTeleporterBAEvent, display: true + Class: Teleporter + # MarketPrice: 2200, display: true + Group: cpgTransportation + Category: Devices + IndexName: Teleporter + RepairToTemplate: false + AllowedInBlueprint: false, display: true + CustomIcon: TeleporterBA + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Misc/TeleporterPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + # TemplateRoot: HullLargeBlocks + PickupTarget: TeleporterBA + BlockColor: "110,110,110" + IsActivateable: true + IsLockable: true + IsOxygenTight: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 1000 + IsDuplicateable: false + Mass: 320, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + CPUIn: 1, type: int, display: true + Volume: 200, type: float, display: true, formatter: Liter + HitPoints: 1000, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + RangeLY: 325 # a value of 0 allows to teleport only within the current sector + + ShowUser: No +} + +## ============= Growable Crystals ======= + +{ Block Name: CrystalsPyramidBlueStage1 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + MarketPrice: 76, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidBlueStage1Prefab.prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + { Child PlantGrowing + Next: CrystalsPyramidBlueStage2 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead + } + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Vegetables, display: true + # CropYield: 2, display: true + GrowthTimeInfo: 50, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: DurianRoot # disassemble + TemplateRoot: CrystalsPyramidBlueStage2 # deconstruct - to avoid exploit +} + +{ Block Name: CrystalsPyramidBlueStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: CrystalsPyramidBlueStage3 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidBlueStage2Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Face + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV +} + +{ Block Name: CrystalsPyramidBlueStage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: PentaxidOre + Count: 2 + } + { Child CropsGrown + OnHarvest: CrystalsPyramidBlueStage3Harvested + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidBlueStage3Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # ShowUser: NoButCSV + XpFactor: 0.25 +} + +{ Block Name: CrystalsPyramidBlueStage3Harvested + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: CrystalsPyramidBlueStage3 + GrowthRate: 45 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidBlueStage3HarvestedPrefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV +} + + +{ Block Name: CrystalsPyramidOrangeStage1 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + MarketPrice: 76, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidOrangeStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + { Child PlantGrowing + Next: CrystalsPyramidOrangeStage2 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead + } + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Vegetables, display: true + # CropYield: 2, display: true + GrowthTimeInfo: 50, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: DurianRoot # disassemble + TemplateRoot: CrystalsPyramidOrangeStage2 # deconstruct - to avoid exploit +} + +{ Block Name: CrystalsPyramidOrangeStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: CrystalsPyramidOrangeStage3 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidOrangeStage2Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Face + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV +} + +{ Block Name: CrystalsPyramidOrangeStage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: PentaxidOre + Count: 2 + } + { Child CropsGrown + OnHarvest: CrystalsPyramidOrangeStage3Harvested + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidOrangeStage3Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # ShowUser: NoButCSV + XpFactor: 0.25 +} + +{ Block Name: CrystalsPyramidOrangeStage3Harvested + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: CrystalsPyramidOrangeStage3 + GrowthRate: 45 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Crystals/CrystalsPyramidOrangeStage3HarvestedPrefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV +} + + +{ Block Name: CrystalStraightStage1 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + MarketPrice: 76, display: true + IndexName: Plant + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Crystals/CrystalStraightStage1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + { Child PlantGrowing + Next: CrystalStraightStage2 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead + } + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # CropType: Vegetables, display: true + # CropYield: 2, display: true + GrowthTimeInfo: 50, type: int, display: true, formatter: Minutes + Mass: 1, type: float, display: true, formatter: Kilogram + Info: bkiPlantSprout, display: true + Category: Farming + XpFactor: 1 + PickupTarget: DurianRoot # disassemble + TemplateRoot: CrystalStraightStage2 # deconstruct - to avoid exploit +} + +{ Block Name: CrystalStraightStage2 + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: CrystalStraightStage3 + GrowthRate: 10 + FertileLevel: 5 + OnDeath: PlantDead2 + } + Material: plants + Shape: ModelEntity + Model: @models2/Entities/Farming/Crystals/CrystalStraightStage2Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Place: Face + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV +} + +{ Block Name: CrystalStraightStage3 + Class: CropsGrown + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child DropOnHarvest + Item: PentaxidOre + Count: 1 + } + { Child CropsGrown + OnHarvest: CrystalStraightStage3Harvested + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Crystals/CrystalStraightStage3Prefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + Category: Farming + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + # ShowUser: NoButCSV + XpFactor: 0.25 +} + +{ Block Name: CrystalStraightStage3Harvested + Class: PlantGrowing + AllowedInBlueprint: false, display: true + IndexName: Plant + { Child PlantGrowing + Next: CrystalStraightStage3 + GrowthRate: 45 + FertileLevel: 5 + OnDeath: PlantDead + } + Material: plants + Shape: ModelEntity + + Model: @models2/Entities/Farming/Crystals/CrystalStraightStage3HarvestedPrefab + PickupTarget: DurianRoot + IsAccessible: false, type: bool + Collide: "bullet,rocket,melee,sight" + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + ShowBlockName: true + ShowUser: NoButCSV +} + +# ============= Airthight Ramps (with Forcefields) ============= + +{ +Block Name: Ramp1x1x1Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x1x1AirtightPrefab + SizeInBlocks: "1,1,1", display: true + Volume: 50, type: float, display: true, formatter: Liter + IsOxygenTight: true, display: true + HitPoints: 100, type: int, display: true + Mass: 15, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Name: Ramp3x1x1Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x1x1AirtightPrefab + SizeInBlocks: "3,1,1", display: true + IsOxygenTight: true, display: true + # Volume: 100, type: float, display: true, formatter: Liter + HitPoints: 100, type: int, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Name: Ramp3x2x1Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x2x1AirtightPrefab + SizeInBlocks: "3,2,1", display: true + IsOxygenTight: true, display: true + Volume: 300, type: float, display: true, formatter: Liter + HitPoints: 120, type: int, display: true + Mass: 50, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Name: Ramp3x3x1Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x3x1AirtightPrefab + SizeInBlocks: "3,3,1", display: true + IsOxygenTight: true, display: true + Volume: 400, type: float, display: true, formatter: Liter + HitPoints: 150, type: int, display: true + Mass: 75, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Name: Ramp3x4x2Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x4x2AirtightPrefab + SizeInBlocks: "3,4,2", display: true + IsOxygenTight: true, display: true + Volume: 1000, type: float, display: true, formatter: Liter + HitPoints: 200, type: int, display: true + Mass: 125, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Name: Ramp3x5x3Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp3x5x3AirtightPrefab + SizeInBlocks: "3,5,3", display: true + IsOxygenTight: true, display: true + Volume: 2000, type: float, display: true, formatter: Liter + HitPoints: 250, type: int, display: true + Mass: 175, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} + +{ +Block Name: Ramp1x2x1Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x2x1AirtightPrefab + SizeInBlocks: "1,2,1", display: true + IsOxygenTight: true, display: true + Volume: 70, type: float, display: true, formatter: Liter + HitPoints: 110, type: int, display: true + Mass: 20, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Name: Ramp1x3x1Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x3x1AirtightPrefab + SizeInBlocks: "1,3,1", display: true + IsOxygenTight: true, display: true + Volume: 100, type: float, display: true, formatter: Liter + HitPoints: 120, type: int, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} +{ +Block Name: Ramp1x4x2Airtight, Ref: RampTemplate + Model: @models/Blocks/Mothership/Ramps/Ramp1x4x2AirtightPrefab + SizeInBlocks: "1,4,2", display: true + IsOxygenTight: true, display: true + Volume: 400, type: float, display: true, formatter: Liter + HitPoints: 130, type: int, display: true + Mass: 50, type: float, display: true, formatter: Kilogram + EnergyIn: 3, type: int, display: true, formatter: Watt + ParentBlocks: "RampSmallBlocks, RampLargeBlocks" +} + +# === Special Mission Loot Devices / Blocks +# === Use for missions which give a mission item to allow all players of the mission can grab it from the box (Personal Locker mode) + +{ +Block Name: PersonalLootBlocks + CustomIcon: ScifiStorage + AllowedInBlueprint: false, display: true + Group: cpgCargoBox + StackSize: 4000 + Material: metal + Shape: Invisible + # Model: @models/Blocks/Misc/CoreBlockV2EnemyPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + #CPUIn: 1, type: int, display: true + #Mass: 1, type: float, display: true, formatter: Kilogram + #EnergyIn: 1, type: int, display: true, formatter: Watt + ChildBlocks: "PLBLockerTriple, PLBScifiNightstand, PLBScifiStorage, PLBTank1, PLBTank2, PLBContainerMS01Large, PLBScifiContainer1Large, PLBScifiContainer2Large, PLBScifiContainerPowerLarge, PLBCargoContainerSmall, PLBCargoContainerMedium, PLBContainerMS02Large, PLBContainerMS03Large, PLBContainerMS04Large, PLBContainerMS05Large, PLBCargoPalette01, PLBCargoPalette02, PLBCargoPalette03, PLBCargoPalette04" + ShowBlockName: true +} +{ +Block Name: PLBLockerTriple + CustomIcon: ContainerPersonal + Info: bkiPersonalLootBlocks, display: true + LootList: 66 + Class: ContainerPersonal + Category: Devices + Group: cpgCargoBox + AllowedInBlueprint: false, display: true + AllowPlacingAt: "Base,MS", display: false + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Material: metal + Place: Free + Shape: ModelEntity + PickupTarget: SteelPlate + TemplateRoot: DecoBlocks + Model: @models/Blocks/Mothership/ContainerPersonalPrefab + # ModelOffset: "0,0.5,0" # + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + BlockColor: "110,110,110" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowBlockName: true + DropInventoryEntity: DropContainer + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + #Volume: 400, type: float, display: true, formatter: Liter + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + #CPUIn: 0, type: int, display: true + #Mass: 0, type: float, display: true, formatter: Kilogram + #EnergyIn: 0, type: int, display: true, formatter: Watt + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 10 + ShowUser: Yes +} +{ +Block Name: PLBScifiNightstand, Ref: PLBLockerTriple + CustomIcon: ScifiNightstand + LootList: 65 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiNightstandPrefab +} +{ +Block Name: PLBScifiStorage, Ref: PLBLockerTriple + CustomIcon: ScifiStorage + LootList: 64 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiStoragePrefab +} +{ +Block Name: PLBTank1, Ref: PLBLockerTriple + CustomIcon: Tank1 + LootList: 60 + SizeInBlocks: "2,1,1", display: true + SymType: 4 + Model: @models2/Entities/Decorations/LaboratoryPack/Tank1Prefab +} +{ +Block Name: PLBTank2, Ref: PLBLockerTriple + CustomIcon: Tank2 + LootList: 60 + Model: @models2/Entities/Decorations/LaboratoryPack/Tank2Prefab +} +# == PLB Containers +{ +Block Name: PLBContainerMS01Large, Ref: PLBLockerTriple + CustomIcon: ContainerMS01Large + LootList: 60 + Model: @models/Blocks/Mothership/CargoBoxPrefab +} + +{ +Block Name: PLBScifiContainer1Large, Ref: PLBLockerTriple + CustomIcon: ScifiContainer1Large + LootList: 60 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer1Prefab +} + +{ +Block Name: PLBScifiContainer2Large, Ref: PLBLockerTriple + CustomIcon: ScifiContainer2Large + LootList: 60 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainer2Prefab +} + +{ +Block Name: PLBScifiContainerPowerLarge, Ref: PLBLockerTriple + CustomIcon: ScifiContainerPowerLarge + LootList: 60 + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/ScifiContainerPowerPrefab +} +{ +Block Name: PLBCargoContainerSmall, Ref: PLBLockerTriple + CustomIcon: CargoContainerSmall + LootList: 60 + Model: @models/Blocks/Mothership/CargoContainerSmallPrefab +} +{ +Block Name: PLBCargoContainerMedium, Ref: PLBLockerTriple + CustomIcon: CargoContainerMedium + LootList: 60 + Model: @models/Blocks/Mothership/CargoContainerMediumPrefab +} +{ +Block Name: PLBContainerMS02Large, Ref: PLBLockerTriple + CustomIcon: ContainerMS02Large + LootList: 60 + Model: @models/Blocks/Mothership/ContainerMS02LargePrefab +} +{ +Block Name: PLBContainerMS03Large, Ref: PLBLockerTriple + CustomIcon: ContainerMS03Large + LootList: 60 + Model: @models/Blocks/Mothership/ContainerMS03LargePrefab +} +{ +Block Name: PLBContainerMS04Large, Ref: PLBLockerTriple + CustomIcon: ContainerMS04Large + LootList: 60 + Model: @models/Blocks/Mothership/ContainerMS04LargePrefab +} +{ +Block Name: PLBContainerMS05Large, Ref: PLBLockerTriple + CustomIcon: ContainerMS05Large + LootList: 60 + Model: @models/Blocks/Mothership/ContainerMS05LargePrefab +} +{ +Block Name: PLBCargoPalette01, Ref: PLBLockerTriple + CustomIcon: CargoPalette01 + LootList: 60 + Model: @models/Blocks/Mothership/CargoPalette01Prefab +} +{ +Block Name: PLBCargoPalette02, Ref: PLBLockerTriple + CustomIcon: CargoPalette02 + LootList: 60 + Model: @models/Blocks/Mothership/CargoPalette02Prefab +} +{ +Block Name: PLBCargoPalette03, Ref: PLBLockerTriple + CustomIcon: CargoPalette03 + LootList: 60 + Model: @models/Blocks/Mothership/CargoPalette03Prefab +} +{ +Block Name: PLBCargoPalette04, Ref: PLBLockerTriple + CustomIcon: CargoPalette04 + LootList: 60 + Model: @models/Blocks/Mothership/CargoPalette04Prefab +} + +{ +Block Name: PersonalLootBlocksTribal + CustomIcon: TribalBox + AllowedInBlueprint: false, display: true + Group: cpgCargoBox + StackSize: 4000 + Material: metal + Shape: Invisible + # Model: @models/Blocks/Misc/CoreBlockV2EnemyPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + #CPUIn: 1, type: int, display: true + #Mass: 1, type: float, display: true, formatter: Kilogram + #EnergyIn: 1, type: int, display: true, formatter: Watt + ChildBlocks: "PLBTribalBox, PLBTribalBarrels, PLBTribalBaskets, PLBTribalSacks, PLBTribalBookcase1, PLBTribalBookcase2, PLBTribalCabinet1, PLBTribalCabinet2, PLBTribalCauldron, PLBTribalCauldron2, PLBTribalTub, PLBTribalBuckets" + ShowBlockName: true +} + +{ +Block Name: PLBTribalBox + CustomIcon: TribalBox + Info: bkiPersonalLootBlocks, display: true + LootList: 56 + Class: ContainerPersonal + Category: Devices + Group: cpgCargoBox + AllowedInBlueprint: false, display: true + AllowPlacingAt: "Base,MS", display: false + IsActivateableInCP: true + IsLockable: true + IsOxygenTight: false, display: true + Material: woodblock + Place: Free + Shape: ModelEntity + PickupTarget: WoodLogs + TemplateRoot: DecoTribalBlocks + Model: @models2/Entities/Decorations/TribalAssets/TribalBoxPrefab + # ModelOffset: "0,0.5,0" # + # Mesh-Damage-1: Mesh/D0 + # Mesh-Damage-2: Mesh/D1 + # Mesh-Damage-3: Mesh/D2 + # Mesh-Damage-4: Mesh/D3 + BlockColor: "110,110,110" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + ShowBlockName: true + DropInventoryEntity: DropContainer + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + #Volume: 400, type: float, display: true, formatter: Liter + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + #CPUIn: 0, type: int, display: true + #Mass: 0, type: float, display: true, formatter: Kilogram + #EnergyIn: 0, type: int, display: true, formatter: Watt + #BlastParticleIndex: 4 + #BlastRadius: 1 + #BlastDamage: 10 + ShowUser: Yes +} +{ +Block Name: PLBTribalBarrels, Ref: PLBTribalBox + CustomIcon: TribalBarrels + LootList: 56 + Model: @models2/Entities/Decorations/TribalAssets/TribalBarrelsPrefab +} +{ +Block Name: PLBTribalBaskets, Ref: PLBTribalBox + CustomIcon: TribalBaskets + LootList: 55 + Model: @models2/Entities/Decorations/TribalAssets/TribalBasketsPrefab +} +{ +Block Name: PLBTribalSacks, Ref: PLBTribalBox + CustomIcon: TribalSacks + LootList: 200 + Model: @models2/Entities/Decorations/TribalAssets/TribalSacksPrefab +} +{ +Block Name: PLBTribalBookcase1, Ref: PLBTribalBox + CustomIcon: TribalBookcase1 + LootList: 62 + Model: @models2/Entities/Decorations/TribalAssets/TribalBookcase1Prefab +} +{ +Block Name: PLBTribalBookcase2, Ref: PLBTribalBox + CustomIcon: TribalBookcase2 + LootList: 62 + Model: @models2/Entities/Decorations/TribalAssets/TribalBookcase2Prefab +} +{ +Block Name: PLBTribalCabinet1, Ref: PLBTribalBox + CustomIcon: TribalCabinet1 + LootList: 59 + Model: @models2/Entities/Decorations/TribalAssets/TribalCabinet1Prefab +} +{ +Block Name: PLBTribalCabinet2, Ref: PLBTribalBox + CustomIcon: TribalCabinet2 + LootList: 59 + Model: @models2/Entities/Decorations/TribalAssets/TribalCabinet2Prefab +} +{ +Block Name: PLBTribalCauldron, Ref: PLBTribalBox + CustomIcon: TribalCauldron + LootList: 200 + Model: @models2/Entities/Decorations/TribalAssets/TribalCauldronPrefab +} +{ +Block Name: PLBTribalCauldron2, Ref: PLBTribalBox + CustomIcon: TribalCauldron2 + LootList: 200 + Model: @models2/Entities/Decorations/TribalAssets/TribalCauldron2Prefab +} +{ +Block Name: PLBTribalTub, Ref: PLBTribalBox + CustomIcon: TribalTub + LootList: 200 + Model: @models2/Entities/Decorations/TribalAssets/TribalTubPrefab +} +{ +Block Name: PLBTribalBuckets, Ref: PLBTribalBox + CustomIcon: TribalBuckets + LootList: 55 + Model: @models2/Entities/Decorations/TribalAssets/TribalBucketsPrefab +} +# =========================================== +# SPECIAL 'Effect' Blocks +# =========================================== +# +# = Destroy per signal = +{ +Block Name: WoodDestroyedRemoteEffectBlocks + Material: woodblock + #CustomIcon: WoodBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 70 + TextureTable: 1 # 2 = concrete table + BlockColor: "187,135,90" + Shape: Invisible + # Model: Cube + PickupTarget: WoodPlanks + Info: bkiWoodDestroyedRemoteEffectBlocks, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 100, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + Category: BuildingBlocks + ChildBlocks: "WoodFullDestroyedRemoteEffect, WoodThinDestroyedRemoteEffect" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: WoodFullDestroyedRemoteEffect + Material: woodblock + #CustomIcon: WoodBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 70 + TextureTable: 1 # 2 = concrete table + BlockColor: "187,135,90" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 100, type: int, display: true + Mass: 300, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: WoodLargeDestroyedBlocks + + Class: Explosive + # Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + UpgradeTo: WoodFull, display: true +} +{ +Block Name: WoodThinDestroyedRemoteEffect, Ref: WoodFullDestroyedRemoteEffect + HitPoints: 50, type: int, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + IsOxygenTight: false, display: true + UpgradeTo: WoodThin, display: true +} +{ +Block Name: WoodDestroyedOnTouchEffectBlocks + Material: woodblock + #CustomIcon: WoodBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 70 + TextureTable: 1 # 2 = concrete table + BlockColor: "187,135,90" + Shape: Invisible + # Model: Cube + PickupTarget: WoodPlanks + Info: bkiWoodDestroyedRemoteEffectBlocks, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 100, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + Category: BuildingBlocks + ChildBlocks: "WoodFullDestroyedOnTouchEffectBlocks, WoodThinDestroyedOnTouchEffectBlocks" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: WoodFullDestroyedOnTouchEffectBlocks + Material: woodblock + #CustomIcon: WoodBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 70 + TextureTable: 1 # 2 = concrete table + BlockColor: "187,135,90" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 100, type: int, display: true + Mass: 300, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: WoodLargeDestroyedBlocks + UpgradeTo: WoodFull, display: true + #Class: Explosive + Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true +} +{ +Block Name: WoodThinDestroyedOnTouchEffectBlocks, Ref: WoodFullDestroyedOnTouchEffectBlocks + HitPoints: 50, type: int, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + IsOxygenTight: false, display: true + UpgradeTo: WoodThin, display: true +} +{ +Block Name: ConcreteDestroyedRemoteEffectBlocks + Material: concrete + CustomIcon: ConcreteDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: Invisible + # Model: Cube + PickupTarget: Cement + Info: bkiConcreteDestroyedRemoteEffectBlocks, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 400, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + Category: BuildingBlocks + ChildBlocks: "ConcreteFullDestroyedRemoteEffect, ConcreteThinDestroyedRemoteEffect" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: ConcreteFullDestroyedRemoteEffect + Material: concrete + CustomIcon: ConcreteDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 400, type: int, display: true + Mass: 300, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: ConcreteDestroyedBlocks + + Class: Explosive + # Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + + UpgradeTo: ConcreteFull, display: true + IsOxygenTight: false, display: true +} +{ +Block Name: ConcreteThinDestroyedRemoteEffect, Ref: ConcreteFullDestroyedRemoteEffect + HitPoints: 200, type: int, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + UpgradeTo: ConcreteThin, display: true + IsOxygenTight: false, display: true +} + +{ +Block Name: ConcreteDestroyedOnTouchEffectBlocks + Material: concrete + CustomIcon: ConcreteDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: Invisible + # Model: Cube + PickupTarget: Cement + Info: bkiConcreteDestroyedOnTouchEffectBlocks, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 400, type: int, display: false + Mass: 300, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + Category: BuildingBlocks + ChildBlocks: "ConcreteFullDestroyedOnTouchEffect, ConcreteThinDestroyedOnTouchEffect" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: ConcreteFullDestroyedOnTouchEffect + Material: concrete + CustomIcon: ConcreteDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 92 + TextureTable: 2 # 2 = concrete table + BlockColor: "170,170,170" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 400, type: int, display: true + Mass: 300, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: ConcreteDestroyedBlocks + + # Class: Explosive + Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + + UpgradeTo: ConcreteFull, display: true + IsOxygenTight: false, display: true +} +{ +Block Name: ConcreteThinDestroyedOnTouchEffect, Ref: ConcreteFullDestroyedOnTouchEffect + HitPoints: 200, type: int, display: true + Mass: 150, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + UpgradeTo: ConcreteThin, display: true + IsOxygenTight: false, display: true +} +{ +Block Name: HullLargeDestroyedRemoteEffectBlocks + Material: hullarmored + CustomIcon: HullLargeDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: Invisible + PickupTarget: SteelPlate + Info: bkiHullLargeDestroyedRemoteEffectBlocks, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 250, type: int, display: false + Mass: 500, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + Category: BuildingBlocks + ChildBlocks: "HullFullLargeDestroyedRemoteEffect, HullThinLargeDestroyedRemoteEffect" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: HullFullLargeDestroyedRemoteEffect + Material: hullarmored + CustomIcon: HullLargeDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 250, type: int, display: true + Mass: 500, type: float, display: true, formatter: Kilogram + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: HullLargeDestroyedBlocks + + Class: Explosive + # Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + UpgradeTo: HullFullLarge, display: true +} +{ +Block Name: HullThinLargeDestroyedRemoteEffect, Ref: HullFullLargeDestroyedRemoteEffect + HitPoints: 125, type: int, display: true + Mass: 250, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + IsOxygenTight: false, display: true + UpgradeTo: HullThinLarge, display: true +} +{ +Block Name: HullLargeDestroyedOnTouchEffect + Material: hullarmored + CustomIcon: HullLargeDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: Invisible + PickupTarget: SteelPlate + Info: bkiHullLargeDestroyedOnTouchEffect, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 250, type: int, display: false + Mass: 500, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + Category: BuildingBlocks + ChildBlocks: "HullFullLargeDestroyedOnTouchEffect, HullThinLargeDestroyedOnTouchEffect" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: HullFullLargeDestroyedOnTouchEffect + Material: hullarmored + CustomIcon: HullLargeDestroyedBlocks #NEEDS an adapted Icon! + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 50 + BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 250, type: int, display: true + Mass: 500, type: float, display: true, formatter: Kilogram + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: HullLargeDestroyedBlocks + UpgradeTo: HullFullLarge, display: true + + #Class: Explosive + Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true +} +{ +Block Name: HullThinLargeDestroyedOnTouchEffect, Ref: HullFullLargeDestroyedOnTouchEffect + HitPoints: 125, type: int, display: true + Mass: 250, type: float, display: true, formatter: Kilogram + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + IsOxygenTight: false, display: true + UpgradeTo: HullThinLarge, display: true +} +{ +Block Name: XenoDestroyedRemoteEffectBlocks #Xeno Steel = AlienLargeBlocks = non-radioactive + Material: alien + #CustomIcon: AlienLargeBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 36 + TextureTable: 3 + # BlockColor: "110,110,110" + Shape: Invisible + PickupTarget: XenoSubstrate + Info: bkiXenoDestroyedRemoteEffectBlocks, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 1300, type: int, display: false + Mass: 1300, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + #BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "XenoFullDestroyedRemoteEffect, XenoThinDestroyedRemoteEffect" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: XenoFullDestroyedRemoteEffect + Material: alien + #CustomIcon: AlienLargeBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 36 + TextureTable: 3 + # BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 1300, type: int, display: true + Mass: 1300, type: float, display: true, formatter: Kilogram + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: XenoLargeDestroyedBlocks + UpgradeTo: AlienFullLarge, display: true + + Class: Explosive + # Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true +} +{ +Block Name: XenoThinDestroyedRemoteEffect, Ref: XenoFullDestroyedRemoteEffect + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + HitPoints: 750, type: int, display: true + Mass: 750, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + UpgradeTo: AlienThinLarge, display: true +} +{ +Block Name: XenoDestroyedOnTouchEffect #Xeno Steel = AlienLargeBlocks = non-radioactive + Material: alien + #CustomIcon: AlienLargeBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 36 + TextureTable: 3 + # BlockColor: "110,110,110" + Shape: Invisible + PickupTarget: XenoSubstrate + Info: bkiXenoDestroyedRemoteEffectBlocks, display: true + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 1300, type: int, display: false + Mass: 1300, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + StackSize: 1000 + #BlockSizeScale: 8 + Category: BuildingBlocks + ChildBlocks: "XenoFullDestroyedOnTouchEffect, XenoThinDestroyedOnTouchEffect" + HideShapesWindow: true + IsOxygenTight: false, display: true + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ +Block Name: XenoFullDestroyedOnTouchEffect + Material: alien + #CustomIcon: AlienLargeBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Texture: 36 + TextureTable: 3 + # BlockColor: "110,110,110" + Shape: New + Place: Free + Model: Cube + AllowPlacingAt: Base, display: true + AllowedInBlueprint: false, display: true + HitPoints: 1300, type: int, display: true + Mass: 1300, type: float, display: true, formatter: Kilogram + ChildShapes: "CubeDestroyed, CutCornerEDestroyed, CutCornerBDestroyed, SlicedCornerA1Destroyed, CornerHalfBDestroyed, CornerSmallCDestroyed, CornerCDestroyed, CornerHalfA3Destroyed, RampCMediumDestroyed, RampADestroyed, RampCDestroyed, CornerRoundBDestroyed, CornerRoundADoubleDestroyed, RoundCornerADestroyed, CubeRoundConnectorADestroyed, EdgeRoundDestroyed, CylinderDestroyed, RampRoundFTripleDestroyed, RampRoundFDestroyed, SmallCornerRoundBDestroyed, SmallCornerRoundADestroyed, SphereHalfDestroyed, ConeDestroyed, ConeBDestroyed, CutCornerCDestroyed, Cylinder6WayDestroyed, CornerRoundATripleDestroyed, CornerADestroyed, CornerHalfA1Destroyed, CornerDoubleA3Destroyed, CornerSmallBDestroyed, PyramidADestroyed" + TemplateRoot: XenoLargeDestroyedBlocks + UpgradeTo: AlienFullLarge, display: true + + #Class: Explosive + Class: TrapDoorOneTime + Group: cpgSignal + Category: Devices + BlastParticleIndex: 13 + BlastRadius: 1, display: Radius + BlastDamage: 0 + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true +} +{ +Block Name: XenoThinDestroyedOnTouchEffect, Ref: XenoFullDestroyedOnTouchEffect + ChildShapes: "WallDestroyed, WallLShapeDestroyed, WallSlopedDestroyed, WallSloped3CornerDestroyed, WallSlopedCDestroyed, WallSlopedCMediumrightDestroyed, WallSlopedArightDestroyed, WallSlopedCMediumleftDestroyed, WallSlopedAleftDestroyed, WallCornerRoundBDestroyed, WallSlopedRoundDestroyed, WallEdgeRoundDestroyed, WallEdgeRound3WayDestroyed, WallCornerRoundADestroyed, WallCornerRoundCDestroyed, WallSloped3CornerLowDestroyed, WallCornerDestroyed, WallLowDestroyed, CubeHalfDestroyed, RampADoubleDestroyed, RampCLowDestroyed, RampBMediumDestroyed, RampCHalfDestroyed, CutCornerEMediumDestroyed, BeamDestroyed, CylinderThinDestroyed, CylinderThinTJointDestroyed, CylinderLDestroyed, PipesFenceDestroyed, FenceTopDestroyed, RampDDestroyed, RoundCornerLongThinDestroyed" + HitPoints: 750, type: int, display: true + Mass: 750, type: float, display: true, formatter: Kilogram + IsOxygenTight: false, display: true + UpgradeTo: AlienThinLarge, display: true +} +# === Explosive Blocks for small vessels + +{ +Block Name: ExplosiveSmallBlocksSelf + Class: Explosive + Group: cpgSignal + CustomIcon: ExplosiveBlocks + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metallight + Texture: 46 + BlockColor: "102,0,0" + # Shape: Invisible # cannot set to invisible because id=1263 is used in several POIs + Shape: New + Model: Cube + Place: Free + PickupTarget: HullSmallBlocks + AllowPlacingAt: "SS,GV", display: true + AllowedInBlueprint: false, display: true + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + HitPoints: 50, type: int, display: false + # CPUIn: 10, type: int, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + StackSize: 1000 + HideShapesWindow: true + Category: Devices + ChildBlocks: "ExplosiveSmallBlocksSelfFull, ExplosiveSmallBlocksSelfThin" + + BlastParticleIndex: 4 + BlastRadius: 1, display: Radius + BlastDamage: 500 +} +{ +Block Name: ExplosiveSmallBlocksSelfFull + Class: Explosive + Group: cpgSignal + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metallight + Texture: 46 + BlockColor: "102,0,0" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullSmallBlocks + AllowPlacingAt: "SS,GV", display: true + AllowedInBlueprint: false, display: true + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + HitPoints: 50, type: int, display: false + # CPUIn: 10, type: int, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + Category: Devices + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + BlastParticleIndex: 4 + BlastRadius: 0, display: Radius + BlastDamage: 100 +} +{ +Block Name: ExplosiveSmallBlocksSelfThin, Ref: ExplosiveSmallBlocksSelfFull + HitPoints: 25, type: int, display: true + Mass: 15, type: float, display: true, formatter: Kilogram + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +} +{ +Block Name: ExplosiveSmallBlocksRadius, Ref: ExplosiveSmallBlocksSelf + BlockColor: "255,0,0" + Mass: 15, type: float, display: true, formatter: Kilogram + PickupTarget: HullSmallBlocks + CustomIcon: ExplosiveBlocks2 + ChildBlocks: "ExplosiveSmallBlocksRadiusFull, ExplosiveSmallBlocksRadiusThin" + + BlastParticleIndex: 4 + BlastRadius: 3, display: Radius + BlastDamage: 1500 +} +{ +Block Name: ExplosiveSmallBlocksRadiusFull + Class: Explosive + Group: cpgSignal + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Material: metallight + Texture: 46 + BlockColor: "255,0,0" + Shape: New + Place: Free + Model: Cube + TemplateRoot: HullLargeBlocks + AllowPlacingAt: "SS,GV", display: true + AllowedInBlueprint: false, display: true + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: true, display: true + HitPoints: 50, type: int, display: false + # CPUIn: 10, type: int, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + Category: Devices + ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + BlastParticleIndex: 4 + BlastRadius: 3, display: Radius + BlastDamage: 1500 + BlastShieldDamage: 500 +} +{ +Block Name: ExplosiveSmallBlocksRadiusThin, Ref: ExplosiveSmallBlocksRadiusFull + Mass: 15, type: float, display: true, formatter: Kilogram + + ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +} +# === Special Dialogue Devices / Blocks + +{ +Block Name: PDAdialogueDevices + CustomIcon: PDAsingle + AllowedInBlueprint: false, display: true + Group: cpgLCD + StackSize: 4000 + Material: metal + Shape: Invisible + # Model: @models/Blocks/Misc/CoreBlockV2EnemyPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + #VolumeCapacity: 8000, type: float, display: true, formatter: Liter + #CPUIn: 1, type: int, display: true + #Mass: 1, type: float, display: true, formatter: Kilogram + #EnergyIn: 1, type: int, display: true, formatter: Watt + HitPoints: 15, type: int, display: false + ChildBlocks: "PDAsingle, PDAsingleLeft, PDAsingleTilted" + ShowBlockName: true +} +{ +Block Name: PDAsingle + # CustomIcon: PDA + Material: metallight + Class: NPCDialogue + Category: Devices + Info: bkiPDAsingle, display: true + IndexName: LCD + Group: cpgLCD + Shape: ModelEntity + Model: @models/Blocks/Misc/PDAsinglePrefab + DropMeshfile: Entities/Misc/PDADropPrefab + Place: Free + NPCModelRotation: false + #BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS,SS,GV", display: true + IsAccessible: true, type: bool + #IsActivateableInCP: true + #IsLockable: true + IsOxygenTight: false, display: true + # CanPickup: true + #IsActivateable: true + #EnergyIn: 1, type: int, display: true, formatter: Watt + # ModelOffset: "0,0.5,0" + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + TemplateRoot: LCDScreenBlocks + PickupTarget: PlasticMaterial + RepairToTemplate: false + AllowedInBlueprint: false, display: true + Volume: 1, type: float, display: true, formatter: Liter + Mass: 1, type: float, display: true, formatter: Kilogram + HitPoints: 25, type: int, display: true + EnergyIn: 0, type: int, display: true, formatter: Watt +} +{ +Block Name: PDAsingleLeft, Ref: PDAsingle + CustomIcon: PDAsingle + Model: @models/Blocks/Misc/PDAsingleLeftPrefab + DropMeshfile: Entities/Misc/PDADropPrefab +} +{ +Block Name: PDAsingleTilted, Ref: PDAsingle + CustomIcon: PDAsingle + Model: @models/Blocks/Misc/PDAsingleTiltedPrefab + DropMeshfile: Entities/Misc/PDADropPrefab +} +{ +Block Name: ConsoleGIN + CustomIcon: ConsoleTrading + Class: NPCDialogue + DialogueState: GIN_Console_Initialize + NPCModelRotation: false + Group: cpgCrewStationAlien + Material: metallight + Shape: ModelEntity + Model: @models/Blocks/Mothership/TradingConsolePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiConsoleGIN, display: true + Category: Deco Blocks + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsAccessible: true, type: bool + # IsLockable: true + IsOxygenTight: false, display: true + # TemplateRoot: ConsoleBlocks + Mass: 65, type: float, display: true, formatter: Kilogram + Volume: 12, type: float, display: true, formatter: Liter + # ModelOffset: "0,0.5,0" + EnergyIn: 1, type: int, display: true, formatter: Watt + # BlockColor: "0,64,255" + BlockColor: "0,255,255" + + BlastParticleIndex: 11 + BlastRadius: 1 + BlastDamage: 80 +} +{ +Block Name: TribalSkeletonAnimalChest, Ref: DecoTemplate + Material: woodblock + Model: @models2/Entities/Decorations/TribalAssets/TribalSkeletonAnimalChestPrefab + SizeInBlocks: "1,1,1", display: true + TemplateRoot: TribalSkeletonAnimalChest + PickupTarget: TribalSkeletonAnimalChest +} +{ +Block Name: DeadAnimalBlocks + Info: bkiDeadAnimalBlocks, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + + # Class: NPCDialogue + # NPCModelRotation: false + Class: Container + IndexName: Container + Category: Deco Blocks + + BlockColor: "110,110,110" + Material: human + Shape: ModelEntity + Place: Free + # ModelOffset: "0,0.5,0" + + + SizeInBlocks: "1,1,1", display: true + AllowPlacingAt: "Base,MS", display: false + SizeInBlocksLocked: "Base,MS" + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + + StackSize: 1000 + HitPoints: 250, type: int, display: true + Mass: 80, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: false, formatter: Watt + VolumeCapacity: 8000, type: float, display: true, formatter: Liter + XpFactor: 0 + + BlastParticleIndex: 14 + BlastRadius: 1 + BlastDamage: 0 + + ChildBlocks: "DeadRipperDog" +} +{ +Block Name: DeadRipperDog, Ref: DeadAnimalBlocks + Model: @models2/Entities/Decorations/DeadAnimals/RipperDogDeadStaticPrefab + LootList: 20 + Shape: ModelEntity + IsLockable: true + Group: cpgCrewStationAlien + TemplateRoot: TribalSkeletonAnimalChest + PickupTarget: Meat + HitPoints: 50, type: int, display: false +} +{ +Block Name: HoverbikeStatic + Model: @models2/Entities/Decorations/Hoverbikes/HoverbikeStaticPrefab + Info: bkiHoverbikeStatic, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + + # Class: NPCDialogue + # NPCModelRotation: false + Class: PassengerSeat + # IndexName: Container + Category: Deco Blocks + + # TemplateRoot: PlayerBike + PickupTarget: PlayerBike #Gives the player back a usable playerbike + + BlockColor: "110,110,110" + Material: metallight + Shape: ModelEntity + Place: Free + # ModelOffset: "0,0.5,0" + CanPickup: true + + SizeInBlocks: "1,1,1", display: true + AllowPlacingAt: "Base,MS", display: false + SizeInBlocksLocked: "Base,MS" + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + #IsAccessible: true, type: bool + IsOxygenTight: false, display: true + + StackSize: 100 + HitPoints: 100, type: int, display: true + Mass: 60, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + EnergyIn: 1, type: int, display: false, formatter: Watt + # VolumeCapacity: 8000, type: float, display: true, formatter: Liter + MarketPrice: 1200, display: true + XpFactor: 0 + RPFactor: 0 + + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + + # ChildBlocks: "HoverbikeStatic" +} +{ +Block Name: HoverbikeDestroyedStatic, Ref: HoverbikeStatic + Info: bkiHoverbikeDestroyedStatic, display: true + PickupTarget: PlayerBikeKit + Model: @models2/Entities/Decorations/Hoverbikes/HoverbikeDestroyedStaticPrefab +} + +{ +Block Name: HoverbikeHowlerStatic, Ref: HoverbikeStatic + Info: bkiHoverbikeHowlerStatic, display: true + PickupTarget: HoverbikeHowler #Gives the player back a usable playerbike + MarketPrice: 1250, display: true + Model: @models2/Entities/Decorations/Hoverbikes/HoverbikeHowlerStaticPrefab +} + +{ +Block Name: HoverbikeHunterStatic, Ref: HoverbikeStatic + Info: bkiHoverbikeHunterStatic, display: true + PickupTarget: HoverbikeHunter #Gives the player back a usable playerbike + MarketPrice: 1420, display: true + Model: @models2/Entities/Decorations/Hoverbikes/HoverbikeHunterStaticPrefab +} + +{ +Block Name: HoverbikeSpeedstarStatic, Ref: HoverbikeStatic + Info: bkiHoverbikeSpeedstarStatic, display: true + MarketPrice: 1750, display: true + PickupTarget: HoverbikeSpeedstar #Gives the player back a usable playerbike + Model: @models2/Entities/Decorations/Hoverbikes/HoverbikeSpeedstarStaticPrefab +} + +{ +Block Name: HoverbikeWaspStatic, Ref: HoverbikeStatic + Info: bkiHoverbikeWaspStatic, display: true + MarketPrice: 2158, display: true + PickupTarget: HoverbikeWasp #Gives the player back a usable playerbike + Model: @models2/Entities/Decorations/Hoverbikes/HoverbikeWaspStaticPrefab +} + +# ======= DECO & Furnishings ===== + +{ +Block Name: DecoIndoorTrees # Deco Tree + Material: metallight + Shape: Invisible + BlockColor: "110,110,110" + #Model: @models2/Entities/Decorations/Trees/Alien_Plant_01_1DecoPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 10, type: float, display: true, formatter: Liter + Mass: 14, type: float, display: true, formatter: Kilogram + HitPoints: 15, type: int, display: true + Info: bkiDecoTrees, display: true + IsAccessible: false, type: bool + StackSize: 2000 + # ModelOffset: "0,0.5,0" + Category: Deco Blocks + + MarketPrice: 20, display: true + XpFactor: 0 + RPFactor: 0 + + CanPickup: true + ChildBlocks: "Alien_Plant_01_1Deco, Alien_ShroomTree_01_3Deco, Alien_Shroom_plant2_1Deco, Alien_Plant_12Deco, AlienDesertPlantDeco, Palmetto_Sabal_Desktop_FallDeco, Alien_ShroomTree_02_1Deco, Alien_Tree_09_1Deco, Alien_Plant_06_2Deco, Palm_Coconut_Desktop_2_SpringDeco, Alien_Plant_14Deco, OmicronPalmGreenDeco, Alien_Plant_16Deco, Alien_Plant_04Deco, Cactus2Deco, UmbrellaTreeDeco, Alien_Plant_8_4Deco, Alien_Plant_11_2Deco, Alien_Plant_02_2Deco, Alien_Plant_03_2Deco" #max 32 entries + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} + +{ +Block Name: Alien_Plant_01_1Deco + Material: metallight + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_01_1DecoPrefab + TemplateRoot: DecoIndoorTrees + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,2,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 10, type: float, display: true, formatter: Liter + Mass: 14, type: float, display: true, formatter: Kilogram + HitPoints: 15, type: int, display: true + Info: bkiDecoTrees, display: true + IsAccessible: false, type: bool + # ModelOffset: "0,0.5,0" + Category: Deco Blocks + + MarketPrice: 20, display: true + XpFactor: 0 + RPFactor: 0 +} +{ +Block Name: Alien_ShroomTree_01_3Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_ShroomTree_01_3DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Shroom_plant2_1Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Shroom_plant2_1DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_12Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_12DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: AlienDesertPlantDeco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/AlienDesertPlantDecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Palmetto_Sabal_Desktop_FallDeco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Palmetto_Sabal_Desktop_FallDecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_ShroomTree_02_1Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_ShroomTree_02_1DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Tree_09_1Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Tree_09_1DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_06_2Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_06_2DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Palm_Coconut_Desktop_2_SpringDeco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Palm_Coconut_Desktop_2_SpringDecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_14Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_14DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: OmicronPalmGreenDeco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/OmicronPalmGreenDecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_16Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_16DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_04Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_04DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Cactus2Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Cactus2DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: UmbrellaTreeDeco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/UmbrellaTreeDecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_8_4Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_8_4DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_11_2Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_11_2DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_02_2Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_02_2DecoPrefab + TemplateRoot: DecoIndoorTrees +} +{ +Block Name: Alien_Plant_03_2Deco, Ref: Alien_Plant_01_1Deco + Shape: ModelEntity + Model: @models2/Entities/Decorations/Trees/Alien_Plant_03_2DecoPrefab + TemplateRoot: DecoIndoorTrees +} + +# ================================ Destroyed blocks Extensions =================================== +#{ +Block Name: HullExtendedLargeDestroyed, Ref: HullFullLargeDestroyed +# #HitPoints: 250, type: int, display: true +# #Mass: 209, type: float, display: true, formatter: Kilogram +# ChildShapes: "RoundCornerLongThinDestroyedRight" +# #UpgradeTo: HullFullLarge, display: true +# #IsOxygenTight: false, display: true +#} +#{ +Block Name: HullExtendedSmallDestroyed, Ref: HullFullSmallDestroyed +# #HitPoints: 12, type: int, display: true +# #Mass: 8, type: float, display: true, formatter: Kilogram +# ChildShapes: "RoundCornerLongThinDestroyedRight" +# #UpgradeTo: HullFullSmall, display: true +# #IsOxygenTight: false, display: true +#} +#{ +Block Name: ConcreteExtendedDestroyed, Ref: ConcreteFullDestroyed +# #HitPoints: 200, type: int, display: true +# #Mass: 150, type: float, display: true, formatter: Kilogram +# ChildShapes: "RoundCornerLongThinDestroyedRight" +# #UpgradeTo: ConcreteFull, display: true +# #IsOxygenTight: false, display: true +#} + + +# ================================ HULL SHAPES TEST =================================== + + +# { Block Id: 2048, Name: HullTest +# Material: hullarmored +# DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! +# Texture: 53 +# BlockColor: "110,110,110" +# Shape: New +# Place: Free +# Model: Wall +# Info: bkiBlockGroup, display: true +# AllowPlacingAt: "Base,MS,SS,GV", display: true +# HitPoints: 50, type: int, display: false +# Mass: 100, type: float, display: true, formatter: Kilogram +# # Category: BuildingBlocks +# ChildShapes: "WallMediumDouble, RampBDouble, SmallCornerRoundB2, CubeSteppedEdge, CubeQuarterEdge, CubeHalfRamp, CubeHalfCubeConnector, CornerSmallCMediumLow, WallLowDouble, CubeRoundConnectorAMedium, CubeRoundConnectorALow, CornerSmallBMediumLow, WallSlopedBold, CylinderCubeConnector, CylinderCubeHalfConnector, CylinderWallConnector" + # ChildBlocks: "HullTestShapes1" + # ShowUser: No +#} + + +# ================================== REPOSITORY ============================== +# ====== Add ANY new device and blocks HERE if not advised otherwise! ======== +# ====== Add a DESCRIPTIVE headline to group the new device/blocks =========== +# ====== Always add new stuff to the TOP of the list, not to the END! ======== +# ====== Always add ANYTHING as commented out with a # like seen below! ====== +# ============================================================================ + +## Device Model Repository ## +# Model: @models/Blocks/Mothership/ArmorLockerPrefab (old BACV model) +# Model: @models/Blocks/Windows/Railing/LadderVertTopPrefab (ladder top frame; not working yet) + +# Model: @models/Blocks/Misc/SecurityCamera01Prefab (non-functional/non-textured) +# Model: @models/Blocks/Misc/SecurityCamera02Prefab (non-functional/non-textured) +# Model: @models/Blocks/Misc/SecurityCamera03Prefab (non-functional/non-textured) +# +# + + + + +# # WINDOW TEST +# { Block Id: 1970, Name: Window_v1x1Bottom +# Material: hull +# Shape: ModelEntity +# Model: @models/Blocks/Windows/Standard/Window_v1x1BottomPrefab +# Mesh-Damage-1: Mesh/D0 +# Mesh-Damage-2: Mesh/D1 +# Mesh-Damage-3: Mesh/D2 +# Mesh-Damage-4: Mesh/D3 +# DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! +# Place: Free +# Info: bkiWindowNoShutterSemiTrans, display: true +# IsAccessible: false, type: bool +# Category: BuildingBlocks +# BlockColor: "110,110,110" +# IsOxygenTight: true, display: true +# Collide: Transparent +# +# AllowPlacingAt: "Base,MS,SS,GV", display: false +# SizeInBlocks: "1,1,1", display: true +# SymType: 1 +# ShowUser: No +# } +# { Block Id: 1971, Name: Window_v1x1BottomConnect1, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1BottomConnect1Prefab + # SymType: 1 +# } +# { Block Id: 1972, Name: Window_v1x1BottomConnect2, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1BottomConnect2Prefab + # SymType: 1 +# } +# { Block Id: 1973, Name: Window_v1x1L, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1LBottomLeftPrefab + # SymType: 1 +# } +# { Block Id: 1974, Name: Window_v1x1LConnect, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1LBottomLeftConnectPrefab + # SymType: 1 +# } +# { Block Id: 1975, Name: Window_v1x1C, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1CBottomPrefab + # SymType: 1 +# } +# { Block Id: 1976, Name: Window_v1x1TopBottom, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1TopBottomPrefab + # SymType: 1 +# } +# { Block Id: 1977, Name: Window_v1x1Glass, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1GlassPrefab + # SymType: 1 +# } +# { Block Id: 1978, Name: Window_v1x1Connect1, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect1Prefab + # SymType: 1 +# } +# { Block Id: 1979, Name: Window_v1x1Connect2, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect2Prefab + # SymType: 1 +# } +# { Block Id: 1980, Name: Window_v1x1Connect2Diagonal, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect2DiagonalPrefab + # SymType: 1 +# } +# { Block Id: 1981, Name: Window_v1x1Connect3, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect3Prefab + # SymType: 1 +# } +# { Block Id: 1982, Name: Window_v1x1Connect4, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect4Prefab + # SymType: 1 +# } +# { Block Id: 1983, Name: Window_3side1x1B, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_3side1x1BPrefab + # SymType: 1 +# } +# { Block Id: 1984, Name: Window_L1x1B, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_L1x1BPrefab + # SymType: 1 +# } +# { Block Id: 1985, Name: Window_L1x1C, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_L1x1CPrefab + # SymType: 1 +# } +# { Block Id: 1986, Name: Window_L1x1D, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_L1x1DPrefab + # SymType: 1 +# } +# { Block Id: 1987, Name: Window_3side1x1BThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_3side1x1BThickPrefab + # SymType: 1 +# } +# { Block Id: 1988, Name: Window_L1x1BThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_L1x1BThickPrefab + # SymType: 1 +# } +# { Block Id: 1989, Name: Window_L1x1CThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_L1x1CThickPrefab + # SymType: 1 +# } +# { Block Id: 1990, Name: Window_L1x1DThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_L1x1DThickPrefab + # SymType: 1 +# } +# { Block Id: 1991, Name: Window_v1x1BottomConnect1Thick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1BottomConnect1ThickPrefab + # SymType: 1 +# } +# { Block Id: 1992, Name: Window_v1x1BottomConnect2Thick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1BottomConnect2ThickPrefab + # SymType: 1 +# } +# { Block Id: 1993, Name: Window_v1x1BottomThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1BottomThickPrefab + # SymType: 1 +# } +# { Block Id: 1994, Name: Window_v1x1BottomTopThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1BottomTopThickPrefab + # SymType: 1 +# } +# { Block Id: 1995, Name: Window_v1x1Connect1Thick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect1ThickPrefab + # SymType: 1 +# } +# { Block Id: 1996, Name: Window_v1x1Connect2DiagonalThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect2DiagonalThickPrefab + # SymType: 1 +# } +# { Block Id: 1997, Name: Window_v1x1Connect2Thick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect2ThickPrefab + # SymType: 1 +# } +# { Block Id: 1998, Name: Window_v1x1Connect3Thick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect3ThickPrefab + # SymType: 1 +# } +# { Block Id: 1999, Name: Window_v1x1Connect4Thick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1Connect4ThickPrefab + # SymType: 1 +# } +# { Block Id: 2000, Name: Window_v1x1GlassThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1GlassThickPrefab + # SymType: 1 +# } +# { Block Id: 2001, Name: Window_v1x1LConnectThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1LConnectThickPrefab + # SymType: 1 +# } +# { Block Id: 2002, Name: Window_v1x1LThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1LThickPrefab + # SymType: 1 +# } +# { Block Id: 2003, Name: Window_v1x1CBottomThick, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1CBottomThickPrefab + # SymType: 1 +# } +# { Block Id: 2004, Name: Window_v1x1DiagonalBottomConnect1Left, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalBottomConnect1LeftPrefab + # SymType: 1 +# } +# { Block Id: 2005, Name: Window_v1x1DiagonalBottomConnect1Right, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalBottomConnect1RightPrefab + # SymType: 1 +# } +# { Block Id: 2006, Name: Window_v1x1DiagonalBottomConnect2, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalBottomConnect2Prefab + # SymType: 1 +# } +# { Block Id: 2007, Name: Window_v1x1DiagonalCBottom, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalCBottomPrefab + # SymType: 1 +# } +# { Block Id: 2008, Name: Window_v1x1DiagonalCLeft, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalCLeftPrefab + # SymType: 1 +# } +# { Block Id: 2011, Name: Window_v1x1DiagonalConnect1Left, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect1LeftPrefab + # SymType: 1 +# } +# { Block Id: 2012, Name: Window_v1x1DiagonalConnect1Right, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect1RightPrefab + # SymType: 1 +# } +# { Block Id: 2013, Name: Window_v1x1DiagonalConnect2Bottom, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalCLeftPrefab + # SymType: 1 +# } +# { Block Id: 2014, Name: Window_v1x1DiagonalConnect2DiagonalLeft, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect2DiagonalLeftPrefab + # SymType: 1 +# } +# { Block Id: 2015, Name: Window_v1x1DiagonalConnect2DiagonalRight, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect2DiagonalRightPrefab + # SymType: 1 +# } +# { Block Id: 2016, Name: Window_v1x1DiagonalConnect2Left, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect2LeftPrefab + # SymType: 1 +# } +# { Block Id: 2017, Name: Window_v1x1DiagonalConnect3Left, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect3LeftPrefab + # SymType: 1 +# } +# { Block Id: 2018, Name: Window_v1x1DiagonalConnect3Right, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect3RightPrefab + # SymType: 1 +# } +# { Block Id: 2019, Name: Window_v1x1DiagonalConnect4, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalConnect4Prefab + # SymType: 1 +# } +# { Block Id: 2020, Name: Window_v1x1DiagonalGlass, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalGlassPrefab + # SymType: 1 +# } +# { Block Id: 2021, Name: Window_v1x1DiagonalLBottomLeftConnect, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLBottomLeftConnectPrefab + # SymType: 1 +# } +# { Block Id: 2022, Name: Window_v1x1DiagonalLBottomLeft, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLBottomLeftPrefab + # SymType: 1 +# } +# { Block Id: 2023, Name: Window_v1x1DiagonalLBottomRightConnect, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLBottomRightConnectPrefab + # SymType: 1 +# } +# { Block Id: 2024, Name: Window_v1x1DiagonalLBottomRight, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLBottomRightPrefab + # SymType: 1 +# } +# { Block Id: 2025, Name: Window_v1x1DiagonalLeftConnect1Bottom, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLeftConnect1BottomPrefab + # SymType: 1 +# } +# { Block Id: 2026, Name: Window_v1x1DiagonalLeftConnect1Top, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLeftConnect1TopPrefab + # SymType: 1 +# } +# { Block Id: 2027, Name: Window_v1x1DiagonalLeftConnect2, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLeftConnect2Prefab + # SymType: 1 +# } +# { Block Id: 2028, Name: Window_v1x1DiagonalLeft, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLeftPrefab + # SymType: 1 +# } +# { Block Id: 2029, Name: Window_v1x1DiagonalLeftRight, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalLeftRightPrefab + # SymType: 1 +# } +# { Block Id: 2030, Name: Window_v1x1Diagonal, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalPrefab + # SymType: 1 +# } +# { Block Id: 2031, Name: Window_v1x1DiagonalTopBottom, Ref: Window_v1x1Bottom + # Model: @models/Blocks/Windows/Standard/Window_v1x1DiagonalTopBottomPrefab + # SymType: 1 +# } + + +### =========== HEAVY WINDOWS =========== + +# { Block Id: 1984, Name: HeavyWindowEI, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowEIPrefab +# # SymType: 1 +# MirrorTo: HeavyWindowEIMirror +# } +# { Block Id: 1985, Name: HeavyWindowEIMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowEIMirrorPrefab +# MirrorTo: HeavyWindowEI +# } +# { Block Id: 1986, Name: HeavyWindowEL, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowELPrefab +# MirrorTo: HeavyWindowELMirror +# } +# { Block Id: 1987, Name: HeavyWindowELMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowELMirrorPrefab +# MirrorTo: HeavyWindowEL +# } +# { Block Id: 1988, Name: HeavyWindowEU, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowEUPrefab +# MirrorTo: HeavyWindowEUMirror +# } +# { Block Id: 1989, Name: HeavyWindowEUMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowEUMirrorPrefab +# MirrorTo: HeavyWindowEU +# } +# { Block Id: 1991, Name: HeavyWindowFI, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowFIPrefab +# MirrorTo: HeavyWindowFIMirror +# } +# { Block Id: 1992, Name: HeavyWindowFIMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowFIMirrorPrefab +# MirrorTo: HeavyWindowFI +# } +# { Block Id: 1993, Name: HeavyWindowFL, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowFLPrefab +# MirrorTo: HeavyWindowFLMirror +# } +# { Block Id: 1994, Name: HeavyWindowFLMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowFLMirrorPrefab +# MirrorTo: HeavyWindowFL +# } +# { Block Id: 1995, Name: HeavyWindowFU, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowFUPrefab +# MirrorTo: HeavyWindowFUMirror +# } +# { Block Id: 1996, Name: HeavyWindowFUMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowFUMirrorPrefab +# MirrorTo: HeavyWindowFUMirror +# } +# { Block Id: 1998, Name: HeavyWindowGI, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowGIPrefab +# MirrorTo: HeavyWindowGIMirror +# } +# { Block Id: 1999, Name: HeavyWindowGIMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowGIMirrorPrefab +# MirrorTo: HeavyWindowGI +# } +# { Block Id: 2000, Name: HeavyWindowGL, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowGLPrefab +# MirrorTo: HeavyWindowGLMirror +# } +# { Block Id: 2001, Name: HeavyWindowGLMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowGLMirrorPrefab +# MirrorTo: HeavyWindowGL +# } +# { Block Id: 2002, Name: HeavyWindowGU, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowGUPrefab +# MirrorTo: HeavyWindowGUMirror +# } +# { Block Id: 2003, Name: HeavyWindowGUMirror, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowGUMirrorPrefab +# MirrorTo: HeavyWindowGU +# } +# { Block Id: 2014, Name: HeavyWindowEClosed, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowEClosedPrefab +# SymType: 2 +# } +# { Block Id: 2015, Name: HeavyWindowFClosed, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowFClosedPrefab +# SymType: 2 +# } +# { Block Id: 2016, Name: HeavyWindowGClosed, Ref: HeavyWindowA +# Model: @models/Blocks/Windows/Heavy/HeavyWindowGClosedPrefab +# SymType: 2 +# } + + +####ID 2017 is further down in test shapes + + +####TEST - DOUBLE RAILINGS + +# { Block Id: 2018, Name: RailingDoubleLGlassModular, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleLGlassModularPrefab +# SymType: 2 +# } +# { Block Id: 2019, Name: RailingDoubleLMetal, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleLMetalPrefab +# SymType: 2 +# } +# { Block Id: 2020, Name: RailingDoubleRoundGlassModular, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleRoundGlassModularPrefab +# SymType: 2 +# } +# { Block Id: 2021, Name: RailingDoubleRoundMetal, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleRoundMetalPrefab +# SymType: 2 +# } +# { Block Id: 2022, Name: RailingDoubleSlopeGlassModular, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleSlopeGlassModularPrefab +# SymType: 4 +# } +# { Block Id: 2023, Name: RailingDoubleSlopeMetal, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleSlopeMetalPrefab +# SymType: 4 +# } +# { +Block Id: 2026, Name: RailingDoubleL, Ref: RailingDiagonal +# Model: Mothership/RailingDoubleL, param1: RailingDoubleL +# SymType: 2 +# } +# { +Block Id: 2027, Name: RailingDoubleRound, Ref: RailingDiagonal +# Model: Mothership/RailingDoubleRound, param1: RailingDoubleRound +# SymType: 2 +# } +# { +Block Id: 2028, Name: RailingDoubleSlope, Ref: RailingDiagonal +# Model: Mothership/RailingDoubleSlope, param1: RailingDoubleSlope +# SymType: 4 +# } +# +# { Block Id: 2030, Name: RailingDoubleLGlass, Ref: RailingVertGlass +# Model: @models/Blocks/Windows/Railing/RailingDoubleLGlassPrefab +# SymType: 2 +# } +# { Block Id: 2031, Name: RailingDoubleRoundGlass, Ref: RailingVertGlass +# Model: @models/Blocks/Windows/Railing/RailingDoubleRoundGlassPrefab +# SymType: 2 +# } +# { Block Id: 2032, Name: RailingDoubleSlopeGlass, Ref: RailingVertGlass +# Model: @models/Blocks/Windows/Railing/RailingDoubleSlopeGlassPrefab +# SymType: 4 +# } +# + +#### INVERTED REAILINGS (single and double) +# +# { Block Id: 2033, Name: RailingDoubleLGlassInv, Ref: RailingVertGlass +# Model: @models/Blocks/Windows/Railing/RailingDoubleLGlassInvPrefab +# SymType: 2 +# } +# { Block Id: 2034, Name: RailingDoubleLGlassModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleLGlassModularInvPrefab +# SymType: 2 +# } +# { Block Id: 2035, Name: RailingDoubleRoundGlassInv, Ref: RailingVertGlass +# Model: @models/Blocks/Windows/Railing/RailingDoubleRoundGlassInvPrefab +# SymType: 2 +# } +# { Block Id: 2036, Name: RailingDoubleRoundGlassModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleRoundGlassModularInvPrefab +# SymType: 2 +# } +# { Block Id: 2037, Name: RailingDoubleSlopeGlassInv, Ref: RailingVertGlass +# Model: @models/Blocks/Windows/Railing/RailingDoubleSlopeGlassInvPrefab +# SymType: 4 +# } +# { Block Id: 2038, Name: RailingDoubleSlopeGlassModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDoubleSlopeGlassModularInvPrefab +# SymType: 4 +# } +# +# { Block Id: 2040, Name: RailingDoubleVertGlassModularInv, Ref: RailingDoubleVertMetal +# Model: @models/Blocks/Windows/Railing/RailingDoubleVertGlassModularInvPrefab +# SymType: 1 +# } +# { Block Id: 2041, Name: RailingDiagonalGlassModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingDiagonalGlassModularInvPrefab +# SymType: 2 +# } +# { Block Id: 2042, Name: RailingLGlassModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingLGlassModularInvPrefab +# SymType: 2 +# } +# { Block Id: 2043, Name: RailingRoundGlassModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingRoundGlassModularInvPrefab +# SymType: 2 +# } +# { Block Id: 2044, Name: RailingSlopeGlassLeftModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingSlopeGlassLeftInvPrefab +# SymType: 4 +# MirrorTo: RailingSlopeGlassRightModularInv +# } +# { Block Id: 2045, Name: RailingSlopeGlassRightModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingSlopeGlassRightInvPrefab +# SymType: 4 +# MirrorTo: RailingSlopeGlassLeftModularInv +# } +# { Block Id: 2046, Name: RailingVertGlassModularInv, Ref: RailingDiagonalGlassModular +# Model: @models/Blocks/Windows/Railing/RailingVertGlassModularInvPrefab +# SymType: 1 +# } + + + +#### ================= MATERIAL TEST SHAPES + +# { Block Id: 1993, Name: HullTestShapes1 + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # ChildShapes: "CorridorPillarD, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" +# } +# { Block Id: 1992, Name: HullTestShapes2 + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" +# } +# { Block Id: 1993, Name: HullTestShapes3, Ref: HullTestShapes1 + # HitPoints: 25, type: int, display: true + # Mass: 4, type: float, display: true, formatter: Kilogram + # ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" +# } + +# # free + +# { Block Id: 2000, Name: HullWedgeLong + # Material: metal + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Model: WedgeLong + # TemplateRoot: HullLargeBlocks + # AllowPlacingAt: "Base,MS,SS,GV", display: true + # HitPoints: 50, type: int, display: false + # Mass: 60, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # SizeInBlocks: "1,1,3" + # ShowUser: No +# } +# { Block Id: 2001, Name: HullCubeLong + # Material: metal + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Model: CubeLong + # TemplateRoot: HullLargeBlocks + # AllowPlacingAt: "Base,MS,SS,GV", display: true + # HitPoints: 50, type: int, display: false + # Mass: 60, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # SizeInBlocks: "1,1,3" + # ShowUser: No +# } + +# { Block Id: 2002, Name: HullBigTest + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hullarmored + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube_3x3x1 + # AllowPlacingAt: "Base,MS,SS,GV", display: true + # HitPoints: 50, type: int, display: true + # Mass: 1000, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # SizeInBlocks: "3,3,1", display: true + # ShowUser: No +# } +# { Block Id: 2003, Name: Hullx2 + # Material: hull + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # # do not change - block used in old structures + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: Invisible + # Place: Free + # # Model: Cube + # TemplateRoot: HullSmallBlocks + # Info: bkiBlockGroup, display: true + # IsOxygenTight: true, display: true + # AllowPlacingAt: "Base,MS", display: true + # HitPoints: 50, type: int, display: false + # Mass: 15, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # ChildBlocks: "Hullx2Full, Hullx2Thin" + + # ShowUser: No +# } + +# { Block Id: 2004, Name: Hullx2Full + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # SizeInBlocks: "2,2,2" + # ModelScale: 2 + # Category: BuildingBlocks + # ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + # ShowUser: No +# } +# { Block Id: 2005, Name: Hullx2Thin, Ref: Hullx2Full + # HitPoints: 25, type: int, display: true + # Mass: 4, type: float, display: true, formatter: Kilogram + # ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +# } + +# { Block Id: 2006, Name: Hullx4 + # Material: hull + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # # do not change - block used in old structures + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: Invisible + # Place: Free + # # Model: Cube + # TemplateRoot: HullSmallBlocks + # Info: bkiBlockGroup, display: true + # IsOxygenTight: true, display: true + # AllowPlacingAt: "Base,MS", display: true + # HitPoints: 50, type: int, display: false + # Mass: 15, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # ChildBlocks: "Hullx4Full, Hullx4Thin" + + # ShowUser: No +# } + +# { Block Id: 2007, Name: Hullx4Full + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # SizeInBlocks: "4,4,4" + # ModelScale: 4 + # Category: BuildingBlocks + # ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + # ShowUser: No +# } +# { Block Id: 2008, Name: Hullx4Thin, Ref: Hullx4Full + # HitPoints: 25, type: int, display: true + # Mass: 4, type: float, display: true, formatter: Kilogram + # ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +# } + +# { Block Id: 1990, Name: HullTest + # Material: hullarmored + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Texture: 53 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Wall + # Info: bkiBlockGroup, display: true + # AllowPlacingAt: "Base,MS,SS,GV", display: true + # HitPoints: 50, type: int, display: false + # Mass: 100, type: float, display: true, formatter: Kilogram + # # Category: BuildingBlocks + # ChildBlocks: "HullTestShapes1, HullTestShapes2, HullTestShapes3" + # ShowUser: No +# } +# { Block Id: 1991, Name: HullTestShapes1 + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # ChildShapes: "CorridorPillarD, CubeStepped, RampConnectorDleft, RampConnectorDright, RampConnectorEleft, RampConnectorFright, CubeDummy, CubeDummy, CornerDoubleA2, CornerDoubleB2, CornerDoubleB1, CornerSmallBMedium, CornerSmallBLow, CornerHalfA3Low, CornerSmallCLow, CornerB, CornerHalfA2, CornerCLow, CornerHalfC, CornerRoundBMediumQuarter, CornerRoundBLowQuarter, PipesX, PipesFenceKinked, CubeDummy, PipesL, PipesT, CylinderThin6Way, CornerRoundAMediumQuarter, CornerRoundALowQuarter, EdgeRoundLowHalf, EdgeRoundLowQuarter, EdgeRoundLowEighth" +# } +# { Block Id: 1992, Name: HullTestShapes2 + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # ChildShapes: "EdgeRoundThin, EdgeRoundMediumHalfDouble, RampRoundE, EdgeRoundThin, EdgeRoundMediumHalfDouble, CubeRoundTransitionleft, CubeDummy, CubeDummy, PipesL, CubeDummy, CubeRoundTransitionright, EdgeRoundHalf, EdgeRoundDoubleA, EdgeRoundDoubleAHalf, EdgeRoundMediumQuarter, RampEHalf, RampRoundADouble, RampRoundADoubleHalf, RampRoundBDouble, RampRoundC, RampConnectorAleft, RampConnectorAright, SlicedCornerA1Low, SlicedCornerB1, SlicedCornerB2, SlicedCornerB1Medium, SlicedCornerB2Medium, CylinderThin3Way, CylinderThin4Way, CylinderThin5Way, CubeDummy" +# } +# { Block Id: 1993, Name: HullTestShapes3, Ref: HullTestShapes1 + # HitPoints: 25, type: int, display: true + # Mass: 4, type: float, display: true, formatter: Kilogram + # ChildShapes: "CubeLShape, CubeRoundTransitionleft, CubeRoundTransitionright, RampEHalf, CutCornerDLow, RampConnectorCleft, RampConnectorCright, NotchedBMedium, NotchedCLow, CutCornerD, CutCornerDMedium, CorridorWallA, CorridorEdgeA, CorridorPillarA, CorridorWallB, CorridorEdgeB, CorridorEdgeC, CorridorPillarB, CorridorPillarC, CorridorRoof, CorridorRoofCorner, CorridorRoofCornerRound, CorridorBulkyWallA, CorridorBulkyWallAWindowed, CorridorBulkyWallB, CorridorBulkyWallBWindowed, CorridorRampA, CorridorRampB, DoorframeA, DoorframeB, DoorframeC, CorridorRoofCornerInverted" +# } + +# # free + +# { Block Id: 2000, Name: HullWedgeLong + # Material: metal + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Model: WedgeLong + # TemplateRoot: HullLargeBlocks + # AllowPlacingAt: "Base,MS,SS,GV", display: true + # HitPoints: 50, type: int, display: false + # Mass: 60, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # SizeInBlocks: "1,1,3" + # ShowUser: No +# } +# { Block Id: 2001, Name: HullCubeLong + # Material: metal + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Model: CubeLong + # TemplateRoot: HullLargeBlocks + # AllowPlacingAt: "Base,MS,SS,GV", display: true + # HitPoints: 50, type: int, display: false + # Mass: 60, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # SizeInBlocks: "1,1,3" + # ShowUser: No +# } + +# { Block Id: 2002, Name: HullBigTest + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hullarmored + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube_3x3x1 + # AllowPlacingAt: "Base,MS,SS,GV", display: true + # HitPoints: 50, type: int, display: true + # Mass: 1000, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # SizeInBlocks: "3,3,1", display: true + # ShowUser: No +# } +# { Block Id: 2003, Name: Hullx2 + # Material: hull + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # # do not change - block used in old structures + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: Invisible + # Place: Free + # # Model: Cube + # TemplateRoot: HullSmallBlocks + # Info: bkiBlockGroup, display: true + # IsOxygenTight: true, display: true + # AllowPlacingAt: "Base,MS", display: true + # HitPoints: 50, type: int, display: false + # Mass: 15, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # ChildBlocks: "Hullx2Full, Hullx2Thin" + + # ShowUser: No +# } + +# { Block Id: 2004, Name: Hullx2Full + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # SizeInBlocks: "2,2,2" + # ModelScale: 2 + # Category: BuildingBlocks + # ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + # ShowUser: No +# } +# { Block Id: 2005, Name: Hullx2Thin, Ref: Hullx2Full + # HitPoints: 25, type: int, display: true + # Mass: 4, type: float, display: true, formatter: Kilogram + # ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +# } + +# { Block Id: 2006, Name: Hullx4 + # Material: hull + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # # do not change - block used in old structures + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: Invisible + # Place: Free + # # Model: Cube + # TemplateRoot: HullSmallBlocks + # Info: bkiBlockGroup, display: true + # IsOxygenTight: true, display: true + # AllowPlacingAt: "Base,MS", display: true + # HitPoints: 50, type: int, display: false + # Mass: 15, type: float, display: true, formatter: Kilogram + # Category: BuildingBlocks + # ChildBlocks: "Hullx4Full, Hullx4Thin" + + # ShowUser: No +# } + +# { Block Id: 2007, Name: Hullx4Full + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Material: hull + # Texture: 50 + # BlockColor: "110,110,110" + # Shape: New + # Place: Free + # Model: Cube + # TemplateRoot: HullSmallBlocks + # AllowPlacingAt: "Base,MS", display: true + # Info: bkiHull, display: true + # HitPoints: 50, type: int, display: true + # Mass: 8, type: float, display: true, formatter: Kilogram + # SizeInBlocks: "4,4,4" + # ModelScale: 4 + # Category: BuildingBlocks + # ChildShapes: "Cube, CutCornerE, CutCornerB, SlicedCornerA1, CornerHalfB, CornerSmallC, CornerC, CornerHalfA3, RampCMedium, RampA, RampC, CornerRoundB, CornerRoundADouble, RoundCornerA, CubeRoundConnectorA, EdgeRound, Cylinder, RampRoundFTriple, RampRoundF, SmallCornerRoundB, SmallCornerRoundA, SphereHalf, Cone, ConeB, CutCornerC, Cylinder6Way, CornerRoundATriple, CornerA, CornerHalfA1, CornerDoubleA3, CornerSmallB, PyramidA" + + # ShowUser: No +# } +# { Block Id: 2008, Name: Hullx4Thin, Ref: Hullx4Full + # HitPoints: 25, type: int, display: true + # Mass: 4, type: float, display: true, formatter: Kilogram + # ChildShapes: "Wall, WallLShape, WallSloped, WallSloped3Corner, WallSlopedC, WallSlopedCMediumright, WallSlopedAright, WallSlopedCMediumleft, WallSlopedAleft, WallCornerRoundB, WallSlopedRound, WallEdgeRound, WallEdgeRound3Way, WallCornerRoundA, WallCornerRoundC, WallSloped3CornerLow, WallCorner, WallLow, CubeHalf, RampADouble, RampCLow, RampBMedium, RampD, CutCornerEMedium, Beam, CylinderThin, CylinderThinTJoint, CylinderL, PipesFence, FenceTop, RampCHalf, CornerHalfA3Medium" +# } + + + +# END Blocks +# ===================================================== + + + +### ==================== +### Test Area +### ==================== + + + +# { +Block Id: 2003, Name: WindowHeavySmallBlocks + # Material: hullarmored + # Shape: ModelEntity + # Model: @models/Blocks/Windows/Standard/Window_v1x1ThickPrefab + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Place: Free + # Info: bkiBlockGroup, display: true + # IsOxygenTight: true, display: true + # IsAccessible: false, type: bool + # Category: BuildingBlocks + # # ModelOffset: "0,0.5,0" + # RemoveOnSI: true + # ChildBlocks: "Window_v1x1Thick, Window_v1x2Thick, Window_v2x2Thick, Window_s1x1Thick, Window_s1x2Thick, Window_sd1x1Thick, Window_sd1x2Thick, Window_sd1x2V2Thick, Window_c1x1Thick, Window_c1x2Thick, Window_cr1x1Thick, Window_crc1x1Thick, Window_crsd1x1Thick, Window_L1x1Thick, Window_3side1x1Thick, Window_crctw1x1Thick, Window_creA1x1Thick, Window_creB1x1Thick, Window_crl1x1Thick, Window_crse1x1Thick, Window_cc1x1Thick" + # Collide: Transparent + + # UnlockCost: 0 + # UnlockLevel: 1 + # TechTreeNames: "" + + # # Parent properties + # TemplateRoot: WindowHeavySmallBlocks + # AllowPlacingAt: "GV", display: true + # Mass: 50, type: float, display: true, formatter: Kilogram + # HitPoints: 250, type: int, display: true +# } +# { +Block Id: 2005, Name: MedicStationHVT2 + # # IndexName: SpawnPoint # no spawning in medic station HV + # Group: cpgMedic + # Material: metal + # Shape: ModelEntity + # Model: @models/Blocks/Spaceship/MedicStationHVPrefab + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # Place: Free + # IsLockable: true + # IsOxygenTight: false, display: true + # AllowPlacingAt: "GV", display: true + # Info: bkiMedicinelabMS, display: true + # # ModelOffset: "0,0.5,0" + # SizeInBlocks: "2,2,1", display: true + # StackSize: 6 + # BlockColor: "110,110,110" + # DebuffNamesActivate: "Hangover,BadTrip,DermalParasite,Indigestion,PoisonFood,PoisonBit,Frostbite,Necrosis,OpenWound,DermalBurn,RadiationBurn,FractureClosed", display: EffectiveAgainst + # Category: Devices + # Mass: 200, type: float, display: true, formatter: Kilogram + # HitPoints: 100, type: int, display: true + # EnergyIn: 10, type: int, display: true, formatter: Watt + # BlastParticleIndex: 4 + # BlastRadius: 2 + # BlastDamage: 100 + + # UnlockCost: 7, display: true + # UnlockLevel: 10, display: true + # TechTreeParent: MedicStationHV + # TechTreeNames: Hover Vessel +# } + +{ Block Name: Alien_Dead_trunk_01 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Dead_trunk_01 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 82, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: Alien_Dead_trunk_02 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Dead_trunk_02 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 88, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 4" + Prob: 1 + } +} +{ Block Name: Alien_Fern1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Fern1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 37, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Flower1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Flower1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 4, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Flower2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Flower2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 11, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Flower3 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Flower3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 21, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Ground_Cover1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Ground_Cover1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 5, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Ground_Cover2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Ground_Cover2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 11, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Liana1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Liana1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 143, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_Liana2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Liana2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 143, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_Liana3 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Liana3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 143, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_Mushroom_Big1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Mushroom_Big1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Mushroom_Big2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Mushroom_Big2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Mushroom_Big3 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Mushroom_Big3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Mushroom_Big4 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Mushroom_Big4 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 17, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Mushroom_Big5 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Mushroom_Big5 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 9, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Nephente1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Nephente1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 19, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Nephente2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Nephente2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Plant_01_1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Collide: "bullet,rocket,melee,sight" + Model: @models2/EnvironmentNew/Alien/Alien_Plant_01_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 58, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_01_2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_01_2 + DecoParticle: chinesebanyan + Collide: "bullet,rocket,melee,sight" + ShowUser: No + HitPoints: 63, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_02_1 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_02_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 49, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Plant_02_2 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_02_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 38, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Plant_03_1 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_03_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 71, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: Alien_Plant_03_2 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_03_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 66, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_03_3 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_03_3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 56, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_04 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_04 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 63, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_5_1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_5_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 60, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_5_2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_5_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 57, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_06_1 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_06_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 94, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 5" + Prob: 1 + } +} +{ Block Name: Alien_Plant_06_2 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_06_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 93, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 5" + Prob: 1 + } +} +{ Block Name: Alien_Plant_07 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_07 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 27, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Plant_8_1 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_8_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 64, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_8_2 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_8_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 65, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_8_3 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_8_3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 45, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Plant_8_4 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_8_4 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 87, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: Alien_Plant_9 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_9 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 23, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Plant_10_1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_10_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 36, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Plant_10_2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_10_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 21, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Plant_11_1 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_11_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 52, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_11_2 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_11_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 72, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: Alien_Plant_12 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_12 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 149, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_Plant_13A + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_13A + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 66, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Plant_13B + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_13B + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 40, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Plant_14 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_14 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 33, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Plant_15A + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_15A + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 191, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "5, 12" + Prob: 1 + } +} +{ Block Name: Alien_Plant_15B + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_15B + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 131, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_Plant_15C + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_15C + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 186, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "5, 9" + Prob: 1 + } +} +{ Block Name: Alien_Plant_16 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Plant_16 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 48, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: Alien_Roots Cover + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Roots Cover + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 18, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Roots_01 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Roots_01 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 67, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Shroom_plant1_1 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroom_plant1_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 55, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Shroom_plant1_2 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroom_plant1_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 55, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Shroom_plant1_3 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroom_plant1_3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 23, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Shroom_plant2_1 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroom_plant2_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 13, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Shroom_plant2_2 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroom_plant2_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 13, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Shroom_plant2_3 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroom_plant2_3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 10, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_Shroom_plant2_4 + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroom_plant2_4 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 11, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: Alien_ShroomTree_01_1 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_ShroomTree_01_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 257, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 13" + Prob: 1 + } +} +{ Block Name: Alien_ShroomTree_01_2 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_ShroomTree_01_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 136, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_ShroomTree_01_3 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_ShroomTree_01_3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 110, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 5" + Prob: 1 + } +} +{ Block Name: Alien_Shroomtree_02_1 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroomtree_02_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 87, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: Alien_Shroomtree_02_2 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Shroomtree_02_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 73, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: Alien_Tree_01_1 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_01_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 177, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "5, 9" + Prob: 1 + } +} +{ Block Name: Alien_Tree_01_2 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_01_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 119, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 6" + Prob: 1 + } +} +{ Block Name: Alien_Tree_02_1 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_02_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 163, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "5, 8" + Prob: 1 + } +} +{ Block Name: Alien_Tree_03_1 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_03_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 111, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 6" + Prob: 1 + } +} +{ Block Name: Alien_Tree_03_2 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_03_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 111, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 6" + Prob: 1 + } +} +{ Block Name: Alien_Tree_04_1 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_04_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 211, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 11" + Prob: 1 + } +} +{ Block Name: Alien_Tree_04_2 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_04_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 224, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 11" + Prob: 1 + } +} +{ Block Name: Alien_Tree_04_3 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_04_3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 222, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 11" + Prob: 1 + } +} +{ Block Name: Alien_Tree_05_1 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_05_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 400, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "11, 20" + Prob: 1 + } +} +{ Block Name: Alien_Tree_05_2 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_05_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 394, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "11, 20" + Prob: 1 + } +} +{ Block Name: Alien_Tree_06_1 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_06_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 142, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_Tree_06_2 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_06_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 140, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: Alien_Tree_06_3 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_06_3 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 69, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: Alien_Tree_07 + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_07 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 531, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "15, 27" + Prob: 1 + } +} +{ Block Name: Alien_Tree_08 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_08 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 95, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 5" + Prob: 1 + } +} +{ Block Name: Alien_Tree_09_1 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_09_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 124, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 6" + Prob: 1 + } +} +{ Block Name: Alien_Tree_09_2 + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_09_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 124, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 6" + Prob: 1 + } +} +{ Block Name: Alien_Tree_Small_07_1 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_Small_07_1 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 86, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: Alien_Tree_Small_07_2 + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Alien/Alien_Tree_Small_07_2 + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 60, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: _Redwood_Sierra_Seedling + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Redwood_Sierra_Seedling + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 11, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Redwood_Sierra_Sapling + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Redwood_Sierra_Sapling + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 32, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: _Redwood_Sierra_Hero_Forest + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Redwood_Sierra_Hero_Forest + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 329, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "9, 16" + Prob: 1 + } +} +{ Block Name: _Redwood_Sierra_Hero_Field + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Redwood_Sierra_Hero_Field + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 319, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "9, 16" + Prob: 1 + } +} +{ Block Name: _Redwood_Sierra_Desktop_Forest + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Redwood_Sierra_Desktop_Forest + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 322, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "9, 16" + Prob: 1 + } +} +{ Block Name: _Redwood_Sierra_Desktop_Field + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Redwood_Sierra_Desktop_Field + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 317, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "9, 16" + Prob: 1 + } +} +{ Block Name: _Moss_Club_Desktop_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Moss_Club_Desktop_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 46, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: _Fern_Rock_Hero_2_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Fern_Rock_Hero_2_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 18, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Fern_Rock_Desktop_2_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Fern_Rock_Desktop_2_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Fern_Rock_Desktop_2_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Fern_Rock_Desktop_2_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Fern_Rock_Desktop_1_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Fern_Rock_Desktop_1_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Fern_Rock_Desktop_1_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Fern_Rock_Desktop_1_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 16, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Fern_Cinnamon_Hero_1_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Fern_Cinnamon_Hero_1_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 22, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Fern_Cinnamon_Hero_1_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/WestCoast/_Fern_Cinnamon_Hero_1_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 22, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Palmetto_Sabal_Desktop_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palmetto_Sabal_Desktop_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 112, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 6" + Prob: 1 + } +} +{ Block Name: _Palmetto_Sabal_Desktop_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palmetto_Sabal_Desktop_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 111, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 6" + Prob: 1 + } +} +{ Block Name: _Palm_Coconut_Hero_1_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palm_Coconut_Hero_1_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 121, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 6" + Prob: 1 + } +} +{ Block Name: _Palm_Coconut_Hero_1_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palm_Coconut_Hero_1_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 121, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "3, 6" + Prob: 1 + } +} +{ Block Name: _Palm_Coconut_Desktop_Sapling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palm_Coconut_Desktop_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 20, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Palm_Coconut_Desktop_Sapling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palm_Coconut_Desktop_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 19, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Palm_Coconut_Desktop_2_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palm_Coconut_Desktop_2_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 149, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Palm_Coconut_Desktop_2_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Palm_Coconut_Desktop_2_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 149, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Dracaena_Sapling_Desktop_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Dracaena_Sapling_Desktop_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 22, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Almond_Tropical_Sapling_Spring + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Almond_Tropical_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 63, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: _Almond_Tropical_Sapling_Fall + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Almond_Tropical_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 63, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: _Almond_Tropical_Desktop_2_Forest_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Almond_Tropical_Desktop_2_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 158, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "5, 8" + Prob: 1 + } +} +{ Block Name: _Almond_Tropical_Desktop_2_Forest_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Almond_Tropical_Desktop_2_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 153, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 8" + Prob: 1 + } +} +{ Block Name: _Acacia_Sapling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Acacia_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 24, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Acacia_Sapling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Tropical/_Acacia_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 24, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Vine_Balloon_Desktop_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Vine_Balloon_Desktop_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 12, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Vine_Balloon_Desktop_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Vine_Balloon_Desktop_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 12, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Vine_Balloon_Desktop_2_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Vine_Balloon_Desktop_2_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 14, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Vine_Balloon_Desktop_2_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Vine_Balloon_Desktop_2_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 14, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Hero_Forest_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Sycamore_American_Hero_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 253, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 13" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Hero_Forest_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Sycamore_American_Hero_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 253, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 13" + Prob: 1 + } +} +{ Block Name: _SweetGum_Sapling_Desktop_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_SweetGum_Sapling_Desktop_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 26, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _SweetGum_Sapling_Desktop_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_SweetGum_Sapling_Desktop_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 26, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Pine_Eastern_White_Desktop_Forest_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Pine_Eastern_White_Desktop_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 252, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 13" + Prob: 1 + } +} +{ Block Name: _Pine_Eastern_White_Desktop_Forest_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Pine_Eastern_White_Desktop_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 252, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 13" + Prob: 1 + } +} +{ Block Name: _Oak_Willow_Seedling_Desktop_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Oak_Willow_Seedling_Desktop_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 7, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Oak_Willow_Seedling_Desktop_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Oak_Willow_Seedling_Desktop_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 6, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Oak_Willow_Sapling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Oak_Willow_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 32, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: _Oak_Willow_Sapling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Oak_Willow_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 32, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: _Maple_Red_Hero_Field_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Maple_Red_Hero_Field_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 144, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Maple_Red_Hero_Field_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Maple_Red_Hero_Field_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 144, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Hickory_Pignut_Undergrowth_1_Hero + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Hickory_Pignut_Undergrowth_1_Hero + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 8, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Hickory_Pignut_Sapling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Hickory_Pignut_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 30, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Hickory_Pignut_Sapling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Temperate/_Hickory_Pignut_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 30, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Sapling_3_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Sapling_3_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 141, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Sapling_3_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Sapling_3_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 141, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Sapling_2_Spring + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Sapling_2_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 81, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Sapling_2_Fall + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Sapling_2_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 81, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Sapling_1_Spring + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Sapling_1_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 78, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Sapling_1_Fall + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Sapling_1_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 78, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 4" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Desktop_Forest_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Desktop_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 273, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "8, 14" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Desktop_Forest_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Desktop_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 273, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "8, 14" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Desktop_Field_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Desktop_Field_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 273, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "8, 14" + Prob: 1 + } +} +{ Block Name: _Sycamore_American_Desktop_Field_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Sycamore/_Sycamore_American_Desktop_Field_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 273, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "8, 14" + Prob: 1 + } +} +{ Block Name: _Oak_White_Sapling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 31, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: _Oak_White_Sapling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 31, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 2" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Forest_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 245, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 12" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Forest_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 245, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 12" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 231, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 12" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 226, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 11" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_3_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_3_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 239, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 12" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_3_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_3_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 240, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 12" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_2_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_2_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 231, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 12" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_2_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_2_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 231, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 12" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_1_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_1_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 154, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 8" + Prob: 1 + } +} +{ Block Name: _Oak_White_Desktop_Field_1_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Oak/_Oak_White_Desktop_Field_1_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 154, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 8" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Seedling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Seedling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 12, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Seedling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Seedling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 12, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Sapling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 24, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Sapling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 24, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Desktop_2_Forest_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Desktop_2_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 138, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Desktop_2_Forest_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Desktop_2_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 138, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Desktop_1_Field_Spring + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Desktop_1_Field_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 251, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 13" + Prob: 1 + } +} +{ Block Name: _Shagbark_Hickory_Desktop_1_Field_Fall + Material: woodhard + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Hickory/_Shagbark_Hickory_Desktop_1_Field_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 251, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "7, 13" + Prob: 1 + } +} +{ Block Name: _Birch_River_Desktop_2_Forest_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_River_Desktop_2_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 134, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Birch_River_Desktop_2_Forest_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_River_Desktop_2_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 134, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "4, 7" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Sapling_Spring' + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Sapling_Spring' + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 67, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Sapling_Small_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Sapling_Small_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 14, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Sapling_Small_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Sapling_Small_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 14, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Sapling_Fall + Material: woodsoft + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 67, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "2, 3" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Desktop_Forest_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Desktop_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 203, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Desktop_Forest_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Desktop_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 203, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Desktop_Field_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Desktop_Field_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 208, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Birch_Paper_Desktop_Field_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Birch/_Birch_Paper_Desktop_Field_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 208, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Desktop_1_Field_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Desktop_1_Field_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 211, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Desktop_1_Field_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Desktop_1_Field_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 211, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Desktop_1_Forest_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Desktop_1_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 202, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Desktop_1_Forest_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Desktop_1_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 202, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Desktop_2_Forest_Fall + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Desktop_2_Forest_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 210, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Desktop_2_Forest_Spring + Material: wood + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Desktop_2_Forest_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 210, type: int, display: false + { Child DropOnDestroy + Item: WoodLogs + Count: "6, 10" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Sapling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Sapling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 28, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Sapling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Sapling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 28, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Seedling_Fall + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Seedling_Fall + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 14, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} +{ Block Name: _Spruce_Norway_Seedling_Spring + Material: plants + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + Model: @models2/EnvironmentNew/Speedtree/Spruce/_Spruce_Norway_Seedling_Spring + DecoParticle: chinesebanyan + ShowUser: No + HitPoints: 14, type: int, display: false + { Child DropOnDestroy + Item: Fiber + Count: "1, 1" + Prob: 1 + } +} + +{ Block Name: Rock_small_2_4 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 11, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_2_4 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "2,2" + Prob: 1 + } +} +{ Block Name: Rock_small_2_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 15, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_2_3 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "2,3" + Prob: 1 + } +} +{ Block Name: Rock_small_2_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 13, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_2_2 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "2,3" + Prob: 1 + } +} +{ Block Name: Rock_small_2_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 18, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_2_1 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "3,4" + Prob: 1 + } +} +{ Block Name: Rock_small_1_5 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 2, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_1_5 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} +{ Block Name: Rock_small_1_4 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 6, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_1_4 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} +{ Block Name: Rock_small_1_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 5, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_1_3 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} +{ Block Name: Rock_small_1_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 8, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_1_2 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,2" + Prob: 1 + } +} +{ Block Name: Rock_small_1_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 15, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_small_1_1 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "2,3" + Prob: 1 + } +} +{ Block Name: Rock_Group_22_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 193, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_22_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,39" + Prob: 1 + } +} +{ Block Name: Rock_Group_21_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 260, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_21_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "39,52" + Prob: 1 + } +} +{ Block Name: Rock_Group_20_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 163, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_20_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "24,33" + Prob: 1 + } +} +{ Block Name: Rock_Group_19_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 215, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_19_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "32,43" + Prob: 1 + } +} +{ Block Name: Rock_Group_18_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 180, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_18_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "27,36" + Prob: 1 + } +} +{ Block Name: Rock_Group_17_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 98, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_17_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "15,20" + Prob: 1 + } +} +{ Block Name: Rock_Group_16_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 101, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_16_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "15,20" + Prob: 1 + } +} +{ Block Name: Rock_Group_15_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 138, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_15_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "21,28" + Prob: 1 + } +} +{ Block Name: Rock_Group_14_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 266, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_14_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "40,53" + Prob: 1 + } +} +{ Block Name: Rock_Group_13_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 196, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_13_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "29,39" + Prob: 1 + } +} +{ Block Name: Rock_Group_12_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 162, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_12_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "24,32" + Prob: 1 + } +} +{ Block Name: Rock_Group_11_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 361, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_11_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "54,72" + Prob: 1 + } +} +{ Block Name: Rock_Group_10_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 247, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_10_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "37,49" + Prob: 1 + } +} +{ Block Name: Rock_Group_09_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 281, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_09_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "42,56" + Prob: 1 + } +} +{ Block Name: Rock_Group_08_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 268, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_08_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "40,54" + Prob: 1 + } +} +{ Block Name: Rock_Group_07_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 257, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_07_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "39,51" + Prob: 1 + } +} +{ Block Name: Rock_Group_06_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 331, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_06_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "50,66" + Prob: 1 + } +} +{ Block Name: Rock_Group_05_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 235, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_05_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "35,47" + Prob: 1 + } +} +{ Block Name: Rock_Group_04_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 118, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_04_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "18,24" + Prob: 1 + } +} +{ Block Name: Rock_Group_03_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 132, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_03_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "20,26" + Prob: 1 + } +} +{ Block Name: Rock_Group_02_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 120, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_02_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "18,24" + Prob: 1 + } +} +{ Block Name: Rock_Group_01_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 95, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_Group_01_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "14,19" + Prob: 1 + } +} +{ Block Name: Rock_16_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 33, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_16_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "5,7" + Prob: 1 + } +} +{ Block Name: Rock_15_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 45, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_15_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "7,9" + Prob: 1 + } +} +{ Block Name: Rock_14_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 36, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_14_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "5,7" + Prob: 1 + } +} +{ Block Name: Rock_13_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 56, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_13_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "8,11" + Prob: 1 + } +} +{ Block Name: Rock_12_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 50, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_12_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "7,10" + Prob: 1 + } +} +{ Block Name: Rock_11_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 58, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_11_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "9,12" + Prob: 1 + } +} +{ Block Name: Rock_10_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 47, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_10_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "7,9" + Prob: 1 + } +} +{ Block Name: Rock_09_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 27, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_09_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "4,5" + Prob: 1 + } +} +{ Block Name: Rock_08_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 25, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_08_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "4,5" + Prob: 1 + } +} +{ Block Name: Rock_07_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 44, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_07_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "7,9" + Prob: 1 + } +} +{ Block Name: Rock_06_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 45, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_06_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "7,9" + Prob: 1 + } +} +{ Block Name: Rock_05_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 46, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_05_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "7,9" + Prob: 1 + } +} +{ Block Name: Rock_04_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 51, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_04_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "8,10" + Prob: 1 + } +} +{ Block Name: Rock_03_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 38, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_03_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "6,8" + Prob: 1 + } +} +{ Block Name: Rock_02_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 41, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_02_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "6,8" + Prob: 1 + } +} +{ Block Name: Rock_01_LoDed + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 48, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/Rock_01_LoDed + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "7,10" + Prob: 1 + } +} +{ Block Name: P_Stone_Group_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 113, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Stone_Group_3 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "17,23" + Prob: 1 + } +} +{ Block Name: P_Stone_Group_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 106, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Stone_Group_2 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "16,21" + Prob: 1 + } +} +{ Block Name: P_Stone_Group_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 62, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Stone_Group_1 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "9,12" + Prob: 1 + } +} +{ Block Name: P_Stone_4 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 23, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Stone_4 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "3,5" + Prob: 1 + } +} +{ Block Name: P_Stone_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 17, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Stone_3 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "3,3" + Prob: 1 + } +} +{ Block Name: P_Stone_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 36, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Stone_2 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "5,7" + Prob: 1 + } +} +{ Block Name: P_Stone_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 33, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Stone_1 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "5,7" + Prob: 1 + } +} +{ Block Name: P_Soil_Cliff_5 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 349, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Soil_Cliff_5 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "52,70" + Prob: 1 + } +} +{ Block Name: P_Soil_Cliff_4 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 381, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Soil_Cliff_4 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "57,76" + Prob: 1 + } +} +{ Block Name: P_Soil_Cliff_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 398, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Soil_Cliff_3 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "60,80" + Prob: 1 + } +} +{ Block Name: P_Soil_Cliff_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 523, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Soil_Cliff_2 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "78,105" + Prob: 1 + } +} +{ Block Name: P_Soil_Cliff_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 414, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Soil_Cliff_1 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "62,83" + Prob: 1 + } +} +{ Block Name: P_Rock_4 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 572, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Rock_4 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "86,114" + Prob: 1 + } +} +{ Block Name: P_Rock_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 222, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Rock_3 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "33,44" + Prob: 1 + } +} +{ Block Name: P_Rock_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 642, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Rock_2 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "96,128" + Prob: 1 + } +} +{ Block Name: P_Rock_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + Model: @models2/EnvironmentNew/Rocks/P_Rock_1 + HitPoints: 298, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "45,60" + Prob: 1 + } +} +{ Block Name: P_Pebble_Group_4 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 24, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Pebble_Group_4 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "4,5" + Prob: 1 + } +} +{ Block Name: P_Pebble_Group_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 10, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Pebble_Group_3 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "2,2" + Prob: 1 + } +} +{ Block Name: P_Pebble_Group_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 10, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Pebble_Group_2 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "2,2" + Prob: 1 + } +} +{ Block Name: P_Pebble_Group_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 31, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Pebble_Group_1 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "5,6" + Prob: 1 + } +} +{ Block Name: P_Pebble_4 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 2, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Pebble_4 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} +{ Block Name: P_Pebble_3 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + Model: @models2/EnvironmentNew/Rocks/P_Pebble_3 + HitPoints: 1, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} +{ Block Name: P_Pebble_2 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + Model: @models2/EnvironmentNew/Rocks/P_Pebble_2 + HitPoints: 2, type: int, display: false + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} +{ Block Name: P_Pebble_1 + Material: rock + Shape: Deco + IsTerrainDecoration: true + IsDeco: true + IsPlant: true + BigDecorationRadius: 10 + ShowUser: No + HitPoints: 2, type: int, display: false + Model: @models2/EnvironmentNew/Rocks/P_Pebble_1 + { Child DropOnDestroy_1 + Item: CrushedStone + Count: "1,1" + Prob: 1 + } +} + +## Gravity Generators +{ Block Name: GravityGeneratorBlocksBACV + Info: bkiGravityGeneratorBlocks, display: true + + IndexName: Gravity + Group: cpgGravityGenerator + Category: Devices + + Material: metal + + Mass: 9540, type: float, display: true, formatter: Kilogram #Only relevant for the group item; calculated as an average of devices! + Volume: 3240, type: float, display: true, formatter: Liter #Only relevant for the group item; calculated as an average of devices! + MarketPrice: 9205, display: true #Only relevant for the group item; calculated as an average of devices! + + IsOxygenTight: false, display: true #all of the group! + StackSize: 250 + + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + Shape: Invisible #Blockgroup! + ShowBlockName: true + BlockColor: "110,110,110" + + Place: Free + AllowPlacingAt: "Base,MS", display: true + + UnlockCost: 11, display: true + UnlockLevel: 7, display: true + # TechTreeParent: GeneratorBA + TechTreeNames: "Base,Capital Vessel" + + ChildBlocks: "GravityGeneratorMS, GravityGeneratorBACVMedium, GravityGeneratorBACVSmall, GravityGeneratorBACVVerySmall" +} + +{ Block Name: GravityGeneratorBACVMedium, Ref: GravityGeneratorMS + HitPoints: 300, type: int, display: true + EnergyIn: 600, type: int, display: true, formatter: Watt + CPUIn: 40, type: int, display: true + Mass: 11200, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 50, type: int, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorBACVMediumPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + SizeInBlocks: "2,1,2", display: true + + BlastParticleIndex: 4 + BlastRadius: 4 + BlastDamage: 90 + + Radiation: 4, display: RadiationLevel + Temperature: 32, display: true +} + +{ Block Name: GravityGeneratorBACVSmall, Ref: GravityGeneratorMS + HitPoints: 200, type: int, display: true + EnergyIn: 300, type: int, display: true, formatter: Watt + CPUIn: 30, type: int, display: true + Mass: 3200, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 25, type: int, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorBACVSmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + SizeInBlocks: "1,1,1", display: true + + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 80 + + Radiation: 3, display: RadiationLevel + Temperature: 32, display: true +} + +{ Block Name: GravityGeneratorBACVVerySmall, Ref: GravityGeneratorMS + HitPoints: 100, type: int, display: true + EnergyIn: 150, type: int, display: true, formatter: Watt + CPUIn: 20, type: int, display: true + Mass: 960, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 12, type: int, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorBACVVerySmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + SizeInBlocks: "1,1,1", display: true + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 70 + + Radiation: 2, display: RadiationLevel + Temperature: 32, display: true +} + +## Gravity Generators SVHV + +{ Block Name: GravityGeneratorBlocksSVHV #Block Group + Info: bkiGravityGeneratorBlocks, display: true + + IndexName: Gravity + Group: cpgGravityGenerator + Category: Devices + + Material: metal + + Mass: 564, type: float, display: true, formatter: Kilogram #Only relevant for the group item; calculated as an average of devices! + Volume: 564, type: float, display: true, formatter: Liter #Only relevant for the group item; calculated as an average of devices! + MarketPrice: 2085, display: true #Only relevant for the group item; calculated as an average of devices! + + IsOxygenTight: false, display: true #all of the group! + StackSize: 250 + + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + Shape: Invisible #Blockgroup! + ShowBlockName: true + BlockColor: "110,110,110" + + Place: Free + AllowPlacingAt: "SS,GV", display: true + + UnlockCost: 11, display: true + UnlockLevel: 7, display: true + # TechTreeParent: GeneratorBA + TechTreeNames: "Small Vessel,Hover Vessel" + + ChildBlocks: "GravityGeneratorSVHVLarge, GravityGeneratorSVHVMedium, GravityGeneratorSVHVSmall, GravityGeneratorSVHVVerySmall" +} + + +{ Block Name: GravityGeneratorSVHVLarge + Class: Generator + Info: bkiGravityGeneratorBlocksPreviewInfo, display: true + CustomIcon: GravityGeneratorBlocksSVHV + + IndexName: Gravity + Group: cpgGravityGenerator + Category: Devices + TemplateRoot: GravityGeneratorBlocksSVHV + + Material: metal + + HitPoints: 100, type: int, display: true + EnergyIn: 150, type: int, display: true, formatter: Watt + CPUIn: 20, type: int, display: true + Mass: 960, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 12, type: int, display: true + + IsActivateable: true + IsOxygenTight: false, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorSVHVLargePrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + Shape: ModelEntity + # ModelOffset: "0,0.5,0" + ShowBlockName: true + BlockColor: "110,110,110" + + Place: Free + AllowPlacingAt: "SS,GV", display: true + SizeInBlocks: "2,2,2", display: true + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 70 + + Radiation: 2, display: RadiationLevel + Temperature: 32, display: true +} + +{ Block Name: GravityGeneratorSVHVMedium, Ref: GravityGeneratorSVHVLarge + + HitPoints: 90, type: int, display: true + EnergyIn: 125, type: int, display: true, formatter: Watt + CPUIn: 18, type: int, display: true + Mass: 672, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 8, type: int, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorSVHVMediumPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + SizeInBlocks: "2,1,2", display: true + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 60 + + Radiation: 1, display: RadiationLevel + Temperature: 32, display: true +} +{ Block Name: GravityGeneratorSVHVSmall, Ref: GravityGeneratorSVHVLarge + + HitPoints: 80, type: int, display: true + EnergyIn: 100, type: int, display: true, formatter: Watt + CPUIn: 16, type: int, display: true + Mass: 384, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 4, type: int, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorSVHVSmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + SizeInBlocks: "1,1,1", display: true + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + + Radiation: 1, display: RadiationLevel + Temperature: 32, display: true +} +{ Block Name: GravityGeneratorSVHVVerySmall, Ref: GravityGeneratorSVHVLarge + + HitPoints: 70, type: int, display: true + EnergyIn: 75, type: int, display: true, formatter: Watt + CPUIn: 14, type: int, display: true + Mass: 240, type: float, display: true, formatter: Kilogram + + GravityGeneratorRadius: 2, type: int, display: true + + Model: @models/Blocks/GravityGenerators/GravityGeneratorSVHVVerySmallPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + + # DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + + SizeInBlocks: "1,1,1", display: true + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 40 + + Radiation: 1, display: RadiationLevel + Temperature: 32, display: true +} + +# New Consoles and Tables [04-2024] + +{ Block Name: ComputerStation_C01, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/ComputerStation_C01_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: ComputerStation_C02, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/ComputerStation_C02_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: ConsoleDesk_C03, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/ConsoleDesk_C03_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: ConsoleDesk_C04, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/ConsoleDesk_C04_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: ConsoleDesk_C05, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/ConsoleDesk_C05_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: StandConsole_C06, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/StandConsole_C06_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: StandConsole_C07, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/StandConsole_C07_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: StandConsole_C17, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/StandConsole_C17_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: ConsoleStation_C18, Ref: DecoTemplate + Class: NPCDialogue + NPCModelRotation: false + Info: bkiGeneralPurposeConsole, display: true + TemplateRoot: ConsoleBlocks + Model: @models2/Entities/Decorations/Consoles/ConsoleStation_C18_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} + +# New tables with dedicated group (other DecoBlocks is too filled) [04-2024] +{ Block Name: DecoTables + Material: metallight + Shape: Invisible + MarketPrice: 10, display: true + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 40, type: float, display: true, formatter: Liter + Info: bkiBlockGroup, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + StackSize: 1000 + IsAccessible: false, type: bool + IsOxygenTight: false, display: true + Category: Deco Blocks + ChildBlocks: "Table_C09, Table_C12, Table_C13, Table_C15, Table_C16, Table_C19, Table_C21, Table_C27" + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: "" +} +{ Block Name: Table_C09, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C09_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: Table_C12, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C12_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: Table_C13, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C13_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: Table_C15, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C15_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: Table_C16, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C16_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: Table_C19, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C19_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: Table_C21, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C21_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} +{ Block Name: Table_C27, Ref: DecoTemplate + TemplateRoot: DecoTables + Model: @models2/Entities/Decorations/Tables/Table_C27_Prefab + EnergyIn: 1, type: int, display: true, formatter: Watt + BlockColor: "110,110,110" + ShowUser: Yes +} + +# Lamps and Lights [04-2024] + + +# Light Large Blocks + +{ Block Name: CeilingLamp01 #New Floor/Ceiling Lamps + Material: metallight + Class: Light + Group: cpgLight + IndexName: Light + CanSetColor: true + Shape: ModelEntity + Place: Face + TemplateRoot: LightLargeBlocks + # Collide: "bullet,rocket" + Model: @models2/Entities/Decorations/Lights/CeilingLamp01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + IsDuplicateable: false + SizeInBlocks: "1,1,1", display: true + ModelScaleLocked: false + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 4000 + IsActivateable: true + # CanPickup: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + Mass: 0.4, type: float, display: true, formatter: Kilogram + RemoveOnSI: true + StabilitySupport: false +} + +{ Block Name: CeilingLamp01Corner, Ref: CeilingLamp01 + Model: @models2/Entities/Decorations/Lights/CeilingLamp01CornerPrefab + SizeInBlocks: "1,1,1", display: true +} + +{ Block Name: LampRing, Ref: CeilingLamp01 + Model: @models2/Entities/Decorations/Lights/LampRingPrefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: LampRingB, Ref: CeilingLamp01 + Model: @models2/Entities/Decorations/Lights/LampRingBPrefab + SizeInBlocks: "1,1,1", display: true + Info: bkiLampRingB, display: true +} + +{ Block Name: FloorLamp04, Ref: CeilingLamp01 + Model: @models2/Entities/Decorations/Lights/FloorLamp04Prefab + SizeInBlocks: "1,1,1", display: true + # Collide: "bullet,rocket,melee" +} + +# Street light blocks + +{ Block Name: StreetLightBlocks # lights higher than 1 block and/or lamps that would be placed singular or NOT neccessarily at a ceiling but besides walkways, streets etc; All HAVE colliders! + Material: metallight + Shape: Invisible + MarketPrice: 255, display: true + # Model: @models/Blocks/Mothership/lightMS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + Info: bkiStreetLightBlocks, display: true + # ModelOffset: "0,0.5,0" + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + Mass: 0.4, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 4000 + ChildBlocks: "ConstructionLamp02b, ConstructionLamp02, LampPole01, LampPole01b, LampPole02, LampPole02b, FloorLamp03, FloorLamp03Corner, FloorLamp03bCorner, FloorLamp03bEdge, ExplorationLamp01, ExplorationLamp01Quarter, ExplorationLamp01QuarterCorner, ExplorationLamp01QuarterEdge, FloorLamp08" #max of 32 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeParent: "" + TechTreeNames: "Base,Capital Vessel" +} + +{ Block Name: ConstructionLamp02 #StreetLights + Model: @models2/Entities/Decorations/Lights/ConstructionLamp02Prefab + Info: bkiLampBlockClipping, display: true + TemplateRoot: StreetLightBlocks + Material: metallight + Class: Light + Group: cpgLight + IndexName: Light + CanSetColor: true + Shape: ModelEntity + Place: Free + # Collide: "bullet,rocket" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + IsDuplicateable: false + SizeInBlocks: "1,1,1", display: true #1,3,1 + ModelScaleLocked: false + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 4000 + IsActivateable: true + # CanPickup: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + Mass: 0.4, type: float, display: true, formatter: Kilogram + RemoveOnSI: true + StabilitySupport: false +} +{ Block Name: ConstructionLamp02b, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/ConstructionLamp02bPrefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true #1,5,1 + Info: bkiLampBlockClipping, display: true +} +{ Block Name: LampPole01, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/LampPole01Prefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true #1,5,1 + Info: bkiLampBlockClipping, display: true +} +{ Block Name: LampPole01b, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/LampPole01bPrefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true #1,3,1 + Info: bkiLampBlockClipping, display: true +} +{ Block Name: LampPole02, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/LampPole02Prefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true #1,5,1 + Info: bkiLampBlockClipping, display: true +} +{ Block Name: LampPole02b, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/LampPole02bPrefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true #1,3,1 + Info: bkiLampBlockClipping, display: true +} +{ Block Name: FloorLamp03, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/FloorLamp03Prefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true + Info: bkiFloorLightExtention, display: true +} +{ Block Name: FloorLamp03Corner, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/FloorLamp03CornerPrefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true + Info: bkiFloorLightExtention, display: true +} + +{ Block Name: FloorLamp03bCorner, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/FloorLamp03bCornerPrefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true + Info: bkiFloorLightNormal, display: true +} +{ Block Name: FloorLamp03bEdge, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/FloorLamp03bEdgePrefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true + Info: bkiFloorLightNormal, display: true +} +{ Block Name: ExplorationLamp01, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/ExplorationLamp01Prefab + SizeInBlocks: "1,1,1", display: true + Info: bkiExplorationLamp01, display: true +} +{ Block Name: ExplorationLamp01Quarter, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/ExplorationLamp01QuarterPrefab + SizeInBlocks: "1,1,1", display: true + Info: bkiExplorationLamp01, display: true +} +{ Block Name: ExplorationLamp01QuarterCorner, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/ExplorationLamp01QuarterCornerPrefab + SizeInBlocks: "1,1,1", display: true + Info: bkiExplorationLamp01, display: true +} +{ Block Name: ExplorationLamp01QuarterEdge, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/ExplorationLamp01QuarterEdgePrefab + SizeInBlocks: "1,1,1", display: true + Info: bkiExplorationLamp01, display: true +} +{ Block Name: FloorLamp08, Ref: ConstructionLamp02 + Model: @models2/Entities/Decorations/Lights/FloorLamp08Prefab + TemplateRoot: StreetLightBlocks + SizeInBlocks: "1,1,1", display: true + Info: bkiFloorLamp08, display: true +} + + +# Work light blocks + +{ Block Name: WorkLightBlocks + Info: bkiWorkLightBlocks, display: true + CustomIcon: ConstructionLamp01 + Material: metallight + Shape: Invisible + MarketPrice: 255, display: true + # Model: @models/Blocks/Mothership/lightMS01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + # ModelOffset: "0,0.5,0" + Category: Devices + EnergyIn: 1, type: int, display: false, formatter: Watt + Mass: 0.4, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 4000 + ChildBlocks: "ConstructionLamp01, ConstructionLamp01Corner, ConstructionLamp01Edge, DeskLamp01, DeskLamp01Corner, DeskLamp01Edge, FloorLamp01, WorkLight01, WorkLight01CornerRotated" #max of 32 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeParent: "" + TechTreeNames: "Base,Capital Vessel" +} + +{ Block Name: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/ConstructionLamp01Prefab + Info: bkiWorkLightBlocks, display: true + CanPickup: true + TemplateRoot: WorkLightBlocks + Material: metallight + Class: Light + Group: cpgLight + IndexName: Light + CanSetColor: true + Shape: ModelEntity + Place: Free + # Collide: "bullet,rocket" + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + AllowPlacingAt: "Base,MS", display: true + IsDuplicateable: false + SizeInBlocks: "1,1,1", display: true + ModelScaleLocked: false + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 4000 + IsActivateable: true + # CanPickup: true + Category: Devices + EnergyIn: 1, type: int, display: true, formatter: Watt + Mass: 0.4, type: float, display: true, formatter: Kilogram + RemoveOnSI: true + StabilitySupport: false +} +{ Block Name: ConstructionLamp01Corner, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/ConstructionLamp01CornerPrefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: ConstructionLamp01Edge, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/ConstructionLamp01EdgePrefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: DeskLamp01, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/DeskLamp01Prefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: DeskLamp01Corner, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/DeskLamp01CornerPrefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: DeskLamp01Edge, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/DeskLamp01EdgePrefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: FloorLamp01, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/FloorLamp01Prefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: WorkLight01, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/WorkLight01Prefab + SizeInBlocks: "1,1,1", display: true +} +{ Block Name: WorkLight01CornerRotated, Ref: ConstructionLamp01 + Model: @models2/Entities/Decorations/Lights/WorkLight01CornerRotatedPrefab + SizeInBlocks: "1,1,1", display: true +} + +# Outdoor Deco - Trees [04-2024] + +{ +Block Name: Acacia_Sapling_Fall_BA + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + Material: metallight + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Acacia_Sapling_Fall_BAPrefab + # TemplateRoot: DecoIndoorTrees + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + PickupTarget: WoodLogs + # SizeInBlocksLocked: "Base,MS" + Volume: 10, type: float, display: true, formatter: Liter + Mass: 14, type: float, display: true, formatter: Kilogram + HitPoints: 24, type: int, display: true + Info: bkiOutdoorTrees, display: true + IsAccessible: false, type: bool + # ModelOffset: "0,0.5,0" + Category: Deco Blocks + + MarketPrice: 20, display: true + XpFactor: 0 + RPFactor: 0 +} +{ +Block Name: Alien_Shroomtree_02_1_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Shroomtree_02_1_BAPrefab + HitPoints: 87, type: int, display: true +} +{ +Block Name: Alien_Shroomtree_02_2_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Shroomtree_02_2_BAPrefab + HitPoints: 73, type: int, display: true +} +{ +Block Name: Alien_Tree_01_1_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Tree_01_1_BAPrefab + HitPoints: 177, type: int, display: true +} +{ +Block Name: Alien_Tree_01_2_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Tree_01_2_BAPrefab + HitPoints: 119, type: int, display: true +} +{ +Block Name: Alien_Tree_04_3_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Tree_04_3_BAPrefab + HitPoints: 222, type: int, display: true +} +{ +Block Name: Birch_Paper_Desktop_Forest_Fall_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Birch_Paper_Desktop_Forest_Fall_BAPrefab + HitPoints: 203, type: int, display: true +} +{ +Block Name: Birch_Paper_Desktop_Forest_Spring_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Birch_Paper_Desktop_Forest_Spring_BAPrefab + HitPoints: 203, type: int, display: true +} +{ +Block Name: Birch_Paper_Sapling_Fall_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Birch_Paper_Sapling_Fall_BAPrefab + HitPoints: 67, type: int, display: true +} +{ +Block Name: Birch_Paper_Sapling_Spring_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Birch_Paper_Sapling_Spring_BAPrefab + HitPoints: 67, type: int, display: true +} +{ +Block Name: ElderberryBushBlue_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/ElderberryBushBlue_BAPrefab + HitPoints: 100, type: int, display: true +} +{ +Block Name: ElderberryBushNoFruitPrefab_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/ElderberryBushNoFruitPrefab_BAPrefab + HitPoints: 100, type: int, display: true +} +{ +Block Name: Maple_Red_Hero_Field_Fall_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Maple_Red_Hero_Field_Fall_BAPrefab + HitPoints: 144, type: int, display: true +} +{ +Block Name: Oak_White_Desktop_Field_Fall_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Oak_White_Desktop_Field_Fall_BAPrefab + HitPoints: 226, type: int, display: true +} +{ +Block Name: Oak_White_Desktop_Field_Spring_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Oak_White_Desktop_Field_Spring_BAPrefab + HitPoints: 231, type: int, display: true +} +{ +Block Name: Palm_Coconut_Hero_1_Spring_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Palm_Coconut_Hero_1_Spring_BAPrefab + HitPoints: 121, type: int, display: true +} +{ +Block Name: Redwood_Sierra_Hero_Field_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Redwood_Sierra_Hero_Field_BAPrefab + HitPoints: 319, type: int, display: true +} +{ +Block Name: Sycamore_American_Hero_Forest_Fall_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Sycamore_American_Hero_Forest_Fall_BAPrefab + HitPoints: 253, type: int, display: true +} +{ +Block Name: Sycamore_American_Hero_Forest_Spring_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Sycamore_American_Hero_Forest_Spring_BAPrefab + HitPoints: 253, type: int, display: true +} +{ +Block Name: Sycamore_American_Sapling_1_Spring_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Sycamore_American_Sapling_1_Spring_BAPrefab + HitPoints: 78, type: int, display: true +} + +# NPC Spawners with console/models attached [04-2024] + +{ Block Name: EntitySpawnerDeviceBlocks # ESD + CustomIcon: ESD_ComputerStation_C01 + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + Material: metal + Shape: Invisible + # Model: @models2/Entities/NPCSpawners/EntitySpawnerDevice_ParentPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + # ModelOffset: "0,0.5,0" + Place: Free + AllowPlacingAt: "Base,MS", display: false + Info: bkiEntitySpawnerDeviceBlocks, display: true + Mass: 1, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: false, formatter: Watt + Category: Devices + ChildBlocks: "ESD_ComputerStation_C01, ESD_ComputerStation_C02, ESD_ConsoleDesk_C03, ESD_ConsoleDesk_C04, ESD_ConsoleDesk_C05, ESD_StandConsole_C06, ESD_StandConsole_C07, ESD_StandConsole_C11, ESD_StandConsole_C17, ESD_ConsoleMS01, ESD_ConsoleSmallMS01, ESD_ConsoleLargeMS01, ESD_ConsoleLargeMS01a, ESD_ConsoleLargeMS02, ESD_ConsoleMapMS01, ESD_ScifiComputerTable, ESD_StationConsole, ESD_GeneralPurposeConsole, ESD_ConsoleTrading, ESD_ReceptionTableWorkstation" # 20 of 32 + +} +{ Block Name: ESD_ComputerStation_C01 + Info: bkiEntitySpawnerDeviceBlocks, display: true + + Class: Spawner + Group: cpgNPCSpawner + IndexName: EntitySpawner + + SpawnClass: ServantCyborgStatic + SpawnCount: 1 + + AllowedInBlueprint: false, display: true + RepairToTemplate: false, display: true + IsActivateable: true + IsAccessible: true, type: bool + + Model: @models2/Entities/NPCSpawners/ESD_ComputerStation_C01Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab + Shape: Config3DCollider + Material: metallight + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + # Texture: 51 + # BlockColor: "170,5,5" + IsTextureable: true # !!! + IsGPUInstance: false # !!! + + #Collide: "bullet,rocket,melee,sight" # do not set for this spawner as the device needs to collide! + # AllowWander: true + + Place: Free + AllowPlacingAt: "Base,MS", display: true + RemoveOnSI: true # avoid to show the dummy block on SI collapse + + TemplateRoot: ConsoleBlocks + PickupTarget: SteelPlate + + Volume: 50, type: float, display: true, formatter: Liter + Mass: 200, type: float, display: true, formatter: Kilogram + HitPoints: 50, type: int, display: true + EnergyIn: 1, type: int, display: false, formatter: Watt + + BlastParticleIndex: 4 + BlastRadius: 1 + BlastDamage: 10 +} +{ Block Name: ESD_ComputerStation_C02, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ComputerStation_C02Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleDesk_C03, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleDesk_C03Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleDesk_C04, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleDesk_C04Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleDesk_C05, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleDesk_C05Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_StandConsole_C06, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_StandConsole_C06Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_StandConsole_C07, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_StandConsole_C07Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_StandConsole_C11, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_StandConsole_C11Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_StandConsole_C17, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_StandConsole_C17Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleMS01, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleMS01Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleSmallMS01, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleSmallMS01Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleLargeMS01, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleLargeMS01Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 + SizeInBlocks: "2,2,1", display: true + SizeInBlocksLocked: "Base,MS" +} +{ Block Name: ESD_ConsoleLargeMS01a, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleLargeMS01aPrefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleLargeMS02, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleLargeMS02Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 + SizeInBlocks: "2,1,1", display: true + SizeInBlocksLocked: "Base,MS" +} +{ Block Name: ESD_ConsoleMapMS01, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleMapMS01Prefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ScifiComputerTable, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ScifiComputerTablePrefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_StationConsole, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_StationConsolePrefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_GeneralPurposeConsole, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_GeneralPurposeConsolePrefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ConsoleTrading, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ConsoleTradingPrefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} +{ Block Name: ESD_ReceptionTableWorkstation, Ref: ESD_ComputerStation_C01 + Model: @models2/Entities/NPCSpawners/ESD_ReceptionTableWorkstationPrefab + SpawnClass: ServantCyborgStatic + SpawnCount: 1 +} + +# Shroom Tree smaller variants +{ +Block Name: Alien_Shroomtree_02_1_h2_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Shroomtree_02_1_h2_BAPrefab + HitPoints: 87, type: int, display: true +} +{ +Block Name: Alien_Shroomtree_02_2_h2_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Shroomtree_02_2_h2_BAPrefab + HitPoints: 73, type: int, display: true +} +{ +Block Name: Alien_Shroomtree_02_2_h3_BA, Ref: Acacia_Sapling_Fall_BA + Shape: ModelEntity + Model: @models2/Entities/Decorations/TreesNonPotted/Alien_Shroomtree_02_2_h3_BAPrefab + HitPoints: 73, type: int, display: true +} +# New NPC Spawners +{ Block Name: EntitySpawnerPlateThinOffsetBack, Ref: EntitySpawnerPlateThin #2648 + Texture: 51 + BlockColor: "170,5,5" + Model: @models/Blocks/Misc/EntitySpawnerPlateThinOffsetBackPrefab + Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + SpawnClass: UCHGuard01Female + SpawnCount: 1 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + AllowWander: true + RemoveOnSI: true # avoid to show the dummy block on SI collapse +} + +{ Block Name: EntitySpawnerPlateThinOffsetFront, Ref: EntitySpawnerPlateThin #2649 + Texture: 51 + BlockColor: "170,5,5" + Model: @models/Blocks/Misc/EntitySpawnerPlateThinOffsetFrontPrefab + Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + SpawnClass: UCHCivilianFemale01 + SpawnCount: 1 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + AllowWander: true + RemoveOnSI: true # avoid to show the dummy block on SI collapse +} + +{ Block Name: EntitySpawnerPlateThinEdgeInwards, Ref: EntitySpawnerPlateThin #2649 + Texture: 51 + BlockColor: "170,5,5" + Model: @models/Blocks/Misc/EntitySpawnerPlateThinEdgeInwardsPrefab + Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + SpawnClass: UCHCivilianFemale01 + SpawnCount: 1 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + AllowWander: true + RemoveOnSI: true # avoid to show the dummy block on SI collapse +} + +{ Block Name: EntitySpawnerPlateThinEdgeOutwards, Ref: EntitySpawnerPlateThin #2649 + Texture: 51 + BlockColor: "170,5,5" + Model: @models/Blocks/Misc/EntitySpawnerPlateThinEdgeOutwardsPrefab + Collide: "bullet,rocket,melee,sight" # for AI pathfinding: do not consider this block as collide with movements + SpawnClass: UCHCivilianFemale01 + SpawnCount: 1 + IsTextureable: true # !!! + IsGPUInstance: false # !!! + AllowWander: true + RemoveOnSI: true # avoid to show the dummy block on SI collapse +} + +# Shield and Walkway Emitter blocks +# Shield: Player and NPC can walk through but not shoot through + +{ Block Name: ForcefieldShieldEmitterBlocks + Material: forcefield + Info: "Force fields take damage from being shot at, but you can walk through them. They are not airtight. Note: Force fields higher than 1 block can extend into other block areas without colliding.", display: true + ShowBlockName: true + Shape: Invisible + MarketPrice: 2600, display: true + # Model: @models2/Entities/Forcefields/ForcefieldEmitter1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + IsOxygenTight: false, display: true + # IsActivateable: true + Category: Devices + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 800, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + CPUIn: 200, type: int, display: true + StackSize: 2000 + BlockColor: "110,110,110" + ChildBlocks: "ForcefieldShieldEmitter1x1, ForcefieldShieldEmitter1x1_Half, ForcefieldShieldEmitter1x2, ForcefieldShieldEmitter1x3, ForcefieldShieldEmitter2x3, ForcefieldShieldEmitter2x4, ForcefieldShieldEmitter3x5, ForcefieldShieldEmitter4x5, ForcefieldShieldEmitter5x5, ForcefieldShieldEmitter6x7, ForcefieldShieldEmitter7x7" + + UnlockCost: 10, display: true + UnlockLevel: 15, display: true + # TechTreeNames: "Small Vessel,Hover Vessel,Base,Capital Vessel" + TechTreeNames: Misc + TechTreeParent: ForcefieldWalkwayEmitterBlocks +} +{ +Block Name: ForcefieldShieldEmitter1x1 + Group: cpgForcefieldEmitter + Category: Devices + Info: bkiForcefieldShieldEmitterBlocks, display: true + Material: forcefield + ShowBlockName: true + Shape: ModelEntity + SizeInBlocks: "1,1,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield1x1Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: ForcefieldShieldEmitterBlocks + AllowPlacingAt: "Base,MS", display: true + IsOxygenTight: false, display: true + IsActivateable: true + IsAccessible: true, type: bool + EnergyIn: 350, type: int, display: true, formatter: Watt + #EnergyDynamicGroup: Shield + #EnergyInIdle: 1000, type: int, display: true, formatter: Watt + CPUIn: 150, type: int, display: true + HitPoints: 300, type: int, display: true #for testing! + Mass: 40, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" +} +{ +Block Name: ForcefieldShieldEmitter1x1_Half, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "1,1,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield1x1_Half_Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 180, type: int, display: true, formatter: Watt + CPUIn: 80, type: int, display: true + HitPoints: 150, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter1x2, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "1,2,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield1x2Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 525, type: int, display: true, formatter: Watt + CPUIn: 225, type: int, display: true + HitPoints: 621, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter1x3, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "1,3,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield1x3Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 700, type: int, display: true, formatter: Watt + CPUIn: 300, type: int, display: true + HitPoints: 937, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter2x3, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "2,3,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield2x3Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 1225, type: int, display: true, formatter: Watt + CPUIn: 525, type: int, display: true + HitPoints: 1887, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter2x4, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "2,4,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield2x4Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 1575, type: int, display: true, formatter: Watt + CPUIn: 675, type: int, display: true + HitPoints: 2523, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter3x5, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "3,5,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield3x5Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 2800, type: int, display: true, formatter: Watt + CPUIn: 1200, type: int, display: true + HitPoints: 4751, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter4x5, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "4,5,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield4x5Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 3675, type: int, display: true, formatter: Watt + CPUIn: 1575, type: int, display: true + HitPoints: 6346, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter5x5, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "5,5,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield5x5Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 4550, type: int, display: true, formatter: Watt + CPUIn: 1950, type: int, display: true + HitPoints: 7943, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter6x7, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "6,7,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield6x7Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 7525, type: int, display: true, formatter: Watt + CPUIn: 3225, type: int, display: true + HitPoints: 13382, type: int, display: true +} +{ +Block Name: ForcefieldShieldEmitter7x7, Ref: ForcefieldShieldEmitter1x1 + SizeInBlocks: "7,7,1", display: true + Model: @models2/Entities/Forcefields/ForcefieldShield7x7Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + EnergyIn: 8750, type: int, display: true, formatter: Watt + CPUIn: 3750, type: int, display: true + HitPoints: 15625, type: int, display: true +} + +{ Block Name: ForcefieldWalkwayEmitterBlocks + Material: forcefield + Info: bkiForcefieldWalkwayEmitterBlocks, display: true + ShowBlockName: true + Shape: Invisible + MarketPrice: 2600, display: true + # Model: @models2/Entities/Forcefields/ForcefieldEmitter1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: "Base,MS", display: true + IsOxygenTight: false, display: true + # IsActivateable: true + Category: Devices + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + StackSize: 2000 + BlockColor: "110,110,110" + ChildBlocks: "ForcefieldWalkwayEmitter1x1,ForcefieldWalkwayEmitter1x1_HalfHeight, ForcefieldWalkwayEmitter1x1_QuarterHeight,ForcefieldWalkwayEmitter1x1_Half,ForcefieldWalkwayEmitter1x1_Ramp,ForcefieldWalkwayEmitter1x1_RampHalf,ForcefieldWalkwayEmitter1x1_RampQuarterToHalf,ForcefieldWalkwayEmitter1x1_RampQuarter,ForcefieldWalkwayEmitter1x2_Ramp,ForcefieldWalkwayEmitter1x3_Ramp,ForcefieldWalkwayEmitter1x5_Ramp" + + UnlockCost: 7, display: true + UnlockLevel: 10, display: true + # TechTreeNames: "Small Vessel,Hover Vessel,Base,Capital Vessel" + TechTreeNames: Misc + TechTreeParent: ForcefieldEmitterBlocks +} +{ +Block Name: ForcefieldWalkwayEmitter1x1 #White + Group: cpgForcefieldEmitter + Info: bkiForcefieldWalkwayEmitterBlocks, display: true + Material: forcefield + ShowBlockName: true + Shape: ModelEntity + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + TemplateRoot: ForcefieldWalkwayEmitterBlocks + AllowPlacingAt: "Base,MS", display: true + IsOxygenTight: false, display: true + IsActivateable: true + IsAccessible: true, type: bool + Category: Devices + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + BlockColor: "110,110,110" + SizeInBlocks: "1,1,1", display: true + #OccupySizeInBlocks: true # shape has no collider so don't voxelize it NEW: we added a special O2 collider so voxelize it now + #Collide: "movement,sight" #Only for walkway. Does not stop bullets +} +{ +Block Name: ForcefieldWalkwayEmitter1x1_HalfHeight, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1_HalfHeight_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram +} +{ +Block Name: ForcefieldWalkwayEmitter1x1_QuarterHeight, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1_QuarterHeight_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram +} +{ +Block Name: ForcefieldWalkwayEmitter1x1_Half, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1_Half_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram +} +{ +Block Name: ForcefieldWalkwayEmitter1x1_Ramp, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1_Ramp_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram +} +{ +Block Name: ForcefieldWalkwayEmitter1x1_RampHalf, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1_RampHalf_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram +} +{ +Block Name: ForcefieldWalkwayEmitter1x1_RampQuarterToHalf, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1_RampQuarterToHalf_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram +} +{ +Block Name: ForcefieldWalkwayEmitter1x1_RampQuarter, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x1_RampQuarter_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram +} +{ +Block Name: ForcefieldWalkwayEmitter1x2_Ramp, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x2_Ramp_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + SizeInBlocks: "1,2,1", display: true +} +{ +Block Name: ForcefieldWalkwayEmitter1x3_Ramp, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x3_Ramp_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + SizeInBlocks: "1,3,1", display: true +} +{ +Block Name: ForcefieldWalkwayEmitter1x5_Ramp, Ref: ForcefieldWalkwayEmitter1x1 + Model: @models2/Entities/Forcefields/ForcefieldWalkway1x5_Ramp_Prefab + EnergyIn: 100, type: int, display: true, formatter: Watt + HitPoints: 80, type: int, display: true + Mass: 40, type: float, display: true, formatter: Kilogram + SizeInBlocks: "1,5,3", display: true +} + +# Field RepairBay + +{ +Block Name: FieldRepairStation + Class: FieldRepairStation + Info: bkiFieldRepairStation, display: true + IndexName: Container + Group: cpgRepair + Category: Devices + MarketPrice: 120538, display: true + GridSize: "4,4" + Material: metallight + Shape: ModelEntity + Model: @models2/Devices/FieldRepairStationPrefab + #Mesh-Damage-1: Mesh/D0 + #Mesh-Damage-2: Mesh/D1 + #Mesh-Damage-3: Mesh/D2 + #Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + DropInventoryEntity: DropContainer + Place: Free + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + BlockColor: "110,110,110" + IsActivateable: true + IsActivateableInCP: false + IsAccessible: true, type: bool + IsLockable: true + IsDuplicateable: false + IsOxygenTight: false, display: true + StackSize: 500 + Mass: 630, type: float, display: true, formatter: Kilogram + Volume: 800, type: float, display: true, formatter: Liter + # VolumeCapacity: 200, type: float, display: true, formatter: Liter + CPUIn: 10000, type: int, display: true + HitPoints: 500, type: int, display: true + EnergyIn: 1500, type: int, display: true, formatter: Watt + SoundOpen: UseActions/LightBarrier + SoundClose: UseActions/LightBarrier + MaxCount: 1, type: int, display: true + ForceMaxCount: true + + UnlockCost: 10, display: true + UnlockLevel: 10, display: true + TechTreeParent: RepairStation + TechTreeNames: "Base,Capital Vessel" +} + +# Shield Extenders + +{ Block Name: ShieldExtenderBA + Info: bkiShieldExtender, display: true + Group: cpgForcefieldEmitter + IndexName: ShieldExtender + Category: Devices + Material: metal + Shape: ModelEntity + ShowBlockName: true + Model: @models/Blocks/Devices/CapacitorFWNPrefab + DropMeshfile: Entities/Misc/BagSmallPrefab + Place: Free + AllowPlacingAt: "Base", display: true + SizeInBlocks: "1,1,1", display: true + BlockColor: "110,110,110" + # SizeInBlocksLocked: "Base" + StackSize: 250 + MaxCount: 1, type: int, display: true + ForceMaxCount: true + + IsAccessible: true, type: bool + IsActivateable: false + IsActivatedOnPlace: true + IsDuplicateable: false + + EnergyIn: 3125, type: int, display: true, formatter: Watt + EnergyInIdle: 50, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Shield + + HitPoints: 500, type: int, display: true + Mass: 6460, type: float, display: true, formatter: Kilogram + Volume: 2160, type: float, display: true, formatter: Liter + CPUIn: 16055, type: int, display: true + + ShieldCapacityBonus: 12500, type: int, display: true + ShieldRechargeBonus: 0, type: int, display: true + MarketPrice: 82672, display: true + + ShowUser: Yes + + UnlockCost: 20, display: true + UnlockLevel: 25, display: true + TechTreeParent: ShieldGeneratorBA + TechTreeNames: "Base" +} + +{ Block Name: ShieldExtenderCV, Ref: ShieldExtenderBA + AllowPlacingAt: "MS", display: true + SizeInBlocks: "1,1,1", display: true + BlockColor: "110,110,110" + # SizeInBlocksLocked: "MS" + EnergyIn: 2250, type: int, display: true, formatter: Watt + EnergyInIdle: 25, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Shield + HitPoints: 500, type: int, display: true + Mass: 6460, type: float, display: true, formatter: Kilogram + Volume: 2160, type: float, display: true, formatter: Liter + CPUIn: 11765, type: int, display: true + ShieldCapacityBonus: 9000, type: int, display: true + ShieldRechargeBonus: 0, type: int, display: true + MarketPrice: 82672, display: true + + ShowUser: Yes + + TechTreeParent: ShieldGeneratorCV + TechTreeNames: "Capital Vessel" +} + +{ Block Name: ShieldExtenderSV, Ref: ShieldExtenderBA + AllowPlacingAt: "SS", display: true + SizeInBlocks: "1,1,1", display: true + BlockColor: "110,110,110" + # SizeInBlocksLocked: "SS" + MaxCount: 1, type: int, display: true + EnergyIn: 450, type: int, display: true, formatter: Watt + EnergyInIdle: 5, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Shield + HitPoints: 250, type: int, display: true + Mass: 815, type: float, display: true, formatter: Kilogram + Volume: 93.75, type: float, display: true, formatter: Liter + CPUIn: 10595, type: int, display: true + ShieldCapacityBonus: 2000, type: int, display: true + ShieldRechargeBonus: 0, type: int, display: true + MarketPrice: 11411, display: true + + ShowUser: Yes + + UnlockLevel: 12, display: true + TechTreeParent: ShieldGeneratorSV + TechTreeNames: "Small Vessel" +} + +{ Block Name: ShieldExtenderHV, Ref: ShieldExtenderBA + AllowPlacingAt: "GV", display: true + SizeInBlocks: "1,1,1", display: true + BlockColor: "110,110,110" + # SizeInBlocksLocked: "GV" + MaxCount: 1, type: int, display: true + EnergyIn: 450, type: int, display: true, formatter: Watt + EnergyInIdle: 5, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Shield + HitPoints: 250, type: int, display: true + Mass: 815, type: float, display: true, formatter: Kilogram + Volume: 93.75, type: float, display: true, formatter: Liter + CPUIn: 10010, type: int, display: true + ShieldCapacityBonus: 3000, type: int, display: true + ShieldRechargeBonus: 0, type: int, display: true + MarketPrice: 8877, display: true + + ShowUser: Yes + + UnlockLevel: 12, display: true + TechTreeParent: ShieldGeneratorHV + TechTreeNames: "Hover Vessel" +} + +{ +Block Name: ThrusterJetMSRound1x3x1 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Class: Thruster + IndexName: Thruster + Group: cpgThruster + EssentialCategory: cpgThruster + Category: Devices + Place: Free + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + # ModelOffset: "0,0.5,0" + ParticleOffset: "-1.3,0.5,0.5" + BlockColor: "110,110,110" + AllowPlacingAt: MS, display: true + OccupySizeInBlocks: true # do not allow to place blocks in the middle + IsDuplicateable: false + IsActivateable: true + IsOxygenTight: true, display: true + IsIgnoreLC: true + StackSize: 500 + Info: bkiThrusterJetMS, display: true + #CustomIcon: ThrusterJetRound1x3x1 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound1x3x1Prefab + SizeInBlocks: "1,3,1", display: true + MarketPrice: 8609, display: true + Volume: 1140, type: float, display: true, formatter: Liter + Mass: 2733, type: float, display: true, formatter: Kilogram + HitPoints: 1225, type: int, display: true + ThrusterForce: 19301, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 1096, type: int, display: true, formatter: Watt + EnergyDynamicGroup: Thruster + CPUIn: 10803, type: int, display: true + Temperature: 1017, display: true + Radiation: 4, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 113 + UnlockCost: 10, display: true + UnlockLevel: 10, display: true + TechTreeParent: ThrusterMSDirectional + TechTreeNames: Capital Vessel +} +{ +Block Name: ThrusterJetMSRound2x5x2, Ref: ThrusterJetMSRound1x3x1 + Info: bkiThrusterJetMS, display: true + #CustomIcon: ThrusterJetRound2x5x2 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound2x5x2Prefab + SizeInBlocks: "2,5,2", display: true + MarketPrice: 39272, display: true + Volume: 3343, type: float, display: true, formatter: Liter + Mass: 11128, type: float, display: true, formatter: Kilogram + HitPoints: 6503, type: int, display: true + ThrusterForce: 193847, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 5496, type: int, display: true, formatter: Watt + CPUIn: 65502, type: int, display: true + Temperature: 1205, display: true + Radiation: 6, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 150 + UnlockCost: 12, display: true + UnlockLevel: 12, display: true + TechTreeParent: ThrusterJetMSRound1x3x1 + TechTreeNames: Capital Vessel + UpgradeTo: ThrusterJetMSRound2x5x2V2, display: true +} +{ +Block Name: ThrusterJetMSRound2x5x2V2, Ref: ThrusterJetMSRound1x3x1 + Info: bkiThrusterJetMSvariant, display: true + #CustomIcon: ThrusterJetRound2x5x2V2 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound2x5x2V2Prefab + SizeInBlocks: "2,5,2", display: true + MarketPrice: 39272, display: true + Volume: 3343, type: float, display: true, formatter: Liter + Mass: 11128, type: float, display: true, formatter: Kilogram + HitPoints: 6503, type: int, display: true + ThrusterForce: 193847, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 5496, type: int, display: true, formatter: Watt + CPUIn: 65502, type: int, display: true + Temperature: 1205, display: true + Radiation: 6, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 150 + UnlockCost: 12, display: true + UnlockLevel: 12, display: true + TechTreeParent: ThrusterJetMSRound1x3x1 + TechTreeNames: Capital Vessel + DowngradeTo: ThrusterJetMSRound2x5x2, display: true +} +{ +Block Name: ThrusterJetMSRound3x7x3, Ref: ThrusterJetMSRound1x3x1 + Info: bkiThrusterJetMS, display: true + #CustomIcon: ThrusterJetRound3x7x3 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound3x7x3Prefab + SizeInBlocks: "3,7,3", display: true + MarketPrice: 98341, display: true + Volume: 6408, type: float, display: true, formatter: Liter + Mass: 26010, type: float, display: true, formatter: Kilogram + HitPoints: 17849, type: int, display: true + ThrusterForce: 782357, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 14575, type: int, display: true, formatter: Watt + CPUIn: 194828, type: int, display: true + Temperature: 1336, display: true + Radiation: 7, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 178 + UnlockCost: 15, display: true + UnlockLevel: 15, display: true + TechTreeParent: ThrusterJetMSRound2x5x2 + TechTreeNames: Capital Vessel +} +{ +Block Name: ThrusterJetMSRound3x10x3, Ref: ThrusterJetMSRound1x3x1 + Info: bkiThrusterJetMS, display: true + #CustomIcon: ThrusterJetRound3x10x3 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound3x10x3Prefab + SizeInBlocks: "3,10,3", display: true + MarketPrice: 130814, display: true + Volume: 7844, type: float, display: true, formatter: Liter + Mass: 33867, type: float, display: true, formatter: Kilogram + HitPoints: 24431, type: int, display: true + ThrusterForce: 1207163, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 19737, type: int, display: true, formatter: Watt + CPUIn: 273406, type: int, display: true + Temperature: 1379, display: true + Radiation: 8, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 188 + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeParent: ThrusterJetMSRound3x7x3 + TechTreeNames: Capital Vessel + UpgradeTo: ThrusterJetMSRound3x10x3V2, display: true +} +{ +Block Name: ThrusterJetMSRound3x10x3V2, Ref: ThrusterJetMSRound1x3x1 + Info: bkiThrusterJetMSvariant, display: true + #CustomIcon: ThrusterJetRound3x10x3V2 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound3x10x3V2Prefab + SizeInBlocks: "3,10,3", display: true + MarketPrice: 130814, display: true + Volume: 7844, type: float, display: true, formatter: Liter + Mass: 33867, type: float, display: true, formatter: Kilogram + HitPoints: 24431, type: int, display: true + ThrusterForce: 1207163, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 19737, type: int, display: true, formatter: Watt + CPUIn: 273406, type: int, display: true + Temperature: 1379, display: true + Radiation: 8, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 188 + UnlockCost: 20, display: true + UnlockLevel: 20, display: true + TechTreeParent: ThrusterJetMSRound2x5x2V2 + TechTreeNames: Capital Vessel + DowngradeTo: ThrusterJetMSRound3x10x3, display: true +} +{ +Block Name: ThrusterJetMSRound3x13x3, Ref: ThrusterJetMSRound1x3x1 + Info: bkiThrusterJetMS, display: true + #CustomIcon: ThrusterJetRound3x13x3 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound3x13x3Prefab + SizeInBlocks: "3,13,3", display: true + MarketPrice: 161365, display: true + Volume: 9102, type: float, display: true, formatter: Liter + Mass: 41124, type: float, display: true, formatter: Kilogram + HitPoints: 30776, type: int, display: true + ThrusterForce: 1660814, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 24667, type: int, display: true, formatter: Watt + CPUIn: 350796, type: int, display: true + Temperature: 1412, display: true + Radiation: 8, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 196 + UnlockCost: 25, display: true + UnlockLevel: 25, display: true + TechTreeParent: ThrusterJetMSRound3x10x3 + TechTreeNames: Capital Vessel + UpgradeTo: ThrusterJetMSRound3x13x3V2, display: true +} +{ +Block Name: ThrusterJetMSRound3x13x3V2, Ref: ThrusterJetMSRound1x3x1 + Info: bkiThrusterJetMSvariant, display: true + #CustomIcon: ThrusterJetRound3x13x3V2 + Model: @models/Blocks/Thrusters/ThrusterJetMSRound3x13x3V2Prefab + SizeInBlocks: "3,13,3", display: true + MarketPrice: 161365, display: true + Volume: 9102, type: float, display: true, formatter: Liter + Mass: 41124, type: float, display: true, formatter: Kilogram + HitPoints: 30776, type: int, display: true + ThrusterForce: 1660814, type: int, display: true, formatter: Newton + ThrusterBoosterFactor: 1.25, type: float, display: true + EnergyIn: 24667, type: int, display: true, formatter: Watt + CPUIn: 350796, type: int, display: true + Temperature: 1412, display: true + Radiation: 8, display: true + BlastParticleIndex: 4 + BlastRadius: 3 + BlastDamage: 196 + UnlockCost: 25, display: true + UnlockLevel: 25, display: true + TechTreeParent: ThrusterJetMSRound3x10x3V2 + TechTreeNames: Capital Vessel + DowngradeTo: ThrusterJetMSRound3x13x3, display: true +} + + +##===== Cockpits for Racing and Event starter checks (PDA) ====== +## Do not use in regular Blueprints! +##===== +## HVSV + +{ +Block Name: CockpitSV06_Event, Ref: CockpitSV07 + CustomIcon: CockpitSV06 + Info: bkiCockpitSV06_Event, display: true + Model: @models/Blocks/Cockpits/CockpitSS06Prefab + Mass: 835, type: float, display: true, formatter: Kilogram + HitPoints: 800, type: int, display: true + # UpgradeTo: CockpitSV06T2, display: true #Single Use, no up-/downgrade for now! + ShowUser: No +} +{ +Block Name: CockpitSV06T2_Event, Ref: CockpitSV06 + CustomIcon: CockpitSV06T2 + Model: @models/Blocks/Cockpits/CockpitSS06T2Prefab + TemplateRoot: CockpitBlocksSVT2 + Mass: 1140, type: float, display: true, formatter: Kilogram + HitPoints: 1700, type: int, display: true + # UpgradeTo: null + ShowUser: No +} + +## =================== +## New "tech portal" variant + + +{ +Block Name: TechPortal + Info: bkiTechPortal, display: true + Class: Portal + Group: cpgTransportation + IndexName: Portal + Shape: ModelEntity + Material: metalhard + Model: @models/Blocks/Misc/TechPortalPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "3,2,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + ShowBlockName: true + StackSize: 1000 + HitPoints: 500, type: int, display: true + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices +} + + +## Portal Devices withouth Frame + allowing to clip into blocks (except center block) + + +{ +Block Name: PortalRing3x3 # Center exit! + Info: bkiPortalRing, display: true + Class: Portal + Group: cpgTransportation + IndexName: Portal + Shape: ModelEntity + Material: metalhard + Model: @models/Blocks/Misc/PortalRing3x3Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + ShowBlockName: true + StackSize: 1000 + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices +} +{ +Block Name: PortalRing5x5 # Center exit! + Class: Portal + Info: bkiPortalRing, display: true + Group: cpgTransportation + IndexName: Portal + Shape: ModelEntity + Material: metalhard + Model: @models/Blocks/Misc/PortalRing5x5Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + ShowBlockName: true + StackSize: 1000 + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices +} +{ +Block Name: PortalRing10x10 # Center exit! + Class: Portal + Info: bkiPortalRing, display: true + Group: cpgTransportation + IndexName: Portal + Shape: ModelEntity + Material: metalhard + Model: @models/Blocks/Misc/PortalRing10x10Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + ShowBlockName: true + StackSize: 1000 + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices +} +{ +Block Name: PortalRing87x87 # Center exit! + Class: Portal + Info: bkiPortalRing, display: true + Group: cpgTransportation + IndexName: Portal + Shape: ModelEntity + Material: metalhard + Model: @models/Blocks/Misc/PortalRing87x87Prefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + BlockColor: "110,110,110" + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsOxygenTight: false, display: true + ShowBlockName: true + StackSize: 1000 + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices +} + +#Terminal Nexus Devices + +{ +Block Name: VisionBox + Info: bkiVisionBox, display: true + Class: PassengerSeat + Category: Devices + Group: cpgPassengerSeat + Material: metallight + Shape: ModelEntity + MarketPrice: 1230, display: true + ShowBlockName: true + Model: @models2/Entities/Decorations/LaboratoryPack/VisionBoxPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Mass: 172, type: float, display: true, formatter: Kilogram + Volume: 132, type: float, display: true, formatter: Liter + IsActivateable: true + IsLockable: true + IsOxygenTight: false, display: true + YawRotation: 150 # how far in degrees head of player can be rotated (default 75°) + StackSize: 1000 + BlockColor: "110,110,110" + + BuffNameActivate: DecreaseBodyTemp, display: StatusEffect + + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 +} + +{ +Block Name: CryoPodTalonAr + Info: bkiCryoPodTalonAr, display: true + Class: Container + Group: cpgEquipment + Category: Deco Blocks + Material: metallight + Shape: ModelEntity + Model: @models2/Entities/Decorations/LaboratoryPack/CryoPodTalonArPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + BlockColor: "110,110,110" + Place: Free + CModelRotation: false + IsOxygenTight: false, display: true + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + StackSize: 1000 + IsActivateable: true + IsAccessible: true, type: bool + IsLockable: true + # CanPickup: true + ShowUser: Yes + + Mass: 170, type: float, display: true, formatter: Kilogram + Volume: 250, type: float, display: true, formatter: Liter + LootList: 76 +} + +## Talon Faction weapon - fixed Pulse Laser with higher frequency but same dps; additional shield damage; available for players as upgrade for MSWeapon02. + +{ +Block Name: WeaponMSLaserFactionTalonar + CustomIcon: WeaponMS02 + Info: bkiWeaponMSLaserFactionTalonar, display: true + Class: Weapon + IndexName: WeaponShip + Group: cpgWeapon + MarketPrice: 109244, display: true + Material: metalhard + Shape: ModelEntity + ShowBlockName: true + WeaponItem: PulseLaserMSFactionTalonar + Model: @models/Blocks/WeaponsShip/WeaponMSPulseLaserT2RetractPrefab ## Modify prefab texture? + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + IsTextureable: true # !!! + IsGPUInstance: false # !!! + Texture: 50 + SoundOpen: UseActions/WeaponOpenClose + SoundClose: UseActions/WeaponOpenClose + IsActivateable: true + IsRetractable: true, display: true + Place: WeaponDir + AllowPlacingAt: MS, display: true + SizeInBlocks: "1,6,1", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 1600, type: float, display: true, formatter: Liter + Mass: 2715, type: float, display: true, formatter: Kilogram + Category: Weapons/Items + BlockColor: "110,110,110" + StackSize: 500 + MaxCount: 6, type: int, display: true + BlastParticleIndex: 4 + BlastRadius: 2 + BlastDamage: 50 + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 7200, type: int, display: true + HitPoints: 3000, type: int, display: true + # DowngradeTo: WeaponMS02 + # Damage: 53, display: true + # AmmoCapacity: 150, display: true + # AmmoType: PulseLaserChargeMSWeapon, display: true + # ReloadDelay: 1, display: true + # ROF: 0.6, type: float, display: true, formatter: ROF + + # UnlockCost: 27, display: true + # UnlockLevel: 25, display: true + # TechTreeParent: WeaponMS01 + # TechTreeNames: Capital Vessel +} + +## Talon Hologram on station + + +{ +Block Name: StationConsoleTalonAr, Ref: DecoTemplate + Material: metallight + Shape: ModelEntity + Info: bkiStationConsoleTalonAr, display: true + Model: @models2/Entities/Decorations/HolographicAIConsole/HolographicAIConsoleTalonArPrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Class: NPCDialogue + Group: cpgCrewStationAlien + Category: Deco Blocks + NPCModelRotation: false + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + Place: Free + AllowPlacingAt: "Base,MS", display: true + IsAccessible: true, type: bool + IsActivateable: true + + IsOxygenTight: false, display: true + # AllowedInBlueprint: false, display: true + # CanPickup: true + Volume: 22, type: float, display: true, formatter: Liter + Mass: 300, type: float, display: true, formatter: Kilogram + HitPoints: 150, type: int, display: true + EnergyIn: 0, type: int, display: true, formatter: Watt + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 80 + TemplateRoot: ConsoleBlocks + + + ShowUser: Yes +} + +# == Static NPC + + +{ +Block Name: StandingNPCAlienCivilian1, Ref: ZiraxCommander #Ref: NPCAlienTemplate + Model: @models2/Entities/Decorations/DecoNPC/StandingNPCAlienCivilian1Prefab +} + +# == Warp Drive T2 + +{ +Block Name: WarpDriveT2 + Info: bkiWarpDriveT2, display: true + Class: WarpDrive + Group: cpgWarp + IndexName: WarpDrive + Material: metal + Shape: ModelEntity + MarketPrice: 120000, display: true + ShowBlockName: true + Model: @models/Blocks/Mothership/WarpDriveT2Prefab + Mesh-Damage-1: Mesh/D0 + Mesh-Damage-2: Mesh/D1 + Mesh-Damage-3: Mesh/D2 + Mesh-Damage-4: Mesh/D3 + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + AllowPlacingAt: MS, display: true + SizeInBlocks: "3,2,5", display: true + SizeInBlocksLocked: "Base,MS" + Volume: 4800, type: float, display: true, formatter: Liter + Mass: 82200, type: float, display: true, formatter: Kilogram + IsDuplicateable: false + MaxCount: 1, type: int, display: true + ForceMaxCount: true + IsOxygenTight: false, display: true + # ModelOffset: "0,0.5,0" + # BlockColor: "26,50,89" + StackSize: 250 + BlockColor: "110,110,110" + Category: Devices + UpgradeTo: WarpDriveCVEpic, display: true + DowngradeTo: WarpDrive, display: true + EnergyIn: 25, type: int, display: true, formatter: Watt + CPUIn: 60000, type: int, display: true + HitPoints: 1150, type: int, display: true + + BlastParticleIndex: 4 + BlastRadius: 5 + BlastDamage: 200 + + ItemPerAU: PentaxidCrystal, display: true + CostPerAU: 3, display: true + CostPerLY: 1, display: true + RangeAU: 48, display: true + RangeLY: 60, display: true + + # Short Range Warp + CostPerKM: 3, display: true + RangeKM: 40000, display: true + #RangeKMuseDetector: true, display: true + MicroWarpCooldown: 25, dipslay: true + + Radiation: 25, display: RadiationLevel # Radiation (= Radiation Protection) already exists for Player Armor + Temperature: 40, display: true + + + UnlockCost: 15, display: true + UnlockLevel: 20, display: true + TechTreeParent: WarpDrive + TechTreeNames: Capital Vessel +} +## Player GIN Console4 + +{ +Block Name: PlayerConsoleGIN + CustomIcon: ScifiComputerTable + Class: NPCDialogue + DialogueState: GIN_Console_Initialize #Not yet updated to 1.15 contents. Test new contents in scenario "default random EXP" ! + NPCModelRotation: false + Group: cpgCrewStationHuman + Material: metallight + Shape: ModelEntity + Model: @models2/Entities/Decorations/Sci-Fi_InteriorPack/PlayerConsolePrefab + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Place: Free + Info: bkiPlayerConsoleGIN, display: true + Category: Devices + AllowPlacingAt: "Base,MS", display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsAccessible: true, type: bool + # IsLockable: true + IsOxygenTight: false, display: true + # TemplateRoot: ConsoleBlocks + Mass: 65, type: float, display: true, formatter: Kilogram + Volume: 12, type: float, display: true, formatter: Liter + # ModelOffset: "0,0.5,0" + EnergyIn: 1, type: int, display: true, formatter: Watt + # BlockColor: "0,64,255" + BlockColor: "0,255,255" + + BlastParticleIndex: 11 + BlastRadius: 1 + BlastDamage: 80 + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + # TechTreeParent: GeneratorSV + TechTreeNames: Misc +} + +{ +Block Name: Jumpgate87x87 + Info: bkiJumpgate, display: true + Model: @models/Blocks/Misc/JumpgatePrefab #use this model if you want JUMPGATE functionality (voxel vessels only; no player-on-foot or hoverbikes) + DropMeshfile: Entities/Misc/DropContainerNewPrefab #Large container box; Available variants: see start of this document! + Group: cpgTransportation + # IndexName: Jumpgate + Shape: ModelEntity + Material: metalhard + Place: Free + BlockColor: "110,110,110" + AllowedInBlueprint: false, display: true + AllowPlacingAt: Base, display: true + SizeInBlocks: "1,1,1", display: true + SizeInBlocksLocked: "Base,MS" + IsActivateable: true + IsAccessible: true, type: bool + IsLockable: true + IsOxygenTight: false, display: true + ShowBlockName: true + StackSize: 1000 + Mass: 1000, type: float, display: true, formatter: Kilogram + EnergyIn: 1, type: int, display: true, formatter: Watt + Category: Devices + # Jumpgate Customizer + JumpgateBaseScale: 0.000839 # Default base scale of model game objects that equals a 2-m ingame block (CV/BA block) (DO NOT CHANGE unless other VFX is used!) + JumpgateDiameter: 87, display: true #Default: 1; 1 = 2m diameter = 1x large block; 10 = 20m diameter = 10 large blocks; Use Un-even numbers as the insertion point is always in the center block! + #JumpgateEffectEnabled: false #commented out / default: true; Activates/Deactivates the Vortex effects of the jumpgate +} \ No newline at end of file diff --git a/pkg/empyrionitems/ItemsConfig.ecf b/pkg/empyrionitems/ItemsConfig.ecf new file mode 100644 index 00000000..4d778c23 --- /dev/null +++ b/pkg/empyrionitems/ItemsConfig.ecf @@ -0,0 +1,15137 @@ +#== Available Properties == # +# CustomIcon: = Use with an existing Icon to show it ingame, f.ex. 'CustomIcon: DetectorSVT1' +# AllowAt: = Use with "AllowAt: NoPvP" and "AllowAt: NoPvE" ; f.e. not allow Filler Tool in PvP playfields +# MarketPrice: = sets the price per unit for use with 'Key: StationInterface' for POIs; if different items are used in the same context (like 3 types of energy cells for "Fuel" the item with the lowest cost per fuel content is used automatically) +# LifetimeOnDrop: float = specifies in seconds how long an item will live after dropping it. Default is 200 seconds. Please only increase this time if ABSOLUTELY needed as lots of items can bring down the performance of a server, but als in SP. +# DropOnDeath: > If set to 'False' this item or block is not dropped on player death. Use for important story items that players shall not lose on death. Default is set to 'True'. +#== Buffs/Debuffs/Buffmod== +# BuffMod: "StatusID_01, DurationFactor, ValueFactor, PreventsOnExpired(Optional);" +# BuffMod: "MedikitBandages, 0.5, 0.5, true; MedikitApplied, -0.2, 2" +# Explanation: +# - Time factor modifies the actual time that the status is active (set a 0 = no changes); example: duration = 1200 > Buffmod -0.1 = [ 1200 + (1200*(-0.1)])] = 1080 +# - Value factor modifies the intensity of all effects (damage, heal, speedmodifier...) (set a 0 = no changes); same calculation than for timeFactor +# - Prevent on expire is a boolean that cancels the onexpire effects if true; example: Infected wound expires to Sepsis. Using AntibioticPills to buffmod Infected Wound protects from Sepis when Infected Wound expires. +#== Armor Suits and Boosters +# Booster-Items need to be set in the Armors "SlotItems" section to be allowed with the armor +# SlotItems can only have 15 entries max! +# NOTE: Should you modify the "SlotItems" boost names list, all boosts in existing savegames will change (as in the savegame only an index in this list is stored) +# = Armor/Booster props +# Armor: Adds the given value to the total HP +# Oxygen: Adds the given value to the total O2 volume : # OxygenConsumptionPerMinute = 12.5f; => consume 500 units of oxygen in 40 min +# Radiation: You are protected from RAD up to this value +# Heat: You are protected from high temperatures up to this value +# Cold: You are protected from low temerpatures down to this value +# PowerFac: +# SpeedFac: +# StaminaFac: # calculations are based on 20 stamina per second while running +# JetpackFac: # no armor : 2.5 units removed per tick. armor : 5* JetpackFac per tick +# JumpFac: +# FoodFac: # calculations are based on 70 minutes +# +#== Information about weapon types == +# Class:Ranged > Damage is defined in the weapon; the Ammotype of those weapons only hold statistic parameters (mass, volume, cost etc) +# Class:LauncherSS > Damage AND BlastDamage (optional) are defined in the Ammotype in the ammo's child entry of 'Class: Projectile' ; If a projectile is used, you still can set BlastDamage to 0 +# +#== Information about weapon damages == +# All weapon types have a DAMAGE value ( refered to as 'base damage') +# LauncherSS type weapons can also have a BLAST damage, defined by BlastDamage & BlastRadius. Note that the BlastDamage is ADDED on top of the weapons base DAMAGE for the CENTER (hit) block (only)! The calculation for the combinded Damage for this block can e approximated by the following formula: (Damage + BlastDamage) - (BlastDamage / BlastRadius) +# Weapons can have multipliers to modify the base/blast damage +# Shield is/can be handled seperately! +# +# Syntax examples: +# DamageMultiplier_1: , param1: | ; NOTE: You can add the parameter "Material: " to an creature/NPC entry in the EClassConfig and use the string set there as reference at this point in the Itemsconfig (for the weapon being more or less effective against a certain creature/NPC!) +# BlastDamageMultiplier_1: , param1: | +# >> Available materials: head, body, dirt, stone, rock, metallight, metal, metalhard, woodsoft, wood, woodhard, concrete, concrete, hull, hullarmored, hullcombat, shield +# +#= Block modifiers for weapons +# PenetrationDepth: 1, display: true = penetration of weapon damage into a block +# BlastEntityDamage: 1, display: false +# BlastShieldDamage: 1, display: false +# +#= -- Shield modifiers for weapons -- +# ShieldDamagePenFac: 0.2, display: true = ShieldDamagePenFac, float: 0.0-1.0 -> how much % of damage goes through the shield +# BlastShieldDamagePenFac, float: 0.3 = how much % of blast damage goes through the shield; range 0.0-1.0 +# +# -- BlastDamage/Radius/Effect setup -- +# BlastParticleIndex: "3" => which prefab/particle is used (1-16; Not that not all numbers have an explosion animiation/effect) +# BlastRadius: "4" => damage radius +# BlastDamage: "50" => damage for objects within the radius around the explosion center +# -- Shields -- +# SHIELD: weapons fired at shiels will NOT apply their BlastDamage! Only the base damage is applied. Using BlastDamageMultiplier with shields does NOT do anyhting! (BlastDamageMultiplier is always 0) +# SHIELD: the Class: ShieldGenerator (BlocksConfig.ecf) can have a parameter 'ShieldMultiplier: AnyStringPossible' > The value 'AnyStringPossible' can be used in the DamageMultiplier setup to define shield-block specific damage multipliers! If not used, damage multiplier = 1 +# -- +# +# Categories: +# Ingredients: Base material without a template (ie picked up in the world) +# Components: Processed material with a template +# +# +#=== Weapon Hold Types: +# HoldType: 0 unspecified +# HoldType: 14 Pistol +# HoldType: 15 Rifle +# HoldType: 16 Rifle + weaker shoot animation (Survival Tool, Multitool, Drill) +# HoldType: 17 Ranged Weapon Bow New! +# HoldType: 20 Melee New! +# +# Categories: +# > Sufficient condition given in Templates.ecf +# > Necessary condition that an item appears in a certain device +# Food: FoodP and SurvC +# Medical: FoodP and SurvC +# Ingredients: FoodP +# Weapons/Items: SurvC, SmallC, LargeC, AdvC +# Components: SurvC, SmallC, LargeC, AdvC +# BuildingBlocks: SurvC, SmallC, LargeC, AdvC +# Devices: SurvC, SmallC, LargeC, AdvC +# Deco Blocks: SurvC, SmallC, LargeC, AdvC +# +# Automatic weapons: duration SfxBegin > ROF +# +# AllowRemote: true / false # allow / disallow in player drone. per default it is: true +# AmmoCapacity: max is 500 for all turret and mounted weapons +# Durability: max is 5000 for all handheld weapons and tools +# AutoReload: true > per default it is set to "true" +# ========== Repository +# NPC Pistols +# Entities/Items/Weapons/Pistols/ScifiPistol2BlueNPCPrefab > Smaller, Fit to TalonAdvanced +# Entities/Items/Weapons/Pistols/LaserPistolNPCTalonarPrefab > Player version fittet for Talonar NPC Model +# +#== FREE IDS === +# 47 +# 71-77 +# 95-99 +# 145-149 +# 179-196 +# 290-299 +# 400-499 +# 504 +# 606-619 +# 630-649 +# 659-699 +# 710-799 +# 809-849 +# 916-920 +# 953-999 +# 1033-1048 +# 1055-1059 +# 1065-1069 +# 1075-1079 +# 1085-1089 +# 1095-1099 +# 1105-1149 +# 1153-1199 +# 1204-1298 +# 1322-1399 +# 1411-1443 +#=========== + +# == CATEGORIE: PLAYER WEAPONS AND TOOLS id 1 - 99 == +{ Item Id: 1, Name: MeleePlayer + Material: human + Canhold: false + HoldType: 0 + StackSize: 1 + Candrop: false + ShowUser: No +} +{ +Item Id: 2, Name: Flashlight + Meshfile: Entities/Items/Tools/flashlight01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Info: bkiFlashlight, display: true + LifetimeOnDrop: 1200 + MarketPrice: 126, display: true + Material: metalweapon + HoldType: 14 + PickupToToolbar: true + StackSize: 1 + Category: Weapons/Items + CreatesLight: true + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 0.45, type: float, display: true, formatter: Liter + + UnlockLevel: 1 + TechTreeNames: Tools +} +{ +Item Id: 3, Name: Pistol + Meshfile: Entities/Items/Weapons/Pistols/ProjectilePistolPrefab + # Meshfile: Entities/Items/Weapons/Pistols/SciFiPistol4BlackPrefab + PickupToToolbar: true + MarketPrice: 244, display: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 2.1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.31, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.125, type: float, display: true, formatter: ROF + Range: 90, display: false + NoiseStrength: 60, display: false + CameraShake: 1 + Automatic: false + BulletSpread: 0.2, display: true + Recoil: 0.03, display: true + Damage: 64, display: true + AmmoCapacity: 16, display: true + AmmoType: 50Caliber, display: true + ReloadDelay: 1.5, display: true + SfxBegin: Weapons/Player/Pistol/Baghira + SfxNoAmmo: Weapons/Misc/MagnumDryFire + SfxReload: Weapons/Player/Reload/PistolReloadNormal + # PfxMuzzle: fire_muzzle_pistol + PfxMuzzle: MuzzleFlash8 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 1.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + # RedDot: true + } + UnlockLevel: 1 + TechTreeNames: Weapons +} +{ +Item Id: 4, Name: Shotgun + Meshfile: Entities/Items/Weapons/Shotguns/Shotgun1Prefab + # Meshfile: Entities/Items/Weapons/Shotguns/SciFiShotgun1BlackPrefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 1366, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 6.2, type: float, display: true, formatter: Kilogram + Volume: 4.5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.625, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.85, type: float, display: true, formatter: ROF + Range: 60, display: false + NoiseStrength: 80, display: false + CameraShake: 3 + Automatic: false + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 0.55, display: true + Recoil: 2.1, display: true + Damage: 100, display: true + AmmoCapacity: 5, display: true + AmmoType: ShotgunShells, display: true + ReloadDelay: 2.75, display: true + # SfxBegin: Weapons/Player/Shotgun/Stakeout + SfxBegin: Weapons/Player/Shotgun/Resistance-ShotGun-01-Single_Shot-01 + SfxNoAmmo: Weapons/Misc/PumpgunDryFire + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: MuzzleFlash2 + + DamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0.015, param1: wood # able to destroy trees + DamageMultiplier_3: 0, param1: dirt|stone|rock|hullarmored|hullcombat + DamageMultiplier_4: 0.4, param1: metal # able to destroy door + DamageMultiplier_5: 0.05, param1: metalhard # able to destroy door + DamageMultiplier_6: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + # RedDot: true + } + + UnlockCost: 7, display: true + UnlockLevel: 3, display: true + TechTreeNames: Weapons +} +{ +Item Id: 5, Name: Minigun + Meshfile: Entities\Items\Weapons\ScifiMinigun\MinigunPrefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 2561, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 39, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.05, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.06, type: float, display: true, formatter: ROF + Range: 150, display: false + NoiseStrength: 120, display: false + CameraShake: 1 + Automatic: true, display: true + BulletSpread: 0.4, display: true + Recoil: 0.6, display: true + Damage: 50, display: true + AmmoCapacity: 250, display: true + AmmoType: 8.3mmBullet, display: true + ReloadDelay: 3.9, display: true + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Reload/HeavyWeaponReloadNormal + PfxMuzzle: MuzzleFlash9 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 1.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0.09, param1: wood # able to destroy trees + DamageMultiplier_3: 0, param1: dirt|stone|rock|hullarmored|hullcombat + DamageMultiplier_4: 0, param1: shield + } + { Child 6 + Class: Movements + StandingLocation: "-0.05, 0.02, 0.05" + StandingRotation: "0.0, 0.0, -1.25" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "-0.5, 0.6, 1" + RunningLocation: "-0.05, 0.1, 0.05" + RunningRotation: "30, -45, 7" + CrouchingLocation: "0.0, 0.025, 0.0" + CrouchingRotation: "15, -30, 5" + RecoilLocation: "0.05, -0.1, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 20, display: true + UnlockLevel: 25, display: true + TechTreeParent: PulseRifle + TechTreeNames: Weapons +} +# Pulse Rifle +{ +Item Id: 6, Name: PulseRifle + Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2BlackT1Prefab + # Meshfile: Entities/Items/Weapons/PulseRifle/PulseRiflePrefab + PickupToToolbar: true + MarketPrice: 1705, display: true + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 4.2, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.125, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.09, type: float, display: true, formatter: ROF + Range: 160, display: false + Automatic: true, display: true + BulletSpread: 0.17, display: true + Recoil: 0.3, display: true + Damage: 45, display: true + NoiseStrength: 50, display: false + CameraShake: 1 + AmmoCapacity: 30, display: true + AmmoType: 5.8mmBullet, display: true + ReloadDelay: 2, display: true + SfxBegin: Weapons/Player/AssaultRifle/M41APulseRifle-03_start + SfxLoop: Weapons/Player/AssaultRifle/M41APulseRifle-03_loop + # SfxStop: Weapons/Player/AssaultRifle/M41APulseRifle-16_end + SfxNoAmmo: Weapons/Misc/G36DryFire + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: MuzzleFlash3 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + + DamageMultiplier_1: 1.25, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 30 + RedDot: true + } + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: AssaultRifle + TechTreeNames: Weapons +} + +{ +Item Id: 7, Name: Sniper + # Meshfile: Entities/Items/Weapons/Snipers/SniperT1Prefab + Meshfile: Entities/Items/Weapons/Snipers/SniperRiflePrefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 2731, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 9.1, type: float, display: true, formatter: Kilogram + Volume: 9, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.625, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.35, type: float, display: true, formatter: ROF + Range: 303, display: false + NoiseStrength: 100, display: false + CameraShake: 2 + Automatic: false + BulletSpread: 0.03, display: true + Recoil: 2.6, display: true + Damage: 165, display: true + AmmoCapacity: 10, display: true + AmmoType: 12.7mmBullet, display: true + # ReloadDelay: 3.7, display: true + ReloadDelay: 2.9, display: true + # SfxBegin: Weapons/Player/Sniper/desert_eagle + SfxBegin: Weapons/Player/Rifle/M24 + SfxNoAmmo: Weapons/Misc/AK47DryFire01 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: MuzzleFlash6 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 10 + RedDot: true + } + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + # TechTreeParent: AssaultRifle + TechTreeNames: Weapons +} + +{ +Item Id: 8, Name: Drill + Meshfile: Entities/Items/Weapons/DrillV2/DrillT0Prefab + PickupToToolbar: true + Material: metalweapon + HoldType: 16 + StackSize: 1 + Category: Weapons/Items + Mass: 6, type: float, display: true, formatter: Kilogram + Volume: 3, type: float, display: true, formatter: Liter + Info: bkiDrill, display: true + Durability: 500, display: false + DegradationProb: 0.03, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + ShowUser: No + # RadialMenu: true + { Child 0 # Resource mining and Rock drilling + Class: Driller + DrillMode: 3 + DecoMode: true + InfoPopup: true + TerrainMode: true + HarvestSupport: true + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float, display: false, formatter: ROF + Damage: 300 + Radius: 1.75 + Range: 5, display: false + NoiseStrength: 50, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 750, display: true + AmmoType: BioFuel, display: true + UseSingleMagazine: true + ReloadDelay: 3.1, display: true + Bullet_use_per_shot: 0.25 + Particles: digger # particle effect shown at impact + ParticleTransform: Muzzle/Particle1 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Drill/Drilling_1 + SfxLoop: Weapons/Player/Drill/Drilling_2 + SfxStop: Weapons/Player/Drill/Drilling_3 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: wood # no tree cutting + + RadialText: rdlResourceMining + RadialDesc: rdlResourceMiningManualTxt + RadialIcon: Radial_ResourceMining + } + # { Child 1 + # Class: Driller + # DecoMode: true + # HarvestSupport: false + # AllowAt: "UnderWater, Planet, Space" + # ROF: 0.5, type: float + # Range: 2.5 + # NoiseStrength: 50, display: false + # CameraShake: 0 + # IgnoreAtmo: true + # Automatic: true + # InfoPopup: true + # Damage: 20 + # Radius: 3 + # AmmoCapacity: 750 + # AmmoType: BioFuel + # UseSingleMagazine: true + # ReloadDelay: 3.1 + # Bullet_use_per_shot: 0.25 + # Particles: digger # particle effect shown at impact + # SfxBegin: Weapons/Player/Drill/Drilling_1 + # SfxLoop: Weapons/Player/Drill/Drilling_2 + # SfxStop: Weapons/Player/Drill/Drilling_3 + # SfxReload: Weapons/Player/Reload/RifleReloadNormal + + # DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield + # DamageMultiplier_2: 0, param1: dirt|stone|wood + # DamageMultiplier_3: 20, param1: rock # for rock drilling + + # RadialText: rdlStoneRemoval + # RadialDesc: rdlStoneRemovalTxt + # RadialIcon: Radial_StoneRemoval + # } + + # UnlockCost: 0, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: Tools +} +{ +Item Id: 9, Name: Shotgun2 + Meshfile: Entities/Items/Weapons/Shotguns/SciFiShotgun2BlackPrefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 2175, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 9.1, type: float, display: true, formatter: Kilogram + Volume: 5.5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.469, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.16, type: float, display: true, formatter: ROF + Range: 45, display: false + NoiseStrength: 80, display: false + CameraShake: 3 + Automatic: true + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 0.95, display: true + Recoil: 0.5, display: true + Damage: 53, display: true + AmmoCapacity: 8, display: true + AmmoType: ShotgunShells, display: true + ReloadDelay: 3.1, display: true + # SfxBegin: Weapons/Player/Shotgun/w1200_fire + SfxBegin: Weapons/Player/Shotgun/Resistance-ShotGun-01-Single_Shot-03 + SfxNoAmmo: Weapons/Misc/PumpgunDryFire02 + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: MuzzleFlash4 + + DamageMultiplier_1: 1, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0.015, param1: wood # able to destroy trees + DamageMultiplier_3: 0, param1: dirt|stone|rock|hullarmored|hullcombat + DamageMultiplier_4: 0.4, param1: metal # able to destroy door + DamageMultiplier_5: 0.05, param1: metalhard # able to destroy door + DamageMultiplier_6: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + RedDot: true + } + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + TechTreeParent: Shotgun + TechTreeNames: Weapons +} +{ +Item Id: 10, Name: RocketLauncher #T1 + Meshfile: Entities/Items/Weapons/SciFiGrenadeLauncher/RocketLauncherAK9Prefab + CustomIcon: RocketLauncherAK9 + PickupToToolbar: true + Material: metalweapon + MarketPrice: 4647, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + Volume: 45, type: float, display: true, formatter: Liter + #just for display - BlastRadius and BlastDamage is controlled by "SlowRocket" + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Durability: 100, display: false + DegradationProb: 1, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: LauncherSS + AllowRemote: false + ROF: 3.0, type: float, display: true, formatter: ROF + Range: 200, display: false + NoiseStrength: 140, display: false + Recoil: 2.4, display: true + CameraShake: 3 + AmmoCapacity: 4, display: true + AmmoType: SlowRocket, display: true + ReloadDelay: 4.5, display: true + SfxBegin: Weapons/Player/RocketLauncher/FLYBYMissile02Slow + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Reload/RifleReloadSlow + # SfxReload: Weapons/Player/RocketLauncher/M203InsertGranade01 + PfxMuzzle: SniperMuzzleFire + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew03 + ZoomOut: 40 + ZoomIn: 40 + RedDot: true + } + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeNames: Weapons +} +{ +Item Id: 11, Name: MultiTool + Meshfile: Entities/Items/Weapons/MultiToolT1Prefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 515, display: true + HoldType: 16 + StackSize: 1 + Category: Weapons/Items + Mass: 8, type: float, display: true, formatter: Kilogram + Volume: 4.5, type: float, display: true, formatter: Liter + Info: bkiMultiTool, display: true + RadialMenu: true + { Child 0 # "Retrieve Blocks" (= get complete blocks in own structure, do "Salvage" in others) + Class: Driller + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float, display: false, formatter: ROF + Range: 5 + NoiseStrength: 10, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + Damage: 120 + # Recoil: 0.2, display: true + Radius: 3 + ReturnFactor: 0.333, display: true + # Bullet_use_per_shot: 2 + AmmoCapacity: 250, display: true + AmmoType: EnergyCellSmall, display: true #old: MultiCharge + UseSingleMagazine: true + ReloadDelay: 3.0, display: true + ParticleTransform: Muzzle/Particle1 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning_start + SfxLoop: Weapons/Player/Tools/WeaponLightning_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + # ParticleTransform: "" # do NOT show a particle effect + + DamageMultiplier_1: 0, param1: dirt|stone|wood|shield|rock + DamageMultiplier_2: 10, param1: metalhard # 80 damage + DamageMultiplier_3: 5, param1: metal # 40 damage + DamageMultiplier_4: 10, param1: hull # 80 damage + DamageMultiplier_5: 25, param1: hullarmored # 200 damage + DamageMultiplier_6: 50, param1: hullcombat # 400 damage + DamageMultiplier_7: 25, param1: concrete # 200 damage + DamageMultiplier_8: 25, param1: woodblock # 200 damage + DamageMultiplier_9: 25, param1: forcefield + + RadialText: rdlDisassemble + RadialDesc: rdlDisassembleTxt + RadialIcon: Radial_Disassemble + } + { Child 1 # "Repair" + Class: Repair + AllowAt: "UnderWater, Planet, Space" + Range: 5 + # Recoil: 0.2, display: true + NoiseStrength: 10, display: false + CameraShake: 0 + IgnoreAtmo: true + ROF: 0.4, type: float + RepairPoints: 10 + Automatic: true + AmmoCapacity: 250 + AmmoType: EnergyCellSmall, display: true + UseSingleMagazine: true + ReloadDelay: 3.0 + ParticleTransform: Muzzle/Particle2 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + SfxUpgrade: UseActions/UpgradeBlock2 + SfxUpgradeErr: UseActions/UpgradeBlockError + AllowUpgrade: false + RadialText: rdlRepair + RadialDesc: rdlRepairTxt + RadialIcon: Radial_Repair + } + { Child 2 # "Exchange and Rotate" + Class: ExchangeBlockEx + AllowAt: "UnderWater, Planet, Space" + Range: 5 + NoiseStrength: 10, display: false + CameraShake: 0 + IgnoreAtmo: true + ROF: 1.0, type: float + AmmoCapacity: 250 + AmmoType: EnergyCellSmall, display: true + UseSingleMagazine: true + ReloadDelay: 3.0 + Focused_blockname_1: "Window_v1x1, Window_v1x1Inv" + Focused_blockname_2: "Window_v1x2, Window_v1x2Inv" + Focused_blockname_3: "Window_v2x2, Window_v2x2Inv" + Focused_blockname_4: "Window_s1x1, Window_s1x1Inv" + Focused_blockname_5: "Window_s1x2, Window_s1x2Inv" + Focused_blockname_6: "Window_sd1x1, Window_sd1x1Inv" + Focused_blockname_7: "Window_sd1x2, Window_sd1x2Inv" + Focused_blockname_8: "Window_sd1x2V2, Window_sd1x2V2Inv" + Focused_blockname_9: "Window_c1x1, Window_c1x1Inv" + Focused_blockname_10: "Window_c1x2, Window_c1x2Inv" + Focused_blockname_11: "Window_cr1x1, Window_cr1x1Inv" + Focused_blockname_12: "Window_crc1x1, Window_crc1x1Inv" + Focused_blockname_13: "Window_crsd1x1, Window_crsd1x1Inv" + Focused_blockname_14: "Window_3side1x1, Window_3side1x1Inv" + Focused_blockname_15: "Window_L1x1, Window_L1x1Inv" + Focused_blockname_16: "Window_crctw1x1, Window_crctw1x1Inv" + Focused_blockname_17: "Window_creA1x1, Window_creA1x1Inv" + Focused_blockname_18: "Window_creB1x1, Window_creB1x1Inv" + Focused_blockname_19: "Window_crl1x1, Window_crl1x1Inv" + Focused_blockname_20: "Window_crse1x1, Window_crse1x1Inv" + Focused_blockname_21: "Window_cc1x1, Window_cc1x1Inv" + Focused_blockname_22: "Window_v1x1Thick, Window_v1x1ThickInv" + Focused_blockname_23: "Window_v1x2Thick, Window_v1x2ThickInv" + Focused_blockname_24: "Window_v2x2Thick, Window_v2x2ThickInv" + Focused_blockname_25: "Window_s1x1Thick, Window_s1x1ThickInv" + Focused_blockname_26: "Window_s1x2Thick, Window_s1x2ThickInv" + Focused_blockname_27: "Window_sd1x1Thick, Window_sd1x1ThickInv" + Focused_blockname_28: "Window_sd1x2Thick, Window_sd1x2ThickInv" + Focused_blockname_29: "Window_sd1x2V2Thick, Window_sd1x2V2ThickInv" + Focused_blockname_30: "Window_c1x1Thick, Window_c1x1ThickInv" + Focused_blockname_31: "Window_c1x2Thick, Window_c1x2ThickInv" + Focused_blockname_32: "Window_cr1x1Thick, Window_cr1x1ThickInv" + Focused_blockname_33: "Window_crc1x1Thick, Window_crc1x1ThickInv" + Focused_blockname_34: "Window_crsd1x1Thick, Window_crsd1x1ThickInv" + Focused_blockname_35: "Window_3side1x1Thick, Window_3side1x1ThickInv" + Focused_blockname_36: "Window_L1x1Thick, Window_L1x1ThickInv" + Focused_blockname_37: "Window_crctw1x1Thick, Window_crctw1x1ThickInv" + Focused_blockname_38: "Window_creA1x1Thick, Window_creA1x1ThickInv" + Focused_blockname_39: "Window_creB1x1Thick, Window_creB1x1ThickInv" + Focused_blockname_40: "Window_crl1x1Thick, Window_crl1x1ThickInv" + Focused_blockname_41: "Window_crse1x1Thick, Window_crse1x1ThickInv" + Focused_blockname_42: "Window_cc1x1Thick, Window_cc1x1ThickInv" + Focused_blockname_43: "Wing6x9a, Wing6x9b, Wing6x9c, Wing6x9d, Wing6x9e" + Focused_blockname_44: "Wing9x6a, Wing9x6b, Wing9x6c, Wing9x6d, Wing9x6e" + Focused_blockname_45: "Wing12x9a, Wing12x9b, Wing12x9c, Wing12x9d, Wing12x9e" + Focused_blockname_46: "Wing6x5a, Wing6x5b, Wing6x5c, Wing6x5d, Wing6x5e" + Focused_blockname_47: "RailingVertGlass, RailingVertGlassInv" + Focused_blockname_48: "RailingRoundGlass, RailingRoundGlassInv" + Focused_blockname_49: "RailingLGlass, RailingLGlassInv" + Focused_blockname_50: "RailingSlopeGlassRight, RailingSlopeGlassRightInv" + Focused_blockname_51: "RailingSlopeGlassLeft, RailingSlopeGlassLeftInv" + Focused_blockname_52: "RailingDiagonalGlass, RailingDiagonalGlassInv" + Focused_blockname_53: "HeavyWindowA, HeavyWindowAInv" + Focused_blockname_54: "HeavyWindowB, HeavyWindowBInv" + Focused_blockname_55: "HeavyWindowC, HeavyWindowCInv" + Focused_blockname_56: "HeavyWindowD, HeavyWindowDInv" + Focused_blockname_57: "HeavyWindowE, HeavyWindowEInv" + Focused_blockname_58: "HeavyWindowF, HeavyWindowFInv" + Focused_blockname_59: "HeavyWindowG, HeavyWindowGInv" + Focused_blockname_60: "HeavyWindowH, HeavyWindowHInv" + Focused_blockname_61: "HeavyWindowI, HeavyWindowIInv" + Focused_blockname_62: "HeavyWindowJ, HeavyWindowJInv" + Focused_blockname_63: "HeavyWindowK, HeavyWindowKInv" + Focused_blockname_64: "HeavyWindowL, HeavyWindowLInv" + Focused_blockname_65: "HeavyWindowM, HeavyWindowMInv" + Focused_blockname_66: "HeavyWindowN, HeavyWindowNInv" + Focused_blockname_67: "HeavyWindowO, HeavyWindowOInv" + Focused_blockname_68: "HeavyWindowP, HeavyWindowPInv" + Focused_blockname_69: "HeavyWindowQ, HeavyWindowQInv" + Focused_blockname_70: "HeavyWindowV, HeavyWindowVInv" + Focused_blockname_71: "HeavyWindowS, HeavyWindowSInv" + Focused_blockname_72: "HeavyWindowT, HeavyWindowTInv" + Focused_blockname_73: "HeavyWindowU, HeavyWindowUInv" + Focused_blockname_74: "HeavyWindowPConnectLeft, HeavyWindowPConnectLeftInv" + Focused_blockname_75: "HeavyWindowPConnectRight, HeavyWindowPConnectRightInv" + Focused_blockname_76: "HeavyWindowPConnectTwo, HeavyWindowPConnectTwoInv" + Focused_blockname_77: "HeavyWindowQConnect, HeavyWindowQConnectInv" + Focused_blockname_78: "HeavyWindowQConnectTwo, HeavyWindowQConnectTwoInv" + Focused_blockname_79: "HeavyWindowQConnectThree, HeavyWindowQConnectThreeInv" + Focused_blockname_80: "HeavyWindowSConnect, HeavyWindowSConnectInv" + Focused_blockname_81: "HeavyWindowTConnect, HeavyWindowTConnectInv" + Focused_blockname_82: "HeavyWindowUConnect, HeavyWindowUConnectInv" + Focused_blockname_83: "HeavyWindowVConnect, HeavyWindowVConnectInv" + Focused_blockname_84: "HeavyWindowQConnectFour, HeavyWindowQConnectFourInv" + Focused_blockname_85: "HeavyWindowEBottom, HeavyWindowEBottomInv" + Focused_blockname_86: "HeavyWindowFBottom, HeavyWindowFBottomInv" + Focused_blockname_87: "HeavyWindowGBottom, HeavyWindowGBottomInv" + Focused_blockname_88: "HeavyWindowHBottom, HeavyWindowHBottomInv" + Focused_blockname_89: "HeavyWindowIBottom, HeavyWindowIBottomInv" + Focused_blockname_90: "HeavyWindowJBottom, HeavyWindowJBottomInv" + Focused_blockname_91: "HeavyWindowEGlass, HeavyWindowEGlassInv" + Focused_blockname_92: "HeavyWindowFGlass, HeavyWindowFGlassInv" + Focused_blockname_93: "HeavyWindowGGlass, HeavyWindowGGlassInv" + Focused_blockname_94: "HeavyWindowHGlass, HeavyWindowHGlassInv" + Focused_blockname_95: "HeavyWindowIGlass, HeavyWindowIGlassInv" + Focused_blockname_96: "HeavyWindowJGlass, HeavyWindowJGlassInv" + Focused_blockname_97: "HeavyWindowQConnectTwoDiagonal, HeavyWindowQConnectTwoDiagonalInv" + Focused_blockname_98: "RailingDoubleVertGlass, RailingDoubleVertGlassInv" + Focused_blockname_99: "HeavyWindowQConnectTwoDiagonalMirror, HeavyWindowQConnectTwoDiagonalMirrorInv" + RadialText: rdlChangeRotate + RadialDesc: rdlChangeRotateTxt + RadialIcon: Radial_ChangeRotate + } + { Child 3 # "Downgrade" + Class: Repair + AllowAt: "UnderWater, Planet, Space" + Range: 5 + # Recoil: 0.2, display: true + NoiseStrength: 10, display: false + CameraShake: 0 + IgnoreAtmo: true + ROF: 0.4, type: float + RepairPoints: 10 + ReturnFactor: 0.333 + Automatic: true + AmmoCapacity: 250 + AmmoType: EnergyCellSmall, display: true + UseSingleMagazine: true + ReloadDelay: 3.0 + ParticleTransform: Muzzle/Particle2 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + SfxDowngrade: UseActions/UpgradeBlock2 + AllowDowngrade: true + RadialText: btnDowngrade + RadialDesc: lblDowngradeTxt + RadialIcon: Radial_Downgrade + } + { Child 4 # "Upgrade" + Class: Repair + AllowAt: "UnderWater, Planet, Space" + Range: 5 + # Recoil: 0.2, display: true + NoiseStrength: 10, display: false + CameraShake: 0 + IgnoreAtmo: true + ROF: 0.4, type: float + RepairPoints: 10 + Automatic: true + AmmoCapacity: 250 + AmmoType: EnergyCellSmall, display: true + UseSingleMagazine: true + ReloadDelay: 3.0 + ParticleTransform: Muzzle/Particle2 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + SfxUpgrade: UseActions/UpgradeBlock2 + SfxUpgradeErr: UseActions/UpgradeBlockError + AllowUpgrade: true + RadialText: rdlUpgrade + RadialDesc: rdlUpgradeTxt + RadialIcon: Radial_Upgrade + } + { Child 5 # "Salvage" (get components from a block) + Class: Driller + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float, display: false, formatter: ROF + Range: 5 + # Recoil: 0.2, display: true + NoiseStrength: 10, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + Damage: 120 + Radius: 3 + Demolish: True + ReturnFactor: 0.333 + # Bullet_use_per_shot: 2 + AmmoCapacity: 250 + AmmoType: EnergyCellSmall, display: true + UseSingleMagazine: true + ReloadDelay: 3.0 + ParticleTransform: Muzzle/Particle3 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning_start + SfxLoop: Weapons/Player/Tools/WeaponLightning_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # ParticleTransform: "" # do NOT show a particle effect + + DamageMultiplier_1: 0, param1: dirt|stone|wood|shield|rock|plants + DamageMultiplier_2: 10, param1: metalhard # 80 damage + DamageMultiplier_3: 5, param1: metal # 40 damage + DamageMultiplier_4: 10, param1: hull # 80 damage + DamageMultiplier_5: 25, param1: hullarmored # 200 damage + DamageMultiplier_6: 50, param1: hullcombat # 400 damage + DamageMultiplier_7: 25, param1: concrete # 200 damage + DamageMultiplier_8: 25, param1: woodblock # 200 damage + DamageMultiplier_9: 25, param1: forcefield + + RadialText: rdlDeconstruct + RadialDesc: rdlDeconstructTxt + RadialIcon: Radial_Deconstruct + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.025" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 5" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.0, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 3, display: true + UnlockLevel: 3, display: true + TechTreeParent: SurvivalTool + TechTreeNames: Tools +} +# not used anymore +{ Item Id: 12, Name: Fillertool + Meshfile: Entities/Items/Weapons/DrillV2/FillerToolPrefab + Material: metalweapon + HoldType: 16 + StackSize: 1 + Category: Weapons/Items + Mass: 8, type: float, display: true, formatter: Kilogram + Info: bkiFillertool, display: true + ShowUser: No + { Child 0 + Class: Driller + TerrainMode: true + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float + Range: 7, display: false # increased range for filling + IgnoreAtmo: true + Automatic: true + Damage: 20 + Radius: 3 + AmmoCapacity: 750, display: true + AmmoType: FillerCharge, display: true + UseSingleMagazine: true + TerrainFillerBlock: FillerMaterial # If this is set the driller turns into a filler + Bullet_use_per_shot: 0.25 + ReloadDelay: 2.9, display: true + SfxBegin: Weapons/Player/Filler/ENGINEDeepSlow_start + SfxLoop: Weapons/Player/Filler/ENGINEDeepSlow_loop + SfxStop: Weapons/Player/Filler/ENGINEDeepSlow_end + SfxReload: Weapons/Player/Reload/RifleReloadNormal + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: wood + } + { Child 1 + Class: Driller + TerrainMode: true + AllowAt: "UnderWater, Planet, Space" + FlattenTerrain: true + ROF: 0.5, type: float + Range: 5.5 + NoiseStrength: 10, display: false + IgnoreAtmo: true + Automatic: true + Damage: 20 + Radius: 3 + AmmoCapacity: 750 + AmmoType: FillerCharge + UseSingleMagazine: true + Bullet_use_per_shot: 2 + ReloadDelay: 2.9 + SfxBegin: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_start + SfxLoop: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_loop + # ParticleTransform: Muzzle/Particle2 + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: dirt|stone|wood + } +} +# not used anymore +{ Item Id: 13, Name: ChangeTool + # Class: RotateBlock + Meshfile: Entities/Items/Tools/ChangeToolPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + Info: bkiRotateBlocks, display: true + Mass: 8, type: float, display: true, formatter: Kilogram + ShowUser: No + { Child 0 + Class: ExchangeBlockEx + AllowAt: "UnderWater, Planet, Space" + ROF: 1.0, type: float + Focused_blockname_1: "Window_v1x1, Window_v1x1Inv" + Focused_blockname_2: "Window_v1x2, Window_v1x2Inv" + Focused_blockname_3: "Window_v2x2, Window_v2x2Inv" + Focused_blockname_4: "Window_s1x1, Window_s1x1Inv" + Focused_blockname_5: "Window_s1x2, Window_s1x2Inv" + Focused_blockname_6: "Window_sd1x1, Window_sd1x1Inv" + Focused_blockname_7: "Window_sd1x2, Window_sd1x2Inv" + Focused_blockname_8: "Window_sd1x2V2, Window_sd1x2V2Inv" + Focused_blockname_9: "Window_c1x1, Window_c1x1Inv" + Focused_blockname_10: "Window_c1x2, Window_c1x2Inv" + Focused_blockname_11: "Window_cr1x1, Window_cr1x1Inv" + Focused_blockname_12: "Window_crc1x1, Window_crc1x1Inv" + Focused_blockname_13: "Window_crsd1x1, Window_crsd1x1Inv" + Focused_blockname_14: "Window_3side1x1, Window_3side1x1Inv" + Focused_blockname_15: "Window_L1x1, Window_L1x1Inv" + Focused_blockname_16: "Window_crctw1x1, Window_crctw1x1Inv" + Focused_blockname_17: "Window_creA1x1, Window_creA1x1Inv" + Focused_blockname_18: "Window_creB1x1, Window_creB1x1Inv" + Focused_blockname_19: "Window_crl1x1, Window_crl1x1Inv" + Focused_blockname_20: "Window_crse1x1, Window_crse1x1Inv" + Focused_blockname_21: "Window_cc1x1, Window_cc1x1Inv" + Focused_blockname_22: "Window_v1x1Thick, Window_v1x1ThickInv" + Focused_blockname_23: "Window_v1x2Thick, Window_v1x2ThickInv" + Focused_blockname_24: "Window_v2x2Thick, Window_v2x2ThickInv" + Focused_blockname_25: "Window_s1x1Thick, Window_s1x1ThickInv" + Focused_blockname_26: "Window_s1x2Thick, Window_s1x2ThickInv" + Focused_blockname_27: "Window_sd1x1Thick, Window_sd1x1ThickInv" + Focused_blockname_28: "Window_sd1x2Thick, Window_sd1x2ThickInv" + Focused_blockname_29: "Window_sd1x2V2Thick, Window_sd1x2V2ThickInv" + Focused_blockname_30: "Window_c1x1Thick, Window_c1x1ThickInv" + Focused_blockname_31: "Window_c1x2Thick, Window_c1x2ThickInv" + Focused_blockname_32: "Window_cr1x1Thick, Window_cr1x1ThickInv" + Focused_blockname_33: "Window_crc1x1Thick, Window_crc1x1ThickInv" + Focused_blockname_34: "Window_crsd1x1Thick, Window_crsd1x1ThickInv" + Focused_blockname_35: "Window_3side1x1Thick, Window_3side1x1ThickInv" + Focused_blockname_36: "Window_L1x1Thick, Window_L1x1ThickInv" + Focused_blockname_37: "Window_crctw1x1Thick, Window_crctw1x1ThickInv" + Focused_blockname_38: "Window_creA1x1Thick, Window_creA1x1ThickInv" + Focused_blockname_39: "Window_creB1x1Thick, Window_creB1x1ThickInv" + Focused_blockname_40: "Window_crl1x1Thick, Window_crl1x1ThickInv" + Focused_blockname_41: "Window_crse1x1Thick, Window_crse1x1ThickInv" + Focused_blockname_42: "Window_cc1x1Thick, Window_cc1x1ThickInv" + Focused_blockname_43: "Wing6x9a, Wing6x9b, Wing6x9c, Wing6x9d, Wing6x9e" + Focused_blockname_44: "Wing9x6a, Wing9x6b, Wing9x6c, Wing9x6d, Wing9x6e" + Focused_blockname_45: "Wing12x9a, Wing12x9b, Wing12x9c, Wing12x9d, Wing12x9e" + Focused_blockname_46: "Wing6x5a, Wing6x5b, Wing6x5c, Wing6x5d, Wing6x5e" + Focused_blockname_47: "RailingVertGlass, RailingVertGlassInv" + Focused_blockname_48: "RailingRoundGlass, RailingRoundGlassInv" + Focused_blockname_49: "RailingLGlass, RailingLGlassInv" + } + + # UnlockCost: 3, display: true + # UnlockLevel: 3, display: true + # TechTreeParent: ColorTool + # TechTreeNames: Tools +} +{ +Item Id: 14, Name: LaserPistol + Meshfile: Entities/Items/Weapons/Pistols/LaserPistolPrefab + # Meshfile: Entities/Items/Weapons/Pistols/ScifiEnergyPistol3T1Prefab + # Meshfile: Entities/Items/Weapons/Pistols/SciFiPistol2BluePrefab + PickupToToolbar: true + MarketPrice: 935, display: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + Info: bkiWeaponHandheldIgnoringAtmo, display: true + Mass: 2.5, type: float, display: true, formatter: Kilogram + Volume: 1.6, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.625, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.18, type: float, display: true, formatter: ROF + Range: 90, display: false + NoiseStrength: 20, display: false + CameraShake: 1 + IgnoreAtmo: true, display: true + Automatic: false + BulletSpread: 0, display: true + Recoil: 0.08, display: true + Damage: 70, display: true + AmmoCapacity: 20, display: true + AmmoType: PulseLaserChargePistol, display: true + ReloadDelay: 1.8, display: true + SfxBegin: Weapons/Player/LaserPistol/LaserPistol + SfxNoAmmo: Weapons/Misc/G36DryFire + SfxReload: Weapons/Player/Reload/PistolReloadNormal + PfxMuzzle: SniperMuzzleLaserBlue + Tracer: Weapons/Projectiles/TracerLaserShotBlue + TracerPerBullet: 1 + + DamageMultiplier_1: 3, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0.005, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + } + # { Child 0 + # Class: LauncherSS + # AllowRemote: false + # ROF: 0.6, type: float, display: true, formatter: ROF + # Range: 400, display: false + # AmmoCapacity: 20, display: true + # AmmoType: PulseLaserChargePistol, display: true + # ReloadDelay: 2.2, display: true + # SfxBegin: Weapons/Player/LaserPistol/BLASTERLargeFastKickback + # SfxNoAmmo: Weapons/Misc/G36DryFire + # SfxReload: Weapons/Player/Reload/PistolReloadNormal + # PfxMuzzle: SniperMuzzleLaserBlue + # } + + UnlockCost: 17, display: true + UnlockLevel: 10, display: true + TechTreeNames: Weapons +} +{ +Item Id: 15, Name: AssaultRifle # T1 + Meshfile: Entities/Items/Weapons/Rifles/AssaultRifle1Prefab + # Meshfile: Entities/Items/Weapons/Rifles/AssaultRiflePrefab + # Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2BlackT1Prefab + PickupToToolbar: true + MarketPrice: 2538, display: true + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.08, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.15, type: float, display: true, formatter: ROF + Range: 180, display: false + NoiseStrength: 100, display: false + CameraShake: 1 + Automatic: true, display: true + BulletSpread: 0.07, display: true + Recoil: 0.7, display: true + Damage: 70, display: true + AmmoCapacity: 20, display: true + AmmoType: 5.8mmBullet, display: true + ReloadDelay: 2.2, display: true + SfxBegin: Weapons/Player/Pistol/AWDS_AR_ Heavy Single + # SfxLoop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Close_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Close_end + SfxNoAmmo: Weapons/Misc/AK47DryFire01 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: MuzzleFlash1 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 5 + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + # RedDot: true + } + + UnlockCost: 6, display: true + UnlockLevel: 3, display: true + TechTreeParent: Pistol + TechTreeNames: Weapons +} +{ +Item Id: 16, Name: LaserRifle + Meshfile: Entities/Items/Weapons/Rifles/LaserRiflePrefab + CustomIcon: LaserRifleV2 + PickupToToolbar: true + Material: metalweapon + MarketPrice: 4235, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + Info: bkiWeaponHandheldIgnoringAtmo, display: true + Mass: 5, type: float, display: true, formatter: Kilogram + Volume: 4, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.125, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + # DamageMultiplier_1: 4, param1: head, display: DmgMultiplierHead + # Damage: 135, display: true + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.24, type: float, display: true, formatter: ROF + Range: 180, display: false + NoiseStrength: 80, display: false + CameraShake: 0 + BulletSpread: 0, display: true + Recoil: 0.35, display: true + IgnoreAtmo: true, display: true + Automatic: true, display: true + Damage: 115, display: true + AmmoCapacity: 25, display: true + AmmoType: PulseLaserChargeRifle, display: true + ReloadDelay: 2.5, display: true + ParticleTransform: Muzzle/Particle1 + SfxBegin: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_start + # SfxLoop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_loop + SfxStop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_end + # SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxNoAmmo: Weapons/Misc/G36DryFire + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: SniperMuzzleLaserRed + Tracer: Weapons/Projectiles/TracerLaserShotRed + TracerPerBullet: 1 + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0.005, param1: shield + } + + # { Child 0 + # Class: LauncherSS + # AllowRemote: false + # ROF: 0.5, type: float, display: true, formatter: ROF + # Range: 600, display: false + # AmmoCapacity: 25, display: true + # AmmoType: PulseLaserChargeRifle, display: true + # ReloadDelay: 3.0, display: true + # SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + # SfxNoAmmo: Weapons/Misc/G36DryFire + # SfxReload: Weapons/Player/Reload/RifleReloadNormal + # PfxMuzzle: SniperMuzzleLaserRed + # } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 15 + RedDot: true + } + { Child 6 + Class: Movements + StandingLocation: "-0.05, 0.010, 0.05" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.12, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 17, display: true + UnlockLevel: 12, display: true + TechTreeParent: LaserPistol + TechTreeNames: Weapons +} + +{ +Item Id: 17, Name: ScifiCannon + Meshfile: Entities/Items/Weapons/ScifiCannon/ScifiCannonPrefab + PickupToToolbar: true + Material: metalweapon + HoldType: 15 + StackSize: 1 + MarketPrice: 9505, display: true + Category: Weapons/Items + Info: bkiWeaponHandheldIgnoringAtmo, display: true + Mass: 55, type: float, display: true, formatter: Kilogram + Volume: 60, type: float, display: true, formatter: Liter + #just for display - BlastRadius and BlastDamage is controlled by "SciFiCannonPlasmaCharge" + Damage: 600, display: true + BlastRadius: 3, display: true + BlastDamage: 500, display: true + # Explosion Radius: 3, display: true + Durability: 100, display: false + DegradationProb: 1, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: LauncherSS + AllowRemote: false + ROF: 1.9, type: float, display: true, formatter: ROF + Range: 360, display: false + NoiseStrength: 120, display: false + CameraShake: 3 + Recoil: 3.0, display: true + IgnoreAtmo: true, display: true + AmmoCapacity: 7, display: true + AmmoType: SciFiCannonPlasmaCharge, display: true + ReloadDelay: 4.1, display: true + SfxBegin: Weapons/Player/Cannon/BLASTERComplexFireSciFiChargePowerfulDeepRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Reload/HeavyWeaponReloadNormal + PfxMuzzle: SniperMuzzleFire + } + + UnlockCost: 25, display: true + UnlockLevel: 25, display: true + TechTreeParent: RocketLauncherT2 + TechTreeNames: Weapons +} +{ +Item Id: 18, Name: Sniper2 + Meshfile: Entities/Items/Weapons/Snipers/ScifiSniper3Prefab + PickupToToolbar: true + Material: metalweapon + HoldType: 15 + MarketPrice: 3735, display: true + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 10, type: float, display: true, formatter: Kilogram + Volume: 12, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.469, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.9, type: float, display: true, formatter: ROF + Range: 333, display: false + NoiseStrength: 100, display: false + CameraShake: 2 + Automatic: false + BulletSpread: 0.023, display: true + Recoil: 5, display: true + Damage: 340, display: true + AmmoCapacity: 6, display: true + AmmoType: 12.7mmBullet, display: true + ReloadDelay: 3.3, display: true + SfxBegin: Weapons/Player/Sniper/Resistance-SniperRifle-02-Single_Shot-01 + SfxNoAmmo: Weapons/Misc/AK47DryFire01 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Rifle/M16ChargingHandle01_V2 + PfxMuzzle: MuzzleFlash7 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood + DamageMultiplier_3: 0, param1: hullarmored|hullcombat + DamageMultiplier_4: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew + ZoomOut: 40 + ZoomIn: 5 + RedDot: true + } + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeParent: Sniper + TechTreeNames: Weapons +} +{ +Item Id: 19, Name: DrillT2 + Meshfile: Entities/Items/Weapons/DrillV2/DrillT2Prefab + PickupToToolbar: true + Material: metalweapon + HoldType: 16 + StackSize: 1 + MarketPrice: 580, display: true + Category: Weapons/Items + Mass: 28, type: float, display: true, formatter: Kilogram + Volume: 37, type: float, display: true, formatter: Liter + Info: bkiDrill, display: true + # Info: bkiDrillT2, display: true + Durability: 500, display: false + DegradationProb: 0.03, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + RadialMenu: true + { Child 0 + Class: Driller + DrillMode: 3 + DecoMode: true + InfoPopup: true + TerrainMode: true + HarvestSupport: true + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float + Damage: 600 + Radius: 2 + Range: 7, display: false + NoiseStrength: 40, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 750, display: true + AmmoType: DrillCharge, display: true + UseSingleMagazine: true + ReloadDelay: 2.9, display: true + Bullet_use_per_shot: 0.25 + Particles: digger # particle effect shown at impact + ParticleTransform: Muzzle/Particle1 # particle effect shown at muzzle + PfxMuzzle: MuzzleFlashLightningLarge + # ParticleTransform: Muzzle/Particle4 # Test + # SfxBegin: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_start + # SfxLoop: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_loop + SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxStop: Weapons/Player/Drill/Mute + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: wood # no tree cutting + DamageMultiplier_3: 0, param1: resourcesoft + # DamageMultiplier_4: 0.1, param1: resourcehard + + RadialText: rdlResourceMining + RadialDesc: rdlResourceMiningTxt + RadialIcon: Radial_ResourceMining + } + # ======================================== + { Child 1 + Class: Driller + DrillMode: 0 + DecoMode: true + InfoPopup: true + TerrainMode: true + # MiningEnabled: false # Use only for terrain removal + HarvestSupport: true + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float + Damage: 35 + Radius: 1 + Range: 5, display: false # lower range to match resource mining range (DrillMode: 2 seems to have lower range) + NoiseStrength: 40, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 750 + AmmoType: DrillCharge + UseSingleMagazine: true + ReloadDelay: 2.9 + Bullet_use_per_shot: 0.25 + Particles: digger # particle effect shown at impact + ParticleTransform: Muzzle/Particle2 # particle effect shown at muzzle + PfxMuzzle: MuzzleFlashLightningLarge + SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxStop: Weapons/Player/Drill/Mute + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: wood # no tree cutting + + # RadialText: rdlTerrainRemoval + # RadialDesc: rdlTerrainRemovalTxt + RadialText: rdlFineDrilling + RadialDesc: rdlFineDrillingTxt + RadialIcon: Radial_TerrainRemoval + } + { Child 2 + Class: Driller + DrillMode: 1 + TerrainMode: true + AllowAt: "UnderWater, Planet, Space" + MiningEnabled: false # Use only for terrain removal + FlattenTerrain: true + ROF: 0.5, type: float + Damage: 500 + Radius: 3 + Range: 5, display: false # lower range to match resource mining range (DrillMode: 2 seems to have lower range) + NoiseStrength: 40, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 750 + AmmoType: DrillCharge + UseSingleMagazine: true + ReloadDelay: 2.9 + Bullet_use_per_shot: 2 + Particles: # particle effect shown at impact + ParticleTransform: Muzzle/Particle3 # particle effect shown at muzzle + PfxMuzzle: MuzzleFlashLightningLargeGreen + SfxBegin: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_start + SfxLoop: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: stone|dirt|woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + + RadialText: rdlFlattening + RadialDesc: rdlFlatteningTxt + RadialIcon: Radial_Flattening + } + { Child 3 + Class: Driller + TerrainMode: true + TerrainFillerBlock: FillerMaterial # If this is set the driller turns into a filler + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float + Range: 14, display: false # increased range for filling + NoiseStrength: 40, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + Damage: 30 + Radius: 3 + AmmoCapacity: 750 + AmmoType: DrillCharge + UseSingleMagazine: true + ReloadDelay: 2.9 + Bullet_use_per_shot: 0.25 + Particles: # particle effect shown at impact + ParticleTransform: Muzzle/Particle4 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Filler/ENGINEDeepSlow_start + SfxLoop: Weapons/Player/Filler/ENGINEDeepSlow_loop + SfxStop: Weapons/Player/Filler/ENGINEDeepSlow_end + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + + RadialText: rdlFiller + RadialDesc: rdlFillerTxt + RadialIcon: Radial_Filler + } + # { Child 4 # Rock removal + # Class: Driller + # DecoMode: true + # HarvestSupport: false + # AllowAt: "UnderWater, Planet, Space" + # ROF: 0.5, type: float + # Range: 7 + # NoiseStrength: 40, display: false + # CameraShake: 0 + # IgnoreAtmo: true + # Automatic: true + # InfoPopup: true + # Damage: 30 + # Radius: 3 + # AmmoCapacity: 750 + # AmmoType: DrillCharge + # UseSingleMagazine: true + # ReloadDelay: 2.9 + # Bullet_use_per_shot: 2 + # Particles: diggerplasma_green # particle effect shown at impact + # ParticleTransform: Muzzle/Particle2 # particle effect shown at muzzle + # SfxBegin: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_start + # SfxLoop: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_loop + # SfxReload: Weapons/Player/Reload/RifleReloadNormal + # # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + # DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield + # DamageMultiplier_2: 0, param1: dirt|stone|wood + # DamageMultiplier_3: 20, param1: rock # for rock drilling + + # RadialText: rdlStoneRemoval + # RadialDesc: rdlStoneRemovalTxt + # RadialIcon: Radial_StoneRemoval + # } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.025" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.0, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 10, display: true + UnlockLevel: 7, display: true + TechTreeParent: SurvivalTool + TechTreeNames: Tools +} +{ +Item Id: 20, Name: PistolT2, Ref: Pistol + Meshfile: Entities/Items/Weapons/Pistols/HandGunPrefab + CustomIcon: HandCannon + PickupToToolbar: true + MarketPrice: 550, display: true + Durability: 500, display: false + DegradationProb: 0.233, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + ROF: 0.38, type: float, display: true, formatter: ROF + Range: 99, display: false + NoiseStrength: 60, display: false + BulletSpread: 0.15, display: true + Recoil: 1.2, display: true + Damage: 170, display: true + AmmoCapacity: 7, display: true + ReloadDelay: 2.1, display: true + SfxBegin: Weapons/Player/Sniper/desert_eagle + + DamageMultiplier_1: 2.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + } + + UnlockCost: 15, display: true + UnlockLevel: 12, display: true + TechTreeParent: SMG + TechTreeNames: Weapons +} + +# not used anymore > instead: TextureColorTool +{ +Item Id: 21, Name: ColorTool + Meshfile: Entities/Items/Tools/ColorToolPrefab + PickupToToolbar: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + Info: bkiColorTool, display: true + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 0.40, type: float, display: true, formatter: Liter + #ShowUser: No + { Child 0 + Class: ColorBlock + AllowAt: "UnderWater, Planet, Space" + # ROF: 0.25, type: float + ROF: 0.1, type: float + Range: 30, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 25, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: false # no need to display since unlimited ammo + AmmoUnlimited: true + ReloadDelay: 3, display: false # no need to display since unlimited ammo + SfxBegin: Weapons/Player/Tools/BurningAirHotHigherPitch_start + SfxLoop: Weapons/Player/Tools/BurningAirHotHigherPitch_loop + # SfxBegin: Weapons/Player/Tools/CanSpraypaint_1 + # SfxLoop: Weapons/Player/Tools/CanSpraypaint_2 + # SfxStop: Weapons/Player/Tools/CanSpraypaint_3 + # SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + } + { Child 1 + Class: ColorBlock + AllowAt: "UnderWater, Planet, Space" + ROF: 1.0, type: float + OpenPopup: true + } + # UnlockCost: 0, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: Tools +} + +# not used anymore > instead: TextureColorTool +{ +Item Id: 22, Name: TextureTool + Meshfile: Entities/Items/Tools/TextureToolV2Prefab + PickupToToolbar: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + Info: bkiTextureTool, display: true + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 0.4, type: float, display: true, formatter: Liter + # ShowUser: No + + { Child 0 + Class: TextureBlock + AllowAt: "UnderWater, Planet, Space" + ROF: 0.1, type: float + Range: 30, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 25, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: false # no need to display since unlimited ammo + AmmoUnlimited: true + ReloadDelay: 3, display: false # no need to display since unlimited ammo + # SfxBegin: Weapons/Player/Tools/CanSpraypaint + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + } + { Child 1 + Class: TextureBlock + AllowAt: "UnderWater, Planet, Space" + ROF: 1.0, type: float + OpenPopup: true + } + # UnlockCost: 3, display: true + # UnlockLevel: 3, display: true + # TechTreeParent: ColorTool + # TechTreeNames: Tools +} + +{ +Item Id: 23, Name: Chainsaw + Meshfile: Entities/Items/Tools/ChainsawV2Prefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 355, display: true + HoldType: 16 + StackSize: 1 + Category: Weapons/Items + Info: bkiChainsaw, display: true + Mass: 16, type: float, display: true, formatter: Kilogram + Volume: 7, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.0625, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + AllowRemote: false + HarvestSupport: true + ROF: 0.25, type: float + Range: 2, display: false + NoiseStrength: 60, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + Damage: 85, display: true + AmmoCapacity: 125, display: true + AmmoType: BioFuel, display: true + ReloadDelay: 3.8, display: true + UseSingleMagazine: true + # SfxBegin: Weapons/Player/Tools/Chainsaw_start + SfxLoop: Weapons/Player/Tools/Chainsaw_loop + SfxStop: Weapons/Player/Tools/Chainsaw_end + SfxReload: Weapons/Player/Reload/HeavyWeaponReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0.09, param1: wood|plants # be able to remove trees and plants + DamageMultiplier_2: 0, param1: concrete|hull|hullarmored|hullcombat|metal|metalhard + DamageMultiplier_3: 0, param1: dirt|stone|rock + DamageMultiplier_4: 0, param1: shield + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.025" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.0, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: Tools +} + +{ +Item Id: 24, Name: AssaultRifleEpic, Ref: AssaultRifle + Meshfile: Entities/Items/Weapons/Rifles/AssaultRifleEpicPrefab + # Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2EpicPrefab + PickupToToolbar: true + MarketPrice: 3560, display: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.039, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + ROF: 0.170, type: float, display: true, formatter: ROF + Range: 198, display: false + NoiseStrength: 100, display: false + BulletSpread: 0.06, display: true + Recoil: 0.288, display: true + Damage: 110, display: true + AmmoCapacity: 45, display: true + ReloadDelay: 3, display: true + SfxBegin: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_end + # SfxBegin: Weapons/ProjectileWeapons/Automatic/M14_Heavy_start + # SfxLoop: Weapons/ProjectileWeapons/Automatic/M14_Heavy_loop + # SfxStop: Weapons/ProjectileWeapons/Automatic/M14_Heavy_end + + DamageMultiplier_1: 4.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew + ZoomOut: 40 + ZoomIn: 10 + RedDot: true + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # # need to set these because we extend a tech tree item + # UnlockCost: 0, display: true + # UnlockLevel: 7, display: true + # TechTreeParent: AssaultRifleT2 + # TechTreeNames: Weapons +} +{ +Item Id: 25, Name: Sniper2Epic, Ref: Sniper2 + Meshfile: Entities/Items/Weapons/Snipers/ScifiSniper3EpicPrefab + PickupToToolbar: true + MarketPrice: 6600, display: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.305, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + ROF: 0.425, type: float, display: true, formatter: ROF + Range: 400, display: false + NoiseStrength: 100, display: false + BulletSpread: 0.011, display: true + Recoil: 0.528, display: true + AmmoType: 15mmBullet, display: true + Damage: 500, display: true + AmmoCapacity: 13, display: true + ReloadDelay: 5, display: true + # AmmoType: PulseLaserChargeRifle, display: true + # SfxBegin: Weapons/Player/Sniper/weapon_sniper_004 + SfxBegin: Weapons/Player/Sniper/Resistance-SniperRifle-01-Single_Shot-03 + # PfxMuzzle: MuzzleFlash5 + + DamageMultiplier_1: 8, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood + DamageMultiplier_3: 0.75, param1: hullarmored|hullcombat + DamageMultiplier_4: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew + ZoomOut: 40 + ZoomIn: 5 + RedDot: true + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.025" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.05, 0.0, 0.0" + RecoilRotation: "3, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # UnlockCost: 0, display: true + # UnlockLevel: 15, display: true + # TechTreeParent: Sniper2 + # TechTreeNames: Weapons +} + +{ +Item Id: 26, Name: MinigunEpic, Ref: Minigun + Meshfile: Entities/Items/Weapons/ScifiMinigun/ScifiMinigunEpicPrefab + PickupToToolbar: true + MarketPrice: 4500, display: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.024, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + ROF: 0.1, type: float, display: true, formatter: ROF + Range: 198, display: false + NoiseStrength: 100, display: false + BulletSpread: 0.113, display: true + Recoil: 0.24, display: true + Damage: 80, display: true + AmmoCapacity: 560, display: true + AmmoType: PulseLaserChargeRifle, display: true + ReloadDelay: 3.149, display: true + SfxBegin: Weapons/Player/Minigun/minigun2_start + SfxLoop: Weapons/Player/Minigun/minigun2_loop + SfxStop: Weapons/Player/Minigun/minigun2_end + PfxMuzzle: SniperMuzzleLaserRed + Tracer: Weapons/Projectiles/TracerLaserShotRed + TracerPerBullet: 2 + + DamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0.09, param1: wood # able to destroy trees + DamageMultiplier_3: 0, param1: dirt|stone|rock|hullarmored|hullcombat + DamageMultiplier_4: 0.4, param1: shield + } + { Child 6 + Class: Movements + StandingLocation: "-0.05, 0.02, 0.05" + StandingRotation: "0.0, 0.0, -1.25" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "-0.5, 0.6, 1" + RunningLocation: "-0.05, -0.05, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.025, 0.0" + CrouchingRotation: "15, -30, 5" + RecoilLocation: "0.05, -0.1, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # UnlockCost: 0, display: true + # UnlockLevel: 20, display: true + # TechTreeParent: Minigun + # TechTreeNames: Weapons +} +{ +Item Id: 27, Name: Shotgun2Epic, Ref: Shotgun2 + Meshfile: Entities/Items/Weapons/Shotguns/ScifiShotgun2EpicPrefab + PickupToToolbar: true + MarketPrice: 3500, display: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.305, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.25, type: float, display: true, formatter: ROF + Range: 60, display: false + NoiseStrength: 80, display: false + CameraShake: 3 + Automatic: true + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 0.8, display: true + Recoil: 2, display: true + Damage: 55, display: true + AmmoCapacity: 11, display: true + AmmoType: ShotgunShells, display: true + ReloadDelay: 3.1, display: true + # SfxBegin: Weapons/Player/Shotgun/w1200_fire + SfxBegin: Weapons/Player/Shotgun/Resistance-ShotGun-01-Single_Shot-03 + SfxNoAmmo: Weapons/Misc/PumpgunDryFire02 + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: MuzzleFlash4 + + DamageMultiplier_1: 1, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0.015, param1: wood # able to destroy trees + DamageMultiplier_3: 0, param1: dirt|stone|rock|hullarmored|hullcombat + DamageMultiplier_4: 0.4, param1: metal # able to destroy door + DamageMultiplier_5: 0.05, param1: metalhard # able to destroy door + DamageMultiplier_6: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + RedDot: true + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # UnlockCost: 0, display: true + # UnlockLevel: 20, display: true + # TechTreeParent: Shotgun2 + # TechTreeNames: Weapons +} +{ +Item Id: 28, Name: LaserRifleEpic, Ref: LaserRifle + Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle4EpicPrefab + CustomIcon: LaserRifleEpic + # HoldType: 16 # no weapon shaking animation + PickupToToolbar: true + MarketPrice: 6800, display: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.061, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.17, type: float, display: true, formatter: ROF + BulletSpread: 0, display: true + Recoil: 0.3, display: true + Damage: 165, display: true + AmmoCapacity: 32, display: true + + PfxMuzzle: SniperMuzzleLaserBlue + Tracer: Weapons/Projectiles/TracerLaserShotBlue + TracerPerBullet: 1 + + DamageMultiplier_1: 4.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0.2, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 5 + RedDot: true + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.025" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.05, 0.0, 0.0" + RecoilRotation: "3, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # UnlockCost: 0, display: true + # UnlockLevel: 25, display: true + # TechTreeParent: LaserRifle + # TechTreeNames: Weapons +} +{ +Item Id: 29, Name: ScifiCannonEpic, Ref: ScifiCannon + Meshfile: Entities/Items/Weapons/ScifiCannon/ScifiCannonEpicPrefab + PickupToToolbar: true + MarketPrice: 12500, display: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 700, display: false + DegradationProb: 0.488, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + ROF: 0.5, type: float, display: true, formatter: ROF + Automatic: true, display: true + AmmoCapacity: 3, display: true + Recoil: 1.44, display: true + ReloadDelay: 6, display: true + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # UnlockCost: 0, display: true + # UnlockLevel: 25, display: true + # TechTreeParent: ScifiCannon + # TechTreeNames: Weapons +} +{ +Item Id: 30, Name: PistolEpic, Ref: Pistol + Meshfile: Entities/Items/Weapons/Pistols/SciFiPistol4EpicPrefab + CustomIcon: PistolEpic + PickupToToolbar: true + MarketPrice: 1200, display: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.151, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + ROF: 0.170, type: float, display: true, formatter: ROF + Automatic: true, display: true + Range: 119, display: false + BulletSpread: 0.075, display: true + Recoil: 0.384, display: true + Damage: 79, display: true + AmmoCapacity: 24, display: true + ReloadDelay: 2.019, display: true + SfxBegin: Weapons/Player/Pistol/AWDS_AR_ HK53_Heavy_Single + + DamageMultiplier_1: 5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 15 + RedDot: true + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # UnlockCost: 0, display: true + # UnlockLevel: 12, display: true + # TechTreeParent: PistolT2 + # TechTreeNames: Weapons +} +{ +Item Id: 31, Name: MultiToolT2, Ref: MultiTool + Meshfile: Entities/Items/Weapons/MultiToolT2Prefab + MarketPrice: 1998, display: true + { Child 0 # "Retrieve Blocks" (= get complete blocks in own structure, do "Salvage" in others) + Range: 8 + AmmoCapacity: 250, display: true + AmmoType: MultiCharge, display: true + ReturnFactor: 0.667, display: true + + DamageMultiplier_1: 0, param1: dirt|stone|wood|shield|rock + DamageMultiplier_2: 50, param1: metalhard # 400 damage + DamageMultiplier_3: 25, param1: metal # 200 damage + DamageMultiplier_4: 65, param1: hull # 500 damage + DamageMultiplier_5: 125, param1: hullarmored # 1000 damage + DamageMultiplier_6: 250, param1: hullcombat # 2000 damage + DamageMultiplier_7: 150, param1: concrete # 1200 damage + DamageMultiplier_8: 50, param1: woodblock # 400 damage + DamageMultiplier_9: 50, param1: forcefield + } + { Child 1 # "Repair" + Range: 8 + AmmoCapacity: 250, display: true + AmmoType: MultiCharge, display: true + RepairPoints: 50 + } + { Child 2 # "Exchange and Rotate" + Range: 8 + AmmoCapacity: 250, display: true + AmmoType: MultiCharge, display: true + } + { Child 3 # "Downgrade" + Range: 8 + AmmoCapacity: 250, display: true + AmmoType: MultiCharge, display: true + ReturnFactor: 0.667 + } + { Child 4 # "Upgrade" + Range: 8 + AmmoCapacity: 250, display: true + AmmoType: MultiCharge, display: true + } + { Child 5 # "Salvage" (get components from a block) + ReturnFactor: 0.667 + Range: 8 + AmmoCapacity: 250, display: true + AmmoType: MultiCharge, display: true + + DamageMultiplier_1: 0, param1: dirt|stone|wood|shield|rock + DamageMultiplier_2: 50, param1: metalhard # 400 damage + DamageMultiplier_3: 25, param1: metal # 200 damage + DamageMultiplier_4: 65, param1: hull # 500 damage + DamageMultiplier_5: 125, param1: hullarmored # 1000 damage + DamageMultiplier_6: 250, param1: hullcombat # 2000 damage + DamageMultiplier_7: 150, param1: concrete # 1200 damage + DamageMultiplier_8: 50, param1: woodblock # 400 damage + DamageMultiplier_9: 50, param1: forcefield + } + + UnlockCost: 14, display: true + UnlockLevel: 12, display: true + TechTreeParent: MultiTool + TechTreeNames: Tools +} +# Damage and Multipliers defined in EClassConfig > Entity Name: ExplosiveDevice +{ +Item Id: 32, Name: Explosives + MarketPrice: 340, display: true + Meshfile: Entities/Misc/ScifiExplosiveDevice1Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + PickupToToolbar: true + Material: metalitem + Canhold: false + HoldType: 0 + StackSize: 2000 + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Category: Weapons/Items + Info: bkiExplosives, display: true + { Child 0 + Class: PlaceExplosives + AllowRemote: false + ROF: 1, type: float + Range: 2 + NoiseStrength: 100, display: false + SfxBegin: UseActions/ExplosivesAttach + + } + + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeNames: Weapons +} +{ +Item Id: 33, Name: RocketLauncherT2, Ref: RocketLauncher + Meshfile: Entities/Items/Weapons/SciFiGrenadeLauncher/SciFiGrenadeLauncherT2Prefab + CustomIcon: RocketLauncherT2 + PickupToToolbar: true + MarketPrice: 8510, display: true + Durability: 500, display: false + #just for display, see ammo "SlowRocketHoming" + BlastRadius: 3, display: true + BlastDamage: 500, display: true + DegradationProb: 0.75, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: LauncherSS + ROF: 2.55, type: float, display: true, formatter: ROF + Recoil: 1.92, display: true + Range: 360, display: false + AmmoCapacity: 6, display: true + AmmoType: SlowRocketHoming, display: true + ReloadDelay: 4.275, display: true + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew03 + ZoomOut: 40 + ZoomIn: 20 + RedDot: true + } + + UnlockCost: 20, display: true + UnlockLevel: 15, display: true + TechTreeParent: RocketLauncher + TechTreeNames: Weapons +} +{ +Item Id: 34, Name: RocketLauncherEpic, Ref: RocketLauncher + Meshfile: Entities/Items/Weapons/SciFiGrenadeLauncher/SciFiGrenadeLauncherEpicPrefab + CustomIcon: RocketLauncherEpic + PickupToToolbar: true + MarketPrice: 11000, display: true + Info: bkiEpicWeapon, display: true + #just for display, see ammo + BlastRadius: 3, display: true + BlastDamage: 500, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.488, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: LauncherSS + ROF: 0.5, type: float, display: true, formatter: ROF + Automatic: true, display: true + Recoil: 1.152, display: true + ReloadDelay: 6, display: true + AmmoCapacity: 3, display: true + AmmoType: SlowRocketHoming, display: true + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew03 + ZoomOut: 40 + ZoomIn: 15 + RedDot: true + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # UnlockCost: 0, display: true + # UnlockLevel: 20, display: true + # TechTreeParent: RocketLauncherT2 + # TechTreeNames: Weapons +} +{ +Item Id: 35, Name: LaserPistolT2, Ref: LaserPistol + Meshfile: Entities/Items/Weapons/Pistols/BlasterPrefab + CustomIcon: Blaster + PickupToToolbar: true + MarketPrice: 1265, display: true + # DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + # Damage: 100, display: true + Durability: 500, display: false + DegradationProb: 0.2, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + ROF: 0.45, type: float, display: true, formatter: ROF + Recoil: 0.8, display: true + Range: 99, display: false + Damage: 220, display: true + AmmoCapacity: 10, display: true + ReloadDelay: 2.375, display: true + SfxBegin: Weapons/Player/LaserPistol/BLASTERLargeFastKickback + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0.005, param1: shield + } + UnlockCost: 20, display: true + UnlockLevel: 25, display: true + TechTreeParent: LaserSMG + TechTreeNames: Weapons +} +{ +Item Id: 36, Name: AssaultRifleT2, Ref: AssaultRifle + Meshfile: Entities/Items/Weapons/Rifles/AssaultRifleT2Prefab + # Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2BlackPrefab + PickupToToolbar: true + MarketPrice: 2960, display: true + Durability: 500, display: false + DegradationProb: 0.06, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + ROF: 0.085, type: float, display: true, formatter: ROF + Range: 165, display: false + BulletSpread: 0.44, display: true + Recoil: 0.6, display: true + Damage: 65, display: true + AmmoCapacity: 30, display: true + ReloadDelay: 2.945, display: true + # SfxBegin: Weapons/Player/AssaultRifle/AWDS_AR_HK416_start + # SfxLoop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_loop + # SfxStop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_end + SfxBegin: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Heavy_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Heavy_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Heavy_end + + DamageMultiplier_1: 4, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + # ZoomOverlay: Entities/Items/Weapons/SniperScopes/Scope04 + ZoomOut: 40 + ZoomIn: 15 + RedDot: true + } + UnlockCost: 7, display: true + UnlockLevel: 15, display: true + TechTreeParent: SniperRifle + TechTreeNames: Hidden +} +{ +Item Id: 37, Name: PulseRifleT2, Ref: PulseRifle + Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2BlackT2Prefab + # Meshfile: Entities/Items/Weapons/PulseRifle/PulseRifleT2Prefab + PickupToToolbar: true + Durability: 500, display: false + MarketPrice: 3420, display: true + DegradationProb: 0.094, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + ROF: 0.075, type: float, display: true, formatter: ROF + Range: 187, display: false + BulletSpread: 0.075, display: true + Recoil: 0.32, display: true + Automatic: true, display: true + Damage: 80, display: true + AmmoCapacity: 35, display: true + AmmoType: 5.8mmBullet, display: true + ReloadDelay: 2.945, display: true + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 20 + RedDot: true + } + UnlockCost: 20, display: true + UnlockLevel: 25, display: true + TechTreeParent: AssaultRifleT2 + TechTreeNames: Hidden +} +{ +Item Id: 38, Name: PulseRifleEpic, Ref: PulseRifle + Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2BlackEpicPrefab + # Meshfile: Entities/Items/Weapons/PulseRifle/PulseRifleEpicPrefab + PickupToToolbar: true + MarketPrice: 4650, display: true + Durability: 500, display: false + DegradationProb: 0.061, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + { Child 0 + Class: Ranged + ROF: 0.1, type: float, display: true, formatter: ROF + ReloadDelay: 3, display: true + Range: 224, display: false + BulletSpread: 0.038, display: true + Recoil: 0.192, display: true + Automatic: true, display: true + Damage: 140, display: true + AmmoCapacity: 56, display: true + AmmoType: PulseLaserChargeRifle, display: true + + PfxMuzzle: SniperMuzzleLaserGreen + Tracer: Weapons/Projectiles/TracerLaserShotGreen + TracerPerBullet: 2 + + DamageMultiplier_1: 4, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0.1, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 10 + RedDot: true + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.025" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.08, 0.0, 0.0" + RecoilRotation: "5, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden + # # need to set these because we extend a tech tree item + # UnlockCost: 0, display: true + # UnlockLevel: 20, display: true + # TechTreeParent: PulseRifleT2 + # TechTreeNames: Weapons +} +{ +Item Id: 39, Name: SurvivalTool + Meshfile: Entities/Items/Weapons/SurvivalToolPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab + PickupToToolbar: true + Material: metalweapon + LifetimeOnDrop: 1200 + MarketPrice: 100, display: true + HoldType: 16 # no weapon shaking animation + StackSize: 1 + Category: Weapons/Items + Mass: 3, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + Info: bkiSurvivalTool, display: true + RadialMenu: true + { Child 0 # Resource mining, stone removal, wood cutter + Class: Driller + DrillMode: 3 + DecoMode: true + InfoPopup: true + TerrainMode: true + HarvestSupport: true + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float, display: false, formatter: ROF + Damage: 120 + Radius: 1.75 + Range: 5, display: false + NoiseStrength: 50, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 25, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true # no need to display since unlimited ammo + AmmoUnlimited: true + UseSingleMagazine: true + ReloadDelay: 3.1, display: true + Bullet_use_per_shot: 0.25 + Particles: digger # particle effect shown at impact + ParticleTransform: Muzzle/Particle2 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning_start + SfxLoop: Weapons/Player/Tools/WeaponLightning_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + PfxMuzzle: MuzzleFlashLightning + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0.05, param1: plants|woodsoft|wood|woodhard + # DamageMultiplier_2: 0, param1: wood # no tree cutting + # DamageMultiplier_3: 0.25, param1: resourcemedium + # DamageMultiplier_4: 0.1, param1: resourcehard + + + RadialText: rdlResourceMiningST + RadialDesc: rdlResourceMiningDescST + RadialIcon: Radial_ResourceMining + } + { Child 1 #Defense Mode + Class: Ranged + AllowRemote: false + ROF: 0.25, type: float + Range: 15, display: false + NoiseStrength: 20, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + Damage: 19, display: true + # Recoil: 0.2, display: true + # Stamina: -7, display: false + AmmoCapacity: 25, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true # no need to display since unlimited ammo + AmmoUnlimited: true + ReloadDelay: 3, display: false # no need to display since unlimited ammo + HarvestSupport: true + UseSingleMagazine: true + ParticleTransform: Muzzle/Particle1 + SfxBegin: Weapons/Player/Tools/WeaponLightning_start + SfxLoop: Weapons/Player/Tools/WeaponLightning_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # PfxMuzzle: MuzzleFlashSurvivalTool # for Class: Ranged, we need to use this + # PfxMuzzle: MuzzleFlashSurvivalToolV2 + + DamageMultiplier_1: 0, param1: wood|woodsoft|woodhard|plants # no wood/plant harvesting + DamageMultiplier_2: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard + DamageMultiplier_3: 0, param1: dirt|stone|rock + DamageMultiplier_4: 0.083, param1: shield + + RadialText: rdlDefence # add text + RadialDesc: rdlDefenceDescST # add text + RadialIcon: Radial_Defence + } + { Child 2 #Salvage mode + Class: Driller + AllowAt: "UnderWater, Planet, Space" + ROF: 0.5, type: float, display: false, formatter: ROF + Range: 5 + # Recoil: 0.1, display: true + NoiseStrength: 10, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + Damage: 17 + # Stamina: -7 + Radius: 3 + Demolish: True + ReturnFactor: 0.333, display: true + AmmoCapacity: 25, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true # no need to display since unlimited ammo + AmmoUnlimited: true + ReloadDelay: 3, display: false # no need to display since unlimited ammo + ParticleTransform: Muzzle/Particle3 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning_start + SfxLoop: Weapons/Player/Tools/WeaponLightning_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # ParticleTransform: "" # do NOT show a particle effect + + DamageMultiplier_1: 0, param1: dirt|stone|wood|shield|plants|woodsoft|woodhard # no wood/plant harvesting + DamageMultiplier_2: 10, param1: metalhard # 20/3 damage + DamageMultiplier_3: 5, param1: metal # 10/3 damage + DamageMultiplier_4: 10, param1: hull # 20/3 damage + DamageMultiplier_5: 25, param1: hullarmored # 50/3 damage + DamageMultiplier_6: 50, param1: hullcombat # 100/3 damage + DamageMultiplier_7: 25, param1: concrete # 50 damage + DamageMultiplier_8: 10, param1: woodblock # 20 damage + DamageMultiplier_9: 25, param1: forcefield + + RadialText: rdlDeconstructST + RadialDesc: rdlDeconstructTxt + RadialIcon: Radial_Deconstruct + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.025" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.0, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockLevel: 1 + TechTreeNames: Tools +} +{ +Item Id: 40, Name: DrillEpic, Ref: DrillT2 + Meshfile: Entities/Items/Weapons/DrillV2/DrillT2EpicPrefab + MarketPrice: 1500, display: true + { Child 0 # Resource Mining + Damage: 750 + Range: 9, display: false + Radius: 2.5 + } + { Child 2 # Flattening + Damage: 1500 + Radius: 3.5 + Range: 6, display: false + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden +} + + +{ +Item Id: 41, Name: TextureColorTool + # Meshfile: Entities/Items/Tools/TextureColorToolPrefab + Meshfile: Entities/Items/Tools/ColorTextureToollPrefab + PickupToToolbar: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + MarketPrice: 168, display: true + Category: Weapons/Items + Info: bkiTextureColorTool, display: true + Mass: 2.5, type: float, display: true, formatter: Kilogram + Volume: 1.40, type: float, display: true, formatter: Liter + { Child 0 + Class: TextureColorBlock + AllowAt: "UnderWater, Planet, Space" + ROF: 0.1, type: float + Range: 30, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 25, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: false # no need to display since unlimited ammo + AmmoUnlimited: true + ReloadDelay: 3, display: false # no need to display since unlimited ammo + SfxBegin: Weapons/Player/Tools/BurningAirHotHigherPitch_start + SfxLoop: Weapons/Player/Tools/BurningAirHotHigherPitch_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + } + { Child 1 + Class: TextureColorBlock + AllowAt: "UnderWater, Planet, Space" + ROF: 1.0, type: float + OpenPopup: true + } + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: Tools +} + +{ +Item Id: 42, Name: FlameThrower + Meshfile: Entities/Items/Weapons/Flamethrower/FlamethrowerV2Prefab + CustomIcon: FlamethrowerV2 + PickupToToolbar: true + Material: metalweapon + HoldType: 15 + StackSize: 1 + MarketPrice: 1980, display: true + Category: Weapons/Items + Mass: 60, type: float, display: false, formatter: Kilogram + Info: bkiFlameThrower, display: true + Durability: 500, display: false + DegradationProb: 0.2, type: float, display: false + + # ShowUser: No + + { Child 0 + Class: LauncherSS + ROF: 0.25, type: float + Range: 6, display: false + #BulletsPerShot: 8 + #BulletSpread: 1 + #RaySpread: 1, display: true + Recoil: 0, display: true + NoiseStrength: 40, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 50, display: true + AmmoType: FlameThrowerCanister, display: true + # UseSingleMagazine: true + ReloadDelay: 2.9, display: true + # Bullet_use_per_shot: 0.25 + # Particles: diggerplasma # particle effect shown at impact + PfxMuzzle: flamethrower_v1 + SfxBegin: Weapons/Player/FlameThrower/Flamethrower_Start + SfxLoop: Weapons/Player/FlameThrower/Flamerthrower_Firing + SfxStop: Weapons/Player/Drill/Mute + SfxReload: Weapons/Player/FlameThrower/Flamethrower_Reload + # SfxNoAmmo: + # SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + + } + { Child 6 + Class: Movements + StandingLocation: "-0.05, 0.03, 0.05" + StandingRotation: "0.0, 0.0, -1.25" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "-0.05, 0.6, 1" + RunningLocation: "-0.05, 0.02, 0.05" + RunningRotation: "-15, -45, 10" + CrouchingLocation: "0.0, 0.025, 0.0" + CrouchingRotation: "15, -30, 5" + RecoilLocation: "0.010, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, -0.05" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 0 + UnlockLevel: 0 + TechTreeNames: Hidden +} + +{ Item Id: 43, Name: PlasmaCannonAlien + Meshfile: Entities/Items/Weapons/Flamethrower/AlienFlamethrowerPrefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 11500, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + Info: bkiPlasmaCannonAlien, display: true + Mass: 45, type: float, display: true, formatter: Kilogram + Volume: 50, type: float, display: true, formatter: Liter + #just for display - BlastRadius and BlastDamage is controlled by "SciFiCannonPlasmaCharge" + Damage: 1000, display: true + BlastRadius: 3, display: true + BlastDamage: 500, display: true + # Explosion Radius: 3, display: true + Durability: 350, display: false + DegradationProb: 1, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: LauncherSS + AllowRemote: false + ROF: 1, type: float, display: true, formatter: ROF + Range: 450, display: false + NoiseStrength: 100, display: false + CameraShake: 1 + Recoil: 0, display: true + IgnoreAtmo: true, display: true + AmmoCapacity: 5, display: true + AmmoType: PlasmaCannonAlienCharge, display: true + ReloadDelay: 3, display: true + SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Reload/HeavyWeaponReloadNormal + PfxMuzzle: SniperMuzzleLaserRed + } + + UnlockCost: 0 + UnlockLevel: 0 + TechTreeNames: Hidden +} + +{ Item Id: 44, Name: OtyughFlameThrowerWeapon + Meshfile: Entities/Items/Weapons/Enemies/RangedAttackPrefab + Material: human + HoldType: 20 + StackSize: 1 + Category: Weapons/Items + #SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + + ShowUser: No + + { Child 0 + Class: Ranged + ROF: 0.0, type: float + Range: 8, display: false + BulletsPerShot: 1 + BulletSpread: 0 + RaySpread: 1, display: true + Recoil: 0, display: true + NoiseStrength: 40, display: false + CameraShake: 0 + IgnoreAtmo: true + Buff: "Fire" + Buff_chance: 1 + Automatic: true + Damage: 70 + Radius: 3 + AmmoCapacity: 750, display: true + AmmoType: BioFuel, display: true + UseSingleMagazine: true + ReloadDelay: 0.0, display: true + Bullet_use_per_shot: 0.25 + ForceMuzzleAiming: false + # Particles: diggerplasma # particle effect shown at impact + PfxMuzzle: land_predator_fire_breath + # ParticleTransform: Muzzle/Flamethrower_v1 # particle effect shown at muzzle + # SfxBegin: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_start + # SfxLoop: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_loop + SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxStop: Weapons/Player/Drill/Mute + SfxReload: UseActions/oxygen_tank_release_air + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + DamageMultiplier_1: 0.1, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0.1, param1: wood + DamageMultiplier_3: 0.001, param1: stone + DamageMultiplier_4: 0.1, param1: rock + DamageMultiplier_5: 0.001, param1: dirt + DamageMultiplier_6: 0.001, param1: hullarmored + DamageMultiplier_7: 0.001, param1: hullcombat + } + + UnlockCost: 0 + UnlockLevel: 0 + TechTreeNames: Hidden +} + +{ +Item Id: 45, Name: OtyughFireBallRangedWeapon + Meshfile: Entities/Items/Weapons/Enemies/RangedAttackPrefab + Material: human + HoldType: 20 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: LauncherSS + ROF: 0, type: float + Range: 30, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 35, display: true + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: OtyughFireBall, display: true + ReloadDelay: 6.3, display: true + SfxBegin: Enemies/Otyugh/Otyugh_SfxAttack_a2 + # MaxFireAngle: 15 #we need bigger angle, as the bug firing angle is not perfectly aligned with look angle + } +} + +{ +Item Id: 46, Name: AlienBugBigRangedWeapon + Meshfile: Entities/Items/Weapons/Enemies/RangedAttackAIPrefab + Material: human + HoldType: 20 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: LauncherAI + BulletsPerShot: 8 + BulletSpread: 0.6 + ROF: 0, type: float + Range: 30, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 35, display: true + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: SlimeAlienBigBug, display: true + ReloadDelay: 6.3, display: true + SfxBegin: Enemies/AlienBugs/AlienBug03/AlienBug03_SfxHardAttack + # MaxFireAngle: 15 #we need bigger angle, as the bug firing angle is not perfectly aligned with look angle + } +} +# available free 46-47 (+2) + + + +# Debug weapons +{ +Item Id: 48, Name: RocketLauncherDebug, Ref: RocketLauncher + Meshfile: Entities/Items/Weapons/SciFiGrenadeLauncher/ScifiGrenadeLauncherDebugPrefab + PickupToToolbar: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 2000, display: false + DegradationProb: 0.25, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: LauncherSS + ROF: 0.1, type: float, display: true, formatter: ROF + Recoil: 0, display: true + HomingSpeed: 3 + Range: 950, display: false + ReloadDelay: 3.7, display: true + Automatic: true + AmmoCapacity: 10, display: false + AmmoType: RocketDebug, display: true + AmmoUnlimited: true + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 15 + RedDot: true + } + UnlockCost: 0 + UnlockLevel: 20 + TechTreeNames: Hidden +} +{ +Item Id: 49, Name: AssaultRifleDebug, Ref: AssaultRifle + Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2DebugPrefab + PickupToToolbar: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 2400, display: false + DegradationProb: 0.25, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + AllowAt: "UnderWater, Planet, Space" + ROF: 0.15, type: float, display: true, formatter: ROF + Range: 950, display: false + Damage: 10000, display: true + Recoil: 0, display: true + CameraShake: 0 + AmmoCapacity: 50, display: true + ReloadDelay: 3.2, display: false + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + SfxBegin: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_end + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 1, param1: wood|rock + DamageMultiplier_3: 0, param1: dirt|stone + DamageMultiplier_4: 1, param1: hullarmored|hullcombat + DamageMultiplier_5: 0.05, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew + ZoomOut: 40 + ZoomIn: 10 + RedDot: true + } + UnlockCost: 0 + UnlockLevel: 20 + TechTreeNames: Hidden +} +{ +Item Id: 50, Name: DrillDebug, Ref: DrillT2 + Meshfile: Entities/Items/Weapons/DrillV2/DrillDebugPrefab + { Child 0 + Damage: 1000 + Range: 14, display: false + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + } + { Child 1 + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + } + { Child 2 + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + } + { Child 3 + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + } + UnlockCost: 0 + UnlockLevel: 20 + TechTreeNames: Hidden +} + + +# == AMMO + +# Ammo id 51 - 79 +{ +Item Id: 51, Name: SlowRocket + Meshfile: Entities/Items/Weapons/Projectiles/rocket01HandheldPrefab + # Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + HoldType: 0 + MarketPrice: 18, display: true + Canhold: false + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + StackSize: 2000 + Mass: 0.9, type: float, display: true, formatter: Kilogram + Volume: 0.45, type: float, display: true, formatter: Liter + { Child 0 + Class: Projectile + Damage: 450 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 50 + Range: 200 + Ballistic: True + + DamageMultiplier_1: 0, param1: dirt|stone + DamageMultiplier_2: 0.75, param1: hullarmored|hullcombat + DamageMultiplier_3: 0.75, param1: shield + DamageMultiplier_4: 10, param1: rock + BlastDamageMultiplier_1: 0.75, param1: hullarmored|hullcombat + BlastDamageMultiplier_2: 0.5, param1: dirt|stone + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 52, Name: 50Caliber + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + HoldType: 0 + MarketPrice: 1, display: true + Canhold: false + StackSize: 4000 + Info: bkiAmmo, display: true + Category: Weapons/Items + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 53, Name: 8.3mmBullet, Ref: 50Caliber + StackSize: 4000 + MarketPrice: 1, display: true + Mass: 0.02, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter +} +{ +Item Id: 54, Name: 5.8mmBullet, Ref: 50Caliber + StackSize: 4000 + MarketPrice: 1, display: true + Mass: 0.02, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter +} +{ +Item Id: 55, Name: 12.7mmBullet, Ref: 50Caliber + StackSize: 4000 + MarketPrice: 1, display: true + Mass: 0.06, type: float, display: true, formatter: Kilogram + Volume: 0.03, type: float, display: true, formatter: Liter +} +{ +Item Id: 56, Name: 15mmBullet, Ref: 50Caliber + StackSize: 4000 + MarketPrice: 1, display: true + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter +} +{ +Item Id: 57, Name: DrillCharge, Ref: 50Caliber + StackSize: 2000 + MarketPrice: 7, display: true + Mass: 1.05, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + Info: bkiAmmoFullLoad, display: true +} +{ +Item Id: 58, Name: ShotgunShells, Ref: 50Caliber + StackSize: 4000 + MarketPrice: 2.5, display: true + Mass: 0.03, type: float, display: true, formatter: Kilogram + Volume: 0.02, type: float, display: true, formatter: Liter +} +{ Item Id: 59, Name: FillerCharge, Ref: 50Caliber + StackSize: 50 + Info: bkiAmmoFullLoad, display: true + ShowUser: NoButCSV +} +{ +Item Id: 60, Name: PulseLaserChargePistol, Ref: 50Caliber + StackSize: 4000 + MarketPrice: 1.5, display: true + Mass: 0.15, type: float, display: true, formatter: Kilogram + Volume: 0.05, type: float, display: true, formatter: Liter +} +{ +Item Id: 61, Name: PulseLaserChargeRifle, Ref: 50Caliber + StackSize: 4000 + MarketPrice: 2, display: true + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.06, type: float, display: true, formatter: Liter +} +{ +Item Id: 62, Name: MultiCharge, Ref: 50Caliber + StackSize: 2000 + MarketPrice: 11, display: true + Mass: 1.05, type: float, display: true, formatter: Kilogram + Volume: 0.6, type: float, display: true, formatter: Liter + Info: bkiAmmoFullLoad, display: true +} +{ +Item Id: 63, Name: BioFuel, Ref: 50Caliber + StackSize: 2000 + Info: bkiBioFuel, display: true + AutoFill: false + MarketPrice: 3.5, display: true + Category: Components + FuelValue: 10, type: int, display: true, formatter: WattHour + Mass: 20, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter +} + +{ +Item Id: 64, Name: SciFiCannonPlasmaCharge + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectilePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + MarketPrice: 15, display: true + HoldType: 0 + Canhold: false + Info: bkiAmmoHoming, display: true + Category: Weapons/Items + StackSize: 2000 + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + { Child 0 + Class: Projectile + Damage: 600, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 75 + Range: 300 + NoiseStrength: 100, display: false + Ballistic: false + + DamageMultiplier_1: 0, param1: dirt|stone + DamageMultiplier_2: 0.75, param1: hullarmored|hullcombat + # DamageMultiplier_3: 10, param1: rock + DamageMultiplier_4: 0.95, param1: shield + BlastDamageMultiplier_1: 0.75, param1: hullarmored|hullcombat + BlastDamageMultiplier_2: 0.5, param1: dirt|stone + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 65, Name: SlowRocketHoming, Ref: SlowRocket + Info: bkiAmmoExplosive, display: true + StackSize: 2000 + MarketPrice: 32, display: true + Mass: 1.6, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + { Child 0 + Class: Projectile + Damage: 600 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 50 + HomingSpeed: 2 + Range: 350 + Ballistic: false + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 66, Name: PlasmaCannonAlienCharge + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketRedPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + CustomIcon: EnergyCellHydrogen + MarketPrice: 20, display: true + Material: metalweapon + HoldType: 0 + Canhold: false + Info: bkiPlasmaCannonAlienCharge, display: true + Category: Weapons/Items + StackSize: 2000 + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + { Child 0 + Class: Projectile + Damage: 1000, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 650, display: true + Speed: 90 + Range: 450 + NoiseStrength: 80, display: false + Ballistic: false + HomingSpeed: 2, display: true + + DamageMultiplier_1: 0, param1: dirt|stone + DamageMultiplier_2: 1.1, param1: hullarmored|hullcombat + # DamageMultiplier_3: 10, param1: rock + DamageMultiplier_4: 1.1, param1: shield + BlastDamageMultiplier_1: 1.1, param1: hullarmored|hullcombat + BlastDamageMultiplier_2: 0.5, param1: dirt|stone + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 67, Name: FlameThrowerCanister + Meshfile: Entities/Items/Weapons/Projectiles/FlameThrowerProjectilePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + MarketPrice: 6, display: true + HoldType: 0 + Canhold: false + Info: bkiFlameThrowerCanister, display: true + Category: Weapons/Items + StackSize: 2000 + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + { Child 0 + Class: Projectile + Damage: 50, display: true + BlastParticleIndex: 15 + BlastRadius: 1, display: true + BlastDamage: 50, display: true + Speed: 150 + Range: 6 + BulletsPerShot: 8 + BulletSpread: 5 + NoiseStrength: 80, display: false + Ballistic: true + # HomingSpeed: 2, display: true + + DamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 2, param1: wood|woodblock|plastic + DamageMultiplier_3: 0, param1: dirt|stone|rock + DamageMultiplier_4: 0, param1: concrete|hull|hullarmored|hullcombat|metal|metalhard + DamageMultiplier_5: 0, param1: shield + DamageMultiplier_6: 2, param1: human + BlastDamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + BlastDamageMultiplier_2: 2, param1: wood|woodblock|plastic + BlastDamageMultiplier_3: 0, param1: dirt|stone|rock + BlastDamageMultiplier_4: 0, param1: concrete|hull|hullarmored|hullcombat|metal|metalhard + BlastDamageMultiplier_5: 0, param1: shield + BlastDamageMultiplier_6: 2, param1: human + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 68, Name: OtyughFireBall + Meshfile: Entities/Items/Weapons/Projectiles/OtyughFireBallPrefab + Material: human + HoldType: 0 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 120 + BlastParticleIndex: 11 + BlastRadius: 2 + BlastDamage: 60 + Speed: 30 + HomingSpeed: 0.0 + Range: 100 + Ballistic: true + Buff: "DermalBurn,Stunned,IncreaseRadiation" + Buff_chance: "0.8,0.015,0.005" + + DamageMultiplier_1: 0, param1: dirt|stone + BlastDamageMultiplier_1: 0.05, param1: dirt|stone + } +} + +{ +Item Id: 69, Name: SlimeAlienBigBug + Meshfile: Entities/Items/Weapons/Projectiles/SlimeAlienBugBigPrefab + Material: human + HoldType: 0 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 60 + BlastParticleIndex: 16 + BlastRadius: 3 + BlastDamage: 40 + Speed: 20 + HomingSpeed: 2.0 + Range: 100 + Ballistic: false + Buff: "DermalBurn,Stunned,IncreaseRadiation" + Buff_chance: "0.8,0.015,0.005" + + DamageMultiplier_1: 0, param1: dirt|stone + BlastDamageMultiplier_1: 0.05, param1: dirt|stone + } +} + +# { Item Id: 70, Name: BombUnguided +# Meshfile: Entities/Items/Weapons/Projectiles/BombDronePrefab +# Material: metalweapon +# HoldType: 0 +# StackSize: 25 +# MarketPrice: 250, display: true +# Canhold: false +# Category: Weapons/Items +# Mass: 250, type: float, display: true, formatter: Kilogram +# Volume: 25, type: float, display: true, formatter: Liter +# ShowUser: No +# { Child 0 +# Class: Projectile +# Damage: 800, display: true +# BlastParticleIndex: 3 +# BlastRadius: 4, display: true +# BlastDamage: 1800, display: true +# Speed: 1 +# Range: 360 +# Ballistic: true +# +# DamageMultiplier_1: 10, param1: metal +# DamageMultiplier_2: 25, param1: metalhard|concrete|woodblock|hull +# DamageMultiplier_3: 60, param1: hullarmored|hullcombat +# DamageMultiplier_4: 30, param1: dirt|stone +# DamageMultiplier_5: 0, param1: shield +# BlastDamageMultiplier_1: 0.05, param1: dirt|stone +# } +# +# UnlockLevel: 1 +# TechTreeNames: Hidden +# } +# + +# free + +{ Item Id: 78, Name: AmmoInfinite # Dummy ammo needed for weapons that have unlimited ammo (eg DrillAttachmentWeapon) + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + HoldType: 0 + Canhold: false + StackSize: 75 + Mass: 1, type: float, display: false, formatter: Kilogram + ShowUser: No + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +# Debug weapon ammo +{ +Item Id: 79, Name: RocketDebug, Ref: SlowRocket + Info: bkiAmmoExplosive, display: true + StackSize: 75 + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter + ShowUser: No + { Child 0 + Class: Projectile + Damage: 10000 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + HomingSpeed: 3 + BlastDamage: 10000, display: true + Speed: 150 + Range: 1000 + Ballistic: false + } + UnlockLevel: 1 + TechTreeNames: Hidden +} + + +# Use items player id 80-99 + +## Please do not rename - referenced in code +{ +Item Id: 80, Name: OxygenBottleLarge + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + MarketPrice: 37, display: true + Canhold: false + HoldType: 0 + Category: Weapons/Items + StackSize: 1000 + Mass: 12, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + O2Value: 250, display: true, type: int # O2Value should be multiples of 200 (=base buff value) +} +## Please do not rename - referenced in code +{ +Item Id: 81, Name: OxygenBottleSmall + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + MarketPrice: 5, display: true + Material: metalweapon + Canhold: false + AutoFill: false + HoldType: 0 + StackSize: 2000 + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + O2Value: 75, display: false, type: int + Category: Weapons/Items + Info: bkiOxygenBottleSmall, display: true + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 75, display: OxygenValue + SfxBegin: UseActions/oxygen_tank_release_air + } +} +{ Item Id: 82, Name: TestDecOxygen + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + HoldType: 0 + Category: Components + ShowUser: No + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: -100 + SfxBegin: UseActions/playerDrinking + } +} +{ Item Id: 83, Name: TestDecFood + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + HoldType: 0 + Category: Components + ShowUser: No + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: -100 + AddOxygen: 0 + SfxBegin: UseActions/playerDrinking + } +} +{ Item Id: 84, Name: TestDecHealth + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + HoldType: 0 + Category: Components + ShowUser: No + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -100 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + SfxBegin: UseActions/playerDrinking + } +} + +{ Item Id: 85, Name: SpawnNewBase + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: Terrain, display: true + MarketPrice: 320, display: true + Material: metal + HoldType: 0 + StackSize: 1000 + Category: BuildingBlocks + Mass: 1000, type: float, display: true, formatter: Kilogram + Volume: 125, type: float, display: true, formatter: Liter + Info: bkiSpawnNewBase, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: BA_Player + ForceSpawnOnGround: true + } + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + TechTreeNames: Misc +} +{ Item Id: 86, Name: SpawnNewSV + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: SS + MarketPrice: 150, display: true + Material: metal + HoldType: 0 + StackSize: 1000 + Category: BuildingBlocks + Mass: 200, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter + Info: bkiSpawnNewSV, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: SV_Player + # ForceSpawnOnBACV: true + } + + UnlockCost: 0, display: true + UnlockLevel: 5, display: true + TechTreeParent: SpawnNewGV + TechTreeNames: Misc +} +{ Item Id: 87, Name: SpawnNewCV + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: MS + MarketPrice: 355, display: true + Material: metal + HoldType: 0 + StackSize: 1000 + Category: BuildingBlocks + Mass: 1500, type: float, display: true, formatter: Kilogram + Volume: 400, type: float, display: true, formatter: Liter + Info: bkiSpawnNewCV, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: CV_Player + # AllowPlanet: false + # ForceSpawnOnBA: true + } + + UnlockCost: 0, display: true + UnlockLevel: 7, display: true + TechTreeParent: SpawnNewSV + TechTreeNames: Misc +} +{ Item Id: 88, Name: SpawnNewGV + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: GV + Material: metal + MarketPrice: 125, display: true + HoldType: 0 + StackSize: 1000 + Category: BuildingBlocks + Mass: 150, type: float, display: true, formatter: Kilogram + Volume: 31.25, type: float, display: true, formatter: Liter + Info: bkiSpawnNewGV, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: HV_Player + AllowSpace: true + # ForceSpawnOnBACV: true + } + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + # TechTreeParent: SpawnNewBase + TechTreeNames: Misc +} + +{ Item Id: 89, Name: SpawnNewPrefab + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + HoldType: 0 + StackSize: 1 + Category: BuildingBlocks + ShowUser: No + Info: bkiSpawnNewBase, display: true + { Child 0 + Class: SpawnPrefab + } + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 90, Name: RespiratorCharge + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + AutoFill: false + HoldType: 0 + StackSize: 2000 + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + O2Value: 35, display: false, type: int + MarketPrice: 1, display: true + Category: Weapons/Items + Info: bkiRespiratorCharge, display: true + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 35, display: OxygenValue + SfxBegin: UseActions/oxygen_tank_release_air + } +} +{ +Item Id: 91, Name: SpawnNewBaseM + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: Base, display: true + Material: metal + HoldType: 0 + StackSize: 1 + Category: BuildingBlocks + Mass: 500, type: float, display: true, formatter: Kilogram + Volume: 175, type: float, display: true, formatter: Liter + Info: bkiSpawnNewBase, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: BA_Player_M + ForceSpawnOnGround: true + } + + # UnlockCost: 0, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: Misc + + ShowUser: No +} +{ +Item Id: 92, Name: SpawnNewBaseS + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: Base, display: true + Material: metal + HoldType: 0 + StackSize: 1 + Category: BuildingBlocks + Mass: 250, type: float, display: true, formatter: Kilogram + Volume: 80, type: float, display: true, formatter: Liter + Info: bkiSpawnNewBase, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: BA_Player_S + ForceSpawnOnGround: true + } + + # UnlockCost: 0, display: true + # UnlockLevel: 1, display: true + # TechTreeNames: Misc + + ShowUser: No +} +{ +Item Id: 93, Name: SpawnNewCVM + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: MS, display: true + Material: metal + HoldType: 0 + StackSize: 1 + Category: BuildingBlocks + Mass: 750, type: float, display: true, formatter: Kilogram + Volume: 300, type: float, display: true, formatter: Liter + Info: bkiSpawnNewCV, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: CV_Player_M + # AllowPlanet: false + # ForceSpawnOnBA: true + } + + # UnlockCost: 0, display: true + # UnlockLevel: 7, display: true + # TechTreeParent: SpawnNewSV + # TechTreeNames: Misc + + ShowUser: No +} +{ +Item Id: 94, Name: SpawnNewCVS + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + AllowPlacingAt: MS, display: true + Material: metal + HoldType: 0 + StackSize: 1 + Category: BuildingBlocks + Mass: 400, type: float, display: true, formatter: Kilogram + Volume: 150, type: float, display: true, formatter: Liter + Info: bkiSpawnNewCV, display: true + { Child 0 + Class: SpawnPrefab + PrefabEntity: CV_Player_S + # AllowPlanet: false + # ForceSpawnOnBA: true + } + + # UnlockCost: 0, display: true + # UnlockLevel: 7, display: true + # TechTreeParent: SpawnNewSV + # TechTreeNames: Misc + + ShowUser: No +} + + +# CATEGORIE: WEAPONS FOR SS MS id 100-199 +{ +Item Id: 100, Name: MinigunSSWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponSSMinigunPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 365, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + ROF: 0.2, type: float, display: true, formatter: ROF + Range: 400, display: false + RangeSpace: 550, display: false + Automatic: true + Damage: 160, display: true + AmmoCapacity: 300, display: true + AmmoType: 15mmBullet, display: true + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 2, display: true # adapt sound + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SideAutoMuzzleFire + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 0.55, param1: head|body + DamageMultiplier_2: 0.01, param1: wood # able to destroy trees + DamageMultiplier_3: 0, param1: dirt|stone|rock + DamageMultiplier_4: 0.05, param1: shield + } +} +{ +Item Id: 101, Name: PlasmaCannonSSWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponSSPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 1682, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 0.8, type: float, display: true, formatter: ROF + Range: 435, display: false + RangeSpace: 725, display: false + AmmoCapacity: 50, display: true + AmmoType: PlasmaCannonChargeSS, display: true + Automatic: true + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 3, display: true + SfxBegin: Weapons/SS/LaserSS/weapon_blaster_002 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/Misc/weapon_outofammo_005 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SniperMuzzleFireLarge + } +} +# Pulse Laser for SS +{ +Item Id: 102, Name: PulseLaserSSWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponSSLaserPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 1645, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + ROF: 0.35, type: float, display: true, formatter: ROF + Range: 500, display: false + RangeSpace: 700, display: false + Damage: 400, display: true + Automatic: true + AmmoCapacity: 150, display: true + AmmoType: PulseLaserChargeSS, display: true + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 1, display: true # adapt sound + SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SniperMuzzleFire + + Tracer: Weapons/Projectiles/TracerLaserShotBlueSV + TracerPerBullet: 1 + TracerSpeed: 600 + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood + DamageMultiplier_3: 0.1, param1: shield + } + # { Child 0 + # Class: LauncherSS + # ROF: 0.2, type: float, display: true, formatter: ROF + # Range: 750, display: false + # AmmoCapacity: 150, display: true + # AmmoType: PulseLaserChargeSS, display: true + # AutoReload: true + # ReloadDelay: 1, display: true # adapt sound + # SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + # SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: SniperMuzzleFire + # } +} +{ +Item Id: 103, Name: TurretMSArtilleryWeapon + Meshfile: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 250 + Mass: 19800, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: Space + ROF: 6.5, type: float, display: true, formatter: ROF + Range: 575, display: false + RangeSpace: 950, display: false + AmmoCapacity: 5, display: true + AmmoType: LargeRocketMS, display: true + NoiseStrength: 200, display: false + CameraShake: 4 + AutoReload: true + ReloadDelay: 13, display: true # adapt sound + SfxBegin: Weapons/MS/Turret/Cannon_2 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_001 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +# For planetary POIs +{ +Item Id: 104, Name: TurretIONCannonWeapon + Meshfile: @models/Blocks/Turrets/TurretsEnemy/turretIONcannonPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 1.0, type: float, display: true, formatter: ROF + Range: 525, display: false + RangeSpace: 875, display: false + AmmoCapacity: 50, display: false # no need to display since unlimited ammo + # AmmoUnlimited: true + AmmoType: IONCannonCharge, display: true + NoiseStrength: 100, display: false + ReloadDelay: 1, display: true + SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 105, Name: TurretBaseFlakWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleFlakTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 5615, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 685, display: false + RangeSpace: 1135, display: false + AmmoCapacity: 50, display: true + AmmoType: FlakRocket, display: true + NoiseStrength: 150, display: false + CameraShake: 2 + AutoReload: true + ReloadDelay: 5, display: true + SfxBegin: Weapons/Player/Misc/Single/cannon + # SfxBegin: Weapons/Player/Misc/Single/AWDSDracoHeavySingle + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_001 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 106, Name: TurretBaseRocketWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleMissileTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 5777, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 4, type: float, display: true, formatter: ROF + Range: 690, display: false + RangeSpace: 1150, display: false + AmmoCapacity: 15, display: true + AmmoType: FastRocketBA, display: true + NoiseStrength: 150, display: false + CameraShake: 3 + AutoReload: true + ReloadDelay: 8, display: true + SfxBegin: Weapons/Turret/missle_fire + # SfxBegin: Weapons/LauncherWeapons/weapon_missile_005 + # SfxBegin: Weapons/Planet/TurretPS01/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +# alternative sound: sound_start=Weapons/Planet/TurretPS01/Cannon_2 +{ +Item Id: 107, Name: RocketLauncherSSWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponSSMissilePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 521, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 2, type: float, display: true, formatter: ROF + Range: 425, display: false + RangeSpace: 675, display: false + AmmoCapacity: 10, display: true + AmmoType: FastRocket, display: true + Automatic: false + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 8, display: true # adapt sound + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SniperMuzzleFireLarge + } +} +{ +Item Id: 108, Name: RailgunSSWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponSSGaussPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 1869, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 525, display: false + RangeSpace: 800, display: false + AmmoCapacity: 20, display: true + AmmoType: RailgunBullet, display: true + Automatic: true + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 4, display: true # adapt sound + SfxBegin: Weapons/ProjectileWeapons/weapon_railgun_004 + # SfxBegin: Weapons/SS/Railgun/FuturisticGunSound02 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SniperMuzzleFireLarge + } +} +{ +Item Id: 109, Name: TurretMSMinigunWeapon + Meshfile: @models/Blocks/Turrets/TurretMSMinigunPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 6120, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + AllowAt: "Space,Planet" + ROF: 0.2, type: float, display: true, formatter: ROF + Range: 485, display: false + RangeSpace: 785, display: false + Automatic: true + Damage: 425, display: true + AmmoCapacity: 300, display: true + AmmoType: 15mmBullet, display: true + NoiseStrength: 120, display: false + CameraShake: 1 + AutoReload: true + ReloadDelay: 2, display: true # adapt sound + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_005 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire2 + TracerPerBullet: 1 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 0.55, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.05, param1: shield + DamageMultiplier_5: 0.85, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 110, Name: RocketLauncherMSWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponMSArtilleryPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 2485, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: Space + ROF: 4, type: float, display: true, formatter: ROF + Range: 560, display: false + RangeSpace: 975, display: false + AmmoCapacity: 15, display: true + AmmoType: FastRocketMS, display: true + Automatic: false + NoiseStrength: 150, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 8, display: true # adapt sound + SfxBegin: Weapons/LauncherWeapons/weapon_missile_006 + # SfxBegin: Weapons/MS/Turret/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 111, Name: TurretEnemyLaserWeapon + Model: Entities/Blocks/Turrets/TurretsEnemy/TurretEnemyLaserPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 650, display: false + RangeSpace: 875, display: false + AmmoCapacity: 60, display: false # no need to display since unlimited ammo + # AmmoUnlimited: true + AmmoType: TurretEnemyLaserCharge, display: true + NoiseStrength: 100, display: false + ReloadDelay: 0.6, display: true + SfxBegin: Weapons/EnergyWeapons/weapon_laser_009 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 112, Name: PulseLaserMSWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponMSPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 2715, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + AllowAt: Space + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 510, display: false + RangeSpace: 925, display: false + Damage: 1000, display: true + Automatic: true + AmmoCapacity: 150, display: true + AmmoType: PulseLaserChargeMSWeapon, display: true + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 1, display: true # adapt sound + SfxBegin: Weapons/EnergyWeapons/weapon_laser_003 + # SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + + Tracer: Weapons/Projectiles/TracerLaserShotBlueCV + TracerPerBullet: 1 + TracerSpeed: 600 + + DamageMultiplier_1: 0.3, param1: head|body + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood + DamageMultiplier_3: 0.083, param1: shield + } + # { Child 0 + # Class: LauncherSS + # AllowAt: Space + # ROF: 0.4, type: float, display: true, formatter: ROF + # Range: 850, display: false + # AmmoCapacity: 150, display: true + # AmmoType: PulseLaserChargeMS, display: true + # AutoReload: true + # ReloadDelay: 1, display: true # adapt sound + # SfxBegin: Weapons/EnergyWeapons/weapon_laser_003 + # SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + # SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + # } +} + +# For space POIs +{ +Item Id: 113, Name: TurretIONCannon2Weapon + Meshfile: @models/Blocks/Turrets/TurretsEnemy/turretIONcannon2Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: Space + ROF: 0.8, type: float, display: true, formatter: ROF + Range: 525, display: false + RangeSpace: 1100, display: false + AmmoCapacity: 50, display: false # no need to display since unlimited ammo + # AmmoUnlimited: true + AmmoType: IONCannonCharge2, display: true + NoiseStrength: 100, display: false + ReloadDelay: 0.8, display: true + SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 114, Name: TurretGVMinigunWeapon + Meshfile: @models/Blocks/Turrets/RealisticTurret/TurretGVMinigunPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + ROF: 0.3, type: float, display: true, formatter: ROF + Range: 425, display: false + RangeSpace: 600, display: false + Automatic: true + Damage: 280, display: true + AmmoCapacity: 300, display: true + AutoReload: true + AmmoType: 15mmBullet, display: true + NoiseStrength: 120, display: false + CameraShake: 1 + ReloadDelay: 2.0, display: true # adapt sound + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_005 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SMGAutoMuzzleFire + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 0.45, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.1, param1: shield + DamageMultiplier_5: 0.85, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 115, Name: TurretGVRocketWeapon + Meshfile: @models/Blocks/Turrets/RealisticTurret/TurretGVRocketPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 520, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 470, display: false + RangeSpace: 735, display: false + AmmoCapacity: 8, display: true + AmmoType: FastRocketGV, display: true + NoiseStrength: 150, display: false + CameraShake: 2 + AutoReload: true + ReloadDelay: 8, display: true # adapt sound + SfxBegin: Weapons/Player/RocketLauncher/FLYBYMissile02Slow + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 116, Name: TurretGVPlasmaWeapon + Meshfile: @models/Blocks/Turrets/RealisticTurret/TurretGVPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 1050, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 450, display: false + RangeSpace: 700, display: false + AmmoCapacity: 50, display: true + AutoReload: true + AmmoType: TurretGVPlasmaCharge, display: true + NoiseStrength: 120, display: false + CameraShake: 2 + ReloadDelay: 3, display: true + SfxBegin: Weapons/SS/LaserSS/weapon_blaster_004 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: PlasmaMuzzleFlashMediumPrefab + } +} +{ +Item Id: 117, Name: TurretBaseCannonWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SinglePlasmaTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 6150, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + AllowAt: "Space,Planet" + ROF: 0.35, type: float, display: true, formatter: ROF + Range: 560, display: false + RangeSpace: 925, display: false + Automatic: false + Damage: 600, display: true + AmmoCapacity: 100, display: true + AutoReload: true + AmmoType: 30mmBullet, display: true + NoiseStrength: 120, display: false + CameraShake: 2 + ReloadDelay: 4, display: true # adapt sound + SfxBegin: Weapons/Player/Misc/Single/AWDSDracoHeavySingle + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + TracerPerBullet: 1 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 0.25, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.05, param1: shield # quarter less damage vs shield than vs blocks + DamageMultiplier_5: 1.15, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 118, Name: TurretMSPlasmaWeapon + Meshfile: @models/Blocks/Turrets/TurretMSPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8490, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: Space + ROF: 8, type: float, display: true, formatter: ROF + Range: 635, display: false + RangeSpace: 1050, display: false + AmmoCapacity: 8, display: true + AutoReload: true + AmmoType: TurretMSPlasmaCharge, display: true + NoiseStrength: 120, display: false + CameraShake: 4 + ReloadDelay: 10, display: true + SfxBegin: Weapons/SS/LaserSS/weapon_blaster_001 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 119, Name: TurretBasePlasmaWeapon + Meshfile: @models/Blocks/Turrets/RealisticTurret/TurretGVPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8490, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 8, type: float, display: true, formatter: ROF + Range: 715, display: false + RangeSpace: 1200, display: false + AmmoCapacity: 8, display: true + AutoReload: true + AmmoType: TurretBAPlasmaCharge, display: true + NoiseStrength: 120, display: false + CameraShake: 4 + ReloadDelay: 10, display: true + SfxBegin: Weapons/SS/LaserSS/weapon_blaster_003 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 120, Name: TurretGVArtilleryWeapon + Meshfile: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 250 + Mass: 4350, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 6.5, type: float, display: true, formatter: ROF + Range: 540, display: false + RangeSpace: 850, display: false + AmmoCapacity: 5, display: true + AmmoType: ArtilleryRocket, display: true + NoiseStrength: 200, display: false + CameraShake: 4 + AutoReload: true + ReloadDelay: 13, display: true # adapt sound + SfxBegin: Weapons/MS/Turret/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 121, Name: SawAttachmentWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 1000 + Mass: 220, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: HarvesterSaw + ROF: 0.25, type: float + Range: 4, display: false + NoiseStrength: 30, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + ForceMuzzleAiming: true + Damage: 50, display: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + ReloadDelay: 4.2, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + HarvestSupport: true + UseSingleMagazine: true + SfxBegin: Weapons/Player/Tools/SawAttachment_1 + SfxLoop: Weapons/Player/Tools/SawAttachment_2 + SfxStop: Weapons/Player/Tools/SawAttachment_3 + SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0.04, param1: wood|plants + DamageMultiplier_2: 0.01, param1: rock + DamageMultiplier_3: 0.08, param1: stone|dirt + DamageMultiplier_4: 0.01, param1: concrete|metal|metalhard|hull + DamageMultiplier_5: 0.001, param1: hullarmored|hullcombat + DamageMultiplier_6: 0, param1: shield + } +} +{ +Item Id: 122, Name: TurretMSCannonWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SinglePlasmaTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 6150, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + AllowAt: "Space,Planet" + ROF: 0.35, type: float, display: true, formatter: ROF + Range: 530, display: false + RangeSpace: 875, display: false + Automatic: false + Damage: 550, display: true + AmmoCapacity: 100, display: true + AutoReload: true + AmmoType: 30mmBullet, display: true + NoiseStrength: 120, display: false + CameraShake: 2 + ReloadDelay: 4, display: true # adapt sound + SfxBegin: Weapons/Player/Misc/Single/AWDSDracoHeavySingle + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + TracerPerBullet: 1 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 0.2, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.05, param1: shield + DamageMultiplier_5: 1.15, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 123, Name: TurretMSPulseLaserWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 11760, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: Space + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 560, display: false + RangeSpace: 925, display: false + AmmoCapacity: 150, display: true + AmmoType: PulseLaserChargeMS, display: true + Automatic: true + NoiseStrength: 100, display: false + CameraShake: 0 + AutoReload: true + ReloadDelay: 1, display: true # adapt sound + SfxBegin: Weapons/Turret/laser_turret_fire2 + # SfxBegin: Weapons/EnergyWeapons/weapon_laser_003 + # SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 124, Name: TurretBasePulseLaserWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 11760, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 635, display: false + RangeSpace: 1085, display: false + AmmoCapacity: 150, display: true + AmmoType: PulseLaserChargeBA, display: true + NoiseStrength: 100, display: false + Automatic: true + CameraShake: 2 + AutoReload: true + ReloadDelay: 1, display: true # adapt sound + SfxBegin: Weapons/Turret/laser_turret_fire2 + # SfxBegin: Weapons/EnergyWeapons/weapon_laser_003 + # SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 125, Name: TurretMSFlakWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleFlakTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 5777, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: Space + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 585, display: false + RangeSpace: 975, display: false + AmmoCapacity: 50, display: true + AmmoType: FlakRocketMS, display: true + NoiseStrength: 150, display: false + CameraShake: 2 + AutoReload: true + ReloadDelay: 5, display: true + SfxBegin: Weapons/Player/Misc/Single/cannon + # SfxBegin: Weapons/Player/Misc/Single/AWDSDracoHeavySingle + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_001 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 126, Name: TurretMSRocketWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleMissileTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 5777, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: Space + ROF: 4, type: float, display: true, formatter: ROF + Range: 600, display: false + RangeSpace: 1000, display: false + AmmoCapacity: 8, display: true + AmmoType: FastRocketMS, display: true + NoiseStrength: 150, display: false + CameraShake: 3 + AutoReload: true + ReloadDelay: 8, display: true + SfxBegin: Weapons/Turret/missle_fire + # SfxBegin: Weapons/LauncherWeapons/weapon_missile_005 + # SfxBegin: Weapons/Planet/TurretPS01/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 127, Name: TurretBaseMinigunWeapon + Meshfile: @models/Blocks/Turrets/TurretMSMinigunPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 6120, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + AllowAt: "Space,Planet" + ROF: 0.2, type: float, display: true, formatter: ROF + Range: 500, display: false + RangeSpace: 850, display: false + Automatic: true + Damage: 425, display: true + AmmoCapacity: 300, display: true + AmmoType: 15mmBullet, display: true + NoiseStrength: 120, display: false + CameraShake: 1 + AutoReload: true + ReloadDelay: 2, display: true # adapt sound + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_005 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire2 + TracerPerBullet: 1 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 0.55, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.05, param1: shield + DamageMultiplier_5: 0.85, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 128, Name: TurretBaseArtilleryWeapon + Meshfile: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 250 + Mass: 19800, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 6.5, type: float, display: true, formatter: ROF + Range: 650, display: false + RangeSpace: 1100, display: false + AmmoCapacity: 5, display: true + AmmoType: LargeRocket, display: true + NoiseStrength: 200, display: false + CameraShake: 4 + AutoReload: true + ReloadDelay: 13, display: true # adapt sound + SfxBegin: Weapons/MS/Turret/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_001 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +# alternative sound: sound_start=Weapons/Planet/TurretPS01/Cannon_2 +{ +Item Id: 129, Name: RocketLauncherSSHomingWeapon + Meshfile: @models/Blocks/WeaponsShip/WeaponSSMissileHomingPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 990, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 2, type: float, display: true, formatter: ROF + Range: 470, display: false + RangeSpace: 735, display: false + AmmoCapacity: 8, display: true + AmmoType: FastRocketHoming, display: true + Automatic: false + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 8, display: true # adapt sound + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} + +{ +Item Id: 130, Name: DrillAttachmentWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWSButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 1000 + Mass: 400, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + # RadialMenu: true + { Child 0 + Class: Driller + DrillMode: 3 + DecoMode: true + InfoPopup: true + TerrainMode: true + HarvestSupport: true + # AllowAt: "Planet" + ForceMuzzleAiming: true + ROF: 0.5, type: float + Damage: 750 + Radius: 2 + Range: 4, display: false + NoiseStrength: 30, display: false + CameraShake: 0 + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + ReloadDelay: 2.7, display: false # no need to display since unlimited ammo + Bullet_use_per_shot: 0.25 + Particles: digger # particle effect shown at impact + SfxBegin: Weapons/Player/Drill/Drilling_1 + SfxLoop: Weapons/Player/Drill/Drilling_2 + SfxStop: Weapons/Player/Drill/Drilling_3 + SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: wood # no tree cutting + + RadialText: rdlResourceMining + RadialDesc: rdlResourceMiningManualTxt + RadialIcon: Radial_ResourceMining + } + # { Child 1 + # Class: Driller + # DecoMode: true + # AllowAt: "Planet" + # HarvestSupport: true + # ForceMuzzleAiming: true + # ROF: 0.25, type: float + # Range: 2, display: false + # NoiseStrength: 30, display: false + # CameraShake: 0 + # IgnoreAtmo: true + # Automatic: true + # Damage: 25 + # Radius: 4 + # AmmoCapacity: 0, display: false # no need to display since unlimited ammo + # AmmoType: AmmoInfinite, display: true + # AmmoUnlimited: true + # ReloadDelay: 2.7, display: false # no need to display since unlimited ammo + # # UseSingleMagazine: true + # Bullet_use_per_shot: 0.25 + # Particles: digger # particle effect shown at impact + # SfxBegin: Weapons/Player/Drill/Drilling_1 + # SfxLoop: Weapons/Player/Drill/Drilling_2 + # SfxStop: Weapons/Player/Drill/Drilling_3 + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + # DamageMultiplier_1: 0.001, param1: woodblock + # DamageMultiplier_2: 0.001, param1: concrete + # DamageMultiplier_3: 0.001, param1: metal + # DamageMultiplier_4: 0.001, param1: metalhard + # DamageMultiplier_5: 0.001, param1: hull + # DamageMultiplier_6: 0.001, param1: hullarmored + # DamageMultiplier_7: 0.001, param1: hullcombat + # DamageMultiplier_8: 0.001, param1: wood + # DamageMultiplier_9: 50, param1: rock + # DamageMultiplier_10: 0, param1: shield + # # DamageMultiplier_9: 0.0001, param1: head + # # DamageMultiplier_10: 0.0001, param1: body + # RadialText: rdlStoneRemoval + # RadialDesc: rdlStoneRemovalTxt + # RadialIcon: Radial_StoneRemoval + # } +} +{ +Item Id: 131, Name: TurretGVDrillWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 790, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + # RadialMenu: true + { Child 0 + Class: Driller + DrillMode: 1 + DecoMode: true + InfoPopup: true + TerrainMode: true + HarvestSupport: true + AllowAt: "Planet" + ForceMuzzleAiming: true + ROF: 0.5, type: float + Damage: 750 + Radius: 4 + Range: 50, display: false + IgnoreAtmo: true + Automatic: true + NoiseStrength: 30, display: false + CameraShake: 0 + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + ReloadDelay: 3.0, display: false # no need to display reload time + Bullet_use_per_shot: 0.25 + Particles: diggerplasma # particle effect shown at impact + ParticleTransform: Mesh/TurretMSDrillBody/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle1 + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxStop: Weapons/Player/Drill/Mute + # SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + # SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: wood # no tree cutting + DamageMultiplier_3: 0.5, param1: rock + + RadialText: rdlResourceMining + RadialDesc: rdlResourceMiningManualTxt + RadialIcon: Radial_ResourceMining + } + # { Child 1 + # Class: Driller + # DecoMode: true + # AllowAt: "Planet" + # HarvestSupport: true + # ForceMuzzleAiming: true + # ROF: 0.5, type: float + # Range: 35, display: false + # IgnoreAtmo: true + # Automatic: true + # Damage: 45 + # NoiseStrength: 30, display: false + # CameraShake: 0 + # Radius: 2 + # AmmoCapacity: 0, display: false # no need to display since unlimited ammo + # AmmoType: AmmoInfinite, display: true + # AmmoUnlimited: true + # ReloadDelay: 3.0, display: false # no need to display since unlimited ammo + # Bullet_use_per_shot: 0.25 + # Particles: diggerplasma # particle effect shown at impact + # ParticleTransform: Mesh/TurretMSDrillBody/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle1 + # SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + # SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + # SfxStop: Weapons/Player/Drill/Mute + + # DamageMultiplier_1: 0.001, param1: stone + # DamageMultiplier_2: 0.001, param1: dirt + # DamageMultiplier_3: 0.001, param1: woodblock + # DamageMultiplier_4: 0.001, param1: concrete + # DamageMultiplier_5: 0.001, param1: metal + # DamageMultiplier_6: 0.001, param1: metalhard + # DamageMultiplier_7: 0.001, param1: hull + # DamageMultiplier_8: 0.001, param1: hullarmored + # DamageMultiplier_9: 0.001, param1: hullcombat + # DamageMultiplier_10: 20, param1: rock + # DamageMultiplier_11: 0.001, param1: wood + # DamageMultiplier_12: 0, param1: shield + + # RadialText: rdlStoneRemoval + # RadialDesc: rdlStoneRemovalTxt + # RadialIcon: Radial_StoneRemoval + # } +} + +{ +Item Id: 132, Name: TurretMSDrillWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 9270, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Driller + DrillMode: 1 + TerrainMode: true + AllowAt: "Space" + HarvestSupport: true + ForceMuzzleAiming: true + ROF: 0.5, type: float + Damage: 700 + Radius: 5 + Range: 150, display: false + IgnoreAtmo: true + Automatic: true + NoiseStrength: 30, display: false + CameraShake: 0 + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + ReloadDelay: 2.7, display: false # no need to display since unlimited ammo + Bullet_use_per_shot: 0.25 + Particles: diggerplasma # particle effect shown at impact + ParticleTransform: Mesh/TurretMSDrillBody/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle1 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxStop: Weapons/Player/Drill/Mute + + DamageMultiplier_1: 0, param1: wood|woodblock|concrete|metal|metalhard|hull|hullarmored|hullcombat + DamageMultiplier_2: 0, param1: shield + } +} + +{ +Item Id: 133, Name: DrillAttachmentT2Weapon, Ref: DrillAttachmentWeapon + { Child 0 + Class: Driller + DrillMode: 3 + TerrainMode: true + # AllowAt: "Planet,Space" + Damage: 1000 + Radius: 2 + Range: 8, display: false + IgnoreAtmo: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 30, display: false + ReloadDelay: 3.8, display: false # no need to display since unlimited ammo + Particles: diggerplasma # particle effect shown at impact + ParticleTransform: Muzzle/Particle1 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + # SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + # SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxStop: Weapons/Player/Drill/Mute + } + # { Child 1 + # Class: Driller + # DecoMode: true + # AllowAt: "Planet" + # Range: 10, display: false + # IgnoreAtmo: true + # Damage: 30 + # Radius: 4 + # AmmoCapacity: 0, display: false # no need to display since unlimited ammo + # AmmoType: AmmoInfinite, display: true + # AmmoUnlimited: true + # NoiseStrength: 30, display: false + # ReloadDelay: 3.8, display: false # no need to display since unlimited ammo + # Particles: diggerplasma # particle effect shown at impact + # ParticleTransform: Muzzle/Particle1 # particle effect shown at muzzle + # SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + # SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + # # SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + # # SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + # SfxStop: Weapons/Player/Drill/Mute + # } +} +{ +Item Id: 134, Name: TurretGVToolWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 5 + Mass: 150, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + RadialMenu: true + { Child 0 + Class: Driller + AllowAt: "Planet" + ROF: 0.5, type: float, display: false, formatter: ROF + Range: 50 + IgnoreAtmo: true + Automatic: true + Damage: 250 + Radius: 3 + ReturnFactor: 0.8, display: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 30, display: false + CameraShake: 0 + ReloadDelay: 3.0, display: false # no need to display since unlimited ammo + ParticleTransform: Mesh/TurretMSToolBody/SubGyro/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle1 + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + DamageMultiplier_1: 0, param1: dirt|stone|wood + DamageMultiplier_2: 50, param1: metalhard # 830 damage + DamageMultiplier_3: 50, param1: metal # 830 damage + DamageMultiplier_4: 65, param1: hull # 1083 damage + DamageMultiplier_5: 125, param1: hullarmored # 2083 damage + DamageMultiplier_6: 250, param1: hullcombat # 4120 damage + DamageMultiplier_7: 150, param1: concrete # 2490 damage + DamageMultiplier_8: 50, param1: woodblock # 830 damage + DamageMultiplier_9: 0, param1: shield + + RadialText: rdlDisassemble + RadialDesc: rdlDisassembleTxt + RadialIcon: Radial_Disassemble + } + { Child 1 + Class: Repair + AllowAt: "Planet" + Range: 50 + IgnoreAtmo: true + ROF: 0.3, type: float + RepairPoints: 150 + Automatic: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 30, display: false + CameraShake: 0 + ReloadDelay: 3.0, display: false # no need to display since unlimited ammo + Particles: diggerplasma # particle effect shown at impact + ParticleTransform: Mesh/TurretMSToolBody/SubGyro/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle2 + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + SfxUpgrade: UseActions/UpgradeBlock2 + SfxUpgradeErr: UseActions/UpgradeBlockError + AllowUpgrade: false + + RadialText: rdlRepair + RadialDesc: rdlRepairTxt + RadialIcon: Radial_Repair + } + { Child 2 + Class: Driller + AllowAt: "Planet" + Range: 50 + IgnoreAtmo: true + ROF: 0.5, type: float, display: false, formatter: ROF + Automatic: true + Damage: 250 + Radius: 3 + Demolish: True + ReturnFactor: 0.8 + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 30, display: false + CameraShake: 0 + ReloadDelay: 3.0, display: false # no need to display since unlimited ammo + ParticleTransform: Mesh/TurretMSToolBody/SubGyro/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle3 + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + DamageMultiplier_1: 0, param1: dirt|stone|wood + DamageMultiplier_2: 50, param1: metalhard # 830 damage + DamageMultiplier_3: 50, param1: metal # 830 damage + DamageMultiplier_4: 65, param1: hull # 1083 damage + DamageMultiplier_5: 125, param1: hullarmored # 2083 damage + DamageMultiplier_6: 250, param1: hullcombat # 4120 damage + DamageMultiplier_7: 150, param1: concrete # 2490 damage + DamageMultiplier_8: 50, param1: woodblock # 830 damage + DamageMultiplier_9: 0, param1: shield + + RadialText: rdlDeconstruct + RadialDesc: rdlDeconstructTxt + RadialIcon: Radial_Deconstruct + } +} +{ +Item Id: 135, Name: TurretMSToolWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 9050, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + RadialMenu: true + { Child 0 + Class: Driller + AllowAt: "Space" + ROF: 0.5, type: float, display: false, formatter: ROF + Range: 120 + IgnoreAtmo: true + Automatic: true + Damage: 600 + Radius: 3 + ReturnFactor: 0.8, display: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 30, display: false + CameraShake: 0 + ReloadDelay: 3.0, display: false # no need to display since unlimited ammo + ParticleTransform: Mesh/TurretMSToolBody/SubGyro/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle1 + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + DamageMultiplier_1: 0, param1: dirt|stone|wood + DamageMultiplier_2: 100, param1: metalhard # 4000 damage + DamageMultiplier_3: 100, param1: metal # 4000 damage + DamageMultiplier_4: 125, param1: hull # 5000 damage + DamageMultiplier_5: 125, param1: hullarmored # 5000 damage + DamageMultiplier_6: 250, param1: hullcombat # 10000 damage + DamageMultiplier_7: 150, param1: concrete # 6000 damage + DamageMultiplier_8: 100, param1: woodblock # 4000 damage + DamageMultiplier_9: 0, param1: shield + + RadialText: rdlDisassemble + RadialDesc: rdlDisassembleTxt + RadialIcon: Radial_Disassemble + } + { Child 1 + Class: Repair + AllowAt: "Space" + Range: 120, display: true + IgnoreAtmo: true + ROF: 0.3, type: float + RepairPoints: 500 + Automatic: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 30, display: false + CameraShake: 0 + ReloadDelay: 3.0, display: false # no need to display since unlimited ammo + Particles: diggerplasma # particle effect shown at impact + ParticleTransform: Mesh/TurretMSToolBody/SubGyro/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle2 + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + SfxUpgrade: UseActions/UpgradeBlock2 + SfxUpgradeErr: UseActions/UpgradeBlockError + AllowUpgrade: false + + RadialText: rdlRepair + RadialDesc: rdlRepairTxt + RadialIcon: Radial_Repair + } + { Child 2 + Class: Driller + AllowAt: "Space" + Range: 120 + IgnoreAtmo: true + ROF: 0.5, type: float, display: false, formatter: ROF + Automatic: true + Damage: 600 + Radius: 3 + Demolish: True + ReturnFactor: 0.8 + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 30, display: false + CameraShake: 0 + ReloadDelay: 3.0, display: false # no need to display since unlimited ammo + ParticleTransform: Mesh/TurretMSToolBody/SubGyro/LaserTurretBarrelAnimated1/LaserTurretBarrelAnimated2/Muzzle/Particle3 + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + SfxReload: Weapons/Player/Reload/RifleReloadNormal + + DamageMultiplier_1: 0, param1: dirt|stone|wood + DamageMultiplier_2: 100, param1: metalhard # 4000 damage + DamageMultiplier_3: 100, param1: metal # 4000 damage + DamageMultiplier_4: 125, param1: hull # 5000 damage + DamageMultiplier_5: 125, param1: hullarmored # 5000 damage + DamageMultiplier_6: 250, param1: hullcombat # 10000 damage + DamageMultiplier_7: 150, param1: concrete # 6000 damage + DamageMultiplier_8: 100, param1: woodblock # 4000 damage + DamageMultiplier_9: 0, param1: shield + + RadialText: rdlDeconstruct + RadialDesc: rdlDeconstructTxt + RadialIcon: Radial_Deconstruct + } +} + +{ +Item Id: 136, Name: TurretEnemyRocketWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleMissileTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 4, type: float, display: true, formatter: ROF + Range: 690, display: false + RangeSpace: 1150, display: false + AmmoCapacity: 15, display: true + AmmoType: TurretEnemyRocketAmmo, display: true + # AmmoUnlimited: true + NoiseStrength: 100, display: false + ReloadDelay: 4, display: true + SfxBegin: Weapons/Turret/missle_fire + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 137, Name: TurretEnemyArtilleryWeapon + Meshfile: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 12700, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 13, type: float, display: true, formatter: ROF + Range: 650, display: false + RangeSpace: 1100, display: false + AmmoCapacity: 1, display: true + # AmmoUnlimited: true + AmmoType: TurretEnemyArtilleryAmmo, display: true + NoiseStrength: 100, display: false + ReloadDelay: 13, display: true # adapt sound + SfxBegin: Weapons/MS/Turret/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_001 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 138, Name: DrillAttachmentCVWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: No + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 3650, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Driller + DrillMode: 1 + DecoMode: true + InfoPopup: true + TerrainMode: true + HarvestSupport: true + AllowAt: "Space" + ForceMuzzleAiming: true + ROF: 0.5, type: float + Damage: 500 + Radius: 3 + Range: 120, display: false + IgnoreAtmo: true + Automatic: true + AmmoCapacity: 0, display: true + AmmoType: AmmoInfinite, display: true + NoiseStrength: 30, display: false + CameraShake: 0 + AmmoUnlimited: true + ReloadDelay: 3.8, display: false # no need to display reload time + Particles: diggerplasma # particle effect shown at impact + ParticleTransform: Muzzle/Particle1 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + # SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + # SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxStop: Weapons/Player/Drill/Mute + + DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield|forcefield + DamageMultiplier_2: 0, param1: wood # no tree cutting + DamageMultiplier_3: 0.5, param1: rock + } +} +{ +Item Id: 139, Name: TurretEnemyBallistaWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 1000 + Mass: 500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 2, type: float, display: true, formatter: ROF + Range: 500, display: false + RangeSpace: 500, display: false + AmmoCapacity: 50, display: false # no need to display since unlimited ammo + AmmoType: BallistaBolt, display: true + AmmoUnlimited: true + NoiseStrength: 20, display: false + ReloadDelay: 8, display: true + SfxBegin: Weapons/Enemies/Talon/BallistaShot + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: MuzzleBallistaSmoke + } +} +{ +Item Id: 140, Name: TurretEnemyEMPWeapon + Model: Entities/Blocks/Turrets/TurretsEnemy/TurretEnemyLaserPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 650, display: false + RangeSpace: 875, display: false + AmmoCapacity: 60, display: false # no need to display since unlimited ammo + # AmmoUnlimited: true + AmmoType: TurretEnemyEMPCharge, display: true + NoiseStrength: 100, display: false + ReloadDelay: 0.6, display: true + SfxBegin: Weapons/EnergyWeapons/weapon_laser_009 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 141, Name: TurretEnemyLaserWeaponT2 + Model: Entities/Blocks/Turrets/TurretsEnemy/TurretEnemyLaserPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + # Mass: 8500, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + ROF: 0.5, type: float, display: true, formatter: ROF + AllowAt: "Planet,Space" + Range: 650, display: false + RangeSpace: 1300, display: false + # Damage: 2000, display: true + AmmoCapacity: 500, display: false # no need to display since unlimited ammo + AmmoType: TurretEnemyLaserChargeT2, display: true + # AmmoUnlimited: true + Automatic: true + NoiseStrength: 100, display: false + ReloadDelay: 0.6, display: true + SfxBegin: Weapons/EnergyWeapons/weapon_laser_009 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + # ParticleTransform: Muzzle/Particle1 + } +} + +{ +Item Id: 142, Name: TurretEnemyPlasmaWeapon + Meshfile: @models/Blocks/Turrets/TurretMSPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8490, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Planet,Space" + ROF: 8, type: float, display: true, formatter: ROF + Range: 635, display: false + RangeSpace: 1050, display: false + AmmoCapacity: 8, display: true + AutoReload: true + AmmoType: TurretMSPlasmaCharge, display: true + NoiseStrength: 120, display: false + CameraShake: 4 + ReloadDelay: 10, display: true + SfxBegin: Weapons/SS/LaserSS/weapon_blaster_001 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 143, Name: DrillAttachmentSVT2Weapon, Ref: DrillAttachmentWeapon + CustomIcon: DrillAttachmentT2 + { Child 0 + Class: Driller + DrillMode: 3 #1 + DecoMode: true + InfoPopup: true + TerrainMode: true + # AllowAt: "Planet,Space" + Damage: 1000 + Radius: 2 + Range: 16, display: false #Range: 30 before + IgnoreAtmo: true + AmmoCapacity: 0, display: false # no need to display since unlimited ammo + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + NoiseStrength: 20, display: false + ReloadDelay: 3.8, display: false # no need to display since unlimited ammo + Particles: diggerplasma # particle effect shown at impact + ParticleTransform: Muzzle/Particle1 # particle effect shown at muzzle + SfxBegin: Weapons/Player/Tools/WeaponLightning02_start + SfxLoop: Weapons/Player/Tools/WeaponLightning02_loop + # SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + # SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxStop: Weapons/Player/Drill/Mute + } +} + +{ +Item Id: 144, Name: PulseLaserMSFactionTalonar + Meshfile: @models/Blocks/WeaponsShip/WeaponMSPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 2715, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + AllowAt: Space + ROF: 0.2, type: float, display: true, formatter: ROF # compare: 0.6) + Range: 560, display: false #slightly more range on planets (compare 510) + RangeSpace: 1050, display: false #slightly more range in space (compar 925) + Damage: 500, display: true #half dmg to match 2500 DPS of standard pulse (Compare 1666 DPS (1000 impact)) + Automatic: true + AmmoCapacity: 100, display: true + AmmoType: PulseLaserChargeMSWeapon, display: true #same ammo than pulse laser as entry does not hold stats + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 1, display: true # adapt sound + SfxBegin: Weapons/EnergyWeapons/weapon_laser_003 + # SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + #PfxMuzzle: turretMuzzleFire + + Tracer: Weapons/Projectiles/TracerLaserShotGreenBrightCV #TracerLaserShotBlueCV + TracerPerBullet: 1 + TracerSpeed: 600 + + DamageMultiplier_1: 0.3, param1: head|body + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood + DamageMultiplier_3: 0.04, param1: shield #50% less effective vs. Shield + DamageMultiplier_4: 12, param1: metallight|metal|metalhard # effective against metals used for devices (eg. turrets) + DamageMultiplier_5: 6, param1: hull|hullarmored|hullcombat #more effective against metallic armor + } +} + +# free available 145-149 + + +# Ammo id 150-199 # + +{ +Item Id: 150, Name: PlasmaCannonChargeSS + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectilePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: SS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 12, type: float, display: true, formatter: Kilogram + Volume: 1.25, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + MarketPrice: 14.5, display: true + { Child 0 + Class: Projectile + Damage: 615 + NoiseStrength: 100, display: false + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 650, display: true + Speed: 300 + Range: 435 + SpeedSpace: 385 + RangeSpace: 725 + Ballistic: false + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.15, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 151, Name: RailgunBullet + Meshfile: Entities/Items/Weapons/Projectiles/sniperBulletPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: SS, display: false + HoldType: 0 + Canhold: false + StackSize: 2000 + Mass: 20.6, type: float, display: true, formatter: Kilogram + Volume: 0.9, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + MarketPrice: 300, display: true + { Child 0 + Class: Projectile + Damage: 2780 , display: true + BlastParticleIndex: 3 + BlastRadius: 0 + BlastDamage: 0 + NoiseStrength: 100, display: false + Speed: 425 + Range: 525 + SpeedSpace: 525 + RangeSpace: 850 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.03, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0, param1: stone|dirt + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 152, Name: FastRocket + Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: SS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 3, type: float, display: true, formatter: Kilogram + Volume: 1.8, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + MarketPrice: 21, display: true + { Child 0 + Class: Projectile + Damage: 2350 + NoiseStrength: 100, display: false + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 1425, display: true + Speed: 185 + Range: 425 + SpeedSpace: 235 + RangeSpace: 725 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.067, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_4: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 153, Name: 30mmBullet + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: "Base,MS", display: false + HoldType: 0 + StackSize: 1500 + Canhold: false + Mass: 0.9, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Info: bkiAmmo, display: true + Category: Weapons/Items + MarketPrice: 2, display: true + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 154, Name: PulseLaserChargeSS + MarketPrice: 6, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: SS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + Info: bkiAmmo, display: true + Category: Weapons/Items + + UnlockLevel: 1 + TechTreeNames: Hidden +} +# For planetary POIs +{ +Item Id: 155, Name: IONCannonCharge + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketGreenPrefab + # Meshfile: Entities/Items/Weapons/Projectiles/alienRocketPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: "Base,MS", display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + MarketPrice: 5, display: true + ShowUser: No + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 420 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 150 + Range: 525 + SpeedSpace: 205 + RangeSpace: 875 + # HomingSpeed: 0.52 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 1, param1: shield + BlastDamageMultiplier_1: 0.15, param1: head|body + BlastDamageMultiplier_2: 0.1, param1: stone|dirt|rock + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 156, Name: LargeRocket + Meshfile: Entities/Items/Weapons/Projectiles/RocketLargePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: Base, display: false + HoldType: 0 + StackSize: 1000 + Canhold: false + Mass: 870, type: float, display: true, formatter: Kilogram + Volume: 200, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + MarketPrice: 640, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 15300 + BlastParticleIndex: 4 + BlastRadius: 4, display: true + BlastDamage: 18375, display: true + Speed: 165 + Range: 715 + SpeedSpace: 215 + RangeSpace: 1200 + Ballistic: true + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 157, Name: PulseLaserChargeMS + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotBlueTurret + # Meshfile: Entities/Items/Weapons/Projectiles/LaserShot02 + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + MarketPrice: 4.5, display: true + { Child 0 + Class: Projectile + Damage: 1050, display: true # new: because we removed explosion damage + BlastParticleIndex: 0 + # BlastRadius: 2, display: true + # BlastDamage: 400, display: true + Speed: 750 + Range: 575 + SpeedSpace: 810 + RangeSpace: 925 + Ballistic: false + + DamageMultiplier_1: 0.25, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 158, Name: FastRocketMS + Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 49, type: float, display: true, formatter: Kilogram + Volume: 32, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + MarketPrice: 295, display: true + { Child 0 + Class: Projectile + Damage: 5400 + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 5400, display: true + Speed: 145 + Range: 600 + SpeedSpace: 235 + RangeSpace: 1000 + # HomingSpeed: 3.6 # Alpha 8 + HomingSpeed: 1.5 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.067, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 159, Name: IONCannonCharge2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketRedPrefab + # Meshfile: Entities/Items/Weapons/Projectiles/alienRocket2Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: "Base,MS", display: false + HoldType: 0 + MarketPrice: 5, display: true + StackSize: 1000 + Canhold: false + ShowUser: No + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 420 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 150 + Range: 525 + SpeedSpace: 215 + RangeSpace: 1100 + # HomingSpeed: 0.57 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 1.5, param1: shield + BlastDamageMultiplier_1: 0.15, param1: head|body + BlastDamageMultiplier_2: 0.1, param1: stone|dirt|rock + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 160, Name: TurretGVPlasmaCharge + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectilePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: GV, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 2, type: float, display: true, formatter: Kilogram + Volume: 0.9, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + MarketPrice: 11, display: true + { Child 0 + Class: Projectile + Damage: 650 + BlastParticleIndex: 7 + BlastRadius: 3, display: true + BlastDamage: 720, display: true + Speed: 385 + Range: 450 + SpeedSpace: 435 + RangeSpace: 750 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.1, param1: shield + BlastDamageMultiplier_1: 0.15, param1: head|body + BlastDamageMultiplier_2: 0.01, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 161, Name: FlakRocket + Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: Base, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 10.1, type: float, display: true, formatter: Kilogram + Volume: 6, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + MarketPrice: 45, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 485 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 650 + Range: 685 + SpeedSpace: 750 + RangeSpace: 1135 + HomingSpeed: 0.15 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.2, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 162, Name: TurretMSPlasmaCharge + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectilePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 50, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + MarketPrice: 44, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 14400 + BlastParticleIndex: 7 + BlastRadius: 3, display: true + BlastDamage: 14400, display: true + Speed: 400 + Range: 575 + SpeedSpace: 475 + RangeSpace: 950 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 163, Name: ArtilleryRocket + Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: GV, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 47, type: float, display: true, formatter: Kilogram + Info: bkiAmmoExplosive, display: true + Volume: 16, type: float, display: true, formatter: Liter + Category: Weapons/Items + MarketPrice: 110, display: true + { Child 0 + Class: Projectile + Damage: 7875 + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 7875, display: true + Speed: 105 + Range: 540 + SpeedSpace: 150 + RangeSpace: 900 + Ballistic: true + + DamageMultiplier_1: 0.075, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.02, param1: shield + BlastDamageMultiplier_1: 0.075, param1: head|body + BlastDamageMultiplier_2: 0.03, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 164, Name: TurretEnemyLaserCharge + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotRedAlien + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: "Base,MS", display: false + HoldType: 0 + MarketPrice: 5, display: true + StackSize: 1000 + Canhold: false + ShowUser: No + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 650, display: true # new: because we removed explosion + BlastParticleIndex: 0 + # BlastRadius: 3 + # BlastDamage: 200 + Speed: 750 + Range: 650 + SpeedSpace: 810 + RangeSpace: 875 + Ballistic: false + + DamageMultiplier_1: 0.25, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 0.1, param1: shield + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0.1, param1: rock + BlastDamageMultiplier_4: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 165, Name: FastRocketHoming, Ref: FastRocket + Info: bkiAmmoHoming, display: true + AllowPlacingAt: "SS,GV", display: false + Mass: 10, type: float, display: true, formatter: Kilogram + Volume: 4.8, type: float, display: true, formatter: Liter + MarketPrice: 38, display: true + { Child 0 + Damage: 2150 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 1170, display: true + Speed: 160 + Range: 470 + SpeedSpace: 225 + RangeSpace: 785 + HomingSpeed: 3.35 + Ballistic: false + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 166, Name: FlakRocketMS, Ref: FlakRocket + AllowPlacingAt: MS, display: false + MarketPrice: 39, display: true + { Child 0 + Damage: 450 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 450, display: true + Speed: 625 + Range: 585 + SpeedSpace: 735 + RangeSpace: 975 + HomingSpeed: 0.16 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.067, param1: shield + BlastDamageMultiplier_1: 0.2, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} + +{ +Item Id: 167, Name: LargeRocketMS, Ref: LargeRocket + Mass: 840, type: float, display: true, formatter: Kilogram + Volume: 185, type: float, display: true, formatter: Liter + AllowPlacingAt: MS, display: false + MarketPrice: 595, display: true + { Child 0 + Damage: 14625 + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 16875, display: true + Speed: 165 + Range: 635 + SpeedSpace: 210 + RangeSpace: 1050 + Ballistic: true + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.067, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} + +{ +Item Id: 168, Name: PulseLaserChargeBA, Ref: PulseLaserChargeMS + AllowPlacingAt: Base, display: false + Mass: 10, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + MarketPrice: 5, display: true + { Child 0 + Damage: 1050, display: true + Speed: 750 + Range: 635 + SpeedSpace: 810 + RangeSpace: 1085 + Ballistic: false + + DamageMultiplier_1: 0.25, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.1, param1: shield + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + } +} + +{ +Item Id: 169, Name: FastRocketBA, Ref: FastRocketMS + AllowPlacingAt: Base, display: false + Mass: 49, type: float, display: true, formatter: Kilogram + Volume: 32, type: float, display: true, formatter: Liter + MarketPrice: 308, display: true + { Child 0 + Damage: 5858 + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 5800, display: true + Speed: 150 + Range: 635 + SpeedSpace: 240 + RangeSpace: 1085 + # HomingSpeed: 3.8 #Alpha 8 + HomingSpeed: 1.5 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} + +{ +Item Id: 170, Name: TurretBAPlasmaCharge, Ref: TurretMSPlasmaCharge + AllowPlacingAt: Base, display: false + Mass: 50, type: float, display: true, formatter: Kilogram + Volume: 25, type: float, display: true, formatter: Liter + MarketPrice: 62, display: true + { Child 0 + Damage: 15525 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 15525, display: true + Speed: 400 + Range: 600 + SpeedSpace: 485 + RangeSpace: 1000 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.1, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 171, Name: PulseLaserChargeMSWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 10, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Info: bkiAmmo, display: true + Category: Weapons/Items + MarketPrice: 7, display: true + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 172, Name: TurretEnemyRocketAmmo, Ref: FastRocketMS + AllowPlacingAt: Base, display: false + MarketPrice: 320, display: true + ShowUser: No + { Child 0 + Damage: 4000 + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 4000, display: true + Speed: 150 + Range: 635 + SpeedSpace: 240 + RangeSpace: 1085 + # HomingSpeed: 3.8 # Alpha 8 + HomingSpeed: 1.5 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 0.083, param1: shield + BlastDamageMultiplier_1: 0.15, param1: head|body + BlastDamageMultiplier_2: 0.1, param1: stone|dirt|rock + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 173, Name: TurretEnemyArtilleryAmmo, Ref: LargeRocket + AllowPlacingAt: Base, display: false + MarketPrice: 680, display: true + ShowUser: No + { Child 0 + Damage: 12240 + BlastParticleIndex: 4 + BlastRadius: 4, display: true + BlastDamage: 14700, display: true + Speed: 165 + Range: 715 + SpeedSpace: 215 + RangeSpace: 1200 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 0.083, param1: shield + BlastDamageMultiplier_1: 0.15, param1: head|body + BlastDamageMultiplier_2: 0.1, param1: stone|dirt|rock + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 174, Name: FastRocketGV, Ref: FlakRocket + AllowPlacingAt: GV, display: false + Mass: 2, type: float, display: true, formatter: Kilogram + Volume: 1.8, type: float, display: true, formatter: Liter + MarketPrice: 16, display: true + { Child 0 + Speed: 185 + Range: 425 + SpeedSpace: 235 + RangeSpace: 725 + Ballistic: false + HomingSpeed: 1.5 + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.08, param1: shield + BlastDamageMultiplier_1: 0.2, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 175, Name: BallistaBolt + Meshfile: Entities/Items/Weapons/Projectiles/BallistaBoltPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: Base, display: false + MarketPrice: 1, display: true + HoldType: 0 + StackSize: 2000 + Canhold: false + ShowUser: No + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + # Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 1200 + BlastParticleIndex: 0 + # BlastRadius: 2, display: true + # BlastDamage: 400, display: true + Speed: 20 + Range: 500 + SpeedSpace: 30 + RangeSpace: 500 + Ballistic: True + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 176, Name: DrillAttachmentLargeWeapon, Ref: DrillAttachmentWeapon + # RadialMenu: true + { Child 0 + DrillMode: 3 + ROF: 0.5, type: float + Damage: 1200 + Radius: 5 + Range: 4, display: false + } + # { Child 1 + # Class: Driller + # DrillMode: 1 + # TerrainMode: true + # AllowAt: "UnderWater, Planet, Space" + # MiningEnabled: false # Use only for terrain removal + # FlattenTerrain: true + # ROF: 0.5, type: float + # Damage: 1500 + # Radius: 5 + # Range: 6, display: false + # NoiseStrength: 40, display: false + # CameraShake: 0 + # IgnoreAtmo: true + # Automatic: true + # AmmoCapacity: 0, display: false # no need to display since unlimited ammo + # AmmoType: AmmoInfinite, display: true + # AmmoUnlimited: true + # ReloadDelay: 2.7, display: false # no need to display since unlimited ammo + # Bullet_use_per_shot: 0.25 + # Particles: digger # particle effect shown at impact + # SfxBegin: Weapons/Player/Drill/Drilling_1 + # SfxLoop: Weapons/Player/Drill/Drilling_2 + # SfxStop: Weapons/Player/Drill/Drilling_3 + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + + # DamageMultiplier_1: 0, param1: woodblock|concrete|hull|hullarmored|hullcombat|metal|metalhard|shield + # DamageMultiplier_2: 0, param1: wood # no tree cutting + + # RadialText: rdlFlattening + # RadialDesc: rdlFlatteningTxt + # RadialIcon: Radial_Flattening + # } +} +{ +Item Id: 177, Name: TurretEnemyEMPCharge + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotYellowAlien + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: "Base,MS", display: false + HoldType: 0 + MarketPrice: 7, display: true + StackSize: 1000 + Canhold: false + ShowUser: No + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + Info: bkiAmmoExplosive, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 450, display: true # new: because we removed explosion + BlastParticleIndex: 0 + # BlastRadius: 3 + # BlastDamage: 200 + Speed: 750 + Range: 650 + SpeedSpace: 810 + RangeSpace: 875 + Ballistic: false + + DamageMultiplier_1: 0.25, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 1, param1: shield + DamageMultiplier_4: 0, param1: dirt|stone|rock|wood|metal|metalhard|hull|hullarmored|hullcombat + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0.1, param1: rock + BlastDamageMultiplier_4: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 178, Name: TurretEnemyLaserChargeT2 + CustomIcon: PulseLaserChargeMS + Info: bkiTurretAntiProtonFactionAmmo, display: true + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketGreenPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: "Base,MS", display: false + HoldType: 0 + StackSize: 1000 + Canhold: false + MarketPrice: 10, display: true + ShowUser: No + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 2000, display: true # new: because we removed explosion + BlastParticleIndex: 0 + # BlastRadius: 3 + # BlastDamage: 200 + Speed: 750 + Range: 650 + SpeedSpace: 810 + RangeSpace: 1300 + Ballistic: false + + DamageMultiplier_1: 0.25, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 0.1, param1: shield + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0.1, param1: rock + BlastDamageMultiplier_4: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +# ============================================ +# TEMPLATES +{ +Item Id: 197, Name: FoodTemplate + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: plants + Mass: 0.1, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + Info: bkiConsumable, display: true + Category: Food # will be shown as template in FoodProc and SurvConstructor + StackSize: 4000 + ShowUser: No +} +{ +Item Id: 198, Name: OreTemplate + Meshfile: Entities/Items/Ores/MagnesiumOrePrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 8, type: float, display: true, formatter: Kilogram + Volume: 3, type: float, display: true, formatter: Liter + Info: bkiSourceMaterial, display: true + Category: Components + StackSize: 4000 + ShowUser: No +} +{ +Item Id: 199, Name: ComponentsTemplate + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 8, type: float, display: true, formatter: Liter + Info: bkiComponent, display: true + Category: Components + StackSize: 4000 + ShowUser: No +} + +# ============================================ + +# CATEGORIE CRAFTING: CONSTRUCTOR id 200-299 +{ +Item Id: 200, Name: MagnesiumOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/MagnesiumOrePrefab + Mass: 6.51, type: float, display: true, formatter: Kilogram + Volume: 2.2, type: float, display: true, formatter: Liter + MarketPrice: 15, display: true + XpFactor: 35 + ShowUser: Yes +} +{ +Item Id: 201, Name: IronOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/IronOrePrefab + Mass: 16.48, type: float, display: true, formatter: Kilogram + Volume: 3.2, type: float, display: true, formatter: Liter + MarketPrice: 10, display: true + XpFactor: 20 + ShowUser: Yes + NoRepairInputItem: true # needed since it has flag "BaseItem: true" and would other be considered for repairing in R2T +} +{ +Item Id: 202, Name: CobaltOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/CobaltOrePrefab + Mass: 26.03, type: float, display: true, formatter: Kilogram + Volume: 4.5, type: float, display: true, formatter: Liter + MarketPrice: 10, display: true + XpFactor: 25 + ShowUser: Yes +} +{ +Item Id: 203, Name: SiliconOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/SiliconOrePrefab + Mass: 4.64, type: float, display: true, formatter: Kilogram + Volume: 2, type: float, display: true, formatter: Liter + MarketPrice: 10, display: true + XpFactor: 30 + ShowUser: Yes + NoRepairInputItem: true # needed since it has flag "BaseItem: true" and would other be considered for repairing in R2T +} +{ +Item Id: 204, Name: NeodymiumOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/NeodymiumOrePrefab + Mass: 24.62, type: float, display: true, formatter: Kilogram + Volume: 3.4, type: float, display: true, formatter: Liter + MarketPrice: 20, display: true + XpFactor: 35 + ShowUser: Yes +} +{ +Item Id: 205, Name: CopperOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/CopperOrePrefab + Mass: 20.39, type: float, display: true, formatter: Kilogram + Volume: 2.8, type: float, display: true, formatter: Liter + MarketPrice: 10, display: true + XpFactor: 25 + ShowUser: Yes + NoRepairInputItem: true # needed since it has flag "BaseItem: true" and would other be considered for repairing in R2T +} +{ +Item Id: 206, Name: PromethiumOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/PromethiumOrePrefab + Mass: 23.74, type: float, display: true, formatter: Kilogram + Volume: 4.1, type: float, display: true, formatter: Liter + MarketPrice: 10, display: true + XpFactor: 30 + ShowUser: Yes +} +{ +Item Id: 207, Name: OpticalFiber, Ref: ComponentsTemplate # former: Cables + ShowUser: Yes + Mass: 0.12, type: float, display: true, formatter: Kilogram + Volume: 0.2, type: float, display: true, formatter: Liter + MarketPrice: 5, display: true +} +{ +Item Id: 208, Name: MechanicalComponents, Ref: ComponentsTemplate # former: MetalComponents + ShowUser: Yes + Mass: 0.63, type: float, display: true, formatter: Kilogram + Volume: 0.12, type: float, display: true, formatter: Liter + MarketPrice: 2, display: true +} +{ +Item Id: 209, Name: CapacitorComponent, Ref: ComponentsTemplate + ShowUser: Yes + Mass: 42, type: float, display: true, formatter: Kilogram + Volume: 9, type: float, display: true, formatter: Liter + MarketPrice: 497, display: true +} +{ +Item Id: 210, Name: Computer, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 84, display: true + Mass: 12, type: float, display: true, formatter: Kilogram + Volume: 40, type: float, display: true, formatter: Liter +} +{ +Item Id: 211, Name: EnergyMatrix, Ref: ComponentsTemplate #former: ControlPanel + ShowUser: Yes + MarketPrice: 45, display: true + Mass: 17.2, type: float, display: true, formatter: Kilogram + Volume: 2.1, type: float, display: true, formatter: Liter +} +{ +Item Id: 212, Name: Electronics, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 16, display: true + Mass: 0.86, type: float, display: true, formatter: Kilogram + Volume: 0.32, type: float, display: true, formatter: Liter +} +{ +Item Id: 213, Name: FluxCoil, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 177, display: true + Mass: 39.1, type: float, display: true, formatter: Kilogram + Volume: 9, type: float, display: true, formatter: Liter +} +{ +Item Id: 214, Name: Motor, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 56, display: true + Mass: 24.28, type: float, display: true, formatter: Kilogram + Volume: 3.5, type: float, display: true, formatter: Liter +} +{ +Item Id: 215, Name: Nanotubes, Ref: ComponentsTemplate # former: Pipes + ShowUser: Yes + MarketPrice: 1.25, display: true + Mass: 0.57, type: float, display: true, formatter: Kilogram + Volume: 2.25, type: float, display: true, formatter: Liter +} +{ +Item Id: 216, Name: CobaltAlloy, Ref: ComponentsTemplate # former: ReactorCore + ShowUser: Yes + MarketPrice: 10.5, display: true + Mass: 6.29, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter +} +{ +Item Id: 217, Name: SteelPlate, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 2, display: true + Mass: 11.8, type: float, display: true, formatter: Kilogram + Volume: 1.25, type: float, display: true, formatter: Liter +} +{ +Item Id: 218, Name: EnergyCell, Ref: ComponentsTemplate + FuelValue: 30, type: int, display: true, formatter: WattHour + MarketPrice: 10, display: true + ShowUser: Yes + Info: bkiEnergyCell, display: true + + Mass: 14.52, type: float, display: true, formatter: Kilogram + Volume: 2.1, type: float, display: true, formatter: Liter + + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: Misc + # FuelValue: 500 + # Equals 10 kWh (=600 kWmin): with 1 energycell, a device that consumes 1kW can run for 10h + # Example: generatorSS (= 600000W = 600kW) would run for 1min under full throttle with 1 energycell +} +{ +Item Id: 219, Name: GlassPlate, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 10, display: true + Mass: 5.8, type: float, display: true, formatter: Kilogram + Volume: 2.5, type: float, display: true, formatter: Liter +} +# deactivated +{ Item Id: 220, Name: MetalPieces, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 1, display: true +} +{ +Item Id: 221, Name: ErestrumOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/ErestrumOrePrefab + Mass: 14.36, type: float, display: true, formatter: Kilogram + Volume: 7.1, type: float, display: true, formatter: Liter + MarketPrice: 40, display: true + XpFactor: 45 + ShowUser: Yes +} +{ +Item Id: 222, Name: ZascosiumOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/ZascosiumOrePrefab + Mass: 16.22, type: float, display: true, formatter: Kilogram + Volume: 7.6, type: float, display: true, formatter: Liter + MarketPrice: 40, display: true + XpFactor: 45 + ShowUser: Yes +} +{ +Item Id: 223, Name: MagnesiumPowder, Ref: ComponentsTemplate + Mass: 0.43, type: float, display: true, formatter: Kilogram + Volume: 0.25, type: float, display: true, formatter: Liter + MarketPrice: 10, display: true + ShowUser: Yes +} +{ +Item Id: 224, Name: IronIngot, Ref: ComponentsTemplate + Mass: 5.9, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 6.5, display: true + ShowUser: Yes +} +{ +Item Id: 225, Name: CobaltIngot, Ref: ComponentsTemplate + Mass: 6.68, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 6.5, display: true + ShowUser: Yes +} +{ +Item Id: 226, Name: SiliconIngot, Ref: ComponentsTemplate + Mass: 1.9, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 6.5, display: true + ShowUser: Yes +} +{ +Item Id: 227, Name: NeodymiumIngot, Ref: ComponentsTemplate + Mass: 5.25, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 13, display: true + ShowUser: Yes +} +{ +Item Id: 228, Name: CopperIngot, Ref: ComponentsTemplate + Mass: 6.71, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 6.5, display: true + ShowUser: Yes +} +{ +Item Id: 229, Name: PromethiumPellets, Ref: ComponentsTemplate + Mass: 2.18, type: float, display: true, formatter: Kilogram + Volume: 0.3, type: float, display: true, formatter: Liter + MarketPrice: 1.5, display: true + ShowUser: Yes +} +{ +Item Id: 230, Name: ErestrumIngot, Ref: ComponentsTemplate + Mass: 4.65, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 26, display: true + ShowUser: Yes +} +{ +Item Id: 231, Name: ZascosiumIngot, Ref: ComponentsTemplate + Mass: 5.36, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 26, display: true + ShowUser: Yes +} +{ +Item Id: 232, Name: CrushedStone, Ref: OreTemplate + Meshfile: Entities/Items/Ores/CrushedStonePrefab + Mass: 5.38, type: float, display: true, formatter: Kilogram + Volume: 2, type: float, display: true, formatter: Liter + MarketPrice: 5, display: true + XpFactor: 1 + ShowUser: Yes +} +{ +Item Id: 233, Name: RockDust, Ref: ComponentsTemplate + Mass: 2.67, type: float, display: true, formatter: Kilogram + Volume: 1.25, type: float, display: true, formatter: Liter + MarketPrice: 1.5, display: true + ShowUser: Yes +} +{ +Item Id: 234, Name: Oscillator, Ref: ComponentsTemplate #former: AdvancedCPU + Mass: 8.27, type: float, display: true, formatter: Kilogram + Volume: 2.5, type: float, display: true, formatter: Liter + MarketPrice: 44.5, display: true + ShowUser: Yes +} +{ +Item Id: 235, Name: PlasticMaterial, Ref: OreTemplate + Meshfile: Entities/Items/Ores/CarbonOrePrefab + Mass: 0.18, type: float, display: true, formatter: Kilogram + Volume: 0.12, type: float, display: true, formatter: Liter + # MarketPrice: 0.2, display: true + MarketPrice: 1.25, display: true + XpFactor: 2 + ShowUser: Yes + #NoRepairInputItem: true +} +{ +Item Id: 236, Name: SathiumOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/SathiumOrePrefab + Mass: 51.3, type: float, display: true, formatter: Kilogram + Volume: 8.4, type: float, display: true, formatter: Liter + MarketPrice: 40, display: true + XpFactor: 45 + ShowUser: Yes +} +{ +Item Id: 237, Name: SathiumIngot, Ref: ComponentsTemplate + Mass: 14.44, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + ShowUser: Yes + MarketPrice: 26, display: true +} +{ +Item Id: 238, Name: PowerCoil, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 1305, display: true + Mass: 184.9, type: float, display: true, formatter: Kilogram + Volume: 24.5, type: float, display: true, formatter: Liter +} + +# ID 239 is hard coded +# Implement that EnergyCellLarge can only be placed into FuelcellMS +{ +Item Id: 239, Name: EnergyCellLarge, Ref: ComponentsTemplate + FuelValue: 150, type: int, display: true, formatter: WattHour + ShowUser: Yes + MarketPrice: 49, display: true + Info: bkiEnergyCell, display: true + + Mass: 30, type: float, display: true, formatter: Kilogram + Volume: 4.5, type: float, display: true, formatter: Liter + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeParent: EnergyCell + TechTreeNames: Misc +} +# Not used anymore +{ Item Id: 240, Name: EnergyCellHydrogen, Ref: ComponentsTemplate + FuelValue: 15, type: int, display: true, formatter: WattHour + ShowUser: NoButCSV + MarketPrice: 50, display: true + + Mass: 20, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + + # UnlockCost: 10, display: true + # UnlockLevel: 7, display: true + # TechTreeParent: EnergyCell + # TechTreeNames: Misc +} +## Please do not rename - referenced in code +{ +Item Id: 241, Name: HydrogenBottle, Ref: ComponentsTemplate + Mass: 17.25, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + MarketPrice: 24.5, display: true + ShowUser: Yes +} +{ +Item Id: 242, Name: WoodLogs, Ref: OreTemplate + Meshfile: Entities/Items/Resources/WoodLogsPrefab + Mass: 8, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + MarketPrice: 5, display: true + XpFactor: 20 + ShowUser: Yes +} +{ +Item Id: 243, Name: WoodPlanks, Ref: ComponentsTemplate + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter + # MarketPrice: 0.13, display: true + MarketPrice: 1.25, display: true + ShowUser: Yes +} +{ +Item Id: 244, Name: Cement, Ref: ComponentsTemplate + Mass: 16.14, type: float, display: true, formatter: Kilogram + Volume: 7.55, type: float, display: true, formatter: Liter + MarketPrice: 4.9, display: true + ShowUser: Yes +} +{ +Item Id: 245, Name: PentaxidOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/PentaxidOrePrefab + Mass: 88.88, type: float, display: true, formatter: Kilogram + Volume: 16.85, type: float, display: true, formatter: Liter + MarketPrice: 35, display: true + XpFactor: 60 + ShowUser: Yes +} +# Used for PentaxidTank +# Before "CrystalWarp" +{ +Item Id: 246, Name: PentaxidCrystal, Ref: ComponentsTemplate + StackSize: 50 + Mass: 12.82, type: float, display: true, formatter: Kilogram + Volume: 2.4, type: float, display: true, formatter: Liter + Info: bkiPentaxidTank, display: true + ShowUser: Yes + MarketPrice: 23, display: true +} +{ +Item Id: 247, Name: SteelPlateArmored, Ref: ComponentsTemplate + ShowUser: Yes + Mass: 28.88, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + MarketPrice: 19.5, display: true +} +## Please do not rename - referenced in code +{ +Item Id: 248, Name: MoneyCard, Ref: ComponentsTemplate + StackSize: 50000 + Info: bkiCurrency, display: true + Credits: 1, display: true + Mass: 0, type: float, display: true, formatter: Kilogram + Volume: 0, type: float, display: true, formatter: Liter + ShowUser: Yes +} +{ +Item Id: 249, Name: GoldOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/GoldOrePrefab + Mass: 8.11, type: float, display: true, formatter: Kilogram + Volume: 2.17, type: float, display: true, formatter: Liter + MarketPrice: 30, display: true + XpFactor: 60 + ShowUser: Yes +} +{ +Item Id: 250, Name: GoldIngot, Ref: ComponentsTemplate + Mass: 14.49, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 195, display: true + ShowUser: Yes +} +{ +Item Id: 251, Name: GoldCoins, Ref: ComponentsTemplate + Info: bkiCurrency, display: true + Mass: 1.44, type: float, display: true, formatter: Kilogram + Volume: 1.0, type: float, display: true, formatter: Liter + Credits: 50, display: true + ShowUser: Yes +} +{ +Item Id: 252, Name: PlayerBikeKit, Ref: ComponentsTemplate + ShowUser: Yes + Info: bkiPlayerbikeKit, display: true + MarketPrice: 10, display: true + Mass: 59.4, type: float, display: true, formatter: Kilogram + Volume: 127.5, type: float, display: true, formatter: Liter +} +## Please do not rename - referenced in code +{ Item Id: 253, Name: PlayerBike + MarketPrice: 120, display: true + Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikePrefab + #Meshfile: Entities/Misc/PlayerBikePrefab + DropMeshfile: Entities/Misc/PlayerBikeFoldedItemPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 59.4, type: float, display: true, formatter: Kilogram + Volume: 100, type: float, display: true, formatter: Liter + Info: bkiPlayerBike, display: true + AllowPlacingAt: Terrain + StackSize: 1 + Category: Devices + MapIcon: map_PlayerBike + PickupToToolbar: true + { Child 0 + Class: PlaceBike + Range: 5 + Entity: PlayerBike + } +} +{ +Item Id: 254, Name: AutoMinerCore, Ref: ComponentsTemplate + Info: bkiAutoMinercore, display: true + MarketPrice: 7500, display: true + Mass: 100, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + ShowUser: Yes +} +{ +Item Id: 255, Name: PistolKit, Ref: ComponentsTemplate + Mass: 1.25, type: float, display: true, formatter: Kilogram + Volume: 4.5, type: float, display: true, formatter: Liter + MarketPrice: 250, display: true + StackSize: 1000 + ShowUser: Yes +} +{ +Item Id: 256, Name: RifleKit, Ref: ComponentsTemplate + Mass: 2.5, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + MarketPrice: 1200, display: true + StackSize: 1000 + ShowUser: Yes +} +{ +Item Id: 257, Name: SniperKit, Ref: ComponentsTemplate + Mass: 5, type: float, display: true, formatter: Kilogram + Volume: 25, type: float, display: true, formatter: Liter + MarketPrice: 1500, display: true + StackSize: 1000 + ShowUser: Yes +} +{ +Item Id: 258, Name: ShotgunKit, Ref: ComponentsTemplate + Mass: 2.5, type: float, display: true, formatter: Kilogram + Volume: 15, type: float, display: true, formatter: Liter + MarketPrice: 800, display: true + StackSize: 1000 + ShowUser: Yes +} +{ +Item Id: 259, Name: HeavyWeaponKit, Ref: ComponentsTemplate + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 62.5, type: float, display: true, formatter: Liter + MarketPrice: 1800, display: true + StackSize: 1000 + ShowUser: Yes +} +{ +Item Id: 260, Name: LaserKit, Ref: ComponentsTemplate + Mass: 5, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + MarketPrice: 1500, display: true + StackSize: 1000 + ShowUser: Yes +} +{ +Item Id: 261, Name: PentaxidElement, Ref: ComponentsTemplate + ShowUser: Yes + Mass: 2.21, type: float, display: true, formatter: Kilogram + Volume: 0.25, type: float, display: true, formatter: Liter + MarketPrice: 9.1, display: true +} + +{ +Item Id: 262, Name: IceBlocks, Ref: OreTemplate + Meshfile: Entities/Items/Ores/IceBlocksPrefab + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + MarketPrice: 5, display: true + XpFactor: 10 + ShowUser: Yes + NoRepairInputItem: true # needed since it has flag "BaseItem: true" and would other be considered for repairing in R2T +} + +{ +Item Id: 263, Name: TitanOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/TitanOrePrefab + Mass: 8.07, type: float, display: true, formatter: Kilogram + Volume: 3.5, type: float, display: true, formatter: Liter + MarketPrice: 20, display: true + XpFactor: 20 + ShowUser: Yes + NoRepairInputItem: true # needed since it has flag "BaseItem: true" and would other be considered for repairing in R2T +} + +{ +Item Id: 264, Name: TitanRods, Ref: ComponentsTemplate + Mass: 3.38, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 13, display: true + ShowUser: Yes +} + +{ +Item Id: 265, Name: TitanPlates, Ref: ComponentsTemplate + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 3, type: float, display: true, formatter: Liter + MarketPrice: 9.75, display: true + ShowUser: Yes +} + +{ +Item Id: 266, Name: AluminiumOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/AluminiumOrePrefab + Mass: 8.07, type: float, display: true, formatter: Kilogram + Volume: 3.5, type: float, display: true, formatter: Liter + MarketPrice: 20, display: true + XpFactor: 20 + ShowUser: Yes + NoRepairInputItem: true # needed since it has flag "BaseItem: true" and would other be considered for repairing in R2T +} + +{ +Item Id: 267, Name: AluminiumPowder, Ref: ComponentsTemplate + Mass: 3.38, type: float, display: true, formatter: Kilogram + Volume: 0.75, type: float, display: true, formatter: Liter + MarketPrice: 13, display: true + ShowUser: Yes +} + +{ +Item Id: 268, Name: AluminiumCoil, Ref: ComponentsTemplate + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 3, type: float, display: true, formatter: Liter + MarketPrice: 97.5, display: true + ShowUser: Yes +} +{ +Item Id: 269, Name: PlatinOre, Ref: OreTemplate + Meshfile: Entities/Items/Ores/PlatinumOrePrefab + Mass: 5, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + MarketPrice: 450, display: true + XpFactor: 20 + ShowUser: Yes + NoRepairInputItem: true # needed since it has flag "BaseItem: true" and would other be considered for repairing in R2T +} + +{ +Item Id: 270, Name: PlatinBar, Ref: ComponentsTemplate + Mass: 2, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter + MarketPrice: 292.5, display: true + ShowUser: Yes +} + +{ +Item Id: 271, Name: PlatinOunce, Ref: ComponentsTemplate + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + MarketPrice: 48.8, display: true + ShowUser: Yes +} +{ Item Id: 272, Name: HoverbikeWasp + MarketPrice: 2158, display: true + Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeWaspPrefab + #Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeWaspPrefab + DropMeshfile: Entities/Misc/PlayerBikeFoldedItemPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 59.4, type: float, display: true, formatter: Kilogram + Volume: 100, type: float, display: true, formatter: Liter + Info: bkiHoverbikeWasp, display: true + AllowPlacingAt: Terrain + StackSize: 1 + Category: Devices + MapIcon: map_PlayerBike + PickupToToolbar: true + { Child 0 + Class: PlaceBike + Range: 5 + Entity: HoverbikeWasp + } +} + +{ Item Id: 273, Name: HoverbikeHunter + MarketPrice: 1420, display: true + Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeHunterPrefab + #Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeWaspPrefab + DropMeshfile: Entities/Misc/PlayerBikeFoldedItemPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 59.4, type: float, display: true, formatter: Kilogram + Volume: 100, type: float, display: true, formatter: Liter + Info: bkiHoverbikeHunter, display: true + AllowPlacingAt: Terrain + StackSize: 1 + Category: Devices + MapIcon: map_PlayerBike + PickupToToolbar: true + { Child 0 + Class: PlaceBike + Range: 5 + Entity: HoverbikeHunter + } +} +{ Item Id: 274, Name: HoverbikeSpeedstar + MarketPrice: 1750, display: true + Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeSpeedstarPrefab + #Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeSpeedstarPrefab + DropMeshfile: Entities/Misc/PlayerBikeFoldedItemPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 59.4, type: float, display: true, formatter: Kilogram + Volume: 100, type: float, display: true, formatter: Liter + Info: bkiHoverbikeSpeedstar, display: true + AllowPlacingAt: Terrain + StackSize: 1 + Category: Devices + MapIcon: map_PlayerBike + PickupToToolbar: true + { Child 0 + Class: PlaceBike + Range: 5 + Entity: HoverbikeSpeedstar + } +} +{ Item Id: 275, Name: HoverbikeHowler + MarketPrice: 1250, display: true + Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeHowlerPrefab + #Meshfile: Entities/Misc/Hoverbike/Prefab/HoverbikeHowlerPrefab + DropMeshfile: Entities/Misc/PlayerBikeFoldedItemPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 59.4, type: float, display: true, formatter: Kilogram + Volume: 100, type: float, display: true, formatter: Liter + Info: bkiHoverbikeHowler, display: true + AllowPlacingAt: Terrain + StackSize: 1 + Category: Devices + MapIcon: map_PlayerBike + PickupToToolbar: true + { Child 0 + Class: PlaceBike + Range: 5 + Entity: HoverbikeHowler + } +} +{ +Item Id: 276, Name: EnergyCellSmall, Ref: 50Caliber #Both Ammo AND fuel! + CustomIcon: EnergyCellHydrogen + FuelValue: 15, type: int, display: true, formatter: WattHour + MarketPrice: 10, display: true + ShowUser: Yes + Category: Components + AutoFill: false #Do not autofill into fuel tanks because of being ammo as well! + Info: bkiEnergyCellSmall, display: true + Mass: 7, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + UnlockCost: 0 + UnlockLevel: 1 + TechTreeNames: Hidden + # FuelValue: 500 + # Equals 10 kWh (=600 kWmin): with 1 energycell, a device that consumes 1kW can run for 10h + # Example: generatorSS (= 600000W = 600kW) would run for 1min under full throttle with 1 energycell +} +# Save game break: move down to food items, e.g. Id: 327 +{ +Item Id: 277, Name: AnniversaryCake, Ref: FoodTemplate + StackSize: 10 + FoodDecayTime: 90, display: true, type: int + FoodDecayedItem: RottenFood + Info: bkiAnniversaryCake, display: true + MarketPrice: 89.49, display: true + ShowUser: Yes + { Child 0 + Class: Eat + AddHealth: 35 + AddStamina: 17, display: StaminaValue + AddFood: 163, display: FoodValue + AddOxygen: 0 + AddTemperature: -1, display: TemperatureValue + # AddRadiation: -2, display: RadiationValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 278, Name: ErestrumGel, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 78, display: true + Mass: 1.6, type: float, display: true, formatter: Kilogram + Volume: 0.25, type: float, display: true, formatter: Liter +} +{ +Item Id: 279, Name: ZascosiumAlloy, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 83.2, display: true + Mass: 7.33, type: float, display: true, formatter: Kilogram + Volume: 1.12, type: float, display: true, formatter: Liter +} +{ +Item Id: 280, Name: XenoSubstrate, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 45, display: true + Mass: 5.4, type: float, display: true, formatter: Kilogram + Volume: 1.25, type: float, display: true, formatter: Liter +} +{ +Item Id: 281, Name: NCPowder, Ref: ComponentsTemplate + ShowUser: Yes + # MarketPrice: 0.02, display: true + MarketPrice: 1.25, display: true + Mass: 0.18, type: float, display: true, formatter: Kilogram + Volume: 0.15, type: float, display: true, formatter: Liter +} +{ +Item Id: 282, Name: SmallOptronicBridge, Ref: ComponentsTemplate + Mass: 16, type: float, display: true, formatter: Kilogram + Volume: 1.25, type: float, display: true, formatter: Liter + MarketPrice: 14299, display: true + ShowUser: Yes +} +{ +Item Id: 283, Name: SmallOptronicMatrix, Ref: ComponentsTemplate + Mass: 30, type: float, display: true, formatter: Kilogram + Volume: 1.5, type: float, display: true, formatter: Liter + MarketPrice: 22355, display: true + ShowUser: Yes +} +{ +Item Id: 284, Name: LargeOptronicBridge, Ref: ComponentsTemplate + Mass: 200, type: float, display: true, formatter: Kilogram + Volume: 3.25, type: float, display: true, formatter: Liter + MarketPrice: 33988, display: true + ShowUser: Yes +} +{ +Item Id: 285, Name: LargeOptronicMatrix, Ref: ComponentsTemplate + Mass: 400, type: float, display: true, formatter: Kilogram + Volume: 8.5, type: float, display: true, formatter: Liter + MarketPrice: 59206, display: true + ShowUser: Yes +} + +{ +Item Id: 286, Name: DetectorHVEpicUpgradeKit, Ref: ComponentsTemplate + #CustomIcon: KeyCardRed + DropOnDeath: false + Info: bkiDetectorHVEpicUpgradeKit, display: true + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 3, type: float, display: true, formatter: Liter + #MarketPrice: 59206, display: true + ShowUser: Yes +} + +{ +Item Id: 287, Name: DetectorCVEpicUpgradeKit, Ref: ComponentsTemplate + #CustomIcon: KeyCardRed + DropOnDeath: false + Info: bkiDetectorCVEpicUpgradeKit, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + #MarketPrice: 59206, display: true + ShowUser: Yes +} + +{ +Item Id: 288, Name: WarpDriveCVEpicUpgradeKit, Ref: ComponentsTemplate + #CustomIcon: KeyCardRed + DropOnDeath: false + Info: bkiWarpDriveCVEpicUpgradeKit, display: true + Mass: 50, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + #MarketPrice: 59206, display: true + ShowUser: Yes +} + +{ +Item Id: 289, Name: TeleporterBAEpicUpgradeKit, Ref: ComponentsTemplate + # CustomIcon: KeyCardRed + DropOnDeath: false + Info: bkiTeleporterBAEpicUpgradeKit, display: true + Mass: 50, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + #MarketPrice: 59206, display: true + ShowUser: Yes +} + +# CATEGORIE CRAFTING: FOOD PROCESSOR id 300-399 +# delete when save game break +{ +Item Id: 300, Name: SpaceDurian, Ref: FoodTemplate + FoodDecayTime: 35, display: true, type: int + FoodDecayedItem: RottenFood + Mass: 0.35, type: float, display: true, formatter: Kilogram + Volume: 0.31, type: float, display: true, formatter: Liter + ShowUser: NoButCSV +{ Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 10, display: HealthValue + AddStamina: 5, display: StaminaValue + AddFood: 35, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 301, Name: Steak, Ref: FoodTemplate + Mass: 0.24, type: float, display: true, formatter: Kilogram + Volume: 0.27, type: float, display: true, formatter: Liter + FoodDecayTime: 13, display: true, type: int + FoodDecayedItem: RottenFood + MarketPrice: 16.80, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 12, display: HealthValue + AddStamina: 3, display: StaminaValue + AddFood: 60, display: FoodValue + AddOxygen: 0 + AddTemperature: 3, display: TemperatureValue + SfxBegin: UseActions/playerEatingSoft + } +} +# Raw Meat +{ +Item Id: 302, Name: Meat, Ref: FoodTemplate + Mass: 0.35, type: float, display: true, formatter: Kilogram + Volume: 0.31, type: float, display: true, formatter: Liter + FoodDecayTime: 10, display: true, type: int + FoodDecayedItem: RottenFood + MarketPrice: 4.20, display: true + Category: Food + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 15, display: HealthValue + AddStamina: 3, display: StaminaValue + AddFood: 32, display: FoodValue + AddOxygen: 0 + Buff: "Indigestion,PoisonFood" + Buff_chance: "0.9,0.3" + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 303, Name: Spice, Ref: FoodTemplate + Mass: 0.03, type: float, display: true, formatter: Kilogram + Volume: 0.05, type: float, display: true, formatter: Liter + Info: bkiIngredient, display: true + Category: Ingredients + MarketPrice: 1, display: true + FoodDecayTime: 74, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -1, display: HealthValue + AddStamina: -20, display: StaminaValue + AddFood: 5, display: FoodValue + AddOxygen: 0 + AddTemperature: 1, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.1 + SfxBegin: UseActions/playerEatingSoft + } +} + +{ +Item Id: 304, Name: OpuntiaCactusSpears, Ref: FoodTemplate + Mass: 0.11, type: float, display: true, formatter: Kilogram + Volume: 0.32, type: float, display: true, formatter: Liter + Category: Ingredients + FoodDecayTime: 30, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 10, display: HealthValue + AddStamina: 35, display: StaminaValue + AddFood: 45, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.05 + Debuff: OpenWound, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 305, Name: Fiber, Ref: FoodTemplate + Info: bkiIngredient, display: true + Category: Components + StackSize: 4000 + ShowUser: Yes + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter + # MarketPrice: 0.22, display: true + MarketPrice: 1.25, display: true +} +{ +Item Id: 306, Name: AkuaWine, Ref: FoodTemplate + MarketPrice: 24.95, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 43, display: HealthValue + AddStamina: 40, display: StaminaValue + AddFood: 2, display: FoodValue + AddOxygen: 0 + AddTemperature: -8, display: TemperatureValue + SfxBegin: UseActions/playerDrinking + Buff: "Hangover" + Buff_chance: "0.4" + # Debuff: Cold, display: EffectiveAgainst + } +} +{ +Item Id: 309, Name: AlienParts01, Ref: FoodTemplate + Mass: 0.3, type: float, display: true, formatter: Kilogram + Volume: 0.14, type: float, display: true, formatter: Liter + Info: bkiIngredient, display: true + MarketPrice: 2, display: true + Category: Food + StackSize: 4000 + ShowUser: Yes +} +{ +Item Id: 310, Name: AlienParts02, Ref: FoodTemplate + Mass: 0.52, type: float, display: true, formatter: Kilogram + Volume: 0.71, type: float, display: true, formatter: Liter + Info: bkiIngredient, display: true + MarketPrice: 2, display: true + Category: Food + StackSize: 4000 + ShowUser: Yes +} +{ +Item Id: 311, Name: AlienParts03, Ref: FoodTemplate + Mass: 0.35, type: float, display: true, formatter: Kilogram + Volume: 0.21, type: float, display: true, formatter: Liter + Info: bkiIngredient, display: true + MarketPrice: 2, display: true + Category: Food + StackSize: 4000 + ShowUser: Yes +} +{ Item Id: 312, Name: AlienParts04, Ref: FoodTemplate + Info: bkiIngredient, display: true + Category: Ingredients + StackSize: 4000 + ShowUser: No + MarketPrice: 2, display: true +} +{ +Item Id: 313, Name: Stew, Ref: FoodTemplate + Mass: 0.75, type: float, display: true, formatter: Kilogram + Volume: 2, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 97.5, display: true + FoodDecayTime: 28, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 90, display: HealthValue + AddStamina: 40, display: StaminaValue + AddFood: 298, display: FoodValue + AddOxygen: 0 + AddTemperature: 5, display: TemperatureValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 314, Name: Bread, Ref: FoodTemplate + Mass: 0.15, type: float, display: true, formatter: Kilogram + Volume: 3.2, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 27.5, display: true + FoodDecayTime: 113, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 6, display: HealthValue + AddStamina: 0, display: StaminaValue + AddFood: 58, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingCrunchy + } +} +{ +Item Id: 315, Name: Fruit, Ref: FoodTemplate + MarketPrice: 1, display: true + Mass: 0.08, type: float, display: true, formatter: Kilogram + Volume: 0.18, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 25, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 10, display: HealthValue + AddStamina: 10, display: StaminaValue + AddFood: 18, display: FoodValue + AddOxygen: 0 + AddTemperature: -1, display: TemperatureValue + SfxBegin: UseActions/playerEatingFruit + Buff: Indigestion + Buff_chance: 0.01 + } +} +{ +Item Id: 316, Name: Grain, Ref: FoodTemplate + MarketPrice: 1, display: true + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 50, display: true, type: int + FoodDecayedItem: RottenFood + Mass: 0.06, type: float, display: true, formatter: Kilogram + Volume: 0.19, type: float, display: true, formatter: Liter + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0, display: HealthValue + AddStamina: 0, display: StaminaValue + AddFood: 5, display: FoodValue + AddOxygen: 0 + # AddTemperature: -0, display: TemperatureValue + SfxBegin: UseActions/playerEatingFruit + Buff: Indigestion + Buff_chance: 0.10 + } +} +{ +Item Id: 317, Name: Egg, Ref: FoodTemplate + MarketPrice: 1, display: true + Mass: 0.6, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 32, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 15, display: HealthValue + AddStamina: 10, display: StaminaValue + AddFood: 21, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingPain + Buff: Indigestion + Buff_chance: 0.8 + } +} +{ +Item Id: 318, Name: Varonroot, Ref: FoodTemplate + MarketPrice: 1.5, display: true + Mass: 0.05, type: float, display: true, formatter: Kilogram + Volume: 0.12, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 160, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 125, display: HealthValue + AddStamina: -20, display: StaminaValue + AddFood: 5, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.1 + SfxBegin: UseActions/playerEatingSoft + Debuff: PoisonBit, display: EffectiveAgainst + } +} +{ +Item Id: 319, Name: NaturalStimulant, Ref: FoodTemplate + MarketPrice: 1, display: true + Mass: 0.07, type: float, display: true, formatter: Kilogram + Volume: 0.08, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 60, display: true, type: int + FoodDecayedItem: RottenFood + StackSize: 4000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -25, display: HealthValue + AddStamina: 50, display: StaminaValue + AddFood: -3, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingSoft + Debuff: Stunned, display: EffectiveAgainst + Buff: "Indigestion,BadTrip" + Buff_chance: "0.5,0.9" + } +} +{ +Item Id: 320, Name: AlienTooth, Ref: FoodTemplate + MarketPrice: 1.5, display: true + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + Info: bkiIngredient, display: true + Category: Medical + StackSize: 4000 + ShowUser: Yes +} +# delete when save game break +{ +Item Id: 321, Name: Ribs, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Food + StackSize: 4000 + FoodDecayTime: 21, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 24, display: HealthValue + AddStamina: 12, display: StaminaValue + AddFood: 182, display: FoodValue + AddOxygen: 0 + AddTemperature: 5, display: TemperatureValue + SfxBegin: UseActions/playerEatingSoft + } +} +# delete when save game break +{ +Item Id: 322, Name: TexturedPlantProtein, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 30, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 25, display: HealthValue + AddStamina: 40, display: StaminaValue + AddFood: 20, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.05 + Debuff: PoisonBit, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +# delete when save game break +{ +Item Id: 323, Name: SpaceOrange, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 35, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 5, display: HealthValue + AddStamina: 10, display: StaminaValue + AddFood: 30, display: FoodValue + AddOxygen: 0 + AddTemperature: -3, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.02 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 324, Name: VeggieBurger, Ref: FoodTemplate + Info: bkiConsumable, display: true + MarketPrice: 124, display: true + Category: Food + FoodDecayTime: 60, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 24, display: HealthValue + AddStamina: 10, display: StaminaValue + AddFood: 143, display: FoodValue + AddOxygen: 0 + AddTemperature: 3, display: TemperatureValue + SfxBegin: UseActions/playerEatingSoft + } +} + +# Save game break: move up to components +{ +Item Id: 325, Name: FusionCell, Ref: ComponentsTemplate + Mass: 75, type: float, display: true, formatter: Kilogram + Volume: 10.5, type: float, display: true, formatter: Liter + FuelValue: 300, type: int, display: true, formatter: WattHour + MarketPrice: 325, display: true + ShowUser: Yes + Info: bkiFusionCell, display: true + + UnlockCost: 25, display: true + UnlockLevel: 20, display: true + TechTreeParent: EnergyCellLarge + TechTreeNames: Misc +} + +{ +Item Id: 326, Name: Milk, Ref: FoodTemplate + Mass: 1.08, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 12.34, display: true + Category: Food + FoodDecayTime: 20, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 15, display: HealthValue + AddStamina: 25, display: StaminaValue + AddFood: 24, display: FoodValue + AddOxygen: 0 + AddTemperature: -1, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.2 + Debuff: BadTrip, display: EffectiveAgainst + SfxBegin: UseActions/playerDrinking + } +} + +{ +Item Id: 328, Name: Cheese, Ref: FoodTemplate + Mass: 0.95, type: float, display: true, formatter: Kilogram + Volume: 0.85, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 37, display: true + FoodDecayTime: 48, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 18, display: HealthValue + AddStamina: 13, display: StaminaValue + AddFood: 36, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 330, Name: Beer, Ref: FoodTemplate + Mass: 0.66, type: float, display: true, formatter: Kilogram + Volume: 0.7, type: float, display: true, formatter: Liter + MarketPrice: 9.90, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 26, display: HealthValue + AddStamina: 70, display: StaminaValue + AddFood: 2, display: FoodValue + AddOxygen: 0 + AddTemperature: -8, display: TemperatureValue + SfxBegin: UseActions/playerDrinking + Buff: "Hangover" + Buff_chance: "0.1" + # Debuff: Cold, display: EffectiveAgainst + } +} + +# free + +{ +Item Id: 332, Name: NutrientSolution, Ref: FoodTemplate + Info: bkiIngredient, display: true + Category: Food + StackSize: 2000 + MarketPrice: 101, display: true + ShowUser: Yes + Mass: 6, type: float, display: true, formatter: Kilogram + Volume: 5.2, type: float, display: true, formatter: Liter +} +{ +Item Id: 333, Name: RottenFood, Ref: FoodTemplate + Info: bkiIngredient, display: true + Category: Food + StackSize: 2000 + MarketPrice: 1.25, display: true + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -10 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Buff: PoisonFood + Buff_chance: 1 + SfxBegin: UseActions/playerEatingPain + } +} + +{ +Item Id: 335, Name: HerbalLeaves, Ref: FoodTemplate + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Ingredients + MarketPrice: 1, display: true + FoodDecayTime: 25, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0, display: HealthValue + AddStamina: -15, display: StaminaValue + AddFood: 0, display: FoodValue + AddOxygen: 0 + Debuff: "DermalBurn,Indigestion", display: EffectiveAgainst + # Buff_chance: 0.025 + # Debuff: PoisonBit, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +# delete when save game break +{ +Item Id: 336, Name: CornDogFruit, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 30, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0, display: HealthValue + AddStamina: 0, display: StaminaValue + AddFood: 25, display: FoodValue + AddOxygen: 0 + # Buff: PoisonFood + # Buff_chance: 0.02 + SfxBegin: UseActions/playerEatingSoft + } +} + +{ +Item Id: 338, Name: PhoenixFernFonds, Ref: FoodTemplate + Info: bkiIngredient, display: true + Category: Ingredients + MarketPrice: 2, display: true + StackSize: 2000 + ShowUser: Yes +} + +# Delete when save game break +{ Item Id: 339, Name: GrilledVegetables, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Food + FoodDecayTime: 54, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 14, display: HealthValue + AddStamina: 6, display: StaminaValue + AddFood: 60, display: FoodValue + AddOxygen: 0 + AddTemperature: 2, display: TemperatureValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 340, Name: TrumpetGreens, Ref: FoodTemplate + Mass: 0.05, type: float, display: true, formatter: Kilogram + Volume: 0.07, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 3, display: true + Category: Ingredients + FoodDecayTime: 24, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 18, display: HealthValue + AddStamina: 50, display: StaminaValue + AddFood: 35, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.15 + SfxBegin: UseActions/playerEatingSoft + } +} + +# =========================================================================== MEDICAL CONSUMABLES ================================================================= + +{ +Item Id: 341, Name: Bandages, Ref: FoodTemplate + Mass: 0.05, type: float, display: true, formatter: Kilogram + Volume: 0.32, type: float, display: true, formatter: Liter + Category: Medical + LifetimeOnDrop: 1200 + MarketPrice: 10, display: true + Info: bkiBandages, display: true + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 10, type: float + AddHealth: 85 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Debuff: "OpenWound,DermalBurn", display: EffectiveAgainst + SfxBegin: UseActions/exhale + BuffMod: "FractureClosed, -0.3, -0.8, true; FractureOpen, -0.4, -0.6, true" + } +} + +{ +Item Id: 382, Name: StomachPills, Ref: FoodTemplate + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + Info: bkiMedicalItem, display: true + MarketPrice: 12, display: true + Category: Medical + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Debuff: "Hangover,Indigestion", display: EffectiveAgainst + SfxBegin: UseActions/playerSwallow + BuffMod: "PoisonFood, -0.7, 0, true; Frostbite, 0.2, -0.7, false; Necrosis, 0, -0.85, false; OrganFailure, 0, -0.4; FeverAttack, 0.3, -0.4, false" + } +} + +{ +Item Id: 327, Name: Medikit01, Ref: FoodTemplate #TraumaKit + Mass: 0.04, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Category: Medical + MarketPrice: 160, display: true + Info: bkiMedikit01, display: true #NewRequired + StackSize: 1000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Buff: MedikitApplied2 + Debuff: "DermalBurn,OpenWound,FractureClosed,FractureOpen,Mutilation,Hangover,BadTrip,FeverAttack,Frostbite,Necrosis,PoisonFood,OrganFailure", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 334, Name: AntibioticPills, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + StackSize: 2000 + MarketPrice: 45, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -10 + AddStamina: 0 + AddFood: -50 + AddOxygen: 0 + Debuff: "DermalParasite,EndoParasite", display: EffectiveAgainst + SfxBegin: UseActions/exhale + BuffMod: "InfectedWound, -0.2, -0.4, true; Sepsis, -0.1, -0.25;EndoParasite2, 0.5, 0, false" + } +} + +{ +Item Id: 345, Name: AntibioticInjection, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + StackSize: 2000 + MarketPrice: 90, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Debuff: "DermalParasite,EndoParasite,EndoParasite2,InfectedWound", display: EffectiveAgainst + SfxBegin: UseActions/exhale + BuffMod: "Sepsis, -0.15, -0.4; EndoParasite3, -0.6, -0.2, false" + } +} + +{ +Item Id: 389, Name: AntiToxicPills, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + MarketPrice: 60, display: true + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -10 + AddStamina: 0 + AddFood: -50 + AddOxygen: 0 + Debuff: "PoisonBit,BadTrip", display: EffectiveAgainst + SfxBegin: UseActions/exhale + BuffMod: "PoisonBit2, 0.6, -0.9, false;Intoxication, 1, -0.4, true" + } +} + +{ +Item Id: 391, Name: AntiToxicInjection, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + MarketPrice: 120, display: true + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Debuff: "Intoxication,BadTrip,PoisonBit,PoisonBit2", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 307, Name: Medikit02, Ref: FoodTemplate #DetoxKit + Mass: 0.4, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Category: Medical + MarketPrice: 600, display: true + Info: bkiMedikit02, display: true + StackSize: 1000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + # Buff: MedikitApplied + Debuff: "Intoxication,BadTrip,PoisonBit,PoisonBit2,DermalParasite,EndoParasite,EndoParasite2,EndoParasite3,InfectedWound,Sepsis,AlienParasite", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 368, Name: RadiationPills, Ref: FoodTemplate + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + Info: bkiMedicalItem, display: true + MarketPrice: 75, display: true + Category: Medical + StackSize: 4000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -50, display: HealthValue + AddStamina: 0, display: StaminaValue + AddFood: -50, display: FoodValue + AddOxygen: 0 + AddRadiation: -3, display: RadiationValue + Debuff: "RadiationPoisoning,RadiationBurn", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 393, Name: AntiRadiationInjection, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + StackSize: 2000 + MarketPrice: 150, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + AddRadiation: -5, display: RadiationValue + Debuff: "RadiationSickness,RadiationPoisoning,RadiationBurn", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 308, Name: Medikit03, Ref: FoodTemplate #DecontKit + Mass: 0.4, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + MarketPrice: 570, display: true + Category: Medical + Info: bkiMedikit03, display: true + StackSize: 1000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + AddRadiation: -7, display: RadiationValue + # Buff: MedikitApplied + Debuff: "DermalParasite,EndoParasite,EndoParasite2,EndoParasite3,AlienParasite,RadiationBurn,RadiationPoisoning,RadiationSickness", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 329, Name: Medikit04, Ref: FoodTemplate #Health Pack small + Mass: 0.12, type: float, display: true, formatter: Kilogram + Volume: 0.11, type: float, display: true, formatter: Liter + Category: Medical + MarketPrice: 100, display: true + Info: bkiMedikit04, display: true #NewRequired + StackSize: 1000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Buff: MedikitApplied + # Debuff: "OpenWound", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 331, Name: Medikit05, Ref: FoodTemplate #Health Pack medium + Mass: 0.12, type: float, display: true, formatter: Kilogram + Volume: 0.11, type: float, display: true, formatter: Liter + Category: Medical + MarketPrice: 410, display: true + Info: bkiMedikit05, display: true #NewRequired + StackSize: 1000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Buff: MedikitApplied2 + # Debuff: "OpenWound", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 366, Name: Medikit06, Ref: FoodTemplate #Health Pack large + Mass: 0.12, type: float, display: true, formatter: Kilogram + Volume: 0.11, type: float, display: true, formatter: Liter + Category: Medical + MarketPrice: 890, display: true + Info: bkiMedikit06, display: true #NewRequired + StackSize: 1000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Buff: MedikitApplied3 + # Debuff: "OpenWound", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 379, Name: AntibioticOintment, Ref: FoodTemplate + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.05, type: float, display: true, formatter: Liter + Info: bkiMedicalItem, display: true + MarketPrice: 15, display: true + Category: Medical + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Debuff: "DermalBurn,Frostbite,Necrosis", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 388, Name: AntiToxicOintment, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + MarketPrice: 20, display: true + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Debuff: "PoisonBit,PoisonBit2,DermalParasite", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 387, Name: AntiRadiationOintment, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + MarketPrice: 25, display: true + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + AddRadiation: -1, display: RadiationValue + Debuff: RadiationBurn, display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 337, Name: RadiationImmunityShot, Ref: FoodTemplate + Info: bkiRadiationImmunity, display: true + MarketPrice: 90, display: true + Category: Medical + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -10 + AddStamina: 0 + AddFood: -50 + AddOxygen: 0 + SfxBegin: UseActions/exhale + Buff: "RadiationImmunity,BadTrip" + Buff_chance: "1,0.2" + } +} + +{ +Item Id: 390, Name: AntiParasitePills, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + StackSize: 2000 + MarketPrice: 90, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -10 + AddStamina: 0 + AddFood: -50 + AddOxygen: 0 + Debuff: "DermalParasite,EndoParasite,EndoParasite2", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 392, Name: AntiParasiteInjection, Ref: FoodTemplate + Info: bkiMedicalItem, display: true + Category: Medical + StackSize: 2000 + MarketPrice: 180, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 0 + AddOxygen: 0 + Debuff: "DermalParasite,AlienParasite,EndoParasite,EndoParasite2,EndoParasite3", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +{ +Item Id: 378, Name: EnergyPills, Ref: FoodTemplate + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + Info: bkiEnergyPills, display: true + MarketPrice: 30, display: true + Category: Medical + StackSize: 4000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -10 + AddStamina: 300 + AddFood: -50 + AddOxygen: 0 + Buff: "StimBoosterUp,BadTrip,PoisonFood" + Debuff: "Stunned", display: EffectiveAgainst + Buff_chance: "1,0.35,0.1" + SfxBegin: UseActions/playerSwallow + } +} + +{ +Item Id: 380, Name: AdrenalineShot, Ref: FoodTemplate + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.05, type: float, display: true, formatter: Liter + Info: bkiAdrenalineShot, display: true + Category: Medical + MarketPrice: 15, display: true + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 100 + AddFood: -20 + AddOxygen: 0 + Buff: "StaminaBoost,BadTrip" + Buff_chance: "1,0.25" + Debuff: "Stunned,PoisonBit,PoisonBit2", display: EffectiveAgainst + SfxBegin: UseActions/exhale + } +} + +# //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + +/* ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Wound: OpenWound,DermalBurn BrokenLeg(removed),FractureClosed,FractureOpen Mutilation +Disease: Hangover,BadTrip FeverAttack,Frostbite,PoisonFood Necrosis,OrganFailure +Infection: DermalParasite,EndoParasite InfectedWound,EndoParasite2 Sepsis,EndoParasite3 +Toxin: PoisonBite,Indigestion PoisonBite2,Intoxication + + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^*/ + +{ +Item Id: 342, Name: BrafusFungi, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Ingredients + MarketPrice: 2, display: true + FoodDecayTime: 25, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 500, display: HealthValue + AddStamina: -20, display: StaminaValue + AddFood: -50, display: FoodValue + AddOxygen: 0 + Buff: "Indigestion, PoisonFood" + Buff_chance: "1, 0.65" + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 343, Name: ConfettiMossScrapings, Ref: FoodTemplate + Info: bkiConsumable, display: true + MarketPrice: 1.5, display: true + Category: Food + FoodDecayTime: 36, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0, display: HealthValue + AddStamina: 150, display: StaminaValue + AddFood: -25, display: FoodValue + AddOxygen: 0 + Debuff: "PoisonBit,InfectedWound", display: EffectiveAgainst + Buff: "Indigestion, PoisonFood" + Buff_chance: "1, 0.4" + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 344, Name: FireMossScrapings, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 1.5, display: true + FoodDecayTime: 33, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -50, display: HealthValue + AddStamina: 385, display: StaminaValue + AddFood: 1, display: FoodValue + AddOxygen: 0 + Debuff: "DermalBurn,Frostbite,BadTrip", display: EffectiveAgainst + Buff: "Indigestion, PoisonFood" + Buff_chance: "1, 0.5" + SfxBegin: UseActions/playerEatingSoft + } +} + +{ +Item Id: 346, Name: EmergencyRations, Ref: FoodTemplate + Mass: 0.3, type: float, display: true, formatter: Kilogram + Volume: 0.50, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 393, display: true + Category: Food + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 50, display: HealthValue + AddStamina: 10, display: StaminaValue + AddFood: 300, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.05 + # AddRadiation: -2, display: RadiationValue + SfxBegin: UseActions/playerEatingCrunchy + } +} +{ +Item Id: 347, Name: PlantProtein, Ref: FoodTemplate + Mass: 0.1, type: float, display: true, formatter: Kilogram + Volume: 0.20, type: float, display: true, formatter: Liter + Info: bkiFoodRaw, display: true + MarketPrice: 1, display: true + Category: Food + FoodDecayTime: 24, display: true, type: int + FoodDecayedItem: RottenFood + StackSize: 4000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 5, display: HealthValue + AddStamina: 9, display: StaminaValue + AddFood: 18, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingCrunchy + Buff: "Indigestion, PoisonFood" + Buff_chance: "0.3, 0.05" + } +} +## Please do not rename - referenced in code +{ +Item Id: 348, Name: WaterJug, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Components + StackSize: 1000 + Mass: 30, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + ShowUser: Yes + MarketPrice: 32.5, display: true + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -10, display: HealthValue + AddStamina: 125, display: StaminaValue + AddFood: 0, display: FoodValue + AddTemperature: -10, display: TemperatureValue + AddOxygen: -5 + Debuff: Fire, display: EffectiveAgainst + Buff: Indigestion + Buff_chance: 0.3 + SfxBegin: UseActions/playerDrinking + } +} +{ +Item Id: 349, Name: WaterBottle, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Components + MarketPrice: 4.88, display: true + StackSize: 2000 + ShowUser: Yes + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 50, display: StaminaValue + AddFood: 0 + AddOxygen: 0 + AddTemperature: -8, display: TemperatureValue + Debuff: "Hangover,Fire", display: EffectiveAgainst + SfxBegin: UseActions/playerDrinking + } +} +# delete when save game break +{ +Item Id: 350, Name: Corn, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 30, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 5, display: HealthValue + AddStamina: 5, display: StaminaValue + AddFood: 35, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.75 + SfxBegin: UseActions/playerEatingCrunchy + } +} +{ +Item Id: 351, Name: MushroomBrown, Ref: FoodTemplate + Mass: 0.03, type: float, display: true, formatter: Kilogram + Volume: 0.09, type: float, display: true, formatter: Liter + Info: bkiFoodRaw, display: true + MarketPrice: 1, display: true + Category: Ingredients + FoodDecayTime: 15, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 25, display: HealthValue + AddStamina: 5, display: StaminaValue + AddFood: 5, display: FoodValue + AddOxygen: 0 + Buff: "Indigestion,PoisonFood,BadTrip" + Buff_chance: "1,0.35,0.25" + Debuff: PoisonBit, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 352, Name: AloeVera, Ref: FoodTemplate + Info: bkiIngredient, display: true + MarketPrice: 1, display: true + Category: Medical + FoodDecayTime: 40, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 40, display: HealthValue + AddStamina: 0, display: StaminaValue + AddFood: 5, display: FoodValue + AddOxygen: 0 + Debuff: DermalBurn, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +# delete when save game break +{ +Item Id: 353, Name: EggPlantParts, Ref: FoodTemplate + Info: bkiIngredient, display: true + MarketPrice: 1, display: true + Category: Ingredients + FoodDecayTime: 35, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV +} +# delete when save game break +{ +Item Id: 354, Name: AkuaFruit, Ref: FoodTemplate + Info: bkiConsumable, display: true + MarketPrice: 1, display: true + Category: Ingredients + FoodDecayTime: 25, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 10, display: HealthValue + AddStamina: 25, display: StaminaValue + AddFood: 50, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingFruit + } +} +{ +Item Id: 355, Name: AlienThorn, Ref: FoodTemplate + Info: bkiIngredient, display: true + MarketPrice: 1.5, display: true + Category: Medical + StackSize: 2000 + ShowUser: Yes +} +{ +Item Id: 356, Name: AlienSpikeLemon, Ref: FoodTemplate + Mass: 0.09, type: float, display: true, formatter: Kilogram + Volume: 0.18, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Ingredients + MarketPrice: 1.5, display: true + FoodDecayTime: 200, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 35, display: HealthValue + AddStamina: 10, display: StaminaValue + AddFood: 3, display: FoodValue + AddOxygen: 0 + AddTemperature: -5, display: TemperatureValue + AddRadiation: -1, display: RadiationValue + Buff: Indigestion + Buff_chance: 0.55 + Debuff: PoisonBit, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingFruit + } +} +{ +Item Id: 357, Name: MushroomSpiky, Ref: FoodTemplate + Mass: 0.18, type: float, display: true, formatter: Kilogram + Volume: 0.27, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 1.5, display: true + Category: Ingredients + FoodDecayTime: 20, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 150, display: HealthValue + AddStamina: -400, display: StaminaValue + AddFood: 50, display: FoodValue + AddOxygen: 0 + AddRadiation: -1, display: RadiationValue + Buff: Indigestion + Buff_chance: 0.1 + SfxBegin: UseActions/playerEatingSoft + } +} +# delete when save game break +{ +Item Id: 358, Name: Tomato, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Ingredients + MarketPrice: 1, display: true + FoodDecayTime: 35, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 5, display: HealthValue + AddStamina: 5, display: StaminaValue + AddFood: 25, display: FoodValue + AddOxygen: 0 + AddTemperature: -3, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.1 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 359, Name: Vegetables, Ref: FoodTemplate + Mass: 0.12, type: float, display: true, formatter: Kilogram + Volume: 0.21, type: float, display: true, formatter: Liter + MarketPrice: 1, display: true + Info: bkiConsumable, display: true + Category: Ingredients + FoodDecayTime: 35, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 10, display: HealthValue + AddStamina: 5, display: StaminaValue + AddFood: 20, display: FoodValue + AddOxygen: 0 + Buff: "Indigestion, PoisonFood" + Buff_chance: "0.35, 0.05" + SfxBegin: UseActions/playerEatingSoft + } + +} +{ +Item Id: 360, Name: Flour, Ref: FoodTemplate + Info: bkiIngredient, display: true + Category: Food + MarketPrice: 4, display: true + FoodDecayTime: 150, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: No +} + +{ +Item Id: 361, Name: MeatBurger, Ref: FoodTemplate + Mass: 0.41, type: float, display: true, formatter: Kilogram + Volume: 0.48, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 151.29, display: true + FoodDecayTime: 98, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 29, display: HealthValue + AddStamina: 25, display: StaminaValue + AddFood: 205, display: FoodValue + AddOxygen: 0 + AddTemperature: 3, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.35 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 362, Name: Ham, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 26.95, display: true + FoodDecayTime: 106, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 18, display: HealthValue + AddStamina: 5, display: StaminaValue + AddFood: 92, display: FoodValue + AddOxygen: 0 + AddTemperature: 2, display: TemperatureValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 363, Name: CannedVegetables, Ref: FoodTemplate + Mass: 0.6, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 45.95, display: true + FoodDecayTime: 307, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 30, display: HealthValue + AddStamina: 14, display: StaminaValue + AddFood: 106, display: FoodValue + AddOxygen: 0 +# AddRadiation: -1, display: RadiationValue + Buff: Indigestion + Buff_chance: 0.05 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 364, Name: CannedMeat, Ref: FoodTemplate + Mass: 0.6, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 72.95, display: true + FoodDecayTime: 282, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 25, display: HealthValue + AddStamina: 15, display: StaminaValue + AddFood: 112, display: FoodValue + AddOxygen: 0 + # AddRadiation: -1, display: RadiationValue + Buff: Indigestion + Buff_chance: 0.15 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 365, Name: Berries, Ref: FoodTemplate + Mass: 0.18, type: float, display: true, formatter: Kilogram + Volume: 0.61, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 1, display: true + Category: Ingredients + FoodDecayTime: 25, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 0.4, type: float + AddHealth: 5, display: HealthValue + AddStamina: 22, display: StaminaValue + AddFood: 10, display: FoodValue + AddOxygen: 0 + AddTemperature: -1, display: TemperatureValue + SfxBegin: UseActions/playerEatingFruit + Buff: "Indigestion" + Buff_chance: "0.01" + } +} +{ +Item Id: 367, Name: Pizza, Ref: FoodTemplate + Mass: 1.1, type: float, display: true, formatter: Kilogram + Volume: 4.8, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 139.95, display: true + Category: Food + FoodDecayTime: 56, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 56, display: HealthValue + AddStamina: 19, display: StaminaValue + AddFood: 243, display: FoodValue + AddOxygen: 0 + AddTemperature: 4, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.2 + SfxBegin: UseActions/playerEatingCrunchy + } +} +{ +Item Id: 369, Name: Sandwich, Ref: FoodTemplate + Mass: 0.07, type: float, display: true, formatter: Kilogram + Volume: 0.2, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 44.95, display: true + Category: Food + FoodDecayTime: 69, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 0.5, type: float + AddHealth: 11, display: HealthValue + AddStamina: 11, display: StaminaValue + AddFood: 35, display: FoodValue + AddOxygen: 0 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 370, Name: Sugarcane, Ref: FoodTemplate + Info: bkiIngredient, display: true + Category: Ingredients + MarketPrice: 1, display: true + StackSize: 2000 + ShowUser: Yes +} +{ +Item Id: 371, Name: Salami, Ref: FoodTemplate + Mass: 0.28, type: float, display: true, formatter: Kilogram + Volume: 0.3, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 16.95, display: true + FoodDecayTime: 166, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 8, display: HealthValue + AddStamina: 2, display: StaminaValue + AddFood: 47, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.3 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 372, Name: Ratatouille, Ref: FoodTemplate + Mass: 0.95, type: float, display: true, formatter: Kilogram + Volume: 1.1, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 61.95, display: true + FoodDecayTime: 24, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 36, display: HealthValue + AddStamina: 44, display: StaminaValue + AddFood: 142, display: FoodValue + AddOxygen: 0 + AddTemperature: 3, display: TemperatureValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 373, Name: Waffles, Ref: FoodTemplate + Mass: 0.22, type: float, display: true, formatter: Kilogram + Volume: 0.95, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 75.95, display: true + FoodDecayTime: 78, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 35, display: HealthValue + AddStamina: 12, display: StaminaValue + AddFood: 150, display: FoodValue + AddOxygen: 0 + AddTemperature: 2, display: TemperatureValue + # AddRadiation: -1, display: RadiationValue + SfxBegin: UseActions/playerEatingCrunchy + } +} +# delete when save game break +{ +Item Id: 374, Name: PumpkinCookie, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 70.95, display: true + FoodDecayTime: 97, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: NoButCSV + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 15, display: HealthValue + AddStamina: 15, display: StaminaValue + AddFood: 37, display: FoodValue + AddOxygen: 0 + AddRadiation: -1, display: RadiationValue + SfxBegin: UseActions/playerEatingCrunchy + } +} +{ +Item Id: 375, Name: PowerBar, Ref: FoodTemplate + Info: bkiPowerBar, display: true + FoodDecayTime: 90, display: true, type: int + Category: Food + LifetimeOnDrop: 1200 + MarketPrice: 4, display: true + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 20 + AddFood: 45, display: FoodValue + AddOxygen: 0 + # AddTemperature: -2, display: TemperatureValue + # Debuff: PoisonFood, display: EffectiveAgainst + Buff: Indigestion + Buff_chance: 0.85 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 376, Name: FriedVegetables, Ref: FoodTemplate + Mass: 0.1, type: float, display: true, formatter: Kilogram + Volume: 0.25, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + FoodDecayTime: 35, display: true, type: int + FoodDecayedItem: RottenFood + MarketPrice: 4.20, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 14, display: HealthValue + AddStamina: 6, display: StaminaValue + AddFood: 32, display: FoodValue + AddOxygen: 0 + AddTemperature: 1, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.15 + SfxBegin: UseActions/playerEatingCrunchy + } +} +{ +Item Id: 377, Name: Cereals, Ref: FoodTemplate + Mass: 0.8, type: float, display: true, formatter: Kilogram + Volume: 1.1, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + MarketPrice: 58.95, display: true + Category: Food + FoodDecayTime: 20, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 24, display: HealthValue + AddStamina: 12, display: StaminaValue + AddFood: 64, display: FoodValue + AddOxygen: 0 + AddTemperature: -3, display: TemperatureValue + Buff: Indigestion + Buff_chance: 0.1 + SfxBegin: UseActions/playerEatingCrunchy + } +} +{ +Item Id: 381, Name: FruitPie, Ref: FoodTemplate + Mass: 0.8, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Info: bkiConsumable, display: true + Category: Food + MarketPrice: 89.45, display: true + FoodDecayTime: 95, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 25, display: HealthValue + AddStamina: 30, display: StaminaValue + AddFood: 188, display: FoodValue + AddOxygen: 0 + # AddRadiation: -2, display: RadiationValue + Buff: Indigestion + Buff_chance: 0.25 + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 383, Name: HotBeverage, Ref: FoodTemplate + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Info: bkiHotBeverage, display: true + MarketPrice: 4.30, display: true + Category: Food + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 57, display: StaminaValue + AddFood: 0 + AddOxygen: 0 + AddTemperature: 8, display: TemperatureValue + Debuff: "Hangover,BadTrip,Indigestion", display: EffectiveAgainst + SfxBegin: UseActions/playerDrinking + } +} +{ +Item Id: 384, Name: NaturalSweetener, Ref: FoodTemplate + Mass: 0.1, type: float, display: true, formatter: Kilogram + Volume: 0.24, type: float, display: true, formatter: Liter + Info: bkiFoodRaw, display: true + MarketPrice: 1, display: true + Category: Ingredients + FoodDecayTime: 60, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 30, display: HealthValue + AddStamina: 25, display: StaminaValue + AddFood: 4, display: FoodValue + AddOxygen: 0 + Buff: Indigestion + Buff_chance: 0.02 + Debuff: "DermalBurn,Indigestion", display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 385, Name: FruitJuice, Ref: FoodTemplate + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Info: bkiFruitJuice, display: true + MarketPrice: 4.50, display: true + Category: Food + StackSize: 2000 + FoodDecayTime: 80, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 2, type: float + AddHealth: 41, display: HealthValue + AddStamina: 40, display: StaminaValue + AddFood: 0, display: FoodValue + AddOxygen: 0 + AddTemperature: -3, display: TemperatureValue + SfxBegin: UseActions/playerDrinking + Debuff: "Hangover,Indigestion", display: EffectiveAgainst + } +} +{ +Item Id: 386, Name: VegetableJuice, Ref: FoodTemplate + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + MarketPrice: 8, display: true + Info: bkiVegetableJuice, display: true + Category: Food + FoodDecayTime: 70, display: true, type: int + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 2, type: float + AddHealth: 43, display: HealthValue + AddStamina: 35, display: StaminaValue + AddFood: 0 + AddOxygen: 0 + AddTemperature: -3, display: TemperatureValue + Debuff: "Hangover,Indigestion", display: EffectiveAgainst + SfxBegin: UseActions/playerDrinking + } +} +{ +Item Id: 394, Name: BerryJuice, Ref: FoodTemplate + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Info: bkiBerryJuice, display: true + Category: Food + MarketPrice: 8, display: true + FoodDecayTime: 80, display: true, type: int + StackSize: 2000 + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 2, type: float + AddHealth: 25, display: HealthValue + AddStamina: 85, display: StaminaValue + AddFood: 0 + AddOxygen: 0 + AddTemperature: -3, display: TemperatureValue + SfxBegin: UseActions/playerDrinking + } +} +{ +Item Id: 395, Name: Buds, Ref: FoodTemplate + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.3, type: float, display: true, formatter: Liter + Info: bkiComponent, display: true + MarketPrice: 1, display: true + Category: Ingredients + FoodDecayTime: 100, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 5 + AddFood: 5, display: FoodValue + AddOxygen: 0 + # Debuff: Indigestion, display: EffectiveAgainst + # Buff_chance: 0.025 + # Debuff: PoisonBit, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 396, Name: Roots, Ref: FoodTemplate + Info: bkiConsumable, display: true + Category: Ingredients + MarketPrice: 1, display: true + FoodDecayTime: 200, display: true, type: int + FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 0 + AddFood: 12 + AddOxygen: 0 + # Debuff: Indigestion, display: EffectiveAgainst + # Buff_chance: 0.025 + # Debuff: PoisonBit, display: EffectiveAgainst + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 397, Name: EnergyDrink, Ref: FoodTemplate + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Info: bkiEnergyDrink, display: true + MarketPrice: 8, display: true + Category: Food + # FoodDecayTime: 200, display: true, type: int + # FoodDecayedItem: RottenFood + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: 0 + AddStamina: 150, display: StaminaValue + AddFood: 0 + AddOxygen: 0 + Buff: Hangover + Buff_chance: 0.025 + Debuff: Stunned, display: EffectiveAgainst + SfxBegin: UseActions/playerDrinking + } +} +{ Item Id: 398, Name: XP + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 1, type: float, display: false, formatter: Kilogram + ShowUser: No +} +{ Item Id: 399, Name: RP + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 1, type: float, display: false, formatter: Kilogram + ShowUser: No +} + # ========================================= + # NPC MELEE ATTACK AND WEAPONS id 500-599 + # ------------------------ + # Melee 500-549 + # ========================================= + +{ +Item Id: 500, Name: MeleeTemplate + Material: human + Meshfile: Entities/Misc/DummyPrefab + Canhold: false + Candrop: false + HoldType: 6 + StackSize: 1 + ShowUser: No + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 4.5 + Damage: 300 + + DamageMultiplier_1: 0, param1: wood|stone|rock|dirt|shield + } +} + +{ +Item Id: 501, Name: MeleeArmoredGolem, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3 + Damage: 300 + SfxBegin: Enemies/ArmoredGolem/ArmoredGolem_SfxAttack + Buff: "OpenWound,FractureClosed,FractureOpen,Mutilation" + Buff_chance: "0.4,0.25,0.1,0.01" + } +} +{ +Item Id: 502, Name: MeleeTotalHorror, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 3.5 + Damage: 130 + SfxBegin: Enemies/TotalHorror/TotalHorror_SfxAttack + Buff: "FractureOpen,AlienParasite" + Buff_chance: "0.2,0.05" + } +} +{ +Item Id: 503, Name: MeleeTalonGuardian, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 1.5, type: float + Range: 3.5 + Damage: 120 + Sphere: 0.5 + SfxBegin: Enemies/TalonGuardian/WhooshWideSlow + Buff: "OpenWound" + Buff_chance: "0.15" + } +} + + # free + +{ +Item Id: 505, Name: MeleeRockGolem, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 0.75, type: float + Range: 4 + Damage: 170 + Buff: "FractureClosed,FractureOpen,Mutilation" + Buff_chance: "0.25,0.1,0.01" + SfxBegin: Enemies/RockGolem/MONSTERGrowlDeep08aShort + } +} +{ +Item Id: 506, Name: MeleeCaveWormWhite, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 4.5 + Damage: 150 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "Frostbite,DermalParasite" + Buff_chance: "0.4,0.15" + } +} +{ +Item Id: 507, Name: MeleeMegalonopsGrey, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 6.5 + Damage: 100 + SfxBegin: Enemies/Megalonops/Megalonops_SfxAttack + Buff: OpenWound + Buff_chance: 0.1 + } +} +{ +Item Id: 508, Name: MeleeMegalonopsBrown, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 9 + Damage: 150 + SfxBegin: Enemies/Megalonops/HybridGrowl02 + Buff: OpenWound + Buff_chance: 0.2 + } +} +{ +Item Id: 509, Name: MeleeLizardMule, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 6.5 + Damage: 180 + SfxBegin: Enemies/LizardMule/LizardMule_SfxAttack + Buff: OpenWound + Buff_chance: 0.2 + } +} +{ +Item Id: 510, Name: MeleeLizardMuleBaby, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 3.5 + Damage: 90 + SfxBegin: Enemies/LizardMule/attack + Buff: OpenWound + Buff_chance: 0.1 + } +} +{ +Item Id: 511, Name: MeleeSpinosaurus, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 3, type: float + Range: 5 + Damage: 75 + SfxBegin: Enemies/Spinosaurus/HybridGrowl + Buff: OpenWound + Buff_chance: 0.1 + } +} +{ +Item Id: 512, Name: MeleeTriceratops, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 4 + Damage: 200 + SfxBegin: Enemies/Triceratops/Triceratops_SfxAttack + Buff: "OpenWound,FractureClosed,FractureOpen,Mutilation" + Buff_chance: "0.4,0.25,0.1,0.01" + } +} +{ +Item Id: 513, Name: MeleeGolem2, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 5 + Damage: 150 + SfxBegin: Enemies/GolemDesert/GolemDesert_SfxAttack + Buff: "FractureClosed,FractureOpen,Mutilation" + Buff_chance: "0.25,0.1,0.01" + } +} +{ +Item Id: 514, Name: MeleeWorm, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 100 + SfxBegin: Enemies/Worm/Worm_SfxAttack + Buff: "PoisonBit,OpenWound,DermalParasite" + Buff_chance: "0.2,0.15,0.1" + } +} +{ +Item Id: 515, Name: MeleeOtyugh, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 5 + Damage: 120 + SfxBegin: Enemies/Otyugh/Otyugh_SfxAttack + Buff: "PoisonBit,FractureOpen,EndoParasite" + Buff_chance: "0.2,0.15,0.1" + } +} +{ +Item Id: 516, Name: MeleePlantMonster, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 3.5 + Damage: 90 + SfxBegin: Enemies/PlantMonster/PlantMonster_SfxAttack + Buff: "PoisonBit,OpenWound" + Buff_chance: "0.2,0.15" + } +} +{ +Item Id: 517, Name: MeleeSlime, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 2 + Damage: 75 + SfxBegin: Enemies/Slime/Slime_SfxAttack + Buff: "Frostbite,DermalParasite" + Buff_chance: "0.4,0.15" + } +} +{ +Item Id: 518, Name: MeleeCrawler, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 100 + SfxBegin: Enemies/Crawler/Crawler_SfxAttack + Buff: "PoisonBit,OpenWound,DermalParasite" + Buff_chance: "0.3,0.15,0.1" + + DamageMultiplier_1: 2.5, param1: metal|metalhard + DamageMultiplier_2: 5, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} +{ +Item Id: 519, Name: MeleeAlienBug01, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 90 + SfxBegin: Enemies/AlienBugs/AlienBug01/AlienBug01_SfxAttack + Buff: "Stunned" + Buff_chance: "0.7" + } +} +{ +Item Id: 520, Name: MeleeAlienBug02, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 120 + SfxBegin: Enemies/AlienBugs/AlienBug02/AlienBug02_SfxAttack + Buff: "DermalParasite,InfectedWound" + Buff_chance: "0.3,0.15" + } +} +{ +Item Id: 521, Name: MeleeAlienBug03, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 200 + SfxBegin: Enemies/AlienBugs/AlienBug03/AlienBug03_SfxAttack + Buff: "DermalParasite,InfectedWound" + Buff_chance: "0.35,0.2" + } +} +{ +Item Id: 522, Name: MeleeWormLarge, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 5 + Damage: 150 + SfxBegin: Enemies/Worm/MonsterScreams-16 + Buff: "PoisonBit,FractureOpen,DermalParasite" + Buff_chance: "0.3,0.2,0.2" + } +} +{ +Item Id: 523, Name: MeleeWoodWalker, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 4 + Damage: 150 + SfxBegin: Enemies/Golem2/HybridGrowl + Buff: FractureOpen + Buff_chance: 0.25 + } +} +{ +Item Id: 524, Name: MeleeAlienBug04, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 3.5 + Damage: 150 + SfxBegin: Enemies/AlienBugs/AlienBug04/AlienBug04_SfxAttack + Buff: "DermalParasite,InfectedWound" + Buff_chance: "0.45,0.3" + } +} +{ +Item Id: 525, Name: MeleeSpider01, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2.5 + Damage: 20 + Sphere: 0.05 + SfxBegin: Enemies/Spiders/Spiders_SfxAttack + Buff: "Stunned" + Buff_chance: "0.05" + + DamageMultiplier_1: 5, param1: metal|metalhard + DamageMultiplier_2: 10, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} +{ +Item Id: 526, Name: MeleeSpider02, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 120 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "PoisonBit,Stunned" + Buff_chance: "0.05,0.02" + } +} +{ +Item Id: 527, Name: MeleeSpider03, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 180 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "PoisonBit,Stunned" + Buff_chance: "0.1,0.04" + + DamageMultiplier_1: 2.5, param1: metal|metalhard + DamageMultiplier_2: 5, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} +{ +Item Id: 528, Name: MeleeRaptor, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.0 + Damage: 50 + SfxBegin: Enemies/Raptor/Raptor_SfxAttack + Buff: "OpenWound,FractureOpen" + Buff_chance: "0.2,0.02" + } +} +{ +Item Id: 529, Name: MeleeHexapod, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 3.5 + Damage: 100 + SfxBegin: Enemies/Hexapod/Hexapod_SfxAttack + Buff: "DermalParasite,EndoParasite,AlienParasite" + Buff_chance: "0.3,0.15,0.05" + } +} +{ +Item Id: 530, Name: MeleeOverseer, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 5.5 + Damage: 120 + SfxBegin: Enemies/Overseer/Overseer_SfxAttack + Buff: "IncreaseRadiation,EndoParasite,AlienParasite" + Buff_chance: "0.3,0.15,0.05" + } +} +{ +Item Id: 531, Name: MeleeOviraptor, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 100 + SfxBegin: Enemies/Oviraptor/Oviraptor_SfxAttack + Buff: OpenWound + Buff_chance: 0.1 + } +} +{ +Item Id: 532, Name: MeleeParasaur, Ref: MeleeTemplate + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 6.5 + Damage: 100 + SfxBegin: Enemies/Parasaur/Parasaur_SfxAttack + Buff: OpenWound + Buff_chance: 0.1 + } +} +{ +Item Id: 533, Name: MeleeCaveWorm, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 3.5 + Damage: 100 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "PoisonBit,OpenWound,DermalParasite" + Buff_chance: "0.2,0.15,0.02" + + DamageMultiplier_1: 2.5, param1: metal|metalhard + DamageMultiplier_2: 5, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} + +{ +Item Id: 534, Name: MeleeSpider01Small, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2.5 + Damage: 10 + Sphere: 0.05 + SfxBegin: Enemies/Spiders/Spiders_SfxAttack + Buff: "PoisonBit,Stunned" + Buff_chance: "0.05,0.015" + + DamageMultiplier_1: 5, param1: metal|metalhard + DamageMultiplier_2: 10, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} +{ +Item Id: 535, Name: MeleeAssassin, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 2, type: float + Range: 2.5 + Damage: 100 + SfxBegin: Enemies/Hexapod/Hexapod_SfxAttack + Buff: "DermalParasite,EndoParasite,AlienParasite" + Buff_chance: "0.3,0.15,0.05" + } +} + +{ +Item Id: 536, Name: MeleeRaptorNight, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2.0 + Damage: 100 + SfxBegin: Enemies/Raptor/Raptor_SfxAttack + Buff: "OpenWound,FractureOpen" + Buff_chance: "0.2,0.02" + } +} + +{ +Item Id: 537, Name: MeleeTelluropod, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 100 + SfxBegin: Enemies/LizardMule/LizardMule_SfxAttack + Buff: OpenWound + Buff_chance: 0.2 + } +} + +{ +Item Id: 538, Name: MeleePangolin, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.8 + Damage: 200 + SfxBegin: Enemies/Triceratops/Triceratops_SfxAttack + Buff: "OpenWound,FractureClosed,FractureOpen,Mutilation" + Buff_chance: "0.4,0.25,0.1,0.01" + + DamageMultiplier_1: 2.5, param1: metal|metalhard + DamageMultiplier_2: 5, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} + +{ +Item Id: 539, Name: MeleeRipperDog, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3 + Damage: 150 + SfxBegin: Enemies/Worm/MonsterScreams-16 + Buff: "FractureOpen" + Buff_chance: "0.4" + } +} + +{ +Item Id: 540, Name: MeleeCreepy, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 1.8 + Damage: 50 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "FractureOpen" + Buff_chance: "0.4,0.15" + } +} + +{ +Item Id: 541, Name: MeleeShell, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2.3 + Damage: 50 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "FractureOpen" + Buff_chance: "0.4,0.15" + } +} +{ +Item Id: 542, Name: MeleeSpider01Baby, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 5 + Sphere: 0.05 + SfxBegin: Enemies/Spiders/Spiders_SfxAttack + # Buff: "PoisonBit,Stunned" + # Buff_chance: "0.05,0.015" + + #DamageMultiplier_1: 5, param1: metal|metalhard + #DamageMultiplier_2: 10, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} +{ +Item Id: 543, Name: MeleeSpider02Small, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 60 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "PoisonBit,Stunned" + Buff_chance: "0.08,0.02" + } +} +{ +Item Id: 544, Name: MeleeSpider02Baby, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 30 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "PoisonBit,Stunned" + # Buff_chance: "0.08,0.02" + } +} +{ +Item Id: 545, Name: MeleeSpider03Small, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 90 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "PoisonBit,Stunned" + Buff_chance: "0.1,0.04" + + DamageMultiplier_1: 2.5, param1: metal|metalhard + DamageMultiplier_2: 5, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} +{ +Item Id: 546, Name: MeleeSpider03Baby, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 45 + SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + Buff: "PoisonBit,Stunned" + # Buff_chance: "0.1,0.04" + + #DamageMultiplier_1: 2.5, param1: metal|metalhard + #DamageMultiplier_2: 5, param1: hull|hullarmored|hullcombat|concrete|woodblock + } +} +{ +Item Id: 547, Name: MeleeAlienBug02Small, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 60 + SfxBegin: Enemies/AlienBugs/AlienBug02/AlienBug02_SfxAttack + Buff: "DermalParasite,InfectedWound" + Buff_chance: "0.3,0.15" + } +} +{ +Item Id: 548, Name: MeleeAlienBug02Baby, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2 + Damage: 30 + SfxBegin: Enemies/AlienBugs/AlienBug02/AlienBug02_SfxAttack + # Buff: "DermalParasite,InfectedWound" + # Buff_chance: "0.3,0.15" + } +} + +{ +Item Id: 549, Name: MeleeHunter, Ref: MeleeTemplate + HoldType: 20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.0 + Damage: 5000 + SfxBegin: Enemies/Raptor/Raptor_SfxAttack + Buff: "OpenWound,FractureOpen" + Buff_chance: "0.5,0.2" + } +} + + +# Ranged 550-599 +{ +Item Id: 550, Name: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/minigunDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + ROF: 0.2, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + Automatic: true + Damage: 12 + AmmoCapacity: 200 + AmmoUnlimited: true + AmmoType: 30mmBullet, display: true + ReloadDelay: 3.1 + SfxBegin: Weapons/Enemies/Drones/minigunLowPitch_start + SfxLoop: Weapons/Enemies/Drones/minigunLowPitch_loop + SfxStop: Weapons/Enemies/Drones/minigunLowPitch_end + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/weapon_reload_standard + PfxMuzzle: turretMuzzleFire + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + + DamageMultiplier_1: 3, param1: metal + DamageMultiplier_2: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_3: 60, param1: hullarmored|hullcombat + DamageMultiplier_4: 0, param1: stone|dirt + DamageMultiplier_5: 4, param1: shield + } +} +# All _Large and double weapon models are old meshes for old drones +{ +Item Id: 551, Name: minigunDroneLarge, Ref: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/minigunDroneLargePrefab + { Child 0 + Class: Ranged + Damage: 18 + } +} +{ +Item Id: 552, Name: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/rocketlauncherDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 3.5, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 25 + AmmoUnlimited: true + AmmoType: rocketDrone, display: true + ReloadDelay: 3 + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 553, Name: rocketDrone + Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 75, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 30 + Range: 360 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} + +{ +Item Id: 554, Name: minigunDoubleDroneSmall, Ref: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/minigunDoubleDroneSmallPrefab + { Child 0 + Damage: 20 + PfxMuzzle: MuzzleFlashDoubleLarge2 + } +} +{ +Item Id: 555, Name: minigunDoubleDroneLarge, Ref: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/minigunDoubleDroneLargePrefab + { Child 0 + Class: Ranged + Damage: 24 + PfxMuzzle: MuzzleFlashDoubleLarge + } +} +{ +Item Id: 556, Name: plasmaDroneSmall, Ref: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/plasmaDronePrefab + { Child 0 + Class: LauncherSS + ROF: 2, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + AmmoType: plasmaCannonChargeDrone, display: true + SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 557, Name: plasmaCannonChargeDrone, Ref: rocketDrone + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotGreenAlien + { Child 0 + Class: Projectile + Speed: 70 + Range: 700 + } +} +{ +Item Id: 558, Name: pulseLaserDrone, Ref: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/laserDronePrefab + { Child 0 + Class: LauncherSS + ROF: 1.2, type: float + AmmoType: pulseLaserChargeDrone + SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + # SfxBegin: Weapons/SS/LaserSS/BLASTERLargeFastKickback(stereo) + } +} +{ +Item Id: 559, Name: pulseLaserChargeDrone, Ref: rocketDrone + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotGreenAlien + # Meshfile: Entities/Items/Weapons/Projectiles/LaserShot02 + { Child 0 + Class: Projectile + Damage: 50, display: true + BlastParticleIndex: 0 + BlastRadius: 0 + BlastDamage: 0 + Speed: 150 + Range: 625 + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + } +} +{ +Item Id: 560, Name: cannonDroneSmall, Ref: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/cannonDronePrefab + { Child 0 + Class: Ranged + ROF: 1, type: float + Automatic: false + Damage: 45 + AmmoType: 50Caliber, display: true + SfxBegin: Weapons/Player/Misc/Single/Cannon_2_short + SfxLoop: Misc/MuteSound + SfxStop: Misc/MuteSound + TracerPerBullet: 1 + } +} +{ +Item Id: 561, Name: cannonDoubleDroneLarge, Ref: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/cannonDoubleDroneLargePrefab + { Child 0 + Class: Ranged + ROF: 0.8, type: float + Automatic: false + Damage: 90 + AmmoType: 50Caliber, display: true + SfxBegin: Weapons/Player/Misc/Single/Cannon_2_short + SfxLoop: Misc/MuteSound + SfxStop: Misc/MuteSound + PfxMuzzle: MuzzleFlashDoubleLarge + TracerPerBullet: 1 + } +} +{ +Item Id: 562, Name: plasmaDroneLarge, Ref: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/plasmaDroneLargePrefab + { Child 0 + Class: LauncherSS + ROF: 1.3, type: float + AmmoType: plasmaCannonChargeDrone, display: true + SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + } +} +# SPACE DRONES +{ +Item Id: 563, Name: plasmaSpaceDrone, Ref: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/plasmaSpaceDronePrefab + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float + Range: 500 + AmmoType: plasmaChargeSpaceDrone, display: true + SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + } +} +{ +Item Id: 564, Name: plasmaChargeSpaceDrone + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketGreenPrefab + # Meshfile: Entities/Items/Weapons/Projectiles/alienRocketPrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 75, display: true + BlastParticleIndex: 7 + BlastRadius: 2, display: true + BlastDamage: 600, display: true + Speed: 150 + Range: 600 + Ballistic: false + + DamageMultiplier_1: 5, param1: hullarmored + DamageMultiplier_2: 5, param1: hullcombat + DamageMultiplier_3: 2, param1: shield + BlastDamageMultiplier_1: 0, param1: shield + } +} +{ +Item Id: 565, Name: laserSpaceDrone, Ref: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/laserSpaceDronePrefab + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float + Range: 500 + AmmoType: laserChargeSpaceDrone, display: true + SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + } +} +{ +Item Id: 566, Name: laserChargeSpaceDrone + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotRedAlien + # Meshfile: Entities/Items/Weapons/Projectiles/LaserImpulseRed01LongPrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 60, display: true + BlastParticleIndex: 12 + BlastRadius: 2, display: true + BlastDamage: 800, display: true + Speed: 200 + Range: 800 + Ballistic: false + + DamageMultiplier_1: 3, param1: hullarmored + DamageMultiplier_2: 3, param1: hullcombat + DamageMultiplier_3: 2, param1: shield + BlastDamageMultiplier_1: 0, param1: shield + } +} +# obsolete +{ Item Id: 567, Name: minigunTurretRobot + Meshfile: Entities/Items/Weapons/Enemies/minigunTurretRobotPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + ROF: 0.2, type: float + Range: 80, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: true + Damage: 12 + BulletSpread: 0.7 + AmmoCapacity: 200 + AmmoUnlimited: true + AmmoType: 30mmBullet, display: true + ReloadDelay: 3.1 + SfxBegin: Weapons/SS/MinigunSS/AK-47ShortBurst05SS_start + SfxLoop: Weapons/SS/MinigunSS/AK-47ShortBurst05SS_loop + SfxStop: Weapons/SS/MinigunSS/AK-47ShortBurst05SS_end + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/weapon_reload_standard + PfxMuzzle: SMGAutoMuzzleFire + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + + DamageMultiplier_1: 0, param1: dirt|stone|wood + } +} +{ +Item Id: 568, Name: ZiraxMinigunWeapon + Meshfile: Entities/Items/Weapons/Enemies/ZiraxMinigunPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + ROF: 0.15, type: float + Range: 80, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: true + Damage: 18, display: true + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: 8.3mmBullet, display: true + BulletSpread: 1.5, display: true + ReloadDelay: 6.3, display: true + # SfxBegin: Weapons/Player/AssaultRifle/AWDS_AR_HK416_start + # SfxLoop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_loop + # SfxStop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_end + # SfxBegin: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Begin + # SfxLoop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Loop + # SfxStop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Stop + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + SfxNoAmmo: Weapons/Misc/P90DryFire + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SMGAutoMuzzleFire + ParticleTransform: # Particle1 contains the destroy blocks effect + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 5, param1: metal|metalhard + } +} +{ +Item Id: 569, Name: ZiraxLaserWeapon, Ref: ZiraxMinigunWeapon + # Meshfile: Entities/Items/Weapons/Enemies/ZiraxGunPrefab + Meshfile: Entities/Items/Weapons/Enemies/ZiraxLaserPrefab + { Child 0 + Class: Ranged + ROF: 0.8, type: float, display: true, formatter: ROF + Range: 80, display: false + Damage: 75, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: false + SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxLoop: Weapons/Enemies/Zirax/mute # define new Zirax and not extend from MinigunZirax + SfxStop: Weapons/Enemies/Zirax/mute + PfxMuzzle: SniperMuzzleLaserGreen + ParticleTransform: # Particle1 contains the destroy blocks effect + Tracer: Weapons/Projectiles/TracerLaserShotGreenZirax + TracerPerBullet: 1 + BulletSpread: 0.8, display: true + TracerSpeed: 200 + # MaxFireAngle: 20 #(default is 5) + } +} +# Player used Sentry Gun +{ +Item Id: 570, Name: sentryGunSingle + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + ROF: 0.5, type: float, display: true, formatter: ROF + Range: 260, display: false + RangeSpace: 300, display: false + Automatic: false + Damage: 50, display: true + AmmoCapacity: 250, display: true + AutoReload: true + # AmmoUnlimited: true + AmmoType: 5.8mmBullet, display: true + ReloadDelay: 0.5, display: true + SfxBegin: Weapons/Player/Misc/Single/M16GAU5AinHeavySingle + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/weapon_reload_standard + PfxMuzzle: turretMuzzleFire + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + + DamageMultiplier_1: 0, param1: dirt|stone|rock|wood + DamageMultiplier_2: 0.05, param1: shield + } +} +# Alien Sentry Gun +{ +Item Id: 571, Name: sentryGunSingle2 + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + ROF: 0.8, type: float + Range: 260, display: false + RangeSpace: 300, display: false + Automatic: false + Damage: 30, display: true + AmmoCapacity: 100, display: true + # AmmoUnlimited: true + AmmoType: 30mmBullet, display: true + ReloadDelay: 0.8, display: true + SfxBegin: Weapons/Player/Misc/Single/ak47_fire + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/weapon_reload_standard + PfxMuzzle: MuzzleFlashDouble + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 0, param1: dirt|stone|rock|wood + DamageMultiplier_2: 0.05, param1: shield + } +} +# Alien Sentry Gun +{ +Item Id: 572, Name: sentryGunSingle3 + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + ROF: 0.6, type: float + Range: 260, display: false + RangeSpace: 300, display: false + Automatic: false + Damage: 30, display: true + AmmoCapacity: 100, display: true + # AmmoUnlimited: true + AmmoType: 5.8mmBullet, display: true + ReloadDelay: 0.6, display: true + SfxBegin: Weapons/Player/Misc/Single/g3_fire + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/weapon_reload_standard + PfxMuzzle: MuzzleFlashDouble + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + DamageMultiplier_1: 0, param1: dirt|stone|rock|wood + DamageMultiplier_2: 0.05, param1: shield + } +} +{ +Item Id: 573, Name: minigunDroneAttackBase, Ref: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/minigunDronePrefab + { Child 0 + ROF: 0.4, type: float + Damage: 15 + } +} +{ +Item Id: 574, Name: ZiraxShotgunWeapon + Meshfile: Entities/Items/Weapons/Enemies/ZiraxShotgunPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 30, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: false + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 1.6, display: true + Damage: 50, display: true + AmmoCapacity: 5, display: true + AmmoUnlimited: true + AmmoType: ShotgunShells, display: true + ReloadDelay: 3.5, display: true + SfxBegin: Weapons/Player/Shotgun/Stakeout + SfxNoAmmo: Weapons/Misc/PumpgunDryFire + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: MuzzleFlash2 + ParticleTransform: # Particle1 contains the destroy blocks effect + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: wood|stone|dirt + DamageMultiplier_3: 5, param1: metal|metalhard + } +} +{ +Item Id: 575, Name: ZiraxRocketWeapon + Meshfile: Entities/Items/Weapons/Enemies/ZiraxRocketPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: LauncherSS + AllowRemote: false + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 70, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + AmmoCapacity: 4, display: true + AmmoUnlimited: true + AmmoType: rocketDrone, display: true + BulletSpread: 0.2, display: true + ReloadDelay: 3.9, display: true + SfxBegin: Weapons/Player/RocketLauncher/FLYBYMissile02Slow + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: SniperMuzzleFire + ParticleTransform: # Particle1 contains the destroy blocks effect + # MaxFireAngle: 20 #(default is 5) + } +} +{ +Item Id: 576, Name: SpiderRangedWeapon + Meshfile: Entities/Items/Weapons/Enemies/RangedAttackPrefab + Material: human + HoldType: 20 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: LauncherSS + ROF: 0, type: float + Range: 20, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 120, display: true + BulletSpread: 0.4 + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: SlimeSpider, display: true + ReloadDelay: 0.0, display: true + # SfxBegin: Enemies/CaveWorm/CritterSFX-Short_3 + SfxBegin: Enemies/Spiders/Spiders_SfxAttack + # MaxFireAngle: 20 #we need bigger angle, as the spider firing angle is not perfectly aligned with look angle + } +} +{ +Item Id: 577, Name: SlimeSpider + Meshfile: Entities/Items/Weapons/Projectiles/SlimeSpiderPrefab + Material: human + HoldType: 0 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 120 + BlastParticleIndex: 0 + BlastRadius: 0 + BlastDamage: 0 + Speed: 20 + HomingSpeed: 1 + Range: 100 # must be higher than range given in weapon to ensure that particle effect is visible + Ballistic: false + Buff: Stunned + Buff_chance: 0.9 + + DamageMultiplier_1: 0, param1: dirt|stone + BlastDamageMultiplier_1: 0.05, param1: dirt|stone + } +} +{ +Item Id: 578, Name: TraderWeapon, Ref: ZiraxLaserWeapon + Meshfile: Entities/Items/Weapons/Enemies/TraderPistolPrefab + # Meshfile: Entities/Items/Weapons/Enemies/TraderGunPrefab + { Child 0 + Class: Ranged + ROF: 0.5, type: float, display: true, formatter: ROF + Range: 100, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 50, display: true + Automatic: false + SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxLoop: Weapons/Enemies/Zirax/mute # define new Zirax and not extend from MinigunZirax + SfxStop: Weapons/Enemies/Zirax/mute + PfxMuzzle: SniperMuzzleLaserGreen + Tracer: Weapons/Projectiles/TracerLaserShotGreenZirax + TracerPerBullet: 1 + TracerSpeed: 200 + } +} +{ +Item Id: 579, Name: CyborgSniperWeapon, Ref: ZiraxLaserWeapon + Meshfile: Entities/Items/Weapons/Enemies/CyborgSniperPrefab + { Child 0 + Class: Ranged + ROF: 0.75, type: float, display: true, formatter: ROF + Range: 100, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 75, display: true + Automatic: true + AmmoUnlimited: true + BulletSpread: 1.5, display: true + SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxLoop: Weapons/Enemies/Zirax/mute # define new Zirax and not extend from MinigunZirax + SfxStop: Weapons/Enemies/Zirax/mute + PfxMuzzle: SniperMuzzleLaserGreen + Tracer: Weapons/Projectiles/TracerLaserShotGreenZirax + TracerPerBullet: 2 + TracerSpeed: 200 + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 5, param1: metal|metalhard + } +} +{ +Item Id: 580, Name: AlienBugRangedWeapon + Meshfile: Entities/Items/Weapons/Enemies/RangedAttackAIPrefab + Material: human + HoldType: 20 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: LauncherSS + ROF: 0, type: float + Range: 30, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 35, display: true + BulletSpread: 0.3 + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: SlimeAlienBug, display: true + ReloadDelay: 6.3, display: true + SfxBegin: Enemies/AlienBugs/AlienBug03/AlienBug03_SfxAttack + # MaxFireAngle: 15 #we need bigger angle, as the bug firing angle is not perfectly aligned with look angle + } +} + + +{ +Item Id: 581, Name: SlimeAlienBug + Meshfile: Entities/Items/Weapons/Projectiles/SlimeAlienBugPrefab + Material: human + HoldType: 0 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 120 + BlastParticleIndex: 0 + BlastRadius: 0 + BlastDamage: 0 + Speed: 20 + HomingSpeed: 0.5 + Range: 100 + Ballistic: false + Buff: "DermalBurn,Stunned,IncreaseRadiation" + Buff_chance: "0.8,0.015,0.005" + + DamageMultiplier_1: 0, param1: dirt|stone + BlastDamageMultiplier_1: 0.05, param1: dirt|stone + } +} + +{ +Item Id: 582, Name: MechanoidWeapon, Ref: ZiraxMinigunWeapon + Meshfile: Entities/Items/Weapons/Enemies/MechanoidWeaponPrefab + { Child 0 + Class: Ranged + ROF: 1.0, type: float, display: true, formatter: ROF + Range: 50, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 75, display: true + Automatic: false + SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxLoop: Weapons/Enemies/Zirax/mute # define new Zirax and not extend from MinigunZirax + SfxStop: Weapons/Enemies/Zirax/mute + PfxMuzzle: SniperMuzzleLaserRed + Tracer: Weapons/Projectiles/TracerLaserShotRedZirax + TracerPerBullet: 1 + TracerSpeed: 100 + # MaxFireAngle: 30 # need a big value (default is 5) as the mechs have the weapon far from the center + } +} +{ +Item Id: 583, Name: AssaultMechWeapon, Ref: ZiraxMinigunWeapon + Meshfile: Entities/Items/Weapons/Enemies/AssaultMechWeaponPrefab + # # MaxFireAngle: 30 # need a big value (default is 5) as the mechs have the weapon far from the center > CHECK +} + +{ +Item Id: 584, Name: BaseAttackDroneWeapon, Ref: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/BaseAttackDroneWeapon + { Child 0 + Class: LauncherSS + ROF: 2, type: float + Range: 350 # Drone will attack from: Range*WeaponRangeFactor + AmmoType: rocketDrone, display: true + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + } +} + +{ +Item Id: 585, Name: BombLauncherWeapon + Meshfile: Entities/Enemies/Drones/Weapons/BombLauncherDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: BombLauncher + ROF: 3.5, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 25 + AmmoUnlimited: true + AmmoType: BombDrone, display: true + BulletsPerShot: 3 + BulletSpread: 5 + ReloadDelay: 3 + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} + +{ +Item Id: 586, Name: BombDrone + Meshfile: Entities/Items/Weapons/Projectiles/BombDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 800, display: true + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 1800, display: true + Speed: 30 + Range: 360 + Ballistic: false + + DamageMultiplier_1: 3, param1: metal + DamageMultiplier_2: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_3: 60, param1: hullarmored|hullcombat + DamageMultiplier_4: 0, param1: dirt|stone + # DamageMultiplier_5: , param1: shield + BlastDamageMultiplier_1: 0.05, param1: dirt|stone + } +} + +{ +Item Id: 587, Name: ZiraxSniperWeapon, Ref: ZiraxMinigunWeapon + Meshfile: Entities/Items/Weapons/Enemies/ZiraxSniperPrefab + { Child 0 + ROF: 2, type: float + Range: 110, display: false + Damage: 150, display: true + Automatic: false + SfxBegin: Weapons/Player/Sniper/Resistance-SniperRifle-02-Single_Shot-01 + SfxLoop: Weapons/Enemies/Zirax/mute # define new Zirax and not extend from MinigunZirax + SfxStop: Weapons/Enemies/Zirax/mute + ParticleTransform: # Particle1 contains the destroy blocks effect + TracerPerBullet: 1 + BulletSpread: 0.05, display: true + TracerSpeed: 200 + # MaxFireAngle: 20 #(default is 5) + } +} +{ +Item Id: 588, Name: AlienBug04RangedWeapon + Meshfile: Entities/Items/Weapons/Enemies/RangedAttackPrefab + Material: human + StackSize: 1 + Category: Weapons/Items + ShowUser: No + HoldType: 20 + + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: LauncherSS + ROF: 0, type: float + Range: 30, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 35, display: true + BulletSpread: 0.4 + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: SlimeAlienBug, display: true + ReloadDelay: 6.3, display: true + SfxBegin: Enemies/AlienBugs/AlienBug03/AlienBug03_SfxAttack + # MaxFireAngle: 15 #we need bigger angle, as the bug may be not perfectly rotated when firing + } +} +{ +Item Id: 589, Name: stunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/plasmaDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 0.5, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + AmmoType: stunCannonChargeDrone, display: true + AmmoCapacity: 60 + AmmoUnlimited: true + ReloadDelay: 3 + SfxBegin: Weapons/EnergyWeapons/weapon_laser_009 + # PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 590, Name: DroneBaseAttackMinigunWeapon, Ref: minigunDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/minigunDronePrefab + { Child 0 + Damage: 12 + Range: 400 # Drone will attack from: Range*WeaponRangeFactor + + DamageMultiplier_1: 5, param1: metal + DamageMultiplier_2: 30, param1: concrete|woodblock|hull + DamageMultiplier_3: 40, param1: metalhard + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 2, param1: shield + } +} + +{ +Item Id: 591, Name: DroneBaseAttackRocketWeapon, Ref: rocketlauncherDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/BaseAttackDroneWeapon + { Child 0 + ROF: 2, type: float + Range: 500 # Drone will attack from: Range*WeaponRangeFactor + AmmoType: RocketBaseAttackDrone, display: true + } +} + +{ +Item Id: 592, Name: RocketBaseAttackDrone + Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 70, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 30 + Range: 500 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 50, param1: concrete|woodblock|hull + DamageMultiplier_4: 60, param1: metalhard|hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0, param1: stone|dirt + BlastDamageMultiplier_3: 2, param1: metalhard|hullarmored|hullcombat + BlastDamageMultiplier_4: 0, param1: shield + } +} +{ +Item Id: 593, Name: DestroyBlocksWeapon + Meshfile: Entities/Items/Weapons/Enemies/DestroyBlocksWeaponPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: DestroyBlocks + Automatic: true + ROF: 1, type: float # can be changed? + Damage: 45, display: true + # SfxBegin: Weapons/Enemies/Zirax/EMP_StartWave_02 + # SfxLoop: Weapons/Enemies/Zirax/EMP_StartWave_02 + SfxBegin: Weapons/Player/Tools/WeaponLightning_start + SfxLoop: Weapons/Player/Tools/WeaponLightning_loop + # ParticleTransform: Muzzle/Particle2 + Tracer: + + DamageMultiplier_1: 10, param1: metal|metalhard + DamageMultiplier_2: 20, param1: hull|hullarmored|hullcombat|concrete|woodblock + DamageMultiplier_3: 0, param1: stone|dirt + DamageMultiplier_4: 0.7, param1: shield + } +} +{ +Item Id: 594, Name: EMPAttackBlocksWeapon + Meshfile: Entities/Items/Weapons/Enemies/EMPAttackBlocksWeaponPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: EMPAttackBlocks + ROF: 6, type: float + Range: 150, display: true + Damage: 30, display: true + SfxBegin: Weapons/Enemies/Zirax/EMP_StartWave_02 + PfxMuzzle: EMPForceField + # PfxMuzzle: PlasmaMuzzleFlashPrefab + Tracer: + } +} +{ +Item Id: 595, Name: TalonCrossbowWeapon + Meshfile: Entities/Items/Weapons/Enemies/TalonCrossbowPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: LauncherSS + AllowRemote: false + ROF: 2, type: float, display: true, formatter: ROF + Range: 75, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: false + Damage: 70, display: true + AmmoCapacity: 20, display: true + AmmoUnlimited: true + AmmoType: CrossbowBolt, display: true + BulletSpread: 0.5, display: true + ReloadDelay: 6.3, display: true + SfxBegin: Weapons/Enemies/Talon/CrossbowShot + PfxMuzzle: MuzzleCrossbowSmoke + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 50, param1: metal|metalhard # do we need it? + DamageMultiplier_3: 60, param1: hull|hullarmored|hullcombat|concrete|woodblock # do we need it? + } +} +{ +Item Id: 596, Name: CrossbowBolt + Meshfile: Entities/Items/Weapons/Projectiles/CrossbowBoltPrefab + Material: human + HoldType: 0 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 120 + BlastParticleIndex: 0 + BlastRadius: 0 + BlastDamage: 0 + Speed: 20 + Range: 80 # must be higher than range given in weapon to ensure that particle effect is visible + Ballistic: True + + DamageMultiplier_1: 0, param1: dirt|stone + BlastDamageMultiplier_1: 0.05, param1: dirt|stone + } +} +{ +Item Id: 597, Name: TalonShamaneDrainPower + Meshfile: Entities/Items/Weapons/Enemies/TalonShamanStaffPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 2.3 + Damage: 50 + SfxBegin: Enemies/TalonGuardian/WhooshWideSlow + Buff: "FractureOpen" + Buff_chance: "0.4,0.15" + } +} + +{ +Item Id: 598, Name: MeleeTalonGuardianBAI, Ref: MeleeTalonGuardian + HoldType:20 + { Child 0 + Class: Melee + ROF: 0, type: float + Range: 3.5 + Damage: 120 + Sphere: 0.5 + SfxBegin: Enemies/TalonGuardian/WhooshWideSlow + Buff: "OpenWound" + Buff_chance: "0.15" + } +} +{ +Item Id: 599, Name: stunCannonChargeDrone + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotYellowAlien + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 0, display: true + BlastParticleIndex: 7 + BlastRadius: 0, display: true + BlastDamage: 0, display: true + Speed: 100 + Range: 500 + Ballistic: false + Buff: "Stunned" + Buff_chance: "0.9" + # DamageMultiplier_1: 3, param1: shield + # BlastDamageMultiplier_1: 0, param1: shield + } +} +## CATEGORIE: PLAYER ARMOR id 600 - 619 +## max 4 slots per armor in NrSlots +## max 15 possible boost per armor in SlotItems + +## Please do not rename - referenced in code +{ +Item Id: 600, Name: ArmorLight + Class: Armor + RecipeName: LightArmor + MarketPrice: 4397, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 25, type: float, display: false, formatter: Kilogram + Volume: 24, type: float, display: true, formatter: Liter + VolumeCapacity: 100, type: float, display: true, formatter: Liter + HoldType: 0 + StackSize: 1 + Canhold: false + Category: Weapons/Items + Info: bkiPlayerArmorLight, display: true + + Durability: 400, display: true + DegradationFac: 0.08, type: float, display: false # do not display anymore + Oxygen: 375, type: int, display: true # OxygenConsumptionPerMinute = 12.5f; => consume 500 units of oxygen in 40 min + Armor: 100, type: int, display: true + Heat: 10, type: int, display: true + Cold: 10, type: int, display: true + Radiation: 2, type: int, display: true + FallDamageFac: 0.40, type: float, display: true + PowerFac: 0.66, type: float, display: true # better: 0.5 + SpeedFac: 1, type: float, display: true # better: 0.5 + JumpFac: 0.67, type: float, display: true + JetpackFac: 1.20, type: float, display: true # no armor : 2.5 units removed per tick. armor : 5* JetpackFac per tick + FoodFac: 1.44, type: float, display: true # calculations are based on 70 minutes + StaminaFac: 1.21, type: float, display: true # calculations are based on 20 stamina per second while running + NrSlots: 2, type: int, display: true + SlotItems: "InsulationBoost, ArmorBoost, JetpackBoost, MultiBoost, OxygenBoost, MobilityBoost, RadiationBoost, EVABoost, TransportationBoost,EVABoostEpic,InsulationBoostEpic,RadiationBoostEpic", display: true + + UnlockCost: 2, display: true + UnlockLevel: 5, display: true + TechTreeNames: Weapons + IsEquippableWithoutLocker: true + IsBoostEquippableWithoutLocker: true +} +## Please do not rename - referenced in code +{ +Item Id: 601, Name: ArmorMedium + Class: Armor + RecipeName: MediumArmor + MarketPrice: 50000, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 44, type: float, display: false, formatter: Kilogram + Volume: 34, type: float, display: true, formatter: Liter + VolumeCapacity: 150, type: float, display: true, formatter: Liter + HoldType: 0 + StackSize: 1 + Canhold: false + Category: Weapons/Items + Info: bkiPlayerArmor, display: true + + Durability: 600, display: true + DegradationFac: 0.08, type: float, display: false # do not display anymore + Oxygen: 550, type: int, display: true + Armor: 150, type: int, display: true + Heat: 20, type: int, display: true + Cold: 20, type: int, display: true + Radiation: 4, type: int, display: true + FallDamageFac: 0.60, type: float, display: true + PowerFac: 0.53, type: float, display: true # better: 0.2 + SpeedFac: 0.9, type: float, display: true # better: 0.2 + JumpFac: 0.55, type: float, display: true + JetpackFac: 1.44, type: float, display: true + FoodFac: 1.73, type: float, display: true + StaminaFac: 1.33, type: float, display: true + NrSlots: 3, type: int, display: true + SlotItems: "InsulationBoost, ArmorBoost, JetpackBoost, MultiBoost, OxygenBoost, MobilityBoost, RadiationBoost, EVABoost, TransportationBoost,EVABoostEpic,InsulationBoostEpic,RadiationBoostEpic", display: true +} +## Please do not rename - referenced in code +{ +Item Id: 602, Name: ArmorHeavy + Class: Armor + RecipeName: HeavyArmor + MarketPrice: 90000, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 52, type: float, display: false, formatter: Kilogram + Volume: 41, type: float, display: true, formatter: Liter + VolumeCapacity: 200, type: float, display: true, formatter: Liter + HoldType: 0 + StackSize: 1 + Canhold: false + Category: Weapons/Items + Info: bkiPlayerArmor, display: true + + Durability: 800, display: true + DegradationFac: 0.08, type: float, display: false # do not display anymore + Oxygen: 725, type: int, display: true + Armor: 200, type: int, display: true + Heat: 30, type: int, display: true + Cold: 30, type: int, display: true + Radiation: 6, type: int, display: true + FallDamageFac: 0.7, type: float, display: true + PowerFac: 0.43, type: float, display: true # better: 0.085 + SpeedFac: 0.7, type: float, display: true # better: 0.085 + JumpFac: 0.45, type: float, display: true + JetpackFac: 1.73, type: float, display: true + FoodFac: 2.5, type: float, display: true + StaminaFac: 1.46, type: float, display: true + NrSlots: 4, type: int, display: true + SlotItems: "InsulationBoost, ArmorBoost, JetpackBoost, MultiBoost, OxygenBoost, MobilityBoost, RadiationBoost, EVABoost, TransportationBoost,EVABoostEpic,InsulationBoostEpic,RadiationBoostEpic", display: true +} + +# Not yet used in game +## Please do not rename - referenced in code +{ Item Id: 603, Name: ArmorLightEpic, Ref: ArmorLight + RecipeName: LightArmorEpic + MarketPrice: 8000, display: true + + UnlockLevel: 1 + TechTreeNames: Hidden + ShowUser: No +} + +# Not yet used in game +## Please do not rename - referenced in code +{ Item Id: 604, Name: ArmorMediumEpic, Ref: ArmorMedium + RecipeName: MediumArmorEpic + MarketPrice: 75000, display: true + Durability: 1500, display: true + Oxygen: 860, type: int, display: true + Armor: 300, type: int, display: true + Heat: 40, type: int, display: true + Cold: 55, type: int, display: true + Radiation: 12, type: int, display: true + FallDamageFac: 0.65, type: float, display: true + + Mass: 18, type: float, display: false, formatter: Kilogram + Volume: 4, type: float, display: true, formatter: Liter + + UnlockLevel: 1 + TechTreeParent: # set null in case a custom Config.ecf puts referenced ArmorMedium in TechTree + TechTreeNames: Hidden + ShowUser: No +} + +# Used in game (can be found in loot) +## Please do not rename - referenced in code +{ +Item Id: 605, Name: ArmorHeavyEpic, Ref: ArmorHeavy + RecipeName: HeavyArmorEpic + MarketPrice: 18000, display: true + + Durability: 1000, display: true + Oxygen: 1000, type: int, display: true + Armor: 500, type: int, display: true + Heat: 150, type: int, display: true + Cold: 250, type: int, display: true + Radiation: 15, type: int, display: true + FallDamageFac: 0.7, type: float, display: true + PowerFac: 0.7, type: float, display: true # better: 0.085 + SpeedFac: 0.8, type: float, display: true # better: 0.085 + JumpFac: 0.7, type: float, display: true + JetpackFac: 1.1, type: float, display: true + FoodFac: 0.9, type: float, display: true + StaminaFac: 1.1, type: float, display: true + NrSlots: 0, type: int, display: true + + Mass: 100, type: float, display: false, formatter: Kilogram + Volume: 60, type: float, display: true, formatter: Liter + + UnlockLevel: 1 + TechTreeParent: # set null in case a custom Config.ecf puts referenced ArmorHeavy in TechTree + TechTreeNames: Hidden + # ShowUser: No +} + +# CATEGORIE: ARMOR BOOSTS id 620 - 699 + +{ +Item Id: 620, Name: ArmorBoostTemplate + Class: ArmorBoost + Info: bkiArmorBoost, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 2.5, type: float, display: false, formatter: Kilogram + Volume: 2, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No +} +{ +Item Id: 621, Name: ArmorBoost, Ref: ArmorBoostTemplate + MarketPrice: 650, display: true + Armor: 50, type: int, display: true + Heat: -3, type: int, display: true + Cold: -2, type: int, display: true + Radiation: 1, type: int, display: true + PowerFac: -0.04, type: float, display: true + SpeedFac: -0.03, type: float, display: true + StaminaFac: 0.2, type: float, display: true + JetpackFac: 0.2, type: float, display: true + JumpFac: -0.04, type: float, display: true + FoodFac: 0.26, type: float, display: true + + Volume: 2.5, type: float, display: true, formatter: Liter + Mass: 5.5, type: float, display: true, formatter: Kilogram + ShowUser: Yes +} +{ +Item Id: 622, Name: JetpackBoost, Ref: ArmorBoostTemplate + JetpackFac: -0.25, type: float, display: true + + Volume: 2.25, type: float, display: true, formatter: Liter + Mass: 4.25, type: float, display: true, formatter: Kilogram + MarketPrice: 450, display: true + ShowUser: Yes +} +{ +Item Id: 623, Name: MultiBoost, Ref: ArmorBoostTemplate + Armor: 15, type: int, display: true + Oxygen: 25, type: int, display: true + Radiation: 1, type: int, display: true + Heat: 6, type: int, display: true + Cold: 3, type: int, display: true + PowerFac: 0.05, type: float, display: true + SpeedFac: 0.05, type: float, display: true + StaminaFac: -0.05, type: float, display: true + JetpackFac: -0.10, type: float, display: true + JumpFac: 0.05, type: float, display: true + FoodFac: -0.25, type: float, display: true + + Volume: 3.5, type: float, display: true, formatter: Liter + Mass: 7.5, type: float, display: true, formatter: Kilogram + MarketPrice: 500, display: true + ShowUser: Yes +} +{ +Item Id: 624, Name: OxygenBoost, Ref: ArmorBoostTemplate + Armor: -10, type: int, display: true + Oxygen: 150, type: int, display: true + PowerFac: -0.03, type: float, display: true + SpeedFac: -0.03, type: float, display: true + + Volume: 10, type: float, display: true, formatter: Liter + Mass: 2.5, type: float, display: true, formatter: Kilogram + MarketPrice: 300, display: true + ShowUser: Yes +} +{ +Item Id: 625, Name: InsulationBoost, Ref: ArmorBoostTemplate + Heat: 50, type: int, display: true + Cold: 25, type: int, display: true + + Volume: 2.5, type: float, display: true, formatter: Liter + Mass: 3.5, type: float, display: true, formatter: Kilogram + MarketPrice: 350, display: true + ShowUser: Yes +} +{ +Item Id: 626, Name: MobilityBoost, Ref: ArmorBoostTemplate + Armor: -25, type: int, display: true + PowerFac: 0.15, type: float, display: true + SpeedFac: 0.10, type: float, display: true + StaminaFac: -0.05, type: float, display: true + JumpFac: 0.15, type: float, display: true + FoodFac: 0.25, type: float, display: true + + Volume: 2.2, type: float, display: true, formatter: Liter + Mass: 1.7, type: float, display: true, formatter: Kilogram + MarketPrice: 350, display: true + ShowUser: Yes +} +{ +Item Id: 627, Name: RadiationBoost, Ref: ArmorBoostTemplate + Radiation: 7, type: int, display: true + PowerFac: -0.02, type: float, display: true + SpeedFac: -0.05, type: float, display: true + + Volume: 7.5, type: float, display: true, formatter: Liter + Mass: 9.5, type: float, display: true, formatter: Kilogram + MarketPrice: 400, display: true + ShowUser: Yes +} +{ +Item Id: 628, Name: EVABoost, Ref: ArmorBoostTemplate + Armor: -80, type: int, display: true + Heat: -15, type: int, display: true + Cold: 190, type: int, display: true + JetpackFac: 0.1, type: float, display: true + PowerFac: -0.2, type: float, display: true + SpeedFac: -0.05, type: float, display: true + + ShowUser: Yes + Mass: 34.5, type: float, display: false, formatter: Kilogram + Volume: 14.5, type: float, display: true, formatter: Liter + MarketPrice: 250, display: true +} +{ +Item Id: 629, Name: TransportationBoost, Ref: ArmorBoostTemplate + VolumeCapacity: 250, type: float, display: true, formatter: Liter + MarketPrice: 300, display: true + FoodFac: 0.05, type: float, display: true + StaminaFac: 0.1, type: float, display: true + + ShowUser: No +} + +# Epic Armor Booster +{ Item Id: 650, Name: ArmorBoostEpic, Ref: ArmorBoostTemplate + MarketPrice: 650, display: true + Armor: 150, type: int, display: true + Heat: -1, type: int, display: true + Cold: -1, type: int, display: true + Radiation: 2, type: int, display: true + PowerFac: -0.04, type: float, display: true + SpeedFac: -0.03, type: float, display: true + StaminaFac: 0.2, type: float, display: true + JetpackFac: 0.2, type: float, display: true + JumpFac: -0.04, type: float, display: true + FoodFac: 0.26, type: float, display: true + + Volume: 2.5, type: float, display: true, formatter: Liter + Mass: 5.5, type: float, display: true, formatter: Kilogram + ShowUser: Yes +} +{ Item Id: 651, Name: JetpackBoostEpic, Ref: ArmorBoostTemplate + JetpackFac: -0.67, type: float, display: true + + Volume: 2.25, type: float, display: true, formatter: Liter + Mass: 4.25, type: float, display: true, formatter: Kilogram + MarketPrice: 450, display: true + ShowUser: Yes +} +{ Item Id: 652, Name: MultiBoostEpic, Ref: ArmorBoostTemplate + Armor: 60, type: int, display: true + Oxygen: 100, type: int, display: true + Radiation: 4, type: int, display: true + Heat: 25, type: int, display: true + Cold: 15, type: int, display: true + PowerFac: 0.05, type: float, display: true + SpeedFac: 0.05, type: float, display: true + StaminaFac: -0.05, type: float, display: true + JetpackFac: -0.10, type: float, display: true + JumpFac: 0.05, type: float, display: true + FoodFac: -0.25, type: float, display: true + + Volume: 3.5, type: float, display: true, formatter: Liter + Mass: 7.5, type: float, display: true, formatter: Kilogram + MarketPrice: 500, display: true + ShowUser: Yes +} +{ Item Id: 653, Name: OxygenBoostEpic, Ref: ArmorBoostTemplate + Armor: -5, type: int, display: true + Oxygen: 300, type: int, display: true + PowerFac: -0.03, type: float, display: true + SpeedFac: -0.03, type: float, display: true + + Volume: 10, type: float, display: true, formatter: Liter + Mass: 2.5, type: float, display: true, formatter: Kilogram + MarketPrice: 300, display: true + ShowUser: Yes +} +{ Item Id: 654, Name: InsulationBoostEpic, Ref: ArmorBoostTemplate + Heat: 100, type: int, display: true + Cold: 50, type: int, display: true + + Volume: 2.5, type: float, display: true, formatter: Liter + Mass: 3.5, type: float, display: true, formatter: Kilogram + MarketPrice: 350, display: true + ShowUser: Yes +} +{ Item Id: 655, Name: MobilityBoostEpic, Ref: ArmorBoostTemplate + Armor: -15, type: int, display: true + PowerFac: 0.2, type: float, display: true + SpeedFac: 0.15, type: float, display: true + StaminaFac: -0.05, type: float, display: true + JumpFac: 0.2, type: float, display: true + FoodFac: 0.05, type: float, display: true + + Volume: 2.2, type: float, display: true, formatter: Liter + Mass: 1.7, type: float, display: true, formatter: Kilogram + MarketPrice: 350, display: true + ShowUser: Yes +} +{ Item Id: 656, Name: RadiationBoostEpic, Ref: ArmorBoostTemplate + Radiation: 15, type: int, display: true + PowerFac: -0.02, type: float, display: true + SpeedFac: -0.05, type: float, display: true + + MarketPrice: 400, display: true + ShowUser: Yes +} +{ Item Id: 657, Name: EVABoostEpic, Ref: ArmorBoostTemplate + Armor: -20, type: int, display: true + Heat: -4, type: int, display: true + Cold: 350, type: int, display: true + JetpackFac: 0.1, type: float, display: true + PowerFac: -0.2, type: float, display: true + SpeedFac: -0.05, type: float, display: true + + ShowUser: Yes + Mass: 34.5, type: float, display: false, formatter: Kilogram + Volume: 14.5, type: float, display: true, formatter: Liter + MarketPrice: 250, display: true +} + +{ +Item Id: 658, Name: TransportationBoostEpic, Ref: ArmorBoostTemplate + VolumeCapacity: 500, type: float, display: true, formatter: Liter + MarketPrice: 300, display: true + FoodFac: 0.1, type: float, display: true + StaminaFac: 0.5, type: float, display: true + + ShowUser: Yes +} +# CATEGORIE: ARMOR EQUIPMENT id 620 - 699 + +{ +Item Id: 700, Name: NightVision + # Class: ArmorBoost + Meshfile: Entities/Misc/DummyPrefab + PickupToToolbar: true + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Volume: 2.5, type: float, display: true, formatter: Liter + Mass: 1.3, type: float, display: true, formatter: Kilogram + Info: bkiNightVision, display: true + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + IsNightVisionItem: true + MarketPrice: 2250, display: true +} +{ +Item Id: 701, Name: OreScanner + # Class: ArmorBoost + Info: bkiOreScanner, display: true + PickupToToolbar: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 0.78, type: float, display: true, formatter: Kilogram + Volume: 1.25, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + IsOreScannerItem: true + MarketPrice: 1250, display: true + + UnlockCost: 3, display: true + UnlockLevel: 3, display: true + TechTreeParent: RadarSuitT1 + TechTreeNames: Tools +} + +{ +Item Id: 702, Name: RadarSuitT1 + Info: bkiDetector, display: true + MarketPrice: 250, display: true + Meshfile: Entities/Items/Tools/DetectorToolPrefab + PickupToToolbar: true + LifetimeOnDrop: 1200 + #DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + Mass: 1.3, type: float, display: true, formatter: Kilogram + Volume: 1.7, type: float, display: true, formatter: Liter + # Info: bkiNightVision, display: true + # Canhold: false + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + IsRadarItem: true + Range: 650, type: int, display: true + RangeSpace: 1250, type: int, display: true + + UnlockCost: 0, display: true + UnlockLevel: 1, display: true + # TechTreeParent: SurvivalTool + TechTreeNames: Tools + + { Child 0 + ROF: 5, type: float + Class: RadarScan + Range: 650 + RangeSpace: 1250 + DiscoveryFac: 0.24 + ShowObjectTime: 30 + AllowAt: "UnderWater, Planet, Space" + } +} +{ Item Id: 703, Name: RadarSuitDebug, Ref: RadarSuitT1 + Range: 5000, type: int, display: true + RangeSpace: 50000, type: int, display: true + UnlockCost: 0 + UnlockLevel: 20 + TechTreeNames: Hidden +} + +{ +Item Id: 704, Name: DetectorHVWeapon + Meshfile: Entities/Misc/DummyPrefab + CustomIcon: DetectorSVT1 + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Category: Weapons/Items + ShowUser: No + Canhold: false + Material: metalweapon + HoldType: 0 + IsRadarItem: true + # CustomIcon: DetectorSVT1 + { Child 0 + ROF: 5, type: float + Class: RadarScan + Range: 2500 + RangeSpace: 10000 + DiscoveryFac: 0.12 + ShowObjectTime: 30 + AmmoCapacity: 0, display: false + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + AllowAt: "UnderWater, Planet, Space" + } +} + +{ +Item Id: 705, Name: DetectorSVWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Category: Weapons/Items + ShowUser: No + Canhold: false + Material: metalweapon + HoldType: 0 + IsRadarItem: true + CustomIcon: DetectorSVT1 + { Child 0 + ROF: 5, type: float + Class: RadarScan + Range: 3500 + RangeSpace: 14000 + DiscoveryFac: 0.12 + ShowObjectTime: 30 + AmmoCapacity: 0, display: false + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + AllowAt: "UnderWater, Planet, Space" + } +} + +{ +Item Id: 706, Name: DetectorCVWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Category: Weapons/Items + ShowUser: No + Canhold: false + Material: metalweapon + HoldType: 0 + IsRadarItem: true + CustomIcon: DetectorSVT1 + { Child 0 + ROF: 5, type: float + Class: RadarScan + Range: 6000 + RangeSpace: 24000 + DiscoveryFac: 0.12 + ShowObjectTime: 30 + AmmoCapacity: 0, display: false + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + AllowAt: "UnderWater, Planet, Space" + } +} + +# Damage and Multipliers defined in EClassConfig > Entity Name: ExplosiveDevice +{ +Item Id: 707, Name: SurvivalConstructor + Meshfile: @models/Blocks/Terrain/ConstructorSurvivalPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + PickupToToolbar: true + Material: metalitem + Canhold: false + HoldType: 0 + StackSize: 100 + ShowUser: No + Mass: 0.5, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Category: Weapons/Items + Info: bkiExplosives, display: true + { Child 0 + Class: PlaceTerrainPlaceable + EntityName: SurvivalConstructor + AllowRemote: false + ROF: 1, type: float + Range: 8 + SfxBegin: UseActions/ExplosivesAttach + } +} + +{ +Item Id: 708, Name: PDA + Info: bkiPDA, display: true + CustomIcon: Tablet + LifetimeOnDrop: 1200 + # MarketPrice: 250, display: true + Meshfile: Entities/Misc/DummyPrefab + # PickupToToolbar: true + DropMeshfile: Entities/Misc/PDADropPrefab #Available variants: BagSmallChristmasPrefab,BagSmallNewPrefab + Material: metal + Mass: 0.2, type: float, display: true, formatter: Kilogram + Volume: 0.1, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + + # UnlockCost: 0, display: true + # UnlockLevel: 1, display: true + # TechTreeParent: SurvivalTool + # TechTreeNames: Tools +} + +{ Item Id: 709, Name: SnapPointTool + CustomIcon: ColorTool + Meshfile: Entities/Items/Tools/ColorToolPrefab + PickupToToolbar: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + Mass: 2.1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + RadialMenu: true + { Child Alt0 + Class: BlueprintPart + Range: 500 + Mode: PlaceBPP + RadialText: "Place a part" + RadialDesc: "Focus a snap point and choose a part with left control. Use left shift and left mouse to toggle through snap points" + RadialIcon: Radial_Upgrade + } + { Child Alt1 + Class: BlueprintPart + Range: 500 + Mode: DeleteSnapPoint + RadialText: "Delete" + RadialDesc: "Delete the focused snap point" + RadialIcon: Radial_Remove + } + { Child Alt2 + Class: BlueprintPart + Range: 500 + Mode: SyncSnapPoints + RadialText: "Reload snap points" + RadialDesc: "Reloads all snap points from the used blueprint. Works only for one source blueprint" + RadialIcon: Radial_ChangeRotate + } +} + +# CATEGORIE: New Weapon Items id 800 - 850 + +{ +Item Id: 800, Name: ZiraxAssaultRiflePlayer, Ref: ZiraxMinigunWeapon + Meshfile: Entities/Items/Weapons/Enemies/ZiraxMinigunPlyPrefab + ShowUser: No + { Child 0 + ROF: 0.17, type: float, display: true, formatter: ROF + Range: 120, display: false + Damage: 81, display: true + AmmoCapacity: 28, display: true + ReloadDelay: 2.945, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + AmmoUnlimited: true + } + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden +} +{ +Item Id: 801, Name: ZiraxShotgunPlayer, Ref: ZiraxMinigunWeapon + Meshfile: Entities/Items/Weapons/Enemies/ZiraxShotgunPlyPrefab + ShowUser: No + { Child 0 + ROF: 0.17, type: float, display: true, formatter: ROF + Range: 120, display: false + Damage: 81, display: true + AmmoCapacity: 28, display: true + ReloadDelay: 2.945, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + AmmoUnlimited: true + } + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden +} +{ +Item Id: 802, Name: TalonCrossbowPlayer + Meshfile: Entities/Items/Weapons/Crossbow/TalonCrossbowPlayerPrefab + CustomIcon: TalonCrossbowWeaponCharged + PickupToToolbar: true + Material: metalweapon + MarketPrice: 5200, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + Info: bkiTalonCrossbowPlayer, display: true + Mass: 6, type: float, display: true, formatter: Kilogram + Volume: 4, type: float, display: true, formatter: Liter + Durability: 1000, display: false + DegradationProb: 0.05, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + + RadialMenu: true + + # Precision Anti-biological + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 180, display: false + NoiseStrength: 50, display: false + CameraShake: 0 + BulletSpread: 0, display: true + Recoil: 0, display: true + IgnoreAtmo: true, display: true + Automatic: true, display: true + Damage: 200, display: true + AmmoCapacity: 4, display: true + AmmoType: TalonPulseCannonCharge, display: true #PulseLaserChargeRifle + ReloadDelay: 3.0, display: true + ParticleTransform: Muzzle/Particle1 + SfxBegin: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_start + SfxLoop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_loop + SfxStop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_end + # SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxNoAmmo: Weapons/Misc/G36DryFire + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: SniperMuzzleLaserGreenBright + Tracer: Weapons/Projectiles/TracerLaserShotGreenBright + #Tracer: Weapons/Projectiles/TracerLaserShotRed + TracerPerBullet: 1 + TracerSpeed: 200 + + DamageMultiplier_1: 4, param1: head|body, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|woodblock|concrete #No dmg vs. non-metallic materials + DamageMultiplier_3: 0.005, param1: shield|forcefield + DamageMultiplier_4: 0, param1: metallight|metal|metalhard|hull #no damage vs. metallic entities + DamageMultiplier_5: 0, param1: hullarmored|hullcombat #Reduced dmg vs. metallig armor plating BUT it does dmg! (other than the standar Laser Rifle) + + RadialText: rdlPulseLaser + RadialDesc: rdlPulseLaserTxt + RadialIcon: PulseLaserChargeRifle + + } + # Explosive anti-metal/Device (but low dmg vs. biologicals) + { Child 1 + Class: LauncherSS + AllowRemote: false + ROF: 4, type: float, display: true, formatter: ROF + Range: 100, display: false + NoiseStrength: 120, display: false + CameraShake: 3 + Recoil: 1, display: true + IgnoreAtmo: true, display: true + AmmoCapacity: 2, display: true + # AmmoUnlimited: true + AmmoType: TalonPulseCannonCharge, display: true + ReloadDelay: 4, display: true + SfxBegin: Weapons/Player/Cannon/BLASTERComplexFireSciFiChargePowerfulDeepRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Reload/HeavyWeaponReloadNormal + PfxMuzzle: SniperMuzzleLaserGreenBright + + RadialText: rdlPulseCannon + RadialDesc: rdlPulseCannonTxt + RadialIcon: TalonPulseCannonCharge + } + # { Child 1 + # Class: Zoom + # ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + # ZoomOut: 40 + # ZoomIn: 40 + # RedDot: true + # } +} + +{ +Item Id: 803, Name: TalonCrossbowTribalPlayer # Ref: TalonCrossbowPlayer + Meshfile: Entities/Items/Weapons/Crossbow/TalonCrossbowPlayerPrefab + CustomIcon: TalonCrossbowWeaponCharged + PickupToToolbar: true + Material: metalweapon + MarketPrice: 1200, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + Info: bkiTalonCrossbowTribalPlayer, display: true + Mass: 6, type: float, display: true, formatter: Kilogram + Volume: 4, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.05, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + RepairDisabled: true, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + { Child 0 + Class: Ranged + AllowRemote: false + AllowAt: "UnderWater, Planet, Space" + ROF: 0.2, type: float, display: true, formatter: ROF + Range: 90, display: false + NoiseStrength: 60, display: false + CameraShake: 1 + Automatic: false + BulletSpread: 0.2, display: true + Recoil: 0.8, display: true + Damage: 90, display: true + AmmoCapacity: 1, display: true + AmmoType: CrossbowBoltPlayer, display: true + ReloadDelay: 2.5, display: true + SfxBegin: Weapons/Enemies/Talon/CrossbowShot + PfxMuzzle: MuzzleCrossbowSmoke + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 1.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 40 + RedDot: true + } +} + +{ +Item Id: 804, Name: CrossbowBoltPlayer + Meshfile: Entities/Misc/DummyPrefab + CustomIcon: MeleeTalonGuardianCharged + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + HoldType: 0 + MarketPrice: 1, display: true + Canhold: false + StackSize: 2000 + Info: bkiCrossbowBoltPlayer, display: true + Category: Weapons/Items + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter + + # UnlockLevel: 1 + # TechTreeNames: Hidden +} + +{ +Item Id: 805, Name: CyborgRifleWeapon, Ref: ZiraxMinigunWeapon + Meshfile: Entities/Items/Weapons/Enemies/CyborgSniperPrefab + { Child 0 + Class: Ranged + ROF: 0.4, type: float + Range: 50, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: true + Damage: 35, display: true # DPS: 87.5 + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: 5.8mmBullet, display: true + BulletSpread: 1.5, display: true + ReloadDelay: 6.3, display: true + SfxBegin: Weapons/ProjectileWeapons/Automatic/HK416_Modern_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/HK416_Modern_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/HK416_Modern_end + SfxNoAmmo: Weapons/Misc/AK47DryFire01 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: MuzzleFlash1 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 5 + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 5, param1: metal|metalhard + } +} + +{ +Item Id: 806, Name: LaserCarbine + Meshfile: Entities/Items/Weapons/Rifles/LaserCarbinePrefab + PickupToToolbar: true + MarketPrice: 3750, display: true + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + Info: bkiWeaponHandheldIgnoringAtmo, display: true + Mass: 4.2, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.125, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.11, type: float, display: true, formatter: ROF + Range: 160, display: false + Automatic: true, display: true + BulletSpread: 0.12, display: true + Recoil: 0.25, display: true + IgnoreAtmo: true, display: true + Damage: 57, display: true + NoiseStrength: 50, display: false + CameraShake: 1 + AmmoCapacity: 32, display: true + AmmoType: PulseLaserChargeRifle, display: true + ReloadDelay: 2.25, display: true + ParticleTransform: Muzzle/Particle1 + SfxBegin: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_start + # SfxLoop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_loop + SfxStop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_end + # SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxNoAmmo: Weapons/Misc/G36DryFire + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: SniperMuzzleLaserRed + Tracer: Weapons/Projectiles/TracerLaserShotRed + TracerPerBullet: 1 + + DamageMultiplier_1: 1.25, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 30 + RedDot: true + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.0" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "-15, -45, 7" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.12, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 15, display: true + UnlockLevel: 20, display: true + TechTreeParent: LaserRifle + TechTreeNames: Weapons +} + +{ +Item Id: 807, Name: SMG + Meshfile: Entities/Items/Weapons/Pistols/SMGPrefab + PickupToToolbar: true + MarketPrice: 1123, display: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + # Info: bkiWeaponHandheld, display: true + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.08, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.052, type: float, display: true, formatter: ROF + Range: 85, display: false + NoiseStrength: 100, display: false + CameraShake: 1 + Automatic: true, display: true + BulletSpread: 0.3, display: true + Recoil: 0.7, display: true + Damage: 38, display: true + AmmoCapacity: 32, display: true + AmmoType: 50Caliber, display: true + ReloadDelay: 1.8, display: true + # SfxBegin: Weapons/ProjectileWeapons/Automatic/HK416_Heavy_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/HK416_Heavy_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/HK416_Heavy_end + SfxNoAmmo: Weapons/Misc/AK47DryFire01 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: MuzzleFlash1 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 5 + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + # RedDot: true + } + UnlockCost: 7, display: true + UnlockLevel: 5, display: true + TechTreeParent: Pistol + TechTreeNames: Weapons +} + +{ +Item Id: 808, Name: LaserSMG + Meshfile: Entities/Items/Weapons/Pistols/LaserSMGPrefab + PickupToToolbar: true + MarketPrice: 3267, display: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + Info: bkiWeaponHandheldIgnoringAtmo, display: true + Mass: 4.2, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.125, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.06, type: float, display: true, formatter: ROF + Range: 95, display: false + Automatic: true, display: true + BulletSpread: 0.25, display: true + Recoil: 0.55, display: true + IgnoreAtmo: true, display: true + Damage: 48, display: true + NoiseStrength: 50, display: false + CameraShake: 1 + AmmoCapacity: 42, display: true + AmmoType: PulseLaserChargePistol, display: true + ReloadDelay: 2, display: true + ParticleTransform: Muzzle/Particle1 + SfxBegin: Weapons/SS/LaserSS/BLASTERLargeFastKickback(stereo) + # SfxLoop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_loop + SfxStop: Weapons/EnergyWeapons/Automatic/BLASTERComplexFireTriggerDigitalDischarge_end + # SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxNoAmmo: Weapons/Misc/G36DryFire + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: SniperMuzzleLaserRed + Tracer: Weapons/Projectiles/TracerLaserShotRed + TracerPerBullet: 1 + + DamageMultiplier_1: 1.25, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + # RedDot: true + } + { Child 6 + Class: Movements + StandingLocation: "0.0, 0.010, 0.0" + StandingRotation: "0.0, 0.0, 0.0" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "0.0, 0.0, 0.0" + RunningLocation: "0.05, 0.0, 0.05" + RunningRotation: "0.0, 0.0, 0.0" + CrouchingLocation: "0.0, 0.0, 0.1" + CrouchingRotation: "0.0, 0.0, 0.0" + RecoilLocation: "0.12, 0.0, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + + UnlockCost: 12, display: true + UnlockLevel: 15, display: true + TechTreeParent: LaserPistol + TechTreeNames: Weapons +} + + + +# CATEGORIE: New Drone Weapon Items id 850 - 900 +{ +Item Id: 850, Name: rocketlauncherDroneLarge + Meshfile: Entities/Enemies/Drones/Weapons/GunMountInvisiblePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 3.5, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 25 + AmmoUnlimited: true + AmmoType: rocketDroneLarge, display: true + ReloadDelay: 3 + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 851, Name: rocketDroneLarge + Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 100, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 30 + Range: 360 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 852, Name: rocketlauncherDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/GunMountInvisiblePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 3.5, type: float + Range: 1500 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 25 + AmmoUnlimited: true + AmmoType: rocketDroneSpace, display: true + ReloadDelay: 3 + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 853, Name: rocketDroneSpace + Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 250, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 165 + Range: 635 + SpeedSpace: 210 + RangeSpace: 1500 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 854, Name: artillerylauncherDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/GunMountInvisiblePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 6, type: float + Range: 1600 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 5 + AmmoUnlimited: true + AmmoType: artilleryrocketDroneSpace, display: true + ReloadDelay: 13 + SfxBegin: Weapons/MS/Turret/Cannon_2 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 855, Name: artilleryrocketDroneSpace + Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 10000, display: true + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 10000, display: true + Speed: 165 + Range: 635 + SpeedSpace: 210 + RangeSpace: 1600 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.067, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 856, Name: laserDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/laserSpaceDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float + Range: 500 + AmmoType: laserchargeDroneSmall, display: true + AmmoCapacity: 25 + ReloadDelay: 3 + AmmoUnlimited: true + SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 857, Name: laserchargeDroneSmall + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotRedAlien + # Meshfile: Entities/Items/Weapons/Projectiles/LaserImpulseRed01LongPrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 60, display: true + BlastParticleIndex: 12 + BlastRadius: 2, display: true + BlastDamage: 800, display: true + Speed: 200 + Range: 800 + Ballistic: false + + DamageMultiplier_1: 3, param1: hullarmored + DamageMultiplier_2: 3, param1: hullcombat + DamageMultiplier_3: 2, param1: shield + BlastDamageMultiplier_1: 0, param1: shield + } +} +{ +Item Id: 858, Name: laserDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/laserSpaceDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float + Range: 500 + AmmoType: laserchargeDroneSpace, display: true + AmmoCapacity: 25 + ReloadDelay: 3 + AmmoUnlimited: true + SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 859, Name: laserchargeDroneSpace + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotRedAlien + # Meshfile: Entities/Items/Weapons/Projectiles/LaserImpulseRed01LongPrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 60, display: true + BlastParticleIndex: 12 + BlastRadius: 2, display: true + BlastDamage: 800, display: true + Speed: 200 + Range: 800 + Ballistic: false + + DamageMultiplier_1: 3, param1: hullarmored + DamageMultiplier_2: 3, param1: hullcombat + DamageMultiplier_3: 2, param1: shield + BlastDamageMultiplier_1: 0, param1: shield + } +} +{ +Item Id: 860, Name: laserboltDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/laserSpaceDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float + Range: 500 + AmmoType: laserboltcharge, display: true + AmmoCapacity: 25 + ReloadDelay: 3 + AmmoUnlimited: true + SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 861, Name: laserboltcharge + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotRedAlien + Material: metalweapon + HoldType: 0 + StackSize: 25 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 60, display: true + BlastParticleIndex: 12 + BlastRadius: 2, display: true + BlastDamage: 800, display: true + Speed: 200 + Range: 800 + Ballistic: false + + DamageMultiplier_1: 3, param1: hullarmored + DamageMultiplier_2: 3, param1: hullcombat + DamageMultiplier_3: 2, param1: shield + BlastDamageMultiplier_1: 0, param1: shield + } +} +{ +Item Id: 862, Name: plasmaDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/plasmaDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 2, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + AmmoType: plasmachargeDroneSpace, display: true + AmmoCapacity: 25 + AmmoUnlimited: true + ReloadDelay: 3 + SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 863, Name: plasmachargeDroneSpace + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectilePrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 75, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 70 + Range: 700 + Ballistic: false + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 864, Name: plasmalauncherDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/GunMountInvisiblePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 6, type: float + Range: 1600 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 5 + AmmoUnlimited: true + AmmoType: plasmarocket, display: true + ReloadDelay: 13 + SfxBegin: Weapons/MS/Turret/Cannon_2 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 865, Name: plasmarocket + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectilePrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 10000, display: true + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 10000, display: true + Speed: 165 + Range: 635 + SpeedSpace: 210 + RangeSpace: 1600 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.067, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 866, Name: mortarlauncherDroneLarge + Meshfile: Entities/Enemies/Drones/Weapons/GunMountInvisiblePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 3.5, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 25 + AmmoUnlimited: true + AmmoType: mortargrenade, display: true + ReloadDelay: 3 + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 867, Name: mortargrenade + Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 100, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 30 + Range: 360 + Ballistic: true + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 868, Name: cannonheatDroneSmall + Meshfile: Entities/Enemies/Drones/Weapons/cannonDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + ROF: 0.75, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + Automatic: true + Damage: 45 + AmmoCapacity: 200 + AmmoUnlimited: true + IgnoreAtmo: true + AmmoType: highexplosiveantitank, display: true + ReloadDelay: 3.1 + SfxBegin: Weapons/Player/Misc/Single/Cannon_2_short + SfxLoop: Misc/MuteSound + SfxStop: Misc/MuteSound + TracerPerBullet: 1 + } +} +{ +Item Id: 869, Name: highexplosiveantitank + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + HoldType: 0 + MarketPrice: 1, display: true + Canhold: false + StackSize: 4000 + Info: bkiAmmo, display: true + Category: Weapons/Items + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter +} +{ +Item Id: 870, Name: empcannonDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/plasmaDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 0.5, type: float + Range: 900 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 25 + AmmoUnlimited: true + AmmoType: empcharge, display: true + ReloadDelay: 3 + SfxBegin: Weapons/EnergyWeapons/weapon_laser_009 + # PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 871, Name: empcharge + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotYellowAlien + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 450, display: true # new: because we removed explosion + BlastParticleIndex: 0 + # BlastRadius: 3 + # BlastDamage: 200 + Speed: 750 + Range: 650 + SpeedSpace: 810 + RangeSpace: 875 + Ballistic: false + + DamageMultiplier_1: 0, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 2, param1: shield + DamageMultiplier_4: 0.5, param1: wood|metal|metalhard|hull|hullarmored|hullcombat + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0.1, param1: rock + BlastDamageMultiplier_4: 1, param1: shield + } +} +{ +Item Id: 872, Name: swarmrocketlauncherDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/GunMountInvisiblePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 3.5, type: float + Range: 1500 # Drone will attack from: Range*WeaponRangeFactor + AmmoCapacity: 25 + AmmoUnlimited: true + AmmoType: swarmrocket, display: true + ReloadDelay: 3 + SfxBegin: Weapons/SS/RocketHumanSS/flak88_fire03 + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 873, Name: swarmrocket + Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 250, display: true + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 165 + Range: 635 + SpeedSpace: 210 + RangeSpace: 1500 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 3, param1: metal + DamageMultiplier_3: 25, param1: metalhard|concrete|woodblock|hull + DamageMultiplier_4: 60, param1: hullarmored|hullcombat + DamageMultiplier_5: 0, param1: stone|dirt + DamageMultiplier_6: 0.1, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 874, Name: chargedrailgunDroneLarge + Meshfile: Entities/Enemies/Drones/Weapons/GunMountInvisiblePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 1, type: float, display: true, formatter: ROF + Range: 525, display: false + RangeSpace: 800, display: false + AmmoCapacity: 20, display: true + AmmoType: chargedrailgunbullet, display: true + Automatic: true + NoiseStrength: 100, display: false + AutoReload: true + CheckObstructed: true + ReloadDelay: 4, display: true # adapt sound + SfxBegin: Weapons/ProjectileWeapons/weapon_railgun_004 + # SfxBegin: Weapons/SS/Railgun/FuturisticGunSound02 + SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SniperMuzzleFireLarge + } +} +{ +Item Id: 875, Name: chargedrailgunbullet + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 1555 , display: true + BlastParticleIndex: 3 + BlastRadius: 0 + BlastDamage: 0 + NoiseStrength: 100, display: false + Speed: 425 + Range: 525 + SpeedSpace: 525 + RangeSpace: 850 + Ballistic: false + + DamageMultiplier_1: 0.5, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.2, param1: shield + BlastDamageMultiplier_1: 0.5, param1: head|body + BlastDamageMultiplier_2: 0, param1: stone|dirt + } +} +{ +Item Id: 876, Name: antiprotonLauncherDroneLarge + Meshfile: Entities/Enemies/Drones/Weapons/laserSpaceDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: LauncherSS + ROF: 0.5, type: float, display: true, formatter: ROF + AllowAt: "Planet,Space" + Range: 650, display: false + RangeSpace: 1300, display: false + # Damage: 2000, display: true + AmmoCapacity: 500, display: false # no need to display since unlimited ammo + AmmoType: antiprotoncharge, display: true + # AmmoUnlimited: true + Automatic: true + NoiseStrength: 100, display: false + ReloadDelay: 0.6, display: true + SfxBegin: Weapons/EnergyWeapons/weapon_laser_009 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + # ParticleTransform: Muzzle/Particle1 + } +} +{ +Item Id: 877, Name: antiprotoncharge + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotRedAlien + Material: metalweapon + HoldType: 0 + StackSize: 25 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Projectile + Damage: 2000, display: true # new: because we removed explosion + BlastParticleIndex: 0 + # BlastRadius: 3 + # BlastDamage: 200 + Speed: 750 + Range: 650 + SpeedSpace: 810 + RangeSpace: 1300 + Ballistic: false + + DamageMultiplier_1: 0.25, param1: head|body + DamageMultiplier_2: 0, param1: stone|dirt|rock + DamageMultiplier_3: 0.1, param1: shield + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0.1, param1: rock + BlastDamageMultiplier_4: 0, param1: shield + } +} +{ +Item Id: 878, Name: cannonDroneSpace + Meshfile: Entities/Enemies/Drones/Weapons/cannonDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 25 + Canhold: false + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + ROF: 0.75, type: float + Range: 300 # Drone will attack from: Range*WeaponRangeFactor + Automatic: true + Damage: 45 + AmmoCapacity: 200 + AmmoUnlimited: true + AmmoType: cannonDroneSpaceAmmo, display: true + ReloadDelay: 3.1 + SfxBegin: Weapons/Player/Misc/Single/Cannon_2_short + SfxLoop: Misc/MuteSound + SfxStop: Misc/MuteSound + TracerPerBullet: 1 + } +} +{ +Item Id: 879, Name: cannonDroneSpaceAmmo + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + HoldType: 0 + MarketPrice: 1, display: true + Canhold: false + StackSize: 4000 + Info: bkiAmmo, display: true + Category: Weapons/Items + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter +} +{ +Item Id: 880, Name: DetectorHVEpicWeapon + Meshfile: Entities/Misc/DummyPrefab + CustomIcon: DetectorSVT1 + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Category: Weapons/Items + ShowUser: No + Canhold: false + Material: metalweapon + HoldType: 0 + IsRadarItem: true + # CustomIcon: DetectorSVT1 + { Child 0 + ROF: 5, type: float + Class: RadarScan + Range: 10000 #Epic; standard 2500 + RangeSpace: 10000 + DiscoveryFac: 0.12 + ShowObjectTime: 30 + AmmoCapacity: 0, display: false + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + AllowAt: "UnderWater, Planet, Space" + } +} +{ +Item Id: 881, Name: DetectorCVEpicWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Category: Weapons/Items + ShowUser: No + Canhold: false + Material: metalweapon + HoldType: 0 + IsRadarItem: true + CustomIcon: DetectorSVT1 + { Child 0 + ROF: 5, type: float + Class: RadarScan + Range: 6000 + RangeSpace: 100000 #Epic; Standard: 25000 + DiscoveryFac: 0.12 + ShowObjectTime: 30 + AmmoCapacity: 0, display: false + AmmoType: AmmoInfinite, display: true + AmmoUnlimited: true + AllowAt: "UnderWater, Planet, Space" + } +} +{ +Item Id: 882, Name: UCHAssaultRifle #UCH Marine Weapon + Meshfile: Entities/Items/Weapons/Enemies/UCH_AssaultRifle_Prefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: true + + ROF: 0.15, type: float + Range: 80, display: false + Damage: 18, display: true + AmmoCapacity: 250, display: true + AmmoType: 8.3mmBullet, display: true + BulletSpread: 0.9, display: true + ReloadDelay: 6.3, display: true + # SfxBegin: Weapons/Player/AssaultRifle/AWDS_AR_HK416_start + # SfxLoop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_loop + # SfxStop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_end + # SfxBegin: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Begin + # SfxLoop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Loop + # SfxStop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Stop + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + SfxNoAmmo: Weapons/Misc/P90DryFire + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SMGAutoMuzzleFire + ParticleTransform: # Particle1 contains the destroy blocks effect + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 5, param1: metal|metalhard + } +} +{ +Item Id: 883, Name: UCHShotgunRifle #UCH Marine Weapon + Meshfile: Entities/Items/Weapons/Enemies/UCH_Shotgun_Prefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: false + + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 30, display: false + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 1.6, display: true + Damage: 50, display: true + AmmoCapacity: 5, display: true + AmmoType: ShotgunShells, display: true + ReloadDelay: 3.5, display: true + SfxBegin: Weapons/Player/Shotgun/Stakeout + SfxNoAmmo: Weapons/Misc/PumpgunDryFire + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: MuzzleFlash2 + ParticleTransform: # Particle1 contains the destroy blocks effect + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: wood|stone|dirt + DamageMultiplier_3: 5, param1: metal|metalhard + } +} + +{ +Item Id: 884, Name: DroneLargePlayer01Weapon + Meshfile: Entities/Enemies/Drones/Weapons/minigunDronePrefab + Material: metalweapon + HoldType: 0 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + { Child 0 + Class: Ranged + ROF: 0.35, type: float + Range: 600 # Drone will attack from: Range*WeaponRangeFactor + RangeSpace: 900 + Automatic: true + Damage: 800 + AmmoCapacity: 200 + AmmoUnlimited: true + AmmoType: 30mmBullet, display: true + ReloadDelay: 1 + #SfxBegin: Weapons/Enemies/Drones/minigunLowPitch_start + #SfxLoop: Weapons/Enemies/Drones/minigunLowPitch_loop + #SfxStop: Weapons/Enemies/Drones/minigunLowPitch_end + #SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + #SfxReload: Weapons/weapon_reload_standard + SfxBegin: Weapons/Player/Misc/Single/cannon #cannon + SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + + PfxMuzzle: turretMuzzleFire + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + #DamageMultiplier_1: 3, param1: metal + #DamageMultiplier_2: 25, param1: metalhard|concrete|woodblock|hull + #DamageMultiplier_3: 60, param1: hullarmored|hullcombat + #DamageMultiplier_4: 0, param1: stone|dirt + #DamageMultiplier_5: 4, param1: shield + DamageMultiplier_1: 0.2, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.05, param1: shield + DamageMultiplier_5: 1.15, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 885, Name: DroneLargePlayer01Unit + DropMeshfile: Entities/Misc/BagSmallNewPrefab + Info: bkiDroneLargePlayer01Unit, display: true + CustomIcon: TechnicalArtifact + Meshfile: Entities/Misc/DummyPrefab + #Meshfile: Entities/Items/Weapons/Projectiles/rocket02Prefab + Material: metalitem + HoldType: 0 + StackSize: 10 + Canhold: false + Category: Weapons/Items + ShowUser: Yes + + Mass: 1350, type: float, display: true, formatter: Kilogram + Volume: 670, type: float, display: true, formatter: Liter + + UnlockCost: 12, display: true + UnlockLevel: 10, display: true + TechTreeNames: Weapons +} + +{ +Item Id: 886, Name: FarrBlaster + Meshfile: Entities/Items/Weapons/Pistols/FarrBlasterPrefab + # CustomIcon: Blaster + PickupToToolbar: true + MarketPrice: 1900, display: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + Info: bkiFarrBlaster, display: true + Mass: 2.5, type: float, display: true, formatter: Kilogram + Volume: 1.6, type: float, display: true, formatter: Liter + # DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + # Damage: 100, display: true + Durability: 500, display: false + DegradationProb: 0.2, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + AllowRemote: false + ROF: 0.4, type: float, display: true, formatter: ROF + Recoil: 0, display: true + BulletSpread: 0, display: true + Range: 90, display: false + Damage: 40, display: true + AmmoCapacity: 15, display: true + AmmoType: PulseLaserChargePistol, display: true + ReloadDelay: 2, display: true + IgnoreAtmo: true, display: true + NoiseStrength: 15, display: false + CameraShake: 1 + Automatic: false + + PfxMuzzle: SniperMuzzleLaserRed + Tracer: Weapons/Projectiles/TracerLaserShotRed + TracerPerBullet: 1 + TracerSpeed: 200 + + SfxBegin: Weapons/Player/LaserPistol/BLASTERLargeFastKickback + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0.005, param1: shield + } + + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + } +} +{ +Item Id: 887, Name: PrennAssaultRifle + Meshfile: Entities/Items/Weapons/Enemies/ZiraxMinigunPlyPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + ROF: 0.15, type: float + Range: 80, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: true + Damage: 18, display: true + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: 8.3mmBullet, display: true + BulletSpread: 0.9, display: true + ReloadDelay: 6.3, display: true + # SfxBegin: Weapons/Player/AssaultRifle/AWDS_AR_HK416_start + # SfxLoop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_loop + # SfxStop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_end + # SfxBegin: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Begin + # SfxLoop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Loop + # SfxStop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Stop + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + SfxNoAmmo: Weapons/Misc/P90DryFire + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SMGAutoMuzzleFire + ParticleTransform: # Particle1 contains the destroy blocks effect + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 5, param1: metal|metalhard + } +} +{ +Item Id: 888, Name: PrennShotgun + Meshfile: Entities/Items/Weapons/Enemies/ZiraxMinigunPlyPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 30, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: false + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 1.6, display: true + Damage: 50, display: true + AmmoCapacity: 5, display: true + AmmoUnlimited: true + AmmoType: ShotgunShells, display: true + ReloadDelay: 3.5, display: true + SfxBegin: Weapons/Player/Shotgun/Stakeout + SfxNoAmmo: Weapons/Misc/PumpgunDryFire + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: MuzzleFlash2 + ParticleTransform: # Particle1 contains the destroy blocks effect + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: wood|stone|dirt + DamageMultiplier_3: 5, param1: metal|metalhard + } +} +{ +Item Id: 889, Name: ArkenianShotgun + Meshfile: Entities/Items/Weapons/Enemies/Protofactor_SciFiShotgunPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 30, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: false + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 1.6, display: true + Damage: 50, display: true + AmmoCapacity: 5, display: true + AmmoUnlimited: true + AmmoType: ShotgunShells, display: true + ReloadDelay: 3.5, display: true + SfxBegin: Weapons/Player/Shotgun/Stakeout + SfxNoAmmo: Weapons/Misc/PumpgunDryFire + SfxReload: Weapons/Player/Reload/RifleReloadSlow + PfxMuzzle: MuzzleFlash2 + ParticleTransform: # Particle1 contains the destroy blocks effect + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 2, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: wood|stone|dirt + DamageMultiplier_3: 5, param1: metal|metalhard + } +} +{ +Item Id: 890, Name: ArkenianAssaultRifle + Meshfile: Entities/Items/Weapons/Enemies/Protofactor_SciFiAssaultRifle01Prefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + ROF: 0.15, type: float + Range: 80, display: false + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: true + Damage: 18, display: true + AmmoCapacity: 250, display: true + AmmoUnlimited: true + AmmoType: 8.3mmBullet, display: true + BulletSpread: 0.9, display: true + ReloadDelay: 6.3, display: true + # SfxBegin: Weapons/Player/AssaultRifle/AWDS_AR_HK416_start + # SfxLoop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_loop + # SfxStop: Weapons/Player/AssaultRifle/AWDS_AR_HK416_end + # SfxBegin: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Begin + # SfxLoop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Loop + # SfxStop: Weapons/Enemies/Zirax/AWDS_AK47Krinkov_Stop + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + SfxNoAmmo: Weapons/Misc/P90DryFire + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: SMGAutoMuzzleFire + ParticleTransform: # Particle1 contains the destroy blocks effect + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 2 + # MaxFireAngle: 20 #(default is 5) + + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 5, param1: metal|metalhard + } +} +{ +Item Id: 891, Name: UCHPistol #Civilian Sidearm + Meshfile: Entities/Items/Weapons/Enemies/UCH_Pistol_Prefab + PickupToToolbar: true + MarketPrice: 244, display: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + # Info: bkiWeaponHandheld, display: true + Mass: 2.1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.31, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: false + + ROF: 0.125, type: float, display: true, formatter: ROF + Range: 90, display: false + NoiseStrength: 60, display: false + CameraShake: 1 + BulletSpread: 0.2, display: true + Recoil: 0.03, display: true + Damage: 64, display: true + AmmoCapacity: 16, display: true + AmmoType: 50Caliber, display: true + ReloadDelay: 1.5, display: true + SfxBegin: Weapons/Player/Pistol/Baghira + SfxNoAmmo: Weapons/Misc/MagnumDryFire + SfxReload: Weapons/Player/Reload/PistolReloadNormal + # PfxMuzzle: fire_muzzle_pistol + PfxMuzzle: MuzzleFlash8 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 1.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + # { Child 1 + # Class: Zoom + # ZoomOut: 40 + # ZoomIn: 40 + # # RedDot: true + # } +} +{ +Item Id: 892, Name: UCHSMG #Officers / Crew weapon + Meshfile: Entities/Items/Weapons/Enemies/UCH_SMG_Prefab + PickupToToolbar: true + MarketPrice: 1123, display: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + # Info: bkiWeaponHandheld, display: true + Mass: 4, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.08, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: true + + ROF: 0.052, type: float, display: true, formatter: ROF + Range: 85, display: false + NoiseStrength: 100, display: false + CameraShake: 1 + BulletSpread: 0.3, display: true + Recoil: 0.7, display: true + Damage: 38, display: true + AmmoCapacity: 32, display: true + AmmoType: 50Caliber, display: true + ReloadDelay: 1.8, display: true + SfxBegin: Weapons/ProjectileWeapons/Automatic/HK416_Heavy_loop #Weapons/ProjectileWeapons/Automatic/HK416_Heavy_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/HK416_Heavy_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/HK416_Heavy_end + SfxNoAmmo: Weapons/Misc/AK47DryFire01 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: MuzzleFlash1 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 5 + + DamageMultiplier_1: 3.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + # RedDot: true + } + # UnlockCost: 7, display: true + # UnlockLevel: 5, display: true + # TechTreeParent: Pistol + # TechTreeNames: Weapons +} +{ +Item Id: 893, Name: UCHSniperRifle #UCH Elite Weapon + Meshfile: Entities/Items/Weapons/Enemies/UCH_SniperRifle_Prefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 2731, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + # Info: bkiWeaponHandheld, display: true + Mass: 9.1, type: float, display: true, formatter: Kilogram + Volume: 9, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.625, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: Ranged + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: false + + ROF: 2, type: float, display: true, formatter: ROF + Range: 110, display: false #120m is render range + check entity view range in Eclass config! + NoiseStrength: 100, display: false + CameraShake: 2 + BulletSpread: 0.03, display: true + Recoil: 2.6, display: true + Damage: 150, display: true + AmmoCapacity: 10, display: true + AmmoType: 12.7mmBullet, display: true + # ReloadDelay: 3.7, display: true + ReloadDelay: 2.9, display: true + # SfxBegin: Weapons/Player/Sniper/desert_eagle + SfxBegin: Weapons/Player/Rifle/M24 + SfxNoAmmo: Weapons/Misc/AK47DryFire01 + SfxReload: Weapons/Player/Reload/RifleReloadNormal + PfxMuzzle: MuzzleFlash6 + Tracer: Weapons/Projectiles/TracerOrange1 + TracerPerBullet: 1 + + DamageMultiplier_1: 5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew02 + ZoomOut: 40 + ZoomIn: 10 + RedDot: true + } + + # UnlockCost: 7, display: true + # UnlockLevel: 5, display: true + # TechTreeParent: AssaultRifle + # TechTreeNames: Weapons +} +{ +Item Id: 894, Name: UCHMinigun #UCH Elite Weapon + Meshfile: Entities/Items/Weapons/Enemies/UCH_Minigun_Prefab + PickupToToolbar: true + Material: metalweapon + MarketPrice: 2561, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + # Info: bkiWeaponHandheld, display: true + Mass: 39, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + Durability: 500, display: false + DegradationProb: 0.05, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + Class: Ranged + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: true + + ROF: 0.15, type: float, display: true, formatter: ROF + Range: 80, display: false #120m is render range + check entity view range in Eclass config! + NoiseStrength: 120, display: false + CameraShake: 1 + BulletSpread: 0.9, display: true + Recoil: 0.6, display: true + Damage: 18, display: true + AmmoCapacity: 250, display: true + AmmoType: 8.3mmBullet, display: true + ReloadDelay: 3.9, display: true + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Reload/HeavyWeaponReloadNormal + PfxMuzzle: MuzzleFlash9 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 0, param1: wood|stone|dirt + DamageMultiplier_2: 5, param1: metal|metalhard + } + { Child 6 + Class: Movements + StandingLocation: "-0.05, 0.02, 0.05" + StandingRotation: "0.0, 0.0, -1.25" + AimingLocation: "0.0, 0.0, 0.0" + AimingRotation: "-0.5, 0.6, 1" + RunningLocation: "-0.05, 0.1, 0.05" + RunningRotation: "30, -45, 7" + CrouchingLocation: "0.0, 0.025, 0.0" + CrouchingRotation: "15, -30, 5" + RecoilLocation: "0.05, -0.1, 0.0" + RecoilRotation: "0.0, 0.0, 0.0" + WalkMovement: "0.010" + RunMovement: "0.02" + } + +} +{ +Item Id: 895, Name: UCHRocketLauncher #UCH Marine Weapon + Meshfile: Entities/Items/Weapons/Enemies/UCH_RocketLauncher_Prefab + CustomIcon: RocketLauncherAK9 + PickupToToolbar: true + Material: metalweapon + MarketPrice: 4647, display: true + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + # Info: bkiWeaponHandheld, display: true + Mass: 25, type: float, display: true, formatter: Kilogram + Volume: 45, type: float, display: true, formatter: Liter + #just for display - BlastRadius and BlastDamage is controlled by "SlowRocket" + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Durability: 100, display: false + DegradationProb: 1, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedSingleShot + { Child 0 + Class: LauncherSS + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: false + + ROF: 1.5, type: float, display: true, formatter: ROF + Range: 70, display: false #120m is render range + check entity view range in Eclass config! + NoiseStrength: 140, display: false + Recoil: 2.4, display: true + CameraShake: 3 + AmmoCapacity: 2, display: true + AmmoType: rocketDrone, display: true #ZiraxRocket Ammo for NPC balancing + ReloadDelay: 3.9, display: true + BulletSpread: 0.3, display: true + SfxBegin: Weapons/Player/RocketLauncher/FLYBYMissile02Slow + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Reload/RifleReloadSlow + # SfxReload: Weapons/Player/RocketLauncher/M203InsertGranade01 + PfxMuzzle: SniperMuzzleFire + } + +} + +{ +Item Id: 896, Name: UCHFlameThrower #UCH Special Forces / GLaD + Meshfile: Entities/Items/Weapons/Enemies/UCH_FlameThrower_Prefab + CustomIcon: FlamethrowerV2 + PickupToToolbar: true + Material: metalweapon + HoldType: 15 + StackSize: 1 + MarketPrice: 1980, display: true + Category: Weapons/Items + Mass: 60, type: float, display: false, formatter: Kilogram + Info: bkiFlameThrower, display: true + Durability: 500, display: false + DegradationProb: 0.2, type: float, display: false + + ShowUser: No + + { Child 0 + Class: LauncherSS + + #AllowRemote: false + AmmoUnlimited: true + IgnoreAtmo: true + Automatic: false + + ROF: 0.25, type: float + Range: 6, display: false + #BulletsPerShot: 8 + #BulletSpread: 1 + #RaySpread: 1, display: true + Recoil: 0, display: true + NoiseStrength: 40, display: false + CameraShake: 0 + AmmoCapacity: 50, display: true + AmmoType: FlameThrowerCanister, display: true + # UseSingleMagazine: true + ReloadDelay: 2.9, display: true + # Bullet_use_per_shot: 0.25 + # Particles: diggerplasma # particle effect shown at impact + PfxMuzzle: flamethrower_v1a + SfxBegin: Weapons/Player/FlameThrower/Flamethrower_Start + SfxLoop: Weapons/Player/FlameThrower/Flamerthrower_Firing + SfxStop: Weapons/Player/Drill/Mute + SfxReload: Weapons/Player/FlameThrower/Flamethrower_Reload + # SfxNoAmmo: + # SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + + } + #{ Child 6 + # Class: Movements + # StandingLocation: "-0.05, 0.03, 0.05" + ## StandingRotation: "0.0, 0.0, -1.25" + # AimingLocation: "0.0, 0.0, 0.0" + # AimingRotation: "-0.05, 0.6, 1" + # RunningLocation: "-0.05, 0.02, 0.05" + # RunningRotation: "-15, -45, 10" + # CrouchingLocation: "0.0, 0.025, 0.0" + # CrouchingRotation: "15, -30, 5" + # RecoilLocation: "0.010, 0.0, 0.0" + # RecoilRotation: "0.0, 0.0, -0.05" + # WalkMovement: "0.010" + # RunMovement: "0.02" + # } + +} +{ +Item Id: 897, Name: TalonLaserWeapon + # Meshfile: Entities/Items/Weapons/Enemies/ZiraxLaserPrefab + Meshfile: Entities/Items/Weapons/Enemies/TalonCrossbowPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + { Child 0 + Class: Ranged + ROF: 0.8, type: float, display: true, formatter: ROF + Range: 80, display: false + Damage: 75, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Automatic: false + SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxLoop: Weapons/Enemies/Zirax/mute # define new Zirax and not extend from MinigunZirax + SfxStop: Weapons/Enemies/Zirax/mute + PfxMuzzle: SniperMuzzleLaserGreenBright + ParticleTransform: # Particle1 contains the destroy blocks effect + Tracer: Weapons/Projectiles/TracerLaserShotGreenBright + TracerPerBullet: 1 + BulletSpread: 0.8, display: true + TracerSpeed: 200 + # MaxFireAngle: 20 #(default is 5) + } +} +{ +Item Id: 898, Name: TalonPulseCannon + # Meshfile: Entities/Items/Weapons/Enemies/ZiraxRocketPrefab + Meshfile: Entities/Items/Weapons/Enemies/TalonCrossbowPrefab + Material: metalweapon + HoldType: 15 + StackSize: 1 + Category: Weapons/Items + ShowUser: No + Mass: 12, type: float, display: false, formatter: Kilogram + Info: bkiTalonPulseCannon, display: true + { Child 0 + Class: LauncherSS + AllowRemote: false + ROF: 1.9, type: float, display: true, formatter: ROF + Range: 100, display: false + NoiseStrength: 120, display: false + CameraShake: 3 + Recoil: 3.0, display: true + IgnoreAtmo: true, display: true + AmmoCapacity: 4, display: true + AmmoUnlimited: true + AmmoType: TalonPulseCannonCharge, display: true + ReloadDelay: 4, display: true + SfxBegin: Weapons/Player/Cannon/BLASTERComplexFireSciFiChargePowerfulDeepRelease + SfxNoAmmo: Weapons/DryFireFromManuallyCockedHammer + SfxReload: Weapons/Player/Reload/HeavyWeaponReloadNormal + PfxMuzzle: SniperMuzzleLaserGreenBright + } +} +{ +Item Id: 899, Name: TalonPulseCannonCharge + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectileGreenBrightPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + MarketPrice: 150, display: true + HoldType: 0 + Canhold: false + Info: bkiTalonPulseCannonCharge, display: true #Only dmg to biologicals; no tech or metallic-dmg + Category: Weapons/Items + StackSize: 25 + Mass: 3, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + { Child 0 + Class: Projectile + Damage: 80, display: true + BlastParticleIndex: 18 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 75 + Range: 300 + NoiseStrength: 100, display: false + Ballistic: false + + DamageMultiplier_1: 4, param1: head|body, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|woodblock|concrete #No dmg vs. non-metallic materials + DamageMultiplier_3: 0.005, param1: shield|forcefield + DamageMultiplier_4: 0, param1: metallight|metal|metalhard|hull + DamageMultiplier_5: 0, param1: hullarmored|hullcombat + BlastDamageMultiplier_1: 4, param1: head|body, display: DmgMultiplierHead + BlastDamageMultiplier_2: 0, param1: dirt|stone|rock|wood|woodblock|concrete #No dmg vs. non-metallic materials + BlastDamageMultiplier_3: 0, param1: shield|forcefield + BlastDamageMultiplier_4: 0, param1: metallight|metal|metalhard|hull #Increased Damage vs. devices/turrets/sentries and weak armor/windows + BlastDamageMultiplier_5: 0.25, param1: hullarmored|hullcombat + } +} +# === High Tier sphere turret weapon and ammo (900-920) === +{ +Item Id: 900, Name: TurretEnemyLaserWeaponBlack, Ref: TurretEnemyLaserWeaponT2 + { Child 0 + AmmoType: TurretEnemyLaserChargeBlack, display: true + } +} +{ +Item Id: 901, Name: TurretEnemyLaserChargeBlack, Ref: TurretEnemyLaserChargeT2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketBlackPrefab +} +{ +Item Id: 902, Name: TurretEnemyLaserWeaponBlue, Ref: TurretEnemyLaserWeaponT2 + { Child 0 + AmmoType: TurretEnemyLaserChargeBlue, display: true + } +} +{ +Item Id: 903, Name: TurretEnemyLaserChargeBlue, Ref: TurretEnemyLaserChargeT2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketBluePrefab +} +{ +Item Id: 904, Name: TurretEnemyLaserWeaponCyan, Ref: TurretEnemyLaserWeaponT2 + { Child 0 + AmmoType: TurretEnemyLaserChargeCyan, display: true + } +} +{ +Item Id: 905, Name: TurretEnemyLaserChargeCyan, Ref: TurretEnemyLaserChargeT2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketCyanPrefab +} +{ +Item Id: 906, Name: TurretEnemyLaserWeaponGreen, Ref: TurretEnemyLaserWeaponT2 + { Child 0 + AmmoType: TurretEnemyLaserChargeGreen, display: true + } +} +{ +Item Id: 907, Name: TurretEnemyLaserChargeGreen, Ref: TurretEnemyLaserChargeT2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketGreenPrefab +} +{ +Item Id: 908, Name: TurretEnemyLaserWeaponOrange, Ref: TurretEnemyLaserWeaponT2 + { Child 0 + AmmoType: TurretEnemyLaserChargeOrange, display: true + } +} +{ +Item Id: 909, Name: TurretEnemyLaserChargeOrange, Ref: TurretEnemyLaserChargeT2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketOrangePrefab +} +{ +Item Id: 910, Name: TurretEnemyLaserWeaponPurple, Ref: TurretEnemyLaserWeaponT2 #Only use with TESCH + { Child 0 + AmmoType: TurretEnemyLaserChargePurple, display: true + } +} +{ +Item Id: 911, Name: TurretEnemyLaserChargePurple, Ref: TurretEnemyLaserChargeT2 #Only use with TESCH + Info: bkiTurretDarkMatterFactionAmmo, display: true + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketPurplePrefab +} +{ +Item Id: 912, Name: TurretEnemyLaserWeaponRed, Ref: TurretEnemyLaserWeaponT2 + { Child 0 + AmmoType: TurretEnemyLaserChargeRed, display: true + } +} +{ +Item Id: 913, Name: TurretEnemyLaserChargeRed, Ref: TurretEnemyLaserChargeT2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketRedPrefab +} +{ +Item Id: 914, Name: TurretEnemyLaserWeaponYellow, Ref: TurretEnemyLaserWeaponT2 + { Child 0 + AmmoType: TurretEnemyLaserChargeYellow, display: true + } +} +{ +Item Id: 915, Name: TurretEnemyLaserChargeYellow, Ref: TurretEnemyLaserChargeT2 + Meshfile: Entities/Items/Weapons/Projectiles/AlienRocketYellowPrefab +} + +# === Faction and Faction-Upgrade turret weapon and ammo (921-999) === + +{ +Item Id: 921, Name: TurretMinigunFactionWeapon + Meshfile: @models/Blocks/Turrets/TurretMSMinigunPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 6120, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + AllowAt: "Space,Planet" + ROF: 0.2, type: float, display: true, formatter: ROF + Range: 500, display: false + RangeSpace: 850, display: false + Automatic: true + Damage: 425, display: true + AmmoCapacity: 300, display: true + AmmoType: TurretMinigunFactionAmmo, display: true + NoiseStrength: 120, display: false + CameraShake: 1 + AutoReload: true + ReloadDelay: 2, display: true # adapt sound + SfxBegin: Weapons/Player/Minigun/minigun_start + SfxLoop: Weapons/Player/Minigun/minigun_loop + SfxStop: Weapons/Player/Minigun/minigun_end + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_005 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire2 + TracerPerBullet: 1 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 0.55, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.05, param1: shield + DamageMultiplier_5: 0.85, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 922, Name: TurretMinigunUpgradeWeapon, Ref: TurretMinigunFactionWeapon + { Child 0 + ROF: 0.2, type: float, display: true, formatter: ROF + Range: 500, display: false + RangeSpace: 800, display: false + Damage: 425, display: true + AmmoCapacity: 300, display: true + AmmoType: TurretMinigunUpgradeAmmo, display: true + ReloadDelay: 2, display: true # adapt sound + } +} +{ +Item Id: 923, Name: TurretMinigunFactionAmmo + Info: bkiTurretMinigunFactionAmmo, display: true + CustomIcon: 15mmBullet + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Category: Weapons/Items + Material: metalweapon + HoldType: 0 + Canhold: false + StackSize: 4000 + MarketPrice: 1, display: true + Mass: 0.06, type: float, display: true, formatter: Kilogram + Volume: 0.03, type: float, display: true, formatter: Liter + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 924, Name: TurretMinigunUpgradeAmmo, Ref: TurretMinigunFactionAmmo + Info: bkiTurretMinigunUpgradeAmmo, display: true + CustomIcon: 15mmBullet +} +{ +Item Id: 925, Name: TurretFlakFactionWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleFlakTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 5615, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 685, display: false + RangeSpace: 1135, display: false + AmmoCapacity: 50, display: true + AmmoType: TurretFlakFactionAmmo, display: true + NoiseStrength: 150, display: false + CameraShake: 2 + AutoReload: true + ReloadDelay: 5, display: true + SfxBegin: Weapons/Player/Misc/Single/cannon + # SfxBegin: Weapons/Player/Misc/Single/AWDSDracoHeavySingle + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_001 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + } +} + +{ +Item Id: 926, Name: TurretFlakUpgradeWeapon, Ref: TurretFlakFactionWeapon + { Child 0 + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 685, display: false + RangeSpace: 1135, display: false + AmmoCapacity: 50, display: true + AmmoType: TurretFlakUpgradeAmmo, display: true + ReloadDelay: 5, display: true + } +} +{ +Item Id: 927, Name: TurretFlakFactionAmmo + Info: bkiTurretFlakFactionAmmo, display: true + CustomIcon: FlakRocket + Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: Base, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 10.1, type: float, display: true, formatter: Kilogram + Volume: 6, type: float, display: true, formatter: Liter + MarketPrice: 45, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 485 + BlastParticleIndex: 3 + BlastRadius: 3, display: true + BlastDamage: 500, display: true + Speed: 650 + Range: 685 + SpeedSpace: 750 + RangeSpace: 1135 + HomingSpeed: 0.15 + Ballistic: false + + DamageMultiplier_1: 0.15, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.2, param1: head|body + BlastDamageMultiplier_2: 0.4, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 928, Name: TurretFlakUpgradeAmmo, Ref: TurretFlakFactionAmmo + Info: bkiTurretFlakUpgradeAmmo, display: true + CustomIcon: FlakRocketMS + { Child 0 + Class: Projectile + Damage: 485 + Speed: 650 + Range: 685 + SpeedSpace: 750 + RangeSpace: 1135 + HomingSpeed: 0.15 + } +} +{ +Item Id: 929, Name: TurretCannonFactionWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SinglePlasmaTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 6150, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: Ranged + IsShootThroughCrosshair: true + AllowAt: "Space,Planet" + ROF: 0.35, type: float, display: true, formatter: ROF + Range: 560, display: false + RangeSpace: 925, display: false + Automatic: false + Damage: 600, display: true + AmmoCapacity: 100, display: true + AutoReload: true + AmmoType: TurretCannonFactionAmmo, display: true + NoiseStrength: 120, display: false + CameraShake: 2 + ReloadDelay: 4, display: true # adapt sound + SfxBegin: Weapons/Player/Misc/Single/AWDSDracoHeavySingle + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_003 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: turretMuzzleFire + TracerPerBullet: 1 + Tracer: Weapons/Projectiles/TracerOrange1 + + DamageMultiplier_1: 0.25, param1: head|body # lower damage on entities + DamageMultiplier_2: 0.01, param1: wood|rock # lower damage on trees and rocks + DamageMultiplier_3: 0, param1: stone|dirt # no damage on terrain + DamageMultiplier_4: 0.05, param1: shield # quarter less damage vs shield than vs blocks + DamageMultiplier_5: 1.15, data: hull|hullarmored|hullcombat|metal|metallight|metalhard + } +} +{ +Item Id: 930, Name: TurretCannonUpgradeWeapon, Ref: TurretCannonFactionWeapon + { Child 0 + Class: Ranged + ROF: 0.35, type: float, display: true, formatter: ROF + Range: 560, display: false + RangeSpace: 925, display: false + Damage: 600, display: true + AmmoCapacity: 100, display: true + AmmoType: TurretCannonUpgradeAmmo, display: true + ReloadDelay: 4, display: true # adapt sound + } +} +{ +Item Id: 931, Name: TurretCannonFactionAmmo + Info: bkiTurretCannonFactionAmmo, display: true + CustomIcon: 30mmBullet + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: "Base,MS", display: false + HoldType: 0 + StackSize: 1500 + Canhold: false + Mass: 0.9, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Category: Weapons/Items + MarketPrice: 2, display: true + + UnlockLevel: 1 + TechTreeNames: Hidden +} + +{ +Item Id: 932, Name: TurretCannonUpgradeAmmo, Ref: TurretCannonFactionAmmo + Info: bkiTurretCannonUpgradeAmmo, display: true +} +{ +Item Id: 933, Name: TurretRocketFactionWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleMissileTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 5777, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 4, type: float, display: true, formatter: ROF + Range: 690, display: false + RangeSpace: 1150, display: false + AmmoCapacity: 15, display: true + AmmoType: TurretRocketFactionAmmo, display: true + NoiseStrength: 150, display: false + CameraShake: 3 + AutoReload: true + ReloadDelay: 8, display: true + SfxBegin: Weapons/Turret/missle_fire + # SfxBegin: Weapons/LauncherWeapons/weapon_missile_005 + # SfxBegin: Weapons/Planet/TurretPS01/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 934, Name: TurretRocketUpgradeWeapon, Ref: TurretRocketFactionWeapon + { Child 0 + Class: LauncherSS + ROF: 4, type: float, display: true, formatter: ROF + Range: 690, display: false + RangeSpace: 1150, display: false + AmmoCapacity: 15, display: true + AmmoType: TurretRocketUpgradeAmmo, display: true + ReloadDelay: 8, display: true + } +} +{ +Item Id: 935, Name: TurretRocketFactionAmmo + Info: bkiTurretRocketFactionAmmo, display: true + CustomIcon: FastRocketBA + Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 49, type: float, display: true, formatter: Kilogram + Volume: 32, type: float, display: true, formatter: Liter + Category: Weapons/Items + MarketPrice: 295, display: true + { Child 0 + Class: Projectile + Damage: 5858 + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 5800, display: true + Speed: 150 + Range: 635 + SpeedSpace: 240 + RangeSpace: 1085 + HomingSpeed: 1.5 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 936, Name: TurretRocketUpgradeAmmo, Ref: TurretRocketFactionAmmo + Info: bkiTurretRocketUpgradeAmmo, display: true + CustomIcon: FastRocketMS + { Child 0 + Damage: 5858 + BlastRadius: 4, display: true + BlastDamage: 5800, display: true + Speed: 150 + Range: 635 + SpeedSpace: 240 + RangeSpace: 1085 + HomingSpeed: 1.5 + } +} +{ +Item Id: 937, Name: TurretArtilleryFactionWeapon + Meshfile: @models/Blocks/Turrets/TurretMSArtilleryPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 250 + Mass: 19800, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 6.5, type: float, display: true, formatter: ROF + Range: 650, display: false + RangeSpace: 1100, display: false + AmmoCapacity: 5, display: true + AmmoType: TurretArtilleryFactionAmmo, display: true + NoiseStrength: 200, display: false + CameraShake: 4 + AutoReload: true + ReloadDelay: 13, display: true # adapt sound + SfxBegin: Weapons/MS/Turret/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_001 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 938, Name: TurretArtilleryUpgradeWeapon, Ref: TurretArtilleryFactionWeapon + { Child 0 + Class: LauncherSS + ROF: 6.5, type: float, display: true, formatter: ROF + Range: 650, display: false + RangeSpace: 1100, display: false + AmmoCapacity: 5, display: true + AmmoType: TurretArtilleryUpgradeAmmo, display: true + ReloadDelay: 13, display: true # adapt sound + } +} +{ +Item Id: 939, Name: TurretArtilleryFactionAmmo + Info: bkiTurretArtilleryFactionAmmo, display: true + CustomIcon: LargeRocket + Meshfile: Entities/Items/Weapons/Projectiles/RocketLargePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: Base, display: false + HoldType: 0 + StackSize: 1000 + Canhold: false + Mass: 870, type: float, display: true, formatter: Kilogram + Volume: 200, type: float, display: true, formatter: Liter + MarketPrice: 640, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 15300 + BlastParticleIndex: 4 + BlastRadius: 4, display: true + BlastDamage: 18375, display: true + Speed: 165 + Range: 715 + SpeedSpace: 215 + RangeSpace: 1200 + Ballistic: true + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.05, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } + + UnlockLevel: 1 + TechTreeNames: Hidden +} +{ +Item Id: 940, Name: TurretArtilleryUpgradeAmmo, Ref: TurretArtilleryFactionAmmo + Info: bkiTurretArtilleryUpgradeAmmo, display: true + CustomIcon: LargeRocketMS + { Child 0 + Class: Projectile + Damage: 15300 + BlastRadius: 4, display: true + BlastDamage: 18375, display: true + Speed: 165 + Range: 715 + SpeedSpace: 215 + RangeSpace: 1200 + } +} +{ +Item Id: 941, Name: TurretRailgunFactionWeapon + Meshfile: @models/Blocks/Turrets/ModularTurret1/SingleMissileTurretBluePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 5777, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 4, type: float, display: true, formatter: ROF + Range: 690, display: false + RangeSpace: 1150, display: false + AmmoCapacity: 15, display: true + AmmoType: TurretRailgunFactionAmmo, display: true + NoiseStrength: 150, display: false + CameraShake: 3 + AutoReload: true + ReloadDelay: 8, display: true + SfxBegin: Weapons/Turret/missle_fire + # SfxBegin: Weapons/LauncherWeapons/weapon_missile_005 + # SfxBegin: Weapons/Planet/TurretPS01/Cannon_2 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_002 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + PfxMuzzle: rocketlauncherMSMuzzleFire + } +} +{ +Item Id: 942, Name: TurretRailgunUpgradeWeapon, Ref: TurretRailgunFactionWeapon + { Child 0 + Class: LauncherSS + ROF: 4, type: float, display: true, formatter: ROF + Range: 690, display: false + RangeSpace: 1150, display: false + AmmoCapacity: 15, display: true + AmmoType: TurretRailgunUpgradeAmmo, display: true + ReloadDelay: 8, display: true + } +} +{ +Item Id: 943, Name: TurretRailgunFactionAmmo + Info: bkiTurretRailgunFactionAmmo, display: true + CustomIcon: RailgunBullet + Meshfile: Entities/Items/Weapons/Projectiles/rocket01Prefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 49, type: float, display: true, formatter: Kilogram + Volume: 32, type: float, display: true, formatter: Liter + Category: Weapons/Items + MarketPrice: 295, display: true + { Child 0 + Class: Projectile + Damage: 5858 + BlastParticleIndex: 3 + BlastRadius: 4, display: true + BlastDamage: 5800, display: true + Speed: 450 + Range: 700 + SpeedSpace: 550 + RangeSpace: 1200 + # HomingSpeed: 1.5 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} +{ +Item Id: 944, Name: TurretRailgunUpgradeAmmo, Ref: TurretRailgunFactionAmmo + Info: bkiTurretRailgunUpgradeAmmo, display: true + { Child 0 + Damage: 5858 + BlastRadius: 4, display: true + BlastDamage: 5800, display: true + Speed: 450 + Range: 700 + SpeedSpace: 550 + RangeSpace: 1200 + #HomingSpeed: 1.5 + } +} +{ +Item Id: 945, Name: TurretPulseLaserFactionWeapon + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 11760, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 635, display: false + RangeSpace: 1085, display: false + AmmoCapacity: 150, display: true + AmmoType: TurretPulseLaserFactionAmmo, display: true + NoiseStrength: 100, display: false + Automatic: true + CameraShake: 2 + AutoReload: true + ReloadDelay: 1, display: true # adapt sound + SfxBegin: Weapons/Turret/laser_turret_fire2 + # SfxBegin: Weapons/EnergyWeapons/weapon_laser_003 + # SfxBegin: Weapons/SS/LaserSS/weapon_laser_001 + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} + +{ +Item Id: 946, Name: TurretPulseLaserUpgradeWeapon, Ref: TurretPulseLaserFactionWeapon + { Child 0 + Class: LauncherSS + ROF: 0.6, type: float, display: true, formatter: ROF + Range: 635, display: false + RangeSpace: 1085, display: false + AmmoCapacity: 150, display: true + AmmoType: TurretPulseLaserUpgradeAmmo, display: true + ReloadDelay: 1, display: true # adapt sound + } +} +{ +Item Id: 947, Name: TurretPulseLaserFactionAmmo + Info: bkiTurretPulseLaserFactionAmmo, display: true + CustomIcon: PulseLaserChargeBA + Meshfile: Entities/Items/Weapons/Projectiles/LaserShotBlueTurret + # Meshfile: Entities/Items/Weapons/Projectiles/LaserShot02 + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 15, type: float, display: true, formatter: Kilogram + Volume: 5, type: float, display: true, formatter: Liter + Category: Weapons/Items + MarketPrice: 4.5, display: true + { Child 0 + Class: Projectile + Damage: 1050, display: true # new: because we removed explosion damage + BlastParticleIndex: 0 + # BlastRadius: 2, display: true + # BlastDamage: 400, display: true + Speed: 750 + Range: 650 + SpeedSpace: 850 + RangeSpace: 1100 + Ballistic: false + + DamageMultiplier_1: 0.25, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.25, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + } +} +{ +Item Id: 948, Name: TurretPulseLaserUpgradeAmmo, Ref: TurretPulseLaserFactionAmmo + Info: bkiTurretPulseLaserUpgradeAmmo, display: true + CustomIcon: PulseLaserChargeMS + { Child 0 + Class: Projectile + Damage: 1050, display: true # new: because we removed explosion damage + Speed: 750 + Range: 650 + SpeedSpace: 850 + RangeSpace: 1100 + } +} +{ +Item Id: 949, Name: TurretPlasmaFactionWeapon + Meshfile: @models/Blocks/Turrets/RealisticTurret/TurretGVPlasmaPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + ShowUser: NoButCSV # reported by HWS + Canhold: false + Material: metalweapon + HoldType: 0 + StackSize: 500 + Mass: 8490, type: float, display: false, formatter: Kilogram + Category: Weapons/Items + { Child 0 + Class: LauncherSS + AllowAt: "Space,Planet" + ROF: 8, type: float, display: true, formatter: ROF + Range: 715, display: false + RangeSpace: 1200, display: false + AmmoCapacity: 8, display: true + AutoReload: true + AmmoType: TurretPlasmaFactionAmmo, display: true + NoiseStrength: 120, display: false + CameraShake: 4 + ReloadDelay: 10, display: true + SfxBegin: Weapons/SS/LaserSS/weapon_blaster_003 + # SfxBegin: Weapons/SS/RocketAlienSS/BLASTERComplexFireTriggerPowerfulRelease + # SfxNoAmmo: Weapons/Misc/weapon_outofammo_006 + SfxReload: Weapons/Player/Misc/Reload/RifleBoltActionCock04_reload + # PfxMuzzle: turretMuzzleFire + } +} +{ +Item Id: 950, Name: TurretPlasmaUpgradeWeapon, Ref: TurretPlasmaFactionWeapon + { Child 0 + Class: LauncherSS + ROF: 8, type: float, display: true, formatter: ROF + Range: 715, display: false + RangeSpace: 1200, display: false + AmmoCapacity: 8, display: true + AmmoType: TurretPlasmaUpgradeAmmo, display: true + ReloadDelay: 10, display: true + } +} +{ +Item Id: 951, Name: TurretPlasmaFactionAmmo + Info: bkiTurretPlasmaFactionAmmo, display: true + CustomIcon: TurretBAPlasmaCharge + Meshfile: Entities/Items/Weapons/Projectiles/PlasmaProjectilePrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + AllowPlacingAt: MS, display: false + HoldType: 0 + StackSize: 2000 + Canhold: false + Mass: 50, type: float, display: true, formatter: Kilogram + Volume: 20, type: float, display: true, formatter: Liter + MarketPrice: 44, display: true + Category: Weapons/Items + { Child 0 + Class: Projectile + Damage: 15000 + BlastParticleIndex: 7 + BlastRadius: 3, display: true + BlastDamage: 15000, display: true + Speed: 400 + Range: 650 + SpeedSpace: 550 + RangeSpace: 1050 + Ballistic: false + + DamageMultiplier_1: 0.05, param1: head|body + DamageMultiplier_2: 0.01, param1: stone|dirt + DamageMultiplier_3: 10, param1: rock # can destroy rocks + DamageMultiplier_4: 0.083, param1: shield + BlastDamageMultiplier_1: 0.05, param1: head|body + BlastDamageMultiplier_2: 0.2, param1: stone|dirt + BlastDamageMultiplier_3: 0, param1: shield + } +} + +{ +Item Id: 952, Name: TurretPlasmaUpgradeAmmo, Ref: TurretPlasmaFactionAmmo + Info: bkiTurretPlasmaUpgradeAmmo, display: true + CustomIcon: TurretMSPlasmaCharge + { Child 0 + Class: Projectile + Damage: 15000 + BlastRadius: 3, display: true + BlastDamage: 15000, display: true + Speed: 400 + Range: 650 + SpeedSpace: 550 + RangeSpace: 1050 + } +} + +## NOT AVAILABLE - Blocked by Dark Faction Config: 953 to 974 + +{ +Item Id: 975, Name: TalonLaserPistol, Ref: TalonLaserWeapon + Meshfile: Entities/Items/Weapons/Pistols/LaserPistolNPCTalonarPrefab #Player version fit to NPC TalonAdv + # Meshfile: Entities/Items/Weapons/Pistols/ScifiPistol2BlueNPCPrefab # + # Meshfile: Entities/Items/Weapons/Enemies/TraderGunPrefab #more like a shotgun! + { Child 0 + Class: Ranged + ROF: 0.5, type: float, display: true, formatter: ROF + Range: 65, display: true + IgnoreAtmo: true # Ground NPC must ignore atmo - otherwise no particle is visible + Damage: 50, display: true + Automatic: false + SfxBegin: Weapons/Player/LaserRifle/BLASTERComplexFireTriggerDigitalDischarge + SfxLoop: Weapons/Enemies/Zirax/mute # define new Zirax and not extend from MinigunZirax + SfxStop: Weapons/Enemies/Zirax/mute + PfxMuzzle: SniperMuzzleLaserGreenBright + Tracer: Weapons/Projectiles/TracerLaserShotGreenBright + TracerPerBullet: 1 + TracerSpeed: 200 + } +} + +# CATEGORIE: Commodity Trade Items id 1000 - 1100 + +{ +Item Id: 1000, Name: TradingGoodsTemplate + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 10, type: float, display: true, formatter: Kilogram + Volume: 10, type: float, display: true, formatter: Liter + StackSize: 1000 + Info: CommodityItemsGlobal, display: true + Category: Components + ShowUser: No +} +{ +Item Id: 1001, Name: RawDiamond, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 350, display: true +} +{ +Item Id: 1002, Name: LuxuryGoods, Ref: TradingGoodsTemplate + MarketPrice: 1250, display: true + ShowUser: Yes +} +{ +Item Id: 1003, Name: Fertilizer, Ref: TradingGoodsTemplate + MarketPrice: 50, display: true + ShowUser: Yes +} +{ +Item Id: 1004, Name: Liquors, Ref: TradingGoodsTemplate + MarketPrice: 100, display: true + ShowUser: Yes + { Child 0 + Class: Eat + ROF: 1, type: float + AddHealth: -5, display: HealthValue + AddStamina: -20, display: StaminaValue + AddFood: -2, display: FoodValue + AddOxygen: 0 + AddTemperature: 10, display: TemperatureValue + SfxBegin: UseActions/playerDrinking + Buff: "Hangover" + Buff_chance: "0.25" + } +} +{ +Item Id: 1005, Name: Narcotics, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 1200, display: true +} +{ +Item Id: 1006, Name: Robotics, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 2500, display: true +} +{ +Item Id: 1007, Name: Optronics, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 350, display: true +} +{ +Item Id: 1008, Name: Leather, Ref: TradingGoodsTemplate + MarketPrice: 250, display: true + ShowUser: Yes +} +{ +Item Id: 1009, Name: ToxicWaste, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 3000, display: true +} +{ +Item Id: 1010, Name: AlienOrganism, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 2500, display: true +} +{ +Item Id: 1011, Name: AncientRelics, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 7500, display: true +} +{ +Item Id: 1012, Name: Coolant, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 150, display: true +} +{ +Item Id: 1013, Name: Chemicals, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 110, display: true +} +{ +Item Id: 1014, Name: Filters, Ref: TradingGoodsTemplate + MarketPrice: 120, display: true + ShowUser: Yes +} +{ +Item Id: 1015, Name: SpareParts, Ref: TradingGoodsTemplate + MarketPrice: 250, display: true + ShowUser: Yes +} +{ +Item Id: 1016, Name: ScienceEquipment, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 1750, display: true +} +{ +Item Id: 1017, Name: MutatedSubstance, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 3500, display: true +} +{ +Item Id: 1018, Name: TechnicalArtifact, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 8000, display: true +} +{ +Item Id: 1019, Name: ToxicLiquid, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 2500, display: true +} +{ +Item Id: 1020, Name: AlphaVirus, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 9500, display: true +} +{ +Item Id: 1021, Name: Tablet, Ref: TradingGoodsTemplate + ShowUser: Yes + MarketPrice: 50, display: true +} +{ +Item Id: 1022, Name: FishMeat, Ref: TradingGoodsTemplate + # CustomIcon: Meat + ShowUser: Yes + MarketPrice: 30, display: true +} +{ +Item Id: 1023, Name: ChitinScale, Ref: TradingGoodsTemplate + #CustomIcon: AlienParts03 + ShowUser: Yes + MarketPrice: 350, display: true +} +{ +Item Id: 1024, Name: AlienCrabMeat, Ref: TradingGoodsTemplate + # CustomIcon: Meat #Icon required + ShowUser: Yes + MarketPrice: 35, display: true +} +{ +Item Id: 1025, Name: AlienSting, Ref: TradingGoodsTemplate + #CustomIcon: AlienThorn + ShowUser: Yes + MarketPrice: 25, display: true +} +{ +Item Id: 1026, Name: RaptorMeat, Ref: TradingGoodsTemplate + # CustomIcon: Meat #Icon required + ShowUser: Yes + MarketPrice: 50, display: true +} + +# Jet Thruster / Crafting Components +{ +Item Id: 1027, Name: PropulsionComponents, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 56, display: true + Mass: 24.28, type: float, display: true, formatter: Kilogram + Volume: 3.5, type: float, display: true, formatter: Liter +} +{ +Item Id: 1028, Name: BuildingFrameLight, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 220, display: true + Mass: 30, type: float, display: true, formatter: Kilogram + Volume: 9, type: float, display: true, formatter: Liter +} +{ +Item Id: 1029, Name: BuildingFrameStandard, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 260, display: true + Mass: 60, type: float, display: true, formatter: Kilogram + Volume: 18, type: float, display: true, formatter: Liter +} +{ +Item Id: 1030, Name: BuildingFrameReinforced, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 350, display: true + Mass: 120, type: float, display: true, formatter: Kilogram + Volume: 36, type: float, display: true, formatter: Liter +} +{ +Item Id: 1031, Name: HighTemperatureComponentsT1, Ref: ComponentsTemplate + ShowUser: Yes + MarketPrice: 600, display: true + Mass: 75, type: float, display: true, formatter: Kilogram + Volume: 30, type: float, display: true, formatter: Liter +} +#{ +Item Id: 1032, Name: HighTemperatureComponentsT2, Ref: ComponentsTemplate +# ShowUser: Yes +# MarketPrice: 750, display: true +# Mass: 85, type: float, display: true, formatter: Kilogram +# Volume: 45, type: float, display: true, formatter: Liter +#} + +# == Reports Common +{ +Item Id: 1049, Name: ReportsTemplate + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 0, type: float, display: false, formatter: Kilogram + Volume: 0, type: float, display: false, formatter: Liter + StackSize: 1000 + Info: bkiReportsGlobalInfo, display: true + Category: Components + ShowUser: No +} +#Reports Common 80% +{ +Item Id: 1050, Name: ReportMaintenanceData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlack + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 300, display: true + Mass: 0, type: float, display: false, formatter: Kilogram + Volume: 0, type: float, display: false, formatter: Liter +} +{ +Item Id: 1051, Name: ReportWorkShiftData, Ref: ReportsTemplate + #CustomIcon: ReportWorkShiftData + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 300, display: true +} +{ +Item Id: 1052, Name: ReportTransportationData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlack + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 300, display: true +} +{ +Item Id: 1053, Name: ReportCommunicationData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlack + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 400, display: true +} +{ +Item Id: 1054, Name: ReportEconomicData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlack + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 500, display: true +} +# == Reports Science 60% +{ +Item Id: 1060, Name: ReportScienceRawData, Ref: ReportsTemplate + #CustomIcon: KeyCardGreen + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 1500, display: true +} +{ +Item Id: 1061, Name: ReportBiologicalData, Ref: ReportsTemplate + #CustomIcon: KeyCardGreen + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 1500, display: true +} +{ +Item Id: 1062, Name: ReportMaterialData, Ref: ReportsTemplate + #CustomIcon: KeyCardGreen + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 1500, display: true +} +{ +Item Id: 1063, Name: ReportArcheologicalData, Ref: ReportsTemplate + #CustomIcon: KeyCardGreen + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 1500, display: true +} +{ +Item Id: 1064, Name: ReportAnomalousData, Ref: ReportsTemplate + #CustomIcon: KeyCardGreen + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 1500, display: true +} + +# NOT AVAILABLE - Blocked by Dark Faction Config: 1065 1066 +# == Reports Exploration 40% +{ +Item Id: 1070, Name: ReportExplorationRawData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlue + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 3000, display: true +} +{ +Item Id: 1071, Name: ReportGeologicalData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlue + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 3000, display: true +} +{ +Item Id: 1072, Name: ReportAtmosphericData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlue + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 3000, display: true +} +{ +Item Id: 1073, Name: ReportAstronomicalData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlue + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 3000, display: true +} +{ +Item Id: 1074, Name: ReportPlanetaryResourceData, Ref: ReportsTemplate + #CustomIcon: KeyCardBlue + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 3000, display: true +} +# == Reports Security 20% +{ +Item Id: 1080, Name: ReportSpaceWeather, Ref: ReportsTemplate + #CustomIcon: KeyCardYellow + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 5000, display: true +} +{ +Item Id: 1081, Name: ReportNavigationData, Ref: ReportsTemplate + #CustomIcon: KeyCardYellow + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 5000, display: true +} +{ +Item Id: 1082, Name: ReportSpaceAnomality, Ref: ReportsTemplate + #CustomIcon: KeyCardYellow + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 5000, display: true +} +{ +Item Id: 1083, Name: ReportWarpSignatures, Ref: ReportsTemplate + #CustomIcon: KeyCardYellow + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 6000, display: true +} +{ +Item Id: 1084, Name: ReportWeaponSignatures, Ref: ReportsTemplate + #CustomIcon: KeyCardYellow + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 6000, display: true +} +# == Reports Military 10% +{ +Item Id: 1090, Name: ReportMilitaryRawData, Ref: ReportsTemplate + #CustomIcon: KeyCardRed + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 8000, display: true +} +{ +Item Id: 1091, Name: ReportMilitaryScouting, Ref: ReportsTemplate + #CustomIcon: KeyCardRed + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 8000, display: true +} +{ +Item Id: 1092, Name: ReportMilitarySurveillance, Ref: ReportsTemplate + #CustomIcon: KeyCardRed + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 8000, display: true +} +{ +Item Id: 1093, Name: ReportMilitaryTroopMovement, Ref: ReportsTemplate + #CustomIcon: KeyCardRed + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 8000, display: true +} +{ +Item Id: 1094, Name: ReportMilitaryClassified, Ref: ReportsTemplate + #CustomIcon: KeyCardRed + Info: bkiReportsGlobalInfo, display: true + ShowUser: Yes + MarketPrice: 8000, display: true +} +# ========================================= Special Story and Mission Items ========================== +{ +Item Id: 1100, Name: MeleeTalonGuardianCharged, Ref: TradingGoodsTemplate + Info: bkiStaffOfGuidance, display: true + ShowUser: Yes +} +{ +Item Id: 1101, Name: TalonCrossbowWeaponCharged, Ref: TradingGoodsTemplate + Info: bkiTalonCrossbowWeaponCharged, display: true + ShowUser: Yes +} +{ +Item Id: 1102, Name: TurretEnemyBallistaCharged, Ref: TradingGoodsTemplate + Info: bkiTalonTurretEnemyBallistaCharged, display: true + ShowUser: Yes +} +{ +Item Id: 1103, Name: RingOfTheHouseOfAbyssal, Ref: TradingGoodsTemplate + CustomIcon: LuxuryGoods #Icon required + Info: bkiRingOfTheHouseOfAbyssal, display: true + ShowUser: Yes +} +{ +Item Id: 1104, Name: Presents, Ref: ComponentsTemplate + StackSize: 4000 + # CustomIcon: Tablet + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + Info: bkiPresents, display: true + ShowUser: Yes + MarketPrice: 5, display: true +} +# ========================================= Suit Props ========================== +{ +Item Id: 1150, Name: SuitPropsTemplate + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + StackSize: 1 + Info: SuitPropsGlobal, display: true + Category: Components + ShowUser: No +} +{ +Item Id: 1151, Name: SantaClausHat, Ref: SuitPropsTemplate + Info: bkiSantaClausHat, display: true + PropHead: SantaClausHat + ShowUser: Yes +} +{ +Item Id: 1152, Name: SnowmanHead, Ref: SuitPropsTemplate + Info: bkiSnowmanHead, display: true + PropHead: SnowmanHead + ShowUser: Yes +} +# ========================================= STATUS EFFECT TEST ========================== +{ Item Id: 1200, Name: FlameThrowerDEBUG + Meshfile: Entities/Items/Weapons/Flamethrower/SciFiFlamethrowerPrefab + PickupToToolbar: true + Material: metalweapon + HoldType: 16 + StackSize: 1 + Category: Weapons/Items + Mass: 8, type: float, display: false, formatter: Kilogram + Info: bkiDrill, display: true + # Info: bkiDrillT2, display: true + Durability: 50000, display: false + DegradationProb: 0.00, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + + ShowUser: No + + { Child 0 + Class: Ranged + ROF: 0.25, type: float + Range: 8, display: false + BulletsPerShot: 8 + BulletSpread: 0 + RaySpread: 1, display: true + Recoil: 0, display: true + NoiseStrength: 40, display: false + CameraShake: 0 + IgnoreAtmo: true + Buff: "Fire" + Buff_chance: 1 + Automatic: true + Damage: 50 + Radius: 3 + AmmoCapacity: 750, display: true + AmmoType: BioFuel, display: true + UseSingleMagazine: true + ReloadDelay: 2.9, display: true + Bullet_use_per_shot: 0.25 + # Particles: diggerplasma # particle effect shown at impact + PfxMuzzle: flamethrower_v1 + # ParticleTransform: Muzzle/Flamethrower_v1 # particle effect shown at muzzle + # SfxBegin: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_start + # SfxLoop: Weapons/Player/Drill/THRUSTERFlangerShiftingBurningAirHot_loop + SfxBegin: Weapons/Player/Drill/weapon_flame_005_start + SfxLoop: Weapons/Player/Drill/weapon_flame_005_loop + SfxStop: Weapons/Player/Drill/Mute + SfxReload: UseActions/oxygen_tank_release_air + # SfxReload: Weapons/Player/Drill/WaltherP99MagInQuick01 + DamageMultiplier_1: 0.1, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0.1, param1: wood + DamageMultiplier_3: 0.001, param1: stone + DamageMultiplier_4: 0.1, param1: rock + DamageMultiplier_5: 0.001, param1: dirt + DamageMultiplier_6: 0.001, param1: hullarmored + DamageMultiplier_7: 0.001, param1: hullcombat + } + + UnlockCost: 0 + UnlockLevel: 0 + TechTreeNames: Hidden +} + + +{ Item Id: 1201, Name: WeakAssaultRifleDEBUG, Ref: AssaultRifle + Meshfile: Entities/Items/Weapons/Rifles/ScifiRifle2WeakDebugPrefab + PickupToToolbar: true + Info: bkiEpicWeapon, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 2400, display: false + DegradationProb: 0.0, type: float, display: false + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + + ShowUser: No + + { Child 0 + ROF: 0.15, type: float, display: true, formatter: ROF + Range: 1000, display: false + Damage: 1, display: true + Recoil: 0, display: true + CameraShake: 0 + AmmoCapacity: 50, display: true + ReloadDelay: 3.2, display: false + AmmoType: AmmoInfinite, display: true + Buff: "OpenWound,FractureClosed" + Buff_chance: "1,0.5" + AmmoUnlimited: true + ParticleTransform: # Particle1 contains the destroy blocks effect + SfxBegin: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_end + DamageMultiplier_1: 2.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 1, param1: wood + DamageMultiplier_3: 0.001, param1: stone + DamageMultiplier_4: 1, param1: rock + DamageMultiplier_5: 0.001, param1: dirt + DamageMultiplier_6: 1, param1: hullarmored + DamageMultiplier_7: 1, param1: hullcombat + } + { Child 1 + Class: DestroyBlocks + Damage: 150, display: true + PfxMuzzle: + Tracer: + + DamageMultiplier_1: 10, param1: metal|metalhard + DamageMultiplier_2: 20, param1: hull|hullarmored|hullcombat|concrete|woodblock + DamageMultiplier_3: 0, param1: stone|dirt + } + # { Child 1 + # Class: Zoom + # ZoomOverlay: Entities/Items/Weapons/SniperScopes/ScopeNew + # ZoomOut: 40 + # ZoomIn: 10 + # } + UnlockCost: 0 + UnlockLevel: 20 + TechTreeNames: Hidden +} +{ Item Id: 1202, Name: DrillDebug123, Ref: DrillT2 + + ShowUser: No + + { Child 0 + Range: 12, display: false + Damage: 80 + Radius: 4 + } + UnlockCost: 0 + UnlockLevel: 20 + TechTreeNames: Hidden +} + +# The decal gun puts decals on structures +# Use RMB to switch between selecting existing decals, adding new decals and removing delals +# LeftShift + Scrollwheel: scales decal +# LeftControl + Scrollwheel: rotates decal +# LeftShift + LeftControl + Scrollwheel: change decal distance it snaps to a wall +# See SharedData/Content/Prefabs/decalsdef.txt: you can define guns for different groups +{ +Item Id: 1203, Name: DecalGunDebug + CustomIcon: ColorTool + Meshfile: Entities/Items/Tools/ColorToolPrefab + PickupToToolbar: true + Material: metalweapon + HoldType: 14 + StackSize: 1 + Category: Weapons/Items + Info: "-[00ff00]LeftShift + Scrollwheel[-] to scale a decal\n\n-[00ff00]LeftControl + Scrollwheel[-] to rotate a decal\n\n-[00ff00]LeftShift + LeftControl + Scrollwheel[-] to change decal distance it snaps to a wall\n\n-[00ff00]PageUp[-] / [00ff00]PageDown[-] to cycle between decals in the same group", display: true + + Mass: 2.1, type: float, display: true, formatter: Kilogram + Volume: 1, type: float, display: true, formatter: Liter + + RadialMenu: true + + { Child 0 + Class: Decal + DecalMode: Select + + RadialText: "Select Decal" + RadialDesc: "Select and move an existing decal" + } + + { Child 1 + Class: Decal + DecalMode: Place + # See SharedData/Content/Prefabs/decalsdef.txt: you can define guns for different groups + DecalGroup: Group_1 + + RadialText: "Add Decal 1" + RadialDesc: "Place a new decal from group 1" + } + + { Child 2 + Class: Decal + DecalMode: Place + DecalGroup: Group_2 + + RadialText: "Add Decal 2" + RadialDesc: "Place a new decal from group 2" + } + + { Child 3 + Class: Decal + DecalMode: Place + DecalGroup: Group_2 + + RadialText: "Sound Decals" + RadialDesc: "Dark Faction Sound Decals" + } + + { Child 4 + Class: Decal + DecalMode: Remove + + + RadialText: "Remove Decal" + RadialDesc: "Point and click at a decal to remove it" + } +} + +# ========================================= SPECIAL ITEMS ========================== +{ +Item Id: 1299, Name: KeysTemplate + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Mass: 0, type: float, display: false, formatter: Kilogram + Volume: 0, type: float, display: false, formatter: Liter + StackSize: 1000 + Info: bkiKeysGeneral, display: true + Category: Components + ShowUser: No + MarketPrice: 1000, display: true + DropOnDeath: false +} +{ +Item Id: 1300, Name: KeyCardBlack, Ref: KeysTemplate + ShowUser: Yes +} +{ +Item Id: 1301, Name: KeyCardBlue, Ref: KeysTemplate + ShowUser: Yes +} +{ +Item Id: 1302, Name: KeyCardGreen, Ref: KeysTemplate + ShowUser: Yes +} +{ +Item Id: 1303, Name: KeyCardYellow, Ref: KeysTemplate + ShowUser: Yes +} +{ +Item Id: 1304, Name: KeyCardRed, Ref: KeysTemplate + ShowUser: Yes +} +{ +Item Id: 1305, Name: Token + Class: Token + + CustomIcon: KeyCardBlack + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalitem + Canhold: false + HoldType: 0 + Info: Don't write anything here, display: true + Category: Components + + StackSize: 1 + ShowUser: Yes +} + +{ +Item Id: 1306, Name: FireStickRed, Ref: KeysTemplate + #CustomIcon: KeyCardRed + Info: bkiFireStickRed, display: true + MarketPrice: 4000, display: true + ShowUser: Yes +} +{ +Item Id: 1307, Name: FireStickBlue, Ref: KeysTemplate + #CustomIcon: KeyCardBlue + Info: bkiFireStickBlue, display: true + ShowUser: Yes + MarketPrice: 2000, display: true +} +{ +Item Id: 1308, Name: FireStickGreen, Ref: KeysTemplate + #CustomIcon: KeyCardGreen + Info: bkiFireStickGreen, display: true + ShowUser: Yes + MarketPrice: 1000, display: true +} +{ +Item Id: 1309, Name: KeyCardCommon, Ref: KeysTemplate + #CustomIcon: KeyCardBlack + Info: bkiClearanceKeysGeneral, display: true + MarketPrice: 120, display: true + ShowUser: Yes +} +{ +Item Id: 1310, Name: KeyCardScience, Ref: KeysTemplate + #CustomIcon: KeyCardGreen + Info: bkiClearanceKeysGeneral, display: true + MarketPrice: 800, display: true + ShowUser: Yes +} +{ +Item Id: 1311, Name: KeyCardExploration, Ref: KeysTemplate + #CustomIcon: KeyCardBlue + Info: bkiClearanceKeysGeneral, display: true + ShowUser: Yes + MarketPrice: 1500, display: true +} +{ +Item Id: 1312, Name: KeyCardSecurity, Ref: KeysTemplate + #CustomIcon: KeyCardYellow + Info: bkiClearanceKeysGeneral, display: true + ShowUser: Yes + MarketPrice: 2500, display: true +} +{ +Item Id: 1313, Name: KeyCardMilitary, Ref: KeysTemplate + #CustomIcon: KeyCardRed + Info: bkiClearanceKeysGeneral, display: true + ShowUser: Yes + MarketPrice: 4500, display: true +} +{ +Item Id: 1314, Name: ExplorerToken, Ref: KeysTemplate + # CustomIcon: Tablet + DropOnDeath: false + Info: bkiExplorerToken, display: true + ShowUser: Yes + # MarketPrice: 4500, display: true +} +{ +Item Id: 1315, Name: MerchantToken, Ref: KeysTemplate + #CustomIcon: Tablet + DropOnDeath: false + Info: bkiMerchantToken, display: true + ShowUser: Yes + # MarketPrice: 4500, display: true +} +{ +Item Id: 1316, Name: TraderToken, Ref: KeysTemplate + #CustomIcon: Tablet + DropOnDeath: false + Info: bkiTraderToken, display: true + ShowUser: Yes + # MarketPrice: 4500, display: true +} +{ +Item Id: 1317, Name: TutorialSurvivalTool, Ref: KeysTemplate #Video Manual Consumable + CustomIcon: Tablet + DropOnDeath: false + Info: bkiTutorialSurvivalTool, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/PDADropPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 0, type: float, display: true, formatter: Kilogram + Volume: 0, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + StackSize: 4000 + ShowUser: No + { Child 0 + Class: Eat + ROF: 1, type: float + } +} +{ +Item Id: 1318, Name: TutorialHVBuilding, Ref: KeysTemplate #Video Manual Consumable + CustomIcon: Tablet + DropOnDeath: false + Info: bkiTutorialHVBuilding, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/PDADropPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 0, type: float, display: true, formatter: Kilogram + Volume: 0, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + StackSize: 4000 + ShowUser: No + { Child 0 + Class: Eat + ROF: 1, type: float + } +} +{ +Item Id: 1319, Name: TutorialBABuilding, Ref: KeysTemplate #Video Manual Consumable + CustomIcon: Tablet + DropOnDeath: false + Info: bkiTutorialBABuilding, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/PDADropPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 0, type: float, display: true, formatter: Kilogram + Volume: 0, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + StackSize: 4000 + ShowUser: No + { Child 0 + Class: Eat + ROF: 1, type: float + } +} +{ +Item Id: 1320, Name: TutorialHUDViewPlayer, Ref: KeysTemplate #Video Manual Consumable + CustomIcon: Tablet + DropOnDeath: false + Info: bkiTutorialHUDViewPlayer, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/PDADropPrefab #Available variants: BagSmallChristmasPrefab + Material: metal + Mass: 0, type: float, display: true, formatter: Kilogram + Volume: 0, type: float, display: true, formatter: Liter + Canhold: false + HoldType: 0 + StackSize: 4000 + ShowUser: No + { Child 0 + Class: Eat + ROF: 1, type: float + } +} +{ +Item Id: 1321, Name: AIDroneBayWeapon #Not required? + Info: bkiTutorialHUDViewPlayer, display: true + Meshfile: Entities/Misc/DummyPrefab + DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab + Material: metalweapon + HoldType: 0 + Canhold: false + StackSize: 10 + Category: Weapons/Items + ShowUser: No + Mass: 0.01, type: float, display: true, formatter: Kilogram + Volume: 0.01, type: float, display: true, formatter: Liter +} + +# ============================================================================= +# ================================ +# ======== EVENT WEAPON & Items == +# ================================ + +{ +Item Id: 1400, Name: ShotgunChristmas, Ref: Shotgun2Epic + #CustomIcon: Shotgun2Epic + Info: bkiShotgunChristmas, display: true + Meshfile: Entities/Items/Weapons/Shotguns/Shotgun1ChristmasPrefab +} +{ +Item Id: 1401, Name: AssaultRifleChristmas, Ref: AssaultRifle + #CustomIcon: AssaultRifleEpic + Info: bkiAssaultRifleChristmas, display: true + Meshfile: Entities/Items/Weapons/Rifles/AssaultRifle1ChristmasPrefab + PickupToToolbar: true + MarketPrice: 3560, display: true + OverrideTradingConstraints: true # even if other constraints exist allow trading this item + Durability: 500, display: false + DegradationProb: 0.039, type: float, display: false + RepairDisabled: true, display: true + SfxJammed: Weapons/Player/Reload/WeaponJammedAutoFire + { Child 0 + ROF: 0.170, type: float, display: true, formatter: ROF + Range: 198, display: false + NoiseStrength: 100, display: false + BulletSpread: 0.06, display: true + Recoil: 0.288, display: true + Damage: 110, display: true + AmmoCapacity: 45, display: true + ReloadDelay: 3, display: true + SfxBegin: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_start + SfxLoop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_loop + SfxStop: Weapons/ProjectileWeapons/Automatic/FAL_50-42_Modern_end + # SfxBegin: Weapons/ProjectileWeapons/Automatic/M14_Heavy_start + # SfxLoop: Weapons/ProjectileWeapons/Automatic/M14_Heavy_loop + # SfxStop: Weapons/ProjectileWeapons/Automatic/M14_Heavy_end + + DamageMultiplier_1: 4.5, param1: head, display: DmgMultiplierHead + DamageMultiplier_2: 0, param1: dirt|stone|rock|wood|hullarmored|hullcombat + DamageMultiplier_3: 0, param1: shield + } + { Child 1 + Class: Zoom + ZoomOut: 40 + ZoomIn: 40 + # RedDot: true + } + + UnlockCost: 0 + UnlockLevel: 25 + TechTreeParent: "" + TechTreeNames: Hidden +} +{ +Item Id: 1402, Name: AlienCookie, Ref: FoodTemplate #Seasonal Baking + Info: bkiAlienCookie, display: true + StackSize: 10 + FoodDecayTime: 150, display: true, type: int + FoodDecayedItem: RottenFood + MarketPrice: 150, display: true + ShowUser: Yes + { Child 0 + Class: Eat + AddHealth: 250, display: HealthValue + AddStamina: 100, display: StaminaValue + AddFood: 500, display: FoodValue + AddOxygen: 20, display: OxygenValue + # AddTemperature: 3, display: TemperatureValue + AddRadiation: -2, display: RadiationValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 1403, Name: MushroomEggRoll, Ref: FoodTemplate #Seasonal Baking + Info: bkiMushroomEggRoll, display: true + StackSize: 10 + FoodDecayTime: 30, display: true, type: int + FoodDecayedItem: RottenFood + MarketPrice: 120, display: true + ShowUser: Yes + { Child 0 + Class: Eat + AddHealth: 125, display: HealthValue + AddStamina: 50, display: StaminaValue + AddFood: 250, display: FoodValue + AddOxygen: 40, display: OxygenValue + AddTemperature: 2, display: TemperatureValue + AddRadiation: -1, display: RadiationValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 1404, Name: FestiveCake, Ref: FoodTemplate #Seasonal Baking + Info: bkiFestiveCake, display: true + StackSize: 10 + FoodDecayTime: 100, display: true, type: int + FoodDecayedItem: RottenFood + MarketPrice: 90, display: true + ShowUser: Yes + { Child 0 + Class: Eat + AddHealth: 60, display: HealthValue + AddStamina: 25, display: StaminaValue + AddFood: 125, display: FoodValue + AddOxygen: 5 + AddTemperature: 1, display: TemperatureValue + # AddRadiation: -2, display: RadiationValue + SfxBegin: UseActions/playerEatingSoft + } +} + +{ +Item Id: 1405, Name: MushroomSweet, Ref: FoodTemplate #Seasonal Baking + Info: bkiMushroomSweet, display: true + StackSize: 10 + FoodDecayTime: 50, display: true, type: int + FoodDecayedItem: RottenFood + MarketPrice: 50, display: true + ShowUser: Yes + { Child 0 + Class: Eat + AddHealth: 30, display: HealthValue + AddStamina: 20, display: StaminaValue + AddFood: 60, display: FoodValue + AddOxygen: 0 + # AddTemperature: 1, display: TemperatureValue + # AddRadiation: -2, display: RadiationValue + SfxBegin: UseActions/playerEatingSoft + } +} +{ +Item Id: 1406, Name: ToothFlour, Ref: FoodTemplate + Info: bkiToothFlour, display: true + Category: Food + MarketPrice: 40, display: true + FoodDecayTime: 150, display: true, type: int + FoodDecayedItem: RottenFood + #ShowUser: No +} + +{ +Item Id: 1407, Name: GreenMilk, Ref: FoodTemplate + Info: bkiGreenMilk, display: true + Category: Food + MarketPrice: 30, display: true + FoodDecayTime: 20, display: true, type: int + FoodDecayedItem: RottenFood + #ShowUser: No +} +{ +Item Id: 1408, Name: AlienLemonSpice, Ref: FoodTemplate + Info: bkiAlienLemonSpice, display: true + Category: Food + MarketPrice: 60, display: true + FoodDecayTime: 200, display: true, type: int + FoodDecayedItem: RottenFood + #ShowUser: No +} +{ +Item Id: 1409, Name: EggMultiColor, Ref: FoodTemplate + Info: bkiEggMultiColor, display: true + MarketPrice: 100, display: true + Mass: 0.6, type: float, display: true, formatter: Kilogram + Volume: 0.5, type: float, display: true, formatter: Liter + Category: Food + FoodDecayTime: 60, display: true, type: int + FoodDecayedItem: RottenFood + # ShowUser: Yes +} +{ +Item Id: 1410, Name: BakingRecipeBook, Ref: TradingGoodsTemplate + ShowUser: Yes + Info: bkiBakingRecipeBook, display: true + MarketPrice: 23, display: true +} + +## Testing + +#{ +Item Id: 1444, Name: DeployableShield #DO NOT USE YET! +# MarketPrice: 340, display: true +# Meshfile: Entities/Misc/DeployableShieldPrefab +# DropMeshfile: Entities/Misc/BagSmallNewPrefab #Available variants: BagSmallChristmasPrefab +# PickupToToolbar: true +# Material: metalitem +# Canhold: false +# HoldType: 0 +# StackSize: 2000 +# Mass: 0.5, type: float, display: true, formatter: Kilogram +# Volume: 1, type: float, display: true, formatter: Liter +# Category: Weapons/Items +# Info: bkiExplosives, display: true +# { Child 0 +# Class: PlaceShield +# Entity: DeployableShieldDevice +# AllowRemote: true +# ROF: 1, type: float +# Range: 2 +# NoiseStrength: 100, display: false +# # SfxBegin: Shield/wpn_scifi_shield_up +# +# } +# +# UnlockCost: 7, display: true +# UnlockLevel: 5, display: true +# TechTreeNames: Weapons +#} +## NOT AVAILABLE - Blocked by Dark Faction Config: 1448 - 1467 \ No newline at end of file diff --git a/pkg/empyrionitems/Templates.ecf b/pkg/empyrionitems/Templates.ecf new file mode 100644 index 00000000..d74edfdc --- /dev/null +++ b/pkg/empyrionitems/Templates.ecf @@ -0,0 +1,6061 @@ +## Target Abbreviations / CraftTime Factors: +## Target Abbreviations / CraftTime Factors: +## SuitC = Survival Constructor - factor: 1.8 +## SurvC = Portable Constructor (ConstructorSurvival) - factor: 1.6 +## SmallC = ConstructorSV - factor: 1.4 +## HoverC = ConstructorHV - factor: 1.4 +## BaseC = ConstructorT0 - factor: 1.2 +## LargeC = ConstructorT1V2 - factor: 1 +## AdvC = ConstructorT2 - factor: 0.5 +## FoodP = FoodProcessorV2 - factor: 1 +## Furn = Furnace - factor: 0.2 +## (no tag) Deconstructor - factor: 0.5 +## Create an item for free = Set all lines of CHILD INPUTS to 0. Do NOT remove! +## 'BaseItem: true' (i) Defines basic level for BP factory. (ii) Will be used for repairing in R2T +## Note on Deconstructor: The decon will split items and devices until the template only has ingredients made from BaseItems. Example: IronOre has 'BaseItem: true' > IronIngot will not be broken down into Ore! +## Add 'DeconOverride: Continue' to allow Deconstructor to split the device or item one more time +## Add 'DeconOverride: Stop' to prevent deconstruction of the device or item + +## Adding T1 devices as Input to T2 devices only works for single devices (eg Constructor T1/T2) - does not work for block groups (eg Cockpits T1/T2). For latter do not use T1 as Input + + # ======================= + # Reprocessed Ores + # ======================= + + +{ +Template Name: PlasticMaterial + BaseItem: true + #OutputCount: 4 + CraftTime: 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CrushedStone: 1 + } +} +{ +Template Name: IronOre + BaseItem: true # even though flag is set, it is not used for R2T repairing (NoRepairInputItem: true) + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CrushedStone: 15 + } +} +{ +Template Name: CopperOre + BaseItem: true # even though flag is set, it is not used for R2T repairing (NoRepairInputItem: true) + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CrushedStone: 15 + } +} +{ +Template Name: SiliconOre + BaseItem: true # even though flag is set, it is not used for R2T repairing (NoRepairInputItem: true) + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CrushedStone: 15 + } +} +{ +Template Name: PentaxidOre + BaseItem: true # even though flag is set, it is not used for R2T repairing (NoRepairInputItem: true) + CraftTime: 120 + OutputCount: 105 + Target: "AdvC" + { Child Inputs + PromethiumOre: 5 + ErestrumOre: 5 + ZascosiumOre: 5 + SathiumOre: 5 + CrushedStone: 5 + PentaxidOre: 100 + } +} + # ======================= + # Common Material Ingots + # ======================= + +{ +Template Name: IronIngot + BaseItem: true #A1 + OutputCount: 2 + CraftTime: 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + IronOre: 1 + } +} + +{ +Template Name: CopperIngot + BaseItem: true #A1 + OutputCount: 2 + CraftTime: 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + CopperOre: 1 + } +} + +{ +Template Name: SiliconIngot + BaseItem: true #A1 + OutputCount: 2 + CraftTime: 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + SiliconOre: 1 + } +} + + # ======================= + # Advanced Material Ingots + # ======================= + +{ +Template Name: CobaltIngot + BaseItem: true #B1 + OutputCount: 10 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + CobaltOre: 5 + } +} + +{ +Template Name: NeodymiumIngot + BaseItem: true #C1 + OutputCount: 10 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + NeodymiumOre: 5 + } +} + +{ +Template Name: SathiumIngot + BaseItem: true #C1 + OutputCount: 10 + CraftTime: 5 + Target: "LargeC,AdvC,Furn" + { Child Inputs + SathiumOre: 5 + } +} + + # ======================= + # Special Material Ingots + # ======================= + +{ +Template Name: ErestrumIngot + BaseItem: true #D1 + OutputCount: 10 + CraftTime: 5 + Target: "LargeC,AdvC,Furn" + { Child Inputs + ErestrumOre: 5 + } +} + +{ +Template Name: ZascosiumIngot + BaseItem: true #D1 + OutputCount: 10 + CraftTime: 5 + Target: "LargeC,AdvC,Furn" + { Child Inputs + ZascosiumOre: 5 + } +} + +{ +Template Name: XenoSubstrate + BaseItem: true + CraftTime: 5 + Target: "" + { Child Inputs + } +} + + # ======================= + # Hidden Templates + # ======================= + +{ +Template Name: RawDiamond + BaseItem: true + CraftTime: 15 + Target: "" + { Child Inputs + } +} + + + + # ======================= + # Money and Payment Ingots + # ======================= + +{ +Template Name: GoldIngot + BaseItem: true #C1 + OutputCount: 1 + CraftTime: 5 + Target: "LargeC,AdvC,Furn" + { Child Inputs + GoldOre: 5 + } +} + + # ============================ + # Alloys and Composite materials + # ============================ + +{ +Template Name: ZascosiumAlloy + OutputCount: 5 #D1 + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + ErestrumIngot: 5 + ZascosiumIngot: 5 + } +} + +{ +Template Name: ErestrumGel + OutputCount: 1 #D1 + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + ErestrumIngot: 2 + } + } + + # ======================= + # Energy Material Sources + # ======================= + +{ +Template Name: PentaxidElement + OutputCount: 5 + CraftTime: 5 + #BaseItem: true + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PentaxidOre: 1 + } +} + +{ +Template Name: PentaxidCrystal + OutputCount: 2 #A1 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PentaxidOre: 1 + } +} + +{ +Template Name: PromethiumPellets + OutputCount: 10 #A1 + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PromethiumOre: 1 + } +} + + # ============================ + # Basic biological materials + # ============================ + +{ +Template Name: WaterJug + OutputCount: 1 + CraftTime: 5 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + IceBlocks: 5 + } +} + +{ +Template Name: WaterBottle + OutputCount: 10 + CraftTime: 5 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + WaterJug: 1 + } +} + +{ +Template Name: Fiber + BaseItem: true #A1 + OutputCount: 30 + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + WoodLogs: 1 + } +} + + + # ========================== + # Basic building materials + # ========================== + +{ +Template Name: Cement + CraftTime: 1 #A1 + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + RockDust: 2 + } +} + +{ +Template Name: WoodPlanks + BaseItem: true #A1 + OutputCount: 50 + CraftTime: 10 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + WoodLogs: 1 + } +} + +{ +Template Name: NCPowder + BaseItem: true #A1 + OutputCount: 20 + CraftTime: 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Fiber: 1 + } +} + +{ +Template Name: MagnesiumPowder + OutputCount: 2 #D1 + CraftTime: 1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MagnesiumOre: 1 + } +} + +{ +Template Name: RockDust + BaseItem: true #A1 + OutputCount: 4 + CraftTime: 1 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CrushedStone: 1 + } +} + + # =============== + # Energy items + # =============== + +{ +Template Name: EnergyCellSmall #T0 Energy Cell + OutputCount: 2 #A1 + CraftTime: 2 + DeconOverride: Stop + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + # IronIngot: 1 + # CopperIngot: 1 + SteelPlate: 5 + Electronics: 2 + } +} + +{ +Template Name: EnergyCell + OutputCount: 2 #A1 + CraftTime: 5 + DeconOverride: Stop + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PromethiumPellets: 10 + } +} + +{ +Template Name: EnergyCellLarge + OutputCount: 2 #A1 + CraftTime: 15 + DeconOverride: Stop + Target: "LargeC,AdvC" + { Child Inputs + PromethiumPellets: 50 + } +} + +# Temporarily deactivated +# { +Template Name: EnergyCellHydrogen + # OutputCount: 2 + # CraftTime: 5 + # Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # HydrogenBottle: 1 + # } +# } +{ +Template Name: FusionCell + OutputCount: 2 #C2 + CraftTime: 15 + DeconOverride: Stop + Target: "AdvC" + { Child Inputs + PromethiumPellets: 50 + HydrogenBottle: 10 + } +} + + # =================== + # Basic Components + # =================== + +{ +Template Name: SteelPlate + OutputCount: 10 #A1 + CraftTime: 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + IronIngot: 2 + } +} + +# no longer used as ingredient, acts as vendor trash for now +{ +Template Name: MetalPieces + OutputCount: 10 + CraftTime: 2 + Target: "" + { Child Inputs + IronIngot: 1 + } +} + +{ +Template Name: EnergyMatrix + CraftTime: 2 #B1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CobaltIngot: 2 + CopperIngot: 1 + SiliconIngot: 1 + } +} + +{ +Template Name: Electronics + OutputCount: 2 #A1 + CraftTime: 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CopperIngot: 2 + # SiliconIngot: 1 + } +} + +{ +Template Name: GlassPlate + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SiliconIngot: 1 + } +} + +{ +Template Name: OpticalFiber + OutputCount: 2 #A1 + CraftTime: 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SiliconIngot: 1 + } +} + +{ +Template Name: MechanicalComponents + OutputCount: 2 #A1 + CraftTime: 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + } +} + +{ +Template Name: Nanotubes + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlasticMaterial: 1 + } +} + +{ +Template Name: TitanRods + BaseItem: true #A1 + OutputCount: 10 #A1 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + TitanOre: 5 + } +} + +{ +Template Name: TitanPlates + OutputCount: 10 + CraftTime: 3 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + TitanRods: 5 + } +} + +{ +Template Name: AluminiumPowder + BaseItem: true #A1 + OutputCount: 10 #A1 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + AluminiumOre: 5 + } +} + +{ +Template Name: AluminiumCoil + OutputCount: 1 + CraftTime: 3 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + AluminiumPowder: 5 + } +} +{ +Template Name: PlatinBar + BaseItem: true #A1 + OutputCount: 10 #A1 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC,Furn" + { Child Inputs + PlatinOre: 5 + } +} + +{ +Template Name: PlatinOunce + OutputCount: 9 + CraftTime: 15 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlatinBar: 1 + } +} + +# =========== Building Frames + + +{ +Template Name: BuildingFrameLight + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + AluminiumCoil: 2 + } +} +{ +Template Name: BuildingFrameStandard + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + } +} +{ +Template Name: BuildingFrameReinforced + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + TitanPlates: 2 + } +} +## ==== Propulsion Components + +{ +Template Name: HighTemperatureComponentsT1 + CraftTime: 3 #D1 + Target: "LargeC,AdvC" + { Child Inputs + PlatinBar: 2 + } +} + +#{ +Template Name: HighTemperatureComponentsT2 +# CraftTime: 3 #D1 +# Target: "LargeC,AdvC" +# { Child Inputs +# PlatinBar: 4 +# } +#} + # ======================= + # Composite Components + # ======================= + + +{ +Template Name: Computer + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + OpticalFiber: 1 + Electronics: 2 + } +} + +{ +Template Name: Motor + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + Nanotubes: 3 + Electronics: 1 + } +} + +{ +Template Name: CobaltAlloy + OutputCount: 5 #C1 + CraftTime: 3 #B1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CobaltIngot: 3 + IronIngot: 2 + } +} + +{ +Template Name: SteelPlateArmored + OutputCount: 10 #C1 + CraftTime: 4 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SathiumIngot: 5 + } +} + +# { +Template Name: CapacitorComponent + # CraftTime: 2 #C2 + # Target: "AdvC" + # { Child Inputs + # Oscillator: 12 + # Nanotubes: 3 + # SteelPlate: 10 + # FluxCoil: 1 + # } +# } +{ +Template Name: CapacitorComponent + CraftTime: 2 #C2 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + Oscillator: 4 + Nanotubes: 3 + SteelPlate: 3 + } +} + + + # ====================== + # Advanced Components + # ====================== + +{ +Template Name: Oscillator + OutputCount: 4 #C1 + CraftTime: 3 #C2 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + NeodymiumIngot: 5 + CobaltIngot: 2 + TitanRods: 2 + } +} + +{ +Template Name: FluxCoil + CraftTime: 3 #C1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + TitanRods: 2 + NeodymiumIngot: 5 + CobaltIngot: 2 + } +} + +{ +Template Name: PowerCoil + CraftTime: 3 #D1 + Target: "LargeC,AdvC" + { Child Inputs + ErestrumIngot: 8 + ZascosiumIngot: 8 + FluxCoil: 1 + } +} + +#{ +Template Name: PropulsionComponentsSimple +# CraftTime: 3 #D1 +# Target: "LargeC,AdvC" +# { Child Inputs +# HighTemperatureComponentsT1: 2 +# } +#} +{ +Template Name: PropulsionComponents + CraftTime: 3 #D1 + Target: "LargeC,AdvC" + { Child Inputs + HighTemperatureComponentsT1: 4 + FluxCoil: 1 + } +} + +#{ +Template Name: PropulsionComponentsAdvanced +# CraftTime: 3 #D1 +# Target: "AdvC" +# { Child Inputs +# HighTemperatureComponentsT2: 4 +# FluxCoil: 2 +# } +#} + # ================= + # Currency Items + # ================= + +{ +Template Name: GoldCoins + OutputCount: 5 #A2 + CraftTime: 3 + Target: "LargeC,AdvC" + { Child Inputs + GoldIngot: 1 + } +} + + # ======================= + # New structure blocks + # ======================= + +{ +Template Name: SpawnNewBase + CraftTime: 12 #A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Core: 1 + } +} +# { +Template Name: SpawnNewBaseS + # CraftTime: 12 #A1 1 + # Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # Core: 1 + # } +# } +# { +Template Name: SpawnNewBaseM + # CraftTime: 12 #A1 1 + # Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # Core: 1 + # } +# } + +{ +Template Name: SpawnNewGV + CraftTime: 12 #A1 3 + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + HullSmallBlocks: 8 + Core: 1 + } +} + +{ +Template Name: SpawnNewSV + CraftTime: 12 #A2 5 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + HullSmallBlocks: 10 + Core: 1 + } +} + +{ +Template Name: SpawnNewCV + CraftTime: 50 #C1 7 + Target: "LargeC,AdvC" + { Child Inputs + HullLargeBlocks: 8 + FluxCoil: 2 + Core: 1 + # CockpitBlocksCV: 1 + } +} +# { +Template Name: SpawnNewCVS + # CraftTime: 50 #C1 7 + # Target: "LargeC,AdvC" + # { Child Inputs + # HullLargeBlocks: 8 + # FluxCoil: 2 + # Core: 1 + # # CockpitBlocksCV: 1 + # } +# } +# { +Template Name: SpawnNewCVM + # CraftTime: 50 #C1 7 + # Target: "LargeC,AdvC" + # { Child Inputs + # HullLargeBlocks: 8 + # FluxCoil: 2 + # Core: 1 + # # CockpitBlocksCV: 1 + # } +# } + + # ======================= + # Fixed Model Vessels + # ======================= + +{ +Template Name: PlayerBikeKit + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlasticMaterial: 5 + } +} + +{ +Template Name: PlayerBike + CraftTime: 2 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlayerBikeKit: 1 + } +} + + # ======================= + # Construction Blocks SMALL + # ======================= + +{ +Template Name: PlasticSmallBlocks + CraftTime: 2 + DeconOverride: Continue + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlasticMaterial: 1 + } +} + +{ +Template Name: TrussSmallBlocks + CraftTime: 2 #A1 HV SV A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + } +} + +{ +Template Name: HullSmallBlocks + CraftTime: 2 #A1 HV SV 1 50hp + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + } +} + +{ +Template Name: HullArmoredSmallBlocks + CraftTime: 2 #C1 HV SV 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + TitanPlates: 2 + } +} + +{ +Template Name: HullCombatSmallBlocks + CraftTime: 2 #C1 HV SV 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + SteelPlateArmored: 2 + } +} + + # ======================= + # Construction Blocks LARGE + # ======================= + +{ +Template Name: PlasticLargeBlocks + CraftTime: 2 + DeconOverride: Continue + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlasticMaterial: 2 + } +} + +{ +Template Name: WoodBlocks + CraftTime: 1 #A1 1 + DeconOverride: Continue + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + WoodPlanks: 1 + } +} + + +{ +Template Name: ConcreteBlocks + CraftTime: 1 #A1 1 600hp + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Cement: 1 + } +} + +{ +Template Name: ConcreteArmoredBlocks + CraftTime: 1 #A2 + Target: "LargeC,AdvC" + { Child Inputs + Cement: 3 + SteelPlate: 2 + } +} + +{ +Template Name: TrussLargeBlocks + CraftTime: 2 #A1 Ba CV A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + } +} + +{ +Template Name: HullLargeBlocks + CraftTime: 2 #A1 Ba CV 1 500hp + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 10 + } +} + +{ +Template Name: HullArmoredLargeBlocks + CraftTime: 3 #C1 Ba CV 1 1000hp + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 10 + TitanPlates: 10 + } +} + +{ +Template Name: HullCombatLargeBlocks + CraftTime: 5 #C2 Ba CV 1 "Combat Steel Blocks L" + Target: AdvC + { Child Inputs + SteelPlate: 10 + SteelPlateArmored: 10 + } +} + +{ +Template Name: AlienLargeBlocks + CraftTime: 2 #C1 Ba CV 1 1500hp + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + SteelPlateArmored: 2 + XenoSubstrate: 2 + } +} + + # ======================= + # Creative Building Blocks + # ======================= + + # Hidden - obsolete +{ +Template Name: HullDestroyed + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlate: 5 + } +} + + # ======================= + # Hidden Blocks + # ======================= +{ +Template Name: HullLargeDestroyedBlocks + CraftTime: 1 + Target: "" + { Child Inputs + SteelPlate: 1 + } +} + +{ +Template Name: HullSmallDestroyedBlocks + CraftTime: 1 + Target: "" + { Child Inputs + SteelPlate: 1 + } +} + +{ +Template Name: ConcreteDestroyedBlocks + CraftTime: 1 #A1 1 400hp + Target: "" + { Child Inputs + Cement: 1 + } +} + +{ +Template Name: WoodLargeDestroyedBlocks + CraftTime: 1 #A1 1 400hp + Target: "" + { Child Inputs + WoodPlanks: 1 + } +} + +{ +Template Name: XenoLargeDestroyedBlocks + CraftTime: 1 #C1 Ba CV 1 1500hp + Target: "LargeC,AdvC" + { Child Inputs + XenoSubstrate: 1 + } +} +# ======================= +# Building Blocks +# ======================= + +{ +Template Name: ArtMassBlocks + CraftTime: 2 #A1 HV SV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 20 + } +} + +{ +Template Name: StairsBlocksWood + CraftTime: 1 #A1 1 + DeconOverride: Continue + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + WoodPlanks: 1 + } +} + +{ +Template Name: StairsBlocksConcrete + CraftTime: 1 #A1 1 + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + Cement: 1 + } +} + +{ +Template Name: StairsBlocks + CraftTime: 2 #A2 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + } +} + +{ +Template Name: LadderBlocks + CraftTime: 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + } +} + +{ +Template Name: WindowSmallBlocks + CraftTime: 3 #A2 HV SV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + GlassPlate: 1 + } +} + +{ +Template Name: WindowArmoredSmallBlocks + CraftTime: 3 #A2 HV SV 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + GlassPlate: 2 + TitanPlates: 3 + } +} + +{ +Template Name: WindowLargeBlocks + CraftTime: 5 #A2 Ba CV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + GlassPlate: 5 + } +} + +{ +Template Name: WindowArmoredLargeBlocks + CraftTime: 8 #C2 Ba CV 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + GlassPlate: 10 + TitanPlates: 5 + } +} + +{ +Template Name: WindowShutterSmallBlocks + CraftTime: 3 #A2 HV SV 200hp 1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + GlassPlate: 2 + TitanPlates: 4 + } +} + +{ +Template Name: WindowShutterLargeBlocks + CraftTime: 3 #C2 Ba CV 1 + Target: AdvC + { Child Inputs + SteelPlate: 10 + GlassPlate: 10 + TitanPlates: 5 + } +} + +{ +Template Name: HeavyWindowBlocks + CraftTime: 10 + Target: AdvC + { Child Inputs + SteelPlate: 10 + GlassPlate: 15 + SteelPlateArmored: 8 + } +} +{ +Template Name: HeavyWindowDetailedBlocks + CraftTime: 10 + Target: AdvC + { Child Inputs + SteelPlate: 11 + GlassPlate: 14 + SteelPlateArmored: 8 + } +} +# { +Template Name: WalkwayBlocks + # CraftTime: 3 #A1 1 + # Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # SteelPlate: 1 + # } +# } + +{ +Template Name: WalkwaySmallBlocks + CraftTime: 3 #A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + } +} + +{ +Template Name: WalkwayLargeBlocks + CraftTime: 3 #A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + } +} + +{ +Template Name: SensorTriggerBlocks + CraftTime: 2 #A2 Ba CV 1 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + MechanicalComponents: 4 + } +} + +{ +Template Name: SensorTriggerBlocksSV + CraftTime: 2 #A1 HV SV 1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + MechanicalComponents: 2 + } +} + + # ======================= + # Deco Devices + # ======================= + +{ +Template Name: DecoBlocks + CraftTime: 5 #A2 + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + WoodPlanks: 1 + PlasticMaterial: 3 + } +} +{ +Template Name: DecoBlocks2 + CraftTime: 5 #A2 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 2 + PlasticMaterial: 5 + GlassPlate: 1 + } +} +{ +Template Name: DecoTables + CraftTime: 5 #A2 + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + WoodPlanks: 1 + PlasticMaterial: 3 + } +} +{ +Template Name: DecoVesselBlocks + CraftTime: 5 #A2 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 2 + PlasticMaterial: 1 + MechanicalComponents: 2 + GlassPlate: 1 + } +} + +{ +Template Name: IndoorPlants + CraftTime: 5 #A2 + Target: "LargeC,AdvC" + { Child Inputs + PlasticMaterial: 2 + WoodPlanks: 1 + } +} +{ +Template Name: DecoIndoorTrees + CraftTime: 5 #A2 + Target: "LargeC,AdvC" + { Child Inputs + PlasticMaterial: 4 + WoodPlanks: 2 + } +} +{ +Template Name: ConsoleBlocks + CraftTime: 10 #A2 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 2 + SteelPlate: 2 + PlasticMaterial: 1 + Computer: 1 + } +} + +{ +Template Name: LCDScreenBlocks + CraftTime: 10 #A2 1 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 3 + PlasticMaterial: 2 + Computer: 2 + } +} + +{ +Template Name: AntennaBlocks + CraftTime: 10 #B1 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 3 + SteelPlate: 7 + Computer: 3 + OpticalFiber: 10 + } +} + +{ +Template Name: TurretRadar + # CraftTime: 10 #B1 + CraftTime: 10 #A1 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 3 + SteelPlate: 7 + Computer: 2 + # EnergyMatrix: 1 + } +} + + # ======================= + # CPU and Core Devices + # ======================= + +{ +Template Name: CoreNoCPU + CraftTime: 25 #A1 1 + BaseItem: true + Target: "" + { Child Inputs + # PlasticMaterial: 6 + # Electronics: 4 + # Computer: 5 + # OpticalFiber: 3 + Computer: 10 + OpticalFiber: 5 + } +} +{ +Template Name: CoreNPC + CraftTime: 25 #A1 1 + Target: "" + { Child Inputs + Computer: 5 + OpticalFiber: 3 + # PlasticMaterial: 6 + # Electronics: 4 + # Computer: 5 + # OpticalFiber: 3 + } +} +{ +Template Name: Core + CraftTime: 25 #A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + # PlasticMaterial: 6 + # Electronics: 4 + Computer: 5 + OpticalFiber: 3 + } +} +{ +Template Name: CPUExtenderHVT2 + CraftTime: 100 #A2 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + Electronics: 8 + Computer: 10 + OpticalFiber: 6 + FluxCoil: 1 + } +} +{ +Template Name: CPUExtenderHVT3 + CraftTime: 200 #A3 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 4 + SmallOptronicBridge: 1 + } +} +{ +Template Name: CPUExtenderHVT4 + CraftTime: 240 #A4 1 + Target: "AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 4 + PowerCoil: 2 + SmallOptronicBridge: 1 + SmallOptronicMatrix: 1 + } +} +{ +Template Name: CPUExtenderSVT2 + CraftTime: 140 #A2 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + Electronics: 8 + Computer: 10 + OpticalFiber: 6 + FluxCoil: 2 + } +} +{ +Template Name: CPUExtenderSVT3 + CraftTime: 300 #A3 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 8 + SmallOptronicBridge: 2 + } +} +{ +Template Name: CPUExtenderSVT4 + CraftTime: 500 #A4 1 + Target: "AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 8 + PowerCoil: 2 + SmallOptronicBridge: 2 + SmallOptronicMatrix: 1 + } +} +{ +Template Name: CPUExtenderCVT2 + CraftTime: 170 #A2 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + Electronics: 8 + Computer: 10 + OpticalFiber: 6 + FluxCoil: 4 + } +} +{ +Template Name: CPUExtenderCVT3 + CraftTime: 400 #A3 1 + Target: "AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 14 + LargeOptronicBridge: 2 + } +} +{ +Template Name: CPUExtenderCVT4 + CraftTime: 800 #A4 1 + Target: "AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 14 + PowerCoil: 6 + LargeOptronicBridge: 2 + LargeOptronicMatrix: 1 + } +} +{ +Template Name: CPUExtenderBAT2 + CraftTime: 160 #A2 1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + Electronics: 8 + Computer: 10 + OpticalFiber: 6 + FluxCoil: 4 + } +} +{ +Template Name: CPUExtenderBAT3 + CraftTime: 400 #A3 1 + Target: "AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 14 + LargeOptronicBridge: 2 + } +} +{ +Template Name: CPUExtenderBAT4 + CraftTime: 800 #A4 1 + Target: "AdvC" + { Child Inputs + SteelPlate: 24 + Electronics: 16 + Computer: 20 + OpticalFiber: 12 + FluxCoil: 14 + PowerCoil: 6 + LargeOptronicBridge: 2 + LargeOptronicMatrix: 1 + } +} + # ======================= + # Ramps & Doors + # ======================= + +{ +Template Name: DoorBlocks + CraftTime: 5 #A2 Ba CV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 2 + Electronics: 2 + SteelPlate: 1 + } +} + +{ +Template Name: DoorArmoredBlocks + CraftTime: 5 #B1 Ba CV 7 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 2 + Electronics: 2 + CobaltAlloy: 5 + TitanPlates: 3 + } +} + + +{ +Template Name: DoorBlocksSV + CraftTime: 5 #A2 HV SV 10 + Target: "SmallC,HoverC,BaseC, LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + Electronics: 3 + SteelPlate: 2 + } +} + +{ +Template Name: ShutterDoorLargeBlocks + CraftTime: 5 #A2 Ba CV 5 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 20 + Motor: 1 + OpticalFiber: 2 + Electronics: 2 + SteelPlate: 4 + } +} + +{ +Template Name: ShutterDoorSmallBlocks + CraftTime: 5 #A2 HV SV 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 10 + Motor: 1 + OpticalFiber: 2 + Electronics: 2 + SteelPlate: 2 + } +} + +{ +Template Name: HangarDoorBlocks + CraftTime: 5 #B1 Ba CV 5 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 20 + Motor: 4 + EnergyMatrix: 1 + Electronics: 4 + SteelPlate: 30 + } +} + +{ +Template Name: BoardingRampBlocks + CraftTime: 5 #B1 Ba CV 5 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 20 + Motor: 2 + EnergyMatrix: 1 + Electronics: 4 + SteelPlate: 10 + } +} + +# { +Template Name: RampBlocks + # CraftTime: 5 #A1 + # Target: "LargeC,AdvC" + # { Child Inputs + # SteelPlate: 10 + # Motor: 1 + # Electronics: 2 + # } +# } +{ +Template Name: RampLargeBlocks + CraftTime: 5 #A1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 20 + Motor: 2 + Electronics: 4 + } +} +{ +Template Name: RampSmallBlocks + CraftTime: 5 #A1 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 10 + Motor: 1 + Electronics: 2 + } +} + +{ +Template Name: BlastDoorLargeBlocks + CraftTime: 10 #B1 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 16 + Motor: 6 + FluxCoil: 2 + CobaltAlloy: 25 + Electronics: 6 + SteelPlateArmored: 30 + } +} + +{ +Template Name: WingBlocks + CraftTime: 2 #A2 SV 1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 3 + MechanicalComponents: 2 + OpticalFiber: 2 + Electronics: 1 + } +} + +{ +Template Name: ModularWingBlocks + CraftTime: 2 #A2 SV 1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 3 + MechanicalComponents: 2 + OpticalFiber: 2 + Electronics: 1 + } +} + +{ +Template Name: ElevatorMS + CraftTime: 3 #A2 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + Electronics: 1 + OpticalFiber: 4 + } +} + + # ======================= + # Basic Devices + # ======================= + +{ +Template Name: ATM + CraftTime: 10 #B1 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 3 + SteelPlate: 12 + Computer: 2 + EnergyMatrix: 1 + } +} + +{ +Template Name: ArmorLocker + CraftTime: 10 #A1 Ba CV 3 + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 3 + SteelPlate: 25 + Computer: 2 + } +} + +{ +Template Name: ArmorLockerSV + CraftTime: 10 #A1 HV SV 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + SteelPlate: 25 + Computer: 2 + } +} + + + +#==================== +# Logistics & Communication +# =================== +{ +Template Name: RemoteConnection + CraftTime: 10 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 3 + OpticalFiber: 5 + Computer: 1 + } +} + +{ +Template Name: PlayerConsoleGIN + CraftTime: 10 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + PlasticMaterial: 1 + } +} + +#==================== +# Detectors +# =================== + +{ +Template Name: DetectorHVT1 + CraftTime: 10 + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 1 + OpticalFiber: 2 + # Computer: 1 + } +} + +{ +Template Name: DetectorHVEpic + CraftTime: 1 #Epic + Target: " " + { Child Inputs + DetectorHVT1: 1 + DetectorHVEpicUpgradeKit: 1 + } +} + +{ +Template Name: DetectorSVT1 + CraftTime: 10 + Target: "SmallC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + OpticalFiber: 4 + # Computer: 1 + } +} + +{ +Template Name: DetectorCV + CraftTime: 20 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 12 + OpticalFiber: 20 + Computer: 6 + CobaltAlloy: 24 + EnergyMatrix: 4 + TitanPlates: 15 + } +} + +{ +Template Name: DetectorCVEpic + CraftTime: 1 #Epic + Target: " " + { Child Inputs + DetectorCV: 1 + DetectorCVEpicUpgradeKit: 1 + } +} + + # ======================= + # Crafting & Repair Devices + # ======================= + +{ +Template Name: RepairStation + CraftTime: 10 #D1 Ba CV 5 + Target: "LargeC,AdvC" + { Child Inputs + PowerCoil: 1 + EnergyMatrix: 1 + ZascosiumAlloy: 5 + OpticalFiber: 10 + Nanotubes: 10 + } +} + +{ +Template Name: FieldRepairStation + CraftTime: 15 #D1 Ba CV 5 + Target: "LargeC,AdvC" + { Child Inputs + PowerCoil: 4 + TitanPlates: 10 + SteelPlate: 10 + CapacitorComponent: 10 + EnergyMatrix: 4 + ZascosiumAlloy: 25 + } +} + +{ +Template Name: ConstructorT0 + CraftTime: 30 #A1 Ba CV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Computer: 1 + SteelPlate: 3 + MechanicalComponents: 6 + OpticalFiber: 2 + Motor: 1 + } +} + +{ +Template Name: ConstructorT1V2 + # +Template Name: ConstructorT1 + CraftTime: 30 #A1 Ba CV 1 + Target: "HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Computer: 2 + SteelPlate: 5 + MechanicalComponents: 10 + OpticalFiber: 4 + Motor: 2 + } +} + +{ +Template Name: ConstructorT2 + CraftTime: 60 #C2 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 10 + OpticalFiber: 4 + MechanicalComponents: 10 + Motor: 2 + Computer: 4 + EnergyMatrix: 5 + CobaltAlloy: 50 + FluxCoil: 10 + } +} + +{ +Template Name: Deconstructor + CraftTime: 60 #C2 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + MechanicalComponents: 10 + Motor: 2 + Computer: 2 + EnergyMatrix: 5 + CobaltAlloy: 50 + FluxCoil: 10 + } +} + +{ +Template Name: Furnace + CraftTime: 80 #C2 Ba 12 + Target: "AdvC" + { Child Inputs + CapacitorComponent: 10 + EnergyMatrix: 4 + TitanPlates: 50 + Motor: 3 + FluxCoil: 5 + } +} +{ +Template Name: RepairBayConsole + CraftTime: 50 #D1 12 + Target: "AdvC" + { Child Inputs + Computer: 2 + PowerCoil: 4 + CapacitorComponent: 10 + EnergyMatrix: 4 + ZascosiumAlloy: 25 + CobaltAlloy: 25 + FluxCoil: 5 + } +} +{ +Template Name: RepairBayBA + CraftTime: 50 #D1 12 + Target: "AdvC" + { Child Inputs + PowerCoil: 4 + TitanPlates: 10 + SteelPlate: 10 + CapacitorComponent: 10 + EnergyMatrix: 4 + ZascosiumAlloy: 25 + } +} +{ +Template Name: RepairBayBAT2 + CraftTime: 100 #D1 12 + Target: "AdvC" + { Child Inputs + SteelPlate: 10 + TitanPlates: 10 + PowerCoil: 6 + CapacitorComponent: 15 + EnergyMatrix: 6 + ZascosiumAlloy: 40 + } +} + +{ +Template Name: RepairBayCV + CraftTime: 50 #D1 12 + Target: "AdvC" + { Child Inputs + PowerCoil: 4 + SteelPlate: 10 + SteelPlateArmored: 10 + CapacitorComponent: 10 + EnergyMatrix: 4 + ZascosiumAlloy: 25 + } +} +{ +Template Name: RepairBayCVT2 + CraftTime: 100 #D1 12 + Target: "AdvC" + { Child Inputs + SteelPlate: 10 + SteelPlateArmored: 10 + PowerCoil: 6 + CapacitorComponent: 15 + EnergyMatrix: 6 + ZascosiumAlloy: 40 + } +} +{ +Template Name: AutoMiningDeviceT1 + CraftTime: 50 #C2 12 + Target: "AdvC" + { Child Inputs + CapacitorComponent: 5 + TitanPlates: 20 + Motor: 4 + FluxCoil: 5 + AutoMinerCore: 1 + } +} + +{ +Template Name: AutoMiningDeviceT2 + CraftTime: 100 #D1 15 + Target: "AdvC" + { Child Inputs + CapacitorComponent: 10 + TitanPlates: 20 + Motor: 4 + FluxCoil: 5 + ZascosiumAlloy: 25 + PowerCoil: 5 + AutoMinerCore: 1 + } +} + +{ +Template Name: AutoMiningDeviceT3 + CraftTime: 150 #D1 20 + Target: "AdvC" + { Child Inputs + TitanPlates: 20 + Motor: 4 + FluxCoil: 5 + CapacitorComponent: 15 + ZascosiumAlloy: 75 + PowerCoil: 10 + AutoMinerCore: 1 + } +} + +{ +Template Name: ConstructorSV + CraftTime: 40 #B1 + Target: "SmallC,BaseC,LargeC,AdvC" + { Child Inputs + Computer: 2 + EnergyMatrix: 2 + Electronics: 3 + SteelPlate: 25 + CobaltAlloy: 50 + } +} + + # ======================= + # Containers & Storage + # ======================= + +{ +Template Name: ConstructorHV + CraftTime: 30 #A1 HV 1 + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Computer: 2 + SteelPlate: 4 + MechanicalComponents: 8 + OpticalFiber: 3 + Motor: 1 + } +} + +{ +Template Name: ContainerSmallBlocks + CraftTime: 5 #A2 Ba HV SV CV 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + } +} + +{ +Template Name: ContainerLargeBlocks + CraftTime: 5 #A2 Ba HV SV CV 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + } +} +# { +Template Name: CargoContainerMedium + # CraftTime: 5 #A2 Ba HV SV CV 2 + # Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # SteelPlate: 24 + # } +# } + +{ +Template Name: ContainerControllerLarge + CraftTime: 10 #A2 Ba CV 2 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + Electronics: 1 + } +} +{ +Template Name: ContainerExtensionLarge + CraftTime: 10 #A2 Ba CV + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + } +} +{ +Template Name: ContainerControllerSmall + CraftTime: 5 #A2 HV SV 2 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + Electronics: 1 + } +} +{ +Template Name: ContainerExtensionSmall + CraftTime: 5 #A2 HV SV 2 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + } +} +{ +Template Name: ContainerHarvestControllerLarge + CraftTime: 5 #A2 HV CV 2 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + Electronics: 1 + } +} +{ +Template Name: ContainerHarvestControllerSmall + CraftTime: 5 #A2 HV CV 2 + Target: "HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + Electronics: 1 + } +} + +{ +Template Name: ContainerAmmoControllerSmall + CraftTime: 5 #A1 Base,CV,SV,HV 3 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + Electronics: 1 + } +} +{ +Template Name: ContainerAmmoLarge + CraftTime: 5 #A1 Base,CV,SV,HV 3 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + MechanicalComponents: 2 + } +} + +{ +Template Name: ContainerAmmoControllerLarge + CraftTime: 5 #B1 Ba CV 7 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + CobaltAlloy: 5 + Electronics: 1 + } +} + + # ======================= + # Lights + # ======================= + +{ +Template Name: LightPlant01 + CraftTime: 5 #A2 3 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + GlassPlate: 1 + OpticalFiber: 10 + } +} + +{ +Template Name: LightLargeBlocks + CraftTime: 5 #A2 Ba CV 1 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + GlassPlate: 1 + OpticalFiber: 5 + } +} + +{ +Template Name: WorkLightBlocks + CraftTime: 5 #A2 Ba CV 1 + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + GlassPlate: 1 + OpticalFiber: 5 + } +} + +{ +Template Name: StreetLightBlocks + CraftTime: 5 #A2 Ba CV 1 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + GlassPlate: 1 + OpticalFiber: 5 + } +} + +{ +Template Name: LightSS01 + CraftTime: 5 #A2 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + GlassPlate: 1 + OpticalFiber: 2 + } +} + +{ +Template Name: Flare + CraftTime: 5 #A2 Ba HV SV CV 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + GlassPlate: 1 + OpticalFiber: 2 + } +} + +{ +Template Name: LightWork + CraftTime: 5 #A1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 5 + GlassPlate: 1 + } +} + +{ +Template Name: LightWork02 + CraftTime: 10 #A2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 10 + GlassPlate: 2 + } +} + +{ +Template Name: SpotlightBlocks + CraftTime: 5 #A1 HV SV CV 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + GlassPlate: 1 + OpticalFiber: 5 + } +} + + # ======================= + # Life support & Medical + # ======================= + +{ +Template Name: CloneChamber + CraftTime: 12 #A1 1 + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Nanotubes: 2 + Computer: 1 + SteelPlate: 10 + } +} + +{ +Template Name: CloneChamberHV + CraftTime: 12 #A1 1 + Target: "LargeC,AdvC" + { Child Inputs + Nanotubes: 1 + Computer: 1 + SteelPlate: 5 + } +} + +{ +Template Name: MedicalStationBlocks + CraftTime: 12 #A2 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + Nanotubes: 2 + Computer: 2 + OpticalFiber: 3 + } +} + +{ +Template Name: MedicStationHV + CraftTime: 12 #A2 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + Nanotubes: 1 + Computer: 1 + OpticalFiber: 2 + } +} + +{ +Template Name: VentilatorBlocks + CraftTime: 10 #A2 Ba HV SV CV 3 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 12 + OpticalFiber: 3 + Motor: 1 + } +} + + +{ +Template Name: OxygenTankSmallMS + CraftTime: 12 #A2 Ba CV 1 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + SteelPlate: 12 + } +} + +{ +Template Name: OxygenTankMS + CraftTime: 12 #B1 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 8 + SteelPlate: 24 + EnergyMatrix: 1 + CobaltAlloy: 5 + } +} + +{ +Template Name: OxygenTankSV + CraftTime: 12 #A2 HV SV 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + SteelPlate: 6 + } +} + +{ +Template Name: OxygenStation + # CraftTime: 12 #B1 Ba CV 3 + CraftTime: 12 #A1 Ba CV 3 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 8 + # EnergyMatrix: 2 + Computer: 3 + Nanotubes: 5 + # CobaltAlloy: 5 + } +} + +{ +Template Name: OxygenStationSV + CraftTime: 12 #B1 HV SV 3 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + # EnergyMatrix: 2 + Computer: 2 + Nanotubes: 2 + # CobaltAlloy: 5 + } +} + +# { +Template Name: OxygenGenerator + # CraftTime: 12 #A1 + # Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # SteelPlate: 15 + # Computer: 1 + # Motor: 1 + # OpticalFiber: 2 + # } +# } + + # ======================= + # Auxiliary Devices + # ======================= + +{ +Template Name: WaterGenerator + CraftTime: 12 #A1 1 + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 15 + Computer: 1 + Motor: 2 + OpticalFiber: 1 + } +} + +# { +Template Name: OxygenHydrogenGenerator + # CraftTime: 12 #B1 7 + # Target: "LargeC,AdvC" + # { Child Inputs + # SteelPlate: 15 + # EnergyMatrix: 1 + # Computer: 1 + # Motor: 1 + # OpticalFiber: 1 + # } +# } + +{ +Template Name: FridgeSV + CraftTime: 15 #A2 HV SV 5 + Target: "SmallC,SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 3 + SteelPlate: 12 + Nanotubes: 2 + } +} + +{ +Template Name: FridgeBlocks + CraftTime: 15 #A1 Ba, CV + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 3 + SteelPlate: 20 + OpticalFiber: 10 + Nanotubes: 3 + } +} + +# { +Template Name: FridgeMS02 + # CraftTime: 15 #C2 Ba CV 10 + # Target: "AdvC" + # { Child Inputs + # FridgeMS: 1 + # EnergyMatrix: 1 + # SteelPlate: 7 + # CapacitorComponent: 2 + # Nanotubes: 3 + # } +# } + +{ +Template Name: FoodProcessorV2 + CraftTime: 15 #A2 Ba CV 1 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + Computer: 2 + Electronics: 3 + SteelPlate: 25 + OpticalFiber: 3 + Nanotubes: 2 + } +} + + # ======================= + # Generators & Fuel + # ======================= + +{ +Template Name: SolarPanelBlocks + CraftTime: 20 #A1 Ba CV 1 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + GlassPlate: 3 + SteelPlate: 3 + OpticalFiber: 1 + } +} + +{ +Template Name: SolarPanelSmallBlocks + CraftTime: 20 #A1 Ba CV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + GlassPlate: 2 + SteelPlate: 2 + OpticalFiber: 1 + } +} + +{ +Template Name: SolarGenerator + CraftTime: 20 #A1 Ba CV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 3 + OpticalFiber: 1 + SteelPlate: 4 + Nanotubes: 2 + } +} + +{ +Template Name: GeneratorBA + CraftTime: 25 #A1 Ba CV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 1 + Electronics: 4 + PlasticMaterial: 7 + } +} + +# { +Template Name: GeneratorBA + # CraftTime: 25 #A1 Ba CV 1 + # Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # MechanicalComponents: 20 + # Electronics: 6 + # EnergyMatrix: 4 + # Computer: 2 + # CapacitorComponent: 2 + # CobaltAlloy: 5 + # } +# } + +{ +Template Name: GeneratorSVSmallEvent #only for special setups; to allow upgrade to full version + CraftTime: 25 #A1 HV SV 2 + Target: " " + { Child Inputs + # Motor: 1 + Nanotubes: 1 + # Electronics: 2 + # PlasticMaterial: 5 + } +} +{ +Template Name: GeneratorSVSmall + CraftTime: 25 #A1 HV SV 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 1 + Nanotubes: 2 + # Electronics: 2 + # PlasticMaterial: 5 + } +} + +{ +Template Name: GeneratorSV + CraftTime: 50 #A2 HV SV 2 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + # SteelPlate: 20 + Motor: 2 + Nanotubes: 4 + # Electronics: 3 + # Computer: 1 + } +} +{ +Template Name: FuelTankSVSmallEvent + CraftTime: 10 #A1 HV SV 2 + Target: " " + { Child Inputs + # Electronics: 1 + SteelPlate: 1 + # Nanotubes: 1 + } +} +{ +Template Name: FuelTankSVSmall + CraftTime: 10 #A1 HV SV 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 1 + SteelPlate: 1 + Nanotubes: 2 + } +} + +{ +Template Name: FuelTankSV + CraftTime: 20 #A2 HV SV 2 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + SteelPlate: 2 + Nanotubes: 4 + } +} + +{ +Template Name: FuelTankMSSmall + CraftTime: 10 #A1 Ba CV 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 5 + SteelPlate: 15 + OpticalFiber: 4 + } +} + +{ +Template Name: FuelTankMSLarge + CraftTime: 10 #B1 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 10 + CobaltAlloy: 30 + OpticalFiber: 8 + SteelPlate: 30 + } +} + +{ +Template Name: FuelTankMSLargeT2 + CraftTime: 20 #C2 Ba CV 15 + Target: AdvC + { Child Inputs + Electronics: 20 + SteelPlateArmored: 30 + OpticalFiber: 16 + CobaltAlloy: 40 + } +} + +{ +Template Name: GeneratorMS + CraftTime: 30 #B1 Ba CV 10 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 20 + Electronics: 20 + TitanPlates: 12 + Motor: 2 + Computer: 4 + CapacitorComponent: 2 + CobaltAlloy: 10 + FluxCoil: 5 + + } +} + +{ +Template Name: GeneratorMST2 + CraftTime: 60 #D1 Ba CV 15 + Target: AdvC + { Child Inputs + MechanicalComponents: 30 + Electronics: 30 + SteelPlateArmored: 15 + Motor: 3 + Computer: 5 + CapacitorComponent: 4 + CobaltAlloy: 15 + FluxCoil: 10 + ZascosiumAlloy: 15 + } +} + + # ======================= + # Advanced Devices + # ======================= + +{ +Template Name: GravityGeneratorBlocksSVHV + CraftTime: 30 #C2 Ba CV 7 + Target: "AdvC" + { Child Inputs + Motor: 1 + EnergyMatrix: 1 + CapacitorComponent: 1 + MechanicalComponents: 2 + FluxCoil: 2 + } +} + +{ +Template Name: GravityGeneratorBlocksBACV + CraftTime: 60 #C2 Ba CV 7 + Target: "AdvC" + { Child Inputs + Motor: 2 + EnergyMatrix: 2 + CapacitorComponent: 2 + MechanicalComponents: 6 + FluxCoil: 5 + } +} + +#Delete on savegame break +#{ +Template Name: GravityGeneratorMS #Moved to block group; +# CraftTime: 60 #C2 Ba CV 7 +# Target: "AdvC" +# { Child Inputs +# Motor: 2 +# EnergyMatrix: 2 +# CapacitorComponent: 2 +# MechanicalComponents: 6 +# FluxCoil: 5 +# } +#} + +{ +Template Name: WarpDrive + CraftTime: 90 #C2 CV 10 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 2 + Oscillator: 16 + SteelPlateArmored: 100 + FluxCoil: 20 + CapacitorComponent: 10 + } +} + +{ +Template Name: WarpDriveT2 + CraftTime: 120 #C2 CV 10 + Target: "" + { Child Inputs + EnergyMatrix: 4 + Oscillator: 24 + SteelPlateArmored: 100 + FluxCoil: 30 + CapacitorComponent: 15 + } +} + +## Requires T2 Warp Drive for Upgrade! +{ +Template Name: WarpDriveCVEpic + CraftTime: 1 #Epic + Target: "" + { Child Inputs + WarpDriveT2: 1 + WarpDriveCVEpicUpgradeKit: 1 + } +} +{ +Template Name: TeleporterBAT1 + CraftTime: 125 + Target: " " + { Child Inputs + EnergyMatrix: 4 + Oscillator: 10 + FluxCoil: 12 + CapacitorComponent: 5 + PentaxidElement: 50 + } +} +{ +Template Name: TeleporterBA + CraftTime: 250 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 6 + Oscillator: 16 + FluxCoil: 20 + CapacitorComponent: 10 + PentaxidElement: 100 + } +} + +{ +Template Name: TeleporterBAEpic + CraftTime: 1 #Epic + Target: " " + { Child Inputs + TeleporterBA: 1 + TeleporterBAEpicUpgradeKit: 1 + } +} + +{ +Template Name: TeleporterCV + CraftTime: 125 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 4 + Oscillator: 10 + FluxCoil: 12 + CapacitorComponent: 5 + PentaxidElement: 50 + } +} + +{ +Template Name: WarpDriveSV + CraftTime: 70 #B1 SV + Target: "LargeC,AdvC" + { Child Inputs + EnergyMatrix: 10 + SteelPlate: 20 + Nanotubes: 20 + CobaltAlloy: 50 + } +} +# =============== +# Shields and Forcefields +# =============== +#{ +Template Name: DeployableShield #DO NOT USE YET! +# CraftTime: 60 #C2 CV 10 + # Target: "AdvC" +# { Child Inputs +# EnergyMatrix: 1 +# PentaxidCrystal: 6 +# FluxCoil: 1 +# PowerCoil: 1 +# } +#} + +{ +Template Name: ForcefieldEmitterBlocks + CraftTime: 15 + Target: "LargeC,AdvC" + { Child Inputs + CapacitorComponent: 1 + EnergyMatrix: 1 + Oscillator: 4 + PentaxidElement: 1 + } +} + +{ +Template Name: ForcefieldWalkwayEmitterBlocks + CraftTime: 15 + Target: "LargeC,AdvC" + { Child Inputs + CapacitorComponent: 1 + EnergyMatrix: 1 + Oscillator: 4 + PentaxidElement: 2 + } +} + +{ +Template Name: ForcefieldShieldEmitterBlocks + CraftTime: 15 + Target: "AdvC" + { Child Inputs + CapacitorComponent: 1 + EnergyMatrix: 1 + Oscillator: 4 + PentaxidElement: 4 + } +} +{ +Template Name: ShieldGeneratorBA + CraftTime: 90 #C2 CV 10 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 2 + Oscillator: 16 + CapacitorComponent: 10 + MechanicalComponents: 10 + FluxCoil: 20 + PowerCoil: 3 + } +} +{ +Template Name: ShieldGeneratorBAT2 + CraftTime: 135 #C2 CV 10 + Target: "" + { Child Inputs + EnergyMatrix: 4 + Oscillator: 24 + CapacitorComponent: 15 + MechanicalComponents: 10 + FluxCoil: 25 + PowerCoil: 4 + } +} +{ +Template Name: ShieldGeneratorCV + CraftTime: 90 #C2 CV 10 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 2 + Oscillator: 8 + CapacitorComponent: 10 + MechanicalComponents: 10 + FluxCoil: 20 + PowerCoil: 3 + } +} +{ +Template Name: ShieldGeneratorCVT2 + CraftTime: 135 #C2 CV 10 + Target: "" + { Child Inputs + EnergyMatrix: 4 + Oscillator: 16 + CapacitorComponent: 15 + MechanicalComponents: 10 + FluxCoil: 25 + PowerCoil: 4 + } +} + +{ +Template Name: ShieldGeneratorSV + CraftTime: 70 #B1 SV + Target: "LargeC,AdvC" + { Child Inputs + EnergyMatrix: 1 + Oscillator: 8 + CapacitorComponent: 5 + MechanicalComponents: 2 + FluxCoil: 5 + PowerCoil: 1 + } +} +{ +Template Name: ShieldGeneratorHV + CraftTime: 70 #B1 SV + Target: "LargeC,AdvC" + { Child Inputs + EnergyMatrix: 1 + Oscillator: 8 + CapacitorComponent: 5 + MechanicalComponents: 2 + FluxCoil: 5 + PowerCoil: 1 + } +} + +{ +Template Name: ShieldExtenderBA + CraftTime: 90 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 2 + Oscillator: 16 + CapacitorComponent: 10 + MechanicalComponents: 10 + FluxCoil: 20 + PowerCoil: 3 + } +} + +{ +Template Name: ShieldExtenderCV + CraftTime: 90 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 2 + Oscillator: 8 + CapacitorComponent: 10 + MechanicalComponents: 10 + FluxCoil: 20 + PowerCoil: 3 + } +} + +{ +Template Name: ShieldExtenderSV + CraftTime: 70 + Target: "LargeC,AdvC" + { Child Inputs + EnergyMatrix: 1 + Oscillator: 8 + CapacitorComponent: 5 + MechanicalComponents: 2 + FluxCoil: 5 + PowerCoil: 1 + } +} + +{ +Template Name: ShieldExtenderHV + CraftTime: 70 + Target: "LargeC,AdvC" + { Child Inputs + EnergyMatrix: 1 + Oscillator: 8 + CapacitorComponent: 5 + MechanicalComponents: 2 + FluxCoil: 5 + PowerCoil: 1 + } +} + +{ +Template Name: PentaxidTank + CraftTime: 30 #C2 CV 10 + Target: "AdvC" + { Child Inputs + Nanotubes: 20 + TitanPlates: 10 + EnergyMatrix: 6 + CapacitorComponent: 5 + FluxCoil: 2 + } +} +{ +Template Name: PentaxidTankSV + CraftTime: 30 #B2 + Target: "LargeC,AdvC" + { Child Inputs + Nanotubes: 10 + SteelPlate: 10 + EnergyMatrix: 4 + CobaltAlloy: 50 + } +} + + # ======================= + # MP Protection Devices + # ======================= + +{ +Template Name: OfflineProtector + CraftTime: 30 #D1 Ba CV 7 + Target: "LargeC,AdvC" + { Child Inputs + EnergyMatrix: 2 + CapacitorComponent: 1 + MechanicalComponents: 6 + FluxCoil: 5 + PowerCoil: 3 + } +} + +{ +Template Name: LandClaimDevice + CraftTime: 30 #D1 Ba CV 7 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 3 + CapacitorComponent: 2 + MechanicalComponents: 6 + FluxCoil: 5 + PowerCoil: 2 + } +} + + # ======================= + # Dronebay & Related + # ======================= + +{ +Template Name: AIDroneBay + CraftTime: 130 + Target: "LargeC,AdvC" + { Child Inputs + AluminiumCoil: 12 + EnergyMatrix: 10 + MechanicalComponents: 10 + OpticalFiber: 5 + } +} + +{ +Template Name: AIDroneBaySpawner + CraftTime: 60 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlateArmored: 6 + EnergyMatrix: 2 + OpticalFiber: 4 + Oscillator: 1 + CapacitorComponent: 1 + } +} + +{ +Template Name: DroneLargePlayer01Unit + CraftTime: 40 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlateArmored: 1 + EnergyMatrix: 1 + OpticalFiber: 1 + Oscillator: 1 + } +} + + # ======================= + # Cockpits + # ======================= + +{ +Template Name: CockpitBlocksSV + CraftTime: 15 #A1 HV SV 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 3 + MechanicalComponents: 6 + OpticalFiber: 1 + Computer: 2 + GlassPlate: 4 + } +} +{ +Template Name: CockpitBlocksSVT2 + CraftTime: 30 #A1 HV SV 2 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 5 + MechanicalComponents: 7 + OpticalFiber: 2 + Computer: 3 + GlassPlate: 8 + CobaltAlloy: 10 + TitanPlates: 10 + } +} + +{ +Template Name: PassengerSeatSV #"Passenger Seat (closed)" + CraftTime: 15 #A2 HV SV 3 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 1 + SteelPlate: 25 + OpticalFiber: 1 + Computer: 1 + } +} + +{ +Template Name: PassengerSeat2SV + CraftTime: 15 #A1 HV SV 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 1 + SteelPlate: 7 + OpticalFiber: 1 + Computer: 1 + } +} + +{ +Template Name: CockpitBlocksCV + CraftTime: 15 #C1 CV 5 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 3 + MechanicalComponents: 40 + GlassPlate: 5 + Computer: 6 + FluxCoil: 10 + } +} + +{ +Template Name: PassengerSeatMS + CraftTime: 15 #C1 7 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 1 + SteelPlate: 7 + OpticalFiber: 1 + } +} + + # ======================= + # Thrusters / Maneuverability / LandingGear + # ======================= + +{ +Template Name: HoverEngineThruster + CraftTime: 10 #A1 HV 2 + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 3 + Nanotubes: 2 + # Motor: 1 + # Electronics: 1 + # SteelPlate: 5 + OpticalFiber: 1 + } +} + +{ +Template Name: HoverEngineSmall + CraftTime: 10 #A1 HV 2 + Target: "SurvC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 2 + Electronics: 3 + SteelPlate: 10 + OpticalFiber: 3 + Nanotubes: 5 + } +} + +{ +Template Name: HoverEngineLarge + CraftTime: 15 #A2 HV 8 + Target: "HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 4 + Electronics: 5 + SteelPlate: 25 + OpticalFiber: 6 + Nanotubes: 5 + } +} + +{ +Template Name: HoverBooster + CraftTime: 10 #A2 HV 5 + Target: "HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 10 + Electronics: 4 + SteelPlate: 15 + OpticalFiber: 5 + } +} + +{ +Template Name: ThrusterGVDirectional + CraftTime: 15 #A2 HV 3 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 1 + Electronics: 6 + SteelPlate: 15 + OpticalFiber: 3 + Nanotubes: 5 + } +} + +{ +Template Name: ThrusterGVRoundBlocks + CraftTime: 20 #A2 HV 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 1 + Electronics: 6 + SteelPlate: 20 + OpticalFiber: 3 + Nanotubes: 5 + } +} +{ +Template Name: ThrusterGVRoundNormalT2 + CraftTime: 25 #A2 HV 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 2 + Electronics: 8 + SteelPlate: 20 + OpticalFiber: 5 + Nanotubes: 7 + CobaltAlloy: 10 + } +} +{ +Template Name: ThrusterGVRoundLarge + CraftTime: 30 #A2 HV 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 2 + Electronics: 12 + SteelPlate: 30 + OpticalFiber: 5 + Nanotubes: 7 + } +} +{ +Template Name: ThrusterGVRoundLargeT2 + CraftTime: 35 #A2 HV 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 4 + Electronics: 15 + SteelPlate: 30 + OpticalFiber: 8 + Nanotubes: 7 + CobaltAlloy: 25 + } +} + +{ +Template Name: ThrusterGVJetRound1x3x1 + CraftTime: 30 #A2 HV 7 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 3 + Electronics: 8 + SteelPlate: 40 + OpticalFiber: 3 + Nanotubes: 5 + } +} + +{ +Template Name: RCSBlockGV + CraftTime: 25 #A2 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + MechanicalComponents: 4 + OpticalFiber: 2 + SteelPlate: 5 + } +} + +# Small Vessel + +{ +Template Name: ThrusterSVSmallEvent + CraftTime: 15 #A2 SV 7 + Target: " " + { Child Inputs + # Motor: 1 + # Electronics: 6 + # MechanicalComponents: 6 + # OpticalFiber: 3 + Nanotubes: 1 + } +} + +{ +Template Name: ThrusterSVDirectional + CraftTime: 15 #A2 SV 7 + Target: "SmallC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 1 + Electronics: 6 + MechanicalComponents: 6 + OpticalFiber: 3 + Nanotubes: 5 + } +} + +{ +Template Name: ThrusterSVRoundBlocks + CraftTime: 20 #A2 SV 10 + Target: "SmallC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 2 + Electronics: 6 + MechanicalComponents: 8 + OpticalFiber: 3 + Nanotubes: 5 + } +} + +{ +Template Name: ThrusterSVRoundNormalT2 + CraftTime: 25 #A2 SV 10 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 3 + Electronics: 8 + MechanicalComponents: 10 + OpticalFiber: 5 + Nanotubes: 7 + CobaltAlloy: 15 + } +} + +{ +Template Name: ThrusterSVRoundLarge + CraftTime: 30 #A2 SV 10 + Target: "SmallC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 4 + Electronics: 10 + MechanicalComponents: 10 + OpticalFiber: 5 + Nanotubes: 7 + } +} + +{ +Template Name: ThrusterSVRoundLargeT2 + CraftTime: 35 #A2 SV 10 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 6 + Electronics: 15 + MechanicalComponents: 10 + OpticalFiber: 5 + Nanotubes: 7 + CobaltAlloy: 25 + } +} + +{ +Template Name: ThrusterJetRound1x3x1 + CraftTime: 30 #B1 SV 12 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 2 + Electronics: 4 + OpticalFiber: 3 + Nanotubes: 5 + CobaltAlloy: 15 + } +} + +{ +Template Name: ThrusterJetRound2x5x2 + CraftTime: 35 #B1 SV 15 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 3 + Electronics: 6 + OpticalFiber: 4 + Nanotubes: 6 + CobaltAlloy: 25 + } +} +{ +Template Name: ThrusterJetRound2x5x2V2 + CraftTime: 35 #B1 SV 15 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 3 + Electronics: 6 + OpticalFiber: 4 + Nanotubes: 6 + CobaltAlloy: 25 + } +} + +{ +Template Name: ThrusterJetRound3x7x3 + CraftTime: 40 #B1 SV 15 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 4 + Electronics: 8 + OpticalFiber: 5 + Nanotubes: 8 + CobaltAlloy: 35 + } +} + +{ +Template Name: ThrusterJetRound3x10x3 + CraftTime: 45 #B1 SV 17 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 5 + Electronics: 10 + OpticalFiber: 6 + Nanotubes: 10 + CobaltAlloy: 50 + } +} +{ +Template Name: ThrusterJetRound3x10x3V2 + CraftTime: 45 #B1 SV 17 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 5 + Electronics: 10 + OpticalFiber: 6 + Nanotubes: 10 + CobaltAlloy: 50 + } +} + +{ +Template Name: ThrusterJetRound3x13x3 + CraftTime: 50 #B1 SV 20 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 6 + Electronics: 12 + OpticalFiber: 7 + Nanotubes: 12 + CobaltAlloy: 65 + } +} +{ +Template Name: ThrusterJetRound3x13x3V2 + CraftTime: 50 #B1 SV 20 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 6 + Electronics: 12 + OpticalFiber: 5 + Nanotubes: 10 + CobaltAlloy: 65 + } +} + +{ +Template Name: RCSBlockSVEvent + CraftTime: 30 #A2 SV 5 + Target: " " + { Child Inputs + # Electronics: 2 + # MechanicalComponents: 10 + # OpticalFiber: 2 + SteelPlate: 1 + # Computer: 1 + } +} +{ +Template Name: RCSBlockSVLight + CraftTime: 30 #A2 SV 5 + Target: " " + { Child Inputs + Electronics: 1 + MechanicalComponents: 4 + OpticalFiber: 1 + SteelPlate: 8 + Computer: 1 + } +} +{ +Template Name: RCSBlockSV + CraftTime: 30 #A2 SV 5 + Target: "SmallC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + MechanicalComponents: 10 + OpticalFiber: 2 + SteelPlate: 20 + Computer: 1 + } +} + +# DockingPad HV +{ +Template Name: DockingPad + CraftTime: 15 #A2 HV 2 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 10 + Electronics: 2 + } +} + +{ +Template Name: LandinggearBlocksSV + CraftTime: 15 #A2 SV 2 + Target: "SmallC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + Electronics: 2 + } +} + +{ +Template Name: LandinggearBlocksHeavySV + CraftTime: 15 #A2 SV 10 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 8 + Electronics: 5 + } +} + +{ +Template Name: LandinggearBlocksCV + CraftTime: 15 #C2 CV + Target: "AdvC" + { Child Inputs + MechanicalComponents: 12 + Electronics: 2 + } +} + +# ====== Thrusters CV (MS) + +{ +Template Name: ThrusterMSDirectional + CraftTime: 40 #C2 CV 7 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 2 + SteelPlate: 10 + OpticalFiber: 5 + Oscillator: 8 + FluxCoil: 2 + } +} + +{ +Template Name: ThrusterMSRoundBlocks + CraftTime: 45 #C2 CV 10 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 3 + TitanPlates: 20 + OpticalFiber: 5 + Oscillator: 20 + FluxCoil: 4 + } +} + +{ +Template Name: ThrusterMSRound2x2Blocks + CraftTime: 50 #D1 CV 15 + Target: AdvC + { Child Inputs + Motor: 4 + ZascosiumAlloy: 40 + OpticalFiber: 10 + Oscillator: 24 + PowerCoil: 6 + } +} + +{ +Template Name: ThrusterMSRound3x3Blocks + CraftTime: 55 #D1 CV 20 + Target: AdvC + { Child Inputs + Motor: 6 + ZascosiumAlloy: 90 + OpticalFiber: 15 + Oscillator: 32 + PowerCoil: 8 + } +} + +# ==== Jet Thruster CV + + +{ +Template Name: ThrusterJetMSRound1x3x1 + CraftTime: 30 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 2 # Function; Platinum, Titanium, Neodymium, Cobalt + BuildingFrameReinforced: 2 # Frame; Titanium (Exposed Device) + OpticalFiber: 5 # Signaling; Silicon (Steering inputs; simple) + Electronics: 5 # Processing; Copper (Simple processing; Main handled by RCS/Cockpit/Pilot!) + SteelPlateArmored: 3 # Plating; Sathium (Exposed device) + } +} + +{ +Template Name: ThrusterJetMSRound2x5x2 + CraftTime: 35 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 4 + BuildingFrameReinforced: 4 + OpticalFiber: 8 + Electronics: 8 + SteelPlateArmored: 6 + } +} +{ +Template Name: ThrusterJetMSRound2x5x2V2 + CraftTime: 35 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 4 + BuildingFrameReinforced: 4 + OpticalFiber: 8 + Electronics: 8 + SteelPlateArmored: 6 + } +} + +{ +Template Name: ThrusterJetMSRound3x7x3 + CraftTime: 40 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 5 + BuildingFrameReinforced: 5 + OpticalFiber: 9 + Electronics: 9 + SteelPlateArmored: 7 + } +} + +{ +Template Name: ThrusterJetMSRound3x10x3 + CraftTime: 45 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 6 + BuildingFrameReinforced: 6 + OpticalFiber: 8 + Electronics: 8 + SteelPlateArmored: 8 + } +} +{ +Template Name: ThrusterJetMSRound3x10x3V2 + CraftTime: 45 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 6 + BuildingFrameReinforced: 6 + OpticalFiber: 8 + Electronics: 8 + SteelPlateArmored: 8 + } +} + +{ +Template Name: ThrusterJetMSRound3x13x3 + CraftTime: 50 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 7 + BuildingFrameReinforced: 7 + OpticalFiber: 10 + Electronics: 10 + SteelPlateArmored: 9 + } +} +{ +Template Name: ThrusterJetMSRound3x13x3V2 + CraftTime: 50 + Target: "LargeC,AdvC" + { Child Inputs + PropulsionComponents: 7 + BuildingFrameReinforced: 7 + OpticalFiber: 10 + Electronics: 10 + SteelPlateArmored: 9 + } +} + +# ===== RCS + +{ +Template Name: RCSBlockMS + CraftTime: 45 #C1 CV 5 + Target: "LargeC,AdvC" + { Child Inputs + EnergyMatrix: 2 + Electronics: 3 + OpticalFiber: 3 + SteelPlate: 20 + Oscillator: 16 + } +} + +{ +Template Name: RCSBlockMS_T2 + CraftTime: 60 #C1 CV 10 + Target: "AdvC" + { Child Inputs + EnergyMatrix: 5 + Electronics: 6 + Oscillator: 16 + TitanPlates: 20 + OpticalFiber: 6 + PowerCoil: 4 + } +} + + # ======================= + # Aux Life Support Items + # ======================= + +{ +Template Name: RespiratorCharge + DeconOverride: Stop + OutputCount: 1 + CraftTime: 5 + Target: SuitC + { Child Inputs + WaterBottle: 1 + } +} + +{ +Template Name: OxygenBottleLarge + DeconOverride: Stop + OutputCount: 2 #A1 + CraftTime: 10 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + WaterJug: 1 + SteelPlate: 1 + } +} +#{ +Template Name: OxygenBottleSmall +# DeconOverride: Stop +# OutputCount: 3 #A1 +# CraftTime: 10 +# Target: "SurvC,SmallC,HoverC,BaseC,LargeC" +# { Child Inputs +# WaterJug: 2 +# SteelPlate: 1 +# } +#} +{ +Template Name: HydrogenBottle + OutputCount: 10 #A1 + DeconOverride: Stop + CraftTime: 10 + Target: "LargeC,AdvC" + { Child Inputs + WaterJug: 5 + } +} + +# =============== +# TOOLS +# =============== + +{ +Template Name: TextureColorTool + CraftTime: 10 #A2 1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 2 + Nanotubes: 4 + } +} +# { +Template Name: ColorTool + # CraftTime: 10 #A2 1 + # Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # Electronics: 2 + # Nanotubes: 4 + # } +# } + +# { +Template Name: TextureTool + # CraftTime: 10 #A2 1 + # Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # Electronics: 2 + # Nanotubes: 4 + # } +# } + +{ +Template Name: Flashlight + CraftTime: 1 #A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 1 + PlasticMaterial: 2 + } +} + +# { +Template Name: OreScanner + # CraftTime: 12 #B1 3 + # Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # MechanicalComponents: 2 + # EnergyMatrix: 1 + # Computer: 2 + # CobaltAlloy: 5 + # } +# } + +{ +Template Name: OreScanner + CraftTime: 12 #A1 3 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 2 + Electronics: 1 + Computer: 1 + } +} +{ +Template Name: RadarSuitT1 + CraftTime: 12 #A1 3 + Target: "SuitC,SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + } +} + +{ +Template Name: ArmorLight + CraftTime: 60 #1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlasticMaterial: 10 + OpticalFiber: 1 + } +} + +{ +Template Name: Chainsaw + CraftTime: 20 #1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + Electronics: 2 + PlasticMaterial: 5 + } +} + +# { +Template Name: Drill + # CraftTime: 20 #A1 + # Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + # { Child Inputs + # MechanicalComponents: 6 + # Electronics: 2 + # OpticalFiber: 1 + # } +# } + +{ +Template Name: DrillT2 + CraftTime: 20 #B1 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + Electronics: 2 + OpticalFiber: 1 + EnergyMatrix: 1 + } +} + +{ +Template Name: SurvivalTent + CraftTime: 15 + Target: "SuitC" + { Child Inputs + PlasticMaterial: 7 + } +} +{ +Template Name: SurvivalTool + CraftTime: 15 + Target: SuitC + { Child Inputs + } +} +{ +Template Name: MultiTool + CraftTime: 15 #A1 3 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Electronics: 1 + OpticalFiber: 1 + } +} + +{ +Template Name: MultiToolT2 + CraftTime: 15 #C2 12 + Target: "AdvC" + { Child Inputs + SteelPlate: 4 + Electronics: 1 + FluxCoil: 1 + Oscillator: 4 + MultiTool: 1 + } +} +# =============== +# Booster +# =============== + +{ +Template Name: EVABoost + CraftTime: 100 + Target: "HoverC,BaseC,SmallC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + OpticalFiber: 1 + Nanotubes: 2 + GoldIngot: 1 + } +} + +# =============== +# Weapons T1 +# =============== + +{ +Template Name: Pistol + CraftTime: 20 #A1 1 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + } +} + +# { +Template Name: PistolEpic + # CraftTime: 20 #A1 1 + # Target: "AdvC" + # { Child Inputs + # SteelPlate: 1 + # PistolKit: 1 + # PistolT2: 1 + # } +# } + +{ +Template Name: SMG + CraftTime: 20 #A2 1 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + Electronics: 2 + } +} + +{ +Template Name: AssaultRifle + CraftTime: 30 #A2 5.8mmBullet 3 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + Electronics: 1 + } +} + +{ +Template Name: PistolT2 + CraftTime: 30 #B1 + Target: "LargeC,AdvC" + { Child Inputs + CobaltAlloy: 20 + PistolKit: 1 + Pistol: 1 + } +} +# { +Template Name: AssaultRifleEpic + # CraftTime: 30 #B1 + # Target: "AdvC" + # #Target: "" + # { Child Inputs + # CobaltAlloy: 5 + # RifleKit: 1 + # AssaultRifleT2: 1 + # } +# } + +{ +Template Name: Shotgun + CraftTime: 30 #A2 3 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + SteelPlate: 2 + } +} + +{ +Template Name: Sniper + CraftTime: 30 #A2 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + SteelPlate: 2 + } +} + +{ +Template Name: RocketLauncher + CraftTime: 30 #B1 10 SlowRocket + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 8 + Electronics: 2 + CobaltAlloy: 50 + # HeavyWeaponKit: 1 + } +} +# { +Template Name: RocketLauncherEpic + # CraftTime: 30 #C2 15 + # Target: "AdvC" + # { Child Inputs + # SteelPlateArmored: 1 + # HeavyWeaponKit: 1 + # RocketLauncherT2: 1 + # } +# } + +{ +Template Name: RocketLauncherT2 + CraftTime: 30 #C2 15 uses "SlowRocketHoming" + Target: "LargeC,AdvC" + { Child Inputs + TitanPlates: 5 + Oscillator: 4 + HeavyWeaponKit: 1 + RocketLauncher: 1 + } +} + +{ +Template Name: Explosives + OutputCount: 1 #A2 5 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + EnergyCell: 1 + Electronics: 1 + } +} + + # ======================= + # Advanced Weapons + # ======================= + +{ +Template Name: LaserPistol + CraftTime: 30 #C2 15 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + Electronics: 3 + Oscillator: 4 + FluxCoil: 1 + # LaserKit: 1 + } +} + +{ +Template Name: LaserPistolT2 + CraftTime: 30 #D1 20 + Target: "LargeC,AdvC" + { Child Inputs + Oscillator: 8 + PowerCoil: 1 + LaserKit: 1 + LaserPistol: 1 + } +} + +{ +Template Name: Shotgun2 + CraftTime: 30 #C1 20 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + FluxCoil: 1 + Shotgun: 1 + ShotgunKit: 1 + } +} +# { +Template Name: Shotgun2Epic + # CraftTime: 30 #C2 3 20 + # Target: "AdvC" + # #Target: "" + # { Child Inputs + # SteelPlateArmored: 1 + # ShotgunKit: 1 + # Shotgun2: 1 + # } +# } + +{ +Template Name: Minigun + CraftTime: 30 #B1 15 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + CobaltAlloy: 10 + Motor: 1 + HeavyWeaponKit: 1 + } +} +# { +Template Name: MinigunEpic + # CraftTime: 30 #B1 15 + # Target: "AdvC" + # #Target: "" + # { Child Inputs + # CobaltAlloy: 5 + # HeavyWeaponKit: 1 + # Minigun: 1 + # } +# } + +{ +Template Name: Sniper2 + CraftTime: 30 #C1 15 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlateArmored: 1 + FluxCoil: 1 + Sniper: 1 + SniperKit: 1 + } +} +# { +Template Name: Sniper2Epic + # CraftTime: 30 #C1 15 + # Target: "AdvC" + # #Target: "" + # { Child Inputs + # SteelPlateArmored: 1 + # SniperKit: 1 + # Sniper2: 1 + # } +# } + +{ +Template Name: PulseRifle #"Pulse Rifle" + CraftTime: 30 #C2 10 uses "5.8mmBullet" + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 2 + Oscillator: 4 + FluxCoil: 2 + RifleKit: 1 + AssaultRifle: 1 + } +} + +# { +Template Name: PulseRifleEpic + # CraftTime: 30 #C2 + # Target: "AdvC" + # #Target: "" + # { Child Inputs + # SteelPlateArmored: 1 + # RifleKit: 1 + # PulseRifleT2: 1 + # } +# } + +{ +Template Name: LaserSMG + CraftTime: 30 #D1 25 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 4 + Electronics: 3 + ZascosiumAlloy: 30 + FluxCoil: 2 + LaserKit: 1 + } +} + +{ +Template Name: LaserRifle + CraftTime: 30 #D1 25 + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 6 + Electronics: 5 + PowerCoil: 1 + ZascosiumAlloy: 50 + LaserKit: 1 + } +} + +{ +Template Name: LaserCarbine + CraftTime: 30 #D1 25 + Target: "LargeC,AdvC" + { Child Inputs + FluxCoil: 2 + EnergyMatrix: 1 + LaserKit: 1 + LaserRifle: 1 + } +} + +# { +Template Name: LaserRifleEpic + # CraftTime: 30 #C2 25 + # Target: "AdvC" + # #Target: "" + # { Child Inputs + # ZascosiumAlloy: 1 + # LaserKit: 1 + # LaserRifle: 1 + # } +# } + +{ +Template Name: ScifiCannon + CraftTime: 30 #D1 25 Plasma Cannon + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 2 + Oscillator: 8 + ZascosiumAlloy: 10 + CapacitorComponent: 4 + HeavyWeaponKit: 1 + } +} + +# { +Template Name: ScifiCannonEpic + # CraftTime: 30 #D1 25 Plasma Cannon + # Target: AdvC + # #Target: "" + # { Child Inputs + # SteelPlate: 1 + # HeavyWeaponKit: 1 + # ScifiCannon: 1 + # } +# } + + # ======================= + # Tools for Vessels + # ======================= + +{ +Template Name: SawAttachment + CraftTime: 20 #A2 3 + Target: "HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 2 + Electronics: 6 + MechanicalComponents: 8 + OpticalFiber: 3 + Nanotubes: 5 + } +} + +{ +Template Name: DrillAttachment + CraftTime: 20 #A2 HV 10 + Target: "HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 4 + MechanicalComponents: 20 + SteelPlate: 2 + } +} + +{ +Template Name: DrillAttachmentLarge + CraftTime: 25 #A2 HV 10 + Target: "HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Motor: 6 + MechanicalComponents: 40 + SteelPlate: 12 + } +} + +{ +Template Name: DrillAttachmentT2 + CraftTime: 30 #D1 HV 20 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 1 + MechanicalComponents: 20 + ZascosiumAlloy: 20 + PowerCoil: 2 + DrillAttachment: 1 + } +} +{ +Template Name: DrillAttachmentSVT2 + CraftTime: 40 #D1 SV 20 + Target: "LargeC,AdvC" + { Child Inputs + Motor: 5 + MechanicalComponents: 46 + ZascosiumAlloy: 22 + PowerCoil: 3 + } +} + +{ +Template Name: DrillAttachmentCV + CraftTime: 60 #A2 HV 10 + Target: "LargeC,AdvC" + { Child Inputs + Electronics: 5 + Oscillator: 8 + ZascosiumAlloy: 50 + PowerCoil: 3 + } +} + # ======================= + # Ship weapons SV/HV fixed + # ======================= +{ +Template Name: WeaponSV01 #"Rail Gun" + CraftTime: 30 #D1 SV 15 uses "RailgunBullet" + Target: "LargeC,AdvC" + { Child Inputs + TitanPlates: 25 + Electronics: 5 + Oscillator: 20 + PowerCoil: 5 + ZascosiumAlloy: 25 + } +} + +{ +Template Name: WeaponSV02 #"Gatling Gun" + CraftTime: 30 #A2 HV SV 3 uses "15mmBullet" + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 16 + Electronics: 3 + Motor: 2 + } +} + +{ +Template Name: WeaponSV03 #"Pulse Laser" + CraftTime: 30 #D1 SV 20 uses "PulseLaserChargeSS" + Target: AdvC + { Child Inputs + ZascosiumAlloy: 50 + Electronics: 8 + Oscillator: 20 + PowerCoil: 5 + } +} + +{ +Template Name: WeaponSV04 #"Plasma Cannon" + CraftTime: 30 #D1 20 + Target: AdvC + { Child Inputs + ZascosiumAlloy: 50 + Electronics: 8 + Oscillator: 20 + PowerCoil: 5 + } +} + +{ +Template Name: WeaponSV05 #"Rocket Launcher" + CraftTime: 30 #C2 SV 10 uses "FastRocket" + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 40 + Electronics: 5 + Motor: 2 + CapacitorComponent: 2 + EnergyMatrix: 2 + } +} + +{ +Template Name: WeaponSV05Homing #"Rocket Launcher (Homing)" + CraftTime: 30 #D1 SV 15 uses "FastRocketHoming" 10 + Target: "LargeC,AdvC" + { Child Inputs + ZascosiumAlloy: 25 + Electronics: 5 + Oscillator: 8 + Motor: 2 + PowerCoil: 2 + } +} + + # ======================= + # Turrets BA & CV + # ======================= +# Rocket Launcher CV +{ +Template Name: WeaponMS01 #"Rocket Launcher" + CraftTime: 30 #D1 CV 20 uses "FastRocketMS" + Target: AdvC + { Child Inputs + ZascosiumAlloy: 50 + Electronics: 10 + Oscillator: 20 + PowerCoil: 5 + } +} + +# Pulse Laser CV +{ +Template Name: WeaponMS02 #"Pulse Laser" + CraftTime: 30 #D1 25 uses "PulseLaserChargeMSWeapon" + Target: AdvC + { Child Inputs + ZascosiumAlloy: 75 + Electronics: 15 + Oscillator: 20 + PowerCoil: 5 + FluxCoil: 5 + } +} + +{ +Template Name: TurretMSProjectileBlocks +# { +Template Name: TurretMSMinigunRetract #"Minigun Turret" + CraftTime: 30 #C1 CV 10 uses "15mmBullet" + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 20 + Electronics: 5 + Motor: 2 + CapacitorComponent: 3 + Oscillator: 20 + } +} + + +{ +Template Name: TurretBaseProjectileBlocks + CraftTime: 30 #B1 Ba 10 uses "15mmBullet" + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 20 + Electronics: 5 + Motor: 2 + OpticalFiber: 5 + } +} + + +# Plasma Turret +{ +Template Name: TurretMSLaserBlocks #"Plasma Turret" + CraftTime: 30 #D1 CV 15 uses "TurretMSPlasmaCharge" + Target: AdvC + { Child Inputs + MechanicalComponents: 20 + Electronics: 5 + Motor: 2 + Oscillator: 8 + PowerCoil: 1 + } +} + + +{ +Template Name: TurretBaseLaserBlocks + CraftTime: 30 #D1 20 uses "PulseLaserChargeBA" + Target: AdvC + { Child Inputs + ZascosiumAlloy: 50 + Motor: 2 + Oscillator: 8 + CapacitorComponent: 2 + PowerCoil: 1 + } +} + +{ +Template Name: TurretMSArtilleryBlocks + CraftTime: 30 #D1 CV 25 uses "LargeRocketMS" + Target: AdvC + { Child Inputs + TitanPlates: 75 + PowerCoil: 10 + Motor: 5 + ZascosiumAlloy: 75 + Oscillator: 20 + } +} + +{ +Template Name: TurretBaseArtilleryBlocks + CraftTime: 30 #D1 25 uses "LargeRocket" + Target: AdvC + { Child Inputs + SteelPlateArmored: 30 + PowerCoil: 10 + Motor: 3 + ZascosiumAlloy: 100 + Oscillator: 20 + } +} + +{ +Template Name: SentryGunBlocks + CraftTime: 30 #A2 Ba CV 3 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + MechanicalComponents: 10 + Electronics: 4 + Motor: 2 + OpticalFiber: 5 + } +} + + +{ +Template Name: TurretMSRocketBlocks + CraftTime: 30 #D1 CV 15 uses "FlakRocketMS" + Target: "LargeC,AdvC" + { Child Inputs + ZascosiumAlloy: 75 + Electronics: 5 + Motor: 2 + FluxCoil: 2 + PowerCoil: 3 + } +} + +# rocket +{ +Template Name: TurretBaseRocketBlocks + CraftTime: 30 #C2 5 uses FastRocketBA + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 20 + Electronics: 5 + Motor: 2 + CobaltAlloy: 25 + Oscillator: 8 + } +} + + # ======================= + # Turrets HV & SV + # ======================= + +{ +Template Name: TurretGVMinigunBlocks #"Minigun Turret" + CraftTime: 30 #A2 5 "15mmBullet" + Target: "BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 25 + Electronics: 2 + Motor: 1 + OpticalFiber: 2 + } +} + +{ +Template Name: TurretGVRocketBlocks #"Rocket Turret" + CraftTime: 30 #A2 HV 10 FastRocketHoming + Target: "LargeC,AdvC" + { Child Inputs + MechanicalComponents: 10 + Motor: 1 + OpticalFiber: 2 + EnergyMatrix: 5 + } +} + +{ +Template Name: TurretGVPlasmaBlocks #"Plasma Turret" + CraftTime: 30 #D1 HV 15 uses"TurretGVPlasmaCharge" + Target: AdvC + { Child Inputs + ZascosiumAlloy: 25 + Motor: 1 + OpticalFiber: 2 + PowerCoil: 1 + Oscillator: 4 + } +} + +{ +Template Name: TurretGVArtilleryBlocks #Artillery Turret" + CraftTime: 30 #D1 20 uses "ArtilleryRocket" + Target: AdvC + { Child Inputs + ZascosiumAlloy: 50 + Motor: 2 + Oscillator: 4 + PowerCoil: 1 + } +} + +{ +Template Name: TurretGVToolBlocks + CraftTime: 30 #D1 HV 15 + Target: AdvC + { Child Inputs + ZascosiumAlloy: 10 + Motor: 2 + OpticalFiber: 2 + Oscillator: 4 + PowerCoil: 1 + } +} + +{ +Template Name: TurretSVSmall + CraftTime: 60 #A2 5 "15mmBullet" + Target: "BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 25 + Electronics: 2 + Motor: 1 + OpticalFiber: 2 + } +} + +{ +Template Name: TurretMSToolBlocks + CraftTime: 60 #D1 20 + Target: AdvC + { Child Inputs + ZascosiumAlloy: 50 + Electronics: 5 + Motor: 2 + Oscillator: 8 + PowerCoil: 1 + } +} + + # ======================= + # Ammo Handheld Weapons + # ======================= + +{ +Template Name: DrillCharge + OutputCount: 5 #A1 + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + PromethiumPellets: 10 + PlasticMaterial: 2 + } +} + +{ +Template Name: MultiCharge + OutputCount: 3 #A1 + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 1 + PromethiumPellets: 10 + } +} + +{ +Template Name: BioFuel + OutputCount: 1 #A1 + CraftTime: 28 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + Fiber: 10 + } +} +#{ +Template Name: FlameThrowerCanister +# OutputCount: 1 #A1 +# CraftTime: 28 +# Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" +# { Child Inputs +# BioFuel: 4 +# } +#} +{ +Template Name: 50Caliber + OutputCount: 25 #A1 Pistol + CraftTime: 5 + DeconOverride: Continue + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + CopperIngot: 1 + NCPowder: 1 + } +} + +{ +Template Name: 5.8mmBullet + OutputCount: 50 #A2 Assaultrifle + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 8 + CopperIngot: 2 + NCPowder: 1 + } +} + +{ +Template Name: ShotgunShells + OutputCount: 30 #A1 1 Shotgun + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 2 + CopperIngot: 2 + NCPowder: 1 + } +} + +{ +Template Name: 12.7mmBullet + OutputCount: 15 #A2 1 Sniper + CraftTime: 5 + Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 6 + CopperIngot: 1 + NCPowder: 1 + } +} + +{ +Template Name: 8.3mmBullet + OutputCount: 50 #A2 1 Minigun + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 10 + NCPowder: 1 + } +} + +{ +Template Name: SlowRocket + OutputCount: 5 #B1 10 + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 8 + PromethiumPellets: 4 + MagnesiumPowder: 2 + } +} + +{ +Template Name: SlowRocketHoming + OutputCount: 6 #B1 15 + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 8 + PromethiumPellets: 4 + Electronics: 2 + MagnesiumPowder: 3 + } +} + +{ +Template Name: PulseLaserChargePistol + OutputCount: 75 #C2 + DeconOverride: Continue + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + PlasticMaterial: 2 + PentaxidElement: 5 + } +} + +{ +Template Name: PulseLaserChargeRifle + OutputCount: 50 #D1 1 + DeconOverride: Continue + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + PlasticMaterial: 2 + PentaxidElement: 5 + } +} + +{ +Template Name: SciFiCannonPlasmaCharge #Pl.Charge Cannon + OutputCount: 25 #D1 + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + ErestrumGel: 1 + PlasticMaterial: 4 + PentaxidElement: 10 + } +} + + + # ======================= + # Ammo other weapons + # ======================= + +{ +Template Name: 15mmBullet + OutputCount: 100 #A2 TurretGVMinigun + CraftTime: 5 + Target: "SmallC,HoverC,BaseC,LargeC,AdvC" + { Child Inputs + SteelPlate: 15 + NCPowder: 4 + } +} + +{ +Template Name: 30mmBullet + OutputCount: 70 + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 20 + NCPowder: 4 + } +} + +{ +Template Name: RailgunBullet + OutputCount: 10 #D1 for WeaponSV01 "Rail Gun" + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + CobaltAlloy: 20 + SathiumIngot: 20 + } +} + +{ +Template Name: PulseLaserChargeSS #"Ls.Charge SV" + OutputCount: 100 #D1 + DeconOverride: Continue + CraftTime: 5 + Target: "AdvC" + { Child Inputs + PentaxidElement: 15 + PlasticMaterial: 3 + } +} + +{ +Template Name: PulseLaserChargeMS #"Ls.Charge CV" + OutputCount: 150 #D1 25 for "TurretMSPulseLaser" + DeconOverride: Continue + CraftTime: 5 + Target: "AdvC" + { Child Inputs + PentaxidElement: 10 + PlasticMaterial: 5 + } +} + +{ +Template Name: PulseLaserChargeMSWeapon #"Ls.Charge II CV" + OutputCount: 100 #D1 25 for "WeaponMS02" Pulse Laser + DeconOverride: Continue + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + PentaxidElement: 10 + PlasticMaterial: 5 + } +} + +{ +Template Name: PulseLaserChargeBA #"Ls.Charge BA" + OutputCount: 120 #D1 20 for "TurretBasePulseLaser" + DeconOverride: Continue + CraftTime: 5 + Target: "AdvC" + { Child Inputs + PentaxidElement: 10 + PlasticMaterial: 5 + } +} + +{ +Template Name: PlasmaCannonChargeSS #"Pl.Charge SV" + OutputCount: 100 #D1 SV 20 " + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + ErestrumGel: 2 + PentaxidElement: 20 + PlasticMaterial: 4 + + } +} + +{ +Template Name: TurretGVPlasmaCharge + OutputCount: 100 #D1 TurretGVPlasma 15 + CraftTime: 5 + Target: "AdvC" + { Child Inputs + ErestrumGel: 2 + PentaxidElement: 20 + PlasticMaterial: 4 + } +} + +{ +Template Name: TurretMSPlasmaCharge + OutputCount: 75 #D1 + CraftTime: 5 + Target: "AdvC" + { Child Inputs + ErestrumGel: 4 + PentaxidElement: 15 + PlasticMaterial: 6 + } +} + +{ +Template Name: TurretBAPlasmaCharge #"Pl.Charge BA" + OutputCount: 50 #D1 for "TurretBasePlasma" + CraftTime: 5 + Target: "AdvC" + { Child Inputs + ErestrumGel: 4 + PentaxidElement: 20 + PlasticMaterial: 6 + } +} + +{ +Template Name: FlakRocket + OutputCount: 20 #C2 for "TurretBaseFlak" + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 8 + MagnesiumPowder: 12 + NCPowder: 10 + } +} + +{ +Template Name: FlakRocketMS #"Flak Shell CV" + OutputCount: 25 #D1 for "TurretMSFlak" + CraftTime: 5 + Target: AdvC + { Child Inputs + SteelPlate: 8 + MagnesiumPowder: 12 + NCPowder: 10 + } +} +{ +Template Name: FastRocket + OutputCount: 6 #B1 10 used by "WeaponSV05" + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + PromethiumPellets: 2 + MagnesiumPowder: 2 + } +} +{ +Template Name: FastRocketGV + OutputCount: 6 #B1 10 used by "WeaponSV05" + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + PromethiumPellets: 2 + MagnesiumPowder: 2 + } +} +{ +Template Name: FastRocketHoming + OutputCount: 7 #B1 for "TurretGVRocket" + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + PromethiumPellets: 2 + MagnesiumPowder: 2 + Electronics: 2 + } +} +{ +Template Name: FastRocketMS + OutputCount: 6 #D1 12 for "TurretMSRocket" + CraftTime: 5 + Target: AdvC + { Child Inputs + SteelPlate: 4 + PromethiumPellets: 4 + MagnesiumPowder: 4 + ErestrumGel: 2 + Electronics: 2 + } +} + +{ +Template Name: FastRocketBA + OutputCount: 5 #C2 5 + CraftTime: 5 + Target: "LargeC,AdvC" + { Child Inputs + SteelPlate: 4 + PromethiumPellets: 4 + MagnesiumPowder: 4 + ErestrumGel: 2 + Electronics: 2 + } +} + +{ +Template Name: LargeRocket + OutputCount: 4 #D1 for "TurretBaseArtillery" + CraftTime: 10 + Target: "AdvC" + { Child Inputs + SteelPlate: 4 + PromethiumPellets: 6 + MagnesiumPowder: 8 + ErestrumGel: 4 + } +} + +{ +Template Name: LargeRocketMS + OutputCount: 5 #D1 for "TurretMSArtillery" + CraftTime: 5 + Target: "AdvC" + { Child Inputs + SteelPlate: 4 + PromethiumPellets: 6 + MagnesiumPowder: 8 + ErestrumGel: 4 + } +} + +{ +Template Name: ArtilleryRocket + OutputCount: 4 #D1 20 for "TurretGVArtillery" + CraftTime: 5 + Target: "AdvC" + { Child Inputs + SteelPlate: 8 + MagnesiumPowder: 12 + NCPowder: 20 + } +} + + +{ +Template Name: AlienNPCBlocks + DeconOverride: Stop + CraftTime: 20 + Target: "" + { Child Inputs + GoldCoins: 5 + } +} +{ +Template Name: HumanNPCBlocks + DeconOverride: Stop + CraftTime: 20 + Target: "" + { Child Inputs + GoldCoins: 5 + } +} + + + # ============================== + # Medical Items: Ointments + # ============================== + +{ +Template Name: AntibioticOintment + DeconOverride: Stop + CraftTime: 10 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,SuitC" + { Child Inputs + HerbalLeaves: 1 + PlantProtein: 1 + } +} +{ +Template Name: AntiRadiationOintment + DeconOverride: Stop + CraftTime: 15 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + MushroomBrown: 1 + PlantProtein: 1 + } +} +{ +Template Name: AntiToxicOintment + DeconOverride: Stop + CraftTime: 15 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,SuitC" + { Child Inputs + AlienTooth: 1 + ConfettiMossScrapings: 1 + PlantProtein: 1 + } +} + + # ============================== + # Medical Items: Kits + # ============================== + +{ +Template Name: Bandages + DeconOverride: Stop + CraftTime: 2.8 #Original: 25 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,SuitC" + { Child Inputs + Fiber: 3 + PlantProtein: 2 + } +} + +{ +Template Name: Medikit01 + DeconOverride: Stop + CraftTime: 60 + Target: FoodP + { Child Inputs + HerbalLeaves: 1 + Spice: 1 + FireMossScrapings: 1 + Fiber: 1 + AlienThorn: 2 + } +} + +{ +Template Name: Medikit02 + DeconOverride: Stop + CraftTime: 120 + Target: FoodP + { Child Inputs + FireMossScrapings: 1 + AlienTooth: 1 + AlienParts01: 2 + AlienParts02: 5 + } +} + +{ +Template Name: Medikit03 + DeconOverride: Stop + CraftTime: 180 + Target: FoodP + { Child Inputs + FireMossScrapings: 1 + MushroomBrown: 1 + AlienParts03: 2 + } +} +#Small Health Pack +{ +Template Name: Medikit04 + DeconOverride: Stop + CraftTime: 60 + Target: FoodP + { Child Inputs + AlienThorn: 1 + WaterBottle: 3 + } +} +#Medium Health Pack +{ +Template Name: Medikit05 + DeconOverride: Stop + CraftTime: 60 + Target: FoodP + { Child Inputs + AlienThorn: 3 + AlienParts01: 3 + } +} +#Large Health Pack (Former Medikit04) +{ +Template Name: Medikit06 + DeconOverride: Stop + CraftTime: 60 + Target: FoodP + { Child Inputs + AlienThorn: 5 + AlienParts03: 2 + } +} + + # ============================== + # Medical Items: Pills + # ============================== + +{ +Template Name: StomachPills + CraftTime: 15 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + Spice: 1 + ConfettiMossScrapings: 1 + } +} + +{ +Template Name: AntibioticPills + DeconOverride: Stop + CraftTime: 15 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + ConfettiMossScrapings: 1 + HerbalLeaves: 1 + } +} + +{ +Template Name: AntiToxicPills + DeconOverride: Stop + CraftTime: 23 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + ConfettiMossScrapings: 1 + AlienTooth: 1 + } +} + +{ +Template Name: AntiParasitePills + DeconOverride: Stop + CraftTime: 23 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + ConfettiMossScrapings: 1 + AlienParts02: 1 + } +} + +{ +Template Name: RadiationPills + DeconOverride: Stop + CraftTime: 28 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + ConfettiMossScrapings: 1 + MushroomBrown: 1 + } +} + + # ============================== + # Medical Items: Injections + # ============================== + +{ +Template Name: AntibioticInjection + DeconOverride: Stop + CraftTime: 30 + Target: FoodP + { Child Inputs + WaterBottle: 1 + HerbalLeaves: 1 + AlienParts01: 3 + } +} +{ +Template Name: AntiToxicInjection + DeconOverride: Stop + CraftTime: 40 + Target: FoodP + { Child Inputs + WaterBottle: 1 + AlienParts01: 3 + AlienTooth: 1 + } +} +{ +Template Name: AntiParasiteInjection + DeconOverride: Stop + CraftTime: 40 + Target: FoodP + { Child Inputs + WaterBottle: 1 + AlienParts02: 3 + AlienParts01: 3 + } +} +{ +Template Name: AntiRadiationInjection + DeconOverride: Stop + CraftTime: 40 + Target: FoodP + { Child Inputs + WaterBottle: 1 + AlienParts01: 3 + MushroomBrown: 1 + } +} + + + # =========================== + # Medical Boosters + # =========================== + +{ +Template Name: EnergyPills + DeconOverride: Stop + CraftTime: 30 + Target: FoodP + { Child Inputs + Spice: 1 + ConfettiMossScrapings: 1 + NaturalStimulant: 1 + } +} + +{ +Template Name: AdrenalineShot + DeconOverride: Stop + CraftTime: 23 + Target: FoodP + { Child Inputs + NaturalStimulant: 7 + Spice: 3 + AlienParts03: 3 + } +} + +{ +Template Name: RadiationImmunityShot + DeconOverride: Stop + CraftTime: 23 + Target: FoodP + { Child Inputs + MushroomBrown: 1 + Spice: 2 + AlienParts03: 5 + } +} + + # =========================== + # Food Basic Ingredients + # =========================== + +{ +Template Name: Flour + DeconOverride: Stop + CraftTime: 6 + Target: FoodP + { Child Inputs + Grain: 2 + } +} + +{ +Template Name: Milk + DeconOverride: Stop + CraftTime: 5 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + PlantProtein: 1 + WaterBottle: 1 + } +} + +{ +Template Name: Meat + DeconOverride: Stop + CraftTime: 7 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + PlantProtein: 1 + Vegetables: 1 + } +} +{ +Template Name: PlantProtein + OutputCount: 2 + CraftTime: 2 + Target: "SuitC,FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + Vegetables: 1 + } +} + + # ======================= + # Meals T1 + # ======================= +{ +Template Name: PowerBar + DeconOverride: Stop + CraftTime: 2.8 #Original: 12 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,SuitC" + { Child Inputs + PlantProtein: 2 + } +} + +# { +Template Name: Ketchup +# CraftTime: 5 +# Target: FoodP +# { Child Inputs +# Fruit: 4 +# # Sugarcane: 1 +# } +# } + +{ +Template Name: Cheese + DeconOverride: Stop + CraftTime: 7 + Target: FoodP + { Child Inputs + Milk: 1 + } +} + +{ +Template Name: Bread + DeconOverride: Stop + CraftTime: 14 + Target: FoodP + { Child Inputs + Flour: 1 + WaterBottle: 1 + } +} + +{ +Template Name: FriedVegetables + CraftTime: 8 + Target: FoodP + { Child Inputs + Vegetables: 1 + } +} + +{ +Template Name: Cereals + DeconOverride: Stop + CraftTime: 8 + Target: FoodP + { Child Inputs + Grain: 2 + Milk: 1 + } +} + +{ +Template Name: Steak + DeconOverride: Stop + CraftTime: 11 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + Meat: 1 + } +} + +#{ +Template Name: Ribs +# CraftTime: 20 +# Target: FoodP +# { Child Inputs +# Meat: 2 +# } +#} + +{ +Template Name: Salami + DeconOverride: Stop + CraftTime: 18 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + Meat: 1 + Spice: 1 + } +} + +{ +Template Name: Ham + DeconOverride: Stop + CraftTime: 21 + Target: FoodP + { Child Inputs + Meat: 1 + NaturalSweetener: 1 + Spice: 1 + } +} + +#{ +Template Name: BakedPumpkin +# CraftTime: 5 +# Target: FoodP +# { Child Inputs +# Vegetables: 1 +# } +#} + +{ +Template Name: AkuaWine + DeconOverride: Stop + CraftTime: 9 + Target: FoodP + { Child Inputs + Berries: 1 + WaterBottle: 1 + } +} + +# Complex Recipes + +{ +Template Name: HotBeverage + CraftTime: 2 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,SuitC" + { Child Inputs + Buds: 2 + } +} +{ +Template Name: EnergyDrink + CraftTime: 3 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,SuitC" + { Child Inputs + NaturalStimulant: 2 + } +} +{ +Template Name: BerryJuice + CraftTime: 3 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + Berries: 2 + } +} +{ +Template Name: FruitJuice + CraftTime: 3 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + Fruit: 2 + } +} +{ +Template Name: VegetableJuice + CraftTime: 3 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC,SuitC" + { Child Inputs + Vegetables: 2 + } +} +{ +Template Name: AnniversaryCake + DeconOverride: Stop + CraftTime: 27 + Target: FoodP + { Child Inputs + NaturalSweetener: 1 + Berries: 2 + Milk: 1 + Egg: 1 + Flour: 1 + } +} +{ +Template Name: EmergencyRations + DeconOverride: Stop + CraftTime: 60 + Target: FoodP + { Child Inputs + WaterBottle: 1 + HotBeverage: 1 + FruitJuice: 2 + PowerBar: 6 + Salami: 3 + } +} + +{ +Template Name: Sandwich + DeconOverride: Stop + OutputCount: 4 + CraftTime: 16 + Target: FoodP + { Child Inputs + Salami: 1 + Bread: 1 + } +} + +{ +Template Name: Stew + DeconOverride: Stop + CraftTime: 34 + Target: FoodP + { Child Inputs + MushroomBrown: 1 + Vegetables: 2 + WaterBottle: 2 + Meat: 2 + Spice: 1 + } +} + +# # { +Template Name: HotDog +# DeconOverride: Stop +# CraftTime: 7 +# Target: FoodP +# { Child Inputs +# Meat: 1 +# Bread: 1 +# Ketchup: 1 +# } +# } +# +# { +Template Name: CannedMeat +# DeconOverride: Stop +# CraftTime: 10 +# Target: FoodP +# { Child Inputs +# Meat: 1 +# Fruit: 8 +# SteelPlate: 2 +# } +# } +# +# { +Template Name: Taco +# DeconOverride: Stop +# CraftTime: 10 +# Target: FoodP +# { Child Inputs +# Meat: 1 +# Grain: 2 +# Cheese: 2 +# Fruit: 2 +# } +# } + + +{ +Template Name: CannedMeat + DeconOverride: Stop + CraftTime: 26 + Target: FoodP + { Child Inputs + WaterBottle: 1 + Meat: 2 + Spice: 4 + } +} +{ +Template Name: CannedVegetables + DeconOverride: Stop + CraftTime: 29 + Target: FoodP + { Child Inputs + Vegetables: 4 + WaterBottle: 1 + Spice: 2 + } +} + +{ +Template Name: VeggieBurger + DeconOverride: Stop + CraftTime: 18 + Target: FoodP + { Child Inputs + Bread: 1 + Vegetables: 1 + PlantProtein: 1 + } +} + +{ +Template Name: MeatBurger + DeconOverride: Stop + CraftTime: 22 + Target: FoodP + { Child Inputs + Meat: 1 + Bread: 1 + Cheese: 1 + } +} + +{ +Template Name: Pizza + DeconOverride: Stop + CraftTime: 29 + Target: FoodP + { Child Inputs + MushroomBrown:1 + Vegetables: 2 + Cheese: 1 + Salami: 1 + Spice: 2 + } +} + +{ +Template Name: Ratatouille + DeconOverride: Stop + CraftTime: 26 + Target: FoodP + { Child Inputs + Vegetables: 4 + WaterBottle: 2 + Spice: 1 + } +} + +{ +Template Name: Waffles + DeconOverride: Stop + CraftTime: 19 + Target: FoodP + { Child Inputs + NaturalSweetener: 1 + Milk: 1 + Egg: 1 + Flour: 1 + } +} + +#{ +Template Name: PumpkinCookie +# CraftTime: 10 +# Target: FoodP +# { Child Inputs +# Wheat: 1 +# Milk: 1 +# Vegetables: 2 +# Egg: 1 +# } +#} + +{ +Template Name: FruitPie + DeconOverride: Stop + CraftTime: 22 + Target: FoodP + { Child Inputs + NaturalSweetener: 1 + Fruit: 2 + Milk: 1 + Egg: 1 + Flour: 1 + } +} + + # ======================= + # Sprouts + # ======================= + +{ +Template Name: DurianRoot + OutputCount: 2 + DeconOverride: Stop + CraftTime: 15 + Target: FoodP + { Child Inputs + Vegetables: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: CornStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Grain: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: PumpkinStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Vegetables: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: TomatoStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Vegetables: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: WheatStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Grain: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: PearthingStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Fruit: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: AlienPlantTube2Stage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + PlantProtein: 3 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: DesertPlant20Stage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + HerbalLeaves: 2 + WaterBottle: 5 + NutrientSolution: 1 + } +} + +{ +Template Name: ElderberryStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Berries: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: AlienPalmTreeStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Fruit: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: AlienplantWormStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + NaturalSweetener: 2 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: BulbShroomYoungStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Vegetables: 4 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: SnakeweedStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Fiber: 6 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: MushroomBellBrown01Stage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + MushroomBrown: 2 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: CobraLeavesPlantStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Spice: 2 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: CoffeePlantStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + NaturalStimulant: 2 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: InsanityPepperStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + NaturalStimulant: 2 + WaterBottle: 5 + NutrientSolution: 1 + } +} +{ +Template Name: BigFlowerStage1 + DeconOverride: Stop + OutputCount: 2 + CraftTime: 15 + Target: FoodP + { Child Inputs + Buds: 2 + WaterBottle: 5 + NutrientSolution: 1 + } +} + # ======================= + # Farming + # ======================= +{ +Template Name: GrowingPot + BaseItem: true #C1 Ba CV 1 + DeconOverride: Continue + CraftTime: 10 + Target: AdvC + { Child Inputs + Fiber: 4 + NutrientSolution: 1 + # PlasticMaterial: 2 + SteelPlate: 5 + CobaltAlloy: 5 + } +} +{ +Template Name: GrowingPotConcrete + BaseItem: true #B1 Ba 1 + DeconOverride: Continue + CraftTime: 10 + Target: "BaseC,LargeC,AdvC" + { Child Inputs + Fiber: 4 + NutrientSolution: 1 + Cement: 2 + # CobaltAlloy: 5 + } +} +{ +Template Name: GrowingPotWood + BaseItem: true #B1 Ba 1 + DeconOverride: Continue + CraftTime: 10 + Target: "SurvC,BaseC,LargeC,AdvC" + { Child Inputs + Fiber: 4 + NutrientSolution: 1 + WoodPlanks: 4 + # CobaltAlloy: 5 + } +} + +{ +Template Name: NutrientSolution + DeconOverride: Stop + CraftTime: 10 + Target: "SurvC,FoodP" + { Child Inputs + RockDust: 4 + WaterBottle: 5 + RottenFood: 1 + } +} + +{ +Template Name: RottenFood + DeconOverride: Stop + OutputCount: 3 + CraftTime: 5 + Target: "FoodP,SurvC,SmallC,HoverC,BaseC" + { Child Inputs + PlantProtein: 1 + } +} + + # ======================= + # Biochemical Compounds + # ======================= + +{ +Template Name: ConfettiMossScrapings + DeconOverride: Stop + OutputCount: 3 + CraftTime: 60 + Target: FoodP + { Child Inputs + CrushedStone: 1 + MushroomBrown: 1 + PromethiumPellets: 1 + } +} +{ +Template Name: FireMossScrapings + DeconOverride: Stop + OutputCount: 4 + CraftTime: 60 + Target: FoodP + { Child Inputs + CrushedStone: 1 + MushroomBrown: 1 + PromethiumPellets: 2 + } +} +{ +Template Name: AlienParts02 + DeconOverride: Stop + OutputCount: 4 + CraftTime: 90 + Target: FoodP + { Child Inputs + Meat: 1 + AlienTooth: 1 + NutrientSolution: 2 + } +} +{ +Template Name: AlienParts01 + DeconOverride: Stop + OutputCount: 4 + CraftTime: 90 + Target: FoodP + { Child Inputs + Egg: 1 + AlienParts02: 1 + NCPowder: 2 + } +} +{ +Template Name: AlienParts03 + DeconOverride: Stop + OutputCount: 4 + CraftTime: 90 + Target: FoodP + { Child Inputs + Egg: 1 + AlienParts01: 2 + NCPowder: 2 + } +} +{ +Template Name: AlienThorn + # DeconOverride: Stop + OutputCount: 4 + CraftTime: 120 + Target: FoodP + { Child Inputs + AlienTooth: 2 + NCPowder: 1 + PentaxidElement: 5 + } +} + + # ======================= + # SPECIAL BLOCKS & Items + # ======================= + +# Special Blocks (not placeable by players) +# needed for PickupTarget and TemplateRoot +{ +Template Name: EntitySpawner1 + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlate: 1 + } +} +{ +Template Name: MarkerBlock + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlate: 1 + } +} +{ +Template Name: MarkerBlock2 + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlate: 1 + } +} + +# templates so some items can be recycled +{ +Template Name: AutoMinerCore + DeconOverride: Stop + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlate: 10 + } +} +{ +Template Name: SmallOptronicBridge + BaseItem: true + CraftTime: 600 + Target: "AdvC" + { Child Inputs + GoldIngot: 15 + Electronics: 125 + Oscillator: 80 + } +} +{ +Template Name: SmallOptronicMatrix + BaseItem: true + CraftTime: 900 + Target: "AdvC" + { Child Inputs + GoldIngot: 20 + Computer: 100 + EnergyMatrix: 20 + } +} +{ +Template Name: LargeOptronicBridge + BaseItem: true + CraftTime: 600 + Target: "AdvC" + { Child Inputs + GoldIngot: 15 + SmallOptronicBridge: 1 + Oscillator: 80 + ZascosiumAlloy: 50 + } +} +{ +Template Name: LargeOptronicMatrix + BaseItem: true + CraftTime: 900 + Target: "AdvC" + { Child Inputs + GoldIngot: 20 + SmallOptronicMatrix: 1 + PowerCoil: 10 + ErestrumGel: 20 + } +} +{ +Template Name: OxygenGeneratorSmall + OutputCount: 1 + CraftTime: 40 + Target: "SuitC" + { Child Inputs + PlasticMaterial: 3 + } +} +{ +Template Name: MobileAirCon + CraftTime: 45 + Target: "SuitC" + { Child Inputs + PlasticMaterial: 1 + } +} +{ +Template Name: ConstructorSurvival + CraftTime: 45 + Target: "SuitC" + { Child Inputs + PlasticMaterial: 5 + } +} + +{ +Template Name: PistolKit + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlate: 1 + } +} + +{ +Template Name: RifleKit + CraftTime: 2 + Target: "" + { Child Inputs + CobaltAlloy: 5 + } +} + +{ +Template Name: LaserKit + CraftTime: 2 + Target: "" + { Child Inputs + ZascosiumAlloy: 5 + } +} + +{ +Template Name: HeavyWeaponKit + CraftTime: 2 + Target: "" + { Child Inputs + CobaltAlloy: 5 + } +} + +{ +Template Name: ShotgunKit + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlateArmored: 1 + } +} + +{ +Template Name: SniperKit + CraftTime: 2 + Target: "" + { Child Inputs + SteelPlateArmored: 1 + } +} +##Dead Entities (Statics) + +{ +Template Name: DeadRipperDog + CraftTime: 2 + Target: "" + { Child Inputs + Meat: 1 + } +} + +#Crew for TemplateRoot + +{ +Template Name: ZiraxCommander + BaseItem: true + DeconOverride: Stop + CraftTime: 2 + Target: "" + { Child Inputs + ZiraxCommander: 1 + } +} +{ +Template Name: SecurityGuardZirax + BaseItem: true + DeconOverride: Stop + CraftTime: 2 + Target: "" + { Child Inputs + SecurityGuardZirax: 1 + } +} +# Deco Items for TemplateRoot + +{ +Template Name: HoverbikeStatic #Converts Hoverbike into Deco Object + CraftTime: 2 + BaseItem: true + DeconOverride: Stop + Target: "BaseC,LargeC,AdvC" + { Child Inputs + PlayerBike: 1 + } +} +{ +Template Name: HoverbikeHowlerStatic #Converts Hoverbike into Deco Object + CraftTime: 2 + BaseItem: true + DeconOverride: Stop + Target: "BaseC,LargeC,AdvC" + { Child Inputs + HoverbikeHowler: 1 + } +} +{ +Template Name: HoverbikeHunterStatic #Converts Hoverbike into Deco Object + CraftTime: 2 + BaseItem: true + DeconOverride: Stop + Target: "BaseC,LargeC,AdvC" + { Child Inputs + HoverbikeHunter: 1 + } +} +{ +Template Name: HoverbikeSpeedstarStatic #Converts Hoverbike into Deco Object + CraftTime: 2 + BaseItem: true + DeconOverride: Stop + Target: "BaseC,LargeC,AdvC" + { Child Inputs + HoverbikeSpeedstar: 1 + } +} +{ +Template Name: HoverbikeWaspStatic #Converts Hoverbike into Deco Object + CraftTime: 2 + BaseItem: true + DeconOverride: Stop + Target: "BaseC,LargeC,AdvC" + { Child Inputs + HoverbikeWasp: 1 + } +} + + + # ======================= + # Seasonal Food/Baking Specials + # ======================= + + +{ +Template Name: GreenMilk + DeconOverride: Stop + CraftTime: 10 + Target: "" #Hidden until season + { Child Inputs + Milk: 1 + MutatedSubstance: 1 + } +} + +{ +Template Name: ToothFlour + DeconOverride: Stop + CraftTime: 10 + Target: "" #Hidden until season + { Child Inputs + AlienTooth: 1 + Flour: 1 + } +} + +{ +Template Name: MushroomSweet + DeconOverride: Stop + CraftTime: 10 + Target: "" #Hidden until season + { Child Inputs + MushroomBrown: 1 + NaturalSweetener: 1 + BakingRecipeBook: 1 + } +} + +{ +Template Name: AlienLemonSpice + DeconOverride: Stop + CraftTime: 10 + Target: "" #Hidden until season + { Child Inputs + AlienSpikeLemon: 1 + Spice: 1 + } +} +{ +Template Name: AlienCookie + DeconOverride: Stop + CraftTime: 100 + Target: "" #Hidden until season + { Child Inputs + EggMultiColor: 2 + GreenMilk: 1 + ToothFlour: 1 + AlienLemonSpice: 1 + BakingRecipeBook: 1 + } +} + +{ +Template Name: MushroomEggRoll + DeconOverride: Stop + CraftTime: 75 + Target: "" #Hidden until season + { Child Inputs + EggMultiColor: 1 + Milk: 1 + Flour: 1 + MushroomSweet: 2 + BakingRecipeBook: 1 + } +} + +{ +Template Name: FestiveCake + DeconOverride: Stop + CraftTime: 30 + Target: "" #Hidden until season + { Child Inputs + Egg: 1 + Milk: 1 + Flour: 1 + NaturalSweetener: 1 + BakingRecipeBook: 1 + } +} \ No newline at end of file diff --git a/pkg/empyrionitems/items.go b/pkg/empyrionitems/items.go new file mode 100644 index 00000000..94c96e62 --- /dev/null +++ b/pkg/empyrionitems/items.go @@ -0,0 +1,389 @@ +// Package empyrionitems parses Empyrion's ItemsConfig.ecf — Eleon's text +// config format — into a flat item index keyed by numeric ID. Used by the +// panel controller to populate the EAH-style give-item picker, ID-to-name +// lookups for inventory views, and category filtering. +// +// The format is brace-delimited with `Key: Value` properties, comment lines +// starting with #, and entries opened by `{ Item Id: N, Name: X` or +// `+ Item Id: N, Name: X` (the `+` form extends a prior block; semantically +// the same for our purposes). Values may be quoted strings, raw tokens, or +// scalar numbers — we keep them as strings and let callers coerce. +// +// We deliberately ignore nested `{ Child ... }` blocks except for tracking +// the brace depth so we don't accidentally close the outer item early. +package empyrionitems + +import ( + "bufio" + "errors" + "io" + "os" + "strconv" + "strings" +) + +// Item is a single Empyrion item / block / weapon entry. Fields populated +// best-effort from properties seen in stock ItemsConfig.ecf; Extras catches +// anything we didn't promote to a typed field so callers can still surface +// scenario-specific keys (Reforged Eden adds plenty). +type Item struct { + ID int `json:"id"` + Name string `json:"name"` // canonical name token (e.g. "EnergyCellHydrogen") + Ref string `json:"ref,omitempty"` // template/parent ref + Category string `json:"category,omitempty"` // ingredients, components, food, weapons, ammo, etc + Group string `json:"group,omitempty"` // displayed UI grouping + Mass string `json:"mass,omitempty"` // kg, raw token + Volume string `json:"volume,omitempty"` // L + MarketPrice string `json:"marketPrice,omitempty"` + StackSize string `json:"stack,omitempty"` + Class string `json:"class,omitempty"` // Ranged / Tool / Item / Block + HoldType string `json:"holdType,omitempty"` + CustomIcon string `json:"customIcon,omitempty"` + AllowAt string `json:"allowAt,omitempty"` + Description string `json:"description,omitempty"` + Extras map[string]string `json:"extras,omitempty"` +} + +// Index is a queryable item store. +type Index struct { + Items []Item + byID map[int]*Item + byName map[string]*Item +} + +// LoadFromFile reads ItemsConfig.ecf from disk. +func LoadFromFile(path string) (*Index, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return LoadFromReader(f) +} + +// LoadFromReaders parses multiple ECF streams into a single merged Index. +// Order matters for ID collisions: later readers win, matching Empyrion's +// own load order where BlocksConfig is read after ItemsConfig. +func LoadFromReaders(readers ...io.Reader) (*Index, error) { + merged := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}} + for _, r := range readers { + idx, err := LoadFromReader(r) + if err != nil { + return nil, err + } + for i := range idx.Items { + it := idx.Items[i] + merged.add(&it) + } + } + return merged, nil +} + +// LoadFromReader parses an ItemsConfig.ecf stream. +func LoadFromReader(r io.Reader) (*Index, error) { + idx := &Index{byID: map[int]*Item{}, byName: map[string]*Item{}} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + + var ( + current *Item + depth int // brace depth WITHIN an item (for child blocks like Child Recipe { ... }) + ) + + for sc.Scan() { + line := stripComment(sc.Text()) + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + + // Open a new top-level entry: `{ Item Id: 12, ...`, `{ Block Id: 220, ...`, + // `+ Item Id: ...`, `+ Block Id: ...`. Empyrion's ECF format uses + // `Item`, `Block`, `Device` interchangeably for the entries we care + // about — they're all addressable via the same numeric ID space. + if current == nil && isEntryHeader(trim) { + it := parseItemHeader(trim) + if it != nil { + current = it + current.Extras = map[string]string{} + depth = 1 + if strings.HasPrefix(trim, "+") { + depth = 0 + } + } + continue + } + + // Inside an item. + if current != nil { + // Track child brace depth so we don't close the outer item early. + opens := strings.Count(trim, "{") + closes := strings.Count(trim, "}") + depth += opens - closes + if depth <= 0 { + idx.add(current) + current = nil + depth = 0 + continue + } + // `+ Item/Block` form has no closing brace; the next entry + // header is its terminator. + if isEntryHeader(trim) { + idx.add(current) + current = nil + depth = 0 + it := parseItemHeader(trim) + if it != nil { + current = it + current.Extras = map[string]string{} + depth = 1 + if strings.HasPrefix(trim, "+") { + depth = 0 + } + } + continue + } + parseProperty(current, trim) + } + } + if current != nil { + idx.add(current) + } + if err := sc.Err(); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + return idx, nil +} + +// isEntryHeader returns true for lines that open a top-level Item / Block / +// Device entry. Empyrion's ECF format uses several keywords interchangeably: +// `{ Item Id: 12, Name: Foo` +// `{ Block Id: 220, Name: MetalPieces` +// `{ Device Id: 1234, Name: ConstructorT2` +// `+ Item Id: 12, Name: Foo` +// `+Block Id: 12, ...` +// `{ +Block Name: Foo, Ref: Bar` (additive — name-only, no Id) +func isEntryHeader(s string) bool { + for _, p := range []string{ + "{ Item ", "{ Block ", "{ Device ", + "+ Item ", "+ Block ", "+ Device ", + "{Item ", "{Block ", "{Device ", + "+Item ", "+Block ", "+Device ", + "{ +Item ", "{ +Block ", "{ +Device ", + "{+Item ", "{+Block ", "{+Device ", + } { + if strings.HasPrefix(s, p) { + return true + } + } + return false +} + +// parseItemHeader handles entry headers in any of the forms isEntryHeader +// recognizes and returns the partial Item populated from Id/Name/Ref keys. +func parseItemHeader(line string) *Item { + body := line + for _, p := range []string{ + "{ +Item ", "{ +Block ", "{ +Device ", + "{+Item ", "{+Block ", "{+Device ", + "{ Item ", "{ Block ", "{ Device ", + "+ Item ", "+ Block ", "+ Device ", + "{Item ", "{Block ", "{Device ", + "+Item ", "+Block ", "+Device ", + } { + body = strings.TrimPrefix(body, p) + } + body = strings.TrimSpace(body) + it := &Item{} + for _, kv := range splitTopLevel(body, ',') { + parts := strings.SplitN(strings.TrimSpace(kv), ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := stripQuotes(strings.TrimSpace(parts[1])) + switch key { + case "Id": + if id, err := strconv.Atoi(val); err == nil { + it.ID = id + } + case "Name": + it.Name = val + case "Ref": + it.Ref = val + } + } + // Name-only `+Block Name: Foo, Ref: Bar` entries (additive overrides) are + // kept only if we can resolve their ID later via the Ref-target. For now + // we drop them since they're a small minority and don't affect the picker. + if it.Name == "" { + return nil + } + return it +} + +func parseProperty(it *Item, line string) { + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return + } + key := strings.TrimSpace(parts[0]) + val := stripQuotes(strings.TrimSpace(parts[1])) + // Trim trailing comment; key/value can be followed by inline `# ...`. + val = stripComment(val) + val = strings.TrimSpace(val) + switch strings.ToLower(key) { + case "category": + it.Category = val + case "group": + it.Group = val + case "mass": + it.Mass = val + case "volume": + it.Volume = val + case "marketprice": + it.MarketPrice = val + case "stacksize": + it.StackSize = val + case "class": + it.Class = val + case "holdtype": + it.HoldType = val + case "customicon": + it.CustomIcon = val + case "allowat": + it.AllowAt = val + case "description": + it.Description = val + default: + if it.Extras == nil { + it.Extras = map[string]string{} + } + it.Extras[key] = val + } +} + +func (idx *Index) add(it *Item) { + if it == nil || it.Name == "" { + return + } + // duplicate item IDs use the LAST seen entry (matches Empyrion's own override semantics). + if it.ID > 0 { + idx.byID[it.ID] = it + } + idx.byName[strings.ToLower(it.Name)] = it + idx.Items = append(idx.Items, *it) +} + +// ByID returns the item with the given numeric ID, or nil. +func (idx *Index) ByID(id int) *Item { + if it, ok := idx.byID[id]; ok { + c := *it + return &c + } + return nil +} + +// ByName looks up an item by its canonical name token (case-insensitive). +func (idx *Index) ByName(name string) *Item { + if it, ok := idx.byName[strings.ToLower(name)]; ok { + c := *it + return &c + } + return nil +} + +// Search returns items whose name OR description contains the query (case- +// insensitive). Empty query returns all items. limit <= 0 returns all matches. +func (idx *Index) Search(q string, category string, limit int) []Item { + q = strings.ToLower(strings.TrimSpace(q)) + cat := strings.ToLower(strings.TrimSpace(category)) + out := make([]Item, 0, 64) + for _, it := range idx.Items { + if cat != "" && strings.ToLower(it.Category) != cat { + continue + } + if q != "" { + lname := strings.ToLower(it.Name) + ldesc := strings.ToLower(it.Description) + if !strings.Contains(lname, q) && !strings.Contains(ldesc, q) { + continue + } + } + out = append(out, it) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +// Categories returns the distinct, sorted-by-frequency categories present in the index. +func (idx *Index) Categories() []string { + counts := map[string]int{} + for _, it := range idx.Items { + if it.Category != "" { + counts[it.Category]++ + } + } + out := make([]string, 0, len(counts)) + for k := range counts { + out = append(out, k) + } + // stable order: descending by count, then alpha + for i := 1; i < len(out); i++ { + for j := i; j > 0 && (counts[out[j]] > counts[out[j-1]] || (counts[out[j]] == counts[out[j-1]] && out[j] < out[j-1])); j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +func stripComment(s string) string { + // `#` starts a comment unless inside quotes — items config doesn't use `#` + // inside string values in stock content, so a naive strip is safe. + if i := strings.Index(s, "#"); i >= 0 { + return s[:i] + } + return s +} + +func stripQuotes(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + return s +} + +// splitTopLevel splits on the given separator while ignoring separators +// inside braces or quotes. Used to break "Id: 12, Name: Foo" into pieces +// without accidentally splitting things like `BuffMod: "a, b, c"`. +func splitTopLevel(s string, sep byte) []string { + var out []string + var cur strings.Builder + depth := 0 + inQuote := false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '"': + inQuote = !inQuote + cur.WriteByte(c) + case !inQuote && (c == '{' || c == '['): + depth++ + cur.WriteByte(c) + case !inQuote && (c == '}' || c == ']'): + depth-- + cur.WriteByte(c) + case !inQuote && depth == 0 && c == sep: + out = append(out, cur.String()) + cur.Reset() + default: + cur.WriteByte(c) + } + } + if cur.Len() > 0 { + out = append(out, cur.String()) + } + return out +} diff --git a/pkg/empyrionitems/items_test.go b/pkg/empyrionitems/items_test.go new file mode 100644 index 00000000..8b09ae08 --- /dev/null +++ b/pkg/empyrionitems/items_test.go @@ -0,0 +1,72 @@ +package empyrionitems + +import ( + "os" + "strings" + "testing" +) + +func TestLoadItemsConfig(t *testing.T) { + idx, err := LoadFromFile("ItemsConfig.ecf") + if err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + if len(idx.Items) < 30 { + t.Fatalf("expected at least 30 items in ItemsConfig.ecf, got %d", len(idx.Items)) + } + t.Logf("ItemsConfig: parsed %d items", len(idx.Items)) + + // Spot checks against entries seen in the live ItemsConfig.ecf header sample. + cases := []struct { + id int + name string + }{ + {1, "MeleePlayer"}, + {12, "Fillertool"}, + {220, "MetalPieces"}, + {240, "EnergyCellHydrogen"}, + {398, "XP"}, + } + for _, tc := range cases { + it := idx.ByID(tc.id) + if it == nil { + t.Errorf("id %d not found", tc.id) + continue + } + if it.Name != tc.name { + t.Errorf("id %d: name %q, want %q", tc.id, it.Name, tc.name) + } + } +} + +func TestLoadBlocksConfig(t *testing.T) { + if _, err := os.Stat("BlocksConfig.ecf"); os.IsNotExist(err) { + t.Skip("BlocksConfig.ecf not present alongside test") + } + idx, err := LoadFromFile("BlocksConfig.ecf") + if err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + if len(idx.Items) < 1500 { + t.Fatalf("expected >1500 entries in BlocksConfig.ecf, got %d", len(idx.Items)) + } + t.Logf("BlocksConfig: parsed %d entries", len(idx.Items)) + + // Spot check by name (IDs evolve across versions; names are stable). + for _, name := range []string{"Air", "Stone", "Grass"} { + if idx.ByName(name) == nil { + t.Errorf("expected to find %q", name) + } + } + + // Search must return matches for common item families. + hits := idx.Search("Constructor", "", 50) + if len(hits) < 2 { + t.Errorf("search 'Constructor' should hit multiple devices, got %d", len(hits)) + } + for _, h := range hits[:1] { + if !strings.Contains(strings.ToLower(h.Name), "constructor") { + t.Errorf("unexpected match: %+v", h) + } + } +} diff --git a/pkg/empyrionitems/loot.go b/pkg/empyrionitems/loot.go new file mode 100644 index 00000000..76f375fa --- /dev/null +++ b/pkg/empyrionitems/loot.go @@ -0,0 +1,159 @@ +package empyrionitems + +// Loot group parser for Empyrion's `LootGroups.ecf`. +// Format: +// +// { +LootGroup Name: EscapePodEasy +// Count: all +// Item_0: EmergencyRations, param1: 1 +// Item_1: WaterBottle, param1: 5 +// ... +// } +// +// param1 = count (or "low-high" range), param2 = optional probability. +// Item names are canonical Item.Name tokens. + +import ( + "bufio" + "io" + "os" + "strings" +) + +type LootEntry struct { + Item string `json:"item"` + Count string `json:"count,omitempty"` + Probability string `json:"probability,omitempty"` +} + +type LootGroup struct { + Name string `json:"name"` + Count string `json:"count,omitempty"` + Items []LootEntry `json:"items"` +} + +type LootIndex struct { + Groups []LootGroup + byName map[string]*LootGroup + byItem map[string][]string // ingredientLower → group names +} + +func LoadLootFromFile(path string) (*LootIndex, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return LoadLootFromReader(f) +} + +func LoadLootFromReader(r io.Reader) (*LootIndex, error) { + idx := &LootIndex{byName: map[string]*LootGroup{}, byItem: map[string][]string{}} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + + var current *LootGroup + var depth int + + for sc.Scan() { + line := stripComment(sc.Text()) + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + + if current == nil { + if strings.HasPrefix(trim, "{ +LootGroup ") || strings.HasPrefix(trim, "{+LootGroup ") || strings.HasPrefix(trim, "{ LootGroup ") { + body := strings.TrimPrefix(trim, "{ +LootGroup ") + body = strings.TrimPrefix(body, "{+LootGroup ") + body = strings.TrimPrefix(body, "{ LootGroup ") + current = &LootGroup{} + for _, kv := range splitTopLevel(body, ',') { + parts := strings.SplitN(strings.TrimSpace(kv), ":", 2) + if len(parts) != 2 { + continue + } + k := strings.TrimSpace(parts[0]) + v := stripQuotes(strings.TrimSpace(parts[1])) + if strings.EqualFold(k, "name") { + current.Name = v + } + } + depth = 1 + } + continue + } + + opens := strings.Count(trim, "{") + closes := strings.Count(trim, "}") + depth += opens - closes + if depth <= 0 { + if current.Name != "" { + cp := *current + idx.Groups = append(idx.Groups, cp) + gp := &idx.Groups[len(idx.Groups)-1] + idx.byName[strings.ToLower(cp.Name)] = gp + for _, it := range cp.Items { + low := strings.ToLower(it.Item) + idx.byItem[low] = append(idx.byItem[low], cp.Name) + } + } + current = nil + depth = 0 + continue + } + + parts := strings.SplitN(trim, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := stripComment(strings.TrimSpace(parts[1])) + + if strings.HasPrefix(strings.ToLower(key), "item_") { + // Item_N: Name, param1: count, param2: probability + le := LootEntry{} + pieces := splitTopLevel(val, ',') + for i, p := range pieces { + p = strings.TrimSpace(p) + if i == 0 { + le.Item = stripQuotes(p) + continue + } + kv := strings.SplitN(p, ":", 2) + if len(kv) != 2 { + continue + } + kk := strings.ToLower(strings.TrimSpace(kv[0])) + vv := strings.TrimSpace(kv[1]) + switch kk { + case "param1", "param": + le.Count = vv + case "param2", "xdata": + le.Probability = vv + } + } + if le.Item != "" { + current.Items = append(current.Items, le) + } + } else if strings.EqualFold(key, "Count") { + current.Count = strings.TrimSpace(val) + } + } + if err := sc.Err(); err != nil { + return nil, err + } + return idx, nil +} + +func (idx *LootIndex) ByName(name string) *LootGroup { + if g, ok := idx.byName[strings.ToLower(name)]; ok { + c := *g + return &c + } + return nil +} + +func (idx *LootIndex) DroppedBy(itemName string) []string { + return append([]string{}, idx.byItem[strings.ToLower(itemName)]...) +} diff --git a/pkg/empyrionitems/templates.go b/pkg/empyrionitems/templates.go new file mode 100644 index 00000000..844d3573 --- /dev/null +++ b/pkg/empyrionitems/templates.go @@ -0,0 +1,170 @@ +package empyrionitems + +// Template (recipe) parser for Empyrion's `Templates.ecf`. +// Format snippet: +// +// { +Template Name: PlasticMaterial +// BaseItem: true +// CraftTime: 2 +// Target: "SurvC,SmallC,HoverC,BaseC,LargeC,AdvC" +// { Child Inputs +// CrushedStone: 1 +// } +// } +// +// We extract Name + outer props (CraftTime, Target, BaseItem, +// OutputCount, etc.) and the Inputs map. + +import ( + "bufio" + "io" + "os" + "strings" +) + +type Template struct { + Name string `json:"name"` + BaseItem bool `json:"baseItem,omitempty"` + CraftTime string `json:"craftTime,omitempty"` + Target string `json:"target,omitempty"` + OutputCount string `json:"outputCount,omitempty"` + Ref string `json:"ref,omitempty"` + Inputs map[string]string `json:"inputs"` // ingredientName → count token + Extras map[string]string `json:"extras,omitempty"` +} + +type TemplateIndex struct { + Templates []Template + byName map[string]*Template +} + +func LoadTemplatesFromFile(path string) (*TemplateIndex, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return LoadTemplatesFromReader(f) +} + +func LoadTemplatesFromReader(r io.Reader) (*TemplateIndex, error) { + idx := &TemplateIndex{byName: map[string]*Template{}} + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + + var current *Template + var inInputs bool + var depth int + + for sc.Scan() { + line := stripComment(sc.Text()) + trim := strings.TrimSpace(line) + if trim == "" { + continue + } + + if current == nil { + if strings.HasPrefix(trim, "{ +Template ") || strings.HasPrefix(trim, "{+Template ") || strings.HasPrefix(trim, "{ Template ") { + body := strings.TrimPrefix(trim, "{ +Template ") + body = strings.TrimPrefix(body, "{+Template ") + body = strings.TrimPrefix(body, "{ Template ") + body = strings.TrimSpace(body) + current = &Template{Inputs: map[string]string{}, Extras: map[string]string{}} + for _, kv := range splitTopLevel(body, ',') { + parts := strings.SplitN(strings.TrimSpace(kv), ":", 2) + if len(parts) != 2 { + continue + } + k := strings.TrimSpace(parts[0]) + v := stripQuotes(strings.TrimSpace(parts[1])) + switch strings.ToLower(k) { + case "name": + current.Name = v + case "ref": + current.Ref = v + } + } + depth = 1 + } + continue + } + + opens := strings.Count(trim, "{") + closes := strings.Count(trim, "}") + depth += opens - closes + if depth <= 0 { + if current.Name != "" { + cp := *current + idx.Templates = append(idx.Templates, cp) + idx.byName[strings.ToLower(cp.Name)] = &idx.Templates[len(idx.Templates)-1] + } + current = nil + inInputs = false + depth = 0 + continue + } + + // Track child blocks like `{ Child Inputs`. + if strings.HasPrefix(trim, "{ Child Inputs") || strings.HasPrefix(trim, "{Child Inputs") { + inInputs = true + continue + } + if inInputs && strings.HasPrefix(trim, "}") { + inInputs = false + continue + } + + // Property line. + parts := strings.SplitN(trim, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := stripQuotes(strings.TrimSpace(parts[1])) + val = strings.TrimSpace(stripComment(val)) + + if inInputs { + current.Inputs[key] = val + continue + } + switch strings.ToLower(key) { + case "baseitem": + current.BaseItem = strings.EqualFold(val, "true") + case "crafttime": + current.CraftTime = val + case "target": + current.Target = val + case "outputcount": + current.OutputCount = val + default: + current.Extras[key] = val + } + } + if err := sc.Err(); err != nil { + return nil, err + } + return idx, nil +} + +func (idx *TemplateIndex) ByName(name string) *Template { + if t, ok := idx.byName[strings.ToLower(name)]; ok { + c := *t + return &c + } + return nil +} + +// UsedIn returns the list of templates whose inputs include the named item. +func (idx *TemplateIndex) UsedIn(itemName string) []Template { + want := strings.ToLower(itemName) + out := []Template{} + for _, t := range idx.Templates { + for k := range t.Inputs { + if strings.ToLower(k) == want { + out = append(out, t) + break + } + } + } + return out +} diff --git a/pkg/go.mod b/pkg/go.mod new file mode 100644 index 00000000..0ecfbdbe --- /dev/null +++ b/pkg/go.mod @@ -0,0 +1,7 @@ +module github.com/dbledeez/panel/pkg + +go 1.26 + +require gopkg.in/yaml.v3 v3.0.1 + +require github.com/beevik/etree v1.6.0 // indirect diff --git a/pkg/go.sum b/pkg/go.sum new file mode 100644 index 00000000..89454504 --- /dev/null +++ b/pkg/go.sum @@ -0,0 +1,6 @@ +github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= +github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/module/events_test.go b/pkg/module/events_test.go new file mode 100644 index 00000000..30165c75 --- /dev/null +++ b/pkg/module/events_test.go @@ -0,0 +1,73 @@ +package module + +import ( + "path/filepath" + "regexp" + "testing" +) + +// TestAllManifestsLoadAndEventRegexesCompile is the repo-wide consistency +// gate for module manifests: every modules//module.yaml must LoadDir +// cleanly, and every declared events regex must compile under Go's RE2 +// engine (AMPTemplates uses .NET (?) syntax — these must have been +// converted to (?P)) and carry at least one named capture group the +// agent's tracker actually reads (see agent/internal/state/tracker.go +// OnLogLine: name/player_name, id-family, msg/detail/reason). +func TestAllManifestsLoadAndEventRegexesCompile(t *testing.T) { + dir := filepath.Join("..", "..", "modules") + manifests, err := LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir(%s): %v", dir, err) + } + if len(manifests) < 20 { + t.Fatalf("expected >=20 manifests, got %d — wrong dir?", len(manifests)) + } + + // Group names the tracker extracts (tracker.go firstGroup calls). + known := map[string]bool{ + "name": true, "player_name": true, + "platform_id": true, "owner": true, "cross_id": true, "id": true, "player_id": true, + "msg": true, "detail": true, "reason": true, + } + validKinds := map[string]bool{"join": true, "leave": true, "chat": true, "death": true, "custom": true} + + for _, m := range manifests { + m := m + t.Run(m.ID, func(t *testing.T) { + for evName, ev := range m.Events { + re, err := regexp.Compile(ev.Pattern) + if err != nil { + t.Errorf("event %q: pattern does not compile: %v", evName, err) + continue + } + if !validKinds[ev.Kind] { + t.Errorf("event %q: unknown kind %q", evName, ev.Kind) + } + // Every event must expose at least one group the tracker + // knows how to read, otherwise the event emits empty + // PlayerName/PlayerId/Detail and is a silently-useless knob. + got := false + for _, g := range re.SubexpNames() { + if known[g] { + got = true + } + } + if !got { + t.Errorf("event %q: no tracker-known named group (want one of name/id/msg families); groups=%v", evName, re.SubexpNames()) + } + // join/leave should identify the player by name or id. + if ev.Kind == "join" || ev.Kind == "leave" { + hasIdent := false + for _, g := range re.SubexpNames() { + if g == "name" || g == "player_name" || g == "id" || g == "platform_id" || g == "player_id" || g == "owner" || g == "cross_id" { + hasIdent = true + } + } + if !hasIdent { + t.Errorf("event %q (kind %s): no player-identifying named group", evName, ev.Kind) + } + } + } + }) + } +} diff --git a/pkg/module/loader.go b/pkg/module/loader.go new file mode 100644 index 00000000..8ebb0cc7 --- /dev/null +++ b/pkg/module/loader.go @@ -0,0 +1,165 @@ +package module + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// LoadFile parses a single module.yaml at path. +func LoadFile(path string) (*Manifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var m Manifest + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if err := m.Validate(); err != nil { + return nil, fmt.Errorf("validate %s: %w", path, err) + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("abs %s: %w", path, err) + } + m.Path = abs + m.Dir = filepath.Dir(abs) + return &m, nil +} + +// LoadDir scans dir for subdirectories containing a module.yaml and loads each. +// Subdirectories without module.yaml are skipped silently. +func LoadDir(dir string) ([]*Manifest, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read dir %s: %w", dir, err) + } + var out []*Manifest + for _, e := range entries { + if !e.IsDir() { + continue + } + p := filepath.Join(dir, e.Name(), "module.yaml") + info, err := os.Stat(p) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, fmt.Errorf("stat %s: %w", p, err) + } + if info.IsDir() { + continue + } + m, err := LoadFile(p) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, nil +} + +// Validate enforces required fields and basic structural invariants. +// It does NOT validate configs against JSON schemas or regex compilability +// (callers do that after load, since it's policy-specific). +func (m *Manifest) Validate() error { + if m.ID == "" { + return errors.New("id is required") + } + if m.Name == "" { + return errors.New("name is required") + } + if m.Version == "" { + return errors.New("version is required") + } + if len(m.SupportedModes) == 0 { + return errors.New("supported_modes must list at least one mode") + } + for _, mode := range m.SupportedModes { + switch mode { + case "docker": + case "host": + // Declared in the manifest schema but the agent has no host + // runtime — accepting it here would advertise a capability + // that silently doesn't work. Reject loudly until host mode + // is actually implemented. + return fmt.Errorf("run mode %q is not implemented (only %q is supported); remove it from supported_modes", mode, "docker") + default: + return fmt.Errorf("unknown run mode %q (valid: docker)", mode) + } + } + if m.HasMode("docker") && (m.Runtime.Docker == nil || m.Runtime.Docker.Image == "") { + return errors.New("supported_modes includes docker but runtime.docker.image is missing") + } + if m.HasMode("host") && m.Runtime.Host == nil { + return errors.New("supported_modes includes host but runtime.host is missing") + } + if len(m.Ports) == 0 { + return errors.New("at least one port must be declared") + } + seen := map[string]bool{} + for _, p := range m.Ports { + if p.Name == "" { + return errors.New("port.name is required") + } + if seen[p.Name] { + return fmt.Errorf("duplicate port name %q", p.Name) + } + seen[p.Name] = true + switch p.Proto { + case "tcp", "udp": + default: + return fmt.Errorf("port %q: proto must be tcp or udp (got %q)", p.Name, p.Proto) + } + if p.Default == 0 { + return fmt.Errorf("port %q: default is required", p.Name) + } + } + if m.RCON != nil { + if m.RCON.Adapter == "" { + return errors.New("rcon.adapter is required when rcon is set") + } + if m.RCON.HostPort != "" && m.Port(m.RCON.HostPort) == nil { + return fmt.Errorf("rcon.host_port %q does not match any declared port", m.RCON.HostPort) + } + } + if len(m.UpdateProviders) == 0 { + return errors.New("at least one update_provider is required") + } + providerIDs := map[string]bool{} + for _, p := range m.UpdateProviders { + if p.ID == "" { + return errors.New("update_provider.id is required") + } + if providerIDs[p.ID] { + return fmt.Errorf("duplicate update_provider id %q", p.ID) + } + providerIDs[p.ID] = true + switch p.Kind { + case "steamcmd": + if p.AppID == "" { + return fmt.Errorf("update_provider %q: app_id required for steamcmd kind", p.ID) + } + case "github": + if p.Repo == "" { + return fmt.Errorf("update_provider %q: repo required for github kind", p.ID) + } + case "direct": + if p.URL == "" { + return fmt.Errorf("update_provider %q: url required for direct kind", p.ID) + } + default: + return fmt.Errorf("update_provider %q: unknown kind %q", p.ID, p.Kind) + } + } + return nil +} + diff --git a/pkg/module/manifest.go b/pkg/module/manifest.go new file mode 100644 index 00000000..c7fbf1a1 --- /dev/null +++ b/pkg/module/manifest.go @@ -0,0 +1,436 @@ +// Package module defines the on-disk manifest format (module.yaml) that +// declares a game plugin: how to install it, how to run it, what ports it +// exposes, what RCON dialect it speaks, and what patterns identify state +// and player events in its logs. +// +// Both Controller and Target load manifests from a local modules/ directory. +// The Controller owns authoring/UI form generation; the Target uses the +// manifest at runtime to shape container launch and state-tracking pipelines. +package module + +import ( + "fmt" + "time" + + "gopkg.in/yaml.v3" +) + +// Manifest is the root structure parsed from a module.yaml file. +type Manifest struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Authors []string `yaml:"authors,omitempty"` + SupportedModes []string `yaml:"supported_modes"` + Runtime Runtime `yaml:"runtime"` + Ports []Port `yaml:"ports"` + Resources Resources `yaml:"resources,omitempty"` + ConfigFiles []ConfigFile `yaml:"config_files,omitempty"` + RCON *RCONConfig `yaml:"rcon,omitempty"` + StateSources []StateSource `yaml:"state_sources,omitempty"` + Events map[string]Event `yaml:"events,omitempty"` + UpdateProviders []UpdateProvider `yaml:"update_providers"` + Secrets []Secret `yaml:"secrets,omitempty"` + Lifecycle *Lifecycle `yaml:"lifecycle,omitempty"` + // AutoUpdateOnCreate — run the first update_provider automatically + // after a successful create, so the operator doesn't have to click + // Update as a separate step. Default is `nil` which the loader + // interprets as true for modules that declare a real provider. Set + // explicitly to `false` for modules whose image handles its own + // downloading at runtime (e.g. the ark-sa community image). + AutoUpdateOnCreate *bool `yaml:"auto_update_on_create,omitempty"` + // AutoStartOnCreate — after a successful create (and first-time update, + // if one ran), boot the server automatically. Default nil = true for any + // module. Opt out with `auto_start_on_create: false` for modules whose + // first boot needs operator attention (e.g. filling in secrets first). + // Independent of AutoUpdateOnCreate: images like ark-sa/ASA skip the + // external updater but still need to auto-start so their in-container + // entrypoint can run the bundled SteamCMD + launch the game in one go. + AutoStartOnCreate *bool `yaml:"auto_start_on_create,omitempty"` + // Backup declares which paths the panel should archive when an + // operator clicks Back-up-now. Without this the agent tars the + // whole BrowseableRoot — fine for tiny games, catastrophic for ARK + // SA where that's a 18 GB SteamCMD install plus 200 MB of saves. + // Paths are container-relative (resolved against BrowseableRoot) + // and use shell-style globs. Saves-only is the goal. + Backup *BackupSpec `yaml:"backup,omitempty"` + + // ConfigValues is the on-disk *declaration* of operator-tunable knobs. + // The dashboard hardcodes its config-form schemas separately (see + // static/index.html `configSchemas`), so this field is not consumed by + // the loader yet — it's accepted only to keep the strict YAML decoder + // happy when modules document their tunables in the manifest. Future + // work: drive the dashboard form generator off this field directly so + // the schema lives in one place. + ConfigValues []map[string]any `yaml:"config_values,omitempty"` + + // ReadyPattern is the log regex that marks the server as actually + // joinable (world loaded, accepting connections) — CONTROLLER/UI + // metadata only; the agent never compiles it. The string is a + // JavaScript regex: either a bare source ("Done \\(...\\)! For help") + // or slash-delimited with flags ("/StartGame done/i"). It may use JS + // constructs Go's regexp lacks (lookahead) — do not compile in Go. + // Source of truth moved here from the dashboard's READY_PATTERNS map. + ReadyPattern string `yaml:"ready_pattern,omitempty"` + // Appearance carries the dashboard's visual identity for this module + // (emoji, tile gradient, packaged art, Steam appid, optional display + // name/tagline/accent). CONTROLLER/UI metadata only. Source of truth + // moved here from the dashboard's moduleAppearance map. + Appearance *Appearance `yaml:"appearance,omitempty"` + + // Path is the absolute path to module.yaml, set by the loader. + Path string `yaml:"-"` + // Dir is the directory containing the manifest; template/schema paths + // in this manifest are resolved relative to Dir. + Dir string `yaml:"-"` +} + +// Runtime declares how the module launches under each supported mode. +type Runtime struct { + Docker *DockerSpec `yaml:"docker,omitempty"` + Host *HostSpec `yaml:"host,omitempty"` +} + +// DockerSpec is the Docker-mode launch recipe. +type DockerSpec struct { + Image string `yaml:"image"` + Volumes []Volume `yaml:"volumes,omitempty"` + Entrypoint []string `yaml:"entrypoint,omitempty"` + Command []string `yaml:"command,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + User string `yaml:"user,omitempty"` + // BrowseableRoot is the container-absolute path the file manager + // treats as "root" when operating on this container (e.g. "/data"). + // If empty, the resolver defaults to the first volume's container + // path. If neither is set, the file manager falls back to the + // host-side data_path bind-mount root. + BrowseableRoot string `yaml:"browseable_root,omitempty"` + // BrowseableRoots lets a module expose multiple independent "views" + // to the file manager — e.g. 7DTD's /game-saves (worlds, configs) + // AND /game (mods, plugins, binaries). Each entry becomes a picker + // option in the Files tab. First entry is the default view. + // Empty → fall back to BrowseableRoot / first volume. + BrowseableRoots []BrowseableRoot `yaml:"browseable_roots,omitempty"` + // NetworkMode is the Docker network mode. Empty → default bridge with + // published ports. Use "host" for modules that rely on long-lived UDP + // sessions to external services (e.g. Windrose's P2P coop gateway, where + // Docker Desktop's UDP NAT rebinds break TURN consent checks). In host + // mode the Ports block is informational only — the container listens on + // the host's interfaces directly; the default bind-mount NAT is skipped. + NetworkMode string `yaml:"network_mode,omitempty"` +} + +// BrowseableRoot names one container-absolute directory for the file +// manager to surface in its roots picker. JSON tags match what the +// dashboard's Files-tab picker consumes via GET /api/modules. +type BrowseableRoot struct { + Name string `yaml:"name" json:"name"` // human label — shown in the UI + Path string `yaml:"path" json:"path"` // container-absolute path + Hint string `yaml:"hint,omitempty" json:"hint,omitempty"` // optional sub-line shown in the picker +} + +// Appearance is UI-only visual metadata for a module. Every field is +// optional; the dashboard falls back to its built-in defaults for any +// missing field. JSON keys mirror the dashboard's moduleAppearance map +// entries (emoji/grad/art/steam) so the payload drops in unchanged. +type Appearance struct { + // DisplayName overrides Manifest.Name in UI card headers when set. + DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"` + // Tagline is an optional one-line blurb shown under the name. + Tagline string `yaml:"tagline,omitempty" json:"tagline,omitempty"` + // Accent is an optional CSS color for module-tinted UI chrome. + Accent string `yaml:"accent,omitempty" json:"accent,omitempty"` + // Emoji shown when no art is available. + Emoji string `yaml:"emoji,omitempty" json:"emoji,omitempty"` + // Grad is the CSS gradient behind the tile / while art loads. + Grad string `yaml:"grad,omitempty" json:"grad,omitempty"` + // Art is the panel-hosted art path, e.g. "/game-art/7dtd.jpg". + Art string `yaml:"art,omitempty" json:"art,omitempty"` + // Steam is the Steam appid used for CDN header-art fallback. + Steam string `yaml:"steam,omitempty" json:"steam,omitempty"` +} + +// HostSpec is the host-mode launch recipe. +type HostSpec struct { + ExecutableLinux string `yaml:"executable_linux,omitempty"` + ExecutableWindows string `yaml:"executable_windows,omitempty"` + Args []string `yaml:"args,omitempty"` + Env map[string]string `yaml:"env,omitempty"` + WorkingDir string `yaml:"working_dir,omitempty"` +} + +// Volume declares a mount point on the instance's container. +// +// Two kinds are supported: +// +// type: bind — Target is the host path (may contain $DATA_PATH). +// Useful for configs + logs operators want to read/edit. +// type: volume — Name is a Docker named volume (may contain $INSTANCE_ID). +// Lives inside the WSL2/container-storage filesystem; +// dramatically faster than Windows bind-mounts for +// high-I/O data like game installs. +// +// The agent's docker runtime auto-creates named volumes on first use. +type Volume struct { + Type string `yaml:"type,omitempty"` // "bind" (default if Target set) or "volume" + Target string `yaml:"target,omitempty"` // host path for bind (may contain $DATA_PATH) + Name string `yaml:"name,omitempty"` // volume name for named volumes (may contain $INSTANCE_ID) + Container string `yaml:"container"` + ReadOnly bool `yaml:"read_only,omitempty"` +} + +// Port is a declared network port. +type Port struct { + Name string `yaml:"name" json:"name"` + Proto string `yaml:"proto" json:"proto"` // "tcp" or "udp" + Default uint32 `yaml:"default" json:"default"` // default port number + Required bool `yaml:"required,omitempty" json:"required,omitempty"` + Internal bool `yaml:"internal,omitempty" json:"internal,omitempty"` // if true, not exposed outside the Target + // Env, when set, names the container env var the agent should + // populate with this port's allocated HOST port at create time. + // Lets the image's entrypoint translate panel-allocated ports + // into game launch args (e.g. ARK's ASA_PORT/QUERY_PORT/RCON_PORT + // → ?Port=N ?QueryPort=N ?RCONPort=N). Without this, two ARK + // instances all advertise the manifest defaults to Steam and only + // one of them is reachable via the server browser. + Env string `yaml:"env,omitempty" json:"env,omitempty"` +} + +// Resources declares RAM / CPU guidance for the UI and default limits. +type Resources struct { + MinRAMMB uint32 `yaml:"min_ram_mb,omitempty"` + RecommendedRAMMB uint32 `yaml:"recommended_ram_mb,omitempty"` + MinCPUShares uint32 `yaml:"min_cpu_shares,omitempty"` +} + +// BackupSpec narrows what gets included when a backup runs. +// +// Paths are interpreted relative to BrowseableRoot (the same path the +// File Manager treats as "the instance root"). Both Include and Exclude +// support tar-style patterns (e.g. "ShooterGame/Saved/SavedArks"). +// +// Without a BackupSpec, the agent falls back to "tar the entire +// BrowseableRoot" — preserves backwards compat for modules that +// haven't been profiled yet but is wasteful for any game with bulky +// install dirs (ARK SA, Empyrion, Conan). Each module's manifest +// should ship one of these as soon as someone has time to verify +// which paths actually need saving. +// +// Notes label is shown in the UI alongside backups, e.g. "Saves + +// configs (~250 MB typical)". +type BackupSpec struct { + // Root is the absolute in-container path to cd into before tarring. + // Defaults to the runtime's BrowseableRoot when empty. Set this when + // the module's save data lives in a different volume than the file + // browser default (7DTD: file browser shows /game by default, but + // saves live in /game-saves). + Root string `yaml:"root,omitempty"` + Include []string `yaml:"include,omitempty"` + Exclude []string `yaml:"exclude,omitempty"` + Notes string `yaml:"notes,omitempty"` +} + +// ConfigFile declares a config file the panel manages for the instance. +type ConfigFile struct { + Path string `yaml:"path"` // relative to $DATA_PATH + Format string `yaml:"format"` // xml, properties, yaml, ini, json, toml, kv + Schema string `yaml:"schema,omitempty"` // relative path to JSON-schema file in the module dir + Template string `yaml:"template,omitempty"` + // TemplateByBranch overrides Template for instances installed from a + // specific Steam branch (the normalized branch from resolveCreateBranch, + // e.g. "" for public/2.6 default, "latest_experimental" / "v3.0" for + // 3.0). Keyed by the normalized branch string; an exact match wins, + // otherwise Template (the default) is used. This is how 7DTD selects + // serverconfig.v3.xml.tmpl for a 3.0 server while ≤2.6 servers keep the + // untouched serverconfig.xml.tmpl. Kept data-driven so adding a future + // version is a manifest edit, not a code change. See + // panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D). + TemplateByBranch map[string]string `yaml:"template_by_branch,omitempty"` +} + +// TemplateForBranch returns the template path to render for this config file +// on the given normalized branch — a branch-specific override if one is +// declared, else the default Template. An empty result means "no template" +// (the file is operator-managed and Render skips it). +func (cf ConfigFile) TemplateForBranch(branch string) string { + if cf.TemplateByBranch != nil { + if t, ok := cf.TemplateByBranch[branch]; ok { + return t + } + } + return cf.Template +} + +// RCONConfig declares the instance's RCON adapter. +type RCONConfig struct { + Adapter string `yaml:"adapter"` // source_rcon, telnet, http, stdin_pipe, custom_wasm + HostPort string `yaml:"host_port"` // port name from Ports + Auth string `yaml:"auth,omitempty"` // password_prompt, source_rcon_login, bearer, none + PasswordSecret string `yaml:"password_secret,omitempty"` // name from Secrets to use as RCON password + PasswordLiteral string `yaml:"password_literal,omitempty"` // literal password (useful when the module's env pins one — lower priority than PasswordSecret / config_values) + // PasswordFromFile reads the password at dial time from a path *inside + // the container*. Lowest priority — used as a fallback when the module's + // entrypoint generates its own RCON secret on first boot (e.g. 7DTD's + // TelnetPassword, which we auto-populate to force 0.0.0.0 binding and + // stash in /game-saves/.panel-telnet-password). + PasswordFromFile string `yaml:"password_from_file,omitempty"` + // PasswordFromINI extracts the password from a specific key in a + // specific section of an INI file inside the container. Useful for + // modules whose config file holds the RCON password alongside other + // settings (ARK: SA stores it as + // [ServerSettings]ServerAdminPassword=... in GameUserSettings.ini). + // Read fresh on each dial so changes the operator makes via the Config + // tab take effect as soon as the server picks them up on restart. + PasswordFromINI *PasswordFromINI `yaml:"password_from_ini,omitempty"` + Commands map[string]string `yaml:"commands,omitempty"` // named command templates +} + +// PasswordFromINI locates a single key/value in an INI file inside the +// container. Section is optional — when empty we match the key anywhere. +type PasswordFromINI struct { + File string `yaml:"file"` + Section string `yaml:"section,omitempty"` + Key string `yaml:"key"` +} + +// StateSource is a state-feed declaration — either an RCON poll or a log tail. +type StateSource struct { + Type string `yaml:"type"` // rcon, log_tail, http_probe + Command string `yaml:"command,omitempty"` + Every Duration `yaml:"every,omitempty"` + Parse *ParseConfig `yaml:"parse,omitempty"` + Files []string `yaml:"files,omitempty"` + URL string `yaml:"url,omitempty"` +} + +// ParseConfig describes how to turn a raw response into named fields. +type ParseConfig struct { + Kind string `yaml:"kind"` // regex, json, kv + Pattern string `yaml:"pattern,omitempty"` + Fields map[string]string `yaml:"fields,omitempty"` // output_name -> named_group +} + +// Event is a log-derived event mapping: regex with named groups, mapped to a kind. +type Event struct { + Pattern string `yaml:"pattern"` + Kind string `yaml:"kind"` // join, leave, chat, death, custom +} + +// UpdateProvider declares a source the panel can fetch new versions from. +type UpdateProvider struct { + ID string `yaml:"id"` + Kind string `yaml:"kind"` // steamcmd, github, direct + AppID string `yaml:"app_id,omitempty"` + Beta string `yaml:"beta,omitempty"` + // Platform, when non-empty, forces SteamCMD to download a specific + // platform's depots via +@sSteamCmdForcePlatformType. Used for Windows- + // only server builds installed on Linux hosts (Empyrion, a few others). + // Values: "windows", "linux", "macos". + Platform string `yaml:"platform,omitempty"` + Repo string `yaml:"repo,omitempty"` + AssetRegex string `yaml:"asset_regex,omitempty"` + URL string `yaml:"url,omitempty"` + // TargetPath is where downloaded bytes land, relative to the + // instance's BrowseableRoot (if the container exists) or its + // DataPath bind mount. Required for `direct` + `github` kinds. + TargetPath string `yaml:"target_path,omitempty"` + // InstallPath is the container-absolute install dir for SteamCMD's + // +force_install_dir. Defaults to BrowseableRoot if empty. + InstallPath string `yaml:"install_path,omitempty"` + // Extract tells the `direct` / `github` provider to treat the + // downloaded bytes as an archive (zip / tar / tar.gz / tar.bz2 / + // tar.xz) and extract its contents INTO TargetPath (a directory) + // instead of writing the raw bytes as a single file. Factorio's + // headless .tar.xz uses this. When false, the provider still + // auto-extracts if the content is a recognized archive AND + // TargetPath has no file extension (looks like a directory) — + // but a TargetPath like /server.jar or /terraria-server.zip is + // always written verbatim (minecraft jars ARE zips; terraria's + // entrypoint expects the zip file on disk). + Extract bool `yaml:"extract,omitempty"` + // SkipValidate omits the `validate` keyword from the `app_update` + // command. Conan Exiles (app 443030) and a handful of other apps + // fail with "Missing configuration" when validate runs against an + // empty install dir; plain `app_update` succeeds. Operators who + // need periodic integrity checks can run Update manually later. + SkipValidate bool `yaml:"skip_validate,omitempty"` + // RequiresSteamLogin means `+login anonymous` won't work — the app + // is paid content (DayZ 223350, Arma 3 229470, etc.) and SteamCMD + // needs a real Steam account that owns it. When true, the controller + // halts the update flow with {steam_login_required: true} if no + // cached credential is available, letting the UI pop a login modal. + // Once creds are stored, they're injected as `+login `. + RequiresSteamLogin bool `yaml:"requires_steam_login,omitempty"` +} + +// Secret declares an operator-supplied or panel-generated credential. +type Secret struct { + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Generated bool `yaml:"generated,omitempty"` +} + +// Lifecycle declares shell commands (or WASM hooks — future) that run at +// specific lifecycle phases. Commands run inside the instance sandbox. +type Lifecycle struct { + PreInstall []string `yaml:"pre_install,omitempty"` + PostInstall []string `yaml:"post_install,omitempty"` + PreStart []string `yaml:"pre_start,omitempty"` + PostStart []string `yaml:"post_start,omitempty"` + PreStop []string `yaml:"pre_stop,omitempty"` + PostStop []string `yaml:"post_stop,omitempty"` +} + +// Duration is a time.Duration that parses from YAML strings like "30s". +type Duration time.Duration + +// UnmarshalYAML parses "30s" / "5m" / "1h" style values into a Duration. +func (d *Duration) UnmarshalYAML(node *yaml.Node) error { + var s string + if err := node.Decode(&s); err != nil { + return err + } + if s == "" { + *d = 0 + return nil + } + v, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", s, err) + } + *d = Duration(v) + return nil +} + +// Std returns the underlying time.Duration. +func (d Duration) Std() time.Duration { return time.Duration(d) } + +// HasMode reports whether the module supports the given runtime mode. +func (m *Manifest) HasMode(mode string) bool { + for _, x := range m.SupportedModes { + if x == mode { + return true + } + } + return false +} + +// Port returns the named port declaration, or nil if not declared. +func (m *Manifest) Port(name string) *Port { + for i := range m.Ports { + if m.Ports[i].Name == name { + return &m.Ports[i] + } + } + return nil +} + +// UpdateProvider returns the named update provider, or nil. +func (m *Manifest) UpdateProvider(id string) *UpdateProvider { + for i := range m.UpdateProviders { + if m.UpdateProviders[i].ID == id { + return &m.UpdateProviders[i] + } + } + return nil +} diff --git a/pkg/module/manifest_test.go b/pkg/module/manifest_test.go new file mode 100644 index 00000000..1200a928 --- /dev/null +++ b/pkg/module/manifest_test.go @@ -0,0 +1,81 @@ +package module + +import ( + "path/filepath" + "testing" + "time" +) + +// TestLoad7DTDManifest loads the real 7 Days to Die manifest shipped in +// modules/7dtd and asserts the panel-critical fields parsed correctly. +// Keeping this coupled to the real file (rather than a testdata copy) means +// changes to the shipped manifest must keep the test green. +func TestLoad7DTDManifest(t *testing.T) { + path := filepath.Join("..", "..", "modules", "7dtd", "module.yaml") + m, err := LoadFile(path) + if err != nil { + t.Fatalf("LoadFile: %v", err) + } + + if m.ID != "7dtd" { + t.Errorf("id = %q, want 7dtd", m.ID) + } + if !m.HasMode("docker") { + t.Errorf("expected docker mode, got %v", m.SupportedModes) + } + if m.Runtime.Docker == nil || m.Runtime.Docker.Image == "" { + t.Error("expected docker runtime with image") + } + if m.RCON == nil || m.RCON.Adapter != "telnet" { + t.Errorf("rcon adapter = %+v, want telnet", m.RCON) + } + if p := m.Port("telnet"); p == nil || p.Proto != "tcp" || !p.Internal { + t.Errorf("telnet port malformed: %+v", p) + } + if up := m.UpdateProvider("stable"); up == nil || up.Kind != "steamcmd" || up.AppID != "294420" { + t.Errorf("stable update provider malformed: %+v", up) + } + if len(m.Events) < 3 { + t.Errorf("expected at least join/leave/chat events, got %d", len(m.Events)) + } + // state_sources[0] should be rcon poll with a 30s cadence + if len(m.StateSources) == 0 { + t.Fatal("expected at least one state source") + } + ss := m.StateSources[0] + if ss.Type != "rcon" || ss.Command != "lp" { + t.Errorf("first state source = %+v, want rcon lp", ss) + } + if ss.Every.Std() != 30*time.Second { + t.Errorf("state source every = %v, want 30s", ss.Every.Std()) + } +} + +func TestLoadDirSkipsDirWithoutManifest(t *testing.T) { + // modules/ has 7dtd + minecraft-java + valheim dirs, but only 7dtd has a + // module.yaml right now. Loader should return 1 module. + dir := filepath.Join("..", "..", "modules") + ms, err := LoadDir(dir) + if err != nil { + t.Fatalf("LoadDir: %v", err) + } + if len(ms) < 1 { + t.Errorf("want >= 1 modules, got %d", len(ms)) + } + found := false + for _, m := range ms { + if m.ID == "7dtd" { + found = true + } + } + if !found { + t.Error("7dtd not present in LoadDir result") + } +} + +func TestValidateRejectsMissingID(t *testing.T) { + m := &Manifest{} + if err := m.Validate(); err == nil { + t.Error("expected validation error for empty manifest") + } +} diff --git a/pkg/module/registry.go b/pkg/module/registry.go new file mode 100644 index 00000000..b40da622 --- /dev/null +++ b/pkg/module/registry.go @@ -0,0 +1,70 @@ +package module + +import ( + "fmt" + "sort" + "sync" +) + +// Registry is a concurrency-safe in-memory index of loaded manifests. +// It's held as a shared component by both Controller and Target. +type Registry struct { + mu sync.RWMutex + modules map[string]*Manifest +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{modules: make(map[string]*Manifest)} +} + +// LoadDir scans dir for modules and inserts them. Duplicate IDs are rejected. +func (r *Registry) LoadDir(dir string) error { + ms, err := LoadDir(dir) + if err != nil { + return err + } + r.mu.Lock() + defer r.mu.Unlock() + for _, m := range ms { + if _, exists := r.modules[m.ID]; exists { + return fmt.Errorf("duplicate module id %q in %s", m.ID, dir) + } + r.modules[m.ID] = m + } + return nil +} + +// Put inserts or replaces a manifest. +func (r *Registry) Put(m *Manifest) { + r.mu.Lock() + defer r.mu.Unlock() + r.modules[m.ID] = m +} + +// Get returns the manifest for id, or (nil, false) if unknown. +func (r *Registry) Get(id string) (*Manifest, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + m, ok := r.modules[id] + return m, ok +} + +// List returns all manifests sorted by id. +func (r *Registry) List() []*Manifest { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*Manifest, 0, len(r.modules)) + for _, m := range r.modules { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// Len returns the number of modules currently registered. +func (r *Registry) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.modules) +} diff --git a/pkg/module/render.go b/pkg/module/render.go new file mode 100644 index 00000000..7f0efd9f --- /dev/null +++ b/pkg/module/render.go @@ -0,0 +1,112 @@ +package module + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "text/template" +) + +// Render writes out each ConfigFile declared by the manifest into dataPath, +// applying `values` (which typically combines user config_values + generated +// secrets) via Go text/template. Existing files are overwritten. +// +// Templates are resolved relative to the manifest's Dir. If a ConfigFile +// declares no Template, it is skipped — operators can also manage those +// files by direct edit via the (future) SFTP/file manager. +// +// Render uses each ConfigFile's default Template. Callers that know the +// instance's Steam branch should use RenderForBranch so version-specific +// templates (7DTD 3.0's serverconfig.v3.xml.tmpl) are selected. +func Render(manifest *Manifest, dataPath string, values map[string]string) error { + return RenderForBranch(manifest, dataPath, values, "") +} + +// RenderForBranch is Render with branch-aware template selection: each +// ConfigFile renders cf.TemplateForBranch(branch) instead of cf.Template, so +// an instance on a 3.0 branch picks serverconfig.v3.xml.tmpl while ≤2.6 +// instances (branch "") keep the default template. branch is the normalized +// Steam branch (see resolveCreateBranch); "" means the module default. +func RenderForBranch(manifest *Manifest, dataPath string, values map[string]string, branch string) error { + if manifest == nil { + return fmt.Errorf("render: manifest is nil") + } + if manifest.Dir == "" { + return fmt.Errorf("render: manifest.Dir is empty (loader must set it)") + } + for _, cf := range manifest.ConfigFiles { + tmpl := cf.TemplateForBranch(branch) + if tmpl == "" { + continue + } + if err := renderOne(manifest.Dir, cf, tmpl, dataPath, values); err != nil { + return err + } + } + return nil +} + +func renderOne(moduleDir string, cf ConfigFile, templateRel string, dataPath string, values map[string]string) error { + templatePath := filepath.Join(moduleDir, templateRel) + tmplData, err := os.ReadFile(templatePath) + if err != nil { + return fmt.Errorf("read template %s: %w", templatePath, err) + } + tmpl, err := template.New(cf.Path).Option("missingkey=zero").Parse(string(tmplData)) + if err != nil { + return fmt.Errorf("parse template %s: %w", templateRel, err) + } + + var buf bytes.Buffer + ctx := struct { + Values map[string]string + }{Values: values} + if err := tmpl.Execute(&buf, ctx); err != nil { + return fmt.Errorf("execute template %s: %w", templateRel, err) + } + + outPath := filepath.Join(dataPath, cf.Path) + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(outPath), err) + } + if err := os.WriteFile(outPath, buf.Bytes(), 0o644); err != nil { + return fmt.Errorf("write %s: %w", outPath, err) + } + return nil +} + +// GenerateSecrets emits a random value for each manifest Secret flagged +// Generated. Values are URL-safe base64 of 24 bytes — safe in XML, shell, +// URLs, and long enough to resist brute force on a local RCON surface. +// +// Callers typically merge this with user-supplied config_values before +// rendering templates and resolving RCON passwords. +func GenerateSecrets(manifest *Manifest) (map[string]string, error) { + if manifest == nil { + return nil, nil + } + out := map[string]string{} + for _, s := range manifest.Secrets { + if !s.Generated { + continue + } + val, err := randomToken(24) + if err != nil { + return nil, fmt.Errorf("generate secret %q: %w", s.Name, err) + } + out[s.Name] = val + } + return out, nil +} + +// randomToken returns a URL-safe base64 string of n random bytes. +func randomToken(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/pkg/module/render_test.go b/pkg/module/render_test.go new file mode 100644 index 00000000..ca763c49 --- /dev/null +++ b/pkg/module/render_test.go @@ -0,0 +1,153 @@ +package module + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRenderWritesTemplate verifies the Go text/template pipeline: +// - template file read from manifest.Dir/ +// - values flow via .Values.key +// - output written under dataPath/, with sub-dirs created +// - missing keys render as empty (missingkey=zero) +func TestRenderWritesTemplate(t *testing.T) { + dir := t.TempDir() + // Simulate a module directory with a template subdir. + if err := os.MkdirAll(filepath.Join(dir, "templates"), 0o755); err != nil { + t.Fatal(err) + } + tmplPath := filepath.Join(dir, "templates", "demo.conf.tmpl") + tmplBody := "name={{ .Values.name }}\npassword={{ .Values.password }}\nmissing={{ .Values.nope }}END\n" + if err := os.WriteFile(tmplPath, []byte(tmplBody), 0o644); err != nil { + t.Fatal(err) + } + + m := &Manifest{ + ID: "demo", + Name: "Demo", + Dir: dir, + ConfigFiles: []ConfigFile{ + {Path: "conf/demo.conf", Format: "kv", Template: "templates/demo.conf.tmpl"}, + }, + } + + dataPath := filepath.Join(t.TempDir(), "instance-data") + values := map[string]string{ + "name": "hello", + "password": "s3cret", + } + if err := Render(m, dataPath, values); err != nil { + t.Fatalf("Render: %v", err) + } + + out, err := os.ReadFile(filepath.Join(dataPath, "conf", "demo.conf")) + if err != nil { + t.Fatalf("read rendered: %v", err) + } + got := string(out) + if !strings.Contains(got, "name=hello") { + t.Errorf("missing 'name=hello' in %q", got) + } + if !strings.Contains(got, "password=s3cret") { + t.Errorf("missing 'password=s3cret' in %q", got) + } + if !strings.Contains(got, "missing=END") { + t.Errorf("missing-key didn't render as empty in %q", got) + } +} + +// TestRenderSkipsConfigFilesWithoutTemplate verifies a ConfigFile entry +// without a Template is left alone (operator edits it directly, for example +// via SFTP/file manager). +func TestRenderSkipsConfigFilesWithoutTemplate(t *testing.T) { + m := &Manifest{ + ID: "demo", + Name: "Demo", + Dir: t.TempDir(), + ConfigFiles: []ConfigFile{ + {Path: "manual.cfg", Format: "kv"}, // no Template + }, + } + dataPath := filepath.Join(t.TempDir(), "instance-data") + if err := Render(m, dataPath, nil); err != nil { + t.Errorf("Render with no-template ConfigFile should not error: %v", err) + } + if _, err := os.Stat(filepath.Join(dataPath, "manual.cfg")); err == nil { + t.Error("should not have written manual.cfg") + } +} + +func TestGenerateSecretsUnique(t *testing.T) { + m := &Manifest{ + Secrets: []Secret{ + {Name: "telnet_password", Generated: true}, + {Name: "admin_token", Generated: true}, + {Name: "unused_static", Generated: false}, + }, + } + secrets, err := GenerateSecrets(m) + if err != nil { + t.Fatalf("GenerateSecrets: %v", err) + } + if len(secrets) != 2 { + t.Errorf("got %d secrets, want 2 (only Generated=true)", len(secrets)) + } + if secrets["telnet_password"] == "" || secrets["admin_token"] == "" { + t.Errorf("empty secret values: %+v", secrets) + } + if secrets["telnet_password"] == secrets["admin_token"] { + t.Errorf("two secrets collided: %q", secrets["telnet_password"]) + } + if _, ok := secrets["unused_static"]; ok { + t.Errorf("non-generated secret should not be in output") + } +} + +// TestRenderAgainst7DTDTemplate is an integration smoke test that renders +// the shipped 7 Days to Die template with a plausible value set and asserts +// the resulting XML contains the expected property values. +func TestRenderAgainst7DTDTemplate(t *testing.T) { + m, err := LoadFile(filepath.Join("..", "..", "modules", "7dtd", "module.yaml")) + if err != nil { + t.Skipf("7dtd manifest not loadable: %v", err) + } + // The template path under the manifest may not exist yet during early + // development. Skip cleanly if so. + var tmplRef, outRel string + for _, cf := range m.ConfigFiles { + if cf.Template != "" { + tmplRef = filepath.Join(m.Dir, cf.Template) + outRel = cf.Path + break + } + } + if tmplRef == "" { + t.Skip("7dtd manifest has no config_files with templates") + } + if _, err := os.Stat(tmplRef); err != nil { + t.Skipf("7dtd template not present: %v", err) + } + + dataPath := t.TempDir() + values := map[string]string{ + "server_name": "Refuge Test", + "telnet_password": "test-pw-123", + "max_players": "16", + "world_seed": "Navezgane", + } + if err := Render(m, dataPath, values); err != nil { + t.Fatalf("Render 7dtd: %v", err) + } + out, err := os.ReadFile(filepath.Join(dataPath, outRel)) + if err != nil { + t.Fatalf("read: %v", err) + } + got := string(out) + for _, needle := range []string{"Refuge Test", "test-pw-123", `value="16"`} { + if !strings.Contains(got, needle) { + t.Errorf("rendered serverconfig.xml missing %q; got:\n%s", needle, got) + } + } +} diff --git a/pkg/regionmedic/backups.go b/pkg/regionmedic/backups.go new file mode 100644 index 00000000..42d7232f --- /dev/null +++ b/pkg/regionmedic/backups.go @@ -0,0 +1,191 @@ +package regionmedic + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" +) + +// BackupRef identifies one panel backup tarball for an instance. +type BackupRef struct { + Path string `json:"path"` + ID string `json:"id,omitempty"` // "bkp_" parsed from the name + CreatedAt time.Time `json:"created_at"` // parsed from the filename (UTC) +} + +// backupTimeLayout matches the panel backup filename timestamp +// (time.Now().UTC().Format) — see agent/internal/dispatch/backup.go. +const backupTimeLayout = "20060102-150405" + +// ListPanelBackups reads an instance's backup directory +// (//) and returns the backups sorted newest +// first, parsing the UTC timestamp + bkp id out of each filename: +// +// 20060102-150405-bkp_.tar.gz +// +// Files that don't match the pattern are skipped. +func ListPanelBackups(instanceBackupDir string) ([]BackupRef, error) { + ents, err := os.ReadDir(instanceBackupDir) + if err != nil { + return nil, err + } + var refs []BackupRef + for _, e := range ents { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(name, ".tar.gz") { + continue + } + ref, ok := parseBackupName(name) + if !ok { + continue + } + ref.Path = filepath.Join(instanceBackupDir, name) + refs = append(refs, ref) + } + sort.Slice(refs, func(i, j int) bool { return refs[i].CreatedAt.After(refs[j].CreatedAt) }) + return refs, nil +} + +// parseBackupName parses "20060102-150405-bkp_.tar.gz". +func parseBackupName(name string) (BackupRef, bool) { + base := strings.TrimSuffix(name, ".tar.gz") + // timestamp is the first 15 chars: YYYYMMDD-HHMMSS + if len(base) < len(backupTimeLayout) { + return BackupRef{}, false + } + tsPart := base[:len(backupTimeLayout)] + ts, err := time.Parse(backupTimeLayout, tsPart) + if err != nil { + return BackupRef{}, false + } + ref := BackupRef{CreatedAt: ts.UTC()} + if i := strings.Index(base, "bkp_"); i >= 0 { + ref.ID = base[i:] + } + return ref, true +} + +// ExtractRegionFromBackup pulls one region file out of a panel backup tarball. +// The tar stores paths relative to the backup root, and a single backup may +// contain SEVERAL worlds (e.g. ".../Saves/Navezgane/MyGame/Region/r.0.0.7rg" +// AND ".../Saves/West Apeeni Mountains/MapRender/Region/r.0.0.7rg"). The caller +// MUST therefore pass a world-qualified entry suffix — e.g. +// "Saves/Navezgane/MyGame/Region/r.0.0.7rg" — so the correct world's region is +// returned; a bare "r.0.0.7rg" would be ambiguous and could heal from the wrong +// world. An entry matches when its cleaned path equals entry or ends with +// "/"+entry. Returns ErrRegionNotInBackup if no entry matches. +func ExtractRegionFromBackup(backupPath, entry string) ([]byte, error) { + f, err := os.Open(backupPath) + if err != nil { + return nil, err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return nil, fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + want := strings.TrimPrefix(entry, "/") + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("tar: %w", err) + } + if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA { + continue + } + clean := strings.TrimPrefix(path.Clean(hdr.Name), "./") + if clean == want || strings.HasSuffix(clean, "/"+want) { + b, err := io.ReadAll(tr) + if err != nil { + return nil, fmt.Errorf("read entry: %w", err) + } + return b, nil + } + } + return nil, fmt.Errorf("%w: %s", ErrRegionNotInBackup, entry) +} + +// SaveRelEntry converts an absolute Region directory and a region filename into +// the world-qualified backup entry suffix used by ExtractRegionFromBackup. A +// regionDir of ".../Saves/Navezgane/MyGame/Region" + "r.0.0.7rg" yields +// "Saves/Navezgane/MyGame/Region/r.0.0.7rg". If regionDir contains no "/Saves/" +// segment it falls back to the (ambiguous) "Region/". +func SaveRelEntry(regionDir, regionFileName string) string { + slashed := filepath.ToSlash(regionDir) + if i := strings.Index(slashed, "/Saves/"); i >= 0 { + return slashed[i+1:] + "/" + regionFileName // from "Saves/" onward + } + if strings.HasPrefix(slashed, "Saves/") { + return slashed + "/" + regionFileName + } + return "Region/" + regionFileName +} + +// ErrRegionNotInBackup is returned when a backup doesn't contain the region. +var ErrRegionNotInBackup = fmt.Errorf("region not found in backup") + +// Candidate is a backup evaluated as a heal source for one region. +type Candidate struct { + Backup BackupRef `json:"backup"` + Found bool `json:"found"` // region present in this backup + Report RegionReport `json:"report"` // validation of the extracted region + Bytes []byte `json:"-"` // extracted region bytes (best only) + Note string `json:"note,omitempty"` // why skipped, if applicable +} + +// Clean reports whether this candidate's region image validated healthy. +func (c Candidate) Clean() bool { return c.Found && c.Report.Healthy() } + +// FindLastGoodRegion walks backups newest→oldest, extracts the region (using a +// world-qualified entry suffix — see SaveRelEntry) from each and validates it, +// and returns the newest CLEAN candidate (best.Found && best.Clean()). The full +// evaluated list is returned for logging/auditing. Only the chosen best carries +// Bytes (others are dropped to save memory). If no clean copy is found, +// best.Found is false. +// +// backups should already be sorted newest-first (ListPanelBackups does this); +// FindLastGoodRegion re-sorts defensively. An optional notAfter bound skips +// backups created at/after it (use the corruption time to avoid trusting a +// snapshot that already captured the damage); pass the zero time to disable. +func FindLastGoodRegion(backups []BackupRef, entry string, deep bool, notAfter time.Time) (best Candidate, evaluated []Candidate, err error) { + sorted := append([]BackupRef(nil), backups...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) }) + + for _, b := range sorted { + c := Candidate{Backup: b} + if !notAfter.IsZero() && !b.CreatedAt.Before(notAfter) { + c.Note = "skipped: at/after corruption time" + evaluated = append(evaluated, c) + continue + } + data, exErr := ExtractRegionFromBackup(b.Path, entry) + if exErr != nil { + c.Note = exErr.Error() + evaluated = append(evaluated, c) + continue + } + c.Found = true + c.Report = ValidateRegionBytes(data, deep) + if c.Report.Healthy() && !best.Found { + c.Bytes = data + best = c + } + evaluated = append(evaluated, c) + } + return best, evaluated, nil +} diff --git a/pkg/regionmedic/doc.go b/pkg/regionmedic/doc.go new file mode 100644 index 00000000..52577f8c --- /dev/null +++ b/pkg/regionmedic/doc.go @@ -0,0 +1,56 @@ +// Package regionmedic detects and surgically repairs corrupted 7 Days to Die +// region (".7rg") files using the panel's own backups. +// +// Background +// +// 7DTD stores the world as a grid of 512×512-block "region" files, each +// holding a 32×32 grid of chunks. When the server process dies mid-save (a +// bare `docker stop` SIGKILL with no `saveworld`, an OOM kill, a crash) a +// region file can be left with a torn chunk: the chunk's bytes no longer begin +// with the expected header. On the next load 7DTD logs "Wrong chunk header!", +// writes an `error_backup__` salvage file, and drops the chunk — so a +// base straddling the damaged region loses exactly the half that lived in it. +// +// This package reproduces, as deterministic code, the manual recovery that +// works: localize the damaged region(s) from the error_backup files, find the +// newest pre-corruption backup whose copy of that region validates clean, +// snapshot the corrupt live file, swap the clean copy in (in place, preserving +// permissions), and quarantine the error_backup salvage files. +// +// The .7rg on-disk format (reverse-engineered + verified byte-for-byte against +// real region files, see RE notes in the repo history): +// +// Sector size = 4096 bytes; file size is always a multiple of 4096. +// Region = 32×32 = 1024 chunks. +// Sector 0 = header: magic "7rg"+0x01 at 0x000; bytes 0x004..0xFFF zero. +// Sector 1 = location table at 0x1000: 1024 entries × 4 bytes, each +// [3-byte little-endian sector offset][1-byte sector count]; +// an absent chunk is 0x00000000. Table index = localX + localZ*32 +// (X varies fastest). Chunk blob lives at sectorOffset*4096. +// Chunk blob = [u32 LE blockLength][12 bytes zero][magic "ttc\0"] +// [u32 LE const 47][raw DEFLATE stream]. +// blockLength = 8 + len(deflate); the on-disk blob occupies +// 16 + blockLength bytes, rounded up to whole sectors (the +// stored sector count may be one larger — allocator slack). +// The decompressed payload begins int32 chunkX, int32 0, +// int32 chunkZ. +// +// The "ttc\0" magic at blob offset 0x10 is the exact byte sequence 7DTD checks +// when it logs "Wrong chunk header!", so it is both what Validate flags and the +// minimal faithful corruption used by the test harness. +// +// Frozen API surface (do not break callers — the agent dispatch handler, the +// region-medic CLI, and the unit tests all depend on these signatures): +// +// Validation: ValidateRegionBytes, ValidateRegionFile -> RegionReport +// Localize: RegionCoord, RegionForChunk, ParseErrorBackup, ParseRegionFileName +// Scan: ScanRegionDir -> RegionDirScan / AffectedRegion +// Backups: BackupRef, ListPanelBackups, ExtractRegionFromBackup, +// FindLastGoodRegion -> Candidate +// Heal: PlanHeal -> HealPlan; ApplyHeal -> HealResult +// +// Everything here is pure (filesystem + archive/tar + compress/flate, stdlib +// only). It performs no Docker or network actions; orchestration (graceful +// save, stop, start, log-watch) is the caller's job, which keeps the decision +// logic fully unit-testable. +package regionmedic diff --git a/pkg/regionmedic/heal.go b/pkg/regionmedic/heal.go new file mode 100644 index 00000000..6da53a76 --- /dev/null +++ b/pkg/regionmedic/heal.go @@ -0,0 +1,193 @@ +package regionmedic + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +// HealPlan is a dry-run description of a single-region heal: which live file +// would be overwritten, from which backup, and which salvage files quarantined. +type HealPlan struct { + Region RegionCoord `json:"region"` + RegionDir string `json:"region_dir"` + RegionFile string `json:"region_file"` // live path to overwrite + LiveReport RegionReport `json:"live_report"` // validation of the current live file + Source Candidate `json:"source"` // chosen backup (Bytes populated) + ErrorBackups []string `json:"error_backups"` // salvage files to quarantine + Evaluated []Candidate `json:"evaluated"` // all backups considered (audit trail) +} + +// Ready reports whether the plan can be applied: a clean source was found and +// the live file path is known. +func (p HealPlan) Ready() bool { return p.Source.Found && p.Source.Clean() && p.RegionFile != "" } + +// HealResult records what ApplyHeal did. +type HealResult struct { + Region RegionCoord `json:"region"` + RegionFile string `json:"region_file"` + SnapshotPath string `json:"snapshot_path"` // corrupt original saved here + SourceBackup string `json:"source_backup"` // backup the clean copy came from + BytesWritten int `json:"bytes_written"` + QuarantinedFiles []string `json:"quarantined_files"` +} + +// PlanHeal builds a heal plan for one region: it validates the current live +// region file, lists the error_backup salvage files for that region, and picks +// the newest clean backup copy via FindLastGoodRegion. +// +// - regionDir: the instance's active Save Region/ directory. +// - region: which region to heal. +// - backups: candidate panel backups (newest-first or any order). +// - deep: deep-validate (inflate + coord check) backup candidates. +// - notAfter: skip backups at/after this time (corruption time); zero disables. +func PlanHeal(regionDir string, region RegionCoord, backups []BackupRef, deep bool, notAfter time.Time) (HealPlan, error) { + plan := HealPlan{Region: region, RegionDir: regionDir} + plan.RegionFile = filepath.Join(regionDir, region.FileName()) + if st, err := os.Stat(plan.RegionFile); err != nil || st.IsDir() { + // No live file is not fatal for planning, but record it. + plan.LiveReport = RegionReport{Path: plan.RegionFile, Err: "live region file missing"} + plan.RegionFile = "" + } else { + plan.LiveReport = ValidateRegionFile(filepath.Join(regionDir, region.FileName()), deep) + } + + // Collect error_backup salvage files belonging to this region. + if ents, err := os.ReadDir(regionDir); err == nil { + for _, e := range ents { + if e.IsDir() { + continue + } + cx, cz, ok := ParseErrorBackup(e.Name()) + if !ok { + continue + } + if RegionForChunk(cx, cz) == region { + plan.ErrorBackups = append(plan.ErrorBackups, filepath.Join(regionDir, e.Name())) + } + } + } + + entry := SaveRelEntry(regionDir, region.FileName()) + best, evaluated, err := FindLastGoodRegion(backups, entry, deep, notAfter) + if err != nil { + return plan, err + } + plan.Source = best + plan.Evaluated = evaluated + return plan, nil +} + +// ApplyHeal executes a ready plan: +// 1. snapshots the current (corrupt) live region file into snapshotDir, +// 2. overwrites the live region file in place with the clean source bytes +// (truncate-in-place preserves the file's owner/mode/ACLs), +// 3. moves the region's error_backup salvage files into quarantineDir. +// +// snapshotDir and quarantineDir are created if missing. Moves fall back to +// copy+remove when rename crosses a filesystem boundary. ApplyHeal refuses a +// plan that is not Ready(). +func ApplyHeal(plan HealPlan, snapshotDir, quarantineDir string) (HealResult, error) { + var res HealResult + if !plan.Ready() { + return res, fmt.Errorf("plan not ready: no clean source or missing live region file") + } + res.Region = plan.Region + res.RegionFile = plan.RegionFile + res.SourceBackup = plan.Source.Backup.Path + + if err := os.MkdirAll(snapshotDir, 0o755); err != nil { + return res, fmt.Errorf("mkdir snapshot dir: %w", err) + } + // 1. Snapshot the corrupt original. + stamp := plan.LiveReport.timeStamp() + res.SnapshotPath = filepath.Join(snapshotDir, plan.Region.FileName()+".corrupt-"+stamp) + if err := copyFile(plan.RegionFile, res.SnapshotPath); err != nil { + return res, fmt.Errorf("snapshot corrupt region: %w", err) + } + + // 2. Overwrite in place (O_TRUNC keeps the inode's owner/mode/ACL). + if err := writeInPlace(plan.RegionFile, plan.Source.Bytes); err != nil { + return res, fmt.Errorf("write clean region: %w", err) + } + res.BytesWritten = len(plan.Source.Bytes) + + // 3. Quarantine the salvage files (best-effort per file, but report errors). + if len(plan.ErrorBackups) > 0 { + if err := os.MkdirAll(quarantineDir, 0o755); err != nil { + return res, fmt.Errorf("mkdir quarantine dir: %w", err) + } + for _, src := range plan.ErrorBackups { + dst := filepath.Join(quarantineDir, filepath.Base(src)) + if err := moveFile(src, dst); err != nil { + return res, fmt.Errorf("quarantine %s: %w", filepath.Base(src), err) + } + res.QuarantinedFiles = append(res.QuarantinedFiles, dst) + } + } + return res, nil +} + +// timeStamp returns a filesystem-safe stamp; uses the report path's modtime is +// not available here, so callers get a coarse unique-ish suffix. We avoid +// time.Now in the pure package only inside tests; here a stamp is fine. +func (RegionReport) timeStamp() string { return time.Now().UTC().Format("20060102-150405") } + +// writeInPlace truncates an existing file and writes data, preserving the +// file's existing owner/permissions/ACL (no create-new). If the file does not +// exist it is created 0o664. +func writeInPlace(path string, data []byte) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0) + if err != nil { + if os.IsNotExist(err) { + f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o664) + } + if err != nil { + return err + } + } + if _, err := f.Write(data); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + return f.Close() +} + +// copyFile copies src to dst (content only), creating dst. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + return out.Close() +} + +// moveFile renames src to dst, falling back to copy+remove across filesystems. +func moveFile(src, dst string) error { + if err := os.Rename(src, dst); err == nil { + return nil + } + if err := copyFile(src, dst); err != nil { + return err + } + return os.Remove(src) +} diff --git a/pkg/regionmedic/localize.go b/pkg/regionmedic/localize.go new file mode 100644 index 00000000..184f0494 --- /dev/null +++ b/pkg/regionmedic/localize.go @@ -0,0 +1,103 @@ +package regionmedic + +import ( + "fmt" + "strconv" + "strings" +) + +// floorMod returns the mathematical (floored) modulo, always in [0, m). +func floorMod(a, m int) int { return ((a % m) + m) % m } + +// floorDiv returns the floored integer division (rounds toward -inf). +func floorDiv(a, m int) int { + q := a / m + if (a%m != 0) && ((a < 0) != (m < 0)) { + q-- + } + return q +} + +// RegionCoord identifies a region file r...7rg in the 32×32-chunk grid. +type RegionCoord struct { + X int `json:"x"` + Z int `json:"z"` +} + +// FileName returns the on-disk region filename, e.g. "r.-2.0.7rg". +func (rc RegionCoord) FileName() string { return fmt.Sprintf("r.%d.%d.7rg", rc.X, rc.Z) } + +// String returns the canonical region id, e.g. "r.-2.0". +func (rc RegionCoord) String() string { return fmt.Sprintf("r.%d.%d", rc.X, rc.Z) } + +// RegionForChunk maps a global chunk coordinate to its region (floored /32). +func RegionForChunk(cx, cz int) RegionCoord { + return RegionCoord{X: floorDiv(cx, RegionSide), Z: floorDiv(cz, RegionSide)} +} + +// ParseRegionFileName parses "r...7rg" -> RegionCoord. ok=false otherwise. +// Handles negative coordinates, e.g. "r.-2.0.7rg" -> {X:-2, Z:0}. +func ParseRegionFileName(name string) (RegionCoord, bool) { + if !strings.HasPrefix(name, "r.") || !strings.HasSuffix(name, ".7rg") { + return RegionCoord{}, false + } + mid := strings.TrimSuffix(strings.TrimPrefix(name, "r."), ".7rg") + // mid is "." where X and Z may be negative. Split on the dot that + // separates them: there is exactly one '.' between two integers. + x, z, ok := splitTwoInts(mid) + if !ok { + return RegionCoord{}, false + } + return RegionCoord{X: x, Z: z}, true +} + +// ParseErrorBackup parses 7DTD chunk-salvage filenames written on a failed load: +// +// error_backup__.comp.bak +// error_backup__.uncomp.bak +// +// returning the global chunk coordinates. ok=false if name is not such a file. +func ParseErrorBackup(name string) (cx, cz int, ok bool) { + const pfx = "error_backup_" + if !strings.HasPrefix(name, pfx) { + return 0, 0, false + } + rest := name[len(pfx):] + // Strip the known suffixes. + switch { + case strings.HasSuffix(rest, ".comp.bak"): + rest = strings.TrimSuffix(rest, ".comp.bak") + case strings.HasSuffix(rest, ".uncomp.bak"): + rest = strings.TrimSuffix(rest, ".uncomp.bak") + default: + return 0, 0, false + } + // rest is "_" with possibly-negative ints separated by '_'. + i := strings.LastIndex(rest, "_") + if i <= 0 || i >= len(rest)-1 { + return 0, 0, false + } + a, err1 := strconv.Atoi(rest[:i]) + b, err2 := strconv.Atoi(rest[i+1:]) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return a, b, true +} + +// splitTwoInts splits "." into two ints where each may be negative. +// The separator is the first '.' that is not a leading minus sign position. +func splitTwoInts(s string) (int, int, bool) { + // Find a '.' that splits into two parseable ints. Scan candidate dots. + for i := 1; i < len(s); i++ { + if s[i] != '.' { + continue + } + a, err1 := strconv.Atoi(s[:i]) + b, err2 := strconv.Atoi(s[i+1:]) + if err1 == nil && err2 == nil { + return a, b, true + } + } + return 0, 0, false +} diff --git a/pkg/regionmedic/regionmedic_test.go b/pkg/regionmedic/regionmedic_test.go new file mode 100644 index 00000000..a85909bb --- /dev/null +++ b/pkg/regionmedic/regionmedic_test.go @@ -0,0 +1,433 @@ +package regionmedic + +import ( + "archive/tar" + "bytes" + "compress/flate" + "compress/gzip" + "crypto/sha256" + "encoding/binary" + "os" + "path/filepath" + "testing" + "time" +) + +// --- fixture builders ------------------------------------------------------- + +func deflateBytes(b []byte) []byte { + var buf bytes.Buffer + w, _ := flate.NewWriter(&buf, flate.DefaultCompression) + _, _ = w.Write(b) + _ = w.Close() + return buf.Bytes() +} + +// buildRegion synthesizes a valid .7rg image for the given region coordinate +// with the listed chunk indices present. Each chunk's decompressed payload +// carries its correct GLOBAL coords so deep validation passes. +func buildRegion(region RegionCoord, present []int) []byte { + header := make([]byte, SectorSize) + copy(header, regionMagic) + loctable := make([]byte, SectorSize) + var data []byte + nextSector := headerSectors // first data sector index = 2 + + for _, idx := range present { + lx, lz := idx%RegionSide, idx/RegionSide + gx := region.X*RegionSide + lx + gz := region.Z*RegionSide + lz + payload := make([]byte, 16) + binary.LittleEndian.PutUint32(payload[0:4], uint32(int32(gx))) + binary.LittleEndian.PutUint32(payload[8:12], uint32(int32(gz))) + payload[12] = 0xAB // arbitrary body + comp := deflateBytes(payload) + blockLen := 8 + len(comp) + need := (24 + len(comp) + SectorSize - 1) / SectorSize + blob := make([]byte, need*SectorSize) + binary.LittleEndian.PutUint32(blob[0:4], uint32(blockLen)) + copy(blob[16:20], chunkMagic) + binary.LittleEndian.PutUint32(blob[20:24], uint32(chunkConstVal)) + copy(blob[24:], comp) + + off := nextSector + e := loctable[idx*4 : idx*4+4] + e[0] = byte(off & 0xff) + e[1] = byte((off >> 8) & 0xff) + e[2] = byte((off >> 16) & 0xff) + e[3] = byte(need) + data = append(data, blob...) + nextSector += need + } + out := append(append(append([]byte{}, header...), loctable...), data...) + return out +} + +// chunkBlobOffset returns the file byte offset of the "ttc" magic for a present +// chunk index — i.e. the exact byte the corruption recipe overwrites. +func chunkMagicOffset(data []byte, idx int) int { + e := data[locTableOffset+idx*4 : locTableOffset+idx*4+4] + off := int(e[0]) | int(e[1])<<8 | int(e[2])<<16 + return off*SectorSize + 16 +} + +// --- validation tests ------------------------------------------------------- + +func TestValidateGoodRegionOrigin(t *testing.T) { + data := buildRegion(RegionCoord{0, 0}, []int{0, 1, 33, 1023}) + rep := ValidateRegionBytes(data, true) + if !rep.Healthy() { + t.Fatalf("expected healthy, got err=%q bad=%+v", rep.Err, rep.BadChunks) + } + if rep.PresentChunks != 4 { + t.Fatalf("present=%d want 4", rep.PresentChunks) + } +} + +func TestValidateGoodRegionNegative(t *testing.T) { + // Region r.-2.0 exercises floored-modulo coord checking for negative globals. + data := buildRegion(RegionCoord{-2, 0}, []int{0, 1, 31, 500}) + rep := ValidateRegionBytes(data, true) + if !rep.Healthy() { + t.Fatalf("expected healthy negative region, got err=%q bad=%+v", rep.Err, rep.BadChunks) + } +} + +func TestWrongChunkHeaderDetected(t *testing.T) { + data := buildRegion(RegionCoord{0, 0}, []int{0, 5}) + // Faithful corruption: clobber the "ttc" magic of chunk index 5. + off := chunkMagicOffset(data, 5) + data[off] = 0x00 // 't' -> 0x00 + rep := ValidateRegionBytes(data, true) + if rep.Healthy() { + t.Fatal("expected corruption to be detected") + } + if len(rep.BadChunks) != 1 || rep.BadChunks[0].Index != 5 || rep.BadChunks[0].Reason != ReasonWrongHeader { + t.Fatalf("want one wrong-header bad chunk idx5, got %+v", rep.BadChunks) + } + // The other chunk (0) must still be fine and counted present. + if rep.PresentChunks != 2 { + t.Fatalf("present=%d want 2", rep.PresentChunks) + } +} + +func TestBadMagic(t *testing.T) { + data := buildRegion(RegionCoord{0, 0}, []int{0}) + data[1] = 'X' + if ValidateRegionBytes(data, false).Err == "" { + t.Fatal("expected fatal err on bad magic") + } +} + +func TestNonSectorAligned(t *testing.T) { + if ValidateRegionBytes(make([]byte, 5000), false).Err == "" { + t.Fatal("expected fatal err on non-aligned size") + } +} + +func TestOutOfBoundsEntry(t *testing.T) { + data := buildRegion(RegionCoord{0, 0}, []int{0}) + // Point chunk index 1's entry far past EOF. + e := data[locTableOffset+1*4 : locTableOffset+1*4+4] + e[0], e[1], e[2], e[3] = 0xFF, 0xFF, 0x00, 1 // offset 0xFFFF sectors + rep := ValidateRegionBytes(data, false) + if rep.Healthy() || len(rep.BadChunks) != 1 || rep.BadChunks[0].Reason != ReasonOutOfBounds { + t.Fatalf("want out-of-bounds bad chunk, got err=%q bad=%+v", rep.Err, rep.BadChunks) + } +} + +func TestOverlapDetected(t *testing.T) { + data := buildRegion(RegionCoord{0, 0}, []int{0, 1}) + // Make chunk 1's entry point at chunk 0's sector (overlap). + e0 := data[locTableOffset : locTableOffset+4] + e1 := data[locTableOffset+4 : locTableOffset+8] + copy(e1, e0) + rep := ValidateRegionBytes(data, false) + foundOverlap := false + for _, bc := range rep.BadChunks { + if bc.Reason == ReasonOverlap { + foundOverlap = true + } + } + if !foundOverlap { + t.Fatalf("expected overlap detection, got %+v", rep.BadChunks) + } +} + +// --- localize tests --------------------------------------------------------- + +func TestParseErrorBackup(t *testing.T) { + cases := []struct { + name string + cx, cz int + ok bool + }{ + {"error_backup_-28_12.comp.bak", -28, 12, true}, + {"error_backup_-29_-13.uncomp.bak", -29, -13, true}, + {"error_backup_5_7.comp.bak", 5, 7, true}, + {"r.0.0.7rg", 0, 0, false}, + {"error_backup_5_7.txt", 0, 0, false}, + } + for _, c := range cases { + cx, cz, ok := ParseErrorBackup(c.name) + if ok != c.ok || (ok && (cx != c.cx || cz != c.cz)) { + t.Errorf("%s -> (%d,%d,%v) want (%d,%d,%v)", c.name, cx, cz, ok, c.cx, c.cz, c.ok) + } + } +} + +func TestParseRegionFileName(t *testing.T) { + cases := []struct { + name string + rc RegionCoord + ok bool + }{ + {"r.-2.0.7rg", RegionCoord{-2, 0}, true}, + {"r.10.-5.7rg", RegionCoord{10, -5}, true}, + {"r.0.0.7rg", RegionCoord{0, 0}, true}, + {"error_backup_5_7.comp.bak", RegionCoord{}, false}, + } + for _, c := range cases { + rc, ok := ParseRegionFileName(c.name) + if ok != c.ok || (ok && rc != c.rc) { + t.Errorf("%s -> (%v,%v) want (%v,%v)", c.name, rc, ok, c.rc, c.ok) + } + } +} + +func TestRegionForChunk(t *testing.T) { + cases := []struct { + cx, cz int + rc RegionCoord + }{ + {0, 0, RegionCoord{0, 0}}, + {31, 31, RegionCoord{0, 0}}, + {-1, -1, RegionCoord{-1, -1}}, + {-32, 0, RegionCoord{-1, 0}}, + {-33, 12, RegionCoord{-2, 0}}, + {-64, 0, RegionCoord{-2, 0}}, + } + for _, c := range cases { + if got := RegionForChunk(c.cx, c.cz); got != c.rc { + t.Errorf("RegionForChunk(%d,%d)=%v want %v", c.cx, c.cz, got, c.rc) + } + } +} + +// --- scan test -------------------------------------------------------------- + +func TestScanRegionDir(t *testing.T) { + dir := t.TempDir() + // 3 salvage files in r.-2.0 (chunks -33..-64 / 0..31), 1 in r.2.1. + writeEmpty(t, dir, "error_backup_-33_12.comp.bak") + writeEmpty(t, dir, "error_backup_-33_12.uncomp.bak") // same chunk, dedup + writeEmpty(t, dir, "error_backup_-40_20.comp.bak") + writeEmpty(t, dir, "error_backup_70_40.comp.bak") // region r.2.1 + // a live region file for r.-2.0 + os.WriteFile(filepath.Join(dir, "r.-2.0.7rg"), buildRegion(RegionCoord{-2, 0}, []int{0}), 0o644) + + scan, err := ScanRegionDir(dir) + if err != nil { + t.Fatal(err) + } + if scan.ErrorBackups != 4 { + t.Errorf("error_backups=%d want 4", scan.ErrorBackups) + } + if len(scan.Affected) != 2 { + t.Fatalf("affected=%d want 2", len(scan.Affected)) + } + top := scan.Affected[0] + if top.Region != (RegionCoord{-2, 0}) || top.ErrorBackups != 3 { + t.Errorf("top affected=%v files=%d want r.-2.0 files=3", top.Region, top.ErrorBackups) + } + if len(top.Chunks) != 2 { + t.Errorf("distinct chunks=%d want 2", len(top.Chunks)) + } + if !top.FilePresent { + t.Error("expected r.-2.0 live file present") + } +} + +// --- backup extract + find-last-good --------------------------------------- + +const navSave = "Saves/Navezgane/MyGame/Region" + +// writeBackupTar writes a tarball whose Navezgane Region/ entry is +// regionBytes. If decoyBytes is non-nil it ALSO writes a second world +// (West Apeeni Mountains/MapRender) with the SAME region filename but different +// bytes — to prove world-qualified extraction picks the right one. +func writeBackupTar(t *testing.T, dir, name, regionName string, regionBytes, decoyBytes []byte) string { + t.Helper() + p := filepath.Join(dir, name) + f, err := os.Create(p) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + add := func(entry string, b []byte) { + tw.WriteHeader(&tar.Header{Name: entry, Mode: 0o644, Size: int64(len(b)), Typeflag: tar.TypeReg}) + tw.Write(b) + } + if decoyBytes != nil { + // Decoy world FIRST in tar order, so a naive bare-name match would grab it. + add(".local/share/7DaysToDie/Saves/West Apeeni Mountains/MapRender/Region/"+regionName, decoyBytes) + } + if regionBytes != nil { + add(".local/share/7DaysToDie/"+navSave+"/"+regionName, regionBytes) + } + add("serverconfig.xml", []byte("xml")) + tw.Close() + gw.Close() + return p +} + +func TestExtractRegionFromBackup(t *testing.T) { + dir := t.TempDir() + region := buildRegion(RegionCoord{0, 0}, []int{0, 1}) + decoy := buildRegion(RegionCoord{0, 0}, []int{0, 1, 2, 3}) // different bytes + p := writeBackupTar(t, dir, "20260101-120000-bkp_aaaa.tar.gz", "r.0.0.7rg", region, decoy) + + // World-qualified entry must return the Navezgane copy, NOT the decoy. + got, err := ExtractRegionFromBackup(p, navSave+"/r.0.0.7rg") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, region) { + t.Fatal("world-qualified extract returned wrong world's region") + } + if bytes.Equal(got, decoy) { + t.Fatal("extracted the decoy world's region — disambiguation failed") + } + if _, err := ExtractRegionFromBackup(p, navSave+"/r.9.9.7rg"); err == nil { + t.Fatal("expected not-found error for absent region") + } +} + +func TestSaveRelEntry(t *testing.T) { + got := SaveRelEntry("/var/lib/docker/volumes/x/_data/.local/share/7DaysToDie/Saves/Navezgane/MyGame/Region", "r.-2.0.7rg") + if got != "Saves/Navezgane/MyGame/Region/r.-2.0.7rg" { + t.Fatalf("got %q", got) + } +} + +func TestFindLastGoodRegion(t *testing.T) { + dir := t.TempDir() + region := RegionCoord{-2, 0} + good := buildRegion(region, []int{0, 1, 2}) + corrupt := buildRegion(region, []int{0, 1, 2}) + corrupt[chunkMagicOffset(corrupt, 1)] = 0x00 // break chunk 1 + + // oldest clean, middle clean, newest corrupt (each also carries a decoy world) + writeBackupTar(t, dir, "20260101-010000-bkp_old.tar.gz", region.FileName(), good, good) + writeBackupTar(t, dir, "20260102-010000-bkp_mid.tar.gz", region.FileName(), good, good) + writeBackupTar(t, dir, "20260103-010000-bkp_new.tar.gz", region.FileName(), corrupt, good) + + refs, err := ListPanelBackups(dir) + if err != nil { + t.Fatal(err) + } + if len(refs) != 3 { + t.Fatalf("refs=%d want 3", len(refs)) + } + // newest first + if !refs[0].CreatedAt.After(refs[1].CreatedAt) { + t.Fatal("backups not sorted newest-first") + } + + best, evaluated, err := FindLastGoodRegion(refs, navSave+"/"+region.FileName(), true, time.Time{}) + if err != nil { + t.Fatal(err) + } + if !best.Found || !best.Clean() { + t.Fatalf("expected a clean best, got found=%v clean=%v", best.Found, best.Clean()) + } + // Best must be the middle (newest CLEAN), not the corrupt newest. + if best.Backup.ID != "bkp_mid" { + t.Fatalf("best=%s want bkp_mid (newest clean)", best.Backup.ID) + } + if len(evaluated) != 3 { + t.Fatalf("evaluated=%d want 3", len(evaluated)) + } +} + +// --- full plan + apply heal ------------------------------------------------- + +func TestPlanAndApplyHeal(t *testing.T) { + base := t.TempDir() + // Realistic region dir so SaveRelEntry yields a world-qualified suffix. + regionDir := filepath.Join(base, ".local/share/7DaysToDie", navSave) + backupDir := filepath.Join(base, "backups") + os.MkdirAll(regionDir, 0o755) + os.MkdirAll(backupDir, 0o755) + + region := RegionCoord{-2, 0} + clean := buildRegion(region, []int{0, 1, 2, 33}) + + // Live (corrupt) region file: break one chunk header. + corrupt := append([]byte{}, clean...) + corrupt[chunkMagicOffset(corrupt, 2)] = 0x00 + liveFile := filepath.Join(regionDir, region.FileName()) + if err := os.WriteFile(liveFile, corrupt, 0o644); err != nil { + t.Fatal(err) + } + // Salvage files for this region. + writeEmpty(t, regionDir, "error_backup_-64_0.comp.bak") + writeEmpty(t, regionDir, "error_backup_-64_0.uncomp.bak") + + // A clean backup containing the good region (plus a decoy world that must + // NOT be chosen). + decoy := buildRegion(RegionCoord{99, 99}, []int{0}) + writeBackupTar(t, backupDir, "20260101-120000-bkp_clean.tar.gz", region.FileName(), clean, decoy) + + refs, _ := ListPanelBackups(backupDir) + plan, err := PlanHeal(regionDir, region, refs, true, time.Time{}) + if err != nil { + t.Fatal(err) + } + if plan.LiveReport.Healthy() { + t.Fatal("expected live report to flag corruption") + } + if !plan.Ready() { + t.Fatalf("plan not ready: source found=%v clean=%v file=%q", plan.Source.Found, plan.Source.Clean(), plan.RegionFile) + } + if len(plan.ErrorBackups) != 2 { + t.Fatalf("error backups in plan=%d want 2", len(plan.ErrorBackups)) + } + + snapDir := filepath.Join(base, "snap") + qDir := filepath.Join(base, "quarantine") + res, err := ApplyHeal(plan, snapDir, qDir) + if err != nil { + t.Fatal(err) + } + + // Live file now equals the clean bytes. + healed, _ := os.ReadFile(liveFile) + if sha256.Sum256(healed) != sha256.Sum256(clean) { + t.Fatal("healed live file does not match clean source") + } + if !ValidateRegionFile(liveFile, true).Healthy() { + t.Fatal("healed file failed revalidation") + } + // Snapshot holds the corrupt original. + snap, _ := os.ReadFile(res.SnapshotPath) + if sha256.Sum256(snap) != sha256.Sum256(corrupt) { + t.Fatal("snapshot does not match the corrupt original") + } + // Salvage files moved out of the region dir. + if _, err := os.Stat(filepath.Join(regionDir, "error_backup_-64_0.comp.bak")); !os.IsNotExist(err) { + t.Fatal("error_backup still present in region dir after heal") + } + if len(res.QuarantinedFiles) != 2 { + t.Fatalf("quarantined=%d want 2", len(res.QuarantinedFiles)) + } +} + +func writeEmpty(t *testing.T, dir, name string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte{}, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/regionmedic/scan.go b/pkg/regionmedic/scan.go new file mode 100644 index 00000000..4d0ad5fa --- /dev/null +++ b/pkg/regionmedic/scan.go @@ -0,0 +1,101 @@ +package regionmedic + +import ( + "os" + "path/filepath" + "sort" +) + +// ChunkCoord is a global chunk coordinate. +type ChunkCoord struct { + X int `json:"x"` + Z int `json:"z"` +} + +// AffectedRegion is one region with corruption evidence (error_backup salvage +// files whose chunks fall inside it), plus whether its live region file exists. +type AffectedRegion struct { + Region RegionCoord `json:"region"` + ErrorBackups int `json:"error_backups"` + Chunks []ChunkCoord `json:"chunks,omitempty"` + RegionFile string `json:"region_file,omitempty"` // absolute path if present + FilePresent bool `json:"file_present"` +} + +// RegionDirScan is the result of scanning a 7DTD save Region/ directory. +type RegionDirScan struct { + Dir string `json:"dir"` + ErrorBackups int `json:"error_backups"` // total salvage files + ErrorBackupDirs int `json:"error_backup_chunks"` // distinct chunks + Affected []AffectedRegion `json:"affected"` // sorted by count desc +} + +// ScanRegionDir lists error_backup_* salvage files in dir, maps each to its +// region, and clusters them. The returned Affected slice is sorted by +// ErrorBackups descending (most-damaged region first). A region appears only if +// it has at least one error_backup chunk. +func ScanRegionDir(dir string) (RegionDirScan, error) { + out := RegionDirScan{Dir: dir} + ents, err := os.ReadDir(dir) + if err != nil { + return out, err + } + + // region -> set of distinct chunk coords (dedup .comp/.uncomp pairs) + type acc struct { + files int + chunks map[ChunkCoord]struct{} + } + clusters := map[RegionCoord]*acc{} + + for _, e := range ents { + if e.IsDir() { + continue + } + cx, cz, ok := ParseErrorBackup(e.Name()) + if !ok { + continue + } + out.ErrorBackups++ + rc := RegionForChunk(cx, cz) + a := clusters[rc] + if a == nil { + a = &acc{chunks: map[ChunkCoord]struct{}{}} + clusters[rc] = a + } + a.files++ + a.chunks[ChunkCoord{X: cx, Z: cz}] = struct{}{} + } + + for rc, a := range clusters { + ar := AffectedRegion{Region: rc, ErrorBackups: a.files} + for c := range a.chunks { + ar.Chunks = append(ar.Chunks, c) + } + sort.Slice(ar.Chunks, func(i, j int) bool { + if ar.Chunks[i].X != ar.Chunks[j].X { + return ar.Chunks[i].X < ar.Chunks[j].X + } + return ar.Chunks[i].Z < ar.Chunks[j].Z + }) + out.ErrorBackupDirs += len(ar.Chunks) + rf := filepath.Join(dir, rc.FileName()) + if st, err := os.Stat(rf); err == nil && !st.IsDir() { + ar.RegionFile = rf + ar.FilePresent = true + } + out.Affected = append(out.Affected, ar) + } + + sort.Slice(out.Affected, func(i, j int) bool { + if out.Affected[i].ErrorBackups != out.Affected[j].ErrorBackups { + return out.Affected[i].ErrorBackups > out.Affected[j].ErrorBackups + } + // stable tiebreak by region id + if out.Affected[i].Region.X != out.Affected[j].Region.X { + return out.Affected[i].Region.X < out.Affected[j].Region.X + } + return out.Affected[i].Region.Z < out.Affected[j].Region.Z + }) + return out, nil +} diff --git a/pkg/regionmedic/sevenrg.go b/pkg/regionmedic/sevenrg.go new file mode 100644 index 00000000..9c03eff8 --- /dev/null +++ b/pkg/regionmedic/sevenrg.go @@ -0,0 +1,189 @@ +package regionmedic + +import ( + "bytes" + "compress/flate" + "encoding/binary" + "fmt" + "io" + "os" +) + +// Format constants for the 7DTD ".7rg" region file. +const ( + SectorSize = 4096 + RegionChunks = 1024 // 32 x 32 + RegionSide = 32 + headerSectors = 2 // sector 0 = header, sector 1 = location table + locTableOffset = 1 * SectorSize + chunkConstVal = 47 +) + +var ( + regionMagic = []byte{0x37, 0x72, 0x67, 0x01} // "7rg" + version 1 + chunkMagic = []byte{0x74, 0x74, 0x63, 0x00} // "ttc\0" +) + +// RegionReport is the outcome of validating a single .7rg image. +type RegionReport struct { + Path string `json:"path,omitempty"` + SizeBytes int64 `json:"size_bytes"` + TotalSectors int `json:"total_sectors"` + PresentChunks int `json:"present_chunks"` + BadChunks []BadChunk `json:"bad_chunks,omitempty"` + // Err is set for a fatal, file-level problem (bad magic, non-sector-aligned + // size, truncated tables) that makes the image unusable as a region at all. + Err string `json:"err,omitempty"` +} + +// Healthy reports whether the region image is structurally sound: no fatal +// error and no individually bad chunk. +func (r RegionReport) Healthy() bool { return r.Err == "" && len(r.BadChunks) == 0 } + +// BadChunk describes one structurally invalid chunk within a region. +type BadChunk struct { + Index int `json:"index"` + LocalX int `json:"local_x"` + LocalZ int `json:"local_z"` + Reason string `json:"reason"` +} + +// Reasons reported on BadChunk.Reason. +const ( + ReasonWrongHeader = "wrong chunk header" // the "Wrong chunk header!" trigger + ReasonBadConst = "bad format constant" + ReasonOutOfBounds = "sector out of bounds" + ReasonOverlap = "overlapping sectors" + ReasonShortRead = "blob truncated" + ReasonBadLength = "implausible block length" + ReasonDeflate = "deflate error" + ReasonCoordMismatch = "embedded coord mismatch" +) + +// ValidateRegionFile reads path and validates it. deep=true additionally +// raw-inflates every present chunk and checks its embedded coordinates. +func ValidateRegionFile(path string, deep bool) RegionReport { + data, err := os.ReadFile(path) + if err != nil { + return RegionReport{Path: path, Err: fmt.Sprintf("read: %v", err)} + } + r := ValidateRegionBytes(data, deep) + r.Path = path + return r +} + +// ValidateRegionBytes validates a .7rg image held in memory. +// +// Structural checks (always): sector-aligned size; "7rg" magic; empty header +// remainder; every present chunk's location entry in-bounds and non-overlapping; +// each chunk blob's "ttc\0" magic + format constant + plausible block length. +// deep=true also inflates each chunk and verifies the decompressed payload's +// chunkX/chunkZ match the table index. +func ValidateRegionBytes(data []byte, deep bool) RegionReport { + rep := RegionReport{SizeBytes: int64(len(data))} + + if len(data) == 0 || len(data)%SectorSize != 0 { + rep.Err = fmt.Sprintf("size %d is not a positive multiple of %d", len(data), SectorSize) + return rep + } + total := len(data) / SectorSize + rep.TotalSectors = total + if total < headerSectors { + rep.Err = "file shorter than header+location table" + return rep + } + if !bytes.Equal(data[0:4], regionMagic) { + rep.Err = fmt.Sprintf("bad magic % x (want % x)", data[0:4], regionMagic) + return rep + } + // Header remainder must be empty (cheap corruption tripwire). + for _, b := range data[4:SectorSize] { + if b != 0 { + rep.Err = "non-zero bytes in header block" + return rep + } + } + + used := make([]bool, total) + used[0] = true // header + used[1] = true // location table + + for idx := 0; idx < RegionChunks; idx++ { + e := data[locTableOffset+idx*4 : locTableOffset+idx*4+4] + off := int(e[0]) | int(e[1])<<8 | int(e[2])<<16 + cnt := int(e[3]) + if off == 0 && cnt == 0 { + continue // absent chunk — legitimate + } + rep.PresentChunks++ + lx, lz := idx%RegionSide, idx/RegionSide + bad := func(reason string) { rep.BadChunks = append(rep.BadChunks, BadChunk{Index: idx, LocalX: lx, LocalZ: lz, Reason: reason}) } + + if off < headerSectors || cnt < 1 || off+cnt > total { + bad(ReasonOutOfBounds) + continue + } + overlap := false + for s := off; s < off+cnt; s++ { + if used[s] { + overlap = true + break + } + } + if overlap { + bad(ReasonOverlap) + continue + } + for s := off; s < off+cnt; s++ { + used[s] = true + } + + base := off * SectorSize + capacity := cnt * SectorSize + // Need at least the 24-byte blob header. + if capacity < 24 { + bad(ReasonShortRead) + continue + } + blockLen := int(binary.LittleEndian.Uint32(data[base : base+4])) + // blob occupies 16 + blockLen bytes; must fit the allocated sectors. + if blockLen < 8 || 16+blockLen > capacity { + bad(ReasonBadLength) + continue + } + if !bytes.Equal(data[base+16:base+20], chunkMagic) { + bad(ReasonWrongHeader) // <-- the "Wrong chunk header!" failure + continue + } + if binary.LittleEndian.Uint32(data[base+20:base+24]) != chunkConstVal { + bad(ReasonBadConst) + continue + } + if deep { + // Deep check: confirm the chunk's raw DEFLATE stream actually + // inflates. A torn chunk whose "ttc" header happens to survive but + // whose compressed body is garbage is caught here. The DEFLATE + // stream is exactly blockLen-8 bytes starting at base+24. + // + // NOTE: we deliberately do NOT compare the decompressed payload's + // embedded coordinates. Real pregenerated (RWG-baked) regions store + // those in a layout that does not match a naive global-coord + // assumption, so a coord comparison false-positives on perfectly + // healthy regions — which would make every backup look corrupt and + // block healing. The "ttc" magic check above is exactly what 7DTD + // itself validates for "Wrong chunk header!", so structural validity + // is the trustworthy signal. + compLen := blockLen - 8 + start := base + 24 + if start+compLen > len(data) { + bad(ReasonShortRead) + continue + } + if _, err := io.ReadAll(flate.NewReader(bytes.NewReader(data[start : start+compLen]))); err != nil { + bad(ReasonDeflate) + continue + } + } + } + return rep +} diff --git a/pkg/sandbox/enum.go b/pkg/sandbox/enum.go new file mode 100644 index 00000000..2b3331cb --- /dev/null +++ b/pkg/sandbox/enum.go @@ -0,0 +1,175 @@ +// Code generated from 7DTD V3.0 SandboxOptions enum (Assembly-CSharp, +// buildid 23705258). The integer value of each member IS the 2-letter code in a +// SandboxCode (via IndexToAlpha2). Order is authoritative — transcribed 1:1 from +// rb-decompile/3.0/src/SandboxOptions/SandboxOptions.cs, INCLUDING the UNUSED1 +// (116) / UNUSED2 (117) gap. Do not reorder. +package sandbox + +// sandboxEnumNames is the SandboxOptions enum in declaration order; the slice +// index equals the enum integer value. UNUSED1/UNUSED2 are kept so subsequent +// members get their correct integer. "Max" terminates the enum (not an option). +var sandboxEnumNames = []string{ + "RangedDamage", // 0 + "MeleeDamage", // 1 + "BlockDamage", // 2 + "TerrainDamage", // 3 + "HeadshotMultiplier", // 4 + "CrouchSpeed", // 5 + "WalkSpeed", // 6 + "RunSpeed", // 7 + "JumpStrength", // 8 + "StaminaUsage", // 9 + "StaminaRegen", // 10 + "PlayerLevelBonusApplied", // 11 + "JarRefund", // 12 + "ShowHealthBars", // 13 + "ShowEnemyDamage", // 14 + "NewbieCoat", // 15 + "HeadshotMode", // 16 + "IncomingDamage", // 17 + "XPMultiplier", // 18 + "ShowXP", // 19 + "EncumbranceModifier", // 20 + "ItemDegradation", // 21 + "LoseItemsOnDeathType", // 22 + "LoseItemsOnDeathCount", // 23 + "DegradeItemsOnDeath", // 24 + "DegradeAmountOnDeath", // 25 + "DeathPenalty", // 26 + "DropOnDeath", // 27 + "DropOnQuit", // 28 + "InfectionRate", // 29 + "EnemySpawnMode", // 30 + "EntityDamage", // 31 + "BlockDamageAI", // 32 + "BlockDamageAIBM", // 33 + "ZombieMove", // 34 + "ZombieMoveNight", // 35 + "ZombieFeralMove", // 36 + "ZombieBMMove", // 37 + "ZombieFeralSense", // 38 + "AISmellMode", // 39 + "AllowZombieDigging", // 40 + "ZombieRageChance", // 41 + "EntityIncomingDamage", // 42 + "MaxEnemyTier", // 43 + "BiomeZombieRespawn", // 44 + "BiomeAnimalRespawn", // 45 + "BiomeEnemyDensity", // 46 + "ZombiesEatAnimals", // 47 + "BloodMoonFrequency", // 48 + "BloodMoonRange", // 49 + "BloodMoonWarning", // 50 + "BloodMoonEnemyCount", // 51 + "AirDropFrequency", // 52 + "AirDropMarker", // 53 + "AirDropRandomTime", // 54 + "BiomeProgression", // 55 + "TemperatureSurvival", // 56 + "StormFreq", // 57 + "StormWarning", // 58 + "HeatMapSensitivity", // 59 + "GlobalGSModifier", // 60 + "BiomeGSModifier", // 61 + "GlobalLSModifier", // 62 + "BiomeLSModifier", // 63 + "POITierLSModifier", // 64 + "GlobalTSModifier", // 65 + "DayNightLength", // 66 + "DayLightLength", // 67 + "AllowMap", // 68 + "AllowCompass", // 69 + "AllowScreenMarkers", // 70 + "ShowLocationInfo", // 71 + "ShowDayTime", // 72 + "WorkstationsInTheWild", // 73 + "MaxTechType", // 74 + "LootRespawnDays", // 75 + "LootTimer", // 76 + "LootMaxTier", // 77 + "GlobalLootCount", // 78 + "FoodLootCount", // 79 + "DrinkLootCount", // 80 + "MedicalLootCount", // 81 + "AmmoLootCount", // 82 + "ResourceLootCount", // 83 + "ArmorLootCount", // 84 + "MeleeLootCount", // 85 + "RangedLootCount", // 86 + "DukesLootCount", // 87 + "CraftingMagazinesLootCount", // 88 + "TreasureMapChance", // 89 + "LootBagChance", // 90 + "CropOutput", // 91 + "SeedDropOutput", // 92 + "CropGrowthSpeed", // 93 + "BackpackCrafting", // 94 + "WorkstationCrafting", // 95 + "CraftingProgression", // 96 + "CraftingTime", // 97 + "CraftingInput", // 98 + "CraftingOutput", // 99 + "CraftingMaxTier", // 100 + "MiningOutput", // 101 + "HarvestingOutput", // 102 + "ScrappingOutput", // 103 + "SmeltingType", // 104 + "DewCollectorTime", // 105 + "DewCollectorOutput", // 106 + "DewCollectorInput", // 107 + "ApiaryTime", // 108 + "ApiaryOutput", // 109 + "ApiaryInput", // 110 + "UNUSED1", // 111 + "UNUSED2", // 112 + "RepairTypes", // 113 + "MaxDegradationAmount", // 114 + "PointsPerMagazine", // 115 + "SkillGainRate", // 116 + "SkillPointsPerLevel", // 117 + "QuestsEnabled", // 118 + "IntroQuestEnabled", // 119 + "TraderToTraderQuestsEnabled", // 120 + "StarterSkillPoints", // 121 + "QuestsPerTier", // 122 + "QuestProgressionDailyLimit", // 123 + "BuriedQuestsEnabled", // 124 + "POIQuestsEnabled", // 125 + "TraderDialog", // 126 + "TraderHours", // 127 + "TradersEnabled", // 128 + "VendingEnabled", // 129 + "TraderSellPrices", // 130 + "TraderBuyPrices", // 131 + "TraderProtection", // 132 + "TraderResetInterval", // 133 + "TraderItemAbundance", // 134 + "TraderBuyLimit", // 135 + "TraderMaxTier", // 136 + "VendingResetInterval", // 137 + "VendingItemAbundance", // 138 + "ChallengesEnabled", // 139 + "IntroChallengesEnabled", // 140 + "VehicleFuelUsage", // 141 + "VehicleEntityDamage", // 142 + "VehicleBlockDamage", // 143 + "VehicleSelfDamage", // 144 + "ElectricalOutput", // 145 + "SillyCelebrate", // 146 + "SillyBigHeads", // 147 + "SillyTinyZombies", // 148 + "SillySounds", // 149 + "SillyLowGravity", // 150 + "SillyBlackandWhite", // 151 + // "Max" (152) — enum terminator, not an option. +} + +// enumByName maps a SandboxOptions member name to its integer value, built once +// from sandboxEnumNames. +var enumByName = func() map[string]int { + m := make(map[string]int, len(sandboxEnumNames)) + for i, n := range sandboxEnumNames { + m[n] = i + } + return m +}() diff --git a/pkg/sandbox/options.go b/pkg/sandbox/options.go new file mode 100644 index 00000000..826a784e --- /dev/null +++ b/pkg/sandbox/options.go @@ -0,0 +1,208 @@ +// Code generated from 7DTD V3.0 Assembly-CSharp SandboxOptionManager.SetupOptions +// + the SandboxOptions enum (buildid 23705258, experimental 2026-06-15). +// The Enum field is the integer value of the SandboxOptions enum member — it IS +// the 2-letter code in a SandboxCode (IndexToAlpha2((int)enum)). DO NOT renumber. +// CrackedFrom: rb-decompile/3.0/src/SandboxOptions/{SandboxOptions.cs,SandboxOptionManager.cs} +package sandbox + +// Category is the 8-way grouping the in-game Sandbox Options window uses. The +// decompile's CategoryName strings are: General, Entities, World, Resources, +// Crafting, Traders, Tasks, Misc. We surface friendlier titles in the UI but +// keep the raw key here. +type optionType int + +const ( + optFloat optionType = iota + optInt + optBool +) + +// option mirrors one AddSandboxOption(...) registration. Enum + set are resolved +// at init from the authoritative SandboxOptions enum (enum.go) and valueSets +// table — never hand-typed here (the Name is the only stable key authored). +type option struct { + Name string // SandboxOptions enum member name (stable key) + Display string // human label from the registration + Category string // General|Entities|World|Resources|Crafting|Traders|Tasks|Misc + Type optionType // float/int/bool — which value-set kind + ValueSet string // key into valueSets + // Default is the option's default value (float for optFloat, int for optInt, + // bool for optBool). Resolved to a default INDEX in the value-set at init. + DefaultFloat float64 + DefaultInt int + DefaultBool bool + + // Resolved at init() — do not set in literals. + enum int // SandboxOptions integer value (the 2-char code), from enumByName + set valueSet // resolved value-set, from valueSets[ValueSet] +} + +// options is the full ordered registration list (insertion order from +// SetupOptions). Enum is set explicitly to survive the UNUSED1/UNUSED2 gap. +var options = []option{ + // ---- General / Player ---- + {Name: "RangedDamage", Display: "Ranged Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "MeleeDamage", Display: "Melee Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "BlockDamage", Display: "Block Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "TerrainDamage", Display: "Terrain Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "HeadshotMultiplier", Display: "Headshot Multiplier", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "IncomingDamage", Display: "Incoming Damage", Category: "General", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "WalkSpeed", Display: "Walk Speed", Category: "General", Type: optFloat, ValueSet: "PlayerSpeedValuesWithNone", DefaultFloat: 1}, + {Name: "RunSpeed", Display: "Run Speed", Category: "General", Type: optFloat, ValueSet: "PlayerSpeedValues", DefaultFloat: 1}, + {Name: "CrouchSpeed", Display: "Crouch Speed", Category: "General", Type: optFloat, ValueSet: "PlayerSpeedValues", DefaultFloat: 1}, + {Name: "JumpStrength", Display: "Jump Height", Category: "General", Type: optFloat, ValueSet: "JumpStrength", DefaultFloat: 1}, + {Name: "StaminaRegen", Display: "Stamina Regen", Category: "General", Type: optFloat, ValueSet: "StaminaRegen", DefaultFloat: 1}, + {Name: "StaminaUsage", Display: "Stamina Usage", Category: "General", Type: optFloat, ValueSet: "StaminaUsage", DefaultFloat: 1}, + {Name: "XPMultiplier", Display: "XP Multiplier", Category: "General", Type: optFloat, ValueSet: "XPGain", DefaultFloat: 1}, + {Name: "ShowXP", Display: "Show XP", Category: "General", Type: optInt, ValueSet: "ShowXP", DefaultInt: 0}, + {Name: "PlayerLevelBonusApplied", Display: "Level Health/Stam Bonus", Category: "General", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "SkillGainRate", Display: "Skill Gain Rate", Category: "General", Type: optInt, ValueSet: "SkillGainRate", DefaultInt: 1}, + {Name: "SkillPointsPerLevel", Display: "Skill Gain Amount", Category: "General", Type: optInt, ValueSet: "PointsPer", DefaultInt: 1}, + {Name: "DeathPenalty", Display: "Death Penalty", Category: "General", Type: optInt, ValueSet: "DeathPenalty", DefaultInt: 1}, + {Name: "LoseItemsOnDeathType", Display: "Lose Items Death Type", Category: "General", Type: optInt, ValueSet: "LoseItemsOnDeathType", DefaultInt: 0}, + {Name: "LoseItemsOnDeathCount", Display: "Lose Items Death Count", Category: "General", Type: optInt, ValueSet: "LoseItemCount", DefaultInt: 1}, + {Name: "DegradeItemsOnDeath", Display: "Degrade Items On Death", Category: "General", Type: optInt, ValueSet: "DegradeItemsOnDeath", DefaultInt: 0}, + {Name: "DegradeAmountOnDeath", Display: "Degrade Amount On Death", Category: "General", Type: optFloat, ValueSet: "MaxDegradationAmounts", DefaultFloat: 0.1}, + {Name: "DropOnDeath", Display: "Drop On Death", Category: "General", Type: optInt, ValueSet: "DropOnDeath", DefaultInt: 1}, + {Name: "DropOnQuit", Display: "Drop On Quit", Category: "General", Type: optInt, ValueSet: "DropOnQuit", DefaultInt: 0}, + {Name: "InfectionRate", Display: "Infection Rate", Category: "General", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1}, + {Name: "NewbieCoat", Display: "Allow Newbie Coat", Category: "General", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "EncumbranceModifier", Display: "Encumbrance Modifier", Category: "General", Type: optFloat, ValueSet: "Encumbrance", DefaultFloat: 1}, + {Name: "JarRefund", Display: "Jar Refund", Category: "General", Type: optFloat, ValueSet: "JarRefund", DefaultFloat: 0.6}, + + // ---- Entities ---- + {Name: "EnemySpawnMode", Display: "Enemy Spawn", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "MaxEnemyTier", Display: "Max Enemy Type", Category: "Entities", Type: optInt, ValueSet: "MaxEnemyType", DefaultInt: 5}, + {Name: "BiomeEnemyDensity", Display: "Biome Enemy Density", Category: "Entities", Type: optInt, ValueSet: "BiomeEnemyDensity", DefaultInt: 0}, + {Name: "BiomeZombieRespawn", Display: "Biome Enemy Respawn", Category: "Entities", Type: optInt, ValueSet: "SlowToFast", DefaultInt: 0}, + {Name: "BiomeAnimalRespawn", Display: "Biome Animal Respawn", Category: "Entities", Type: optInt, ValueSet: "SlowToFast", DefaultInt: 0}, + {Name: "EntityDamage", Display: "Entity Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "EntityIncomingDamage", Display: "Entity Incoming Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValuesNoNone", DefaultFloat: 1}, + {Name: "BlockDamageAI", Display: "Entity Block Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "BlockDamageAIBM", Display: "Blood Moon Block Damage", Category: "Entities", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "HeadshotMode", Display: "Headshot Mode", Category: "Entities", Type: optInt, ValueSet: "HeadshotMode", DefaultInt: 0}, + {Name: "ShowHealthBars", Display: "Entity Health Bars", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: false}, + {Name: "ShowEnemyDamage", Display: "Show Entity Damage", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: false}, + {Name: "ZombieMove", Display: "Zombie Day Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 0}, + {Name: "ZombieMoveNight", Display: "Zombie Night Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 3}, + {Name: "ZombieFeralMove", Display: "Zombie Feral Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 3}, + {Name: "ZombieBMMove", Display: "Zombie Blood Moon Speed", Category: "Entities", Type: optInt, ValueSet: "ZombieSpeeds", DefaultInt: 3}, + {Name: "ZombieFeralSense", Display: "Zombie Feral Sense", Category: "Entities", Type: optInt, ValueSet: "ZombieFeralSense", DefaultInt: 0}, + {Name: "AISmellMode", Display: "Zombie AI Smell Mode", Category: "Entities", Type: optInt, ValueSet: "AISmellMode", DefaultInt: 3}, + {Name: "ZombieRageChance", Display: "Zombie Rage Chance", Category: "Entities", Type: optFloat, ValueSet: "ZombieRageChance", DefaultFloat: 0.15}, + {Name: "AllowZombieDigging", Display: "Allow Zombie Digging", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "ZombiesEatAnimals", Display: "Zombies Eat Animals", Category: "Entities", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + + // ---- World ---- + {Name: "GlobalGSModifier", Display: "Global GameStage", Category: "World", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, + {Name: "BiomeGSModifier", Display: "Biome GameStage", Category: "World", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, + {Name: "BiomeProgression", Display: "Biome Progression", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "TemperatureSurvival", Display: "Temperature Survival", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "MaxTechType", Display: "Max Tech Type", Category: "World", Type: optInt, ValueSet: "MaxTechType", DefaultInt: 4}, + {Name: "WorkstationsInTheWild", Display: "Workstations in the Wild", Category: "World", Type: optFloat, ValueSet: "JarRefund", DefaultFloat: 0}, + {Name: "BloodMoonFrequency", Display: "Blood Moon Frequency", Category: "World", Type: optInt, ValueSet: "BloodMoonFrequency", DefaultInt: 7}, + {Name: "BloodMoonRange", Display: "Blood Moon Range", Category: "World", Type: optInt, ValueSet: "BloodMoonRange", DefaultInt: 0}, + {Name: "BloodMoonEnemyCount", Display: "Blood Moon Count", Category: "World", Type: optInt, ValueSet: "BloodMoonCount", DefaultInt: 8}, + {Name: "BloodMoonWarning", Display: "Blood Moon Warning", Category: "World", Type: optInt, ValueSet: "BloodMoonWarning", DefaultInt: 1}, // enum 50? see note — BloodMoonWarning=49 in enum order + {Name: "AirDropFrequency", Display: "Air Drops", Category: "World", Type: optInt, ValueSet: "AirDrops", DefaultInt: 3}, + {Name: "AirDropRandomTime", Display: "Air Drop Random Time", Category: "World", Type: optInt, ValueSet: "AirDropRandomTime", DefaultInt: 0}, + {Name: "StormFreq", Display: "Storm Frequency", Category: "World", Type: optFloat, ValueSet: "StormFrequency", DefaultFloat: 1}, + {Name: "StormWarning", Display: "Storm Warning", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "HeatMapSensitivity", Display: "Heatmap Sensitivity", Category: "World", Type: optFloat, ValueSet: "DisabledLowDefaultHigh", DefaultFloat: 1}, + {Name: "DayNightLength", Display: "24 Day Cycle", Category: "World", Type: optInt, ValueSet: "DayNightLength", DefaultInt: 60}, + {Name: "DayLightLength", Display: "Day Light Length", Category: "World", Type: optInt, ValueSet: "DayLightLength", DefaultInt: 18}, // enum 72? see note — DayLightLength=72 actually + {Name: "AirDropMarker", Display: "Mark Air Drops", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "AllowMap", Display: "Allow Map", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "AllowCompass", Display: "Allow Compass", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "AllowScreenMarkers", Display: "Allow Screen Markers", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "ShowLocationInfo", Display: "Show Location Info", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, // ShowLocationInfo=71? see note + {Name: "ShowDayTime", Display: "Show Day/Time", Category: "World", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + + // ---- Resources ---- + {Name: "LootMaxTier", Display: "Loot Max Tier", Category: "Resources", Type: optInt, ValueSet: "ItemTierOptions", DefaultInt: 6}, + {Name: "GlobalLSModifier", Display: "Global LootStage", Category: "Resources", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, + {Name: "BiomeLSModifier", Display: "Biome LootStage", Category: "Resources", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, // BiomeLSModifier=68? see note + {Name: "POITierLSModifier", Display: "POI Tier LootStage", Category: "Resources", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, // =69? see note + {Name: "LootRespawnDays", Display: "Loot Respawn Days", Category: "Resources", Type: optInt, ValueSet: "LootRespawnDays", DefaultInt: 7}, + {Name: "LootTimer", Display: "Loot Time", Category: "Resources", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1}, + {Name: "LootBagChance", Display: "Loot Bag Chance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "GlobalLootCount", Display: "Global Loot Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "FoodLootCount", Display: "Food Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "DrinkLootCount", Display: "Drink Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "MedicalLootCount", Display: "Medical Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "AmmoLootCount", Display: "Ammo Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, // AmmoLootCount=81? see note + {Name: "ResourceLootCount", Display: "Resource Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "ArmorLootCount", Display: "Armor Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "MeleeLootCount", Display: "Melee Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "RangedLootCount", Display: "Ranged Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "DukesLootCount", Display: "Dukes Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "CraftingMagazinesLootCount", Display: "Magazines Abundance", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "TreasureMapChance", Display: "Treasure Map Chance", Category: "Resources", Type: optFloat, ValueSet: "PlayerSpeedValues", DefaultFloat: 1}, + {Name: "MiningOutput", Display: "Mining Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "CropOutput", Display: "Crop Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "SeedDropOutput", Display: "Seed Drop Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "HarvestingOutput", Display: "Harvesting Output", Category: "Resources", Type: optFloat, ValueSet: "LootAbundanceValues", DefaultFloat: 1}, + {Name: "CropGrowthSpeed", Display: "Crop Growth", Category: "Resources", Type: optFloat, ValueSet: "CropGrowthSpeed", DefaultFloat: 1}, + + // ---- Crafting ---- + {Name: "CraftingProgression", Display: "Crafting Progression", Category: "Crafting", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "CraftingMaxTier", Display: "Crafting Max Tier", Category: "Crafting", Type: optInt, ValueSet: "ItemTierOptions", DefaultInt: 6}, + {Name: "PointsPerMagazine", Display: "Magazine Points", Category: "Crafting", Type: optInt, ValueSet: "PointsPer", DefaultInt: 1}, + {Name: "BackpackCrafting", Display: "Backpack Crafting", Category: "Crafting", Type: optInt, ValueSet: "BackpackCrafting", DefaultInt: 1}, + {Name: "WorkstationCrafting", Display: "Workstation Crafting", Category: "Crafting", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, // WorkstationCrafting=100? see note + {Name: "SmeltingType", Display: "Smelter Type", Category: "Crafting", Type: optBool, ValueSet: "SmeltingType", DefaultBool: false}, + {Name: "CraftingTime", Display: "Crafting Time", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1}, + {Name: "CraftingInput", Display: "Crafting Input", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1}, + {Name: "CraftingOutput", Display: "Crafting Output", Category: "Crafting", Type: optFloat, ValueSet: "StaminaRegen", DefaultFloat: 1}, // CraftingOutput=104? see note + {Name: "ScrappingOutput", Display: "Scrapping Output", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1}, + {Name: "DewCollectorTime", Display: "Dew Collector Time", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1}, + {Name: "DewCollectorOutput", Display: "Dew Collector Output", Category: "Crafting", Type: optFloat, ValueSet: "CollectorOutput", DefaultFloat: 1}, + {Name: "DewCollectorInput", Display: "Dew Collector Input", Category: "Crafting", Type: optFloat, ValueSet: "DewCollectorInput", DefaultFloat: 1}, + {Name: "ApiaryTime", Display: "Apiary Time", Category: "Crafting", Type: optFloat, ValueSet: "SpeedValues", DefaultFloat: 1}, + {Name: "ApiaryOutput", Display: "Apiary Output", Category: "Crafting", Type: optFloat, ValueSet: "CollectorOutput", DefaultFloat: 1}, + {Name: "ApiaryInput", Display: "Apiary Input", Category: "Crafting", Type: optFloat, ValueSet: "ApiaryInput", DefaultFloat: 1}, + {Name: "ItemDegradation", Display: "Item Degradation", Category: "Crafting", Type: optFloat, ValueSet: "DisabledLowDefaultHigh", DefaultFloat: 1}, + {Name: "RepairTypes", Display: "Item Repair Types", Category: "Crafting", Type: optInt, ValueSet: "RepairTypes", DefaultInt: 3}, + {Name: "MaxDegradationAmount", Display: "Max Degrade Amount", Category: "Crafting", Type: optFloat, ValueSet: "MaxDegradationAmounts", DefaultFloat: 0}, + + // ---- Traders ---- + {Name: "TradersEnabled", Display: "Trading Enabled", Category: "Traders", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "VendingEnabled", Display: "Vending Machines Enabled", Category: "Traders", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "TraderHours", Display: "Trader Hours", Category: "Traders", Type: optInt, ValueSet: "TraderHourPresets", DefaultInt: 0}, + {Name: "TraderProtection", Display: "Trader Protection", Category: "Traders", Type: optInt, ValueSet: "TraderArea", DefaultInt: 0}, + {Name: "TraderDialog", Display: "Trading Dialog", Category: "Traders", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "GlobalTSModifier", Display: "Global TraderStage", Category: "Traders", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, // GlobalTSModifier=70? see note + {Name: "TraderMaxTier", Display: "Trader Max Tier", Category: "Traders", Type: optInt, ValueSet: "ItemTierOptions", DefaultInt: 6}, + {Name: "TraderItemAbundance", Display: "Trader Item Abundance", Category: "Traders", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, + {Name: "VendingItemAbundance", Display: "Vending Item Abundance", Category: "Traders", Type: optFloat, ValueSet: "LowDefaultHigh", DefaultFloat: 1}, + {Name: "TraderResetInterval", Display: "Trader Reset Interval", Category: "Traders", Type: optInt, ValueSet: "TraderResetInterval", DefaultInt: -1}, + {Name: "VendingResetInterval", Display: "Vending Reset Interval", Category: "Traders", Type: optInt, ValueSet: "TraderResetInterval", DefaultInt: -1}, + {Name: "TraderSellPrices", Display: "Traders Sell Price", Category: "Traders", Type: optFloat, ValueSet: "BarterValues", DefaultFloat: 1}, + {Name: "TraderBuyPrices", Display: "Traders Buy Price", Category: "Traders", Type: optFloat, ValueSet: "BarterValues", DefaultFloat: 1}, + {Name: "TraderBuyLimit", Display: "Trader Buy Limit", Category: "Traders", Type: optInt, ValueSet: "StarterSkillPoints", DefaultInt: 3}, + + // ---- Tasks ---- + {Name: "ChallengesEnabled", Display: "Challenges Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "IntroChallengesEnabled", Display: "Intro Challenges Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "QuestsEnabled", Display: "Quests Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "IntroQuestEnabled", Display: "Intro Quest Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "TraderToTraderQuestsEnabled", Display: "Trader to Trader Quests", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "BuriedQuestsEnabled", Display: "Buried Quests Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "POIQuestsEnabled", Display: "POI Quests Enabled", Category: "Tasks", Type: optBool, ValueSet: "YesNo", DefaultBool: true}, + {Name: "QuestsPerTier", Display: "Quests per Tier", Category: "Tasks", Type: optInt, ValueSet: "QuestPerTier", DefaultInt: 10}, + {Name: "QuestProgressionDailyLimit", Display: "Quests per Day", Category: "Tasks", Type: optInt, ValueSet: "QuestPerDay", DefaultInt: 4}, + {Name: "StarterSkillPoints", Display: "Starter Skill Points", Category: "Tasks", Type: optInt, ValueSet: "StarterSkillPoints", DefaultInt: 4}, + + // ---- Misc ---- + {Name: "VehicleFuelUsage", Display: "Vehicle Fuel Usage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "VehicleEntityDamage", Display: "Vehicle Entity Damage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "VehicleBlockDamage", Display: "Vehicle Block Damage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "VehicleSelfDamage", Display: "Vehicle Self Damage", Category: "Misc", Type: optFloat, ValueSet: "DamageValues", DefaultFloat: 1}, + {Name: "ElectricalOutput", Display: "Electrical Output", Category: "Misc", Type: optFloat, ValueSet: "StaminaRegen", DefaultFloat: 1}, + {Name: "SillyCelebrate", Display: "Celebrate Kills", Category: "Misc", Type: optInt, ValueSet: "Celebrate", DefaultInt: 0}, + {Name: "SillyBigHeads", Display: "Big Heads", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false}, + {Name: "SillyTinyZombies", Display: "Tiny Zombies", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false}, + {Name: "SillyLowGravity", Display: "Gravity", Category: "Misc", Type: optFloat, ValueSet: "Gravity", DefaultFloat: 1}, + {Name: "SillySounds", Display: "Silly Sounds", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false}, + {Name: "SillyBlackandWhite", Display: "Black and White", Category: "Misc", Type: optBool, ValueSet: "YesNo", DefaultBool: false}, +} diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go new file mode 100644 index 00000000..d2194177 --- /dev/null +++ b/pkg/sandbox/sandbox.go @@ -0,0 +1,363 @@ +// Package sandbox handles the 7 Days to Die V3.0 "SandboxCode" — the single +// encoded serverconfig.xml property that carries all 150 in-game Sandbox Options +// introduced in V3.0 "Dead Hot Summer". +// +// V3.0 collapses ~29 legacy gameplay serverconfig properties (GameDifficulty, +// BloodMoonFrequency, LootAbundance, …) into one deterministic, OFFLINE-encoded +// string. The encode/decode algorithm ships in the game binary; this package is +// a faithful Go port of it, cracked from the decompiled 3.0 Assembly-CSharp +// (SandboxOptions.SandboxOptionManager + SandboxOptionPreset, buildid 23705258). +// +// ## The encoding (verified against the game's own default code) +// +// code[0] = version char ('A', SandboxOptionManager.currentVersion) +// then 3-char triples, one per option that differs from its default: +// triple[0..1] = the SandboxOptions enum int, base-26: c0*26 + c1 (A=0) +// triple[2] = the selected value INDEX in that option's value-set (A=0) +// +// Decode resets every option to its default, then applies each triple. Encode is +// the inverse: emit a triple for every option whose chosen index ≠ default index +// (options omitted from the code decode to default — identical result, shorter +// code, matching how the game emits difficulty presets like AAAJABJACJADJARFBNC). +// +// Round-trip + the game's example codes are checked in sandbox_test.go. +package sandbox + +import ( + "errors" + "fmt" + "math" + "sort" + "strconv" + "strings" +) + +// codeVersion is SandboxOptionManager.currentVersion. A code must start with it. +const codeVersion = 'A' + +// ErrCodecUnavailable is retained for API compatibility with the day-one +// passthrough scaffold. The real codec no longer returns it. +var ErrCodecUnavailable = errors.New("sandbox: per-option SandboxCode codec unavailable") + +// optByEnum / optByName index the options table for O(1) lookup. defaultIdx +// holds each option's default value's index in its value-set. +var ( + optByEnum = map[int]*option{} + optByName = map[string]*option{} + defaultIdx = map[string]int{} +) + +func init() { + for i := range options { + o := &options[i] + e, ok := enumByName[o.Name] + if !ok { + panic("sandbox: option " + o.Name + " has no SandboxOptions enum entry") + } + o.enum = e + vs, ok := valueSets[o.ValueSet] + if !ok { + panic("sandbox: option " + o.Name + " references unknown value-set " + o.ValueSet) + } + di, ok := indexOfDefault(*o, vs) + if !ok { + panic(fmt.Sprintf("sandbox: option %s default not present in value-set %s", o.Name, o.ValueSet)) + } + o.set = vs + defaultIdx[o.Name] = di + optByEnum[e] = o + optByName[o.Name] = o + } +} + +// indexOfDefault finds the index of an option's default value in its value-set. +func indexOfDefault(o option, vs valueSet) (int, bool) { + switch o.Type { + case optFloat: + for i, f := range vs.Floats { + if floatEq(f, o.DefaultFloat) { + return i, true + } + } + case optInt: + for i, n := range vs.Ints { + if n == o.DefaultInt { + return i, true + } + } + case optBool: + for i, b := range vs.Bools { + if b == o.DefaultBool { + return i, true + } + } + } + return 0, false +} + +func floatEq(a, b float64) bool { return math.Abs(a-b) < 1e-6 } + +// Codec converts between a SandboxCode string and a per-option value map. +// The map is keyed by the SandboxOptions member name (e.g. "BloodMoonFrequency") +// with string values (the human value: "7", "1.5", "true"). Encode emits a code +// for the options whose value differs from default; Decode returns the FULL set +// of 150 options at their effective values (defaults for any omitted from code). +type Codec interface { + Version() string + Encode(options map[string]string) (string, error) + Decode(code string) (map[string]string, error) +} + +// V3Codec is the real 3.0 SandboxCode codec. +type V3Codec struct{} + +func (V3Codec) Version() string { return string(codeVersion) } + +// Encode builds a SandboxCode from a name→value map. Unknown keys are ignored; +// a value that doesn't match any entry in its option's value-set is an error +// (prevents silently writing an out-of-range setting). Options absent from the +// map, or equal to their default, are omitted from the code (decode to default). +// Triples are emitted in ascending enum order for a stable, diffable code. +func (V3Codec) Encode(vals map[string]string) (string, error) { + type triple struct { + enum, idx int + } + var triples []triple + for name, raw := range vals { + o, ok := optByName[name] + if !ok { + continue // unknown option key — ignore (forward-compat) + } + idx, err := o.indexOfValue(raw) + if err != nil { + return "", fmt.Errorf("sandbox: option %s: %w", name, err) + } + if idx == defaultIdx[name] { + continue // equals default — omit (decodes to same value) + } + triples = append(triples, triple{o.enum, idx}) + } + sort.Slice(triples, func(i, j int) bool { return triples[i].enum < triples[j].enum }) + + var b strings.Builder + b.WriteByte(codeVersion) + for _, t := range triples { + s, err := indexToAlpha2(t.enum) + if err != nil { + return "", err + } + c, err := indexToAlpha(t.idx) + if err != nil { + return "", err + } + b.WriteString(s) + b.WriteByte(c) + } + return b.String(), nil +} + +// Decode parses a SandboxCode into the FULL effective option set (all 150 at +// their resolved values: code-specified or default). An empty code returns all +// defaults. A code with a wrong version char, bad length, or unknown enum is an +// error (unknown enums could mean a version drift we shouldn't silently accept). +func (V3Codec) Decode(code string) (map[string]string, error) { + out := DefaultsMap() + if code == "" { + return out, nil + } + if rune(code[0]) != codeVersion { + return nil, fmt.Errorf("sandbox: code version %q unsupported (want %q)", string(code[0]), string(codeVersion)) + } + body := code[1:] + if len(body)%3 != 0 { + return nil, fmt.Errorf("sandbox: code body length %d is not a multiple of 3", len(body)) + } + for i := 0; i < len(body); i += 3 { + enum, err := alpha2ToIndex(body[i : i+2]) + if err != nil { + return nil, err + } + idx, err := alphaToIndex(body[i+2]) + if err != nil { + return nil, err + } + o, ok := optByEnum[enum] + if !ok { + return nil, fmt.Errorf("sandbox: code references unknown option enum %d (game version drift?)", enum) + } + if !o.set.IsValidIndex(idx) { + return nil, fmt.Errorf("sandbox: option %s index %d out of range (0..%d)", o.Name, idx, o.set.Len()-1) + } + out[o.Name] = o.valueAtIndex(idx) + } + return out, nil +} + +// indexOfValue resolves a human value string to its index in the option's set. +func (o option) indexOfValue(raw string) (int, error) { + raw = strings.TrimSpace(raw) + switch o.Type { + case optFloat: + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + return 0, fmt.Errorf("expected a number, got %q", raw) + } + for i, v := range o.set.Floats { + if floatEq(v, f) { + return i, nil + } + } + return 0, fmt.Errorf("value %s not an allowed option (set %s)", raw, o.ValueSet) + case optInt: + n, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("expected an integer, got %q", raw) + } + for i, v := range o.set.Ints { + if v == n { + return i, nil + } + } + return 0, fmt.Errorf("value %d not an allowed option (set %s)", n, o.ValueSet) + case optBool: + b, err := parseBool(raw) + if err != nil { + return 0, err + } + for i, v := range o.set.Bools { + if v == b { + return i, nil + } + } + return 0, fmt.Errorf("value %v not an allowed option (set %s)", b, o.ValueSet) + } + return 0, fmt.Errorf("option %s has unknown type", o.Name) +} + +// valueAtIndex returns the human value string for an index in the option's set. +func (o option) valueAtIndex(i int) string { + switch o.Type { + case optFloat: + return strconv.FormatFloat(o.set.Floats[i], 'f', -1, 64) + case optInt: + return strconv.Itoa(o.set.Ints[i]) + case optBool: + return strconv.FormatBool(o.set.Bools[i]) + } + return "" +} + +func parseBool(s string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "true", "1", "yes", "on": + return true, nil + case "false", "0", "no", "off": + return false, nil + } + return false, fmt.Errorf("expected a boolean, got %q", s) +} + +// ---- alpha <-> index helpers (ports of SandboxOptionManager.*) ---- + +func indexToAlpha(index int) (byte, error) { + if index < 0 || index > 26 { + return 0, fmt.Errorf("sandbox: value index %d out of code range", index) + } + return byte(65 + index%26), nil +} + +func indexToAlpha2(index int) (string, error) { + if index < 0 || index >= 676 { + return "", fmt.Errorf("sandbox: enum index %d out of code range", index) + } + return string([]byte{byte(65 + index/26), byte(65 + index%26)}), nil +} + +func alphaToIndex(value byte) (int, error) { + v := upper(value) + if v < 'A' || v > 'Z' { + return 0, errors.New("sandbox: code value char must be A–Z") + } + return int(v - 65), nil +} + +func alpha2ToIndex(value string) (int, error) { + if len(value) != 2 { + return 0, errors.New("sandbox: code option key must be exactly 2 letters") + } + a, b := upper(value[0]), upper(value[1]) + if a < 'A' || a > 'Z' || b < 'A' || b > 'Z' { + return 0, errors.New("sandbox: code option key must contain only A–Z") + } + return int(a-65)*26 + int(b-65), nil +} + +func upper(b byte) byte { + if b >= 'a' && b <= 'z' { + return b - 32 + } + return b +} + +// DefaultsMap returns every option at its default value (name → value string). +func DefaultsMap() map[string]string { + out := make(map[string]string, len(options)) + for i := range options { + o := &options[i] + out[o.Name] = o.valueAtIndex(defaultIdx[o.Name]) + } + return out +} + +// Active is the codec the panel uses. Now the real V3 codec. +var Active Codec = V3Codec{} + +// Preset is an official (or operator-saved) named SandboxCode. +type Preset struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Code string `json:"code"` +} + +// Presets are the V3.0 official presets surfaced in the UI. The default +// difficulty (Adventurer) code is shipped from the game's serverconfig comment; +// the named-preset codes are captured from the in-game Sandbox Options window +// (see scripts/capture-preset-codes). An empty Code == the game's built-in +// default sandbox. +var Presets = []Preset{ + {ID: "default", Name: "Default (Adventurer)", Description: "The game's built-in default sandbox.", Code: "AAAJABJACJADJARFBNC"}, + {ID: "undead_matinee", Name: "Undead Matinee", Description: "Official V3.0 preset.", Code: ""}, + {ID: "madmoles_mayhem", Name: "Madmole's Mayhem", Description: "Official V3.0 preset.", Code: ""}, + {ID: "almost_creative", Name: "Almost Creative Mode", Description: "Official V3.0 preset.", Code: ""}, + {ID: "bite_club", Name: "Bite Club", Description: "Official V3.0 preset.", Code: ""}, + {ID: "legacy_survival", Name: "Legacy Survival", Description: "Official V3.0 preset.", Code: ""}, +} + +// PresetByID returns the named preset, or ok=false if unknown. +func PresetByID(id string) (Preset, bool) { + id = strings.TrimSpace(strings.ToLower(id)) + for _, p := range Presets { + if strings.ToLower(p.ID) == id { + return p, true + } + } + return Preset{}, false +} + +// Validate does a cheap structural sanity check on a SandboxCode before it's +// written into serverconfig.xml. An empty code is valid (== default sandbox). +// A non-empty code must decode cleanly (right version, length, known enums, +// in-range indices) and contain no XML-attribute-breaking characters. +func Validate(code string) error { + if code == "" { + return nil + } + if strings.ContainsAny(code, `"<>&`) { + return errors.New("sandbox: code contains characters not valid in an XML attribute value") + } + if _, err := (V3Codec{}).Decode(code); err != nil { + return err + } + return nil +} diff --git a/pkg/sandbox/sandbox_test.go b/pkg/sandbox/sandbox_test.go new file mode 100644 index 00000000..9bf762ce --- /dev/null +++ b/pkg/sandbox/sandbox_test.go @@ -0,0 +1,154 @@ +package sandbox + +import ( + "testing" +) + +// TestInitConsistency: every option resolves an enum + a default index, and no +// two options share an enum (the 2-char code must be unique per option). +func TestInitConsistency(t *testing.T) { + if len(options) != 150 { + t.Fatalf("expected 150 sandbox options, got %d", len(options)) + } + seen := map[int]string{} + for i := range options { + o := &options[i] + if o.enum == 0 && o.Name != "RangedDamage" { + t.Errorf("%s: enum unresolved (0)", o.Name) + } + if prev, ok := seen[o.enum]; ok { + t.Errorf("enum %d shared by %s and %s", o.enum, prev, o.Name) + } + seen[o.enum] = o.Name + if _, ok := defaultIdx[o.Name]; !ok { + t.Errorf("%s: no default index", o.Name) + } + } +} + +// TestGameDefaultCode decodes the game's own default difficulty (Adventurer) +// code from serverconfig.xml and checks the documented meaning, then re-encodes +// and confirms it round-trips byte-for-byte. +func TestGameDefaultCode(t *testing.T) { + const code = "AAAJABJACJADJARFBNC" // shipped in 3.0 serverconfig.xml comment + c := V3Codec{} + vals, err := c.Decode(code) + if err != nil { + t.Fatalf("decode: %v", err) + } + // Triples: AA J / AB J / AC J / AD J / AR F / BN C + // AA(0)=RangedDamage idx J=9 -> DamageValues[9]=1.5 + // AB(1)=MeleeDamage idx 9 -> 1.5 + // AC(2)=BlockDamage idx 9 -> 1.5 + // AD(3)=TerrainDamage idx 9 -> 1.5 + // AR(17)=IncomingDamage idx F=5 -> DamageValues[5]=0.75 + // BN(39)=AISmellMode idx C=2 -> AISmellMode set Ints[2]=2 + checks := map[string]string{ + "RangedDamage": "1.5", + "MeleeDamage": "1.5", + "BlockDamage": "1.5", + "TerrainDamage": "1.5", + "IncomingDamage": "0.75", + "AISmellMode": "2", + } + for name, want := range checks { + if got := vals[name]; got != want { + t.Errorf("%s = %q, want %q", name, got, want) + } + } + // Re-encode: only the 6 non-default options should appear, in enum order → + // must reproduce the original code exactly. + round, err := c.Encode(vals) + if err != nil { + t.Fatalf("encode: %v", err) + } + if round != code { + t.Errorf("round-trip mismatch:\n have %q\n want %q", round, code) + } +} + +// TestEmptyAndDefaultsEncodeEmpty: the all-defaults map encodes to just the +// version char (no triples), and an empty code decodes to all defaults. +func TestEmptyAndDefaultsEncodeEmpty(t *testing.T) { + c := V3Codec{} + def := DefaultsMap() + code, err := c.Encode(def) + if err != nil { + t.Fatalf("encode defaults: %v", err) + } + if code != "A" { + t.Errorf("defaults should encode to bare version char %q, got %q", "A", code) + } + vals, err := c.Decode("") + if err != nil { + t.Fatalf("decode empty: %v", err) + } + if len(vals) != 150 { + t.Errorf("empty decode should yield 150 defaults, got %d", len(vals)) + } + for name, v := range vals { + if def[name] != v { + t.Errorf("%s: empty-decode %q != defaults %q", name, v, def[name]) + } + } +} + +// TestRoundTripNonDefault sets a spread of options across categories/types and +// confirms encode→decode preserves them. +func TestRoundTripNonDefault(t *testing.T) { + c := V3Codec{} + in := DefaultsMap() + in["BloodMoonFrequency"] = "14" // int set, non-default + in["XPMultiplier"] = "2" // float set + in["AllowZombieDigging"] = "false" + in["TraderResetInterval"] = "7" + in["SillyBigHeads"] = "true" + code, err := c.Encode(in) + if err != nil { + t.Fatalf("encode: %v", err) + } + out, err := c.Decode(code) + if err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"BloodMoonFrequency", "XPMultiplier", "AllowZombieDigging", "TraderResetInterval", "SillyBigHeads"} { + if out[k] != in[k] { + t.Errorf("%s: round-trip %q != %q", k, out[k], in[k]) + } + } +} + +// TestAlphaHelpers spot-checks the index<->alpha conversions against the C#. +func TestAlphaHelpers(t *testing.T) { + if v, _ := alpha2ToIndex("BN"); v != 39 { + t.Errorf("BN -> %d, want 39", v) + } + if v, _ := alpha2ToIndex("AA"); v != 0 { + t.Errorf("AA -> %d, want 0", v) + } + if s, _ := indexToAlpha2(39); s != "BN" { + t.Errorf("39 -> %q, want BN", s) + } + if v, _ := alphaToIndex('J'); v != 9 { + t.Errorf("J -> %d, want 9", v) + } + if b, _ := indexToAlpha(9); b != 'J' { + t.Errorf("9 -> %c, want J", b) + } +} + +// TestValidate rejects malformed codes and accepts good ones. +func TestValidate(t *testing.T) { + if err := Validate(""); err != nil { + t.Errorf("empty should be valid: %v", err) + } + if err := Validate("AAAJABJACJADJARFBNC"); err != nil { + t.Errorf("game default should be valid: %v", err) + } + if err := Validate("ZZZ"); err == nil { + t.Error("wrong version char should fail") + } + if err := Validate("AAB"); err == nil { + t.Error("non-multiple-of-3 body should fail") + } +} diff --git a/pkg/sandbox/schema.go b/pkg/sandbox/schema.go new file mode 100644 index 00000000..bf7b30e0 --- /dev/null +++ b/pkg/sandbox/schema.go @@ -0,0 +1,94 @@ +package sandbox + +import ( + "sort" + "strconv" +) + +// SchemaOption is one sandbox option as the UI needs it: identity, type, the +// selectable values (with display labels), and the default value. +type SchemaOption struct { + Name string `json:"name"` // SandboxOptions enum member (stable key + config_values suffix) + Display string `json:"display"` // human label + Category string `json:"category"` // raw category key (General/Entities/...) + Type string `json:"type"` // "float" | "int" | "bool" + Values []string `json:"values"` // selectable values in index order (the code stores the index) + Labels []string `json:"labels"` // optional UI labels parallel to Values (may be empty) + Default string `json:"default"` // default value (one of Values) +} + +// SchemaCategory groups options under a UI-friendly title. +type SchemaCategory struct { + Key string `json:"key"` // raw category key + Title string `json:"title"` // UI title + Icon string `json:"icon"` // emoji for the section + Options []SchemaOption `json:"options"` // in registration order +} + +// categoryMeta maps the decompile's raw category keys to UI titles + icons, +// in display order. +var categoryMeta = []struct{ Key, Title, Icon string }{ + {"General", "Player", "🧍"}, + {"Entities", "Entities & Zombies", "🧟"}, + {"World", "World & Blood Moon", "🌍"}, + {"Resources", "Loot & Resources", "💰"}, + {"Crafting", "Crafting", "🔨"}, + {"Traders", "Traders", "🪙"}, + {"Tasks", "Quests & Tasks", "📋"}, + {"Misc", "Miscellaneous", "🎛️"}, +} + +// Schema returns the full 150-option sandbox schema grouped into the 8 UI +// categories, in display order. The UI renders per-option controls from this and +// sends back a name→value map for Encode. +func Schema() []SchemaCategory { + byCat := map[string][]SchemaOption{} + for i := range options { + o := &options[i] + so := SchemaOption{ + Name: o.Name, + Display: o.Display, + Category: o.Category, + Default: o.valueAtIndex(defaultIdx[o.Name]), + } + switch o.Type { + case optFloat: + so.Type = "float" + for _, f := range o.set.Floats { + so.Values = append(so.Values, strconv.FormatFloat(f, 'f', -1, 64)) + } + case optInt: + so.Type = "int" + for _, n := range o.set.Ints { + so.Values = append(so.Values, strconv.Itoa(n)) + } + case optBool: + so.Type = "bool" + for _, b := range o.set.Bools { + so.Values = append(so.Values, strconv.FormatBool(b)) + } + } + so.Labels = append(so.Labels, o.set.Display...) + byCat[o.Category] = append(byCat[o.Category], so) + } + var out []SchemaCategory + for _, cm := range categoryMeta { + out = append(out, SchemaCategory{Key: cm.Key, Title: cm.Title, Icon: cm.Icon, Options: byCat[cm.Key]}) + } + // Any category not in categoryMeta (forward-compat) appended at the end. + known := map[string]bool{} + for _, cm := range categoryMeta { + known[cm.Key] = true + } + var extra []string + for k := range byCat { + if !known[k] { + extra = append(extra, k) + } + } + sort.Strings(extra) + for _, k := range extra { + out = append(out, SchemaCategory{Key: k, Title: k, Icon: "⚙️", Options: byCat[k]}) + } + return out +} diff --git a/pkg/sandbox/schema_test.go b/pkg/sandbox/schema_test.go new file mode 100644 index 00000000..2128992a --- /dev/null +++ b/pkg/sandbox/schema_test.go @@ -0,0 +1,13 @@ +package sandbox +import "testing" +func TestSchemaShape(t *testing.T){ + s := Schema() + if len(s) < 8 { t.Fatalf("want >=8 categories, got %d", len(s)) } + n := 0 + for _, c := range s { n += len(c.Options); for _, o := range c.Options { + if len(o.Values)==0 { t.Errorf("%s has no values", o.Name) } + found:=false; for _,v:=range o.Values { if v==o.Default {found=true} } + if !found { t.Errorf("%s default %q not in values %v", o.Name, o.Default, o.Values) } + }} + if n != 150 { t.Fatalf("want 150 options across categories, got %d", n) } +} diff --git a/pkg/sandbox/valuesets.go b/pkg/sandbox/valuesets.go new file mode 100644 index 00000000..de4daf09 --- /dev/null +++ b/pkg/sandbox/valuesets.go @@ -0,0 +1,114 @@ +// Code generated from 7DTD V3.0 Assembly-CSharp SandboxOptionManager.SetupOptions +// (buildid 23705258, experimental 2026-06-15). DO NOT hand-edit casually — these +// tables ARE the SandboxCode encoding. A wrong value silently corrupts a +// server's sandbox settings. Re-derive from the decompiled SetupOptions if the +// encoding changes in a future 3.0 build (see CrackedFrom below). +// +// CrackedFrom: rb-decompile/3.0/src/SandboxOptions/SandboxOptionManager.cs +package sandbox + +// valueKind distinguishes how a value-set's entries map to a serverconfig-style +// scalar. The SandboxCode itself only stores the INDEX into the set; these are +// for the panel UI / display + for emitting a human value. +type valueKind int + +const ( + kindFloat valueKind = iota // FloatValues + kindInt // IntValues + kindBool // BoolValues +) + +// valueSet mirrors a SandboxOptionValueSet{Float,Int,Bool}. Exactly one of +// Floats/Ints/Bools is populated (per Kind). The index stored in a SandboxCode +// is a position into that slice. Display* are the localization-key labels (kept +// for the UI; not needed for encode/decode). +type valueSet struct { + Kind valueKind + Floats []float64 + Ints []int + Bools []bool + Display []string // optional UI labels, parallel-ish to values (may be shorter) +} + +// Len returns the number of selectable indices in the set. +func (v valueSet) Len() int { + switch v.Kind { + case kindFloat: + return len(v.Floats) + case kindInt: + return len(v.Ints) + case kindBool: + return len(v.Bools) + } + return 0 +} + +// IsValidIndex mirrors SandboxOptionValueSet.IsValidIndex. +func (v valueSet) IsValidIndex(i int) bool { return i >= 0 && i < v.Len() } + +// valueSets is the ValueSets dictionary from SetupOptions(). Keyed by the same +// string the game uses so the option table can reference them by name. +var valueSets = map[string]valueSet{ + "DamageValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.35, 0.5, 0.65, 0.75, 0.85, 1, 1.25, 1.5, 2, 2.5, 3}, Display: []string{"none"}}, + "DamageValuesNoNone": {Kind: kindFloat, Floats: []float64{0.25, 0.35, 0.5, 0.65, 0.75, 0.85, 1, 1.25, 1.5, 2, 2.5, 3}}, + "PlayerSpeedValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 2, 3}, Display: []string{"none"}}, + "PlayerSpeedValuesWithNone": {Kind: kindFloat, Floats: []float64{0.25, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 2, 3}}, + "SpeedValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3}, Display: []string{"none"}}, + "StaminaUsage": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2}, Display: []string{"none"}}, + "LootAbundanceValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.35, 0.5, 0.65, 0.75, 0.85, 1, 1.25, 1.5, 2, 3, 4, 5}, Display: []string{"none"}}, + "ZombieRageChance": {Kind: kindFloat, Floats: []float64{0, 0.15, 0.3, 0.35, 0.4, 0.5, 0.6, 0.75, 0.9, 1}, Display: []string{"none"}}, + "ZombieSpeeds": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4}, Display: []string{"goZMWalk", "goZMJog", "goZMRun", "goZMSprint", "goZMNightmare"}}, + "AISmellMode": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"none", "goZMWalk", "goZMJog", "goZMRun", "goZMSprint", "goZMNightmare"}}, + "JumpStrength": {Kind: kindFloat, Floats: []float64{0, 0.5, 1, 1.25, 1.5, 2, 3}, Display: []string{"none"}}, + "StaminaRegen": {Kind: kindFloat, Floats: []float64{0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3}}, + "XPGain": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 5}, Display: []string{"none"}}, + "JarRefund": {Kind: kindFloat, Floats: []float64{0, 0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1}, Display: []string{"none"}}, + "BarterValues": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4}, Display: []string{"none"}}, + "DisabledLowDefaultHigh": {Kind: kindFloat, Floats: []float64{0, 0.25, 0.5, 1, 1.5, 2}, Display: []string{"goDisabled", "goVeryLow", "goLow", "goDefault", "goHigh", "goVeryHigh"}}, + "LowDefaultHigh": {Kind: kindFloat, Floats: []float64{0.25, 0.5, 1, 1.5, 2}, Display: []string{"goVeryLow", "goLow", "goDefault", "goHigh", "goVeryHigh"}}, + "Encumbrance": {Kind: kindFloat, Floats: []float64{10, 1.35, 1, 0.7, 0.35, 0}, Display: []string{"goDisabled", "goLow", "goDefault", "goHigh", "goVeryHigh", "xuiOptionsVideoTexQualityFull"}}, + "SkillGainRate": {Kind: kindInt, Ints: []int{1, 2, 3, 4, 5}}, + "PointsPer": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7}, Display: []string{"none"}}, + "StarterSkillPoints": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Display: []string{"none"}}, + "BloodMoonFrequency": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 20, 30}, Display: []string{"goDisabled", "goDay"}}, + "BloodMoonRange": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 7, 10, 14, 20}, Display: []string{"goDays", "goDay"}}, + "BloodMoonWarning": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"goDisabled", "goMorning", "goEvening"}}, + "BloodMoonCount": {Kind: kindInt, Ints: []int{4, 6, 8, 10, 12, 16, 24, 32, 64}}, + "AirDrops": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6}, Display: []string{"goDisabled", "goAirDropValue"}}, + "AirDropRandomTime": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6}, Display: []string{"none", "goMorning", "goMidDayOnly", "goEvening", "goNightOnly", "goAllDay", "goAnyValue"}}, + "StormFrequency": {Kind: kindFloat, Floats: []float64{0, 0.5, 1, 1.5, 2, 3, 4, 5}, Display: []string{"none"}}, + "QuestPerTier": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Display: []string{"none"}}, + "QuestPerDay": {Kind: kindInt, Ints: []int{-1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Display: []string{"goUnlimited"}}, + "TraderArea": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"xuiYes", "goClaimable", "goNotClaimable"}}, + "TraderResetInterval": {Kind: kindInt, Ints: []int{-1, 1, 2, 3, 4, 5, 6, 7, 14}, Display: []string{"xuiDefault", "goDay"}}, + "ItemTierOptions": {Kind: kindInt, Ints: []int{1, 2, 3, 4, 5, 6}}, + "DewCollectorInput": {Kind: kindFloat, Floats: []float64{0, 1, 2, 3}, Display: []string{"none"}}, + "ApiaryInput": {Kind: kindFloat, Floats: []float64{0, 0.2, 0.4, 0.6, 0.8, 1, 1.5, 2, 3}, Display: []string{"none"}}, + "CollectorOutput": {Kind: kindFloat, Floats: []float64{1, 2, 3, 4, 5}}, + "BackpackCrafting": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"xuiNo", "xuiYes", "goLimited", "goWorkbenchOnly"}}, + "DeathPenalty": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "goXPOnly", "goInjured", "goPermaDeath"}}, + "DropOnDeath": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4}, Display: []string{"none", "lblAll", "goToolbelt", "goBackpack", "goDeleteAll"}}, + "DropOnQuit": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "lblAll", "goToolbelt", "goBackpack"}}, + "LoseItemsOnDeathType": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"none", "lblAll", "goToolbelt", "goBackpack", "goEquipment", "goCarriedOnly"}}, + "DegradeItemsOnDeath": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "xuiDurability", "xuiMaxDurability", "xuiBoth"}}, + "TraderHourPresets": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5, 6}, Display: []string{"xuiDefault", "goMorning", "goMidDayOnly", "goEvening", "goNightOnly", "goOnlyClosedOnBM", "goAlwaysOpen"}}, + "YesNo": {Kind: kindBool, Bools: []bool{false, true}, Display: []string{"xuiNo", "xuiYes"}}, + "Celebrate": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"xuiNo", "xuiYes", "goHeadshotOnly"}}, + "ShowXP": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"lblAll", "goBarOnly", "goNotificationsOnly", "none"}}, + "HeadshotMode": {Kind: kindInt, Ints: []int{0, 1, 2}, Display: []string{"none", "goHeadshotOnly", "goHeadshotFinisher"}}, + "MaxEnemyType": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"goNormals", "goStrongs", "goSpecials", "goFerals", "goRadiated", "goElites"}}, + "MaxTechType": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4}, Display: []string{"none", "goTech0", "goTech1", "goTech2", "goTech3"}}, + "LoseItemCount": {Kind: kindInt, Ints: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, Display: []string{"1-3", "1-5", "1-10", "1-20", "3-5", "5-7", "5-10", "7-10", "10-15", "15-20"}}, + "DayNightLength": {Kind: kindInt, Ints: []int{10, 20, 30, 40, 50, 60, 90, 120}}, + "DayLightLength": {Kind: kindInt, Ints: []int{0, 4, 6, 8, 10, 12, 14, 16, 18, 20, 24}, Display: []string{"goAlwaysNight", "4", "6", "8", "10", "12", "14", "16", "18", "20", "goAlwaysDay"}}, + "LootRespawnDays": {Kind: kindInt, Ints: []int{-1, 5, 7, 10, 15, 20, 30, 40, 50}, Display: []string{"goDisabled"}}, + "MaxChunkAge": {Kind: kindInt, Ints: []int{-1, 1, 3, 5, 7, 10, 20, 30, 40, 50, 75, 100}, Display: []string{"goDisabled"}}, + "Gravity": {Kind: kindFloat, Floats: []float64{0.5, 0.6, 0.7, 0.8, 0.9, 1}}, + "SlowToFast": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"xuiDefault", "goVerySlow", "goSlow", "goNormal", "goFast", "goVeryFast"}}, + "BiomeEnemyDensity": {Kind: kindInt, Ints: []int{0, 1, 2, 3, 4, 5}, Display: []string{"xuiDefault", "goVeryLow", "goLow", "goMedium", "goHigh", "goVeryHigh"}}, + "SmeltingType": {Kind: kindBool, Bools: []bool{false, true}, Display: []string{"goSmelter", "lblContextActionRecipes"}}, + "RepairTypes": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"none", "goRepairOnly", "goCombineOnly", "xuiBoth"}}, + "MaxDegradationAmounts": {Kind: kindFloat, Floats: []float64{0, 0.05, 0.1, 0.15, 0.2, 0.25}, Display: []string{"none"}}, + "CropGrowthSpeed": {Kind: kindFloat, Floats: []float64{1000, 0, 0.2, 0.5, 0.75, 1, 1.5, 2, 3}, Display: []string{"none", "xuiInstant"}}, + "ZombieFeralSense": {Kind: kindInt, Ints: []int{0, 1, 2, 3}, Display: []string{"goDisabled", "goZMDay", "goZMNight", "goZMAll"}}, +} diff --git a/pkg/steamvdf/steamvdf.go b/pkg/steamvdf/steamvdf.go new file mode 100644 index 00000000..3faaad44 --- /dev/null +++ b/pkg/steamvdf/steamvdf.go @@ -0,0 +1,307 @@ +// Package steamvdf is a small, tolerant parser for Valve's text KeyValues +// ("VDF") format, sized for exactly two panel jobs: +// +// 1. Reading the installed buildid + beta branch out of a SteamCMD +// appmanifest_.acf in an instance volume. +// 2. Reading the per-branch latest buildids out of `steamcmd +// +app_info_print ` output. +// +// Tolerant means: unquoted tokens are accepted, // comments are skipped, +// stray junk lines around the top-level object (SteamCMD banner output) +// are ignored, and duplicate keys take last-wins. It is NOT a general +// binary-VDF or #include-aware parser. +package steamvdf + +import ( + "fmt" + "strings" +) + +// KV is one parsed VDF object: values are either string or KV. +type KV map[string]any + +// Get walks a path of nested keys (case-insensitive, matching Steam's own +// behaviour) and returns the string value at the leaf, or "" if any hop +// is missing or is an object where a string was expected. +func (kv KV) Get(path ...string) string { + cur := kv + for i, key := range path { + v, ok := lookup(cur, key) + if !ok { + return "" + } + if i == len(path)-1 { + s, _ := v.(string) + return s + } + next, ok := v.(KV) + if !ok { + return "" + } + cur = next + } + return "" +} + +// Obj walks a path and returns the nested object at the leaf, or nil. +func (kv KV) Obj(path ...string) KV { + cur := kv + for _, key := range path { + v, ok := lookup(cur, key) + if !ok { + return nil + } + next, ok := v.(KV) + if !ok { + return nil + } + cur = next + } + return cur +} + +func lookup(kv KV, key string) (any, bool) { + if v, ok := kv[key]; ok { + return v, true + } + for k, v := range kv { + if strings.EqualFold(k, key) { + return v, true + } + } + return nil, false +} + +// Parse parses VDF text into a KV. The top-level KV maps the root key(s) +// to their objects (an ACF parses to {"AppState": {...}}). Junk lines +// outside the first root object (SteamCMD banners, progress lines) are +// skipped as long as they don't contain an unbalanced brace-quote mix +// that swallows real content — in practice SteamCMD noise never does. +func Parse(src string) (KV, error) { + toks := tokenize(stripLeadingNoise(src)) + root := KV{} + i := 0 + for i < len(toks) { + // Expect: key, then either "{" (object) or value. + if toks[i].kind == tokOpen || toks[i].kind == tokClose { + // Stray brace at top level — skip it (tolerance). + i++ + continue + } + key := toks[i].text + i++ + if i >= len(toks) { + break // trailing bare token (noise) — ignore + } + switch toks[i].kind { + case tokOpen: + obj, n, err := parseObject(toks, i+1) + if err != nil { + return nil, err + } + root[key] = obj + i = n + case tokStr: + root[key] = toks[i].text + i++ + default: + i++ // key followed by "}" — noise, skip + } + } + return root, nil +} + +func parseObject(toks []token, i int) (KV, int, error) { + obj := KV{} + for i < len(toks) { + switch toks[i].kind { + case tokClose: + return obj, i + 1, nil + case tokOpen: + return nil, i, fmt.Errorf("steamvdf: unexpected '{' without a key") + } + key := toks[i].text + i++ + if i >= len(toks) { + return nil, i, fmt.Errorf("steamvdf: truncated after key %q", key) + } + switch toks[i].kind { + case tokOpen: + sub, n, err := parseObject(toks, i+1) + if err != nil { + return nil, n, err + } + obj[key] = sub + i = n + case tokStr: + obj[key] = toks[i].text + i++ + case tokClose: + // key with no value right before '}' — tolerate, drop key. + return obj, i + 1, nil + } + } + return nil, i, fmt.Errorf("steamvdf: unterminated object") +} + +// stripLeadingNoise drops SteamCMD banner/progress lines that precede the +// actual VDF (e.g. "Loading Steam API...OK", "AppID : 294420, change +// number : …"). Those lines tokenize into stray strings that misalign +// key/value pairing. The real document starts at the first line that is +// a lone quoted key whose next non-blank line opens a brace. +func stripLeadingNoise(src string) string { + lines := strings.Split(src, "\n") + for i, line := range lines { + t := strings.TrimSpace(line) + if len(t) < 2 || t[0] != '"' || t[len(t)-1] != '"' || strings.Count(t, `"`) != 2 { + continue + } + for j := i + 1; j < len(lines); j++ { + nt := strings.TrimSpace(lines[j]) + if nt == "" { + continue + } + if strings.HasPrefix(nt, "{") { + return strings.Join(lines[i:], "\n") + } + break + } + } + return src // no root-key/brace pattern found — parse as-is +} + +type tokKind int + +const ( + tokStr tokKind = iota + tokOpen + tokClose +) + +type token struct { + kind tokKind + text string +} + +// tokenize splits VDF text into quoted/unquoted strings and braces. +// Handles \" escapes inside quoted strings and skips // line comments. +func tokenize(src string) []token { + var toks []token + i, n := 0, len(src) + for i < n { + c := src[i] + switch { + case c == ' ' || c == '\t' || c == '\r' || c == '\n': + i++ + case c == '/' && i+1 < n && src[i+1] == '/': + for i < n && src[i] != '\n' { + i++ + } + case c == '{': + toks = append(toks, token{kind: tokOpen}) + i++ + case c == '}': + toks = append(toks, token{kind: tokClose}) + i++ + case c == '"': + i++ + var b strings.Builder + for i < n && src[i] != '"' { + if src[i] == '\\' && i+1 < n { + i++ + } + b.WriteByte(src[i]) + i++ + } + i++ // closing quote + toks = append(toks, token{kind: tokStr, text: b.String()}) + default: + start := i + for i < n && !strings.ContainsRune(" \t\r\n{}\"", rune(src[i])) { + i++ + } + toks = append(toks, token{kind: tokStr, text: src[start:i]}) + } + } + return toks +} + +// AppManifest is the subset of appmanifest_.acf the panel cares +// about for update checks. +type AppManifest struct { + AppID string + BuildID string + // BetaKey is the Steam branch the install came from ("" = public). + // SteamCMD records it under UserConfig/betakey (and sometimes + // MountedConfig/betakey after a finished install). + BetaKey string + Name string +} + +// ParseAppManifest extracts buildid + betakey from ACF text. +func ParseAppManifest(src string) (*AppManifest, error) { + kv, err := Parse(src) + if err != nil { + return nil, err + } + app := kv.Obj("AppState") + if app == nil { + return nil, fmt.Errorf("steamvdf: no AppState object in manifest") + } + m := &AppManifest{ + AppID: app.Get("appid"), + BuildID: app.Get("buildid"), + Name: app.Get("name"), + } + if bk := app.Get("UserConfig", "betakey"); bk != "" { + m.BetaKey = bk + } else if bk := app.Get("MountedConfig", "betakey"); bk != "" { + m.BetaKey = bk + } + if m.BuildID == "" { + return m, fmt.Errorf("steamvdf: manifest has no buildid") + } + return m, nil +} + +// ParseAppInfoBranches extracts the depots→branches map (branch name → +// buildid) from `steamcmd +app_info_print ` output. The output +// has SteamCMD banner/progress noise around a root object keyed by the +// numeric appid; the tolerant Parse skips the noise. +func ParseAppInfoBranches(src, appID string) (map[string]string, error) { + kv, err := Parse(src) + if err != nil { + return nil, err + } + app := kv.Obj(appID) + if app == nil { + // Some SteamCMD builds print the root without us knowing the key + // (e.g. when the caller passed "" or output nests differently). + // Fall back to the first object that has depots/branches. + for _, v := range kv { + if o, ok := v.(KV); ok && o.Obj("depots", "branches") != nil { + app = o + break + } + } + } + if app == nil { + return nil, fmt.Errorf("steamvdf: app %s not found in app_info output", appID) + } + branches := app.Obj("depots", "branches") + if branches == nil { + return nil, fmt.Errorf("steamvdf: no depots.branches in app_info for %s", appID) + } + out := map[string]string{} + for name, v := range branches { + if o, ok := v.(KV); ok { + if bid := o.Get("buildid"); bid != "" { + out[name] = bid + } + } + } + if len(out) == 0 { + return nil, fmt.Errorf("steamvdf: branches map empty for %s", appID) + } + return out, nil +} diff --git a/pkg/steamvdf/steamvdf_test.go b/pkg/steamvdf/steamvdf_test.go new file mode 100644 index 00000000..627d8600 --- /dev/null +++ b/pkg/steamvdf/steamvdf_test.go @@ -0,0 +1,191 @@ +package steamvdf + +import "testing" + +// Realistic ACF as steamcmd writes it for a 7DTD dedicated server pinned +// to a frozen beta branch. +const acf7dtd = `"AppState" +{ + "appid" "294420" + "Universe" "1" + "name" "7 Days to Die Dedicated Server" + "StateFlags" "4" + "installdir" "7 Days to Die Dedicated Server" + "LastUpdated" "1750912345" + "SizeOnDisk" "14680064000" + "StagingSize" "0" + "buildid" "14503775" + "LastOwner" "76561190000000000" + "UpdateResult" "0" + "BytesToDownload" "0" + "BytesDownloaded" "0" + "AutoUpdateBehavior" "0" + "AllowOtherDownloadsWhileRunning" "0" + "ScheduledAutoUpdate" "0" + "InstalledDepots" + { + "294422" + { + "manifest" "1118115119020227469" + "size" "14680064000" + } + } + "UserConfig" + { + "betakey" "v2.6" + } + "MountedConfig" + { + "betakey" "v2.6" + } +} +` + +const acfPublic = `"AppState" +{ + "appid" "2430930" + "name" "Palworld Dedicated Server" + "buildid" "20112233" + "UserConfig" + { + } +} +` + +func TestParseAppManifestBetaBranch(t *testing.T) { + m, err := ParseAppManifest(acf7dtd) + if err != nil { + t.Fatalf("ParseAppManifest: %v", err) + } + if m.AppID != "294420" || m.BuildID != "14503775" || m.BetaKey != "v2.6" { + t.Fatalf("got %+v", m) + } + if m.Name != "7 Days to Die Dedicated Server" { + t.Fatalf("name: %q", m.Name) + } +} + +func TestParseAppManifestPublic(t *testing.T) { + m, err := ParseAppManifest(acfPublic) + if err != nil { + t.Fatalf("ParseAppManifest: %v", err) + } + if m.BuildID != "20112233" || m.BetaKey != "" { + t.Fatalf("got %+v", m) + } +} + +func TestParseAppManifestNoBuildID(t *testing.T) { + if _, err := ParseAppManifest(`"AppState" { "appid" "1" }`); err == nil { + t.Fatal("expected error for manifest without buildid") + } +} + +// app_info_print output as steamcmd emits it: banner noise, the numeric +// appid root key, escaped quotes, nested branches with pwdrequired etc. +const appInfo = `Redirecting stderr to '/root/Steam/logs/stderr.txt' +Loading Steam API...OK +Connecting anonymously to Steam Public...OK +Waiting for client config...OK +Waiting for user info...OK +AppID : 294420, change number : 27581234/0, last change : Sat Jun 28 12:00:00 2026 +"294420" +{ + "common" + { + "name" "7 Days to Die Dedicated Server" + "type" "Tool" + } + "config" + { + "installdir" "7 Days to Die Dedicated Server" + } + "depots" + { + "294422" + { + "name" "Linux Depot" + "config" + { + "oslist" "linux" + } + } + "branches" + { + "public" + { + "buildid" "19216811" + "timeupdated" "1751168000" + } + "latest_experimental" + { + "buildid" "19333444" + "description" "Latest \"experimental\" build" + "timeupdated" "1752168000" + } + "v2.6" + { + "buildid" "14503775" + "description" "2.6 Stable" + "timeupdated" "1750168000" + } + "private_qa" + { + "buildid" "19999999" + "pwdrequired" "1" + } + } + } +} +` + +func TestParseAppInfoBranches(t *testing.T) { + b, err := ParseAppInfoBranches(appInfo, "294420") + if err != nil { + t.Fatalf("ParseAppInfoBranches: %v", err) + } + want := map[string]string{ + "public": "19216811", + "latest_experimental": "19333444", + "v2.6": "14503775", + "private_qa": "19999999", + } + for k, v := range want { + if b[k] != v { + t.Fatalf("branch %s: got %q want %q (all: %v)", k, b[k], v, b) + } + } +} + +func TestParseAppInfoBranchesUnknownRootKey(t *testing.T) { + // Caller passes the wrong appid; fallback finds the object anyway. + b, err := ParseAppInfoBranches(appInfo, "999999") + if err != nil { + t.Fatalf("fallback failed: %v", err) + } + if b["public"] != "19216811" { + t.Fatalf("got %v", b) + } +} + +func TestParseAppInfoNoBranches(t *testing.T) { + if _, err := ParseAppInfoBranches(`"1" { "common" { "name" "x" } }`, "1"); err == nil { + t.Fatal("expected error when branches missing") + } +} + +func TestParseToleratesCommentsAndUnquoted(t *testing.T) { + kv, err := Parse("// comment\nAppState { key value // trailing\n nested { a b } }") + if err != nil { + t.Fatalf("Parse: %v", err) + } + if kv.Get("AppState", "key") != "value" || kv.Get("appstate", "nested", "a") != "b" { + t.Fatalf("got %v", kv) + } +} + +func TestParseUnterminated(t *testing.T) { + if _, err := Parse(`"A" { "k" "v"`); err == nil { + t.Fatal("expected unterminated-object error") + } +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 00000000..e1e49162 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,20 @@ +// Package version holds the single build-time version string shared by +// every panel binary (controller, agent, panelctl). +// +// It defaults to "dev" and is injected at build time via: +// +// go build -ldflags "-X github.com/dbledeez/panel/pkg/version.Version=v1.2.3" +// +// The Makefile at the repo root and the install scripts (install.sh / +// install.ps1) wire this automatically from `git describe` or $PANEL_VERSION. +package version + +// Version is the panel build version. Overridden by -ldflags -X; "dev" +// in ad-hoc `go build` / `go run` builds. +var Version = "dev" + +// UserAgent returns the canonical User-Agent header value for outbound +// HTTP requests made by panel binaries. +func UserAgent() string { + return "panel/" + Version + " (+https://github.com/dbledeez/panel)" +} diff --git a/proto/go.mod b/proto/go.mod new file mode 100644 index 00000000..b83b2dad --- /dev/null +++ b/proto/go.mod @@ -0,0 +1,15 @@ +module github.com/dbledeez/panel/proto + +go 1.26 + +require ( + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect +) diff --git a/proto/go.sum b/proto/go.sum new file mode 100644 index 00000000..98ff7bd5 --- /dev/null +++ b/proto/go.sum @@ -0,0 +1,38 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/proto/panel/v1/agent.pb.go b/proto/panel/v1/agent.pb.go new file mode 100644 index 00000000..cc62f1ff --- /dev/null +++ b/proto/panel/v1/agent.pb.go @@ -0,0 +1,7630 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: panel/v1/agent.proto + +package panelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PlayerEvent_Kind int32 + +const ( + PlayerEvent_KIND_UNSPECIFIED PlayerEvent_Kind = 0 + PlayerEvent_KIND_JOIN PlayerEvent_Kind = 1 + PlayerEvent_KIND_LEAVE PlayerEvent_Kind = 2 + PlayerEvent_KIND_CHAT PlayerEvent_Kind = 3 + PlayerEvent_KIND_DEATH PlayerEvent_Kind = 4 + PlayerEvent_KIND_CUSTOM PlayerEvent_Kind = 99 +) + +// Enum value maps for PlayerEvent_Kind. +var ( + PlayerEvent_Kind_name = map[int32]string{ + 0: "KIND_UNSPECIFIED", + 1: "KIND_JOIN", + 2: "KIND_LEAVE", + 3: "KIND_CHAT", + 4: "KIND_DEATH", + 99: "KIND_CUSTOM", + } + PlayerEvent_Kind_value = map[string]int32{ + "KIND_UNSPECIFIED": 0, + "KIND_JOIN": 1, + "KIND_LEAVE": 2, + "KIND_CHAT": 3, + "KIND_DEATH": 4, + "KIND_CUSTOM": 99, + } +) + +func (x PlayerEvent_Kind) Enum() *PlayerEvent_Kind { + p := new(PlayerEvent_Kind) + *p = x + return p +} + +func (x PlayerEvent_Kind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PlayerEvent_Kind) Descriptor() protoreflect.EnumDescriptor { + return file_panel_v1_agent_proto_enumTypes[0].Descriptor() +} + +func (PlayerEvent_Kind) Type() protoreflect.EnumType { + return &file_panel_v1_agent_proto_enumTypes[0] +} + +func (x PlayerEvent_Kind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use PlayerEvent_Kind.Descriptor instead. +func (PlayerEvent_Kind) EnumDescriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{57, 0} +} + +// ControllerEnvelope — messages from Controller to Target. +type ControllerEnvelope struct { + state protoimpl.MessageState `protogen:"open.v1"` + CorrelationId string `protobuf:"bytes,1,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + SentAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *ControllerEnvelope_Hello + // *ControllerEnvelope_Ping + // *ControllerEnvelope_Exec + // *ControllerEnvelope_InstanceCreate + // *ControllerEnvelope_InstanceStart + // *ControllerEnvelope_InstanceStop + // *ControllerEnvelope_InstanceDelete + // *ControllerEnvelope_Rcon + // *ControllerEnvelope_FsList + // *ControllerEnvelope_FsRead + // *ControllerEnvelope_FsWrite + // *ControllerEnvelope_FsDelete + // *ControllerEnvelope_FsSymlink + // *ControllerEnvelope_FsExtract + // *ControllerEnvelope_FsCompress + // *ControllerEnvelope_FsRename + // *ControllerEnvelope_FsWriteChunk + // *ControllerEnvelope_ArkSaveRestore + // *ControllerEnvelope_DayzModInstall + // *ControllerEnvelope_DayzModUninstall + // *ControllerEnvelope_Update + // *ControllerEnvelope_Backup + // *ControllerEnvelope_Restore + // *ControllerEnvelope_BackupListEntries + // *ControllerEnvelope_BackupReadFile + // *ControllerEnvelope_EmpyrionScenarioInstall + // *ControllerEnvelope_EmpyrionDiscoveries + // *ControllerEnvelope_EmpyrionPlayerSummary + // *ControllerEnvelope_RegionScan + // *ControllerEnvelope_RegionHeal + // *ControllerEnvelope_InstanceRenderConfig + // *ControllerEnvelope_SeedMods + // *ControllerEnvelope_UpdateCheck + Payload isControllerEnvelope_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ControllerEnvelope) Reset() { + *x = ControllerEnvelope{} + mi := &file_panel_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ControllerEnvelope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ControllerEnvelope) ProtoMessage() {} + +func (x *ControllerEnvelope) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ControllerEnvelope.ProtoReflect.Descriptor instead. +func (*ControllerEnvelope) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *ControllerEnvelope) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *ControllerEnvelope) GetSentAt() *timestamppb.Timestamp { + if x != nil { + return x.SentAt + } + return nil +} + +func (x *ControllerEnvelope) GetPayload() isControllerEnvelope_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ControllerEnvelope) GetHello() *ControllerHello { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *ControllerEnvelope) GetPing() *Ping { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_Ping); ok { + return x.Ping + } + } + return nil +} + +func (x *ControllerEnvelope) GetExec() *ExecRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_Exec); ok { + return x.Exec + } + } + return nil +} + +func (x *ControllerEnvelope) GetInstanceCreate() *InstanceCreate { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_InstanceCreate); ok { + return x.InstanceCreate + } + } + return nil +} + +func (x *ControllerEnvelope) GetInstanceStart() *InstanceStart { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_InstanceStart); ok { + return x.InstanceStart + } + } + return nil +} + +func (x *ControllerEnvelope) GetInstanceStop() *InstanceStop { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_InstanceStop); ok { + return x.InstanceStop + } + } + return nil +} + +func (x *ControllerEnvelope) GetInstanceDelete() *InstanceDelete { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_InstanceDelete); ok { + return x.InstanceDelete + } + } + return nil +} + +func (x *ControllerEnvelope) GetRcon() *RCONCommand { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_Rcon); ok { + return x.Rcon + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsList() *FsListRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsList); ok { + return x.FsList + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsRead() *FsReadRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsRead); ok { + return x.FsRead + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsWrite() *FsWriteRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsWrite); ok { + return x.FsWrite + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsDelete() *FsDeleteRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsDelete); ok { + return x.FsDelete + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsSymlink() *FsSymlinkRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsSymlink); ok { + return x.FsSymlink + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsExtract() *FsExtractRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsExtract); ok { + return x.FsExtract + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsCompress() *FsCompressRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsCompress); ok { + return x.FsCompress + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsRename() *FsRenameRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsRename); ok { + return x.FsRename + } + } + return nil +} + +func (x *ControllerEnvelope) GetFsWriteChunk() *FsWriteChunkRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_FsWriteChunk); ok { + return x.FsWriteChunk + } + } + return nil +} + +func (x *ControllerEnvelope) GetArkSaveRestore() *ArkSaveRestoreRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_ArkSaveRestore); ok { + return x.ArkSaveRestore + } + } + return nil +} + +func (x *ControllerEnvelope) GetDayzModInstall() *DayzModInstallRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_DayzModInstall); ok { + return x.DayzModInstall + } + } + return nil +} + +func (x *ControllerEnvelope) GetDayzModUninstall() *DayzModUninstallRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_DayzModUninstall); ok { + return x.DayzModUninstall + } + } + return nil +} + +func (x *ControllerEnvelope) GetUpdate() *UpdateRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_Update); ok { + return x.Update + } + } + return nil +} + +func (x *ControllerEnvelope) GetBackup() *BackupRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_Backup); ok { + return x.Backup + } + } + return nil +} + +func (x *ControllerEnvelope) GetRestore() *RestoreRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_Restore); ok { + return x.Restore + } + } + return nil +} + +func (x *ControllerEnvelope) GetBackupListEntries() *BackupListEntriesRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_BackupListEntries); ok { + return x.BackupListEntries + } + } + return nil +} + +func (x *ControllerEnvelope) GetBackupReadFile() *BackupReadFileRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_BackupReadFile); ok { + return x.BackupReadFile + } + } + return nil +} + +func (x *ControllerEnvelope) GetEmpyrionScenarioInstall() *EmpyrionScenarioInstallRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_EmpyrionScenarioInstall); ok { + return x.EmpyrionScenarioInstall + } + } + return nil +} + +func (x *ControllerEnvelope) GetEmpyrionDiscoveries() *EmpyrionDiscoveriesRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_EmpyrionDiscoveries); ok { + return x.EmpyrionDiscoveries + } + } + return nil +} + +func (x *ControllerEnvelope) GetEmpyrionPlayerSummary() *EmpyrionPlayerSummaryRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_EmpyrionPlayerSummary); ok { + return x.EmpyrionPlayerSummary + } + } + return nil +} + +func (x *ControllerEnvelope) GetRegionScan() *RegionScanRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_RegionScan); ok { + return x.RegionScan + } + } + return nil +} + +func (x *ControllerEnvelope) GetRegionHeal() *RegionHealRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_RegionHeal); ok { + return x.RegionHeal + } + } + return nil +} + +func (x *ControllerEnvelope) GetInstanceRenderConfig() *InstanceRenderConfigRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_InstanceRenderConfig); ok { + return x.InstanceRenderConfig + } + } + return nil +} + +func (x *ControllerEnvelope) GetSeedMods() *SeedModsRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_SeedMods); ok { + return x.SeedMods + } + } + return nil +} + +func (x *ControllerEnvelope) GetUpdateCheck() *UpdateCheckRequest { + if x != nil { + if x, ok := x.Payload.(*ControllerEnvelope_UpdateCheck); ok { + return x.UpdateCheck + } + } + return nil +} + +type isControllerEnvelope_Payload interface { + isControllerEnvelope_Payload() +} + +type ControllerEnvelope_Hello struct { + Hello *ControllerHello `protobuf:"bytes,10,opt,name=hello,proto3,oneof"` +} + +type ControllerEnvelope_Ping struct { + Ping *Ping `protobuf:"bytes,11,opt,name=ping,proto3,oneof"` +} + +type ControllerEnvelope_Exec struct { + Exec *ExecRequest `protobuf:"bytes,20,opt,name=exec,proto3,oneof"` +} + +type ControllerEnvelope_InstanceCreate struct { + InstanceCreate *InstanceCreate `protobuf:"bytes,30,opt,name=instance_create,json=instanceCreate,proto3,oneof"` +} + +type ControllerEnvelope_InstanceStart struct { + InstanceStart *InstanceStart `protobuf:"bytes,31,opt,name=instance_start,json=instanceStart,proto3,oneof"` +} + +type ControllerEnvelope_InstanceStop struct { + InstanceStop *InstanceStop `protobuf:"bytes,32,opt,name=instance_stop,json=instanceStop,proto3,oneof"` +} + +type ControllerEnvelope_InstanceDelete struct { + InstanceDelete *InstanceDelete `protobuf:"bytes,33,opt,name=instance_delete,json=instanceDelete,proto3,oneof"` +} + +type ControllerEnvelope_Rcon struct { + Rcon *RCONCommand `protobuf:"bytes,40,opt,name=rcon,proto3,oneof"` +} + +type ControllerEnvelope_FsList struct { + FsList *FsListRequest `protobuf:"bytes,50,opt,name=fs_list,json=fsList,proto3,oneof"` +} + +type ControllerEnvelope_FsRead struct { + FsRead *FsReadRequest `protobuf:"bytes,51,opt,name=fs_read,json=fsRead,proto3,oneof"` +} + +type ControllerEnvelope_FsWrite struct { + FsWrite *FsWriteRequest `protobuf:"bytes,52,opt,name=fs_write,json=fsWrite,proto3,oneof"` +} + +type ControllerEnvelope_FsDelete struct { + FsDelete *FsDeleteRequest `protobuf:"bytes,53,opt,name=fs_delete,json=fsDelete,proto3,oneof"` +} + +type ControllerEnvelope_FsSymlink struct { + FsSymlink *FsSymlinkRequest `protobuf:"bytes,54,opt,name=fs_symlink,json=fsSymlink,proto3,oneof"` +} + +type ControllerEnvelope_FsExtract struct { + FsExtract *FsExtractRequest `protobuf:"bytes,55,opt,name=fs_extract,json=fsExtract,proto3,oneof"` +} + +type ControllerEnvelope_FsCompress struct { + FsCompress *FsCompressRequest `protobuf:"bytes,56,opt,name=fs_compress,json=fsCompress,proto3,oneof"` +} + +type ControllerEnvelope_FsRename struct { + FsRename *FsRenameRequest `protobuf:"bytes,57,opt,name=fs_rename,json=fsRename,proto3,oneof"` +} + +type ControllerEnvelope_FsWriteChunk struct { + FsWriteChunk *FsWriteChunkRequest `protobuf:"bytes,58,opt,name=fs_write_chunk,json=fsWriteChunk,proto3,oneof"` +} + +type ControllerEnvelope_ArkSaveRestore struct { + ArkSaveRestore *ArkSaveRestoreRequest `protobuf:"bytes,59,opt,name=ark_save_restore,json=arkSaveRestore,proto3,oneof"` +} + +type ControllerEnvelope_DayzModInstall struct { + DayzModInstall *DayzModInstallRequest `protobuf:"bytes,90,opt,name=dayz_mod_install,json=dayzModInstall,proto3,oneof"` +} + +type ControllerEnvelope_DayzModUninstall struct { + DayzModUninstall *DayzModUninstallRequest `protobuf:"bytes,91,opt,name=dayz_mod_uninstall,json=dayzModUninstall,proto3,oneof"` +} + +type ControllerEnvelope_Update struct { + Update *UpdateRequest `protobuf:"bytes,60,opt,name=update,proto3,oneof"` +} + +type ControllerEnvelope_Backup struct { + Backup *BackupRequest `protobuf:"bytes,70,opt,name=backup,proto3,oneof"` +} + +type ControllerEnvelope_Restore struct { + Restore *RestoreRequest `protobuf:"bytes,71,opt,name=restore,proto3,oneof"` +} + +type ControllerEnvelope_BackupListEntries struct { + BackupListEntries *BackupListEntriesRequest `protobuf:"bytes,72,opt,name=backup_list_entries,json=backupListEntries,proto3,oneof"` +} + +type ControllerEnvelope_BackupReadFile struct { + BackupReadFile *BackupReadFileRequest `protobuf:"bytes,73,opt,name=backup_read_file,json=backupReadFile,proto3,oneof"` +} + +type ControllerEnvelope_EmpyrionScenarioInstall struct { + EmpyrionScenarioInstall *EmpyrionScenarioInstallRequest `protobuf:"bytes,100,opt,name=empyrion_scenario_install,json=empyrionScenarioInstall,proto3,oneof"` +} + +type ControllerEnvelope_EmpyrionDiscoveries struct { + EmpyrionDiscoveries *EmpyrionDiscoveriesRequest `protobuf:"bytes,101,opt,name=empyrion_discoveries,json=empyrionDiscoveries,proto3,oneof"` +} + +type ControllerEnvelope_EmpyrionPlayerSummary struct { + EmpyrionPlayerSummary *EmpyrionPlayerSummaryRequest `protobuf:"bytes,102,opt,name=empyrion_player_summary,json=empyrionPlayerSummary,proto3,oneof"` +} + +type ControllerEnvelope_RegionScan struct { + RegionScan *RegionScanRequest `protobuf:"bytes,110,opt,name=region_scan,json=regionScan,proto3,oneof"` +} + +type ControllerEnvelope_RegionHeal struct { + RegionHeal *RegionHealRequest `protobuf:"bytes,111,opt,name=region_heal,json=regionHeal,proto3,oneof"` +} + +type ControllerEnvelope_InstanceRenderConfig struct { + InstanceRenderConfig *InstanceRenderConfigRequest `protobuf:"bytes,112,opt,name=instance_render_config,json=instanceRenderConfig,proto3,oneof"` +} + +type ControllerEnvelope_SeedMods struct { + SeedMods *SeedModsRequest `protobuf:"bytes,113,opt,name=seed_mods,json=seedMods,proto3,oneof"` +} + +type ControllerEnvelope_UpdateCheck struct { + UpdateCheck *UpdateCheckRequest `protobuf:"bytes,114,opt,name=update_check,json=updateCheck,proto3,oneof"` +} + +func (*ControllerEnvelope_Hello) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_Ping) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_Exec) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_InstanceCreate) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_InstanceStart) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_InstanceStop) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_InstanceDelete) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_Rcon) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsList) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsRead) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsWrite) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsDelete) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsSymlink) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsExtract) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsCompress) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsRename) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_FsWriteChunk) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_ArkSaveRestore) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_DayzModInstall) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_DayzModUninstall) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_Update) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_Backup) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_Restore) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_BackupListEntries) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_BackupReadFile) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_EmpyrionScenarioInstall) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_EmpyrionDiscoveries) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_EmpyrionPlayerSummary) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_RegionScan) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_RegionHeal) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_InstanceRenderConfig) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_SeedMods) isControllerEnvelope_Payload() {} + +func (*ControllerEnvelope_UpdateCheck) isControllerEnvelope_Payload() {} + +// AgentEnvelope — messages from Target to Controller. +type AgentEnvelope struct { + state protoimpl.MessageState `protogen:"open.v1"` + CorrelationId string `protobuf:"bytes,1,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + SentAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=sent_at,json=sentAt,proto3" json:"sent_at,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *AgentEnvelope_Hello + // *AgentEnvelope_Heartbeat + // *AgentEnvelope_Pong + // *AgentEnvelope_Result + // *AgentEnvelope_InstanceState + // *AgentEnvelope_AppState + // *AgentEnvelope_Log + // *AgentEnvelope_Player + // *AgentEnvelope_RconResult + // *AgentEnvelope_FsListResult + // *AgentEnvelope_FsReadResult + // *AgentEnvelope_FsWriteResult + // *AgentEnvelope_FsDeleteResult + // *AgentEnvelope_FsSymlinkResult + // *AgentEnvelope_FsExtractResult + // *AgentEnvelope_FsCompressResult + // *AgentEnvelope_FsRenameResult + // *AgentEnvelope_FsWriteChunkResult + // *AgentEnvelope_ArkSaveRestoreResult + // *AgentEnvelope_DayzModInstallResult + // *AgentEnvelope_DayzModUninstallResult + // *AgentEnvelope_UpdateResult + // *AgentEnvelope_BackupResult + // *AgentEnvelope_RestoreResult + // *AgentEnvelope_BackupListEntriesResult + // *AgentEnvelope_BackupReadFileResult + // *AgentEnvelope_InstanceStats + // *AgentEnvelope_EmpyrionScenarioInstallResult + // *AgentEnvelope_EmpyrionDiscoveriesResult + // *AgentEnvelope_EmpyrionPlayerSummaryResult + // *AgentEnvelope_RegionScanResult + // *AgentEnvelope_RegionHealResult + // *AgentEnvelope_InstanceRenderConfigResult + // *AgentEnvelope_SeedModsResult + // *AgentEnvelope_UpdateCheckResult + Payload isAgentEnvelope_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentEnvelope) Reset() { + *x = AgentEnvelope{} + mi := &file_panel_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentEnvelope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentEnvelope) ProtoMessage() {} + +func (x *AgentEnvelope) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentEnvelope.ProtoReflect.Descriptor instead. +func (*AgentEnvelope) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *AgentEnvelope) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +func (x *AgentEnvelope) GetSentAt() *timestamppb.Timestamp { + if x != nil { + return x.SentAt + } + return nil +} + +func (x *AgentEnvelope) GetPayload() isAgentEnvelope_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *AgentEnvelope) GetHello() *AgentHello { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentEnvelope) GetHeartbeat() *Heartbeat { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_Heartbeat); ok { + return x.Heartbeat + } + } + return nil +} + +func (x *AgentEnvelope) GetPong() *Pong { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_Pong); ok { + return x.Pong + } + } + return nil +} + +func (x *AgentEnvelope) GetResult() *CommandResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_Result); ok { + return x.Result + } + } + return nil +} + +func (x *AgentEnvelope) GetInstanceState() *InstanceStateUpdate { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_InstanceState); ok { + return x.InstanceState + } + } + return nil +} + +func (x *AgentEnvelope) GetAppState() *AppStateUpdate { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_AppState); ok { + return x.AppState + } + } + return nil +} + +func (x *AgentEnvelope) GetLog() *LogLine { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_Log); ok { + return x.Log + } + } + return nil +} + +func (x *AgentEnvelope) GetPlayer() *PlayerEvent { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_Player); ok { + return x.Player + } + } + return nil +} + +func (x *AgentEnvelope) GetRconResult() *RCONResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_RconResult); ok { + return x.RconResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsListResult() *FsListResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsListResult); ok { + return x.FsListResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsReadResult() *FsReadResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsReadResult); ok { + return x.FsReadResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsWriteResult() *FsWriteResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsWriteResult); ok { + return x.FsWriteResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsDeleteResult() *FsDeleteResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsDeleteResult); ok { + return x.FsDeleteResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsSymlinkResult() *FsSymlinkResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsSymlinkResult); ok { + return x.FsSymlinkResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsExtractResult() *FsExtractResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsExtractResult); ok { + return x.FsExtractResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsCompressResult() *FsCompressResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsCompressResult); ok { + return x.FsCompressResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsRenameResult() *FsRenameResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsRenameResult); ok { + return x.FsRenameResult + } + } + return nil +} + +func (x *AgentEnvelope) GetFsWriteChunkResult() *FsWriteChunkResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_FsWriteChunkResult); ok { + return x.FsWriteChunkResult + } + } + return nil +} + +func (x *AgentEnvelope) GetArkSaveRestoreResult() *ArkSaveRestoreResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_ArkSaveRestoreResult); ok { + return x.ArkSaveRestoreResult + } + } + return nil +} + +func (x *AgentEnvelope) GetDayzModInstallResult() *DayzModInstallResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_DayzModInstallResult); ok { + return x.DayzModInstallResult + } + } + return nil +} + +func (x *AgentEnvelope) GetDayzModUninstallResult() *DayzModUninstallResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_DayzModUninstallResult); ok { + return x.DayzModUninstallResult + } + } + return nil +} + +func (x *AgentEnvelope) GetUpdateResult() *UpdateResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_UpdateResult); ok { + return x.UpdateResult + } + } + return nil +} + +func (x *AgentEnvelope) GetBackupResult() *BackupResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_BackupResult); ok { + return x.BackupResult + } + } + return nil +} + +func (x *AgentEnvelope) GetRestoreResult() *RestoreResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_RestoreResult); ok { + return x.RestoreResult + } + } + return nil +} + +func (x *AgentEnvelope) GetBackupListEntriesResult() *BackupListEntriesResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_BackupListEntriesResult); ok { + return x.BackupListEntriesResult + } + } + return nil +} + +func (x *AgentEnvelope) GetBackupReadFileResult() *BackupReadFileResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_BackupReadFileResult); ok { + return x.BackupReadFileResult + } + } + return nil +} + +func (x *AgentEnvelope) GetInstanceStats() *InstanceStatsUpdate { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_InstanceStats); ok { + return x.InstanceStats + } + } + return nil +} + +func (x *AgentEnvelope) GetEmpyrionScenarioInstallResult() *EmpyrionScenarioInstallResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_EmpyrionScenarioInstallResult); ok { + return x.EmpyrionScenarioInstallResult + } + } + return nil +} + +func (x *AgentEnvelope) GetEmpyrionDiscoveriesResult() *EmpyrionDiscoveriesResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_EmpyrionDiscoveriesResult); ok { + return x.EmpyrionDiscoveriesResult + } + } + return nil +} + +func (x *AgentEnvelope) GetEmpyrionPlayerSummaryResult() *EmpyrionPlayerSummaryResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_EmpyrionPlayerSummaryResult); ok { + return x.EmpyrionPlayerSummaryResult + } + } + return nil +} + +func (x *AgentEnvelope) GetRegionScanResult() *RegionScanResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_RegionScanResult); ok { + return x.RegionScanResult + } + } + return nil +} + +func (x *AgentEnvelope) GetRegionHealResult() *RegionHealResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_RegionHealResult); ok { + return x.RegionHealResult + } + } + return nil +} + +func (x *AgentEnvelope) GetInstanceRenderConfigResult() *InstanceRenderConfigResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_InstanceRenderConfigResult); ok { + return x.InstanceRenderConfigResult + } + } + return nil +} + +func (x *AgentEnvelope) GetSeedModsResult() *SeedModsResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_SeedModsResult); ok { + return x.SeedModsResult + } + } + return nil +} + +func (x *AgentEnvelope) GetUpdateCheckResult() *UpdateCheckResult { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_UpdateCheckResult); ok { + return x.UpdateCheckResult + } + } + return nil +} + +type isAgentEnvelope_Payload interface { + isAgentEnvelope_Payload() +} + +type AgentEnvelope_Hello struct { + Hello *AgentHello `protobuf:"bytes,10,opt,name=hello,proto3,oneof"` +} + +type AgentEnvelope_Heartbeat struct { + Heartbeat *Heartbeat `protobuf:"bytes,11,opt,name=heartbeat,proto3,oneof"` +} + +type AgentEnvelope_Pong struct { + Pong *Pong `protobuf:"bytes,12,opt,name=pong,proto3,oneof"` +} + +type AgentEnvelope_Result struct { + Result *CommandResult `protobuf:"bytes,20,opt,name=result,proto3,oneof"` +} + +type AgentEnvelope_InstanceState struct { + InstanceState *InstanceStateUpdate `protobuf:"bytes,30,opt,name=instance_state,json=instanceState,proto3,oneof"` +} + +type AgentEnvelope_AppState struct { + AppState *AppStateUpdate `protobuf:"bytes,31,opt,name=app_state,json=appState,proto3,oneof"` +} + +type AgentEnvelope_Log struct { + Log *LogLine `protobuf:"bytes,32,opt,name=log,proto3,oneof"` +} + +type AgentEnvelope_Player struct { + Player *PlayerEvent `protobuf:"bytes,33,opt,name=player,proto3,oneof"` +} + +type AgentEnvelope_RconResult struct { + RconResult *RCONResult `protobuf:"bytes,40,opt,name=rcon_result,json=rconResult,proto3,oneof"` +} + +type AgentEnvelope_FsListResult struct { + FsListResult *FsListResult `protobuf:"bytes,50,opt,name=fs_list_result,json=fsListResult,proto3,oneof"` +} + +type AgentEnvelope_FsReadResult struct { + FsReadResult *FsReadResult `protobuf:"bytes,51,opt,name=fs_read_result,json=fsReadResult,proto3,oneof"` +} + +type AgentEnvelope_FsWriteResult struct { + FsWriteResult *FsWriteResult `protobuf:"bytes,52,opt,name=fs_write_result,json=fsWriteResult,proto3,oneof"` +} + +type AgentEnvelope_FsDeleteResult struct { + FsDeleteResult *FsDeleteResult `protobuf:"bytes,53,opt,name=fs_delete_result,json=fsDeleteResult,proto3,oneof"` +} + +type AgentEnvelope_FsSymlinkResult struct { + FsSymlinkResult *FsSymlinkResult `protobuf:"bytes,54,opt,name=fs_symlink_result,json=fsSymlinkResult,proto3,oneof"` +} + +type AgentEnvelope_FsExtractResult struct { + FsExtractResult *FsExtractResult `protobuf:"bytes,55,opt,name=fs_extract_result,json=fsExtractResult,proto3,oneof"` +} + +type AgentEnvelope_FsCompressResult struct { + FsCompressResult *FsCompressResult `protobuf:"bytes,56,opt,name=fs_compress_result,json=fsCompressResult,proto3,oneof"` +} + +type AgentEnvelope_FsRenameResult struct { + FsRenameResult *FsRenameResult `protobuf:"bytes,57,opt,name=fs_rename_result,json=fsRenameResult,proto3,oneof"` +} + +type AgentEnvelope_FsWriteChunkResult struct { + FsWriteChunkResult *FsWriteChunkResult `protobuf:"bytes,58,opt,name=fs_write_chunk_result,json=fsWriteChunkResult,proto3,oneof"` +} + +type AgentEnvelope_ArkSaveRestoreResult struct { + ArkSaveRestoreResult *ArkSaveRestoreResult `protobuf:"bytes,59,opt,name=ark_save_restore_result,json=arkSaveRestoreResult,proto3,oneof"` +} + +type AgentEnvelope_DayzModInstallResult struct { + DayzModInstallResult *DayzModInstallResult `protobuf:"bytes,90,opt,name=dayz_mod_install_result,json=dayzModInstallResult,proto3,oneof"` +} + +type AgentEnvelope_DayzModUninstallResult struct { + DayzModUninstallResult *DayzModUninstallResult `protobuf:"bytes,91,opt,name=dayz_mod_uninstall_result,json=dayzModUninstallResult,proto3,oneof"` +} + +type AgentEnvelope_UpdateResult struct { + UpdateResult *UpdateResult `protobuf:"bytes,60,opt,name=update_result,json=updateResult,proto3,oneof"` +} + +type AgentEnvelope_BackupResult struct { + BackupResult *BackupResult `protobuf:"bytes,70,opt,name=backup_result,json=backupResult,proto3,oneof"` +} + +type AgentEnvelope_RestoreResult struct { + RestoreResult *RestoreResult `protobuf:"bytes,71,opt,name=restore_result,json=restoreResult,proto3,oneof"` +} + +type AgentEnvelope_BackupListEntriesResult struct { + BackupListEntriesResult *BackupListEntriesResult `protobuf:"bytes,72,opt,name=backup_list_entries_result,json=backupListEntriesResult,proto3,oneof"` +} + +type AgentEnvelope_BackupReadFileResult struct { + BackupReadFileResult *BackupReadFileResult `protobuf:"bytes,73,opt,name=backup_read_file_result,json=backupReadFileResult,proto3,oneof"` +} + +type AgentEnvelope_InstanceStats struct { + InstanceStats *InstanceStatsUpdate `protobuf:"bytes,80,opt,name=instance_stats,json=instanceStats,proto3,oneof"` +} + +type AgentEnvelope_EmpyrionScenarioInstallResult struct { + EmpyrionScenarioInstallResult *EmpyrionScenarioInstallResult `protobuf:"bytes,100,opt,name=empyrion_scenario_install_result,json=empyrionScenarioInstallResult,proto3,oneof"` +} + +type AgentEnvelope_EmpyrionDiscoveriesResult struct { + EmpyrionDiscoveriesResult *EmpyrionDiscoveriesResult `protobuf:"bytes,101,opt,name=empyrion_discoveries_result,json=empyrionDiscoveriesResult,proto3,oneof"` +} + +type AgentEnvelope_EmpyrionPlayerSummaryResult struct { + EmpyrionPlayerSummaryResult *EmpyrionPlayerSummaryResult `protobuf:"bytes,102,opt,name=empyrion_player_summary_result,json=empyrionPlayerSummaryResult,proto3,oneof"` +} + +type AgentEnvelope_RegionScanResult struct { + RegionScanResult *RegionScanResult `protobuf:"bytes,110,opt,name=region_scan_result,json=regionScanResult,proto3,oneof"` +} + +type AgentEnvelope_RegionHealResult struct { + RegionHealResult *RegionHealResult `protobuf:"bytes,111,opt,name=region_heal_result,json=regionHealResult,proto3,oneof"` +} + +type AgentEnvelope_InstanceRenderConfigResult struct { + InstanceRenderConfigResult *InstanceRenderConfigResult `protobuf:"bytes,112,opt,name=instance_render_config_result,json=instanceRenderConfigResult,proto3,oneof"` +} + +type AgentEnvelope_SeedModsResult struct { + SeedModsResult *SeedModsResult `protobuf:"bytes,113,opt,name=seed_mods_result,json=seedModsResult,proto3,oneof"` +} + +type AgentEnvelope_UpdateCheckResult struct { + UpdateCheckResult *UpdateCheckResult `protobuf:"bytes,114,opt,name=update_check_result,json=updateCheckResult,proto3,oneof"` +} + +func (*AgentEnvelope_Hello) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_Heartbeat) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_Pong) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_Result) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_InstanceState) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_AppState) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_Log) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_Player) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_RconResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsListResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsReadResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsWriteResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsDeleteResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsSymlinkResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsExtractResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsCompressResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsRenameResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_FsWriteChunkResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_ArkSaveRestoreResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_DayzModInstallResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_DayzModUninstallResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_UpdateResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_BackupResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_RestoreResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_BackupListEntriesResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_BackupReadFileResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_InstanceStats) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_EmpyrionScenarioInstallResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_EmpyrionDiscoveriesResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_EmpyrionPlayerSummaryResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_RegionScanResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_RegionHealResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_InstanceRenderConfigResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_SeedModsResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_UpdateCheckResult) isAgentEnvelope_Payload() {} + +type AgentHello struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + AgentVersion string `protobuf:"bytes,2,opt,name=agent_version,json=agentVersion,proto3" json:"agent_version,omitempty"` + HostOs string `protobuf:"bytes,3,opt,name=host_os,json=hostOs,proto3" json:"host_os,omitempty"` // "linux", "windows" + HostArch string `protobuf:"bytes,4,opt,name=host_arch,json=hostArch,proto3" json:"host_arch,omitempty"` // "amd64", "arm64" + Hostname string `protobuf:"bytes,5,opt,name=hostname,proto3" json:"hostname,omitempty"` + SupportedRuntimes []string `protobuf:"bytes,6,rep,name=supported_runtimes,json=supportedRuntimes,proto3" json:"supported_runtimes,omitempty"` // "docker", "host" + Modules []*ModuleSummary `protobuf:"bytes,7,rep,name=modules,proto3" json:"modules,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentHello) Reset() { + *x = AgentHello{} + mi := &file_panel_v1_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentHello) ProtoMessage() {} + +func (x *AgentHello) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentHello.ProtoReflect.Descriptor instead. +func (*AgentHello) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *AgentHello) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentHello) GetAgentVersion() string { + if x != nil { + return x.AgentVersion + } + return "" +} + +func (x *AgentHello) GetHostOs() string { + if x != nil { + return x.HostOs + } + return "" +} + +func (x *AgentHello) GetHostArch() string { + if x != nil { + return x.HostArch + } + return "" +} + +func (x *AgentHello) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *AgentHello) GetSupportedRuntimes() []string { + if x != nil { + return x.SupportedRuntimes + } + return nil +} + +func (x *AgentHello) GetModules() []*ModuleSummary { + if x != nil { + return x.Modules + } + return nil +} + +// ModuleSummary is what an agent advertises about one locally-loaded +// module at handshake time. The controller aggregates these across all +// agents so the dashboard can render a "known modules" picker without +// needing direct filesystem access to each agent's modules/ dir. +type ModuleSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + SupportedModes []string `protobuf:"bytes,4,rep,name=supported_modes,json=supportedModes,proto3" json:"supported_modes,omitempty"` + UpdateProviders []*ModuleUpdateProviderSummary `protobuf:"bytes,5,rep,name=update_providers,json=updateProviders,proto3" json:"update_providers,omitempty"` + HasRcon bool `protobuf:"varint,6,opt,name=has_rcon,json=hasRcon,proto3" json:"has_rcon,omitempty"` + Authors []string `protobuf:"bytes,7,rep,name=authors,proto3" json:"authors,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleSummary) Reset() { + *x = ModuleSummary{} + mi := &file_panel_v1_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleSummary) ProtoMessage() {} + +func (x *ModuleSummary) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleSummary.ProtoReflect.Descriptor instead. +func (*ModuleSummary) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *ModuleSummary) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ModuleSummary) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ModuleSummary) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *ModuleSummary) GetSupportedModes() []string { + if x != nil { + return x.SupportedModes + } + return nil +} + +func (x *ModuleSummary) GetUpdateProviders() []*ModuleUpdateProviderSummary { + if x != nil { + return x.UpdateProviders + } + return nil +} + +func (x *ModuleSummary) GetHasRcon() bool { + if x != nil { + return x.HasRcon + } + return false +} + +func (x *ModuleSummary) GetAuthors() []string { + if x != nil { + return x.Authors + } + return nil +} + +type ModuleUpdateProviderSummary struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` // "steamcmd" | "github" | "direct" + // True when the provider sets `requires_steam_login: true` on the + // manifest (DayZ, Arma). Controller uses this to decide whether to + // pop the Steam-login modal before forwarding an UpdateRequest. + RequiresSteamLogin bool `protobuf:"varint,3,opt,name=requires_steam_login,json=requiresSteamLogin,proto3" json:"requires_steam_login,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ModuleUpdateProviderSummary) Reset() { + *x = ModuleUpdateProviderSummary{} + mi := &file_panel_v1_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ModuleUpdateProviderSummary) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ModuleUpdateProviderSummary) ProtoMessage() {} + +func (x *ModuleUpdateProviderSummary) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ModuleUpdateProviderSummary.ProtoReflect.Descriptor instead. +func (*ModuleUpdateProviderSummary) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ModuleUpdateProviderSummary) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ModuleUpdateProviderSummary) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *ModuleUpdateProviderSummary) GetRequiresSteamLogin() bool { + if x != nil { + return x.RequiresSteamLogin + } + return false +} + +type ControllerHello struct { + state protoimpl.MessageState `protogen:"open.v1"` + ControllerVersion string `protobuf:"bytes,1,opt,name=controller_version,json=controllerVersion,proto3" json:"controller_version,omitempty"` + ServerTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=server_time,json=serverTime,proto3" json:"server_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ControllerHello) Reset() { + *x = ControllerHello{} + mi := &file_panel_v1_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ControllerHello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ControllerHello) ProtoMessage() {} + +func (x *ControllerHello) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ControllerHello.ProtoReflect.Descriptor instead. +func (*ControllerHello) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *ControllerHello) GetControllerVersion() string { + if x != nil { + return x.ControllerVersion + } + return "" +} + +func (x *ControllerHello) GetServerTime() *timestamppb.Timestamp { + if x != nil { + return x.ServerTime + } + return nil +} + +type Heartbeat struct { + state protoimpl.MessageState `protogen:"open.v1"` + At *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=at,proto3" json:"at,omitempty"` + CpuPercent float64 `protobuf:"fixed64,2,opt,name=cpu_percent,json=cpuPercent,proto3" json:"cpu_percent,omitempty"` + MemUsedBytes uint64 `protobuf:"varint,3,opt,name=mem_used_bytes,json=memUsedBytes,proto3" json:"mem_used_bytes,omitempty"` + MemTotalBytes uint64 `protobuf:"varint,4,opt,name=mem_total_bytes,json=memTotalBytes,proto3" json:"mem_total_bytes,omitempty"` + InstanceCount uint32 `protobuf:"varint,5,opt,name=instance_count,json=instanceCount,proto3" json:"instance_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Heartbeat) Reset() { + *x = Heartbeat{} + mi := &file_panel_v1_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Heartbeat) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Heartbeat) ProtoMessage() {} + +func (x *Heartbeat) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Heartbeat.ProtoReflect.Descriptor instead. +func (*Heartbeat) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *Heartbeat) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +func (x *Heartbeat) GetCpuPercent() float64 { + if x != nil { + return x.CpuPercent + } + return 0 +} + +func (x *Heartbeat) GetMemUsedBytes() uint64 { + if x != nil { + return x.MemUsedBytes + } + return 0 +} + +func (x *Heartbeat) GetMemTotalBytes() uint64 { + if x != nil { + return x.MemTotalBytes + } + return 0 +} + +func (x *Heartbeat) GetInstanceCount() uint32 { + if x != nil { + return x.InstanceCount + } + return 0 +} + +type Ping struct { + state protoimpl.MessageState `protogen:"open.v1"` + Nonce string `protobuf:"bytes,1,opt,name=nonce,proto3" json:"nonce,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Ping) Reset() { + *x = Ping{} + mi := &file_panel_v1_agent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Ping) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Ping) ProtoMessage() {} + +func (x *Ping) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Ping.ProtoReflect.Descriptor instead. +func (*Ping) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{7} +} + +func (x *Ping) GetNonce() string { + if x != nil { + return x.Nonce + } + return "" +} + +type Pong struct { + state protoimpl.MessageState `protogen:"open.v1"` + Nonce string `protobuf:"bytes,1,opt,name=nonce,proto3" json:"nonce,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Pong) Reset() { + *x = Pong{} + mi := &file_panel_v1_agent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Pong) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Pong) ProtoMessage() {} + +func (x *Pong) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Pong.ProtoReflect.Descriptor instead. +func (*Pong) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{8} +} + +func (x *Pong) GetNonce() string { + if x != nil { + return x.Nonce + } + return "" +} + +type ExecRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` // empty = run on Target host + Argv []string `protobuf:"bytes,2,rep,name=argv,proto3" json:"argv,omitempty"` + Env map[string]string `protobuf:"bytes,3,rep,name=env,proto3" json:"env,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + WorkingDir string `protobuf:"bytes,4,opt,name=working_dir,json=workingDir,proto3" json:"working_dir,omitempty"` + TimeoutSeconds uint32 `protobuf:"varint,5,opt,name=timeout_seconds,json=timeoutSeconds,proto3" json:"timeout_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRequest) Reset() { + *x = ExecRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRequest) ProtoMessage() {} + +func (x *ExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead. +func (*ExecRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{9} +} + +func (x *ExecRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *ExecRequest) GetArgv() []string { + if x != nil { + return x.Argv + } + return nil +} + +func (x *ExecRequest) GetEnv() map[string]string { + if x != nil { + return x.Env + } + return nil +} + +func (x *ExecRequest) GetWorkingDir() string { + if x != nil { + return x.WorkingDir + } + return "" +} + +func (x *ExecRequest) GetTimeoutSeconds() uint32 { + if x != nil { + return x.TimeoutSeconds + } + return 0 +} + +type CommandResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Stdout []byte `protobuf:"bytes,2,opt,name=stdout,proto3" json:"stdout,omitempty"` + Stderr []byte `protobuf:"bytes,3,opt,name=stderr,proto3" json:"stderr,omitempty"` + Error *Error `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandResult) Reset() { + *x = CommandResult{} + mi := &file_panel_v1_agent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CommandResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandResult) ProtoMessage() {} + +func (x *CommandResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandResult.ProtoReflect.Descriptor instead. +func (*CommandResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{10} +} + +func (x *CommandResult) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *CommandResult) GetStdout() []byte { + if x != nil { + return x.Stdout + } + return nil +} + +func (x *CommandResult) GetStderr() []byte { + if x != nil { + return x.Stderr + } + return nil +} + +func (x *CommandResult) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +type InstanceCreate struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + ModuleId string `protobuf:"bytes,2,opt,name=module_id,json=moduleId,proto3" json:"module_id,omitempty"` + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + RunMode RunMode `protobuf:"varint,4,opt,name=run_mode,json=runMode,proto3,enum=panel.v1.RunMode" json:"run_mode,omitempty"` + Limits *ResourceLimits `protobuf:"bytes,5,opt,name=limits,proto3" json:"limits,omitempty"` + ConfigValues map[string]string `protobuf:"bytes,6,rep,name=config_values,json=configValues,proto3" json:"config_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // flattened key/value, module schema-validated + Ports []*PortMap `protobuf:"bytes,7,rep,name=ports,proto3" json:"ports,omitempty"` + DataPath string `protobuf:"bytes,8,opt,name=data_path,json=dataPath,proto3" json:"data_path,omitempty"` + // mount_overrides lets the controller swap one of the module's + // manifest-defined volume mounts at create time. Keyed by container + // path (matching a volume entry's `container:` field in module.yaml). + // Value is either a Docker named volume name (no leading slash) or an + // absolute host path (leading slash = bind mount). + // + // Used by the ARK cluster feature to swap an instance's per-instance + // `panel--cluster` named volume for a shared `panel-ark-cluster-` + // volume when the server joins a cluster. Generic enough for future + // features — any module can benefit from per-instance mount swaps + // without needing a manifest change. + MountOverrides map[string]string `protobuf:"bytes,9,rep,name=mount_overrides,json=mountOverrides,proto3" json:"mount_overrides,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceCreate) Reset() { + *x = InstanceCreate{} + mi := &file_panel_v1_agent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceCreate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceCreate) ProtoMessage() {} + +func (x *InstanceCreate) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceCreate.ProtoReflect.Descriptor instead. +func (*InstanceCreate) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{11} +} + +func (x *InstanceCreate) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *InstanceCreate) GetModuleId() string { + if x != nil { + return x.ModuleId + } + return "" +} + +func (x *InstanceCreate) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *InstanceCreate) GetRunMode() RunMode { + if x != nil { + return x.RunMode + } + return RunMode_RUN_MODE_UNSPECIFIED +} + +func (x *InstanceCreate) GetLimits() *ResourceLimits { + if x != nil { + return x.Limits + } + return nil +} + +func (x *InstanceCreate) GetConfigValues() map[string]string { + if x != nil { + return x.ConfigValues + } + return nil +} + +func (x *InstanceCreate) GetPorts() []*PortMap { + if x != nil { + return x.Ports + } + return nil +} + +func (x *InstanceCreate) GetDataPath() string { + if x != nil { + return x.DataPath + } + return "" +} + +func (x *InstanceCreate) GetMountOverrides() map[string]string { + if x != nil { + return x.MountOverrides + } + return nil +} + +type InstanceStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceStart) Reset() { + *x = InstanceStart{} + mi := &file_panel_v1_agent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceStart) ProtoMessage() {} + +func (x *InstanceStart) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceStart.ProtoReflect.Descriptor instead. +func (*InstanceStart) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{12} +} + +func (x *InstanceStart) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +type InstanceStop struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + GraceSeconds uint32 `protobuf:"varint,2,opt,name=grace_seconds,json=graceSeconds,proto3" json:"grace_seconds,omitempty"` + Force bool `protobuf:"varint,3,opt,name=force,proto3" json:"force,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceStop) Reset() { + *x = InstanceStop{} + mi := &file_panel_v1_agent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceStop) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceStop) ProtoMessage() {} + +func (x *InstanceStop) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceStop.ProtoReflect.Descriptor instead. +func (*InstanceStop) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{13} +} + +func (x *InstanceStop) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *InstanceStop) GetGraceSeconds() uint32 { + if x != nil { + return x.GraceSeconds + } + return 0 +} + +func (x *InstanceStop) GetForce() bool { + if x != nil { + return x.Force + } + return false +} + +// InstanceDelete tears down the instance record. If purge_volumes is +// true, every Docker volume labeled panel.instance_id= is +// removed too; otherwise volumes survive so a subsequent re-create can +// pick up the existing world/save data. +type InstanceDelete struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + GraceSeconds uint32 `protobuf:"varint,2,opt,name=grace_seconds,json=graceSeconds,proto3" json:"grace_seconds,omitempty"` // if the container is still running, stop with this grace first + PurgeVolumes bool `protobuf:"varint,3,opt,name=purge_volumes,json=purgeVolumes,proto3" json:"purge_volumes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceDelete) Reset() { + *x = InstanceDelete{} + mi := &file_panel_v1_agent_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceDelete) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceDelete) ProtoMessage() {} + +func (x *InstanceDelete) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceDelete.ProtoReflect.Descriptor instead. +func (*InstanceDelete) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{14} +} + +func (x *InstanceDelete) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *InstanceDelete) GetGraceSeconds() uint32 { + if x != nil { + return x.GraceSeconds + } + return 0 +} + +func (x *InstanceDelete) GetPurgeVolumes() bool { + if x != nil { + return x.PurgeVolumes + } + return false +} + +type InstanceStateUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Status InstanceStatus `protobuf:"varint,2,opt,name=status,proto3,enum=panel.v1.InstanceStatus" json:"status,omitempty"` + ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + At *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=at,proto3" json:"at,omitempty"` + Detail string `protobuf:"bytes,5,opt,name=detail,proto3" json:"detail,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceStateUpdate) Reset() { + *x = InstanceStateUpdate{} + mi := &file_panel_v1_agent_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceStateUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceStateUpdate) ProtoMessage() {} + +func (x *InstanceStateUpdate) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceStateUpdate.ProtoReflect.Descriptor instead. +func (*InstanceStateUpdate) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{15} +} + +func (x *InstanceStateUpdate) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *InstanceStateUpdate) GetStatus() InstanceStatus { + if x != nil { + return x.Status + } + return InstanceStatus_INSTANCE_STATUS_UNSPECIFIED +} + +func (x *InstanceStateUpdate) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *InstanceStateUpdate) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +func (x *InstanceStateUpdate) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +type AppStateUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + PlayersOnline int32 `protobuf:"varint,2,opt,name=players_online,json=playersOnline,proto3" json:"players_online,omitempty"` + PlayersMax int32 `protobuf:"varint,3,opt,name=players_max,json=playersMax,proto3" json:"players_max,omitempty"` + UptimeSeconds int64 `protobuf:"varint,4,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` + ModuleFields map[string]string `protobuf:"bytes,10,rep,name=module_fields,json=moduleFields,proto3" json:"module_fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // world_seed, day_number, tps, etc. + At *timestamppb.Timestamp `protobuf:"bytes,20,opt,name=at,proto3" json:"at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AppStateUpdate) Reset() { + *x = AppStateUpdate{} + mi := &file_panel_v1_agent_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AppStateUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AppStateUpdate) ProtoMessage() {} + +func (x *AppStateUpdate) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AppStateUpdate.ProtoReflect.Descriptor instead. +func (*AppStateUpdate) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{16} +} + +func (x *AppStateUpdate) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *AppStateUpdate) GetPlayersOnline() int32 { + if x != nil { + return x.PlayersOnline + } + return 0 +} + +func (x *AppStateUpdate) GetPlayersMax() int32 { + if x != nil { + return x.PlayersMax + } + return 0 +} + +func (x *AppStateUpdate) GetUptimeSeconds() int64 { + if x != nil { + return x.UptimeSeconds + } + return 0 +} + +func (x *AppStateUpdate) GetModuleFields() map[string]string { + if x != nil { + return x.ModuleFields + } + return nil +} + +func (x *AppStateUpdate) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +type LogLine struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Stream string `protobuf:"bytes,2,opt,name=stream,proto3" json:"stream,omitempty"` // "stdout", "stderr", or a named log file + At *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=at,proto3" json:"at,omitempty"` + Line string `protobuf:"bytes,4,opt,name=line,proto3" json:"line,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LogLine) Reset() { + *x = LogLine{} + mi := &file_panel_v1_agent_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LogLine) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LogLine) ProtoMessage() {} + +func (x *LogLine) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LogLine.ProtoReflect.Descriptor instead. +func (*LogLine) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{17} +} + +func (x *LogLine) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *LogLine) GetStream() string { + if x != nil { + return x.Stream + } + return "" +} + +func (x *LogLine) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +func (x *LogLine) GetLine() string { + if x != nil { + return x.Line + } + return "" +} + +// InstanceStatsUpdate is emitted periodically (default every 10s) by the +// agent's per-instance stats poller. Values come from Docker's stats +// stream API. Kept sparse — enough for UI gauges + future scheduler +// expressions like "cpu_percent > 80 sustained 5m -> notify". +type InstanceStatsUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + CpuPercent float64 `protobuf:"fixed64,2,opt,name=cpu_percent,json=cpuPercent,proto3" json:"cpu_percent,omitempty"` // 100 = 1 vCPU saturated + MemUsedBytes uint64 `protobuf:"varint,3,opt,name=mem_used_bytes,json=memUsedBytes,proto3" json:"mem_used_bytes,omitempty"` + MemLimitBytes uint64 `protobuf:"varint,4,opt,name=mem_limit_bytes,json=memLimitBytes,proto3" json:"mem_limit_bytes,omitempty"` // 0 if no explicit limit set (then use container's view) + NetRxBytes uint64 `protobuf:"varint,5,opt,name=net_rx_bytes,json=netRxBytes,proto3" json:"net_rx_bytes,omitempty"` // cumulative since container start + NetTxBytes uint64 `protobuf:"varint,6,opt,name=net_tx_bytes,json=netTxBytes,proto3" json:"net_tx_bytes,omitempty"` + Pids uint32 `protobuf:"varint,7,opt,name=pids,proto3" json:"pids,omitempty"` + At *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=at,proto3" json:"at,omitempty"` + // Host core count (docker OnlineCPUs) so UIs can translate the + // docker-style cpu_percent (100 = 1 core) into percent-of-host. + HostCpus uint32 `protobuf:"varint,9,opt,name=host_cpus,json=hostCpus,proto3" json:"host_cpus,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceStatsUpdate) Reset() { + *x = InstanceStatsUpdate{} + mi := &file_panel_v1_agent_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceStatsUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceStatsUpdate) ProtoMessage() {} + +func (x *InstanceStatsUpdate) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceStatsUpdate.ProtoReflect.Descriptor instead. +func (*InstanceStatsUpdate) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{18} +} + +func (x *InstanceStatsUpdate) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *InstanceStatsUpdate) GetCpuPercent() float64 { + if x != nil { + return x.CpuPercent + } + return 0 +} + +func (x *InstanceStatsUpdate) GetMemUsedBytes() uint64 { + if x != nil { + return x.MemUsedBytes + } + return 0 +} + +func (x *InstanceStatsUpdate) GetMemLimitBytes() uint64 { + if x != nil { + return x.MemLimitBytes + } + return 0 +} + +func (x *InstanceStatsUpdate) GetNetRxBytes() uint64 { + if x != nil { + return x.NetRxBytes + } + return 0 +} + +func (x *InstanceStatsUpdate) GetNetTxBytes() uint64 { + if x != nil { + return x.NetTxBytes + } + return 0 +} + +func (x *InstanceStatsUpdate) GetPids() uint32 { + if x != nil { + return x.Pids + } + return 0 +} + +func (x *InstanceStatsUpdate) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +func (x *InstanceStatsUpdate) GetHostCpus() uint32 { + if x != nil { + return x.HostCpus + } + return 0 +} + +type RCONCommand struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RCONCommand) Reset() { + *x = RCONCommand{} + mi := &file_panel_v1_agent_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RCONCommand) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RCONCommand) ProtoMessage() {} + +func (x *RCONCommand) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RCONCommand.ProtoReflect.Descriptor instead. +func (*RCONCommand) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{19} +} + +func (x *RCONCommand) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *RCONCommand) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +type RCONResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Output string `protobuf:"bytes,1,opt,name=output,proto3" json:"output,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // empty on success; short machine-readable on failure + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RCONResult) Reset() { + *x = RCONResult{} + mi := &file_panel_v1_agent_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RCONResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RCONResult) ProtoMessage() {} + +func (x *RCONResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RCONResult.ProtoReflect.Descriptor instead. +func (*RCONResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{20} +} + +func (x *RCONResult) GetOutput() string { + if x != nil { + return x.Output + } + return "" +} + +func (x *RCONResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type FsEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // base name + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` // path relative to instance data_path + IsDir bool `protobuf:"varint,3,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` // bytes (for files) + ModTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=mod_time,json=modTime,proto3" json:"mod_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsEntry) Reset() { + *x = FsEntry{} + mi := &file_panel_v1_agent_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsEntry) ProtoMessage() {} + +func (x *FsEntry) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsEntry.ProtoReflect.Descriptor instead. +func (*FsEntry) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{21} +} + +func (x *FsEntry) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FsEntry) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FsEntry) GetIsDir() bool { + if x != nil { + return x.IsDir + } + return false +} + +func (x *FsEntry) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *FsEntry) GetModTime() *timestamppb.Timestamp { + if x != nil { + return x.ModTime + } + return nil +} + +type FsListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsListRequest) Reset() { + *x = FsListRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsListRequest) ProtoMessage() {} + +func (x *FsListRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsListRequest.ProtoReflect.Descriptor instead. +func (*FsListRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{22} +} + +func (x *FsListRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsListRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type FsListResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*FsEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsListResult) Reset() { + *x = FsListResult{} + mi := &file_panel_v1_agent_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsListResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsListResult) ProtoMessage() {} + +func (x *FsListResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsListResult.ProtoReflect.Descriptor instead. +func (*FsListResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{23} +} + +func (x *FsListResult) GetEntries() []*FsEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *FsListResult) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FsListResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type FsReadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsReadRequest) Reset() { + *x = FsReadRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsReadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsReadRequest) ProtoMessage() {} + +func (x *FsReadRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsReadRequest.ProtoReflect.Descriptor instead. +func (*FsReadRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{24} +} + +func (x *FsReadRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsReadRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type FsReadResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Content []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsReadResult) Reset() { + *x = FsReadResult{} + mi := &file_panel_v1_agent_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsReadResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsReadResult) ProtoMessage() {} + +func (x *FsReadResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsReadResult.ProtoReflect.Descriptor instead. +func (*FsReadResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{25} +} + +func (x *FsReadResult) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FsReadResult) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +func (x *FsReadResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type FsWriteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Content []byte `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsWriteRequest) Reset() { + *x = FsWriteRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsWriteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsWriteRequest) ProtoMessage() {} + +func (x *FsWriteRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsWriteRequest.ProtoReflect.Descriptor instead. +func (*FsWriteRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{26} +} + +func (x *FsWriteRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsWriteRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FsWriteRequest) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +type FsWriteResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + BytesWritten int64 `protobuf:"varint,1,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsWriteResult) Reset() { + *x = FsWriteResult{} + mi := &file_panel_v1_agent_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsWriteResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsWriteResult) ProtoMessage() {} + +func (x *FsWriteResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsWriteResult.ProtoReflect.Descriptor instead. +func (*FsWriteResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{27} +} + +func (x *FsWriteResult) GetBytesWritten() int64 { + if x != nil { + return x.BytesWritten + } + return 0 +} + +func (x *FsWriteResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type FsDeleteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,3,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsDeleteRequest) Reset() { + *x = FsDeleteRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsDeleteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsDeleteRequest) ProtoMessage() {} + +func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. +func (*FsDeleteRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{28} +} + +func (x *FsDeleteRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsDeleteRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FsDeleteRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type FsDeleteResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsDeleteResult) Reset() { + *x = FsDeleteResult{} + mi := &file_panel_v1_agent_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsDeleteResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsDeleteResult) ProtoMessage() {} + +func (x *FsDeleteResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsDeleteResult.ProtoReflect.Descriptor instead. +func (*FsDeleteResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{29} +} + +func (x *FsDeleteResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// FsSymlinkRequest creates (or replaces) a symlink inside the instance +// container. Used by the DayZ mod manager to link @ModName entries to +// the shared workshop tree at /game/steamapps/workshop/content/... +type FsSymlinkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + LinkPath string `protobuf:"bytes,3,opt,name=link_path,json=linkPath,proto3" json:"link_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsSymlinkRequest) Reset() { + *x = FsSymlinkRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsSymlinkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsSymlinkRequest) ProtoMessage() {} + +func (x *FsSymlinkRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsSymlinkRequest.ProtoReflect.Descriptor instead. +func (*FsSymlinkRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{30} +} + +func (x *FsSymlinkRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsSymlinkRequest) GetTarget() string { + if x != nil { + return x.Target + } + return "" +} + +func (x *FsSymlinkRequest) GetLinkPath() string { + if x != nil { + return x.LinkPath + } + return "" +} + +type FsSymlinkResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsSymlinkResult) Reset() { + *x = FsSymlinkResult{} + mi := &file_panel_v1_agent_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsSymlinkResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsSymlinkResult) ProtoMessage() {} + +func (x *FsSymlinkResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsSymlinkResult.ProtoReflect.Descriptor instead. +func (*FsSymlinkResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{31} +} + +func (x *FsSymlinkResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// FsExtractRequest expands an archive that already lives inside the +// instance's volume. Format is auto-detected from the archive bytes +// (zip / tar / tar.gz / tar.bz2). dest_dir is rooted in the instance's +// browseable root just like every other Fs op; "" means same dir as +// the archive. +type FsExtractRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + ArchivePath string `protobuf:"bytes,2,opt,name=archive_path,json=archivePath,proto3" json:"archive_path,omitempty"` + DestDir string `protobuf:"bytes,3,opt,name=dest_dir,json=destDir,proto3" json:"dest_dir,omitempty"` + Overwrite bool `protobuf:"varint,4,opt,name=overwrite,proto3" json:"overwrite,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsExtractRequest) Reset() { + *x = FsExtractRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsExtractRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsExtractRequest) ProtoMessage() {} + +func (x *FsExtractRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsExtractRequest.ProtoReflect.Descriptor instead. +func (*FsExtractRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{32} +} + +func (x *FsExtractRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsExtractRequest) GetArchivePath() string { + if x != nil { + return x.ArchivePath + } + return "" +} + +func (x *FsExtractRequest) GetDestDir() string { + if x != nil { + return x.DestDir + } + return "" +} + +func (x *FsExtractRequest) GetOverwrite() bool { + if x != nil { + return x.Overwrite + } + return false +} + +type FsExtractResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntriesWritten int64 `protobuf:"varint,1,opt,name=entries_written,json=entriesWritten,proto3" json:"entries_written,omitempty"` + BytesWritten int64 `protobuf:"varint,2,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsExtractResult) Reset() { + *x = FsExtractResult{} + mi := &file_panel_v1_agent_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsExtractResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsExtractResult) ProtoMessage() {} + +func (x *FsExtractResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsExtractResult.ProtoReflect.Descriptor instead. +func (*FsExtractResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{33} +} + +func (x *FsExtractResult) GetEntriesWritten() int64 { + if x != nil { + return x.EntriesWritten + } + return 0 +} + +func (x *FsExtractResult) GetBytesWritten() int64 { + if x != nil { + return x.BytesWritten + } + return 0 +} + +func (x *FsExtractResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// FsCompressRequest produces a zip archive at dest_zip_path containing +// the contents of every path in `sources` (recursive for dirs). All +// paths must be inside the instance's browseable root. +type FsCompressRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Sources []string `protobuf:"bytes,2,rep,name=sources,proto3" json:"sources,omitempty"` + DestZipPath string `protobuf:"bytes,3,opt,name=dest_zip_path,json=destZipPath,proto3" json:"dest_zip_path,omitempty"` + Overwrite bool `protobuf:"varint,4,opt,name=overwrite,proto3" json:"overwrite,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsCompressRequest) Reset() { + *x = FsCompressRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsCompressRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsCompressRequest) ProtoMessage() {} + +func (x *FsCompressRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsCompressRequest.ProtoReflect.Descriptor instead. +func (*FsCompressRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{34} +} + +func (x *FsCompressRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsCompressRequest) GetSources() []string { + if x != nil { + return x.Sources + } + return nil +} + +func (x *FsCompressRequest) GetDestZipPath() string { + if x != nil { + return x.DestZipPath + } + return "" +} + +func (x *FsCompressRequest) GetOverwrite() bool { + if x != nil { + return x.Overwrite + } + return false +} + +type FsCompressResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntriesWritten int64 `protobuf:"varint,1,opt,name=entries_written,json=entriesWritten,proto3" json:"entries_written,omitempty"` + BytesWritten int64 `protobuf:"varint,2,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsCompressResult) Reset() { + *x = FsCompressResult{} + mi := &file_panel_v1_agent_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsCompressResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsCompressResult) ProtoMessage() {} + +func (x *FsCompressResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsCompressResult.ProtoReflect.Descriptor instead. +func (*FsCompressResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{35} +} + +func (x *FsCompressResult) GetEntriesWritten() int64 { + if x != nil { + return x.EntriesWritten + } + return 0 +} + +func (x *FsCompressResult) GetBytesWritten() int64 { + if x != nil { + return x.BytesWritten + } + return 0 +} + +func (x *FsCompressResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// FsRenameRequest is a path-safe `mv` inside the instance's volume — +// supports rename + move-into-sibling-dir within the same root. +type FsRenameRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + FromPath string `protobuf:"bytes,2,opt,name=from_path,json=fromPath,proto3" json:"from_path,omitempty"` + ToPath string `protobuf:"bytes,3,opt,name=to_path,json=toPath,proto3" json:"to_path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsRenameRequest) Reset() { + *x = FsRenameRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsRenameRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsRenameRequest) ProtoMessage() {} + +func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. +func (*FsRenameRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{36} +} + +func (x *FsRenameRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsRenameRequest) GetFromPath() string { + if x != nil { + return x.FromPath + } + return "" +} + +func (x *FsRenameRequest) GetToPath() string { + if x != nil { + return x.ToPath + } + return "" +} + +type FsRenameResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsRenameResult) Reset() { + *x = FsRenameResult{} + mi := &file_panel_v1_agent_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsRenameResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsRenameResult) ProtoMessage() {} + +func (x *FsRenameResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsRenameResult.ProtoReflect.Descriptor instead. +func (*FsRenameResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{37} +} + +func (x *FsRenameResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// FsWriteChunkRequest streams a single chunk of a larger upload. Chunks +// are uploaded in order; each one carries the same upload_id (browser- +// generated UUID) so the agent can append to the same temp file. On +// is_final = true the agent finalizes (closes the temp file, ships it +// to the container via CopyToContainer, then unlinks). +type FsWriteChunkRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + UploadId string `protobuf:"bytes,2,opt,name=upload_id,json=uploadId,proto3" json:"upload_id,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + Offset int64 `protobuf:"varint,4,opt,name=offset,proto3" json:"offset,omitempty"` + Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` + TotalSize int64 `protobuf:"varint,6,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"` // optional; agent uses for sanity checks on final + IsFinal bool `protobuf:"varint,7,opt,name=is_final,json=isFinal,proto3" json:"is_final,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsWriteChunkRequest) Reset() { + *x = FsWriteChunkRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsWriteChunkRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsWriteChunkRequest) ProtoMessage() {} + +func (x *FsWriteChunkRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsWriteChunkRequest.ProtoReflect.Descriptor instead. +func (*FsWriteChunkRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{38} +} + +func (x *FsWriteChunkRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *FsWriteChunkRequest) GetUploadId() string { + if x != nil { + return x.UploadId + } + return "" +} + +func (x *FsWriteChunkRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *FsWriteChunkRequest) GetOffset() int64 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *FsWriteChunkRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +func (x *FsWriteChunkRequest) GetTotalSize() int64 { + if x != nil { + return x.TotalSize + } + return 0 +} + +func (x *FsWriteChunkRequest) GetIsFinal() bool { + if x != nil { + return x.IsFinal + } + return false +} + +type FsWriteChunkResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + BytesReceived int64 `protobuf:"varint,1,opt,name=bytes_received,json=bytesReceived,proto3" json:"bytes_received,omitempty"` + TotalReceived int64 `protobuf:"varint,2,opt,name=total_received,json=totalReceived,proto3" json:"total_received,omitempty"` // running total per upload_id + Finalized bool `protobuf:"varint,3,opt,name=finalized,proto3" json:"finalized,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FsWriteChunkResult) Reset() { + *x = FsWriteChunkResult{} + mi := &file_panel_v1_agent_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FsWriteChunkResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FsWriteChunkResult) ProtoMessage() {} + +func (x *FsWriteChunkResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FsWriteChunkResult.ProtoReflect.Descriptor instead. +func (*FsWriteChunkResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{39} +} + +func (x *FsWriteChunkResult) GetBytesReceived() int64 { + if x != nil { + return x.BytesReceived + } + return 0 +} + +func (x *FsWriteChunkResult) GetTotalReceived() int64 { + if x != nil { + return x.TotalReceived + } + return 0 +} + +func (x *FsWriteChunkResult) GetFinalized() bool { + if x != nil { + return x.Finalized + } + return false +} + +func (x *FsWriteChunkResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// ArkSaveRestoreRequest swaps a timestamped rolling backup into the +// active save slot under SavedArks//.ark. The previous +// active save is moved aside (NEVER deleted) by appending a "_replaced" +// timestamp suffix so even abandoned attempts are recoverable. Path is +// rooted at ShooterGame/Saved/SavedArks// in the +// instance's browseable root. +// +// Caller (controller) is responsible for stopping the container before +// invoking this — the agent only performs file operations. If a backup +// rolling file (.arkrbf) is selected, it is renamed to .ark before +// installing as the active save. +type ArkSaveRestoreRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + SavedArksSubdir string `protobuf:"bytes,2,opt,name=saved_arks_subdir,json=savedArksSubdir,proto3" json:"saved_arks_subdir,omitempty"` // e.g. "TheIsland_WP" + TargetFilename string `protobuf:"bytes,3,opt,name=target_filename,json=targetFilename,proto3" json:"target_filename,omitempty"` // basename within saved_arks_subdir (e.g. "TheIsland_WP_30.04.2026_22.13.00.ark") + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ArkSaveRestoreRequest) Reset() { + *x = ArkSaveRestoreRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ArkSaveRestoreRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ArkSaveRestoreRequest) ProtoMessage() {} + +func (x *ArkSaveRestoreRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ArkSaveRestoreRequest.ProtoReflect.Descriptor instead. +func (*ArkSaveRestoreRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{40} +} + +func (x *ArkSaveRestoreRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *ArkSaveRestoreRequest) GetSavedArksSubdir() string { + if x != nil { + return x.SavedArksSubdir + } + return "" +} + +func (x *ArkSaveRestoreRequest) GetTargetFilename() string { + if x != nil { + return x.TargetFilename + } + return "" +} + +type ArkSaveRestoreResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + AsideFilename string `protobuf:"bytes,1,opt,name=aside_filename,json=asideFilename,proto3" json:"aside_filename,omitempty"` // what the previously-active save was renamed to + InstalledFilename string `protobuf:"bytes,2,opt,name=installed_filename,json=installedFilename,proto3" json:"installed_filename,omitempty"` // the filename that's now active (always ".ark") + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ArkSaveRestoreResult) Reset() { + *x = ArkSaveRestoreResult{} + mi := &file_panel_v1_agent_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ArkSaveRestoreResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ArkSaveRestoreResult) ProtoMessage() {} + +func (x *ArkSaveRestoreResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ArkSaveRestoreResult.ProtoReflect.Descriptor instead. +func (*ArkSaveRestoreResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{41} +} + +func (x *ArkSaveRestoreResult) GetAsideFilename() string { + if x != nil { + return x.AsideFilename + } + return "" +} + +func (x *ArkSaveRestoreResult) GetInstalledFilename() string { + if x != nil { + return x.InstalledFilename + } + return "" +} + +func (x *ArkSaveRestoreResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// DayzModInstallRequest asks the agent to (1) copy the mod's bikeys into +// /game/keys and (2) create a @ModName symlink at /game/ +// pointing at the shared workshop content at +// /game/steamapps/workshop/content/221100/. Agent-side so +// it can scan the workshop dir and preserve file ownership. +type DayzModInstallRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + WorkshopId string `protobuf:"bytes,2,opt,name=workshop_id,json=workshopId,proto3" json:"workshop_id,omitempty"` + FolderName string `protobuf:"bytes,3,opt,name=folder_name,json=folderName,proto3" json:"folder_name,omitempty"` // e.g. "@CommunityOnlineTools" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DayzModInstallRequest) Reset() { + *x = DayzModInstallRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DayzModInstallRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DayzModInstallRequest) ProtoMessage() {} + +func (x *DayzModInstallRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DayzModInstallRequest.ProtoReflect.Descriptor instead. +func (*DayzModInstallRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{42} +} + +func (x *DayzModInstallRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *DayzModInstallRequest) GetWorkshopId() string { + if x != nil { + return x.WorkshopId + } + return "" +} + +func (x *DayzModInstallRequest) GetFolderName() string { + if x != nil { + return x.FolderName + } + return "" +} + +type DayzModInstallResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + BikeysCopied []string `protobuf:"bytes,2,rep,name=bikeys_copied,json=bikeysCopied,proto3" json:"bikeys_copied,omitempty"` // filenames of bikeys dropped in /game/keys + ResolvedModPath string `protobuf:"bytes,3,opt,name=resolved_mod_path,json=resolvedModPath,proto3" json:"resolved_mod_path,omitempty"` // /game/steamapps/workshop/content/221100/ + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DayzModInstallResult) Reset() { + *x = DayzModInstallResult{} + mi := &file_panel_v1_agent_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DayzModInstallResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DayzModInstallResult) ProtoMessage() {} + +func (x *DayzModInstallResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DayzModInstallResult.ProtoReflect.Descriptor instead. +func (*DayzModInstallResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{43} +} + +func (x *DayzModInstallResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *DayzModInstallResult) GetBikeysCopied() []string { + if x != nil { + return x.BikeysCopied + } + return nil +} + +func (x *DayzModInstallResult) GetResolvedModPath() string { + if x != nil { + return x.ResolvedModPath + } + return "" +} + +// DayzModUninstallRequest removes the @ModName symlink and the mod's +// bikeys from /game/keys (bikeys are matched by file hash — only keys +// whose content originated from this workshop item are removed). +type DayzModUninstallRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + FolderName string `protobuf:"bytes,2,opt,name=folder_name,json=folderName,proto3" json:"folder_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DayzModUninstallRequest) Reset() { + *x = DayzModUninstallRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DayzModUninstallRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DayzModUninstallRequest) ProtoMessage() {} + +func (x *DayzModUninstallRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DayzModUninstallRequest.ProtoReflect.Descriptor instead. +func (*DayzModUninstallRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{44} +} + +func (x *DayzModUninstallRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *DayzModUninstallRequest) GetFolderName() string { + if x != nil { + return x.FolderName + } + return "" +} + +type DayzModUninstallResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + BikeysRemoved []string `protobuf:"bytes,2,rep,name=bikeys_removed,json=bikeysRemoved,proto3" json:"bikeys_removed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DayzModUninstallResult) Reset() { + *x = DayzModUninstallResult{} + mi := &file_panel_v1_agent_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DayzModUninstallResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DayzModUninstallResult) ProtoMessage() {} + +func (x *DayzModUninstallResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DayzModUninstallResult.ProtoReflect.Descriptor instead. +func (*DayzModUninstallResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{45} +} + +func (x *DayzModUninstallResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *DayzModUninstallResult) GetBikeysRemoved() []string { + if x != nil { + return x.BikeysRemoved + } + return nil +} + +type UpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` // empty = use the module's first update_provider + // Steam login credentials for apps whose SteamCMD refuses +login + // anonymous (DayZ, Arma, etc. — modules with `requires_steam_login` + // set on the provider). The controller pulls these from its + // encrypted steam_credentials store and forwards them via the + // mTLS-protected control stream. Agent never logs them. + SteamUsername string `protobuf:"bytes,3,opt,name=steam_username,json=steamUsername,proto3" json:"steam_username,omitempty"` + SteamPassword string `protobuf:"bytes,4,opt,name=steam_password,json=steamPassword,proto3" json:"steam_password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRequest) Reset() { + *x = UpdateRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRequest) ProtoMessage() {} + +func (x *UpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. +func (*UpdateRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{46} +} + +func (x *UpdateRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *UpdateRequest) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *UpdateRequest) GetSteamUsername() string { + if x != nil { + return x.SteamUsername + } + return "" +} + +func (x *UpdateRequest) GetSteamPassword() string { + if x != nil { + return x.SteamPassword + } + return "" +} + +type UpdateResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` // true = agent kicked off the update goroutine + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // empty on accept; populated on immediate reject + ProviderId string `protobuf:"bytes,3,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + ProviderKind string `protobuf:"bytes,4,opt,name=provider_kind,json=providerKind,proto3" json:"provider_kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateResult) Reset() { + *x = UpdateResult{} + mi := &file_panel_v1_agent_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateResult) ProtoMessage() {} + +func (x *UpdateResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateResult.ProtoReflect.Descriptor instead. +func (*UpdateResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{47} +} + +func (x *UpdateResult) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +func (x *UpdateResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *UpdateResult) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *UpdateResult) GetProviderKind() string { + if x != nil { + return x.ProviderKind + } + return "" +} + +type BackupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BackupRequest) Reset() { + *x = BackupRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BackupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BackupRequest) ProtoMessage() {} + +func (x *BackupRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BackupRequest.ProtoReflect.Descriptor instead. +func (*BackupRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{48} +} + +func (x *BackupRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *BackupRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type BackupResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + BackupId string `protobuf:"bytes,1,opt,name=backup_id,json=backupId,proto3" json:"backup_id,omitempty"` // bkp_ + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + SizeBytes int64 `protobuf:"varint,3,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BackupResult) Reset() { + *x = BackupResult{} + mi := &file_panel_v1_agent_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BackupResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BackupResult) ProtoMessage() {} + +func (x *BackupResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BackupResult.ProtoReflect.Descriptor instead. +func (*BackupResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{49} +} + +func (x *BackupResult) GetBackupId() string { + if x != nil { + return x.BackupId + } + return "" +} + +func (x *BackupResult) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *BackupResult) GetSizeBytes() int64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +func (x *BackupResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type RestoreRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + BackupId string `protobuf:"bytes,2,opt,name=backup_id,json=backupId,proto3" json:"backup_id,omitempty"` + BackupPath string `protobuf:"bytes,3,opt,name=backup_path,json=backupPath,proto3" json:"backup_path,omitempty"` // absolute path on the agent + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreRequest) Reset() { + *x = RestoreRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreRequest) ProtoMessage() {} + +func (x *RestoreRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreRequest.ProtoReflect.Descriptor instead. +func (*RestoreRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{50} +} + +func (x *RestoreRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *RestoreRequest) GetBackupId() string { + if x != nil { + return x.BackupId + } + return "" +} + +func (x *RestoreRequest) GetBackupPath() string { + if x != nil { + return x.BackupPath + } + return "" +} + +type RestoreResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + BytesRestored int64 `protobuf:"varint,2,opt,name=bytes_restored,json=bytesRestored,proto3" json:"bytes_restored,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreResult) Reset() { + *x = RestoreResult{} + mi := &file_panel_v1_agent_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreResult) ProtoMessage() {} + +func (x *RestoreResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreResult.ProtoReflect.Descriptor instead. +func (*RestoreResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{51} +} + +func (x *RestoreResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *RestoreResult) GetBytesRestored() int64 { + if x != nil { + return x.BytesRestored + } + return 0 +} + +// BackupListEntriesRequest asks the agent to enumerate the contents of a +// backup tarball without extracting it. Used by the backup-browser UI so +// operators can drill into a snapshot to confirm what's inside before +// restoring (or to recover a single file). +type BackupListEntriesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + BackupId string `protobuf:"bytes,2,opt,name=backup_id,json=backupId,proto3" json:"backup_id,omitempty"` + BackupPath string `protobuf:"bytes,3,opt,name=backup_path,json=backupPath,proto3" json:"backup_path,omitempty"` // absolute path on the agent (resolved controller-side) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BackupListEntriesRequest) Reset() { + *x = BackupListEntriesRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BackupListEntriesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BackupListEntriesRequest) ProtoMessage() {} + +func (x *BackupListEntriesRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BackupListEntriesRequest.ProtoReflect.Descriptor instead. +func (*BackupListEntriesRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{52} +} + +func (x *BackupListEntriesRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *BackupListEntriesRequest) GetBackupId() string { + if x != nil { + return x.BackupId + } + return "" +} + +func (x *BackupListEntriesRequest) GetBackupPath() string { + if x != nil { + return x.BackupPath + } + return "" +} + +// BackupTarEntry — one row from `tar -tvzf`. Size is bytes-on-disk, not +// compressed size. is_dir distinguishes directories so the UI can render +// a tree without a second pass. +type BackupTarEntry struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // path inside the tarball, no leading slash + Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` + IsDir bool `protobuf:"varint,3,opt,name=is_dir,json=isDir,proto3" json:"is_dir,omitempty"` + Mode string `protobuf:"bytes,4,opt,name=mode,proto3" json:"mode,omitempty"` // permissions string, e.g. "-rw-r--r--" + ModTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=mod_time,json=modTime,proto3" json:"mod_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BackupTarEntry) Reset() { + *x = BackupTarEntry{} + mi := &file_panel_v1_agent_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BackupTarEntry) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BackupTarEntry) ProtoMessage() {} + +func (x *BackupTarEntry) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BackupTarEntry.ProtoReflect.Descriptor instead. +func (*BackupTarEntry) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{53} +} + +func (x *BackupTarEntry) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *BackupTarEntry) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *BackupTarEntry) GetIsDir() bool { + if x != nil { + return x.IsDir + } + return false +} + +func (x *BackupTarEntry) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *BackupTarEntry) GetModTime() *timestamppb.Timestamp { + if x != nil { + return x.ModTime + } + return nil +} + +type BackupListEntriesResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*BackupTarEntry `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + TotalBytes int64 `protobuf:"varint,2,opt,name=total_bytes,json=totalBytes,proto3" json:"total_bytes,omitempty"` // sum of file sizes (not compressed size of the tarball) + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BackupListEntriesResult) Reset() { + *x = BackupListEntriesResult{} + mi := &file_panel_v1_agent_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BackupListEntriesResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BackupListEntriesResult) ProtoMessage() {} + +func (x *BackupListEntriesResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[54] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BackupListEntriesResult.ProtoReflect.Descriptor instead. +func (*BackupListEntriesResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{54} +} + +func (x *BackupListEntriesResult) GetEntries() []*BackupTarEntry { + if x != nil { + return x.Entries + } + return nil +} + +func (x *BackupListEntriesResult) GetTotalBytes() int64 { + if x != nil { + return x.TotalBytes + } + return 0 +} + +func (x *BackupListEntriesResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// BackupReadFileRequest extracts a single entry's bytes to memory by +// streaming `tar -xzOf ` into a buffer. Caller-supplied +// max_bytes caps the read so a malicious / corrupt archive can't OOM the +// agent — the result includes truncated=true if max_bytes was hit. +type BackupReadFileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + BackupId string `protobuf:"bytes,2,opt,name=backup_id,json=backupId,proto3" json:"backup_id,omitempty"` + BackupPath string `protobuf:"bytes,3,opt,name=backup_path,json=backupPath,proto3" json:"backup_path,omitempty"` + EntryPath string `protobuf:"bytes,4,opt,name=entry_path,json=entryPath,proto3" json:"entry_path,omitempty"` + MaxBytes int64 `protobuf:"varint,5,opt,name=max_bytes,json=maxBytes,proto3" json:"max_bytes,omitempty"` // 0 → controller-side default (8 MiB) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BackupReadFileRequest) Reset() { + *x = BackupReadFileRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BackupReadFileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BackupReadFileRequest) ProtoMessage() {} + +func (x *BackupReadFileRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BackupReadFileRequest.ProtoReflect.Descriptor instead. +func (*BackupReadFileRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{55} +} + +func (x *BackupReadFileRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *BackupReadFileRequest) GetBackupId() string { + if x != nil { + return x.BackupId + } + return "" +} + +func (x *BackupReadFileRequest) GetBackupPath() string { + if x != nil { + return x.BackupPath + } + return "" +} + +func (x *BackupReadFileRequest) GetEntryPath() string { + if x != nil { + return x.EntryPath + } + return "" +} + +func (x *BackupReadFileRequest) GetMaxBytes() int64 { + if x != nil { + return x.MaxBytes + } + return 0 +} + +type BackupReadFileResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Content []byte `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // actual entry size from tar header (may be > len(content) if truncated) + Truncated bool `protobuf:"varint,3,opt,name=truncated,proto3" json:"truncated,omitempty"` + IsBinary bool `protobuf:"varint,4,opt,name=is_binary,json=isBinary,proto3" json:"is_binary,omitempty"` // heuristic: NUL byte in first 8 KiB + Error string `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BackupReadFileResult) Reset() { + *x = BackupReadFileResult{} + mi := &file_panel_v1_agent_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BackupReadFileResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BackupReadFileResult) ProtoMessage() {} + +func (x *BackupReadFileResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[56] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BackupReadFileResult.ProtoReflect.Descriptor instead. +func (*BackupReadFileResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{56} +} + +func (x *BackupReadFileResult) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +func (x *BackupReadFileResult) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *BackupReadFileResult) GetTruncated() bool { + if x != nil { + return x.Truncated + } + return false +} + +func (x *BackupReadFileResult) GetIsBinary() bool { + if x != nil { + return x.IsBinary + } + return false +} + +func (x *BackupReadFileResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type PlayerEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Kind PlayerEvent_Kind `protobuf:"varint,2,opt,name=kind,proto3,enum=panel.v1.PlayerEvent_Kind" json:"kind,omitempty"` + PlayerId string `protobuf:"bytes,3,opt,name=player_id,json=playerId,proto3" json:"player_id,omitempty"` // SteamID / UUID where available + PlayerName string `protobuf:"bytes,4,opt,name=player_name,json=playerName,proto3" json:"player_name,omitempty"` + Detail string `protobuf:"bytes,5,opt,name=detail,proto3" json:"detail,omitempty"` + At *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=at,proto3" json:"at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayerEvent) Reset() { + *x = PlayerEvent{} + mi := &file_panel_v1_agent_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayerEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayerEvent) ProtoMessage() {} + +func (x *PlayerEvent) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[57] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayerEvent.ProtoReflect.Descriptor instead. +func (*PlayerEvent) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{57} +} + +func (x *PlayerEvent) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *PlayerEvent) GetKind() PlayerEvent_Kind { + if x != nil { + return x.Kind + } + return PlayerEvent_KIND_UNSPECIFIED +} + +func (x *PlayerEvent) GetPlayerId() string { + if x != nil { + return x.PlayerId + } + return "" +} + +func (x *PlayerEvent) GetPlayerName() string { + if x != nil { + return x.PlayerName + } + return "" +} + +func (x *PlayerEvent) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +func (x *PlayerEvent) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +// EmpyrionScenarioInstallRequest is sent controller → agent to install +// (or refresh) a workshop scenario into a specific Empyrion instance's +// game volume. The agent runs SteamCMD on its OWN Docker daemon — the +// previous (single-host) implementation ran on the controller's daemon +// which has no view of an agent's volumes. +type EmpyrionScenarioInstallRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` // controller-generated; echoed in result + log lines + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` // target empyrion instance (must live on this agent) + WorkshopId string `protobuf:"bytes,3,opt,name=workshop_id,json=workshopId,proto3" json:"workshop_id,omitempty"` // Steam workshop item ID (e.g. "3041847672" for RE2) + ScenarioName string `protobuf:"bytes,4,opt,name=scenario_name,json=scenarioName,proto3" json:"scenario_name,omitempty"` // folder name under Content/Scenarios; sanitized by controller + AppId string `protobuf:"bytes,5,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` // empyrion app id; defaults to 530870 if empty + // Steam credentials for the workshop_download_item call. Steam policy + // (~2023+) requires a logged-in account for most app workshops, even + // for public/free items — anonymous returns "Access Denied". Forwarded + // from the controller's encrypted steam_credentials store. Same shape + // as UpdateRequest's steam_username/steam_password fields. + SteamUsername string `protobuf:"bytes,6,opt,name=steam_username,json=steamUsername,proto3" json:"steam_username,omitempty"` + SteamPassword string `protobuf:"bytes,7,opt,name=steam_password,json=steamPassword,proto3" json:"steam_password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmpyrionScenarioInstallRequest) Reset() { + *x = EmpyrionScenarioInstallRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[58] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmpyrionScenarioInstallRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmpyrionScenarioInstallRequest) ProtoMessage() {} + +func (x *EmpyrionScenarioInstallRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[58] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmpyrionScenarioInstallRequest.ProtoReflect.Descriptor instead. +func (*EmpyrionScenarioInstallRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{58} +} + +func (x *EmpyrionScenarioInstallRequest) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *EmpyrionScenarioInstallRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *EmpyrionScenarioInstallRequest) GetWorkshopId() string { + if x != nil { + return x.WorkshopId + } + return "" +} + +func (x *EmpyrionScenarioInstallRequest) GetScenarioName() string { + if x != nil { + return x.ScenarioName + } + return "" +} + +func (x *EmpyrionScenarioInstallRequest) GetAppId() string { + if x != nil { + return x.AppId + } + return "" +} + +func (x *EmpyrionScenarioInstallRequest) GetSteamUsername() string { + if x != nil { + return x.SteamUsername + } + return "" +} + +func (x *EmpyrionScenarioInstallRequest) GetSteamPassword() string { + if x != nil { + return x.SteamPassword + } + return "" +} + +type EmpyrionScenarioInstallResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + Ok bool `protobuf:"varint,2,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` // empty when ok + ScenarioName string `protobuf:"bytes,4,opt,name=scenario_name,json=scenarioName,proto3" json:"scenario_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmpyrionScenarioInstallResult) Reset() { + *x = EmpyrionScenarioInstallResult{} + mi := &file_panel_v1_agent_proto_msgTypes[59] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmpyrionScenarioInstallResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmpyrionScenarioInstallResult) ProtoMessage() {} + +func (x *EmpyrionScenarioInstallResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[59] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmpyrionScenarioInstallResult.ProtoReflect.Descriptor instead. +func (*EmpyrionScenarioInstallResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{59} +} + +func (x *EmpyrionScenarioInstallResult) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *EmpyrionScenarioInstallResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *EmpyrionScenarioInstallResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *EmpyrionScenarioInstallResult) GetScenarioName() string { + if x != nil { + return x.ScenarioName + } + return "" +} + +// EmpyrionDiscoveriesRequest queries the empyrion server's global.db +// (SQLite) for what a specific player has discovered. The agent mounts +// the instance's saves volume into a tiny alpine+sqlite sidecar, runs +// a join query against DiscoveredPOIs / Entities / Playfields / +// SolarSystems / LoginLogoff, and returns the rows. +// +// `mode = "personal"` returns POIs the named player personally +// discovered. `mode = "faction"` returns POIs anyone in their faction +// has discovered (the in-game shared-map view). Default: personal. +type EmpyrionDiscoveriesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + SteamId string `protobuf:"bytes,2,opt,name=steam_id,json=steamId,proto3" json:"steam_id,omitempty"` // 17-digit SteamID64 of the player + Mode string `protobuf:"bytes,3,opt,name=mode,proto3" json:"mode,omitempty"` // "personal" | "faction" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmpyrionDiscoveriesRequest) Reset() { + *x = EmpyrionDiscoveriesRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[60] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmpyrionDiscoveriesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmpyrionDiscoveriesRequest) ProtoMessage() {} + +func (x *EmpyrionDiscoveriesRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[60] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmpyrionDiscoveriesRequest.ProtoReflect.Descriptor instead. +func (*EmpyrionDiscoveriesRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{60} +} + +func (x *EmpyrionDiscoveriesRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *EmpyrionDiscoveriesRequest) GetSteamId() string { + if x != nil { + return x.SteamId + } + return "" +} + +func (x *EmpyrionDiscoveriesRequest) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +type DiscoveredPOI struct { + state protoimpl.MessageState `protogen:"open.v1"` + SolarSystem string `protobuf:"bytes,1,opt,name=solar_system,json=solarSystem,proto3" json:"solar_system,omitempty"` + Playfield string `protobuf:"bytes,2,opt,name=playfield,proto3" json:"playfield,omitempty"` + PoiName string `protobuf:"bytes,3,opt,name=poi_name,json=poiName,proto3" json:"poi_name,omitempty"` + PoiType int32 `protobuf:"varint,4,opt,name=poi_type,json=poiType,proto3" json:"poi_type,omitempty"` // Entities.etype enum + GameTime int64 `protobuf:"varint,5,opt,name=game_time,json=gameTime,proto3" json:"game_time,omitempty"` // ticks since save start + DiscovererName string `protobuf:"bytes,6,opt,name=discoverer_name,json=discovererName,proto3" json:"discoverer_name,omitempty"` // who first discovered (when known) + DiscovererSteamId string `protobuf:"bytes,7,opt,name=discoverer_steam_id,json=discovererSteamId,proto3" json:"discoverer_steam_id,omitempty"` + FactionGroup int32 `protobuf:"varint,8,opt,name=faction_group,json=factionGroup,proto3" json:"faction_group,omitempty"` + FactionId int32 `protobuf:"varint,9,opt,name=faction_id,json=factionId,proto3" json:"faction_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DiscoveredPOI) Reset() { + *x = DiscoveredPOI{} + mi := &file_panel_v1_agent_proto_msgTypes[61] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DiscoveredPOI) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DiscoveredPOI) ProtoMessage() {} + +func (x *DiscoveredPOI) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[61] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DiscoveredPOI.ProtoReflect.Descriptor instead. +func (*DiscoveredPOI) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{61} +} + +func (x *DiscoveredPOI) GetSolarSystem() string { + if x != nil { + return x.SolarSystem + } + return "" +} + +func (x *DiscoveredPOI) GetPlayfield() string { + if x != nil { + return x.Playfield + } + return "" +} + +func (x *DiscoveredPOI) GetPoiName() string { + if x != nil { + return x.PoiName + } + return "" +} + +func (x *DiscoveredPOI) GetPoiType() int32 { + if x != nil { + return x.PoiType + } + return 0 +} + +func (x *DiscoveredPOI) GetGameTime() int64 { + if x != nil { + return x.GameTime + } + return 0 +} + +func (x *DiscoveredPOI) GetDiscovererName() string { + if x != nil { + return x.DiscovererName + } + return "" +} + +func (x *DiscoveredPOI) GetDiscovererSteamId() string { + if x != nil { + return x.DiscovererSteamId + } + return "" +} + +func (x *DiscoveredPOI) GetFactionGroup() int32 { + if x != nil { + return x.FactionGroup + } + return 0 +} + +func (x *DiscoveredPOI) GetFactionId() int32 { + if x != nil { + return x.FactionId + } + return 0 +} + +type EmpyrionDiscoveriesResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + Pois []*DiscoveredPOI `protobuf:"bytes,3,rep,name=pois,proto3" json:"pois,omitempty"` + Mode string `protobuf:"bytes,4,opt,name=mode,proto3" json:"mode,omitempty"` // echoed back from request + SteamId string `protobuf:"bytes,5,opt,name=steam_id,json=steamId,proto3" json:"steam_id,omitempty"` + PlayerName string `protobuf:"bytes,6,opt,name=player_name,json=playerName,proto3" json:"player_name,omitempty"` // last-known name from LoginLogoff + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmpyrionDiscoveriesResult) Reset() { + *x = EmpyrionDiscoveriesResult{} + mi := &file_panel_v1_agent_proto_msgTypes[62] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmpyrionDiscoveriesResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmpyrionDiscoveriesResult) ProtoMessage() {} + +func (x *EmpyrionDiscoveriesResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[62] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmpyrionDiscoveriesResult.ProtoReflect.Descriptor instead. +func (*EmpyrionDiscoveriesResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{62} +} + +func (x *EmpyrionDiscoveriesResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *EmpyrionDiscoveriesResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *EmpyrionDiscoveriesResult) GetPois() []*DiscoveredPOI { + if x != nil { + return x.Pois + } + return nil +} + +func (x *EmpyrionDiscoveriesResult) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +func (x *EmpyrionDiscoveriesResult) GetSteamId() string { + if x != nil { + return x.SteamId + } + return "" +} + +func (x *EmpyrionDiscoveriesResult) GetPlayerName() string { + if x != nil { + return x.PlayerName + } + return "" +} + +// EmpyrionPlayerSummaryRequest pulls everything we know about a single +// player from the empyrion savegame DB: stats, login sessions, current +// inventory snapshot. Used by the public /me page on AMP-Monitor. +type EmpyrionPlayerSummaryRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + SteamId string `protobuf:"bytes,2,opt,name=steam_id,json=steamId,proto3" json:"steam_id,omitempty"` // 17-digit SteamID64 + MaxLoginRows int32 `protobuf:"varint,3,opt,name=max_login_rows,json=maxLoginRows,proto3" json:"max_login_rows,omitempty"` // limit on session history; default 50 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmpyrionPlayerSummaryRequest) Reset() { + *x = EmpyrionPlayerSummaryRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[63] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmpyrionPlayerSummaryRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmpyrionPlayerSummaryRequest) ProtoMessage() {} + +func (x *EmpyrionPlayerSummaryRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[63] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmpyrionPlayerSummaryRequest.ProtoReflect.Descriptor instead. +func (*EmpyrionPlayerSummaryRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{63} +} + +func (x *EmpyrionPlayerSummaryRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *EmpyrionPlayerSummaryRequest) GetSteamId() string { + if x != nil { + return x.SteamId + } + return "" +} + +func (x *EmpyrionPlayerSummaryRequest) GetMaxLoginRows() int32 { + if x != nil { + return x.MaxLoginRows + } + return 0 +} + +type PlayerStatsRow struct { + state protoimpl.MessageState `protogen:"open.v1"` + KilledEnemies int64 `protobuf:"varint,1,opt,name=killed_enemies,json=killedEnemies,proto3" json:"killed_enemies,omitempty"` + KilledAllied int64 `protobuf:"varint,2,opt,name=killed_allied,json=killedAllied,proto3" json:"killed_allied,omitempty"` + KilledAnimals int64 `protobuf:"varint,3,opt,name=killed_animals,json=killedAnimals,proto3" json:"killed_animals,omitempty"` + KilledDrones int64 `protobuf:"varint,4,opt,name=killed_drones,json=killedDrones,proto3" json:"killed_drones,omitempty"` + KilledPlayers int64 `protobuf:"varint,5,opt,name=killed_players,json=killedPlayers,proto3" json:"killed_players,omitempty"` + KilledAlliedPlayers int64 `protobuf:"varint,6,opt,name=killed_allied_players,json=killedAlliedPlayers,proto3" json:"killed_allied_players,omitempty"` + Died int64 `protobuf:"varint,7,opt,name=died,proto3" json:"died,omitempty"` + Score int64 `protobuf:"varint,8,opt,name=score,proto3" json:"score,omitempty"` + BlocksPlaced int64 `protobuf:"varint,9,opt,name=blocks_placed,json=blocksPlaced,proto3" json:"blocks_placed,omitempty"` + BlocksDigged int64 `protobuf:"varint,10,opt,name=blocks_digged,json=blocksDigged,proto3" json:"blocks_digged,omitempty"` + WalkedMeters int64 `protobuf:"varint,11,opt,name=walked_meters,json=walkedMeters,proto3" json:"walked_meters,omitempty"` + JetpackMeters int64 `protobuf:"varint,12,opt,name=jetpack_meters,json=jetpackMeters,proto3" json:"jetpack_meters,omitempty"` + HvMeters int64 `protobuf:"varint,13,opt,name=hv_meters,json=hvMeters,proto3" json:"hv_meters,omitempty"` + SvMeters int64 `protobuf:"varint,14,opt,name=sv_meters,json=svMeters,proto3" json:"sv_meters,omitempty"` + CvMeters int64 `protobuf:"varint,15,opt,name=cv_meters,json=cvMeters,proto3" json:"cv_meters,omitempty"` + Playtime float64 `protobuf:"fixed64,16,opt,name=playtime,proto3" json:"playtime,omitempty"` // hours per the schema + LightYears float64 `protobuf:"fixed64,17,opt,name=light_years,json=lightYears,proto3" json:"light_years,omitempty"` // travly + AstronomicalUnits float64 `protobuf:"fixed64,18,opt,name=astronomical_units,json=astronomicalUnits,proto3" json:"astronomical_units,omitempty"` // travau + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayerStatsRow) Reset() { + *x = PlayerStatsRow{} + mi := &file_panel_v1_agent_proto_msgTypes[64] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayerStatsRow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayerStatsRow) ProtoMessage() {} + +func (x *PlayerStatsRow) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[64] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayerStatsRow.ProtoReflect.Descriptor instead. +func (*PlayerStatsRow) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{64} +} + +func (x *PlayerStatsRow) GetKilledEnemies() int64 { + if x != nil { + return x.KilledEnemies + } + return 0 +} + +func (x *PlayerStatsRow) GetKilledAllied() int64 { + if x != nil { + return x.KilledAllied + } + return 0 +} + +func (x *PlayerStatsRow) GetKilledAnimals() int64 { + if x != nil { + return x.KilledAnimals + } + return 0 +} + +func (x *PlayerStatsRow) GetKilledDrones() int64 { + if x != nil { + return x.KilledDrones + } + return 0 +} + +func (x *PlayerStatsRow) GetKilledPlayers() int64 { + if x != nil { + return x.KilledPlayers + } + return 0 +} + +func (x *PlayerStatsRow) GetKilledAlliedPlayers() int64 { + if x != nil { + return x.KilledAlliedPlayers + } + return 0 +} + +func (x *PlayerStatsRow) GetDied() int64 { + if x != nil { + return x.Died + } + return 0 +} + +func (x *PlayerStatsRow) GetScore() int64 { + if x != nil { + return x.Score + } + return 0 +} + +func (x *PlayerStatsRow) GetBlocksPlaced() int64 { + if x != nil { + return x.BlocksPlaced + } + return 0 +} + +func (x *PlayerStatsRow) GetBlocksDigged() int64 { + if x != nil { + return x.BlocksDigged + } + return 0 +} + +func (x *PlayerStatsRow) GetWalkedMeters() int64 { + if x != nil { + return x.WalkedMeters + } + return 0 +} + +func (x *PlayerStatsRow) GetJetpackMeters() int64 { + if x != nil { + return x.JetpackMeters + } + return 0 +} + +func (x *PlayerStatsRow) GetHvMeters() int64 { + if x != nil { + return x.HvMeters + } + return 0 +} + +func (x *PlayerStatsRow) GetSvMeters() int64 { + if x != nil { + return x.SvMeters + } + return 0 +} + +func (x *PlayerStatsRow) GetCvMeters() int64 { + if x != nil { + return x.CvMeters + } + return 0 +} + +func (x *PlayerStatsRow) GetPlaytime() float64 { + if x != nil { + return x.Playtime + } + return 0 +} + +func (x *PlayerStatsRow) GetLightYears() float64 { + if x != nil { + return x.LightYears + } + return 0 +} + +func (x *PlayerStatsRow) GetAstronomicalUnits() float64 { + if x != nil { + return x.AstronomicalUnits + } + return 0 +} + +type PlayerLoginRow struct { + state protoimpl.MessageState `protogen:"open.v1"` + LoginTicks int64 `protobuf:"varint,1,opt,name=login_ticks,json=loginTicks,proto3" json:"login_ticks,omitempty"` + LogoffTicks int64 `protobuf:"varint,2,opt,name=logoff_ticks,json=logoffTicks,proto3" json:"logoff_ticks,omitempty"` + PlayerName string `protobuf:"bytes,3,opt,name=player_name,json=playerName,proto3" json:"player_name,omitempty"` + BuildNr int64 `protobuf:"varint,4,opt,name=build_nr,json=buildNr,proto3" json:"build_nr,omitempty"` + Ip string `protobuf:"bytes,5,opt,name=ip,proto3" json:"ip,omitempty"` + Os string `protobuf:"bytes,6,opt,name=os,proto3" json:"os,omitempty"` + Gfx string `protobuf:"bytes,7,opt,name=gfx,proto3" json:"gfx,omitempty"` + Cpu string `protobuf:"bytes,8,opt,name=cpu,proto3" json:"cpu,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayerLoginRow) Reset() { + *x = PlayerLoginRow{} + mi := &file_panel_v1_agent_proto_msgTypes[65] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayerLoginRow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayerLoginRow) ProtoMessage() {} + +func (x *PlayerLoginRow) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[65] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayerLoginRow.ProtoReflect.Descriptor instead. +func (*PlayerLoginRow) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{65} +} + +func (x *PlayerLoginRow) GetLoginTicks() int64 { + if x != nil { + return x.LoginTicks + } + return 0 +} + +func (x *PlayerLoginRow) GetLogoffTicks() int64 { + if x != nil { + return x.LogoffTicks + } + return 0 +} + +func (x *PlayerLoginRow) GetPlayerName() string { + if x != nil { + return x.PlayerName + } + return "" +} + +func (x *PlayerLoginRow) GetBuildNr() int64 { + if x != nil { + return x.BuildNr + } + return 0 +} + +func (x *PlayerLoginRow) GetIp() string { + if x != nil { + return x.Ip + } + return "" +} + +func (x *PlayerLoginRow) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *PlayerLoginRow) GetGfx() string { + if x != nil { + return x.Gfx + } + return "" +} + +func (x *PlayerLoginRow) GetCpu() string { + if x != nil { + return x.Cpu + } + return "" +} + +type PlayerInventoryItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + ItemId int32 `protobuf:"varint,1,opt,name=item_id,json=itemId,proto3" json:"item_id,omitempty"` + Count int32 `protobuf:"varint,2,opt,name=count,proto3" json:"count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PlayerInventoryItem) Reset() { + *x = PlayerInventoryItem{} + mi := &file_panel_v1_agent_proto_msgTypes[66] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PlayerInventoryItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PlayerInventoryItem) ProtoMessage() {} + +func (x *PlayerInventoryItem) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[66] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PlayerInventoryItem.ProtoReflect.Descriptor instead. +func (*PlayerInventoryItem) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{66} +} + +func (x *PlayerInventoryItem) GetItemId() int32 { + if x != nil { + return x.ItemId + } + return 0 +} + +func (x *PlayerInventoryItem) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +type EmpyrionPlayerSummaryResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + SteamId string `protobuf:"bytes,3,opt,name=steam_id,json=steamId,proto3" json:"steam_id,omitempty"` + PlayerName string `protobuf:"bytes,4,opt,name=player_name,json=playerName,proto3" json:"player_name,omitempty"` + EntityId int64 `protobuf:"varint,5,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` + FactionGroup int32 `protobuf:"varint,6,opt,name=faction_group,json=factionGroup,proto3" json:"faction_group,omitempty"` + FactionId int32 `protobuf:"varint,7,opt,name=faction_id,json=factionId,proto3" json:"faction_id,omitempty"` + // Aggregate stats. nil when player has no PlayerStatistics row yet + // (very new player). + Stats *PlayerStatsRow `protobuf:"bytes,8,opt,name=stats,proto3" json:"stats,omitempty"` + // Recent login sessions, newest first. + Sessions []*PlayerLoginRow `protobuf:"bytes,9,rep,name=sessions,proto3" json:"sessions,omitempty"` + // Most recent inventory snapshot. May be empty for players without + // a recorded snapshot. + InventoryGametime int64 `protobuf:"varint,10,opt,name=inventory_gametime,json=inventoryGametime,proto3" json:"inventory_gametime,omitempty"` + Inventory []*PlayerInventoryItem `protobuf:"bytes,11,rep,name=inventory,proto3" json:"inventory,omitempty"` + // Last-known position. + LastPlayfield string `protobuf:"bytes,12,opt,name=last_playfield,json=lastPlayfield,proto3" json:"last_playfield,omitempty"` + LastX float64 `protobuf:"fixed64,13,opt,name=last_x,json=lastX,proto3" json:"last_x,omitempty"` + LastY float64 `protobuf:"fixed64,14,opt,name=last_y,json=lastY,proto3" json:"last_y,omitempty"` + LastZ float64 `protobuf:"fixed64,15,opt,name=last_z,json=lastZ,proto3" json:"last_z,omitempty"` + Credits int64 `protobuf:"varint,16,opt,name=credits,proto3" json:"credits,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EmpyrionPlayerSummaryResult) Reset() { + *x = EmpyrionPlayerSummaryResult{} + mi := &file_panel_v1_agent_proto_msgTypes[67] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmpyrionPlayerSummaryResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmpyrionPlayerSummaryResult) ProtoMessage() {} + +func (x *EmpyrionPlayerSummaryResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[67] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmpyrionPlayerSummaryResult.ProtoReflect.Descriptor instead. +func (*EmpyrionPlayerSummaryResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{67} +} + +func (x *EmpyrionPlayerSummaryResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *EmpyrionPlayerSummaryResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *EmpyrionPlayerSummaryResult) GetSteamId() string { + if x != nil { + return x.SteamId + } + return "" +} + +func (x *EmpyrionPlayerSummaryResult) GetPlayerName() string { + if x != nil { + return x.PlayerName + } + return "" +} + +func (x *EmpyrionPlayerSummaryResult) GetEntityId() int64 { + if x != nil { + return x.EntityId + } + return 0 +} + +func (x *EmpyrionPlayerSummaryResult) GetFactionGroup() int32 { + if x != nil { + return x.FactionGroup + } + return 0 +} + +func (x *EmpyrionPlayerSummaryResult) GetFactionId() int32 { + if x != nil { + return x.FactionId + } + return 0 +} + +func (x *EmpyrionPlayerSummaryResult) GetStats() *PlayerStatsRow { + if x != nil { + return x.Stats + } + return nil +} + +func (x *EmpyrionPlayerSummaryResult) GetSessions() []*PlayerLoginRow { + if x != nil { + return x.Sessions + } + return nil +} + +func (x *EmpyrionPlayerSummaryResult) GetInventoryGametime() int64 { + if x != nil { + return x.InventoryGametime + } + return 0 +} + +func (x *EmpyrionPlayerSummaryResult) GetInventory() []*PlayerInventoryItem { + if x != nil { + return x.Inventory + } + return nil +} + +func (x *EmpyrionPlayerSummaryResult) GetLastPlayfield() string { + if x != nil { + return x.LastPlayfield + } + return "" +} + +func (x *EmpyrionPlayerSummaryResult) GetLastX() float64 { + if x != nil { + return x.LastX + } + return 0 +} + +func (x *EmpyrionPlayerSummaryResult) GetLastY() float64 { + if x != nil { + return x.LastY + } + return 0 +} + +func (x *EmpyrionPlayerSummaryResult) GetLastZ() float64 { + if x != nil { + return x.LastZ + } + return 0 +} + +func (x *EmpyrionPlayerSummaryResult) GetCredits() int64 { + if x != nil { + return x.Credits + } + return 0 +} + +// RegionScanRequest asks the agent to scan an instance's active-world Region +// directory for corruption evidence (error_backup salvage files + invalid +// region files). +type RegionScanRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegionScanRequest) Reset() { + *x = RegionScanRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegionScanRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegionScanRequest) ProtoMessage() {} + +func (x *RegionScanRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[68] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegionScanRequest.ProtoReflect.Descriptor instead. +func (*RegionScanRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{68} +} + +func (x *RegionScanRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +// AffectedRegionMsg is one region flagged by a scan. +type AffectedRegionMsg struct { + state protoimpl.MessageState `protogen:"open.v1"` + Region string `protobuf:"bytes,1,opt,name=region,proto3" json:"region,omitempty"` // canonical id, e.g. "r.-2.0" + ErrorBackups int32 `protobuf:"varint,2,opt,name=error_backups,json=errorBackups,proto3" json:"error_backups,omitempty"` // salvage files whose chunks fall in this region + FilePresent bool `protobuf:"varint,3,opt,name=file_present,json=filePresent,proto3" json:"file_present,omitempty"` // r.X.Z.7rg exists on disk + FileCorrupt bool `protobuf:"varint,4,opt,name=file_corrupt,json=fileCorrupt,proto3" json:"file_corrupt,omitempty"` // region file failed structural validation + BadChunks int32 `protobuf:"varint,5,opt,name=bad_chunks,json=badChunks,proto3" json:"bad_chunks,omitempty"` // count of invalid chunks if validated + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AffectedRegionMsg) Reset() { + *x = AffectedRegionMsg{} + mi := &file_panel_v1_agent_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AffectedRegionMsg) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AffectedRegionMsg) ProtoMessage() {} + +func (x *AffectedRegionMsg) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[69] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AffectedRegionMsg.ProtoReflect.Descriptor instead. +func (*AffectedRegionMsg) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{69} +} + +func (x *AffectedRegionMsg) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *AffectedRegionMsg) GetErrorBackups() int32 { + if x != nil { + return x.ErrorBackups + } + return 0 +} + +func (x *AffectedRegionMsg) GetFilePresent() bool { + if x != nil { + return x.FilePresent + } + return false +} + +func (x *AffectedRegionMsg) GetFileCorrupt() bool { + if x != nil { + return x.FileCorrupt + } + return false +} + +func (x *AffectedRegionMsg) GetBadChunks() int32 { + if x != nil { + return x.BadChunks + } + return 0 +} + +type RegionScanResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + ErrorBackups int32 `protobuf:"varint,1,opt,name=error_backups,json=errorBackups,proto3" json:"error_backups,omitempty"` // total salvage files found + Affected []*AffectedRegionMsg `protobuf:"bytes,2,rep,name=affected,proto3" json:"affected,omitempty"` + RegionDir string `protobuf:"bytes,3,opt,name=region_dir,json=regionDir,proto3" json:"region_dir,omitempty"` // resolved active Region dir (container path) + Error *Error `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegionScanResult) Reset() { + *x = RegionScanResult{} + mi := &file_panel_v1_agent_proto_msgTypes[70] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegionScanResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegionScanResult) ProtoMessage() {} + +func (x *RegionScanResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[70] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegionScanResult.ProtoReflect.Descriptor instead. +func (*RegionScanResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{70} +} + +func (x *RegionScanResult) GetErrorBackups() int32 { + if x != nil { + return x.ErrorBackups + } + return 0 +} + +func (x *RegionScanResult) GetAffected() []*AffectedRegionMsg { + if x != nil { + return x.Affected + } + return nil +} + +func (x *RegionScanResult) GetRegionDir() string { + if x != nil { + return x.RegionDir + } + return "" +} + +func (x *RegionScanResult) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +// RegionHealRequest asks the agent to heal one region from the newest clean +// backup. The agent stops the instance (graceful save first), swaps the region +// file in place, quarantines the region's error_backups, then restarts (if +// restart=true). +type RegionHealRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Region string `protobuf:"bytes,2,opt,name=region,proto3" json:"region,omitempty"` // "r.-2.0" + Restart bool `protobuf:"varint,3,opt,name=restart,proto3" json:"restart,omitempty"` // restart the instance after healing + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegionHealRequest) Reset() { + *x = RegionHealRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegionHealRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegionHealRequest) ProtoMessage() {} + +func (x *RegionHealRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[71] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegionHealRequest.ProtoReflect.Descriptor instead. +func (*RegionHealRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{71} +} + +func (x *RegionHealRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *RegionHealRequest) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *RegionHealRequest) GetRestart() bool { + if x != nil { + return x.Restart + } + return false +} + +type RegionHealResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + Region string `protobuf:"bytes,1,opt,name=region,proto3" json:"region,omitempty"` + SourceBackup string `protobuf:"bytes,2,opt,name=source_backup,json=sourceBackup,proto3" json:"source_backup,omitempty"` // backup id the clean copy came from + BytesWritten int64 `protobuf:"varint,3,opt,name=bytes_written,json=bytesWritten,proto3" json:"bytes_written,omitempty"` + Quarantined int32 `protobuf:"varint,4,opt,name=quarantined,proto3" json:"quarantined,omitempty"` // error_backup files moved aside + SnapshotPath string `protobuf:"bytes,5,opt,name=snapshot_path,json=snapshotPath,proto3" json:"snapshot_path,omitempty"` // where the corrupt original was saved + Healed bool `protobuf:"varint,6,opt,name=healed,proto3" json:"healed,omitempty"` + Error *Error `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RegionHealResult) Reset() { + *x = RegionHealResult{} + mi := &file_panel_v1_agent_proto_msgTypes[72] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RegionHealResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RegionHealResult) ProtoMessage() {} + +func (x *RegionHealResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[72] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RegionHealResult.ProtoReflect.Descriptor instead. +func (*RegionHealResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{72} +} + +func (x *RegionHealResult) GetRegion() string { + if x != nil { + return x.Region + } + return "" +} + +func (x *RegionHealResult) GetSourceBackup() string { + if x != nil { + return x.SourceBackup + } + return "" +} + +func (x *RegionHealResult) GetBytesWritten() int64 { + if x != nil { + return x.BytesWritten + } + return 0 +} + +func (x *RegionHealResult) GetQuarantined() int32 { + if x != nil { + return x.Quarantined + } + return 0 +} + +func (x *RegionHealResult) GetSnapshotPath() string { + if x != nil { + return x.SnapshotPath + } + return "" +} + +func (x *RegionHealResult) GetHealed() bool { + if x != nil { + return x.Healed + } + return false +} + +func (x *RegionHealResult) GetError() *Error { + if x != nil { + return x.Error + } + return nil +} + +// InstanceRenderConfigRequest asks the agent to re-render the module's +// declared config_files (templates) into the instance's host DataPath, +// WITHOUT recreating or restarting the container. For modules whose +// rendered output is bind-mounted into the container (7dtd's +// serverconfig.xml.rendered), the entrypoint picks the new content up on +// the instance's NEXT boot — this is the durable, no-recreate config path. +// +// config_values is the FULL desired value map (the controller sends its +// merged DB row, not a delta), mirroring what InstanceCreate would render. +type InstanceRenderConfigRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + ConfigValues map[string]string `protobuf:"bytes,2,rep,name=config_values,json=configValues,proto3" json:"config_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceRenderConfigRequest) Reset() { + *x = InstanceRenderConfigRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[73] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceRenderConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceRenderConfigRequest) ProtoMessage() {} + +func (x *InstanceRenderConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[73] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceRenderConfigRequest.ProtoReflect.Descriptor instead. +func (*InstanceRenderConfigRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{73} +} + +func (x *InstanceRenderConfigRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *InstanceRenderConfigRequest) GetConfigValues() map[string]string { + if x != nil { + return x.ConfigValues + } + return nil +} + +type InstanceRenderConfigResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + RenderedFiles []string `protobuf:"bytes,1,rep,name=rendered_files,json=renderedFiles,proto3" json:"rendered_files,omitempty"` // config_file paths written (manifest-relative) + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceRenderConfigResult) Reset() { + *x = InstanceRenderConfigResult{} + mi := &file_panel_v1_agent_proto_msgTypes[74] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceRenderConfigResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceRenderConfigResult) ProtoMessage() {} + +func (x *InstanceRenderConfigResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[74] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceRenderConfigResult.ProtoReflect.Descriptor instead. +func (*InstanceRenderConfigResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{74} +} + +func (x *InstanceRenderConfigResult) GetRenderedFiles() []string { + if x != nil { + return x.RenderedFiles + } + return nil +} + +func (x *InstanceRenderConfigResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// SeedModsRequest asks an agent to clone the Mods/ folder of src_instance_id +// (the cluster master) onto instance_id (a member), live — no container +// recreate. Per-server state (RefugeBot homes.json) is excluded from the copy +// and the member's own homes.json is preserved. The member keeps running on +// its currently-loaded mods; the new files take effect on its NEXT restart. +// Both instances must live on the same agent (the sidecar mounts local volumes). +type SeedModsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` // destination member to receive the mods + SrcInstanceId string `protobuf:"bytes,2,opt,name=src_instance_id,json=srcInstanceId,proto3" json:"src_instance_id,omitempty"` // source cluster master to copy mods from + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SeedModsRequest) Reset() { + *x = SeedModsRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[75] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SeedModsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SeedModsRequest) ProtoMessage() {} + +func (x *SeedModsRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[75] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SeedModsRequest.ProtoReflect.Descriptor instead. +func (*SeedModsRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{75} +} + +func (x *SeedModsRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *SeedModsRequest) GetSrcInstanceId() string { + if x != nil { + return x.SrcInstanceId + } + return "" +} + +type SeedModsResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Ok bool `protobuf:"varint,2,opt,name=ok,proto3" json:"ok,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + ModCount int32 `protobuf:"varint,4,opt,name=mod_count,json=modCount,proto3" json:"mod_count,omitempty"` // number of mods present on the member after the copy + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SeedModsResult) Reset() { + *x = SeedModsResult{} + mi := &file_panel_v1_agent_proto_msgTypes[76] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SeedModsResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SeedModsResult) ProtoMessage() {} + +func (x *SeedModsResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[76] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SeedModsResult.ProtoReflect.Descriptor instead. +func (*SeedModsResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{76} +} + +func (x *SeedModsResult) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *SeedModsResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *SeedModsResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *SeedModsResult) GetModCount() int32 { + if x != nil { + return x.ModCount + } + return 0 +} + +type UpdateCheckRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + // provider_id selects the module update_provider (steamcmd kind) whose + // app_id/beta define what "installed vs latest" means. Empty = same + // default the Update RPC uses (first provider in the manifest). + ProviderId string `protobuf:"bytes,2,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + // refresh forces a fresh steamcmd app_info fetch, bypassing the + // agent-side 15-minute cache. + Refresh bool `protobuf:"varint,3,opt,name=refresh,proto3" json:"refresh,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateCheckRequest) Reset() { + *x = UpdateCheckRequest{} + mi := &file_panel_v1_agent_proto_msgTypes[77] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateCheckRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateCheckRequest) ProtoMessage() {} + +func (x *UpdateCheckRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[77] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateCheckRequest.ProtoReflect.Descriptor instead. +func (*UpdateCheckRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{77} +} + +func (x *UpdateCheckRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *UpdateCheckRequest) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *UpdateCheckRequest) GetRefresh() bool { + if x != nil { + return x.Refresh + } + return false +} + +type UpdateCheckResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + AppId string `protobuf:"bytes,2,opt,name=app_id,json=appId,proto3" json:"app_id,omitempty"` + Branch string `protobuf:"bytes,3,opt,name=branch,proto3" json:"branch,omitempty"` // Steam branch compared ("public" when no -beta) + InstalledBuildid string `protobuf:"bytes,4,opt,name=installed_buildid,json=installedBuildid,proto3" json:"installed_buildid,omitempty"` // from appmanifest ACF; "" if unreadable + LatestBuildid string `protobuf:"bytes,5,opt,name=latest_buildid,json=latestBuildid,proto3" json:"latest_buildid,omitempty"` // from app_info branches map; "" if fetch failed + UpdateAvailable bool `protobuf:"varint,6,opt,name=update_available,json=updateAvailable,proto3" json:"update_available,omitempty"` // installed != latest (both non-empty) + Cached bool `protobuf:"varint,7,opt,name=cached,proto3" json:"cached,omitempty"` // latest came from the agent-side cache + Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` // non-empty on failure; other fields best-effort + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateCheckResult) Reset() { + *x = UpdateCheckResult{} + mi := &file_panel_v1_agent_proto_msgTypes[78] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateCheckResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateCheckResult) ProtoMessage() {} + +func (x *UpdateCheckResult) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_agent_proto_msgTypes[78] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateCheckResult.ProtoReflect.Descriptor instead. +func (*UpdateCheckResult) Descriptor() ([]byte, []int) { + return file_panel_v1_agent_proto_rawDescGZIP(), []int{78} +} + +func (x *UpdateCheckResult) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *UpdateCheckResult) GetAppId() string { + if x != nil { + return x.AppId + } + return "" +} + +func (x *UpdateCheckResult) GetBranch() string { + if x != nil { + return x.Branch + } + return "" +} + +func (x *UpdateCheckResult) GetInstalledBuildid() string { + if x != nil { + return x.InstalledBuildid + } + return "" +} + +func (x *UpdateCheckResult) GetLatestBuildid() string { + if x != nil { + return x.LatestBuildid + } + return "" +} + +func (x *UpdateCheckResult) GetUpdateAvailable() bool { + if x != nil { + return x.UpdateAvailable + } + return false +} + +func (x *UpdateCheckResult) GetCached() bool { + if x != nil { + return x.Cached + } + return false +} + +func (x *UpdateCheckResult) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +var File_panel_v1_agent_proto protoreflect.FileDescriptor + +const file_panel_v1_agent_proto_rawDesc = "" + + "\n" + + "\x14panel/v1/agent.proto\x12\bpanel.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x15panel/v1/common.proto\"\xfa\x11\n" + + "\x12ControllerEnvelope\x12%\n" + + "\x0ecorrelation_id\x18\x01 \x01(\tR\rcorrelationId\x123\n" + + "\asent_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x121\n" + + "\x05hello\x18\n" + + " \x01(\v2\x19.panel.v1.ControllerHelloH\x00R\x05hello\x12$\n" + + "\x04ping\x18\v \x01(\v2\x0e.panel.v1.PingH\x00R\x04ping\x12+\n" + + "\x04exec\x18\x14 \x01(\v2\x15.panel.v1.ExecRequestH\x00R\x04exec\x12C\n" + + "\x0finstance_create\x18\x1e \x01(\v2\x18.panel.v1.InstanceCreateH\x00R\x0einstanceCreate\x12@\n" + + "\x0einstance_start\x18\x1f \x01(\v2\x17.panel.v1.InstanceStartH\x00R\rinstanceStart\x12=\n" + + "\rinstance_stop\x18 \x01(\v2\x16.panel.v1.InstanceStopH\x00R\finstanceStop\x12C\n" + + "\x0finstance_delete\x18! \x01(\v2\x18.panel.v1.InstanceDeleteH\x00R\x0einstanceDelete\x12+\n" + + "\x04rcon\x18( \x01(\v2\x15.panel.v1.RCONCommandH\x00R\x04rcon\x122\n" + + "\afs_list\x182 \x01(\v2\x17.panel.v1.FsListRequestH\x00R\x06fsList\x122\n" + + "\afs_read\x183 \x01(\v2\x17.panel.v1.FsReadRequestH\x00R\x06fsRead\x125\n" + + "\bfs_write\x184 \x01(\v2\x18.panel.v1.FsWriteRequestH\x00R\afsWrite\x128\n" + + "\tfs_delete\x185 \x01(\v2\x19.panel.v1.FsDeleteRequestH\x00R\bfsDelete\x12;\n" + + "\n" + + "fs_symlink\x186 \x01(\v2\x1a.panel.v1.FsSymlinkRequestH\x00R\tfsSymlink\x12;\n" + + "\n" + + "fs_extract\x187 \x01(\v2\x1a.panel.v1.FsExtractRequestH\x00R\tfsExtract\x12>\n" + + "\vfs_compress\x188 \x01(\v2\x1b.panel.v1.FsCompressRequestH\x00R\n" + + "fsCompress\x128\n" + + "\tfs_rename\x189 \x01(\v2\x19.panel.v1.FsRenameRequestH\x00R\bfsRename\x12E\n" + + "\x0efs_write_chunk\x18: \x01(\v2\x1d.panel.v1.FsWriteChunkRequestH\x00R\ffsWriteChunk\x12K\n" + + "\x10ark_save_restore\x18; \x01(\v2\x1f.panel.v1.ArkSaveRestoreRequestH\x00R\x0earkSaveRestore\x12K\n" + + "\x10dayz_mod_install\x18Z \x01(\v2\x1f.panel.v1.DayzModInstallRequestH\x00R\x0edayzModInstall\x12Q\n" + + "\x12dayz_mod_uninstall\x18[ \x01(\v2!.panel.v1.DayzModUninstallRequestH\x00R\x10dayzModUninstall\x121\n" + + "\x06update\x18< \x01(\v2\x17.panel.v1.UpdateRequestH\x00R\x06update\x121\n" + + "\x06backup\x18F \x01(\v2\x17.panel.v1.BackupRequestH\x00R\x06backup\x124\n" + + "\arestore\x18G \x01(\v2\x18.panel.v1.RestoreRequestH\x00R\arestore\x12T\n" + + "\x13backup_list_entries\x18H \x01(\v2\".panel.v1.BackupListEntriesRequestH\x00R\x11backupListEntries\x12K\n" + + "\x10backup_read_file\x18I \x01(\v2\x1f.panel.v1.BackupReadFileRequestH\x00R\x0ebackupReadFile\x12f\n" + + "\x19empyrion_scenario_install\x18d \x01(\v2(.panel.v1.EmpyrionScenarioInstallRequestH\x00R\x17empyrionScenarioInstall\x12Y\n" + + "\x14empyrion_discoveries\x18e \x01(\v2$.panel.v1.EmpyrionDiscoveriesRequestH\x00R\x13empyrionDiscoveries\x12`\n" + + "\x17empyrion_player_summary\x18f \x01(\v2&.panel.v1.EmpyrionPlayerSummaryRequestH\x00R\x15empyrionPlayerSummary\x12>\n" + + "\vregion_scan\x18n \x01(\v2\x1b.panel.v1.RegionScanRequestH\x00R\n" + + "regionScan\x12>\n" + + "\vregion_heal\x18o \x01(\v2\x1b.panel.v1.RegionHealRequestH\x00R\n" + + "regionHeal\x12]\n" + + "\x16instance_render_config\x18p \x01(\v2%.panel.v1.InstanceRenderConfigRequestH\x00R\x14instanceRenderConfig\x128\n" + + "\tseed_mods\x18q \x01(\v2\x19.panel.v1.SeedModsRequestH\x00R\bseedMods\x12A\n" + + "\fupdate_check\x18r \x01(\v2\x1c.panel.v1.UpdateCheckRequestH\x00R\vupdateCheckB\t\n" + + "\apayload\"\xf9\x14\n" + + "\rAgentEnvelope\x12%\n" + + "\x0ecorrelation_id\x18\x01 \x01(\tR\rcorrelationId\x123\n" + + "\asent_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x06sentAt\x12,\n" + + "\x05hello\x18\n" + + " \x01(\v2\x14.panel.v1.AgentHelloH\x00R\x05hello\x123\n" + + "\theartbeat\x18\v \x01(\v2\x13.panel.v1.HeartbeatH\x00R\theartbeat\x12$\n" + + "\x04pong\x18\f \x01(\v2\x0e.panel.v1.PongH\x00R\x04pong\x121\n" + + "\x06result\x18\x14 \x01(\v2\x17.panel.v1.CommandResultH\x00R\x06result\x12F\n" + + "\x0einstance_state\x18\x1e \x01(\v2\x1d.panel.v1.InstanceStateUpdateH\x00R\rinstanceState\x127\n" + + "\tapp_state\x18\x1f \x01(\v2\x18.panel.v1.AppStateUpdateH\x00R\bappState\x12%\n" + + "\x03log\x18 \x01(\v2\x11.panel.v1.LogLineH\x00R\x03log\x12/\n" + + "\x06player\x18! \x01(\v2\x15.panel.v1.PlayerEventH\x00R\x06player\x127\n" + + "\vrcon_result\x18( \x01(\v2\x14.panel.v1.RCONResultH\x00R\n" + + "rconResult\x12>\n" + + "\x0efs_list_result\x182 \x01(\v2\x16.panel.v1.FsListResultH\x00R\ffsListResult\x12>\n" + + "\x0efs_read_result\x183 \x01(\v2\x16.panel.v1.FsReadResultH\x00R\ffsReadResult\x12A\n" + + "\x0ffs_write_result\x184 \x01(\v2\x17.panel.v1.FsWriteResultH\x00R\rfsWriteResult\x12D\n" + + "\x10fs_delete_result\x185 \x01(\v2\x18.panel.v1.FsDeleteResultH\x00R\x0efsDeleteResult\x12G\n" + + "\x11fs_symlink_result\x186 \x01(\v2\x19.panel.v1.FsSymlinkResultH\x00R\x0ffsSymlinkResult\x12G\n" + + "\x11fs_extract_result\x187 \x01(\v2\x19.panel.v1.FsExtractResultH\x00R\x0ffsExtractResult\x12J\n" + + "\x12fs_compress_result\x188 \x01(\v2\x1a.panel.v1.FsCompressResultH\x00R\x10fsCompressResult\x12D\n" + + "\x10fs_rename_result\x189 \x01(\v2\x18.panel.v1.FsRenameResultH\x00R\x0efsRenameResult\x12Q\n" + + "\x15fs_write_chunk_result\x18: \x01(\v2\x1c.panel.v1.FsWriteChunkResultH\x00R\x12fsWriteChunkResult\x12W\n" + + "\x17ark_save_restore_result\x18; \x01(\v2\x1e.panel.v1.ArkSaveRestoreResultH\x00R\x14arkSaveRestoreResult\x12W\n" + + "\x17dayz_mod_install_result\x18Z \x01(\v2\x1e.panel.v1.DayzModInstallResultH\x00R\x14dayzModInstallResult\x12]\n" + + "\x19dayz_mod_uninstall_result\x18[ \x01(\v2 .panel.v1.DayzModUninstallResultH\x00R\x16dayzModUninstallResult\x12=\n" + + "\rupdate_result\x18< \x01(\v2\x16.panel.v1.UpdateResultH\x00R\fupdateResult\x12=\n" + + "\rbackup_result\x18F \x01(\v2\x16.panel.v1.BackupResultH\x00R\fbackupResult\x12@\n" + + "\x0erestore_result\x18G \x01(\v2\x17.panel.v1.RestoreResultH\x00R\rrestoreResult\x12`\n" + + "\x1abackup_list_entries_result\x18H \x01(\v2!.panel.v1.BackupListEntriesResultH\x00R\x17backupListEntriesResult\x12W\n" + + "\x17backup_read_file_result\x18I \x01(\v2\x1e.panel.v1.BackupReadFileResultH\x00R\x14backupReadFileResult\x12F\n" + + "\x0einstance_stats\x18P \x01(\v2\x1d.panel.v1.InstanceStatsUpdateH\x00R\rinstanceStats\x12r\n" + + " empyrion_scenario_install_result\x18d \x01(\v2'.panel.v1.EmpyrionScenarioInstallResultH\x00R\x1dempyrionScenarioInstallResult\x12e\n" + + "\x1bempyrion_discoveries_result\x18e \x01(\v2#.panel.v1.EmpyrionDiscoveriesResultH\x00R\x19empyrionDiscoveriesResult\x12l\n" + + "\x1eempyrion_player_summary_result\x18f \x01(\v2%.panel.v1.EmpyrionPlayerSummaryResultH\x00R\x1bempyrionPlayerSummaryResult\x12J\n" + + "\x12region_scan_result\x18n \x01(\v2\x1a.panel.v1.RegionScanResultH\x00R\x10regionScanResult\x12J\n" + + "\x12region_heal_result\x18o \x01(\v2\x1a.panel.v1.RegionHealResultH\x00R\x10regionHealResult\x12i\n" + + "\x1dinstance_render_config_result\x18p \x01(\v2$.panel.v1.InstanceRenderConfigResultH\x00R\x1ainstanceRenderConfigResult\x12D\n" + + "\x10seed_mods_result\x18q \x01(\v2\x18.panel.v1.SeedModsResultH\x00R\x0eseedModsResult\x12M\n" + + "\x13update_check_result\x18r \x01(\v2\x1b.panel.v1.UpdateCheckResultH\x00R\x11updateCheckResultB\t\n" + + "\apayload\"\x80\x02\n" + + "\n" + + "AgentHello\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12#\n" + + "\ragent_version\x18\x02 \x01(\tR\fagentVersion\x12\x17\n" + + "\ahost_os\x18\x03 \x01(\tR\x06hostOs\x12\x1b\n" + + "\thost_arch\x18\x04 \x01(\tR\bhostArch\x12\x1a\n" + + "\bhostname\x18\x05 \x01(\tR\bhostname\x12-\n" + + "\x12supported_runtimes\x18\x06 \x03(\tR\x11supportedRuntimes\x121\n" + + "\amodules\x18\a \x03(\v2\x17.panel.v1.ModuleSummaryR\amodules\"\xfd\x01\n" + + "\rModuleSummary\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversion\x12'\n" + + "\x0fsupported_modes\x18\x04 \x03(\tR\x0esupportedModes\x12P\n" + + "\x10update_providers\x18\x05 \x03(\v2%.panel.v1.ModuleUpdateProviderSummaryR\x0fupdateProviders\x12\x19\n" + + "\bhas_rcon\x18\x06 \x01(\bR\ahasRcon\x12\x18\n" + + "\aauthors\x18\a \x03(\tR\aauthors\"s\n" + + "\x1bModuleUpdateProviderSummary\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x120\n" + + "\x14requires_steam_login\x18\x03 \x01(\bR\x12requiresSteamLogin\"}\n" + + "\x0fControllerHello\x12-\n" + + "\x12controller_version\x18\x01 \x01(\tR\x11controllerVersion\x12;\n" + + "\vserver_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" + + "serverTime\"\xcd\x01\n" + + "\tHeartbeat\x12*\n" + + "\x02at\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\x12\x1f\n" + + "\vcpu_percent\x18\x02 \x01(\x01R\n" + + "cpuPercent\x12$\n" + + "\x0emem_used_bytes\x18\x03 \x01(\x04R\fmemUsedBytes\x12&\n" + + "\x0fmem_total_bytes\x18\x04 \x01(\x04R\rmemTotalBytes\x12%\n" + + "\x0einstance_count\x18\x05 \x01(\rR\rinstanceCount\"\x1c\n" + + "\x04Ping\x12\x14\n" + + "\x05nonce\x18\x01 \x01(\tR\x05nonce\"\x1c\n" + + "\x04Pong\x12\x14\n" + + "\x05nonce\x18\x01 \x01(\tR\x05nonce\"\xf6\x01\n" + + "\vExecRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x12\n" + + "\x04argv\x18\x02 \x03(\tR\x04argv\x120\n" + + "\x03env\x18\x03 \x03(\v2\x1e.panel.v1.ExecRequest.EnvEntryR\x03env\x12\x1f\n" + + "\vworking_dir\x18\x04 \x01(\tR\n" + + "workingDir\x12'\n" + + "\x0ftimeout_seconds\x18\x05 \x01(\rR\x0etimeoutSeconds\x1a6\n" + + "\bEnvEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x83\x01\n" + + "\rCommandResult\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x16\n" + + "\x06stdout\x18\x02 \x01(\fR\x06stdout\x12\x16\n" + + "\x06stderr\x18\x03 \x01(\fR\x06stderr\x12%\n" + + "\x05error\x18\x04 \x01(\v2\x0f.panel.v1.ErrorR\x05error\"\xba\x04\n" + + "\x0eInstanceCreate\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tmodule_id\x18\x02 \x01(\tR\bmoduleId\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversion\x12,\n" + + "\brun_mode\x18\x04 \x01(\x0e2\x11.panel.v1.RunModeR\arunMode\x120\n" + + "\x06limits\x18\x05 \x01(\v2\x18.panel.v1.ResourceLimitsR\x06limits\x12O\n" + + "\rconfig_values\x18\x06 \x03(\v2*.panel.v1.InstanceCreate.ConfigValuesEntryR\fconfigValues\x12'\n" + + "\x05ports\x18\a \x03(\v2\x11.panel.v1.PortMapR\x05ports\x12\x1b\n" + + "\tdata_path\x18\b \x01(\tR\bdataPath\x12U\n" + + "\x0fmount_overrides\x18\t \x03(\v2,.panel.v1.InstanceCreate.MountOverridesEntryR\x0emountOverrides\x1a?\n" + + "\x11ConfigValuesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aA\n" + + "\x13MountOverridesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"0\n" + + "\rInstanceStart\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\"j\n" + + "\fInstanceStop\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12#\n" + + "\rgrace_seconds\x18\x02 \x01(\rR\fgraceSeconds\x12\x14\n" + + "\x05force\x18\x03 \x01(\bR\x05force\"{\n" + + "\x0eInstanceDelete\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12#\n" + + "\rgrace_seconds\x18\x02 \x01(\rR\fgraceSeconds\x12#\n" + + "\rpurge_volumes\x18\x03 \x01(\bR\fpurgeVolumes\"\xc9\x01\n" + + "\x13InstanceStateUpdate\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x120\n" + + "\x06status\x18\x02 \x01(\x0e2\x18.panel.v1.InstanceStatusR\x06status\x12\x1b\n" + + "\texit_code\x18\x03 \x01(\x05R\bexitCode\x12*\n" + + "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\x12\x16\n" + + "\x06detail\x18\x05 \x01(\tR\x06detail\"\xde\x02\n" + + "\x0eAppStateUpdate\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12%\n" + + "\x0eplayers_online\x18\x02 \x01(\x05R\rplayersOnline\x12\x1f\n" + + "\vplayers_max\x18\x03 \x01(\x05R\n" + + "playersMax\x12%\n" + + "\x0euptime_seconds\x18\x04 \x01(\x03R\ruptimeSeconds\x12O\n" + + "\rmodule_fields\x18\n" + + " \x03(\v2*.panel.v1.AppStateUpdate.ModuleFieldsEntryR\fmoduleFields\x12*\n" + + "\x02at\x18\x14 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\x1a?\n" + + "\x11ModuleFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x82\x01\n" + + "\aLogLine\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x16\n" + + "\x06stream\x18\x02 \x01(\tR\x06stream\x12*\n" + + "\x02at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\x12\x12\n" + + "\x04line\x18\x04 \x01(\tR\x04line\"\xc6\x02\n" + + "\x13InstanceStatsUpdate\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1f\n" + + "\vcpu_percent\x18\x02 \x01(\x01R\n" + + "cpuPercent\x12$\n" + + "\x0emem_used_bytes\x18\x03 \x01(\x04R\fmemUsedBytes\x12&\n" + + "\x0fmem_limit_bytes\x18\x04 \x01(\x04R\rmemLimitBytes\x12 \n" + + "\fnet_rx_bytes\x18\x05 \x01(\x04R\n" + + "netRxBytes\x12 \n" + + "\fnet_tx_bytes\x18\x06 \x01(\x04R\n" + + "netTxBytes\x12\x12\n" + + "\x04pids\x18\a \x01(\rR\x04pids\x12*\n" + + "\x02at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\x02at\x12\x1b\n" + + "\thost_cpus\x18\t \x01(\rR\bhostCpus\"H\n" + + "\vRCONCommand\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x18\n" + + "\acommand\x18\x02 \x01(\tR\acommand\":\n" + + "\n" + + "RCONResult\x12\x16\n" + + "\x06output\x18\x01 \x01(\tR\x06output\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x93\x01\n" + + "\aFsEntry\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x15\n" + + "\x06is_dir\x18\x03 \x01(\bR\x05isDir\x12\x12\n" + + "\x04size\x18\x04 \x01(\x03R\x04size\x125\n" + + "\bmod_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\amodTime\"D\n" + + "\rFsListRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"e\n" + + "\fFsListResult\x12+\n" + + "\aentries\x18\x01 \x03(\v2\x11.panel.v1.FsEntryR\aentries\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"D\n" + + "\rFsReadRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"R\n" + + "\fFsReadResult\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x18\n" + + "\acontent\x18\x02 \x01(\fR\acontent\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"_\n" + + "\x0eFsWriteRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + + "\acontent\x18\x03 \x01(\fR\acontent\"J\n" + + "\rFsWriteResult\x12#\n" + + "\rbytes_written\x18\x01 \x01(\x03R\fbytesWritten\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"d\n" + + "\x0fFsDeleteRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x03 \x01(\bR\trecursive\"&\n" + + "\x0eFsDeleteResult\x12\x14\n" + + "\x05error\x18\x01 \x01(\tR\x05error\"h\n" + + "\x10FsSymlinkRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x16\n" + + "\x06target\x18\x02 \x01(\tR\x06target\x12\x1b\n" + + "\tlink_path\x18\x03 \x01(\tR\blinkPath\"'\n" + + "\x0fFsSymlinkResult\x12\x14\n" + + "\x05error\x18\x01 \x01(\tR\x05error\"\x8f\x01\n" + + "\x10FsExtractRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12!\n" + + "\farchive_path\x18\x02 \x01(\tR\varchivePath\x12\x19\n" + + "\bdest_dir\x18\x03 \x01(\tR\adestDir\x12\x1c\n" + + "\toverwrite\x18\x04 \x01(\bR\toverwrite\"u\n" + + "\x0fFsExtractResult\x12'\n" + + "\x0fentries_written\x18\x01 \x01(\x03R\x0eentriesWritten\x12#\n" + + "\rbytes_written\x18\x02 \x01(\x03R\fbytesWritten\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"\x90\x01\n" + + "\x11FsCompressRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x18\n" + + "\asources\x18\x02 \x03(\tR\asources\x12\"\n" + + "\rdest_zip_path\x18\x03 \x01(\tR\vdestZipPath\x12\x1c\n" + + "\toverwrite\x18\x04 \x01(\bR\toverwrite\"v\n" + + "\x10FsCompressResult\x12'\n" + + "\x0fentries_written\x18\x01 \x01(\x03R\x0eentriesWritten\x12#\n" + + "\rbytes_written\x18\x02 \x01(\x03R\fbytesWritten\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"h\n" + + "\x0fFsRenameRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tfrom_path\x18\x02 \x01(\tR\bfromPath\x12\x17\n" + + "\ato_path\x18\x03 \x01(\tR\x06toPath\"&\n" + + "\x0eFsRenameResult\x12\x14\n" + + "\x05error\x18\x01 \x01(\tR\x05error\"\xcd\x01\n" + + "\x13FsWriteChunkRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tupload_id\x18\x02 \x01(\tR\buploadId\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x16\n" + + "\x06offset\x18\x04 \x01(\x03R\x06offset\x12\x12\n" + + "\x04data\x18\x05 \x01(\fR\x04data\x12\x1d\n" + + "\n" + + "total_size\x18\x06 \x01(\x03R\ttotalSize\x12\x19\n" + + "\bis_final\x18\a \x01(\bR\aisFinal\"\x96\x01\n" + + "\x12FsWriteChunkResult\x12%\n" + + "\x0ebytes_received\x18\x01 \x01(\x03R\rbytesReceived\x12%\n" + + "\x0etotal_received\x18\x02 \x01(\x03R\rtotalReceived\x12\x1c\n" + + "\tfinalized\x18\x03 \x01(\bR\tfinalized\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"\x8d\x01\n" + + "\x15ArkSaveRestoreRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12*\n" + + "\x11saved_arks_subdir\x18\x02 \x01(\tR\x0fsavedArksSubdir\x12'\n" + + "\x0ftarget_filename\x18\x03 \x01(\tR\x0etargetFilename\"\x82\x01\n" + + "\x14ArkSaveRestoreResult\x12%\n" + + "\x0easide_filename\x18\x01 \x01(\tR\rasideFilename\x12-\n" + + "\x12installed_filename\x18\x02 \x01(\tR\x11installedFilename\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"z\n" + + "\x15DayzModInstallRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1f\n" + + "\vworkshop_id\x18\x02 \x01(\tR\n" + + "workshopId\x12\x1f\n" + + "\vfolder_name\x18\x03 \x01(\tR\n" + + "folderName\"}\n" + + "\x14DayzModInstallResult\x12\x14\n" + + "\x05error\x18\x01 \x01(\tR\x05error\x12#\n" + + "\rbikeys_copied\x18\x02 \x03(\tR\fbikeysCopied\x12*\n" + + "\x11resolved_mod_path\x18\x03 \x01(\tR\x0fresolvedModPath\"[\n" + + "\x17DayzModUninstallRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1f\n" + + "\vfolder_name\x18\x02 \x01(\tR\n" + + "folderName\"U\n" + + "\x16DayzModUninstallResult\x12\x14\n" + + "\x05error\x18\x01 \x01(\tR\x05error\x12%\n" + + "\x0ebikeys_removed\x18\x02 \x03(\tR\rbikeysRemoved\"\x9f\x01\n" + + "\rUpdateRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1f\n" + + "\vprovider_id\x18\x02 \x01(\tR\n" + + "providerId\x12%\n" + + "\x0esteam_username\x18\x03 \x01(\tR\rsteamUsername\x12%\n" + + "\x0esteam_password\x18\x04 \x01(\tR\rsteamPassword\"\x86\x01\n" + + "\fUpdateResult\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\x12\x1f\n" + + "\vprovider_id\x18\x03 \x01(\tR\n" + + "providerId\x12#\n" + + "\rprovider_kind\x18\x04 \x01(\tR\fproviderKind\"R\n" + + "\rBackupRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12 \n" + + "\vdescription\x18\x02 \x01(\tR\vdescription\"t\n" + + "\fBackupResult\x12\x1b\n" + + "\tbackup_id\x18\x01 \x01(\tR\bbackupId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x1d\n" + + "\n" + + "size_bytes\x18\x03 \x01(\x03R\tsizeBytes\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"o\n" + + "\x0eRestoreRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tbackup_id\x18\x02 \x01(\tR\bbackupId\x12\x1f\n" + + "\vbackup_path\x18\x03 \x01(\tR\n" + + "backupPath\"L\n" + + "\rRestoreResult\x12\x14\n" + + "\x05error\x18\x01 \x01(\tR\x05error\x12%\n" + + "\x0ebytes_restored\x18\x02 \x01(\x03R\rbytesRestored\"y\n" + + "\x18BackupListEntriesRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tbackup_id\x18\x02 \x01(\tR\bbackupId\x12\x1f\n" + + "\vbackup_path\x18\x03 \x01(\tR\n" + + "backupPath\"\x9a\x01\n" + + "\x0eBackupTarEntry\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x02 \x01(\x03R\x04size\x12\x15\n" + + "\x06is_dir\x18\x03 \x01(\bR\x05isDir\x12\x12\n" + + "\x04mode\x18\x04 \x01(\tR\x04mode\x125\n" + + "\bmod_time\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\amodTime\"\x84\x01\n" + + "\x17BackupListEntriesResult\x122\n" + + "\aentries\x18\x01 \x03(\v2\x18.panel.v1.BackupTarEntryR\aentries\x12\x1f\n" + + "\vtotal_bytes\x18\x02 \x01(\x03R\n" + + "totalBytes\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"\xb2\x01\n" + + "\x15BackupReadFileRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tbackup_id\x18\x02 \x01(\tR\bbackupId\x12\x1f\n" + + "\vbackup_path\x18\x03 \x01(\tR\n" + + "backupPath\x12\x1d\n" + + "\n" + + "entry_path\x18\x04 \x01(\tR\tentryPath\x12\x1b\n" + + "\tmax_bytes\x18\x05 \x01(\x03R\bmaxBytes\"\x95\x01\n" + + "\x14BackupReadFileResult\x12\x18\n" + + "\acontent\x18\x01 \x01(\fR\acontent\x12\x12\n" + + "\x04size\x18\x02 \x01(\x03R\x04size\x12\x1c\n" + + "\ttruncated\x18\x03 \x01(\bR\ttruncated\x12\x1b\n" + + "\tis_binary\x18\x04 \x01(\bR\bisBinary\x12\x14\n" + + "\x05error\x18\x05 \x01(\tR\x05error\"\xcd\x02\n" + + "\vPlayerEvent\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12.\n" + + "\x04kind\x18\x02 \x01(\x0e2\x1a.panel.v1.PlayerEvent.KindR\x04kind\x12\x1b\n" + + "\tplayer_id\x18\x03 \x01(\tR\bplayerId\x12\x1f\n" + + "\vplayer_name\x18\x04 \x01(\tR\n" + + "playerName\x12\x16\n" + + "\x06detail\x18\x05 \x01(\tR\x06detail\x12*\n" + + "\x02at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\"k\n" + + "\x04Kind\x12\x14\n" + + "\x10KIND_UNSPECIFIED\x10\x00\x12\r\n" + + "\tKIND_JOIN\x10\x01\x12\x0e\n" + + "\n" + + "KIND_LEAVE\x10\x02\x12\r\n" + + "\tKIND_CHAT\x10\x03\x12\x0e\n" + + "\n" + + "KIND_DEATH\x10\x04\x12\x0f\n" + + "\vKIND_CUSTOM\x10c\"\x83\x02\n" + + "\x1eEmpyrionScenarioInstallRequest\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12\x1f\n" + + "\vworkshop_id\x18\x03 \x01(\tR\n" + + "workshopId\x12#\n" + + "\rscenario_name\x18\x04 \x01(\tR\fscenarioName\x12\x15\n" + + "\x06app_id\x18\x05 \x01(\tR\x05appId\x12%\n" + + "\x0esteam_username\x18\x06 \x01(\tR\rsteamUsername\x12%\n" + + "\x0esteam_password\x18\a \x01(\tR\rsteamPassword\"\x81\x01\n" + + "\x1dEmpyrionScenarioInstallResult\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x0e\n" + + "\x02ok\x18\x02 \x01(\bR\x02ok\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12#\n" + + "\rscenario_name\x18\x04 \x01(\tR\fscenarioName\"l\n" + + "\x1aEmpyrionDiscoveriesRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x19\n" + + "\bsteam_id\x18\x02 \x01(\tR\asteamId\x12\x12\n" + + "\x04mode\x18\x03 \x01(\tR\x04mode\"\xc0\x02\n" + + "\rDiscoveredPOI\x12!\n" + + "\fsolar_system\x18\x01 \x01(\tR\vsolarSystem\x12\x1c\n" + + "\tplayfield\x18\x02 \x01(\tR\tplayfield\x12\x19\n" + + "\bpoi_name\x18\x03 \x01(\tR\apoiName\x12\x19\n" + + "\bpoi_type\x18\x04 \x01(\x05R\apoiType\x12\x1b\n" + + "\tgame_time\x18\x05 \x01(\x03R\bgameTime\x12'\n" + + "\x0fdiscoverer_name\x18\x06 \x01(\tR\x0ediscovererName\x12.\n" + + "\x13discoverer_steam_id\x18\a \x01(\tR\x11discovererSteamId\x12#\n" + + "\rfaction_group\x18\b \x01(\x05R\ffactionGroup\x12\x1d\n" + + "\n" + + "faction_id\x18\t \x01(\x05R\tfactionId\"\xbe\x01\n" + + "\x19EmpyrionDiscoveriesResult\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\x12+\n" + + "\x04pois\x18\x03 \x03(\v2\x17.panel.v1.DiscoveredPOIR\x04pois\x12\x12\n" + + "\x04mode\x18\x04 \x01(\tR\x04mode\x12\x19\n" + + "\bsteam_id\x18\x05 \x01(\tR\asteamId\x12\x1f\n" + + "\vplayer_name\x18\x06 \x01(\tR\n" + + "playerName\"\x80\x01\n" + + "\x1cEmpyrionPlayerSummaryRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x19\n" + + "\bsteam_id\x18\x02 \x01(\tR\asteamId\x12$\n" + + "\x0emax_login_rows\x18\x03 \x01(\x05R\fmaxLoginRows\"\x86\x05\n" + + "\x0ePlayerStatsRow\x12%\n" + + "\x0ekilled_enemies\x18\x01 \x01(\x03R\rkilledEnemies\x12#\n" + + "\rkilled_allied\x18\x02 \x01(\x03R\fkilledAllied\x12%\n" + + "\x0ekilled_animals\x18\x03 \x01(\x03R\rkilledAnimals\x12#\n" + + "\rkilled_drones\x18\x04 \x01(\x03R\fkilledDrones\x12%\n" + + "\x0ekilled_players\x18\x05 \x01(\x03R\rkilledPlayers\x122\n" + + "\x15killed_allied_players\x18\x06 \x01(\x03R\x13killedAlliedPlayers\x12\x12\n" + + "\x04died\x18\a \x01(\x03R\x04died\x12\x14\n" + + "\x05score\x18\b \x01(\x03R\x05score\x12#\n" + + "\rblocks_placed\x18\t \x01(\x03R\fblocksPlaced\x12#\n" + + "\rblocks_digged\x18\n" + + " \x01(\x03R\fblocksDigged\x12#\n" + + "\rwalked_meters\x18\v \x01(\x03R\fwalkedMeters\x12%\n" + + "\x0ejetpack_meters\x18\f \x01(\x03R\rjetpackMeters\x12\x1b\n" + + "\thv_meters\x18\r \x01(\x03R\bhvMeters\x12\x1b\n" + + "\tsv_meters\x18\x0e \x01(\x03R\bsvMeters\x12\x1b\n" + + "\tcv_meters\x18\x0f \x01(\x03R\bcvMeters\x12\x1a\n" + + "\bplaytime\x18\x10 \x01(\x01R\bplaytime\x12\x1f\n" + + "\vlight_years\x18\x11 \x01(\x01R\n" + + "lightYears\x12-\n" + + "\x12astronomical_units\x18\x12 \x01(\x01R\x11astronomicalUnits\"\xd4\x01\n" + + "\x0ePlayerLoginRow\x12\x1f\n" + + "\vlogin_ticks\x18\x01 \x01(\x03R\n" + + "loginTicks\x12!\n" + + "\flogoff_ticks\x18\x02 \x01(\x03R\vlogoffTicks\x12\x1f\n" + + "\vplayer_name\x18\x03 \x01(\tR\n" + + "playerName\x12\x19\n" + + "\bbuild_nr\x18\x04 \x01(\x03R\abuildNr\x12\x0e\n" + + "\x02ip\x18\x05 \x01(\tR\x02ip\x12\x0e\n" + + "\x02os\x18\x06 \x01(\tR\x02os\x12\x10\n" + + "\x03gfx\x18\a \x01(\tR\x03gfx\x12\x10\n" + + "\x03cpu\x18\b \x01(\tR\x03cpu\"D\n" + + "\x13PlayerInventoryItem\x12\x17\n" + + "\aitem_id\x18\x01 \x01(\x05R\x06itemId\x12\x14\n" + + "\x05count\x18\x02 \x01(\x05R\x05count\"\xb8\x04\n" + + "\x1bEmpyrionPlayerSummaryResult\x12\x0e\n" + + "\x02ok\x18\x01 \x01(\bR\x02ok\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\x12\x19\n" + + "\bsteam_id\x18\x03 \x01(\tR\asteamId\x12\x1f\n" + + "\vplayer_name\x18\x04 \x01(\tR\n" + + "playerName\x12\x1b\n" + + "\tentity_id\x18\x05 \x01(\x03R\bentityId\x12#\n" + + "\rfaction_group\x18\x06 \x01(\x05R\ffactionGroup\x12\x1d\n" + + "\n" + + "faction_id\x18\a \x01(\x05R\tfactionId\x12.\n" + + "\x05stats\x18\b \x01(\v2\x18.panel.v1.PlayerStatsRowR\x05stats\x124\n" + + "\bsessions\x18\t \x03(\v2\x18.panel.v1.PlayerLoginRowR\bsessions\x12-\n" + + "\x12inventory_gametime\x18\n" + + " \x01(\x03R\x11inventoryGametime\x12;\n" + + "\tinventory\x18\v \x03(\v2\x1d.panel.v1.PlayerInventoryItemR\tinventory\x12%\n" + + "\x0elast_playfield\x18\f \x01(\tR\rlastPlayfield\x12\x15\n" + + "\x06last_x\x18\r \x01(\x01R\x05lastX\x12\x15\n" + + "\x06last_y\x18\x0e \x01(\x01R\x05lastY\x12\x15\n" + + "\x06last_z\x18\x0f \x01(\x01R\x05lastZ\x12\x18\n" + + "\acredits\x18\x10 \x01(\x03R\acredits\"4\n" + + "\x11RegionScanRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\"\xb5\x01\n" + + "\x11AffectedRegionMsg\x12\x16\n" + + "\x06region\x18\x01 \x01(\tR\x06region\x12#\n" + + "\rerror_backups\x18\x02 \x01(\x05R\ferrorBackups\x12!\n" + + "\ffile_present\x18\x03 \x01(\bR\vfilePresent\x12!\n" + + "\ffile_corrupt\x18\x04 \x01(\bR\vfileCorrupt\x12\x1d\n" + + "\n" + + "bad_chunks\x18\x05 \x01(\x05R\tbadChunks\"\xb6\x01\n" + + "\x10RegionScanResult\x12#\n" + + "\rerror_backups\x18\x01 \x01(\x05R\ferrorBackups\x127\n" + + "\baffected\x18\x02 \x03(\v2\x1b.panel.v1.AffectedRegionMsgR\baffected\x12\x1d\n" + + "\n" + + "region_dir\x18\x03 \x01(\tR\tregionDir\x12%\n" + + "\x05error\x18\x04 \x01(\v2\x0f.panel.v1.ErrorR\x05error\"f\n" + + "\x11RegionHealRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x16\n" + + "\x06region\x18\x02 \x01(\tR\x06region\x12\x18\n" + + "\arestart\x18\x03 \x01(\bR\arestart\"\xfa\x01\n" + + "\x10RegionHealResult\x12\x16\n" + + "\x06region\x18\x01 \x01(\tR\x06region\x12#\n" + + "\rsource_backup\x18\x02 \x01(\tR\fsourceBackup\x12#\n" + + "\rbytes_written\x18\x03 \x01(\x03R\fbytesWritten\x12 \n" + + "\vquarantined\x18\x04 \x01(\x05R\vquarantined\x12#\n" + + "\rsnapshot_path\x18\x05 \x01(\tR\fsnapshotPath\x12\x16\n" + + "\x06healed\x18\x06 \x01(\bR\x06healed\x12%\n" + + "\x05error\x18\a \x01(\v2\x0f.panel.v1.ErrorR\x05error\"\xdd\x01\n" + + "\x1bInstanceRenderConfigRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\\\n" + + "\rconfig_values\x18\x02 \x03(\v27.panel.v1.InstanceRenderConfigRequest.ConfigValuesEntryR\fconfigValues\x1a?\n" + + "\x11ConfigValuesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"Y\n" + + "\x1aInstanceRenderConfigResult\x12%\n" + + "\x0erendered_files\x18\x01 \x03(\tR\rrenderedFiles\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"Z\n" + + "\x0fSeedModsRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12&\n" + + "\x0fsrc_instance_id\x18\x02 \x01(\tR\rsrcInstanceId\"t\n" + + "\x0eSeedModsResult\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x0e\n" + + "\x02ok\x18\x02 \x01(\bR\x02ok\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12\x1b\n" + + "\tmod_count\x18\x04 \x01(\x05R\bmodCount\"p\n" + + "\x12UpdateCheckRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x1f\n" + + "\vprovider_id\x18\x02 \x01(\tR\n" + + "providerId\x12\x18\n" + + "\arefresh\x18\x03 \x01(\bR\arefresh\"\x90\x02\n" + + "\x11UpdateCheckResult\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x15\n" + + "\x06app_id\x18\x02 \x01(\tR\x05appId\x12\x16\n" + + "\x06branch\x18\x03 \x01(\tR\x06branch\x12+\n" + + "\x11installed_buildid\x18\x04 \x01(\tR\x10installedBuildid\x12%\n" + + "\x0elatest_buildid\x18\x05 \x01(\tR\rlatestBuildid\x12)\n" + + "\x10update_available\x18\x06 \x01(\bR\x0fupdateAvailable\x12\x16\n" + + "\x06cached\x18\a \x01(\bR\x06cached\x12\x14\n" + + "\x05error\x18\b \x01(\tR\x05error2M\n" + + "\x05Agent\x12D\n" + + "\aConnect\x12\x17.panel.v1.AgentEnvelope\x1a\x1c.panel.v1.ControllerEnvelope(\x010\x01B2Z0github.com/dbledeez/panel/proto/panel/v1;panelv1b\x06proto3" + +var ( + file_panel_v1_agent_proto_rawDescOnce sync.Once + file_panel_v1_agent_proto_rawDescData []byte +) + +func file_panel_v1_agent_proto_rawDescGZIP() []byte { + file_panel_v1_agent_proto_rawDescOnce.Do(func() { + file_panel_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_panel_v1_agent_proto_rawDesc), len(file_panel_v1_agent_proto_rawDesc))) + }) + return file_panel_v1_agent_proto_rawDescData +} + +var file_panel_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_panel_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 84) +var file_panel_v1_agent_proto_goTypes = []any{ + (PlayerEvent_Kind)(0), // 0: panel.v1.PlayerEvent.Kind + (*ControllerEnvelope)(nil), // 1: panel.v1.ControllerEnvelope + (*AgentEnvelope)(nil), // 2: panel.v1.AgentEnvelope + (*AgentHello)(nil), // 3: panel.v1.AgentHello + (*ModuleSummary)(nil), // 4: panel.v1.ModuleSummary + (*ModuleUpdateProviderSummary)(nil), // 5: panel.v1.ModuleUpdateProviderSummary + (*ControllerHello)(nil), // 6: panel.v1.ControllerHello + (*Heartbeat)(nil), // 7: panel.v1.Heartbeat + (*Ping)(nil), // 8: panel.v1.Ping + (*Pong)(nil), // 9: panel.v1.Pong + (*ExecRequest)(nil), // 10: panel.v1.ExecRequest + (*CommandResult)(nil), // 11: panel.v1.CommandResult + (*InstanceCreate)(nil), // 12: panel.v1.InstanceCreate + (*InstanceStart)(nil), // 13: panel.v1.InstanceStart + (*InstanceStop)(nil), // 14: panel.v1.InstanceStop + (*InstanceDelete)(nil), // 15: panel.v1.InstanceDelete + (*InstanceStateUpdate)(nil), // 16: panel.v1.InstanceStateUpdate + (*AppStateUpdate)(nil), // 17: panel.v1.AppStateUpdate + (*LogLine)(nil), // 18: panel.v1.LogLine + (*InstanceStatsUpdate)(nil), // 19: panel.v1.InstanceStatsUpdate + (*RCONCommand)(nil), // 20: panel.v1.RCONCommand + (*RCONResult)(nil), // 21: panel.v1.RCONResult + (*FsEntry)(nil), // 22: panel.v1.FsEntry + (*FsListRequest)(nil), // 23: panel.v1.FsListRequest + (*FsListResult)(nil), // 24: panel.v1.FsListResult + (*FsReadRequest)(nil), // 25: panel.v1.FsReadRequest + (*FsReadResult)(nil), // 26: panel.v1.FsReadResult + (*FsWriteRequest)(nil), // 27: panel.v1.FsWriteRequest + (*FsWriteResult)(nil), // 28: panel.v1.FsWriteResult + (*FsDeleteRequest)(nil), // 29: panel.v1.FsDeleteRequest + (*FsDeleteResult)(nil), // 30: panel.v1.FsDeleteResult + (*FsSymlinkRequest)(nil), // 31: panel.v1.FsSymlinkRequest + (*FsSymlinkResult)(nil), // 32: panel.v1.FsSymlinkResult + (*FsExtractRequest)(nil), // 33: panel.v1.FsExtractRequest + (*FsExtractResult)(nil), // 34: panel.v1.FsExtractResult + (*FsCompressRequest)(nil), // 35: panel.v1.FsCompressRequest + (*FsCompressResult)(nil), // 36: panel.v1.FsCompressResult + (*FsRenameRequest)(nil), // 37: panel.v1.FsRenameRequest + (*FsRenameResult)(nil), // 38: panel.v1.FsRenameResult + (*FsWriteChunkRequest)(nil), // 39: panel.v1.FsWriteChunkRequest + (*FsWriteChunkResult)(nil), // 40: panel.v1.FsWriteChunkResult + (*ArkSaveRestoreRequest)(nil), // 41: panel.v1.ArkSaveRestoreRequest + (*ArkSaveRestoreResult)(nil), // 42: panel.v1.ArkSaveRestoreResult + (*DayzModInstallRequest)(nil), // 43: panel.v1.DayzModInstallRequest + (*DayzModInstallResult)(nil), // 44: panel.v1.DayzModInstallResult + (*DayzModUninstallRequest)(nil), // 45: panel.v1.DayzModUninstallRequest + (*DayzModUninstallResult)(nil), // 46: panel.v1.DayzModUninstallResult + (*UpdateRequest)(nil), // 47: panel.v1.UpdateRequest + (*UpdateResult)(nil), // 48: panel.v1.UpdateResult + (*BackupRequest)(nil), // 49: panel.v1.BackupRequest + (*BackupResult)(nil), // 50: panel.v1.BackupResult + (*RestoreRequest)(nil), // 51: panel.v1.RestoreRequest + (*RestoreResult)(nil), // 52: panel.v1.RestoreResult + (*BackupListEntriesRequest)(nil), // 53: panel.v1.BackupListEntriesRequest + (*BackupTarEntry)(nil), // 54: panel.v1.BackupTarEntry + (*BackupListEntriesResult)(nil), // 55: panel.v1.BackupListEntriesResult + (*BackupReadFileRequest)(nil), // 56: panel.v1.BackupReadFileRequest + (*BackupReadFileResult)(nil), // 57: panel.v1.BackupReadFileResult + (*PlayerEvent)(nil), // 58: panel.v1.PlayerEvent + (*EmpyrionScenarioInstallRequest)(nil), // 59: panel.v1.EmpyrionScenarioInstallRequest + (*EmpyrionScenarioInstallResult)(nil), // 60: panel.v1.EmpyrionScenarioInstallResult + (*EmpyrionDiscoveriesRequest)(nil), // 61: panel.v1.EmpyrionDiscoveriesRequest + (*DiscoveredPOI)(nil), // 62: panel.v1.DiscoveredPOI + (*EmpyrionDiscoveriesResult)(nil), // 63: panel.v1.EmpyrionDiscoveriesResult + (*EmpyrionPlayerSummaryRequest)(nil), // 64: panel.v1.EmpyrionPlayerSummaryRequest + (*PlayerStatsRow)(nil), // 65: panel.v1.PlayerStatsRow + (*PlayerLoginRow)(nil), // 66: panel.v1.PlayerLoginRow + (*PlayerInventoryItem)(nil), // 67: panel.v1.PlayerInventoryItem + (*EmpyrionPlayerSummaryResult)(nil), // 68: panel.v1.EmpyrionPlayerSummaryResult + (*RegionScanRequest)(nil), // 69: panel.v1.RegionScanRequest + (*AffectedRegionMsg)(nil), // 70: panel.v1.AffectedRegionMsg + (*RegionScanResult)(nil), // 71: panel.v1.RegionScanResult + (*RegionHealRequest)(nil), // 72: panel.v1.RegionHealRequest + (*RegionHealResult)(nil), // 73: panel.v1.RegionHealResult + (*InstanceRenderConfigRequest)(nil), // 74: panel.v1.InstanceRenderConfigRequest + (*InstanceRenderConfigResult)(nil), // 75: panel.v1.InstanceRenderConfigResult + (*SeedModsRequest)(nil), // 76: panel.v1.SeedModsRequest + (*SeedModsResult)(nil), // 77: panel.v1.SeedModsResult + (*UpdateCheckRequest)(nil), // 78: panel.v1.UpdateCheckRequest + (*UpdateCheckResult)(nil), // 79: panel.v1.UpdateCheckResult + nil, // 80: panel.v1.ExecRequest.EnvEntry + nil, // 81: panel.v1.InstanceCreate.ConfigValuesEntry + nil, // 82: panel.v1.InstanceCreate.MountOverridesEntry + nil, // 83: panel.v1.AppStateUpdate.ModuleFieldsEntry + nil, // 84: panel.v1.InstanceRenderConfigRequest.ConfigValuesEntry + (*timestamppb.Timestamp)(nil), // 85: google.protobuf.Timestamp + (*Error)(nil), // 86: panel.v1.Error + (RunMode)(0), // 87: panel.v1.RunMode + (*ResourceLimits)(nil), // 88: panel.v1.ResourceLimits + (*PortMap)(nil), // 89: panel.v1.PortMap + (InstanceStatus)(0), // 90: panel.v1.InstanceStatus +} +var file_panel_v1_agent_proto_depIdxs = []int32{ + 85, // 0: panel.v1.ControllerEnvelope.sent_at:type_name -> google.protobuf.Timestamp + 6, // 1: panel.v1.ControllerEnvelope.hello:type_name -> panel.v1.ControllerHello + 8, // 2: panel.v1.ControllerEnvelope.ping:type_name -> panel.v1.Ping + 10, // 3: panel.v1.ControllerEnvelope.exec:type_name -> panel.v1.ExecRequest + 12, // 4: panel.v1.ControllerEnvelope.instance_create:type_name -> panel.v1.InstanceCreate + 13, // 5: panel.v1.ControllerEnvelope.instance_start:type_name -> panel.v1.InstanceStart + 14, // 6: panel.v1.ControllerEnvelope.instance_stop:type_name -> panel.v1.InstanceStop + 15, // 7: panel.v1.ControllerEnvelope.instance_delete:type_name -> panel.v1.InstanceDelete + 20, // 8: panel.v1.ControllerEnvelope.rcon:type_name -> panel.v1.RCONCommand + 23, // 9: panel.v1.ControllerEnvelope.fs_list:type_name -> panel.v1.FsListRequest + 25, // 10: panel.v1.ControllerEnvelope.fs_read:type_name -> panel.v1.FsReadRequest + 27, // 11: panel.v1.ControllerEnvelope.fs_write:type_name -> panel.v1.FsWriteRequest + 29, // 12: panel.v1.ControllerEnvelope.fs_delete:type_name -> panel.v1.FsDeleteRequest + 31, // 13: panel.v1.ControllerEnvelope.fs_symlink:type_name -> panel.v1.FsSymlinkRequest + 33, // 14: panel.v1.ControllerEnvelope.fs_extract:type_name -> panel.v1.FsExtractRequest + 35, // 15: panel.v1.ControllerEnvelope.fs_compress:type_name -> panel.v1.FsCompressRequest + 37, // 16: panel.v1.ControllerEnvelope.fs_rename:type_name -> panel.v1.FsRenameRequest + 39, // 17: panel.v1.ControllerEnvelope.fs_write_chunk:type_name -> panel.v1.FsWriteChunkRequest + 41, // 18: panel.v1.ControllerEnvelope.ark_save_restore:type_name -> panel.v1.ArkSaveRestoreRequest + 43, // 19: panel.v1.ControllerEnvelope.dayz_mod_install:type_name -> panel.v1.DayzModInstallRequest + 45, // 20: panel.v1.ControllerEnvelope.dayz_mod_uninstall:type_name -> panel.v1.DayzModUninstallRequest + 47, // 21: panel.v1.ControllerEnvelope.update:type_name -> panel.v1.UpdateRequest + 49, // 22: panel.v1.ControllerEnvelope.backup:type_name -> panel.v1.BackupRequest + 51, // 23: panel.v1.ControllerEnvelope.restore:type_name -> panel.v1.RestoreRequest + 53, // 24: panel.v1.ControllerEnvelope.backup_list_entries:type_name -> panel.v1.BackupListEntriesRequest + 56, // 25: panel.v1.ControllerEnvelope.backup_read_file:type_name -> panel.v1.BackupReadFileRequest + 59, // 26: panel.v1.ControllerEnvelope.empyrion_scenario_install:type_name -> panel.v1.EmpyrionScenarioInstallRequest + 61, // 27: panel.v1.ControllerEnvelope.empyrion_discoveries:type_name -> panel.v1.EmpyrionDiscoveriesRequest + 64, // 28: panel.v1.ControllerEnvelope.empyrion_player_summary:type_name -> panel.v1.EmpyrionPlayerSummaryRequest + 69, // 29: panel.v1.ControllerEnvelope.region_scan:type_name -> panel.v1.RegionScanRequest + 72, // 30: panel.v1.ControllerEnvelope.region_heal:type_name -> panel.v1.RegionHealRequest + 74, // 31: panel.v1.ControllerEnvelope.instance_render_config:type_name -> panel.v1.InstanceRenderConfigRequest + 76, // 32: panel.v1.ControllerEnvelope.seed_mods:type_name -> panel.v1.SeedModsRequest + 78, // 33: panel.v1.ControllerEnvelope.update_check:type_name -> panel.v1.UpdateCheckRequest + 85, // 34: panel.v1.AgentEnvelope.sent_at:type_name -> google.protobuf.Timestamp + 3, // 35: panel.v1.AgentEnvelope.hello:type_name -> panel.v1.AgentHello + 7, // 36: panel.v1.AgentEnvelope.heartbeat:type_name -> panel.v1.Heartbeat + 9, // 37: panel.v1.AgentEnvelope.pong:type_name -> panel.v1.Pong + 11, // 38: panel.v1.AgentEnvelope.result:type_name -> panel.v1.CommandResult + 16, // 39: panel.v1.AgentEnvelope.instance_state:type_name -> panel.v1.InstanceStateUpdate + 17, // 40: panel.v1.AgentEnvelope.app_state:type_name -> panel.v1.AppStateUpdate + 18, // 41: panel.v1.AgentEnvelope.log:type_name -> panel.v1.LogLine + 58, // 42: panel.v1.AgentEnvelope.player:type_name -> panel.v1.PlayerEvent + 21, // 43: panel.v1.AgentEnvelope.rcon_result:type_name -> panel.v1.RCONResult + 24, // 44: panel.v1.AgentEnvelope.fs_list_result:type_name -> panel.v1.FsListResult + 26, // 45: panel.v1.AgentEnvelope.fs_read_result:type_name -> panel.v1.FsReadResult + 28, // 46: panel.v1.AgentEnvelope.fs_write_result:type_name -> panel.v1.FsWriteResult + 30, // 47: panel.v1.AgentEnvelope.fs_delete_result:type_name -> panel.v1.FsDeleteResult + 32, // 48: panel.v1.AgentEnvelope.fs_symlink_result:type_name -> panel.v1.FsSymlinkResult + 34, // 49: panel.v1.AgentEnvelope.fs_extract_result:type_name -> panel.v1.FsExtractResult + 36, // 50: panel.v1.AgentEnvelope.fs_compress_result:type_name -> panel.v1.FsCompressResult + 38, // 51: panel.v1.AgentEnvelope.fs_rename_result:type_name -> panel.v1.FsRenameResult + 40, // 52: panel.v1.AgentEnvelope.fs_write_chunk_result:type_name -> panel.v1.FsWriteChunkResult + 42, // 53: panel.v1.AgentEnvelope.ark_save_restore_result:type_name -> panel.v1.ArkSaveRestoreResult + 44, // 54: panel.v1.AgentEnvelope.dayz_mod_install_result:type_name -> panel.v1.DayzModInstallResult + 46, // 55: panel.v1.AgentEnvelope.dayz_mod_uninstall_result:type_name -> panel.v1.DayzModUninstallResult + 48, // 56: panel.v1.AgentEnvelope.update_result:type_name -> panel.v1.UpdateResult + 50, // 57: panel.v1.AgentEnvelope.backup_result:type_name -> panel.v1.BackupResult + 52, // 58: panel.v1.AgentEnvelope.restore_result:type_name -> panel.v1.RestoreResult + 55, // 59: panel.v1.AgentEnvelope.backup_list_entries_result:type_name -> panel.v1.BackupListEntriesResult + 57, // 60: panel.v1.AgentEnvelope.backup_read_file_result:type_name -> panel.v1.BackupReadFileResult + 19, // 61: panel.v1.AgentEnvelope.instance_stats:type_name -> panel.v1.InstanceStatsUpdate + 60, // 62: panel.v1.AgentEnvelope.empyrion_scenario_install_result:type_name -> panel.v1.EmpyrionScenarioInstallResult + 63, // 63: panel.v1.AgentEnvelope.empyrion_discoveries_result:type_name -> panel.v1.EmpyrionDiscoveriesResult + 68, // 64: panel.v1.AgentEnvelope.empyrion_player_summary_result:type_name -> panel.v1.EmpyrionPlayerSummaryResult + 71, // 65: panel.v1.AgentEnvelope.region_scan_result:type_name -> panel.v1.RegionScanResult + 73, // 66: panel.v1.AgentEnvelope.region_heal_result:type_name -> panel.v1.RegionHealResult + 75, // 67: panel.v1.AgentEnvelope.instance_render_config_result:type_name -> panel.v1.InstanceRenderConfigResult + 77, // 68: panel.v1.AgentEnvelope.seed_mods_result:type_name -> panel.v1.SeedModsResult + 79, // 69: panel.v1.AgentEnvelope.update_check_result:type_name -> panel.v1.UpdateCheckResult + 4, // 70: panel.v1.AgentHello.modules:type_name -> panel.v1.ModuleSummary + 5, // 71: panel.v1.ModuleSummary.update_providers:type_name -> panel.v1.ModuleUpdateProviderSummary + 85, // 72: panel.v1.ControllerHello.server_time:type_name -> google.protobuf.Timestamp + 85, // 73: panel.v1.Heartbeat.at:type_name -> google.protobuf.Timestamp + 80, // 74: panel.v1.ExecRequest.env:type_name -> panel.v1.ExecRequest.EnvEntry + 86, // 75: panel.v1.CommandResult.error:type_name -> panel.v1.Error + 87, // 76: panel.v1.InstanceCreate.run_mode:type_name -> panel.v1.RunMode + 88, // 77: panel.v1.InstanceCreate.limits:type_name -> panel.v1.ResourceLimits + 81, // 78: panel.v1.InstanceCreate.config_values:type_name -> panel.v1.InstanceCreate.ConfigValuesEntry + 89, // 79: panel.v1.InstanceCreate.ports:type_name -> panel.v1.PortMap + 82, // 80: panel.v1.InstanceCreate.mount_overrides:type_name -> panel.v1.InstanceCreate.MountOverridesEntry + 90, // 81: panel.v1.InstanceStateUpdate.status:type_name -> panel.v1.InstanceStatus + 85, // 82: panel.v1.InstanceStateUpdate.at:type_name -> google.protobuf.Timestamp + 83, // 83: panel.v1.AppStateUpdate.module_fields:type_name -> panel.v1.AppStateUpdate.ModuleFieldsEntry + 85, // 84: panel.v1.AppStateUpdate.at:type_name -> google.protobuf.Timestamp + 85, // 85: panel.v1.LogLine.at:type_name -> google.protobuf.Timestamp + 85, // 86: panel.v1.InstanceStatsUpdate.at:type_name -> google.protobuf.Timestamp + 85, // 87: panel.v1.FsEntry.mod_time:type_name -> google.protobuf.Timestamp + 22, // 88: panel.v1.FsListResult.entries:type_name -> panel.v1.FsEntry + 85, // 89: panel.v1.BackupTarEntry.mod_time:type_name -> google.protobuf.Timestamp + 54, // 90: panel.v1.BackupListEntriesResult.entries:type_name -> panel.v1.BackupTarEntry + 0, // 91: panel.v1.PlayerEvent.kind:type_name -> panel.v1.PlayerEvent.Kind + 85, // 92: panel.v1.PlayerEvent.at:type_name -> google.protobuf.Timestamp + 62, // 93: panel.v1.EmpyrionDiscoveriesResult.pois:type_name -> panel.v1.DiscoveredPOI + 65, // 94: panel.v1.EmpyrionPlayerSummaryResult.stats:type_name -> panel.v1.PlayerStatsRow + 66, // 95: panel.v1.EmpyrionPlayerSummaryResult.sessions:type_name -> panel.v1.PlayerLoginRow + 67, // 96: panel.v1.EmpyrionPlayerSummaryResult.inventory:type_name -> panel.v1.PlayerInventoryItem + 70, // 97: panel.v1.RegionScanResult.affected:type_name -> panel.v1.AffectedRegionMsg + 86, // 98: panel.v1.RegionScanResult.error:type_name -> panel.v1.Error + 86, // 99: panel.v1.RegionHealResult.error:type_name -> panel.v1.Error + 84, // 100: panel.v1.InstanceRenderConfigRequest.config_values:type_name -> panel.v1.InstanceRenderConfigRequest.ConfigValuesEntry + 2, // 101: panel.v1.Agent.Connect:input_type -> panel.v1.AgentEnvelope + 1, // 102: panel.v1.Agent.Connect:output_type -> panel.v1.ControllerEnvelope + 102, // [102:103] is the sub-list for method output_type + 101, // [101:102] is the sub-list for method input_type + 101, // [101:101] is the sub-list for extension type_name + 101, // [101:101] is the sub-list for extension extendee + 0, // [0:101] is the sub-list for field type_name +} + +func init() { file_panel_v1_agent_proto_init() } +func file_panel_v1_agent_proto_init() { + if File_panel_v1_agent_proto != nil { + return + } + file_panel_v1_common_proto_init() + file_panel_v1_agent_proto_msgTypes[0].OneofWrappers = []any{ + (*ControllerEnvelope_Hello)(nil), + (*ControllerEnvelope_Ping)(nil), + (*ControllerEnvelope_Exec)(nil), + (*ControllerEnvelope_InstanceCreate)(nil), + (*ControllerEnvelope_InstanceStart)(nil), + (*ControllerEnvelope_InstanceStop)(nil), + (*ControllerEnvelope_InstanceDelete)(nil), + (*ControllerEnvelope_Rcon)(nil), + (*ControllerEnvelope_FsList)(nil), + (*ControllerEnvelope_FsRead)(nil), + (*ControllerEnvelope_FsWrite)(nil), + (*ControllerEnvelope_FsDelete)(nil), + (*ControllerEnvelope_FsSymlink)(nil), + (*ControllerEnvelope_FsExtract)(nil), + (*ControllerEnvelope_FsCompress)(nil), + (*ControllerEnvelope_FsRename)(nil), + (*ControllerEnvelope_FsWriteChunk)(nil), + (*ControllerEnvelope_ArkSaveRestore)(nil), + (*ControllerEnvelope_DayzModInstall)(nil), + (*ControllerEnvelope_DayzModUninstall)(nil), + (*ControllerEnvelope_Update)(nil), + (*ControllerEnvelope_Backup)(nil), + (*ControllerEnvelope_Restore)(nil), + (*ControllerEnvelope_BackupListEntries)(nil), + (*ControllerEnvelope_BackupReadFile)(nil), + (*ControllerEnvelope_EmpyrionScenarioInstall)(nil), + (*ControllerEnvelope_EmpyrionDiscoveries)(nil), + (*ControllerEnvelope_EmpyrionPlayerSummary)(nil), + (*ControllerEnvelope_RegionScan)(nil), + (*ControllerEnvelope_RegionHeal)(nil), + (*ControllerEnvelope_InstanceRenderConfig)(nil), + (*ControllerEnvelope_SeedMods)(nil), + (*ControllerEnvelope_UpdateCheck)(nil), + } + file_panel_v1_agent_proto_msgTypes[1].OneofWrappers = []any{ + (*AgentEnvelope_Hello)(nil), + (*AgentEnvelope_Heartbeat)(nil), + (*AgentEnvelope_Pong)(nil), + (*AgentEnvelope_Result)(nil), + (*AgentEnvelope_InstanceState)(nil), + (*AgentEnvelope_AppState)(nil), + (*AgentEnvelope_Log)(nil), + (*AgentEnvelope_Player)(nil), + (*AgentEnvelope_RconResult)(nil), + (*AgentEnvelope_FsListResult)(nil), + (*AgentEnvelope_FsReadResult)(nil), + (*AgentEnvelope_FsWriteResult)(nil), + (*AgentEnvelope_FsDeleteResult)(nil), + (*AgentEnvelope_FsSymlinkResult)(nil), + (*AgentEnvelope_FsExtractResult)(nil), + (*AgentEnvelope_FsCompressResult)(nil), + (*AgentEnvelope_FsRenameResult)(nil), + (*AgentEnvelope_FsWriteChunkResult)(nil), + (*AgentEnvelope_ArkSaveRestoreResult)(nil), + (*AgentEnvelope_DayzModInstallResult)(nil), + (*AgentEnvelope_DayzModUninstallResult)(nil), + (*AgentEnvelope_UpdateResult)(nil), + (*AgentEnvelope_BackupResult)(nil), + (*AgentEnvelope_RestoreResult)(nil), + (*AgentEnvelope_BackupListEntriesResult)(nil), + (*AgentEnvelope_BackupReadFileResult)(nil), + (*AgentEnvelope_InstanceStats)(nil), + (*AgentEnvelope_EmpyrionScenarioInstallResult)(nil), + (*AgentEnvelope_EmpyrionDiscoveriesResult)(nil), + (*AgentEnvelope_EmpyrionPlayerSummaryResult)(nil), + (*AgentEnvelope_RegionScanResult)(nil), + (*AgentEnvelope_RegionHealResult)(nil), + (*AgentEnvelope_InstanceRenderConfigResult)(nil), + (*AgentEnvelope_SeedModsResult)(nil), + (*AgentEnvelope_UpdateCheckResult)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_panel_v1_agent_proto_rawDesc), len(file_panel_v1_agent_proto_rawDesc)), + NumEnums: 1, + NumMessages: 84, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_panel_v1_agent_proto_goTypes, + DependencyIndexes: file_panel_v1_agent_proto_depIdxs, + EnumInfos: file_panel_v1_agent_proto_enumTypes, + MessageInfos: file_panel_v1_agent_proto_msgTypes, + }.Build() + File_panel_v1_agent_proto = out.File + file_panel_v1_agent_proto_goTypes = nil + file_panel_v1_agent_proto_depIdxs = nil +} diff --git a/proto/panel/v1/agent.proto b/proto/panel/v1/agent.proto new file mode 100644 index 00000000..1b520ade --- /dev/null +++ b/proto/panel/v1/agent.proto @@ -0,0 +1,785 @@ +syntax = "proto3"; + +package panel.v1; + +option go_package = "github.com/dbledeez/panel/proto/panel/v1;panelv1"; + +import "google/protobuf/timestamp.proto"; +import "panel/v1/common.proto"; + +// Agent is the gRPC service a Target agent dials on the Controller. +// A single persistent bidirectional stream carries all control-plane +// traffic between Controller and Target: commands downstream, state +// and events upstream. +service Agent { + rpc Connect(stream AgentEnvelope) returns (stream ControllerEnvelope); +} + +// ControllerEnvelope — messages from Controller to Target. +message ControllerEnvelope { + string correlation_id = 1; + google.protobuf.Timestamp sent_at = 2; + oneof payload { + ControllerHello hello = 10; + Ping ping = 11; + ExecRequest exec = 20; + InstanceCreate instance_create = 30; + InstanceStart instance_start = 31; + InstanceStop instance_stop = 32; + InstanceDelete instance_delete = 33; + RCONCommand rcon = 40; + FsListRequest fs_list = 50; + FsReadRequest fs_read = 51; + FsWriteRequest fs_write = 52; + FsDeleteRequest fs_delete = 53; + FsSymlinkRequest fs_symlink = 54; + FsExtractRequest fs_extract = 55; + FsCompressRequest fs_compress = 56; + FsRenameRequest fs_rename = 57; + FsWriteChunkRequest fs_write_chunk = 58; + ArkSaveRestoreRequest ark_save_restore = 59; + DayzModInstallRequest dayz_mod_install = 90; + DayzModUninstallRequest dayz_mod_uninstall = 91; + UpdateRequest update = 60; + BackupRequest backup = 70; + RestoreRequest restore = 71; + BackupListEntriesRequest backup_list_entries = 72; + BackupReadFileRequest backup_read_file = 73; + EmpyrionScenarioInstallRequest empyrion_scenario_install = 100; + EmpyrionDiscoveriesRequest empyrion_discoveries = 101; + EmpyrionPlayerSummaryRequest empyrion_player_summary = 102; + RegionScanRequest region_scan = 110; + RegionHealRequest region_heal = 111; + InstanceRenderConfigRequest instance_render_config = 112; + SeedModsRequest seed_mods = 113; + UpdateCheckRequest update_check = 114; + } +} + +// AgentEnvelope — messages from Target to Controller. +message AgentEnvelope { + string correlation_id = 1; + google.protobuf.Timestamp sent_at = 2; + oneof payload { + AgentHello hello = 10; + Heartbeat heartbeat = 11; + Pong pong = 12; + CommandResult result = 20; + InstanceStateUpdate instance_state = 30; + AppStateUpdate app_state = 31; + LogLine log = 32; + PlayerEvent player = 33; + RCONResult rcon_result = 40; + FsListResult fs_list_result = 50; + FsReadResult fs_read_result = 51; + FsWriteResult fs_write_result = 52; + FsDeleteResult fs_delete_result = 53; + FsSymlinkResult fs_symlink_result = 54; + FsExtractResult fs_extract_result = 55; + FsCompressResult fs_compress_result = 56; + FsRenameResult fs_rename_result = 57; + FsWriteChunkResult fs_write_chunk_result = 58; + ArkSaveRestoreResult ark_save_restore_result = 59; + DayzModInstallResult dayz_mod_install_result = 90; + DayzModUninstallResult dayz_mod_uninstall_result = 91; + UpdateResult update_result = 60; + BackupResult backup_result = 70; + RestoreResult restore_result = 71; + BackupListEntriesResult backup_list_entries_result = 72; + BackupReadFileResult backup_read_file_result = 73; + InstanceStatsUpdate instance_stats = 80; + EmpyrionScenarioInstallResult empyrion_scenario_install_result = 100; + EmpyrionDiscoveriesResult empyrion_discoveries_result = 101; + EmpyrionPlayerSummaryResult empyrion_player_summary_result = 102; + RegionScanResult region_scan_result = 110; + RegionHealResult region_heal_result = 111; + InstanceRenderConfigResult instance_render_config_result = 112; + SeedModsResult seed_mods_result = 113; + UpdateCheckResult update_check_result = 114; + } +} + +// ---- Handshake ---- + +message AgentHello { + string agent_id = 1; + string agent_version = 2; + string host_os = 3; // "linux", "windows" + string host_arch = 4; // "amd64", "arm64" + string hostname = 5; + repeated string supported_runtimes = 6; // "docker", "host" + repeated ModuleSummary modules = 7; +} + +// ModuleSummary is what an agent advertises about one locally-loaded +// module at handshake time. The controller aggregates these across all +// agents so the dashboard can render a "known modules" picker without +// needing direct filesystem access to each agent's modules/ dir. +message ModuleSummary { + string id = 1; + string name = 2; + string version = 3; + repeated string supported_modes = 4; + repeated ModuleUpdateProviderSummary update_providers = 5; + bool has_rcon = 6; + repeated string authors = 7; +} + +message ModuleUpdateProviderSummary { + string id = 1; + string kind = 2; // "steamcmd" | "github" | "direct" + // True when the provider sets `requires_steam_login: true` on the + // manifest (DayZ, Arma). Controller uses this to decide whether to + // pop the Steam-login modal before forwarding an UpdateRequest. + bool requires_steam_login = 3; +} + +message ControllerHello { + string controller_version = 1; + google.protobuf.Timestamp server_time = 2; +} + +message Heartbeat { + google.protobuf.Timestamp at = 1; + double cpu_percent = 2; + uint64 mem_used_bytes = 3; + uint64 mem_total_bytes = 4; + uint32 instance_count = 5; +} + +message Ping { string nonce = 1; } +message Pong { string nonce = 1; } + +// ---- Exec / commands ---- + +message ExecRequest { + string instance_id = 1; // empty = run on Target host + repeated string argv = 2; + map env = 3; + string working_dir = 4; + uint32 timeout_seconds = 5; +} + +message CommandResult { + int32 exit_code = 1; + bytes stdout = 2; + bytes stderr = 3; + Error error = 4; +} + +// ---- Instance lifecycle ---- + +message InstanceCreate { + string instance_id = 1; + string module_id = 2; + string version = 3; + RunMode run_mode = 4; + ResourceLimits limits = 5; + map config_values = 6; // flattened key/value, module schema-validated + repeated PortMap ports = 7; + string data_path = 8; + // mount_overrides lets the controller swap one of the module's + // manifest-defined volume mounts at create time. Keyed by container + // path (matching a volume entry's `container:` field in module.yaml). + // Value is either a Docker named volume name (no leading slash) or an + // absolute host path (leading slash = bind mount). + // + // Used by the ARK cluster feature to swap an instance's per-instance + // `panel--cluster` named volume for a shared `panel-ark-cluster-` + // volume when the server joins a cluster. Generic enough for future + // features — any module can benefit from per-instance mount swaps + // without needing a manifest change. + map mount_overrides = 9; +} + +message InstanceStart { string instance_id = 1; } +message InstanceStop { + string instance_id = 1; + uint32 grace_seconds = 2; + bool force = 3; +} +// InstanceDelete tears down the instance record. If purge_volumes is +// true, every Docker volume labeled panel.instance_id= is +// removed too; otherwise volumes survive so a subsequent re-create can +// pick up the existing world/save data. +message InstanceDelete { + string instance_id = 1; + uint32 grace_seconds = 2; // if the container is still running, stop with this grace first + bool purge_volumes = 3; +} + +// ---- State streams ---- + +message InstanceStateUpdate { + string instance_id = 1; + InstanceStatus status = 2; + int32 exit_code = 3; + google.protobuf.Timestamp at = 4; + string detail = 5; +} + +message AppStateUpdate { + string instance_id = 1; + int32 players_online = 2; + int32 players_max = 3; + int64 uptime_seconds = 4; + map module_fields = 10; // world_seed, day_number, tps, etc. + google.protobuf.Timestamp at = 20; +} + +message LogLine { + string instance_id = 1; + string stream = 2; // "stdout", "stderr", or a named log file + google.protobuf.Timestamp at = 3; + string line = 4; +} + +// InstanceStatsUpdate is emitted periodically (default every 10s) by the +// agent's per-instance stats poller. Values come from Docker's stats +// stream API. Kept sparse — enough for UI gauges + future scheduler +// expressions like "cpu_percent > 80 sustained 5m -> notify". +message InstanceStatsUpdate { + string instance_id = 1; + double cpu_percent = 2; // 100 = 1 vCPU saturated + uint64 mem_used_bytes = 3; + uint64 mem_limit_bytes = 4; // 0 if no explicit limit set (then use container's view) + uint64 net_rx_bytes = 5; // cumulative since container start + uint64 net_tx_bytes = 6; + uint32 pids = 7; + google.protobuf.Timestamp at = 8; + // Host core count (docker OnlineCPUs) so UIs can translate the + // docker-style cpu_percent (100 = 1 core) into percent-of-host. + uint32 host_cpus = 9; +} + +// ---- Ad-hoc RCON ---- + +message RCONCommand { + string instance_id = 1; + string command = 2; +} + +message RCONResult { + string output = 1; + string error = 2; // empty on success; short machine-readable on failure +} + +// ---- File management (rooted at each instance's data_path) ---- + +message FsEntry { + string name = 1; // base name + string path = 2; // path relative to instance data_path + bool is_dir = 3; + int64 size = 4; // bytes (for files) + google.protobuf.Timestamp mod_time = 5; +} + +message FsListRequest { string instance_id = 1; string path = 2; } +message FsListResult { + repeated FsEntry entries = 1; + string path = 2; + string error = 3; +} + +message FsReadRequest { string instance_id = 1; string path = 2; } +message FsReadResult { + string path = 1; + bytes content = 2; + string error = 3; +} + +message FsWriteRequest { string instance_id = 1; string path = 2; bytes content = 3; } +message FsWriteResult { int64 bytes_written = 1; string error = 2; } + +message FsDeleteRequest { string instance_id = 1; string path = 2; bool recursive = 3; } +message FsDeleteResult { string error = 1; } + +// FsSymlinkRequest creates (or replaces) a symlink inside the instance +// container. Used by the DayZ mod manager to link @ModName entries to +// the shared workshop tree at /game/steamapps/workshop/content/... +message FsSymlinkRequest { string instance_id = 1; string target = 2; string link_path = 3; } +message FsSymlinkResult { string error = 1; } + +// FsExtractRequest expands an archive that already lives inside the +// instance's volume. Format is auto-detected from the archive bytes +// (zip / tar / tar.gz / tar.bz2). dest_dir is rooted in the instance's +// browseable root just like every other Fs op; "" means same dir as +// the archive. +message FsExtractRequest { + string instance_id = 1; + string archive_path = 2; + string dest_dir = 3; + bool overwrite = 4; +} +message FsExtractResult { + int64 entries_written = 1; + int64 bytes_written = 2; + string error = 3; +} + +// FsCompressRequest produces a zip archive at dest_zip_path containing +// the contents of every path in `sources` (recursive for dirs). All +// paths must be inside the instance's browseable root. +message FsCompressRequest { + string instance_id = 1; + repeated string sources = 2; + string dest_zip_path = 3; + bool overwrite = 4; +} +message FsCompressResult { + int64 entries_written = 1; + int64 bytes_written = 2; + string error = 3; +} + +// FsRenameRequest is a path-safe `mv` inside the instance's volume — +// supports rename + move-into-sibling-dir within the same root. +message FsRenameRequest { + string instance_id = 1; + string from_path = 2; + string to_path = 3; +} +message FsRenameResult { string error = 1; } + +// FsWriteChunkRequest streams a single chunk of a larger upload. Chunks +// are uploaded in order; each one carries the same upload_id (browser- +// generated UUID) so the agent can append to the same temp file. On +// is_final = true the agent finalizes (closes the temp file, ships it +// to the container via CopyToContainer, then unlinks). +message FsWriteChunkRequest { + string instance_id = 1; + string upload_id = 2; + string path = 3; + int64 offset = 4; + bytes data = 5; + int64 total_size = 6; // optional; agent uses for sanity checks on final + bool is_final = 7; +} +message FsWriteChunkResult { + int64 bytes_received = 1; + int64 total_received = 2; // running total per upload_id + bool finalized = 3; + string error = 4; +} + +// ArkSaveRestoreRequest swaps a timestamped rolling backup into the +// active save slot under SavedArks//.ark. The previous +// active save is moved aside (NEVER deleted) by appending a "_replaced" +// timestamp suffix so even abandoned attempts are recoverable. Path is +// rooted at ShooterGame/Saved/SavedArks// in the +// instance's browseable root. +// +// Caller (controller) is responsible for stopping the container before +// invoking this — the agent only performs file operations. If a backup +// rolling file (.arkrbf) is selected, it is renamed to .ark before +// installing as the active save. +message ArkSaveRestoreRequest { + string instance_id = 1; + string saved_arks_subdir = 2; // e.g. "TheIsland_WP" + string target_filename = 3; // basename within saved_arks_subdir (e.g. "TheIsland_WP_30.04.2026_22.13.00.ark") +} +message ArkSaveRestoreResult { + string aside_filename = 1; // what the previously-active save was renamed to + string installed_filename = 2; // the filename that's now active (always ".ark") + string error = 3; +} + +// DayzModInstallRequest asks the agent to (1) copy the mod's bikeys into +// /game/keys and (2) create a @ModName symlink at /game/ +// pointing at the shared workshop content at +// /game/steamapps/workshop/content/221100/. Agent-side so +// it can scan the workshop dir and preserve file ownership. +message DayzModInstallRequest { + string instance_id = 1; + string workshop_id = 2; + string folder_name = 3; // e.g. "@CommunityOnlineTools" +} +message DayzModInstallResult { + string error = 1; + repeated string bikeys_copied = 2; // filenames of bikeys dropped in /game/keys + string resolved_mod_path = 3; // /game/steamapps/workshop/content/221100/ +} + +// DayzModUninstallRequest removes the @ModName symlink and the mod's +// bikeys from /game/keys (bikeys are matched by file hash — only keys +// whose content originated from this workshop item are removed). +message DayzModUninstallRequest { + string instance_id = 1; + string folder_name = 2; +} +message DayzModUninstallResult { + string error = 1; + repeated string bikeys_removed = 2; +} + +// ---- Updates ---- +// +// The agent runs the update asynchronously. The synchronous UpdateResult +// carries only the acknowledgement + ref-id for later correlation; real +// progress + final status flow through LogLine (stream="update") and the +// terminal "update:ok"/"update:error:" LogLine sentinel at the end. + +message UpdateRequest { + string instance_id = 1; + string provider_id = 2; // empty = use the module's first update_provider + // Steam login credentials for apps whose SteamCMD refuses +login + // anonymous (DayZ, Arma, etc. — modules with `requires_steam_login` + // set on the provider). The controller pulls these from its + // encrypted steam_credentials store and forwards them via the + // mTLS-protected control stream. Agent never logs them. + string steam_username = 3; + string steam_password = 4; +} + +message UpdateResult { + bool accepted = 1; // true = agent kicked off the update goroutine + string error = 2; // empty on accept; populated on immediate reject + string provider_id = 3; + string provider_kind = 4; +} + +// ---- Backup + Restore ---- +// +// Synchronous over the bidi stream. The sidecar tars the instance's +// volume into a .tar.gz on the agent's --backup-dir; the terminal +// BackupResult carries the path + size. Restore takes a backup id +// (path resolved by the controller via DB) and reverses. + +message BackupRequest { + string instance_id = 1; + string description = 2; +} + +message BackupResult { + string backup_id = 1; // bkp_ + string path = 2; + int64 size_bytes = 3; + string error = 4; +} + +message RestoreRequest { + string instance_id = 1; + string backup_id = 2; + string backup_path = 3; // absolute path on the agent +} + +message RestoreResult { + string error = 1; + int64 bytes_restored = 2; +} + +// BackupListEntriesRequest asks the agent to enumerate the contents of a +// backup tarball without extracting it. Used by the backup-browser UI so +// operators can drill into a snapshot to confirm what's inside before +// restoring (or to recover a single file). +message BackupListEntriesRequest { + string instance_id = 1; + string backup_id = 2; + string backup_path = 3; // absolute path on the agent (resolved controller-side) +} + +// BackupTarEntry — one row from `tar -tvzf`. Size is bytes-on-disk, not +// compressed size. is_dir distinguishes directories so the UI can render +// a tree without a second pass. +message BackupTarEntry { + string path = 1; // path inside the tarball, no leading slash + int64 size = 2; + bool is_dir = 3; + string mode = 4; // permissions string, e.g. "-rw-r--r--" + google.protobuf.Timestamp mod_time = 5; +} + +message BackupListEntriesResult { + repeated BackupTarEntry entries = 1; + int64 total_bytes = 2; // sum of file sizes (not compressed size of the tarball) + string error = 3; +} + +// BackupReadFileRequest extracts a single entry's bytes to memory by +// streaming `tar -xzOf ` into a buffer. Caller-supplied +// max_bytes caps the read so a malicious / corrupt archive can't OOM the +// agent — the result includes truncated=true if max_bytes was hit. +message BackupReadFileRequest { + string instance_id = 1; + string backup_id = 2; + string backup_path = 3; + string entry_path = 4; + int64 max_bytes = 5; // 0 → controller-side default (8 MiB) +} + +message BackupReadFileResult { + bytes content = 1; + int64 size = 2; // actual entry size from tar header (may be > len(content) if truncated) + bool truncated = 3; + bool is_binary = 4; // heuristic: NUL byte in first 8 KiB + string error = 5; +} + +message PlayerEvent { + enum Kind { + KIND_UNSPECIFIED = 0; + KIND_JOIN = 1; + KIND_LEAVE = 2; + KIND_CHAT = 3; + KIND_DEATH = 4; + KIND_CUSTOM = 99; + } + string instance_id = 1; + Kind kind = 2; + string player_id = 3; // SteamID / UUID where available + string player_name = 4; + string detail = 5; + google.protobuf.Timestamp at = 6; +} + +// EmpyrionScenarioInstallRequest is sent controller → agent to install +// (or refresh) a workshop scenario into a specific Empyrion instance's +// game volume. The agent runs SteamCMD on its OWN Docker daemon — the +// previous (single-host) implementation ran on the controller's daemon +// which has no view of an agent's volumes. +message EmpyrionScenarioInstallRequest { + string job_id = 1; // controller-generated; echoed in result + log lines + string instance_id = 2; // target empyrion instance (must live on this agent) + string workshop_id = 3; // Steam workshop item ID (e.g. "3041847672" for RE2) + string scenario_name = 4; // folder name under Content/Scenarios; sanitized by controller + string app_id = 5; // empyrion app id; defaults to 530870 if empty + // Steam credentials for the workshop_download_item call. Steam policy + // (~2023+) requires a logged-in account for most app workshops, even + // for public/free items — anonymous returns "Access Denied". Forwarded + // from the controller's encrypted steam_credentials store. Same shape + // as UpdateRequest's steam_username/steam_password fields. + string steam_username = 6; + string steam_password = 7; +} + +message EmpyrionScenarioInstallResult { + string job_id = 1; + bool ok = 2; + string error = 3; // empty when ok + string scenario_name = 4; +} + +// EmpyrionDiscoveriesRequest queries the empyrion server's global.db +// (SQLite) for what a specific player has discovered. The agent mounts +// the instance's saves volume into a tiny alpine+sqlite sidecar, runs +// a join query against DiscoveredPOIs / Entities / Playfields / +// SolarSystems / LoginLogoff, and returns the rows. +// +// `mode = "personal"` returns POIs the named player personally +// discovered. `mode = "faction"` returns POIs anyone in their faction +// has discovered (the in-game shared-map view). Default: personal. +message EmpyrionDiscoveriesRequest { + string instance_id = 1; + string steam_id = 2; // 17-digit SteamID64 of the player + string mode = 3; // "personal" | "faction" +} + +message DiscoveredPOI { + string solar_system = 1; + string playfield = 2; + string poi_name = 3; + int32 poi_type = 4; // Entities.etype enum + int64 game_time = 5; // ticks since save start + string discoverer_name = 6; // who first discovered (when known) + string discoverer_steam_id = 7; + int32 faction_group = 8; + int32 faction_id = 9; +} + +message EmpyrionDiscoveriesResult { + bool ok = 1; + string error = 2; + repeated DiscoveredPOI pois = 3; + string mode = 4; // echoed back from request + string steam_id = 5; + string player_name = 6; // last-known name from LoginLogoff +} + +// EmpyrionPlayerSummaryRequest pulls everything we know about a single +// player from the empyrion savegame DB: stats, login sessions, current +// inventory snapshot. Used by the public /me page on AMP-Monitor. +message EmpyrionPlayerSummaryRequest { + string instance_id = 1; + string steam_id = 2; // 17-digit SteamID64 + int32 max_login_rows = 3; // limit on session history; default 50 +} + +message PlayerStatsRow { + int64 killed_enemies = 1; + int64 killed_allied = 2; + int64 killed_animals = 3; + int64 killed_drones = 4; + int64 killed_players = 5; + int64 killed_allied_players = 6; + int64 died = 7; + int64 score = 8; + int64 blocks_placed = 9; + int64 blocks_digged = 10; + int64 walked_meters = 11; + int64 jetpack_meters = 12; + int64 hv_meters = 13; + int64 sv_meters = 14; + int64 cv_meters = 15; + double playtime = 16; // hours per the schema + double light_years = 17; // travly + double astronomical_units = 18; // travau +} + +message PlayerLoginRow { + int64 login_ticks = 1; + int64 logoff_ticks = 2; + string player_name = 3; + int64 build_nr = 4; + string ip = 5; + string os = 6; + string gfx = 7; + string cpu = 8; +} + +message PlayerInventoryItem { + int32 item_id = 1; + int32 count = 2; +} + +message EmpyrionPlayerSummaryResult { + bool ok = 1; + string error = 2; + string steam_id = 3; + string player_name = 4; + int64 entity_id = 5; + int32 faction_group = 6; + int32 faction_id = 7; + + // Aggregate stats. nil when player has no PlayerStatistics row yet + // (very new player). + PlayerStatsRow stats = 8; + + // Recent login sessions, newest first. + repeated PlayerLoginRow sessions = 9; + + // Most recent inventory snapshot. May be empty for players without + // a recorded snapshot. + int64 inventory_gametime = 10; + repeated PlayerInventoryItem inventory = 11; + + // Last-known position. + string last_playfield = 12; + double last_x = 13; + double last_y = 14; + double last_z = 15; + int64 credits = 16; +} + +// ---- Region Medic (7DTD .7rg region-corruption detect + heal) ---- + +// RegionScanRequest asks the agent to scan an instance's active-world Region +// directory for corruption evidence (error_backup salvage files + invalid +// region files). +message RegionScanRequest { + string instance_id = 1; +} + +// AffectedRegionMsg is one region flagged by a scan. +message AffectedRegionMsg { + string region = 1; // canonical id, e.g. "r.-2.0" + int32 error_backups = 2; // salvage files whose chunks fall in this region + bool file_present = 3; // r.X.Z.7rg exists on disk + bool file_corrupt = 4; // region file failed structural validation + int32 bad_chunks = 5; // count of invalid chunks if validated +} + +message RegionScanResult { + int32 error_backups = 1; // total salvage files found + repeated AffectedRegionMsg affected = 2; + string region_dir = 3; // resolved active Region dir (container path) + Error error = 4; +} + +// RegionHealRequest asks the agent to heal one region from the newest clean +// backup. The agent stops the instance (graceful save first), swaps the region +// file in place, quarantines the region's error_backups, then restarts (if +// restart=true). +message RegionHealRequest { + string instance_id = 1; + string region = 2; // "r.-2.0" + bool restart = 3; // restart the instance after healing +} + +message RegionHealResult { + string region = 1; + string source_backup = 2; // backup id the clean copy came from + int64 bytes_written = 3; + int32 quarantined = 4; // error_backup files moved aside + string snapshot_path = 5; // where the corrupt original was saved + bool healed = 6; + Error error = 7; +} + +// ---- Durable config re-render (no container recreate) ---- + +// InstanceRenderConfigRequest asks the agent to re-render the module's +// declared config_files (templates) into the instance's host DataPath, +// WITHOUT recreating or restarting the container. For modules whose +// rendered output is bind-mounted into the container (7dtd's +// serverconfig.xml.rendered), the entrypoint picks the new content up on +// the instance's NEXT boot — this is the durable, no-recreate config path. +// +// config_values is the FULL desired value map (the controller sends its +// merged DB row, not a delta), mirroring what InstanceCreate would render. +message InstanceRenderConfigRequest { + string instance_id = 1; + map config_values = 2; +} + +message InstanceRenderConfigResult { + repeated string rendered_files = 1; // config_file paths written (manifest-relative) + string error = 2; +} + +// SeedModsRequest asks an agent to clone the Mods/ folder of src_instance_id +// (the cluster master) onto instance_id (a member), live — no container +// recreate. Per-server state (RefugeBot homes.json) is excluded from the copy +// and the member's own homes.json is preserved. The member keeps running on +// its currently-loaded mods; the new files take effect on its NEXT restart. +// Both instances must live on the same agent (the sidecar mounts local volumes). +message SeedModsRequest { + string instance_id = 1; // destination member to receive the mods + string src_instance_id = 2; // source cluster master to copy mods from +} + +message SeedModsResult { + string instance_id = 1; + bool ok = 2; + string error = 3; + int32 mod_count = 4; // number of mods present on the member after the copy +} + +// ---- Update-available check (WI-14) ---- +// +// CHECK-ONLY: this RPC never triggers an update or restart. The agent +// reads the installed buildid from the Steam appmanifest_.acf in +// the instance volume and fetches the latest buildid for the instance's +// branch via a one-shot `steamcmd +app_info_print` sidecar (cached per +// app_id for 15 minutes agent-side). + +message UpdateCheckRequest { + string instance_id = 1; + // provider_id selects the module update_provider (steamcmd kind) whose + // app_id/beta define what "installed vs latest" means. Empty = same + // default the Update RPC uses (first provider in the manifest). + string provider_id = 2; + // refresh forces a fresh steamcmd app_info fetch, bypassing the + // agent-side 15-minute cache. + bool refresh = 3; +} + +message UpdateCheckResult { + string instance_id = 1; + string app_id = 2; + string branch = 3; // Steam branch compared ("public" when no -beta) + string installed_buildid = 4; // from appmanifest ACF; "" if unreadable + string latest_buildid = 5; // from app_info branches map; "" if fetch failed + bool update_available = 6; // installed != latest (both non-empty) + bool cached = 7; // latest came from the agent-side cache + string error = 8; // non-empty on failure; other fields best-effort +} diff --git a/proto/panel/v1/agent_grpc.pb.go b/proto/panel/v1/agent_grpc.pb.go new file mode 100644 index 00000000..5149cdc6 --- /dev/null +++ b/proto/panel/v1/agent_grpc.pb.go @@ -0,0 +1,125 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc (unknown) +// source: panel/v1/agent.proto + +package panelv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Agent_Connect_FullMethodName = "/panel.v1.Agent/Connect" +) + +// AgentClient is the client API for Agent service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Agent is the gRPC service a Target agent dials on the Controller. +// A single persistent bidirectional stream carries all control-plane +// traffic between Controller and Target: commands downstream, state +// and events upstream. +type AgentClient interface { + Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentEnvelope, ControllerEnvelope], error) +} + +type agentClient struct { + cc grpc.ClientConnInterface +} + +func NewAgentClient(cc grpc.ClientConnInterface) AgentClient { + return &agentClient{cc} +} + +func (c *agentClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentEnvelope, ControllerEnvelope], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Agent_ServiceDesc.Streams[0], Agent_Connect_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[AgentEnvelope, ControllerEnvelope]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Agent_ConnectClient = grpc.BidiStreamingClient[AgentEnvelope, ControllerEnvelope] + +// AgentServer is the server API for Agent service. +// All implementations must embed UnimplementedAgentServer +// for forward compatibility. +// +// Agent is the gRPC service a Target agent dials on the Controller. +// A single persistent bidirectional stream carries all control-plane +// traffic between Controller and Target: commands downstream, state +// and events upstream. +type AgentServer interface { + Connect(grpc.BidiStreamingServer[AgentEnvelope, ControllerEnvelope]) error + mustEmbedUnimplementedAgentServer() +} + +// UnimplementedAgentServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAgentServer struct{} + +func (UnimplementedAgentServer) Connect(grpc.BidiStreamingServer[AgentEnvelope, ControllerEnvelope]) error { + return status.Error(codes.Unimplemented, "method Connect not implemented") +} +func (UnimplementedAgentServer) mustEmbedUnimplementedAgentServer() {} +func (UnimplementedAgentServer) testEmbeddedByValue() {} + +// UnsafeAgentServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AgentServer will +// result in compilation errors. +type UnsafeAgentServer interface { + mustEmbedUnimplementedAgentServer() +} + +func RegisterAgentServer(s grpc.ServiceRegistrar, srv AgentServer) { + // If the following call panics, it indicates UnimplementedAgentServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Agent_ServiceDesc, srv) +} + +func _Agent_Connect_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(AgentServer).Connect(&grpc.GenericServerStream[AgentEnvelope, ControllerEnvelope]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Agent_ConnectServer = grpc.BidiStreamingServer[AgentEnvelope, ControllerEnvelope] + +// Agent_ServiceDesc is the grpc.ServiceDesc for Agent service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Agent_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "panel.v1.Agent", + HandlerType: (*AgentServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Connect", + Handler: _Agent_Connect_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "panel/v1/agent.proto", +} diff --git a/proto/panel/v1/common.pb.go b/proto/panel/v1/common.pb.go new file mode 100644 index 00000000..57bb95c0 --- /dev/null +++ b/proto/panel/v1/common.pb.go @@ -0,0 +1,424 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: panel/v1/common.proto + +package panelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// RunMode selects how a game instance is launched on a Target. +type RunMode int32 + +const ( + RunMode_RUN_MODE_UNSPECIFIED RunMode = 0 + RunMode_RUN_MODE_DOCKER RunMode = 1 + RunMode_RUN_MODE_HOST RunMode = 2 +) + +// Enum value maps for RunMode. +var ( + RunMode_name = map[int32]string{ + 0: "RUN_MODE_UNSPECIFIED", + 1: "RUN_MODE_DOCKER", + 2: "RUN_MODE_HOST", + } + RunMode_value = map[string]int32{ + "RUN_MODE_UNSPECIFIED": 0, + "RUN_MODE_DOCKER": 1, + "RUN_MODE_HOST": 2, + } +) + +func (x RunMode) Enum() *RunMode { + p := new(RunMode) + *p = x + return p +} + +func (x RunMode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (RunMode) Descriptor() protoreflect.EnumDescriptor { + return file_panel_v1_common_proto_enumTypes[0].Descriptor() +} + +func (RunMode) Type() protoreflect.EnumType { + return &file_panel_v1_common_proto_enumTypes[0] +} + +func (x RunMode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use RunMode.Descriptor instead. +func (RunMode) EnumDescriptor() ([]byte, []int) { + return file_panel_v1_common_proto_rawDescGZIP(), []int{0} +} + +// InstanceStatus is the coarse lifecycle state of an instance. +type InstanceStatus int32 + +const ( + InstanceStatus_INSTANCE_STATUS_UNSPECIFIED InstanceStatus = 0 + InstanceStatus_INSTANCE_STATUS_STOPPED InstanceStatus = 1 + InstanceStatus_INSTANCE_STATUS_STARTING InstanceStatus = 2 + InstanceStatus_INSTANCE_STATUS_RUNNING InstanceStatus = 3 + InstanceStatus_INSTANCE_STATUS_STOPPING InstanceStatus = 4 + InstanceStatus_INSTANCE_STATUS_CRASHED InstanceStatus = 5 + InstanceStatus_INSTANCE_STATUS_UPDATING InstanceStatus = 6 +) + +// Enum value maps for InstanceStatus. +var ( + InstanceStatus_name = map[int32]string{ + 0: "INSTANCE_STATUS_UNSPECIFIED", + 1: "INSTANCE_STATUS_STOPPED", + 2: "INSTANCE_STATUS_STARTING", + 3: "INSTANCE_STATUS_RUNNING", + 4: "INSTANCE_STATUS_STOPPING", + 5: "INSTANCE_STATUS_CRASHED", + 6: "INSTANCE_STATUS_UPDATING", + } + InstanceStatus_value = map[string]int32{ + "INSTANCE_STATUS_UNSPECIFIED": 0, + "INSTANCE_STATUS_STOPPED": 1, + "INSTANCE_STATUS_STARTING": 2, + "INSTANCE_STATUS_RUNNING": 3, + "INSTANCE_STATUS_STOPPING": 4, + "INSTANCE_STATUS_CRASHED": 5, + "INSTANCE_STATUS_UPDATING": 6, + } +) + +func (x InstanceStatus) Enum() *InstanceStatus { + p := new(InstanceStatus) + *p = x + return p +} + +func (x InstanceStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (InstanceStatus) Descriptor() protoreflect.EnumDescriptor { + return file_panel_v1_common_proto_enumTypes[1].Descriptor() +} + +func (InstanceStatus) Type() protoreflect.EnumType { + return &file_panel_v1_common_proto_enumTypes[1] +} + +func (x InstanceStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use InstanceStatus.Descriptor instead. +func (InstanceStatus) EnumDescriptor() ([]byte, []int) { + return file_panel_v1_common_proto_rawDescGZIP(), []int{1} +} + +// ResourceLimits caps an instance's CPU / memory / disk usage. +type ResourceLimits struct { + state protoimpl.MessageState `protogen:"open.v1"` + CpuShares uint32 `protobuf:"varint,1,opt,name=cpu_shares,json=cpuShares,proto3" json:"cpu_shares,omitempty"` // relative weight, 1024 = 1 core equivalent + MemBytes uint64 `protobuf:"varint,2,opt,name=mem_bytes,json=memBytes,proto3" json:"mem_bytes,omitempty"` // hard memory ceiling + DiskBytes uint64 `protobuf:"varint,3,opt,name=disk_bytes,json=diskBytes,proto3" json:"disk_bytes,omitempty"` // data dir quota, 0 = unlimited + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResourceLimits) Reset() { + *x = ResourceLimits{} + mi := &file_panel_v1_common_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResourceLimits) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResourceLimits) ProtoMessage() {} + +func (x *ResourceLimits) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_common_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResourceLimits.ProtoReflect.Descriptor instead. +func (*ResourceLimits) Descriptor() ([]byte, []int) { + return file_panel_v1_common_proto_rawDescGZIP(), []int{0} +} + +func (x *ResourceLimits) GetCpuShares() uint32 { + if x != nil { + return x.CpuShares + } + return 0 +} + +func (x *ResourceLimits) GetMemBytes() uint64 { + if x != nil { + return x.MemBytes + } + return 0 +} + +func (x *ResourceLimits) GetDiskBytes() uint64 { + if x != nil { + return x.DiskBytes + } + return 0 +} + +// PortMap declares a port exposed by an instance. +type PortMap struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // logical name: "game", "rcon", "query" + Proto string `protobuf:"bytes,2,opt,name=proto,proto3" json:"proto,omitempty"` // "tcp" or "udp" + ContainerPort uint32 `protobuf:"varint,3,opt,name=container_port,json=containerPort,proto3" json:"container_port,omitempty"` // port inside the sandbox + HostPort uint32 `protobuf:"varint,4,opt,name=host_port,json=hostPort,proto3" json:"host_port,omitempty"` // port on the Target host (0 = same as container_port) + Internal bool `protobuf:"varint,5,opt,name=internal,proto3" json:"internal,omitempty"` // true = not exposed outside the Target + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PortMap) Reset() { + *x = PortMap{} + mi := &file_panel_v1_common_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PortMap) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PortMap) ProtoMessage() {} + +func (x *PortMap) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_common_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PortMap.ProtoReflect.Descriptor instead. +func (*PortMap) Descriptor() ([]byte, []int) { + return file_panel_v1_common_proto_rawDescGZIP(), []int{1} +} + +func (x *PortMap) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PortMap) GetProto() string { + if x != nil { + return x.Proto + } + return "" +} + +func (x *PortMap) GetContainerPort() uint32 { + if x != nil { + return x.ContainerPort + } + return 0 +} + +func (x *PortMap) GetHostPort() uint32 { + if x != nil { + return x.HostPort + } + return 0 +} + +func (x *PortMap) GetInternal() bool { + if x != nil { + return x.Internal + } + return false +} + +// Error is a structured error payload carried on the wire. +type Error struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` // short machine-readable code + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // human-readable message + At *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=at,proto3" json:"at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Error) Reset() { + *x = Error{} + mi := &file_panel_v1_common_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Error) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Error) ProtoMessage() {} + +func (x *Error) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_common_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Error.ProtoReflect.Descriptor instead. +func (*Error) Descriptor() ([]byte, []int) { + return file_panel_v1_common_proto_rawDescGZIP(), []int{2} +} + +func (x *Error) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *Error) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *Error) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +var File_panel_v1_common_proto protoreflect.FileDescriptor + +const file_panel_v1_common_proto_rawDesc = "" + + "\n" + + "\x15panel/v1/common.proto\x12\bpanel.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"k\n" + + "\x0eResourceLimits\x12\x1d\n" + + "\n" + + "cpu_shares\x18\x01 \x01(\rR\tcpuShares\x12\x1b\n" + + "\tmem_bytes\x18\x02 \x01(\x04R\bmemBytes\x12\x1d\n" + + "\n" + + "disk_bytes\x18\x03 \x01(\x04R\tdiskBytes\"\x93\x01\n" + + "\aPortMap\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05proto\x18\x02 \x01(\tR\x05proto\x12%\n" + + "\x0econtainer_port\x18\x03 \x01(\rR\rcontainerPort\x12\x1b\n" + + "\thost_port\x18\x04 \x01(\rR\bhostPort\x12\x1a\n" + + "\binternal\x18\x05 \x01(\bR\binternal\"a\n" + + "\x05Error\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12*\n" + + "\x02at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x02at*K\n" + + "\aRunMode\x12\x18\n" + + "\x14RUN_MODE_UNSPECIFIED\x10\x00\x12\x13\n" + + "\x0fRUN_MODE_DOCKER\x10\x01\x12\x11\n" + + "\rRUN_MODE_HOST\x10\x02*\xe2\x01\n" + + "\x0eInstanceStatus\x12\x1f\n" + + "\x1bINSTANCE_STATUS_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17INSTANCE_STATUS_STOPPED\x10\x01\x12\x1c\n" + + "\x18INSTANCE_STATUS_STARTING\x10\x02\x12\x1b\n" + + "\x17INSTANCE_STATUS_RUNNING\x10\x03\x12\x1c\n" + + "\x18INSTANCE_STATUS_STOPPING\x10\x04\x12\x1b\n" + + "\x17INSTANCE_STATUS_CRASHED\x10\x05\x12\x1c\n" + + "\x18INSTANCE_STATUS_UPDATING\x10\x06B2Z0github.com/dbledeez/panel/proto/panel/v1;panelv1b\x06proto3" + +var ( + file_panel_v1_common_proto_rawDescOnce sync.Once + file_panel_v1_common_proto_rawDescData []byte +) + +func file_panel_v1_common_proto_rawDescGZIP() []byte { + file_panel_v1_common_proto_rawDescOnce.Do(func() { + file_panel_v1_common_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_panel_v1_common_proto_rawDesc), len(file_panel_v1_common_proto_rawDesc))) + }) + return file_panel_v1_common_proto_rawDescData +} + +var file_panel_v1_common_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_panel_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_panel_v1_common_proto_goTypes = []any{ + (RunMode)(0), // 0: panel.v1.RunMode + (InstanceStatus)(0), // 1: panel.v1.InstanceStatus + (*ResourceLimits)(nil), // 2: panel.v1.ResourceLimits + (*PortMap)(nil), // 3: panel.v1.PortMap + (*Error)(nil), // 4: panel.v1.Error + (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp +} +var file_panel_v1_common_proto_depIdxs = []int32{ + 5, // 0: panel.v1.Error.at:type_name -> google.protobuf.Timestamp + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_panel_v1_common_proto_init() } +func file_panel_v1_common_proto_init() { + if File_panel_v1_common_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_panel_v1_common_proto_rawDesc), len(file_panel_v1_common_proto_rawDesc)), + NumEnums: 2, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_panel_v1_common_proto_goTypes, + DependencyIndexes: file_panel_v1_common_proto_depIdxs, + EnumInfos: file_panel_v1_common_proto_enumTypes, + MessageInfos: file_panel_v1_common_proto_msgTypes, + }.Build() + File_panel_v1_common_proto = out.File + file_panel_v1_common_proto_goTypes = nil + file_panel_v1_common_proto_depIdxs = nil +} diff --git a/proto/panel/v1/common.proto b/proto/panel/v1/common.proto new file mode 100644 index 00000000..897d3d98 --- /dev/null +++ b/proto/panel/v1/common.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package panel.v1; + +option go_package = "github.com/dbledeez/panel/proto/panel/v1;panelv1"; + +import "google/protobuf/timestamp.proto"; + +// RunMode selects how a game instance is launched on a Target. +enum RunMode { + RUN_MODE_UNSPECIFIED = 0; + RUN_MODE_DOCKER = 1; + RUN_MODE_HOST = 2; +} + +// InstanceStatus is the coarse lifecycle state of an instance. +enum InstanceStatus { + INSTANCE_STATUS_UNSPECIFIED = 0; + INSTANCE_STATUS_STOPPED = 1; + INSTANCE_STATUS_STARTING = 2; + INSTANCE_STATUS_RUNNING = 3; + INSTANCE_STATUS_STOPPING = 4; + INSTANCE_STATUS_CRASHED = 5; + INSTANCE_STATUS_UPDATING = 6; +} + +// ResourceLimits caps an instance's CPU / memory / disk usage. +message ResourceLimits { + uint32 cpu_shares = 1; // relative weight, 1024 = 1 core equivalent + uint64 mem_bytes = 2; // hard memory ceiling + uint64 disk_bytes = 3; // data dir quota, 0 = unlimited +} + +// PortMap declares a port exposed by an instance. +message PortMap { + string name = 1; // logical name: "game", "rcon", "query" + string proto = 2; // "tcp" or "udp" + uint32 container_port = 3; // port inside the sandbox + uint32 host_port = 4; // port on the Target host (0 = same as container_port) + bool internal = 5; // true = not exposed outside the Target +} + +// Error is a structured error payload carried on the wire. +message Error { + string code = 1; // short machine-readable code + string message = 2; // human-readable message + google.protobuf.Timestamp at = 3; +} diff --git a/proto/panel/v1/panel.pb.go b/proto/panel/v1/panel.pb.go new file mode 100644 index 00000000..7733c48f --- /dev/null +++ b/proto/panel/v1/panel.pb.go @@ -0,0 +1,1858 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: panel/v1/panel.proto + +package panelv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ListAgentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAgentsRequest) Reset() { + *x = ListAgentsRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAgentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentsRequest) ProtoMessage() {} + +func (x *ListAgentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAgentsRequest.ProtoReflect.Descriptor instead. +func (*ListAgentsRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{0} +} + +type ListAgentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Agents []*AgentInfo `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAgentsResponse) Reset() { + *x = ListAgentsResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAgentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentsResponse) ProtoMessage() {} + +func (x *ListAgentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAgentsResponse.ProtoReflect.Descriptor instead. +func (*ListAgentsResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{1} +} + +func (x *ListAgentsResponse) GetAgents() []*AgentInfo { + if x != nil { + return x.Agents + } + return nil +} + +type AgentInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + HostOs string `protobuf:"bytes,3,opt,name=host_os,json=hostOs,proto3" json:"host_os,omitempty"` + HostArch string `protobuf:"bytes,4,opt,name=host_arch,json=hostArch,proto3" json:"host_arch,omitempty"` + Hostname string `protobuf:"bytes,5,opt,name=hostname,proto3" json:"hostname,omitempty"` + ConnectedAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=connected_at,json=connectedAt,proto3" json:"connected_at,omitempty"` + LastSeen *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_seen,json=lastSeen,proto3" json:"last_seen,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentInfo) Reset() { + *x = AgentInfo{} + mi := &file_panel_v1_panel_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentInfo) ProtoMessage() {} + +func (x *AgentInfo) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentInfo.ProtoReflect.Descriptor instead. +func (*AgentInfo) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{2} +} + +func (x *AgentInfo) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentInfo) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AgentInfo) GetHostOs() string { + if x != nil { + return x.HostOs + } + return "" +} + +func (x *AgentInfo) GetHostArch() string { + if x != nil { + return x.HostArch + } + return "" +} + +func (x *AgentInfo) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *AgentInfo) GetConnectedAt() *timestamppb.Timestamp { + if x != nil { + return x.ConnectedAt + } + return nil +} + +func (x *AgentInfo) GetLastSeen() *timestamppb.Timestamp { + if x != nil { + return x.LastSeen + } + return nil +} + +type CreateInstanceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + ModuleId string `protobuf:"bytes,3,opt,name=module_id,json=moduleId,proto3" json:"module_id,omitempty"` + Version string `protobuf:"bytes,4,opt,name=version,proto3" json:"version,omitempty"` + RunMode RunMode `protobuf:"varint,5,opt,name=run_mode,json=runMode,proto3,enum=panel.v1.RunMode" json:"run_mode,omitempty"` + DataPath string `protobuf:"bytes,6,opt,name=data_path,json=dataPath,proto3" json:"data_path,omitempty"` // optional; agent will derive if empty + ConfigValues map[string]string `protobuf:"bytes,7,rep,name=config_values,json=configValues,proto3" json:"config_values,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` // user-supplied overrides, module-validated + Ports []*PortMap `protobuf:"bytes,8,rep,name=ports,proto3" json:"ports,omitempty"` // port overrides + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateInstanceRequest) Reset() { + *x = CreateInstanceRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateInstanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateInstanceRequest) ProtoMessage() {} + +func (x *CreateInstanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateInstanceRequest.ProtoReflect.Descriptor instead. +func (*CreateInstanceRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{3} +} + +func (x *CreateInstanceRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *CreateInstanceRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *CreateInstanceRequest) GetModuleId() string { + if x != nil { + return x.ModuleId + } + return "" +} + +func (x *CreateInstanceRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *CreateInstanceRequest) GetRunMode() RunMode { + if x != nil { + return x.RunMode + } + return RunMode_RUN_MODE_UNSPECIFIED +} + +func (x *CreateInstanceRequest) GetDataPath() string { + if x != nil { + return x.DataPath + } + return "" +} + +func (x *CreateInstanceRequest) GetConfigValues() map[string]string { + if x != nil { + return x.ConfigValues + } + return nil +} + +func (x *CreateInstanceRequest) GetPorts() []*PortMap { + if x != nil { + return x.Ports + } + return nil +} + +type CreateInstanceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + CorrelationId string `protobuf:"bytes,2,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateInstanceResponse) Reset() { + *x = CreateInstanceResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateInstanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateInstanceResponse) ProtoMessage() {} + +func (x *CreateInstanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateInstanceResponse.ProtoReflect.Descriptor instead. +func (*CreateInstanceResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateInstanceResponse) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *CreateInstanceResponse) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +type StartInstanceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartInstanceRequest) Reset() { + *x = StartInstanceRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartInstanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartInstanceRequest) ProtoMessage() {} + +func (x *StartInstanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartInstanceRequest.ProtoReflect.Descriptor instead. +func (*StartInstanceRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{5} +} + +func (x *StartInstanceRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *StartInstanceRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +type StartInstanceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + CorrelationId string `protobuf:"bytes,1,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartInstanceResponse) Reset() { + *x = StartInstanceResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartInstanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartInstanceResponse) ProtoMessage() {} + +func (x *StartInstanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartInstanceResponse.ProtoReflect.Descriptor instead. +func (*StartInstanceResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{6} +} + +func (x *StartInstanceResponse) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +type StopInstanceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + GraceSeconds uint32 `protobuf:"varint,3,opt,name=grace_seconds,json=graceSeconds,proto3" json:"grace_seconds,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopInstanceRequest) Reset() { + *x = StopInstanceRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopInstanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopInstanceRequest) ProtoMessage() {} + +func (x *StopInstanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StopInstanceRequest.ProtoReflect.Descriptor instead. +func (*StopInstanceRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{7} +} + +func (x *StopInstanceRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *StopInstanceRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *StopInstanceRequest) GetGraceSeconds() uint32 { + if x != nil { + return x.GraceSeconds + } + return 0 +} + +type StopInstanceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + CorrelationId string `protobuf:"bytes,1,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StopInstanceResponse) Reset() { + *x = StopInstanceResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StopInstanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StopInstanceResponse) ProtoMessage() {} + +func (x *StopInstanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StopInstanceResponse.ProtoReflect.Descriptor instead. +func (*StopInstanceResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{8} +} + +func (x *StopInstanceResponse) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +type DeleteInstanceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + GraceSeconds uint32 `protobuf:"varint,3,opt,name=grace_seconds,json=graceSeconds,proto3" json:"grace_seconds,omitempty"` + PurgeVolumes bool `protobuf:"varint,4,opt,name=purge_volumes,json=purgeVolumes,proto3" json:"purge_volumes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteInstanceRequest) Reset() { + *x = DeleteInstanceRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteInstanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteInstanceRequest) ProtoMessage() {} + +func (x *DeleteInstanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteInstanceRequest.ProtoReflect.Descriptor instead. +func (*DeleteInstanceRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteInstanceRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *DeleteInstanceRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *DeleteInstanceRequest) GetGraceSeconds() uint32 { + if x != nil { + return x.GraceSeconds + } + return 0 +} + +func (x *DeleteInstanceRequest) GetPurgeVolumes() bool { + if x != nil { + return x.PurgeVolumes + } + return false +} + +type DeleteInstanceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + CorrelationId string `protobuf:"bytes,1,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteInstanceResponse) Reset() { + *x = DeleteInstanceResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteInstanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteInstanceResponse) ProtoMessage() {} + +func (x *DeleteInstanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteInstanceResponse.ProtoReflect.Descriptor instead. +func (*DeleteInstanceResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{10} +} + +func (x *DeleteInstanceResponse) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +// ExecRCON sends an ad-hoc RCON command to the running instance and waits +// synchronously for the agent's response. The controller correlates the +// request → response round-trip by correlation_id. +type ExecRCONRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Command string `protobuf:"bytes,3,opt,name=command,proto3" json:"command,omitempty"` + TimeoutMs uint32 `protobuf:"varint,4,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"` // optional; default 10000 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRCONRequest) Reset() { + *x = ExecRCONRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRCONRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRCONRequest) ProtoMessage() {} + +func (x *ExecRCONRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRCONRequest.ProtoReflect.Descriptor instead. +func (*ExecRCONRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{11} +} + +func (x *ExecRCONRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *ExecRCONRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *ExecRCONRequest) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *ExecRCONRequest) GetTimeoutMs() uint32 { + if x != nil { + return x.TimeoutMs + } + return 0 +} + +type ExecRCONResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Output string `protobuf:"bytes,1,opt,name=output,proto3" json:"output,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + CorrelationId string `protobuf:"bytes,3,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRCONResponse) Reset() { + *x = ExecRCONResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRCONResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRCONResponse) ProtoMessage() {} + +func (x *ExecRCONResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRCONResponse.ProtoReflect.Descriptor instead. +func (*ExecRCONResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{12} +} + +func (x *ExecRCONResponse) GetOutput() string { + if x != nil { + return x.Output + } + return "" +} + +func (x *ExecRCONResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *ExecRCONResponse) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +// UpdateInstance kicks off a module-declared update provider against the +// target instance. The agent runs it asynchronously; progress streams as +// LogLines on the event bus. +type UpdateInstanceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + ProviderId string `protobuf:"bytes,3,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` // empty = first provider + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateInstanceRequest) Reset() { + *x = UpdateInstanceRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateInstanceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateInstanceRequest) ProtoMessage() {} + +func (x *UpdateInstanceRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateInstanceRequest.ProtoReflect.Descriptor instead. +func (*UpdateInstanceRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{13} +} + +func (x *UpdateInstanceRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *UpdateInstanceRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *UpdateInstanceRequest) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +type UpdateInstanceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + ProviderId string `protobuf:"bytes,3,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` + ProviderKind string `protobuf:"bytes,4,opt,name=provider_kind,json=providerKind,proto3" json:"provider_kind,omitempty"` + CorrelationId string `protobuf:"bytes,5,opt,name=correlation_id,json=correlationId,proto3" json:"correlation_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateInstanceResponse) Reset() { + *x = UpdateInstanceResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateInstanceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateInstanceResponse) ProtoMessage() {} + +func (x *UpdateInstanceResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateInstanceResponse.ProtoReflect.Descriptor instead. +func (*UpdateInstanceResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{14} +} + +func (x *UpdateInstanceResponse) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +func (x *UpdateInstanceResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +func (x *UpdateInstanceResponse) GetProviderId() string { + if x != nil { + return x.ProviderId + } + return "" +} + +func (x *UpdateInstanceResponse) GetProviderKind() string { + if x != nil { + return x.ProviderKind + } + return "" +} + +func (x *UpdateInstanceResponse) GetCorrelationId() string { + if x != nil { + return x.CorrelationId + } + return "" +} + +type CreateBackupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateBackupRequest) Reset() { + *x = CreateBackupRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateBackupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateBackupRequest) ProtoMessage() {} + +func (x *CreateBackupRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateBackupRequest.ProtoReflect.Descriptor instead. +func (*CreateBackupRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateBackupRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *CreateBackupRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *CreateBackupRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +type CreateBackupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + BackupId string `protobuf:"bytes,1,opt,name=backup_id,json=backupId,proto3" json:"backup_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + SizeBytes int64 `protobuf:"varint,3,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateBackupResponse) Reset() { + *x = CreateBackupResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateBackupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateBackupResponse) ProtoMessage() {} + +func (x *CreateBackupResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateBackupResponse.ProtoReflect.Descriptor instead. +func (*CreateBackupResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{16} +} + +func (x *CreateBackupResponse) GetBackupId() string { + if x != nil { + return x.BackupId + } + return "" +} + +func (x *CreateBackupResponse) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *CreateBackupResponse) GetSizeBytes() int64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +func (x *CreateBackupResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type RestoreBackupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + InstanceId string `protobuf:"bytes,2,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + BackupId string `protobuf:"bytes,3,opt,name=backup_id,json=backupId,proto3" json:"backup_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreBackupRequest) Reset() { + *x = RestoreBackupRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreBackupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreBackupRequest) ProtoMessage() {} + +func (x *RestoreBackupRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreBackupRequest.ProtoReflect.Descriptor instead. +func (*RestoreBackupRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{17} +} + +func (x *RestoreBackupRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *RestoreBackupRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *RestoreBackupRequest) GetBackupId() string { + if x != nil { + return x.BackupId + } + return "" +} + +type RestoreBackupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + BytesRestored int64 `protobuf:"varint,1,opt,name=bytes_restored,json=bytesRestored,proto3" json:"bytes_restored,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RestoreBackupResponse) Reset() { + *x = RestoreBackupResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RestoreBackupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RestoreBackupResponse) ProtoMessage() {} + +func (x *RestoreBackupResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RestoreBackupResponse.ProtoReflect.Descriptor instead. +func (*RestoreBackupResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{18} +} + +func (x *RestoreBackupResponse) GetBytesRestored() int64 { + if x != nil { + return x.BytesRestored + } + return 0 +} + +func (x *RestoreBackupResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type InstanceInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + ModuleId string `protobuf:"bytes,3,opt,name=module_id,json=moduleId,proto3" json:"module_id,omitempty"` + ModuleVersion string `protobuf:"bytes,4,opt,name=module_version,json=moduleVersion,proto3" json:"module_version,omitempty"` + RunMode RunMode `protobuf:"varint,5,opt,name=run_mode,json=runMode,proto3,enum=panel.v1.RunMode" json:"run_mode,omitempty"` + Status InstanceStatus `protobuf:"varint,6,opt,name=status,proto3,enum=panel.v1.InstanceStatus" json:"status,omitempty"` + LastExitCode int32 `protobuf:"varint,7,opt,name=last_exit_code,json=lastExitCode,proto3" json:"last_exit_code,omitempty"` + DataPath string `protobuf:"bytes,8,opt,name=data_path,json=dataPath,proto3" json:"data_path,omitempty"` + Detail string `protobuf:"bytes,9,opt,name=detail,proto3" json:"detail,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InstanceInfo) Reset() { + *x = InstanceInfo{} + mi := &file_panel_v1_panel_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InstanceInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InstanceInfo) ProtoMessage() {} + +func (x *InstanceInfo) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InstanceInfo.ProtoReflect.Descriptor instead. +func (*InstanceInfo) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{19} +} + +func (x *InstanceInfo) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *InstanceInfo) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *InstanceInfo) GetModuleId() string { + if x != nil { + return x.ModuleId + } + return "" +} + +func (x *InstanceInfo) GetModuleVersion() string { + if x != nil { + return x.ModuleVersion + } + return "" +} + +func (x *InstanceInfo) GetRunMode() RunMode { + if x != nil { + return x.RunMode + } + return RunMode_RUN_MODE_UNSPECIFIED +} + +func (x *InstanceInfo) GetStatus() InstanceStatus { + if x != nil { + return x.Status + } + return InstanceStatus_INSTANCE_STATUS_UNSPECIFIED +} + +func (x *InstanceInfo) GetLastExitCode() int32 { + if x != nil { + return x.LastExitCode + } + return 0 +} + +func (x *InstanceInfo) GetDataPath() string { + if x != nil { + return x.DataPath + } + return "" +} + +func (x *InstanceInfo) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +func (x *InstanceInfo) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *InstanceInfo) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +type ListInstancesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` // optional filter by agent + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListInstancesRequest) Reset() { + *x = ListInstancesRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListInstancesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListInstancesRequest) ProtoMessage() {} + +func (x *ListInstancesRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListInstancesRequest.ProtoReflect.Descriptor instead. +func (*ListInstancesRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{20} +} + +func (x *ListInstancesRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +type ListInstancesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Instances []*InstanceInfo `protobuf:"bytes,1,rep,name=instances,proto3" json:"instances,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListInstancesResponse) Reset() { + *x = ListInstancesResponse{} + mi := &file_panel_v1_panel_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListInstancesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListInstancesResponse) ProtoMessage() {} + +func (x *ListInstancesResponse) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListInstancesResponse.ProtoReflect.Descriptor instead. +func (*ListInstancesResponse) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{21} +} + +func (x *ListInstancesResponse) GetInstances() []*InstanceInfo { + if x != nil { + return x.Instances + } + return nil +} + +// StreamEvents — server streaming of the live event bus. Filters are +// optional AND-ed; empty strings = no filter on that field. +type StreamEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"` + AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamEventsRequest) Reset() { + *x = StreamEventsRequest{} + mi := &file_panel_v1_panel_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamEventsRequest) ProtoMessage() {} + +func (x *StreamEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamEventsRequest.ProtoReflect.Descriptor instead. +func (*StreamEventsRequest) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{22} +} + +func (x *StreamEventsRequest) GetInstanceId() string { + if x != nil { + return x.InstanceId + } + return "" +} + +func (x *StreamEventsRequest) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +// Event is the controller's normalized form of everything an agent sends +// up that isn't a one-shot command result. It wraps the original payload +// and annotates it with the agent_id for filtering + display. +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + At *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=at,proto3" json:"at,omitempty"` + // Types that are valid to be assigned to Payload: + // + // *Event_InstanceState + // *Event_AppState + // *Event_Player + // *Event_Log + // *Event_InstanceStats + Payload isEvent_Payload `protobuf_oneof:"payload"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Event) Reset() { + *x = Event{} + mi := &file_panel_v1_panel_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Event) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Event) ProtoMessage() {} + +func (x *Event) ProtoReflect() protoreflect.Message { + mi := &file_panel_v1_panel_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_panel_v1_panel_proto_rawDescGZIP(), []int{23} +} + +func (x *Event) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *Event) GetAt() *timestamppb.Timestamp { + if x != nil { + return x.At + } + return nil +} + +func (x *Event) GetPayload() isEvent_Payload { + if x != nil { + return x.Payload + } + return nil +} + +func (x *Event) GetInstanceState() *InstanceStateUpdate { + if x != nil { + if x, ok := x.Payload.(*Event_InstanceState); ok { + return x.InstanceState + } + } + return nil +} + +func (x *Event) GetAppState() *AppStateUpdate { + if x != nil { + if x, ok := x.Payload.(*Event_AppState); ok { + return x.AppState + } + } + return nil +} + +func (x *Event) GetPlayer() *PlayerEvent { + if x != nil { + if x, ok := x.Payload.(*Event_Player); ok { + return x.Player + } + } + return nil +} + +func (x *Event) GetLog() *LogLine { + if x != nil { + if x, ok := x.Payload.(*Event_Log); ok { + return x.Log + } + } + return nil +} + +func (x *Event) GetInstanceStats() *InstanceStatsUpdate { + if x != nil { + if x, ok := x.Payload.(*Event_InstanceStats); ok { + return x.InstanceStats + } + } + return nil +} + +type isEvent_Payload interface { + isEvent_Payload() +} + +type Event_InstanceState struct { + InstanceState *InstanceStateUpdate `protobuf:"bytes,10,opt,name=instance_state,json=instanceState,proto3,oneof"` +} + +type Event_AppState struct { + AppState *AppStateUpdate `protobuf:"bytes,11,opt,name=app_state,json=appState,proto3,oneof"` +} + +type Event_Player struct { + Player *PlayerEvent `protobuf:"bytes,12,opt,name=player,proto3,oneof"` +} + +type Event_Log struct { + Log *LogLine `protobuf:"bytes,13,opt,name=log,proto3,oneof"` +} + +type Event_InstanceStats struct { + InstanceStats *InstanceStatsUpdate `protobuf:"bytes,14,opt,name=instance_stats,json=instanceStats,proto3,oneof"` +} + +func (*Event_InstanceState) isEvent_Payload() {} + +func (*Event_AppState) isEvent_Payload() {} + +func (*Event_Player) isEvent_Payload() {} + +func (*Event_Log) isEvent_Payload() {} + +func (*Event_InstanceStats) isEvent_Payload() {} + +var File_panel_v1_panel_proto protoreflect.FileDescriptor + +const file_panel_v1_panel_proto_rawDesc = "" + + "\n" + + "\x14panel/v1/panel.proto\x12\bpanel.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x14panel/v1/agent.proto\x1a\x15panel/v1/common.proto\"\x13\n" + + "\x11ListAgentsRequest\"A\n" + + "\x12ListAgentsResponse\x12+\n" + + "\x06agents\x18\x01 \x03(\v2\x13.panel.v1.AgentInfoR\x06agents\"\x8a\x02\n" + + "\tAgentInfo\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12\x17\n" + + "\ahost_os\x18\x03 \x01(\tR\x06hostOs\x12\x1b\n" + + "\thost_arch\x18\x04 \x01(\tR\bhostArch\x12\x1a\n" + + "\bhostname\x18\x05 \x01(\tR\bhostname\x12=\n" + + "\fconnected_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\vconnectedAt\x127\n" + + "\tlast_seen\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\blastSeen\"\x97\x03\n" + + "\x15CreateInstanceRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tmodule_id\x18\x03 \x01(\tR\bmoduleId\x12\x18\n" + + "\aversion\x18\x04 \x01(\tR\aversion\x12,\n" + + "\brun_mode\x18\x05 \x01(\x0e2\x11.panel.v1.RunModeR\arunMode\x12\x1b\n" + + "\tdata_path\x18\x06 \x01(\tR\bdataPath\x12V\n" + + "\rconfig_values\x18\a \x03(\v21.panel.v1.CreateInstanceRequest.ConfigValuesEntryR\fconfigValues\x12'\n" + + "\x05ports\x18\b \x03(\v2\x11.panel.v1.PortMapR\x05ports\x1a?\n" + + "\x11ConfigValuesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"`\n" + + "\x16CreateInstanceResponse\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12%\n" + + "\x0ecorrelation_id\x18\x02 \x01(\tR\rcorrelationId\"R\n" + + "\x14StartInstanceRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\">\n" + + "\x15StartInstanceResponse\x12%\n" + + "\x0ecorrelation_id\x18\x01 \x01(\tR\rcorrelationId\"v\n" + + "\x13StopInstanceRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12#\n" + + "\rgrace_seconds\x18\x03 \x01(\rR\fgraceSeconds\"=\n" + + "\x14StopInstanceResponse\x12%\n" + + "\x0ecorrelation_id\x18\x01 \x01(\tR\rcorrelationId\"\x9d\x01\n" + + "\x15DeleteInstanceRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12#\n" + + "\rgrace_seconds\x18\x03 \x01(\rR\fgraceSeconds\x12#\n" + + "\rpurge_volumes\x18\x04 \x01(\bR\fpurgeVolumes\"?\n" + + "\x16DeleteInstanceResponse\x12%\n" + + "\x0ecorrelation_id\x18\x01 \x01(\tR\rcorrelationId\"\x86\x01\n" + + "\x0fExecRCONRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12\x18\n" + + "\acommand\x18\x03 \x01(\tR\acommand\x12\x1d\n" + + "\n" + + "timeout_ms\x18\x04 \x01(\rR\ttimeoutMs\"g\n" + + "\x10ExecRCONResponse\x12\x16\n" + + "\x06output\x18\x01 \x01(\tR\x06output\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\x12%\n" + + "\x0ecorrelation_id\x18\x03 \x01(\tR\rcorrelationId\"t\n" + + "\x15UpdateInstanceRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12\x1f\n" + + "\vprovider_id\x18\x03 \x01(\tR\n" + + "providerId\"\xb7\x01\n" + + "\x16UpdateInstanceResponse\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\x12\x1f\n" + + "\vprovider_id\x18\x03 \x01(\tR\n" + + "providerId\x12#\n" + + "\rprovider_kind\x18\x04 \x01(\tR\fproviderKind\x12%\n" + + "\x0ecorrelation_id\x18\x05 \x01(\tR\rcorrelationId\"s\n" + + "\x13CreateBackupRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12 \n" + + "\vdescription\x18\x03 \x01(\tR\vdescription\"|\n" + + "\x14CreateBackupResponse\x12\x1b\n" + + "\tbackup_id\x18\x01 \x01(\tR\bbackupId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x1d\n" + + "\n" + + "size_bytes\x18\x03 \x01(\x03R\tsizeBytes\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"o\n" + + "\x14RestoreBackupRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12\x1f\n" + + "\vinstance_id\x18\x02 \x01(\tR\n" + + "instanceId\x12\x1b\n" + + "\tbackup_id\x18\x03 \x01(\tR\bbackupId\"T\n" + + "\x15RestoreBackupResponse\x12%\n" + + "\x0ebytes_restored\x18\x01 \x01(\x03R\rbytesRestored\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\xbf\x03\n" + + "\fInstanceInfo\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x19\n" + + "\bagent_id\x18\x02 \x01(\tR\aagentId\x12\x1b\n" + + "\tmodule_id\x18\x03 \x01(\tR\bmoduleId\x12%\n" + + "\x0emodule_version\x18\x04 \x01(\tR\rmoduleVersion\x12,\n" + + "\brun_mode\x18\x05 \x01(\x0e2\x11.panel.v1.RunModeR\arunMode\x120\n" + + "\x06status\x18\x06 \x01(\x0e2\x18.panel.v1.InstanceStatusR\x06status\x12$\n" + + "\x0elast_exit_code\x18\a \x01(\x05R\flastExitCode\x12\x1b\n" + + "\tdata_path\x18\b \x01(\tR\bdataPath\x12\x16\n" + + "\x06detail\x18\t \x01(\tR\x06detail\x129\n" + + "\n" + + "created_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + + "\n" + + "updated_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"1\n" + + "\x14ListInstancesRequest\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\"M\n" + + "\x15ListInstancesResponse\x124\n" + + "\tinstances\x18\x01 \x03(\v2\x16.panel.v1.InstanceInfoR\tinstances\"Q\n" + + "\x13StreamEventsRequest\x12\x1f\n" + + "\vinstance_id\x18\x01 \x01(\tR\n" + + "instanceId\x12\x19\n" + + "\bagent_id\x18\x02 \x01(\tR\aagentId\"\xfa\x02\n" + + "\x05Event\x12\x19\n" + + "\bagent_id\x18\x01 \x01(\tR\aagentId\x12*\n" + + "\x02at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x02at\x12F\n" + + "\x0einstance_state\x18\n" + + " \x01(\v2\x1d.panel.v1.InstanceStateUpdateH\x00R\rinstanceState\x127\n" + + "\tapp_state\x18\v \x01(\v2\x18.panel.v1.AppStateUpdateH\x00R\bappState\x12/\n" + + "\x06player\x18\f \x01(\v2\x15.panel.v1.PlayerEventH\x00R\x06player\x12%\n" + + "\x03log\x18\r \x01(\v2\x11.panel.v1.LogLineH\x00R\x03log\x12F\n" + + "\x0einstance_stats\x18\x0e \x01(\v2\x1d.panel.v1.InstanceStatsUpdateH\x00R\rinstanceStatsB\t\n" + + "\apayload2\xe8\x06\n" + + "\x05Panel\x12G\n" + + "\n" + + "ListAgents\x12\x1b.panel.v1.ListAgentsRequest\x1a\x1c.panel.v1.ListAgentsResponse\x12P\n" + + "\rListInstances\x12\x1e.panel.v1.ListInstancesRequest\x1a\x1f.panel.v1.ListInstancesResponse\x12S\n" + + "\x0eCreateInstance\x12\x1f.panel.v1.CreateInstanceRequest\x1a .panel.v1.CreateInstanceResponse\x12P\n" + + "\rStartInstance\x12\x1e.panel.v1.StartInstanceRequest\x1a\x1f.panel.v1.StartInstanceResponse\x12M\n" + + "\fStopInstance\x12\x1d.panel.v1.StopInstanceRequest\x1a\x1e.panel.v1.StopInstanceResponse\x12S\n" + + "\x0eDeleteInstance\x12\x1f.panel.v1.DeleteInstanceRequest\x1a .panel.v1.DeleteInstanceResponse\x12A\n" + + "\bExecRCON\x12\x19.panel.v1.ExecRCONRequest\x1a\x1a.panel.v1.ExecRCONResponse\x12S\n" + + "\x0eUpdateInstance\x12\x1f.panel.v1.UpdateInstanceRequest\x1a .panel.v1.UpdateInstanceResponse\x12M\n" + + "\fCreateBackup\x12\x1d.panel.v1.CreateBackupRequest\x1a\x1e.panel.v1.CreateBackupResponse\x12P\n" + + "\rRestoreBackup\x12\x1e.panel.v1.RestoreBackupRequest\x1a\x1f.panel.v1.RestoreBackupResponse\x12@\n" + + "\fStreamEvents\x12\x1d.panel.v1.StreamEventsRequest\x1a\x0f.panel.v1.Event0\x01B2Z0github.com/dbledeez/panel/proto/panel/v1;panelv1b\x06proto3" + +var ( + file_panel_v1_panel_proto_rawDescOnce sync.Once + file_panel_v1_panel_proto_rawDescData []byte +) + +func file_panel_v1_panel_proto_rawDescGZIP() []byte { + file_panel_v1_panel_proto_rawDescOnce.Do(func() { + file_panel_v1_panel_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_panel_v1_panel_proto_rawDesc), len(file_panel_v1_panel_proto_rawDesc))) + }) + return file_panel_v1_panel_proto_rawDescData +} + +var file_panel_v1_panel_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_panel_v1_panel_proto_goTypes = []any{ + (*ListAgentsRequest)(nil), // 0: panel.v1.ListAgentsRequest + (*ListAgentsResponse)(nil), // 1: panel.v1.ListAgentsResponse + (*AgentInfo)(nil), // 2: panel.v1.AgentInfo + (*CreateInstanceRequest)(nil), // 3: panel.v1.CreateInstanceRequest + (*CreateInstanceResponse)(nil), // 4: panel.v1.CreateInstanceResponse + (*StartInstanceRequest)(nil), // 5: panel.v1.StartInstanceRequest + (*StartInstanceResponse)(nil), // 6: panel.v1.StartInstanceResponse + (*StopInstanceRequest)(nil), // 7: panel.v1.StopInstanceRequest + (*StopInstanceResponse)(nil), // 8: panel.v1.StopInstanceResponse + (*DeleteInstanceRequest)(nil), // 9: panel.v1.DeleteInstanceRequest + (*DeleteInstanceResponse)(nil), // 10: panel.v1.DeleteInstanceResponse + (*ExecRCONRequest)(nil), // 11: panel.v1.ExecRCONRequest + (*ExecRCONResponse)(nil), // 12: panel.v1.ExecRCONResponse + (*UpdateInstanceRequest)(nil), // 13: panel.v1.UpdateInstanceRequest + (*UpdateInstanceResponse)(nil), // 14: panel.v1.UpdateInstanceResponse + (*CreateBackupRequest)(nil), // 15: panel.v1.CreateBackupRequest + (*CreateBackupResponse)(nil), // 16: panel.v1.CreateBackupResponse + (*RestoreBackupRequest)(nil), // 17: panel.v1.RestoreBackupRequest + (*RestoreBackupResponse)(nil), // 18: panel.v1.RestoreBackupResponse + (*InstanceInfo)(nil), // 19: panel.v1.InstanceInfo + (*ListInstancesRequest)(nil), // 20: panel.v1.ListInstancesRequest + (*ListInstancesResponse)(nil), // 21: panel.v1.ListInstancesResponse + (*StreamEventsRequest)(nil), // 22: panel.v1.StreamEventsRequest + (*Event)(nil), // 23: panel.v1.Event + nil, // 24: panel.v1.CreateInstanceRequest.ConfigValuesEntry + (*timestamppb.Timestamp)(nil), // 25: google.protobuf.Timestamp + (RunMode)(0), // 26: panel.v1.RunMode + (*PortMap)(nil), // 27: panel.v1.PortMap + (InstanceStatus)(0), // 28: panel.v1.InstanceStatus + (*InstanceStateUpdate)(nil), // 29: panel.v1.InstanceStateUpdate + (*AppStateUpdate)(nil), // 30: panel.v1.AppStateUpdate + (*PlayerEvent)(nil), // 31: panel.v1.PlayerEvent + (*LogLine)(nil), // 32: panel.v1.LogLine + (*InstanceStatsUpdate)(nil), // 33: panel.v1.InstanceStatsUpdate +} +var file_panel_v1_panel_proto_depIdxs = []int32{ + 2, // 0: panel.v1.ListAgentsResponse.agents:type_name -> panel.v1.AgentInfo + 25, // 1: panel.v1.AgentInfo.connected_at:type_name -> google.protobuf.Timestamp + 25, // 2: panel.v1.AgentInfo.last_seen:type_name -> google.protobuf.Timestamp + 26, // 3: panel.v1.CreateInstanceRequest.run_mode:type_name -> panel.v1.RunMode + 24, // 4: panel.v1.CreateInstanceRequest.config_values:type_name -> panel.v1.CreateInstanceRequest.ConfigValuesEntry + 27, // 5: panel.v1.CreateInstanceRequest.ports:type_name -> panel.v1.PortMap + 26, // 6: panel.v1.InstanceInfo.run_mode:type_name -> panel.v1.RunMode + 28, // 7: panel.v1.InstanceInfo.status:type_name -> panel.v1.InstanceStatus + 25, // 8: panel.v1.InstanceInfo.created_at:type_name -> google.protobuf.Timestamp + 25, // 9: panel.v1.InstanceInfo.updated_at:type_name -> google.protobuf.Timestamp + 19, // 10: panel.v1.ListInstancesResponse.instances:type_name -> panel.v1.InstanceInfo + 25, // 11: panel.v1.Event.at:type_name -> google.protobuf.Timestamp + 29, // 12: panel.v1.Event.instance_state:type_name -> panel.v1.InstanceStateUpdate + 30, // 13: panel.v1.Event.app_state:type_name -> panel.v1.AppStateUpdate + 31, // 14: panel.v1.Event.player:type_name -> panel.v1.PlayerEvent + 32, // 15: panel.v1.Event.log:type_name -> panel.v1.LogLine + 33, // 16: panel.v1.Event.instance_stats:type_name -> panel.v1.InstanceStatsUpdate + 0, // 17: panel.v1.Panel.ListAgents:input_type -> panel.v1.ListAgentsRequest + 20, // 18: panel.v1.Panel.ListInstances:input_type -> panel.v1.ListInstancesRequest + 3, // 19: panel.v1.Panel.CreateInstance:input_type -> panel.v1.CreateInstanceRequest + 5, // 20: panel.v1.Panel.StartInstance:input_type -> panel.v1.StartInstanceRequest + 7, // 21: panel.v1.Panel.StopInstance:input_type -> panel.v1.StopInstanceRequest + 9, // 22: panel.v1.Panel.DeleteInstance:input_type -> panel.v1.DeleteInstanceRequest + 11, // 23: panel.v1.Panel.ExecRCON:input_type -> panel.v1.ExecRCONRequest + 13, // 24: panel.v1.Panel.UpdateInstance:input_type -> panel.v1.UpdateInstanceRequest + 15, // 25: panel.v1.Panel.CreateBackup:input_type -> panel.v1.CreateBackupRequest + 17, // 26: panel.v1.Panel.RestoreBackup:input_type -> panel.v1.RestoreBackupRequest + 22, // 27: panel.v1.Panel.StreamEvents:input_type -> panel.v1.StreamEventsRequest + 1, // 28: panel.v1.Panel.ListAgents:output_type -> panel.v1.ListAgentsResponse + 21, // 29: panel.v1.Panel.ListInstances:output_type -> panel.v1.ListInstancesResponse + 4, // 30: panel.v1.Panel.CreateInstance:output_type -> panel.v1.CreateInstanceResponse + 6, // 31: panel.v1.Panel.StartInstance:output_type -> panel.v1.StartInstanceResponse + 8, // 32: panel.v1.Panel.StopInstance:output_type -> panel.v1.StopInstanceResponse + 10, // 33: panel.v1.Panel.DeleteInstance:output_type -> panel.v1.DeleteInstanceResponse + 12, // 34: panel.v1.Panel.ExecRCON:output_type -> panel.v1.ExecRCONResponse + 14, // 35: panel.v1.Panel.UpdateInstance:output_type -> panel.v1.UpdateInstanceResponse + 16, // 36: panel.v1.Panel.CreateBackup:output_type -> panel.v1.CreateBackupResponse + 18, // 37: panel.v1.Panel.RestoreBackup:output_type -> panel.v1.RestoreBackupResponse + 23, // 38: panel.v1.Panel.StreamEvents:output_type -> panel.v1.Event + 28, // [28:39] is the sub-list for method output_type + 17, // [17:28] is the sub-list for method input_type + 17, // [17:17] is the sub-list for extension type_name + 17, // [17:17] is the sub-list for extension extendee + 0, // [0:17] is the sub-list for field type_name +} + +func init() { file_panel_v1_panel_proto_init() } +func file_panel_v1_panel_proto_init() { + if File_panel_v1_panel_proto != nil { + return + } + file_panel_v1_agent_proto_init() + file_panel_v1_common_proto_init() + file_panel_v1_panel_proto_msgTypes[23].OneofWrappers = []any{ + (*Event_InstanceState)(nil), + (*Event_AppState)(nil), + (*Event_Player)(nil), + (*Event_Log)(nil), + (*Event_InstanceStats)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_panel_v1_panel_proto_rawDesc), len(file_panel_v1_panel_proto_rawDesc)), + NumEnums: 0, + NumMessages: 25, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_panel_v1_panel_proto_goTypes, + DependencyIndexes: file_panel_v1_panel_proto_depIdxs, + MessageInfos: file_panel_v1_panel_proto_msgTypes, + }.Build() + File_panel_v1_panel_proto = out.File + file_panel_v1_panel_proto_goTypes = nil + file_panel_v1_panel_proto_depIdxs = nil +} diff --git a/proto/panel/v1/panel.proto b/proto/panel/v1/panel.proto new file mode 100644 index 00000000..72bb50e1 --- /dev/null +++ b/proto/panel/v1/panel.proto @@ -0,0 +1,179 @@ +syntax = "proto3"; + +package panel.v1; + +option go_package = "github.com/dbledeez/panel/proto/panel/v1;panelv1"; + +import "google/protobuf/timestamp.proto"; +import "panel/v1/agent.proto"; +import "panel/v1/common.proto"; + +// Panel is the operator-facing API — what panelctl, the web UI, and future +// automation talk to. The controller fulfils these by forwarding commands +// to the appropriate Target agent over the Agent bidi stream. +service Panel { + rpc ListAgents(ListAgentsRequest) returns (ListAgentsResponse); + rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse); + rpc CreateInstance(CreateInstanceRequest) returns (CreateInstanceResponse); + rpc StartInstance(StartInstanceRequest) returns (StartInstanceResponse); + rpc StopInstance(StopInstanceRequest) returns (StopInstanceResponse); + rpc DeleteInstance(DeleteInstanceRequest) returns (DeleteInstanceResponse); + rpc ExecRCON(ExecRCONRequest) returns (ExecRCONResponse); + rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse); + rpc CreateBackup(CreateBackupRequest) returns (CreateBackupResponse); + rpc RestoreBackup(RestoreBackupRequest) returns (RestoreBackupResponse); + rpc StreamEvents(StreamEventsRequest) returns (stream Event); +} + +message ListAgentsRequest {} +message ListAgentsResponse { + repeated AgentInfo agents = 1; +} + +message AgentInfo { + string agent_id = 1; + string version = 2; + string host_os = 3; + string host_arch = 4; + string hostname = 5; + google.protobuf.Timestamp connected_at = 6; + google.protobuf.Timestamp last_seen = 7; +} + +message CreateInstanceRequest { + string agent_id = 1; + string instance_id = 2; + string module_id = 3; + string version = 4; + RunMode run_mode = 5; + string data_path = 6; // optional; agent will derive if empty + map config_values = 7; // user-supplied overrides, module-validated + repeated PortMap ports = 8; // port overrides +} +message CreateInstanceResponse { + string instance_id = 1; + string correlation_id = 2; +} + +message StartInstanceRequest { + string agent_id = 1; + string instance_id = 2; +} +message StartInstanceResponse { + string correlation_id = 1; +} + +message StopInstanceRequest { + string agent_id = 1; + string instance_id = 2; + uint32 grace_seconds = 3; +} +message StopInstanceResponse { + string correlation_id = 1; +} + +message DeleteInstanceRequest { + string agent_id = 1; + string instance_id = 2; + uint32 grace_seconds = 3; + bool purge_volumes = 4; +} +message DeleteInstanceResponse { + string correlation_id = 1; +} + +// ExecRCON sends an ad-hoc RCON command to the running instance and waits +// synchronously for the agent's response. The controller correlates the +// request → response round-trip by correlation_id. +message ExecRCONRequest { + string agent_id = 1; + string instance_id = 2; + string command = 3; + uint32 timeout_ms = 4; // optional; default 10000 +} +message ExecRCONResponse { + string output = 1; + string error = 2; + string correlation_id = 3; +} + +// UpdateInstance kicks off a module-declared update provider against the +// target instance. The agent runs it asynchronously; progress streams as +// LogLines on the event bus. +message UpdateInstanceRequest { + string agent_id = 1; + string instance_id = 2; + string provider_id = 3; // empty = first provider +} +message UpdateInstanceResponse { + bool accepted = 1; + string error = 2; + string provider_id = 3; + string provider_kind = 4; + string correlation_id = 5; +} + +message CreateBackupRequest { + string agent_id = 1; + string instance_id = 2; + string description = 3; +} +message CreateBackupResponse { + string backup_id = 1; + string path = 2; + int64 size_bytes = 3; + string error = 4; +} + +message RestoreBackupRequest { + string agent_id = 1; + string instance_id = 2; + string backup_id = 3; +} +message RestoreBackupResponse { + int64 bytes_restored = 1; + string error = 2; +} + +message InstanceInfo { + string instance_id = 1; + string agent_id = 2; + string module_id = 3; + string module_version = 4; + RunMode run_mode = 5; + InstanceStatus status = 6; + int32 last_exit_code = 7; + string data_path = 8; + string detail = 9; + google.protobuf.Timestamp created_at = 10; + google.protobuf.Timestamp updated_at = 11; +} + +message ListInstancesRequest { + string agent_id = 1; // optional filter by agent +} +message ListInstancesResponse { + repeated InstanceInfo instances = 1; +} + +// StreamEvents — server streaming of the live event bus. Filters are +// optional AND-ed; empty strings = no filter on that field. +message StreamEventsRequest { + string instance_id = 1; + string agent_id = 2; +} + +// Event is the controller's normalized form of everything an agent sends +// up that isn't a one-shot command result. It wraps the original payload +// and annotates it with the agent_id for filtering + display. +message Event { + string agent_id = 1; + google.protobuf.Timestamp at = 2; + oneof payload { + InstanceStateUpdate instance_state = 10; + AppStateUpdate app_state = 11; + PlayerEvent player = 12; + LogLine log = 13; + InstanceStatsUpdate instance_stats = 14; + } +} diff --git a/proto/panel/v1/panel_grpc.pb.go b/proto/panel/v1/panel_grpc.pb.go new file mode 100644 index 00000000..9e8cf5f3 --- /dev/null +++ b/proto/panel/v1/panel_grpc.pb.go @@ -0,0 +1,513 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.0 +// - protoc (unknown) +// source: panel/v1/panel.proto + +package panelv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Panel_ListAgents_FullMethodName = "/panel.v1.Panel/ListAgents" + Panel_ListInstances_FullMethodName = "/panel.v1.Panel/ListInstances" + Panel_CreateInstance_FullMethodName = "/panel.v1.Panel/CreateInstance" + Panel_StartInstance_FullMethodName = "/panel.v1.Panel/StartInstance" + Panel_StopInstance_FullMethodName = "/panel.v1.Panel/StopInstance" + Panel_DeleteInstance_FullMethodName = "/panel.v1.Panel/DeleteInstance" + Panel_ExecRCON_FullMethodName = "/panel.v1.Panel/ExecRCON" + Panel_UpdateInstance_FullMethodName = "/panel.v1.Panel/UpdateInstance" + Panel_CreateBackup_FullMethodName = "/panel.v1.Panel/CreateBackup" + Panel_RestoreBackup_FullMethodName = "/panel.v1.Panel/RestoreBackup" + Panel_StreamEvents_FullMethodName = "/panel.v1.Panel/StreamEvents" +) + +// PanelClient is the client API for Panel service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Panel is the operator-facing API — what panelctl, the web UI, and future +// automation talk to. The controller fulfils these by forwarding commands +// to the appropriate Target agent over the Agent bidi stream. +type PanelClient interface { + ListAgents(ctx context.Context, in *ListAgentsRequest, opts ...grpc.CallOption) (*ListAgentsResponse, error) + ListInstances(ctx context.Context, in *ListInstancesRequest, opts ...grpc.CallOption) (*ListInstancesResponse, error) + CreateInstance(ctx context.Context, in *CreateInstanceRequest, opts ...grpc.CallOption) (*CreateInstanceResponse, error) + StartInstance(ctx context.Context, in *StartInstanceRequest, opts ...grpc.CallOption) (*StartInstanceResponse, error) + StopInstance(ctx context.Context, in *StopInstanceRequest, opts ...grpc.CallOption) (*StopInstanceResponse, error) + DeleteInstance(ctx context.Context, in *DeleteInstanceRequest, opts ...grpc.CallOption) (*DeleteInstanceResponse, error) + ExecRCON(ctx context.Context, in *ExecRCONRequest, opts ...grpc.CallOption) (*ExecRCONResponse, error) + UpdateInstance(ctx context.Context, in *UpdateInstanceRequest, opts ...grpc.CallOption) (*UpdateInstanceResponse, error) + CreateBackup(ctx context.Context, in *CreateBackupRequest, opts ...grpc.CallOption) (*CreateBackupResponse, error) + RestoreBackup(ctx context.Context, in *RestoreBackupRequest, opts ...grpc.CallOption) (*RestoreBackupResponse, error) + StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Event], error) +} + +type panelClient struct { + cc grpc.ClientConnInterface +} + +func NewPanelClient(cc grpc.ClientConnInterface) PanelClient { + return &panelClient{cc} +} + +func (c *panelClient) ListAgents(ctx context.Context, in *ListAgentsRequest, opts ...grpc.CallOption) (*ListAgentsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListAgentsResponse) + err := c.cc.Invoke(ctx, Panel_ListAgents_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) ListInstances(ctx context.Context, in *ListInstancesRequest, opts ...grpc.CallOption) (*ListInstancesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListInstancesResponse) + err := c.cc.Invoke(ctx, Panel_ListInstances_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) CreateInstance(ctx context.Context, in *CreateInstanceRequest, opts ...grpc.CallOption) (*CreateInstanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateInstanceResponse) + err := c.cc.Invoke(ctx, Panel_CreateInstance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) StartInstance(ctx context.Context, in *StartInstanceRequest, opts ...grpc.CallOption) (*StartInstanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StartInstanceResponse) + err := c.cc.Invoke(ctx, Panel_StartInstance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) StopInstance(ctx context.Context, in *StopInstanceRequest, opts ...grpc.CallOption) (*StopInstanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(StopInstanceResponse) + err := c.cc.Invoke(ctx, Panel_StopInstance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) DeleteInstance(ctx context.Context, in *DeleteInstanceRequest, opts ...grpc.CallOption) (*DeleteInstanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteInstanceResponse) + err := c.cc.Invoke(ctx, Panel_DeleteInstance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) ExecRCON(ctx context.Context, in *ExecRCONRequest, opts ...grpc.CallOption) (*ExecRCONResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ExecRCONResponse) + err := c.cc.Invoke(ctx, Panel_ExecRCON_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) UpdateInstance(ctx context.Context, in *UpdateInstanceRequest, opts ...grpc.CallOption) (*UpdateInstanceResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateInstanceResponse) + err := c.cc.Invoke(ctx, Panel_UpdateInstance_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) CreateBackup(ctx context.Context, in *CreateBackupRequest, opts ...grpc.CallOption) (*CreateBackupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateBackupResponse) + err := c.cc.Invoke(ctx, Panel_CreateBackup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) RestoreBackup(ctx context.Context, in *RestoreBackupRequest, opts ...grpc.CallOption) (*RestoreBackupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RestoreBackupResponse) + err := c.cc.Invoke(ctx, Panel_RestoreBackup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *panelClient) StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[Event], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &Panel_ServiceDesc.Streams[0], Panel_StreamEvents_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[StreamEventsRequest, Event]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Panel_StreamEventsClient = grpc.ServerStreamingClient[Event] + +// PanelServer is the server API for Panel service. +// All implementations must embed UnimplementedPanelServer +// for forward compatibility. +// +// Panel is the operator-facing API — what panelctl, the web UI, and future +// automation talk to. The controller fulfils these by forwarding commands +// to the appropriate Target agent over the Agent bidi stream. +type PanelServer interface { + ListAgents(context.Context, *ListAgentsRequest) (*ListAgentsResponse, error) + ListInstances(context.Context, *ListInstancesRequest) (*ListInstancesResponse, error) + CreateInstance(context.Context, *CreateInstanceRequest) (*CreateInstanceResponse, error) + StartInstance(context.Context, *StartInstanceRequest) (*StartInstanceResponse, error) + StopInstance(context.Context, *StopInstanceRequest) (*StopInstanceResponse, error) + DeleteInstance(context.Context, *DeleteInstanceRequest) (*DeleteInstanceResponse, error) + ExecRCON(context.Context, *ExecRCONRequest) (*ExecRCONResponse, error) + UpdateInstance(context.Context, *UpdateInstanceRequest) (*UpdateInstanceResponse, error) + CreateBackup(context.Context, *CreateBackupRequest) (*CreateBackupResponse, error) + RestoreBackup(context.Context, *RestoreBackupRequest) (*RestoreBackupResponse, error) + StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[Event]) error + mustEmbedUnimplementedPanelServer() +} + +// UnimplementedPanelServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedPanelServer struct{} + +func (UnimplementedPanelServer) ListAgents(context.Context, *ListAgentsRequest) (*ListAgentsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListAgents not implemented") +} +func (UnimplementedPanelServer) ListInstances(context.Context, *ListInstancesRequest) (*ListInstancesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListInstances not implemented") +} +func (UnimplementedPanelServer) CreateInstance(context.Context, *CreateInstanceRequest) (*CreateInstanceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateInstance not implemented") +} +func (UnimplementedPanelServer) StartInstance(context.Context, *StartInstanceRequest) (*StartInstanceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StartInstance not implemented") +} +func (UnimplementedPanelServer) StopInstance(context.Context, *StopInstanceRequest) (*StopInstanceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method StopInstance not implemented") +} +func (UnimplementedPanelServer) DeleteInstance(context.Context, *DeleteInstanceRequest) (*DeleteInstanceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteInstance not implemented") +} +func (UnimplementedPanelServer) ExecRCON(context.Context, *ExecRCONRequest) (*ExecRCONResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ExecRCON not implemented") +} +func (UnimplementedPanelServer) UpdateInstance(context.Context, *UpdateInstanceRequest) (*UpdateInstanceResponse, error) { + return nil, status.Error(codes.Unimplemented, "method UpdateInstance not implemented") +} +func (UnimplementedPanelServer) CreateBackup(context.Context, *CreateBackupRequest) (*CreateBackupResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CreateBackup not implemented") +} +func (UnimplementedPanelServer) RestoreBackup(context.Context, *RestoreBackupRequest) (*RestoreBackupResponse, error) { + return nil, status.Error(codes.Unimplemented, "method RestoreBackup not implemented") +} +func (UnimplementedPanelServer) StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[Event]) error { + return status.Error(codes.Unimplemented, "method StreamEvents not implemented") +} +func (UnimplementedPanelServer) mustEmbedUnimplementedPanelServer() {} +func (UnimplementedPanelServer) testEmbeddedByValue() {} + +// UnsafePanelServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to PanelServer will +// result in compilation errors. +type UnsafePanelServer interface { + mustEmbedUnimplementedPanelServer() +} + +func RegisterPanelServer(s grpc.ServiceRegistrar, srv PanelServer) { + // If the following call panics, it indicates UnimplementedPanelServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Panel_ServiceDesc, srv) +} + +func _Panel_ListAgents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAgentsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).ListAgents(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_ListAgents_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).ListAgents(ctx, req.(*ListAgentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_ListInstances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListInstancesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).ListInstances(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_ListInstances_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).ListInstances(ctx, req.(*ListInstancesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_CreateInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateInstanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).CreateInstance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_CreateInstance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).CreateInstance(ctx, req.(*CreateInstanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_StartInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StartInstanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).StartInstance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_StartInstance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).StartInstance(ctx, req.(*StartInstanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_StopInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StopInstanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).StopInstance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_StopInstance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).StopInstance(ctx, req.(*StopInstanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_DeleteInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteInstanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).DeleteInstance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_DeleteInstance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).DeleteInstance(ctx, req.(*DeleteInstanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_ExecRCON_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ExecRCONRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).ExecRCON(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_ExecRCON_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).ExecRCON(ctx, req.(*ExecRCONRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_UpdateInstance_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateInstanceRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).UpdateInstance(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_UpdateInstance_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).UpdateInstance(ctx, req.(*UpdateInstanceRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_CreateBackup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateBackupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).CreateBackup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_CreateBackup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).CreateBackup(ctx, req.(*CreateBackupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_RestoreBackup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RestoreBackupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PanelServer).RestoreBackup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Panel_RestoreBackup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PanelServer).RestoreBackup(ctx, req.(*RestoreBackupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Panel_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(StreamEventsRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(PanelServer).StreamEvents(m, &grpc.GenericServerStream[StreamEventsRequest, Event]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type Panel_StreamEventsServer = grpc.ServerStreamingServer[Event] + +// Panel_ServiceDesc is the grpc.ServiceDesc for Panel service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Panel_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "panel.v1.Panel", + HandlerType: (*PanelServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListAgents", + Handler: _Panel_ListAgents_Handler, + }, + { + MethodName: "ListInstances", + Handler: _Panel_ListInstances_Handler, + }, + { + MethodName: "CreateInstance", + Handler: _Panel_CreateInstance_Handler, + }, + { + MethodName: "StartInstance", + Handler: _Panel_StartInstance_Handler, + }, + { + MethodName: "StopInstance", + Handler: _Panel_StopInstance_Handler, + }, + { + MethodName: "DeleteInstance", + Handler: _Panel_DeleteInstance_Handler, + }, + { + MethodName: "ExecRCON", + Handler: _Panel_ExecRCON_Handler, + }, + { + MethodName: "UpdateInstance", + Handler: _Panel_UpdateInstance_Handler, + }, + { + MethodName: "CreateBackup", + Handler: _Panel_CreateBackup_Handler, + }, + { + MethodName: "RestoreBackup", + Handler: _Panel_RestoreBackup_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "StreamEvents", + Handler: _Panel_StreamEvents_Handler, + ServerStreams: true, + }, + }, + Metadata: "panel/v1/panel.proto", +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100644 index 00000000..3e3ec87b --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Local dev helper: start Postgres, then foreground the controller. +# Run the agent separately in another terminal: go run ./agent/cmd/agent --insecure +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo ">> starting postgres" +docker compose -f docker-compose.dev.yml up -d postgres + +echo ">> waiting for postgres to be healthy" +for _ in {1..30}; do + if docker inspect -f '{{.State.Health.Status}}' panel-postgres 2>/dev/null | grep -q healthy; then + echo ">> postgres healthy" + break + fi + sleep 1 +done + +echo ">> running controller (Ctrl-C to stop)" +exec go run ./controller/cmd/controller "$@" diff --git a/scripts/gen-proto.sh b/scripts/gen-proto.sh new file mode 100644 index 00000000..786563d7 --- /dev/null +++ b/scripts/gen-proto.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Regenerate protobuf Go stubs. +# Requires: buf, protoc-gen-go, protoc-gen-go-grpc on $PATH. +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v buf >/dev/null 2>&1; then + echo "error: buf not on PATH" >&2 + echo "install: go install github.com/bufbuild/buf/cmd/buf@latest" >&2 + exit 1 +fi + +echo ">> buf lint" +buf lint + +echo ">> buf generate" +buf generate + +echo ">> tidy go modules" +for dir in proto controller agent; do + (cd "$dir" && go mod tidy) +done + +echo ">> done" diff --git a/tools/amp-distill/go.mod b/tools/amp-distill/go.mod new file mode 100644 index 00000000..e3c118e7 --- /dev/null +++ b/tools/amp-distill/go.mod @@ -0,0 +1,3 @@ +module panel/tools/amp-distill + +go 1.24 diff --git a/tools/amp-distill/main.go b/tools/amp-distill/main.go new file mode 100644 index 00000000..803f437e --- /dev/null +++ b/tools/amp-distill/main.go @@ -0,0 +1,500 @@ +// Command amp-distill parses the CubeCoders AMPTemplates repository (MIT +// licensed, see CREDITS.md) into a single self-contained catalog.json the +// panel can serve to its Library UI. It is a build-time tool: run it by hand +// whenever the AMPTemplates clone is refreshed. +// +// Usage: +// +// go run ./tools/amp-distill \ +// -templates C:/Users/dbled/sources/AMPTemplates \ +// -modules ./modules \ +// -out ./controller/cmd/controller/static/catalog.json +// +// For every .kvp it reads the sibling ports.json / +// updates.json / metaconfig.json and distills one CatalogEntry. +// Malformed templates are skipped with a logged reason — the tool never +// fails the whole run over one bad template. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// CatalogEntry is one distilled game template. +type CatalogEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + OS []string `json:"os,omitempty"` + + Steam *SteamInfo `json:"steam,omitempty"` + + Ports []PortEntry `json:"ports,omitempty"` + + RCONAdapter string `json:"rcon_adapter,omitempty"` // source_rcon, telnet, stdio, ... + AdminMethod string `json:"admin_method,omitempty"` // raw App.AdminMethod + + ReadyPattern string `json:"ready_pattern,omitempty"` + Events map[string]string `json:"events,omitempty"` // join/leave/chat -> Go regex + + UpdateStages []UpdateStage `json:"update_stages,omitempty"` + + ConfigFiles []string `json:"config_files,omitempty"` + + // SteamLoginRequired: kvp App.SteamUpdateAnonymousLogin=False — + // SteamCMD refuses `+login anonymous` for this title (DayZ/Arma class). + SteamLoginRequired bool `json:"steam_login_required,omitempty"` + + ArtSteamAppID string `json:"art_steam_app_id,omitempty"` + Wine bool `json:"wine"` // needs Wine/Proton on a Linux host + EntrypointHint string `json:"entrypoint_hint,omitempty"` + + Ported bool `json:"ported,omitempty"` // already exists as a panel module + ModuleID string `json:"module_id,omitempty"` + + Provenance string `json:"provenance"` // source .kvp filename + Notes []string `json:"notes,omitempty"` +} + +// SteamInfo carries the SteamCMD install identity. +type SteamInfo struct { + AppID string `json:"app_id"` // dedicated-server app id + GameAppID string `json:"game_app_id,omitempty"` // client/game app id (art, SteamAppId env) + Platform string `json:"platform,omitempty"` // "windows" when ForceDownloadPlatform=Windows +} + +// PortEntry is one declared network port. +type PortEntry struct { + Name string `json:"name"` + Proto string `json:"proto"` // tcp / udp / both + Port uint32 `json:"port"` +} + +// UpdateStage is one mapped install/update pipeline stage. +type UpdateStage struct { + Kind string `json:"kind"` // steamcmd, github, direct + Name string `json:"name,omitempty"` + AppID string `json:"app_id,omitempty"` + Platform string `json:"platform,omitempty"` + Repo string `json:"repo,omitempty"` + AssetRegex string `json:"asset_regex,omitempty"` + URL string `json:"url,omitempty"` + TargetPath string `json:"target_path,omitempty"` + // Conditional stages (AMP UpdateSourceConditionSetting) are optional + // extras like ValheimPlus/BepInEx — flagged so the UI can hide them. + Conditional bool `json:"conditional,omitempty"` +} + +// ampPort mirrors the shape of ports.json entries (recursive). +type ampPort struct { + Protocol string `json:"Protocol"` + Port json.RawMessage `json:"Port"` + Name string `json:"Name"` + ChildPorts []ampPort `json:"ChildPorts"` +} + +// ampUpdateStage mirrors updates.json entries. +type ampUpdateStage struct { + UpdateStageName string `json:"UpdateStageName"` + UpdateSourcePlatform string `json:"UpdateSourcePlatform"` + UpdateSource string `json:"UpdateSource"` + UpdateSourceData string `json:"UpdateSourceData"` + UpdateSourceArgs string `json:"UpdateSourceArgs"` + UpdateSourceTarget string `json:"UpdateSourceTarget"` + ForceDownloadPlatform string `json:"ForceDownloadPlatform"` + UpdateSourceConditionSetting string `json:"UpdateSourceConditionSetting"` +} + +// ampMetaConfig mirrors metaconfig.json entries (only the file path). +type ampMetaConfig struct { + ConfigFile string `json:"ConfigFile"` +} + +// moduleInfo is what we scrape from an existing modules//module.yaml. +type moduleInfo struct { + ID string + Name string + AppIDs []string +} + +var namedGroupRe = regexp.MustCompile(`\(\?<([A-Za-z][A-Za-z0-9]*)>`) + +// toGoRegex converts .NET named groups (?...) to Go (?P...). +// Returns the converted pattern and whether it compiles as a Go regexp. +func toGoRegex(p string) (string, bool) { + if p == "" { + return "", true + } + conv := namedGroupRe.ReplaceAllString(p, `(?P<$1>`) + _, err := regexp.Compile(conv) + return conv, err == nil +} + +func parseKVP(path string) (map[string]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + kv := make(map[string]string) + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimRight(line, "\r") + if line == "" || strings.HasPrefix(line, "#") { + continue + } + i := strings.IndexByte(line, '=') + if i < 0 { + continue + } + key := strings.TrimSpace(line[:i]) + if _, seen := kv[key]; !seen { // first occurrence wins + kv[key] = line[i+1:] + } + } + return kv, nil +} + +func flattenPorts(in []ampPort, out *[]PortEntry, notes *[]string) { + for _, p := range in { + var num uint64 + var perr error + s := strings.Trim(string(p.Port), `"`) + if s != "" && s != "null" { + num, perr = strconv.ParseUint(strings.TrimSpace(s), 10, 32) + } + if perr != nil || num == 0 || num > 65535 { + *notes = append(*notes, fmt.Sprintf("skipped port %q: unparseable value %s", p.Name, string(p.Port))) + } else { + proto := strings.ToLower(p.Protocol) + if proto != "tcp" && proto != "udp" { + proto = "both" + } + name := p.Name + if name == "" { + name = fmt.Sprintf("port-%d", num) + } + *out = append(*out, PortEntry{Name: name, Proto: proto, Port: uint32(num)}) + } + flattenPorts(p.ChildPorts, out, notes) + } +} + +func adapterFor(adminMethod string) string { + switch adminMethod { + case "SourceRCON": + return "source_rcon" + case "TelnetRCON", "Telnet": + return "telnet" + case "STDIO": + return "stdio" + case "BattlEyeRCON": + return "battleye" + case "QuakeRCON": + return "quake_rcon" + case "GoldSrcRCON": + return "goldsrc_rcon" + default: + return "" + } +} + +// norm reduces a name to lowercase alphanumerics for fuzzy matching. +func norm(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +// moduleAliases maps our module id -> extra normalized names it answers to, +// covering cases where the AMP template id differs from ours. +var moduleAliases = map[string][]string{ + "7dtd": {"sevendaystodie", "sdtd"}, + "ark-sa": {"arksamin", "arksurvivalascended"}, + "dragonwilds": {"runescapedragonwilds"}, + "empyrion": {"empyriongalacticsurvival"}, + "conan-exiles": {"conanexiles"}, +} + +var yamlAppIDRe = regexp.MustCompile(`(?m)^\s*app_id:\s*["']?(\d+)`) +var yamlIDRe = regexp.MustCompile(`(?m)^id:\s*["']?([A-Za-z0-9._-]+)`) +var yamlNameRe = regexp.MustCompile(`(?m)^name:\s*["']?([^"'\r\n]+)`) + +func loadModules(dir string) ([]moduleInfo, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + // Helper/infra modules that are not real game ports — never claim an + // AMP template as "ported" via these (steamcmd-test uses HLDS app 90, + // which would falsely mark half-life/counter-strike as ported). + nonGame := map[string]bool{"demo": true, "steamcmd-test": true, "empyrion-bridge": true} + var out []moduleInfo + for _, e := range entries { + if !e.IsDir() || nonGame[e.Name()] { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, e.Name(), "module.yaml")) + if err != nil { + continue + } + mi := moduleInfo{ID: e.Name()} + if m := yamlIDRe.FindSubmatch(raw); m != nil { + mi.ID = string(m[1]) + } + if m := yamlNameRe.FindSubmatch(raw); m != nil { + mi.Name = strings.TrimSpace(string(m[1])) + } + for _, m := range yamlAppIDRe.FindAllSubmatch(raw, -1) { + mi.AppIDs = append(mi.AppIDs, string(m[1])) + } + out = append(out, mi) + } + return out, nil +} + +// matchModule returns the module id an entry corresponds to, or "". +func matchModule(e *CatalogEntry, mods []moduleInfo) string { + entryNames := map[string]bool{norm(e.ID): true, norm(e.Name): true} + for _, m := range mods { + // by steam app id (server app id or game app id) + if e.Steam != nil { + for _, id := range m.AppIDs { + if id == e.Steam.AppID || id == e.Steam.GameAppID { + return m.ID + } + } + } + // by name heuristics + names := []string{norm(m.ID), norm(m.Name)} + names = append(names, moduleAliases[m.ID]...) + for _, n := range names { + if n != "" && entryNames[n] { + return m.ID + } + } + } + return "" +} + +func distillOne(dir, kvpFile string) (*CatalogEntry, error) { + id := strings.TrimSuffix(kvpFile, ".kvp") + kv, err := parseKVP(filepath.Join(dir, kvpFile)) + if err != nil { + return nil, fmt.Errorf("read kvp: %w", err) + } + name := kv["Meta.DisplayName"] + if name == "" { + name = kv["App.DisplayName"] + } + if name == "" { + return nil, fmt.Errorf("no Meta.DisplayName/App.DisplayName") + } + e := &CatalogEntry{ + ID: id, + Name: name, + Description: kv["Meta.Description"], + AdminMethod: kv["App.AdminMethod"], + RCONAdapter: adapterFor(kv["App.AdminMethod"]), + Provenance: kvpFile, + } + if osList := kv["Meta.OS"]; osList != "" { + for _, o := range strings.Split(osList, ",") { + if o = strings.TrimSpace(o); o != "" { + e.OS = append(e.OS, o) + } + } + } + e.SteamLoginRequired = strings.EqualFold(strings.TrimSpace(kv["App.SteamUpdateAnonymousLogin"]), "false") + if hint := strings.TrimSpace(kv["App.CommandLineArgs"]); hint != "" { + e.EntrypointHint = hint + } + if src := kv["Meta.DisplayImageSource"]; strings.HasPrefix(src, "steam:") { + e.ArtSteamAppID = strings.TrimPrefix(src, "steam:") + } + + // Regexes: AppReady + join/leave/chat. + if p, ok := toGoRegex(kv["Console.AppReadyRegex"]); ok { + e.ReadyPattern = p + } else { + e.Notes = append(e.Notes, "ready_pattern dropped: does not compile as Go regexp") + } + e.Events = map[string]string{} + for kind, key := range map[string]string{ + "join": "Console.UserJoinRegex", + "leave": "Console.UserLeaveRegex", + "chat": "Console.UserChatRegex", + } { + if raw := kv[key]; raw != "" { + if p, ok := toGoRegex(raw); ok { + e.Events[kind] = p + } else { + e.Notes = append(e.Notes, kind+" regex dropped: does not compile as Go regexp") + } + } + } + if len(e.Events) == 0 { + e.Events = nil + } + + // Ports. + if raw, err := os.ReadFile(filepath.Join(dir, id+"ports.json")); err == nil { + var ports []ampPort + if err := json.Unmarshal(raw, &ports); err != nil { + e.Notes = append(e.Notes, "ports.json unparseable: "+err.Error()) + } else { + flattenPorts(ports, &e.Ports, &e.Notes) + } + } + + // Update stages. + wineSeen := false + if raw, err := os.ReadFile(filepath.Join(dir, id+"updates.json")); err == nil { + var stages []ampUpdateStage + if err := json.Unmarshal(raw, &stages); err != nil { + e.Notes = append(e.Notes, "updates.json unparseable: "+err.Error()) + } else { + for _, s := range stages { + if strings.Contains(strings.ToLower(s.UpdateSourceArgs), "wine") || + strings.Contains(strings.ToLower(s.UpdateStageName), "wine") { + wineSeen = true + } + st := UpdateStage{Name: s.UpdateStageName, Conditional: s.UpdateSourceConditionSetting != ""} + switch s.UpdateSource { + case "SteamCMD": + st.Kind = "steamcmd" + st.AppID = s.UpdateSourceData + if strings.EqualFold(s.ForceDownloadPlatform, "Windows") { + st.Platform = "windows" + } + if e.Steam == nil && !st.Conditional { + e.Steam = &SteamInfo{AppID: s.UpdateSourceData, GameAppID: s.UpdateSourceArgs, Platform: st.Platform} + } + case "GithubRelease": + st.Kind = "github" + st.Repo = s.UpdateSourceArgs + st.AssetRegex = s.UpdateSourceData + st.TargetPath = s.UpdateSourceTarget + case "FetchURL": + st.Kind = "direct" + st.URL = s.UpdateSourceData + st.TargetPath = s.UpdateSourceTarget + if st.TargetPath == "" { + st.TargetPath = s.UpdateSourceArgs + } + default: + continue // Executable/CreateDirectory/etc. — entrypoint territory + } + e.UpdateStages = append(e.UpdateStages, st) + } + } + } + + // Config file paths from metaconfig. + if raw, err := os.ReadFile(filepath.Join(dir, id+"metaconfig.json")); err == nil { + var metas []ampMetaConfig + if err := json.Unmarshal(raw, &metas); err == nil { + seen := map[string]bool{} + for _, m := range metas { + if m.ConfigFile != "" && !seen[m.ConfigFile] { + seen[m.ConfigFile] = true + e.ConfigFiles = append(e.ConfigFiles, m.ConfigFile) + } + } + } else { + e.Notes = append(e.Notes, "metaconfig.json unparseable: "+err.Error()) + } + } + + // Wine flag: Windows-only template, forced Windows depots, or explicit + // wine bootstrap in the update pipeline. + linuxNative := false + for _, o := range e.OS { + if strings.EqualFold(o, "Linux") { + linuxNative = true + } + } + e.Wine = wineSeen || !linuxNative || (e.Steam != nil && e.Steam.Platform == "windows") + return e, nil +} + +func main() { + templates := flag.String("templates", `C:\Users\dbled\sources\AMPTemplates`, "path to the AMPTemplates clone") + modules := flag.String("modules", "modules", "path to the panel modules/ directory") + out := flag.String("out", filepath.Join("controller", "cmd", "controller", "static", "catalog.json"), "output catalog path") + flag.Parse() + + mods, err := loadModules(*modules) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: load modules: %v\n", err) + os.Exit(1) + } + entries, err := os.ReadDir(*templates) + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: read templates dir: %v\n", err) + os.Exit(1) + } + + var catalog []*CatalogEntry + var skipped []string + portedMap := map[string]string{} + for _, de := range entries { + if de.IsDir() || !strings.HasSuffix(de.Name(), ".kvp") { + continue + } + e, err := distillOne(*templates, de.Name()) + if err != nil { + skipped = append(skipped, fmt.Sprintf("%s: %v", de.Name(), err)) + continue + } + if mid := matchModule(e, mods); mid != "" { + e.Ported = true + e.ModuleID = mid + portedMap[e.ID] = mid + } + catalog = append(catalog, e) + } + sort.Slice(catalog, func(i, j int) bool { return catalog[i].ID < catalog[j].ID }) + + buf, err := json.MarshalIndent(catalog, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "FATAL: marshal: %v\n", err) + os.Exit(1) + } + buf = append(buf, '\n') + if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil { + fmt.Fprintf(os.Stderr, "FATAL: mkdir: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(*out, buf, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "FATAL: write: %v\n", err) + os.Exit(1) + } + + fmt.Printf("catalog: %d entries -> %s (%d KB)\n", len(catalog), *out, len(buf)/1024) + fmt.Printf("ported matches (%d):\n", len(portedMap)) + var ids []string + for k := range portedMap { + ids = append(ids, k) + } + sort.Strings(ids) + for _, k := range ids { + fmt.Printf(" %s -> module %s\n", k, portedMap[k]) + } + if len(skipped) > 0 { + fmt.Printf("skipped (%d):\n", len(skipped)) + for _, s := range skipped { + fmt.Println(" " + s) + } + } +} diff --git a/tools/module-meta-dump/go.mod b/tools/module-meta-dump/go.mod new file mode 100644 index 00000000..0a966ed4 --- /dev/null +++ b/tools/module-meta-dump/go.mod @@ -0,0 +1,9 @@ +module github.com/dbledeez/panel/tools/module-meta-dump + +go 1.26 + +require github.com/dbledeez/panel/pkg v0.0.0 + +require gopkg.in/yaml.v3 v3.0.1 // indirect + +replace github.com/dbledeez/panel/pkg => ../../pkg diff --git a/tools/module-meta-dump/go.sum b/tools/module-meta-dump/go.sum new file mode 100644 index 00000000..a62c313c --- /dev/null +++ b/tools/module-meta-dump/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/module-meta-dump/main.go b/tools/module-meta-dump/main.go new file mode 100644 index 00000000..f94385d6 --- /dev/null +++ b/tools/module-meta-dump/main.go @@ -0,0 +1,45 @@ +// Command module-meta-dump loads ./modules via the same loader the +// controller uses (module.LoadDir) and prints the WI-07 UI-metadata +// enrichment as JSON — used by controller/.test/wi07-diff-maps.mjs to +// prove the manifest-derived values match the dashboard's old inline +// maps. Run from the repo root: go run ./tools/module-meta-dump +package main + +import ( + "encoding/json" + "fmt" + "os" + + module "github.com/dbledeez/panel/pkg/module" +) + +type meta struct { + ID string `json:"id"` + Ports []module.Port `json:"ports,omitempty"` + BrowseableRoots []module.BrowseableRoot `json:"browseable_roots,omitempty"` + ReadyPattern string `json:"ready_pattern,omitempty"` + Appearance *module.Appearance `json:"appearance,omitempty"` +} + +func main() { + dir := "./modules" + if len(os.Args) > 1 { + dir = os.Args[1] + } + ms, err := module.LoadDir(dir) + if err != nil { + fmt.Fprintln(os.Stderr, "load:", err) + os.Exit(1) + } + out := map[string]meta{} + for _, m := range ms { + e := meta{ID: m.ID, Ports: m.Ports, ReadyPattern: m.ReadyPattern, Appearance: m.Appearance} + if m.Runtime.Docker != nil { + e.BrowseableRoots = m.Runtime.Docker.BrowseableRoots + } + out[m.ID] = e + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(out) +}