3 Commits

Author SHA1 Message Date
gitea 240306da20 3 2026-08-14 15:14:33 +02:00
gitea 3b4175aaff 2 2026-08-14 14:39:57 +02:00
gitea a67c18d691 fix(foundry): poprawki zgodności v13/v14 + skrypt NeDB→LevelDB
Poprawki punktowe (niskiego ryzyka, z fallbackiem na v11/v12):
- greyknights/auto-loadout: pochodzenie z paczki przez _stats.compendiumSource
  (flags.core.sourceId deprecated od v12)
- greyknights/gk-glow-toggle: canvas.perception.update -> płaska flaga
  refreshLighting (stary format {lighting:{refresh}} nie działa od v11)
- greyknights/helpers: TextEditor -> foundry.applications.ux.TextEditor;
  game.ui -> ui (martwy no-op)
- greyknights/ui-gkpanel: scene control tool v13 onClick -> onChange
- voidships-builder/voidships: loadTemplates -> foundry.applications.handlebars
- voidships-builder/voidship-actor: Actors -> foundry.documents.collections.Actors
- usunięto śmieciowe packs/*.db.bak

Skrypt help_scripts/pack_nedb_to_leveldb.mjs: przepakowuje kompendia NeDB(.db)
-> LevelDB (wymagane w v11+/v13/v14), czyta paczki z module.json, domyślnie
dry-run. Wykrywa też rozbieżność: gk-greyknights i wg-voidships-journal mają
w manifeście ścieżkę bez .db, a na dysku plik NeDB.

Nie ruszano vendored investigation-board (do aktualizacji z upstreamu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 19:31:37 +02:00
10 changed files with 944 additions and 42 deletions
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
// pack_nedb_to_leveldb.mjs
// Przepakowuje kompendia z legacy NeDB (.db, jeden JSON/linia) do formatu LevelDB
// (katalog), którego wymaga Foundry VTT v11+ (i v13/v14). Czyta listę paczek
// wprost z module.json danego modułu, więc nie trzeba niczego wpisywać ręcznie.
//
// Wymaga jednorazowo: npm i -D @foundryvtt/foundryvtt-cli (lub -g)
//
// Użycie:
// node help_scripts/pack_nedb_to_leveldb.mjs [--write] [--replace] [ścieżki/do/modułów...]
//
// (bez argumentów ścieżek) -> domyślnie 3 własne moduły w tym repo
// --write faktycznie buduje katalogi LevelDB (bez tego: tylko dry-run/plan)
// --replace po udanym zbudowaniu usuwa stary plik .db ORAZ podmienia ścieżkę
// w module.json (packs[].path: "packs/foo.db" -> "packs/foo")
//
// Bezpiecznie: domyślnie nic nie nadpisuje. LevelDB trafia obok, do packs/<name>.
// Stare .db zostają, dopóki nie podasz --replace.
import { readFile, writeFile, rm, mkdtemp, mkdir } from "node:fs/promises";
import { existsSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import path from "node:path";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, "..");
const args = process.argv.slice(2);
const WRITE = args.includes("--write");
const REPLACE = args.includes("--replace");
const moduleDirs = args.filter((a) => !a.startsWith("--"));
const DEFAULT_MODULES = [
"wg-greyknights",
"wg-voidships-builder",
"wg-voidships-journal",
].map((m) => path.join(REPO, m));
const targets = (moduleDirs.length ? moduleDirs : DEFAULT_MODULES).map((p) =>
path.resolve(p)
);
// Typ paczki z module.json -> documentType wymagany przez CLI dla źródła NeDB.
const TYPE_OK = new Set([
"Actor", "Item", "JournalEntry", "RollTable", "Macro",
"Scene", "Playlist", "Cards", "Adventure",
]);
async function loadCli() {
try {
return await import("@foundryvtt/foundryvtt-cli");
} catch {
console.error(
"\n[!] Brak @foundryvtt/foundryvtt-cli. Zainstaluj jednorazowo:\n" +
" npm i -D @foundryvtt/foundryvtt-cli\n"
);
process.exit(2);
}
}
async function processModule(cli, modDir) {
const manifestPath = path.join(modDir, "module.json");
if (!existsSync(manifestPath)) {
console.warn(`- pomijam ${modDir} (brak module.json)`);
return;
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
const packs = manifest.packs ?? [];
if (!packs.length) {
console.log(`- ${manifest.id ?? path.basename(modDir)}: brak paczek`);
return;
}
let manifestDirty = false;
console.log(`\n=== ${manifest.id ?? path.basename(modDir)} ===`);
for (const pack of packs) {
const rel = pack.path; // np. "packs/wg-voidship-hulls.db" LUB "packs/gk-items"
const newRel = rel.replace(/\.db$/, ""); // docelowa ścieżka LevelDB (bez .db)
const outDir = path.join(modDir, newRel);
// Plik NeDB może być tam, gdzie wskazuje manifest (…​.db) albo obok, gdy
// manifest podaje ścieżkę bez rozszerzenia, a na dysku i tak leży <path>.db.
const nedbAbs = rel.endsWith(".db")
? path.join(modDir, rel)
: path.join(modDir, rel + ".db");
if (existsSync(outDir) && statSync(outDir).isDirectory() && !existsSync(nedbAbs)) {
console.log(`${pack.name}: już LevelDB (${newRel}/) — pomijam`);
continue;
}
if (!existsSync(nedbAbs) || !statSync(nedbAbs).isFile()) {
console.warn(`${pack.name}: nie znaleziono źródła NeDB (${path.relative(modDir, nedbAbs)}) — pomijam`);
continue;
}
if (rel === newRel) {
// manifest już wskazuje bez .db, ale plik to NeDB -> rozbieżność do naprawy
console.log(` (uwaga: module.json wskazuje "${rel}", a na dysku jest NeDB "${path.basename(nedbAbs)}")`);
}
const type = pack.type;
if (!TYPE_OK.has(type)) {
console.warn(`${pack.name}: nieznany type="${type}" — pomijam`);
continue;
}
const abs = nedbAbs;
console.log(
`${pack.name} [${type}] ${rel} -> ${newRel}/ ${WRITE ? "" : "(dry-run)"}`
);
if (!WRITE) continue;
// 1) NeDB -> katalog źródeł (.json z poprawnymi _key)
const srcTmp = await mkdtemp(path.join(tmpdir(), "fvtt-src-"));
await cli.extractPack(abs, srcTmp, {
nedb: true,
documentType: type,
yaml: false,
log: false,
});
// 2) źródła -> LevelDB
await mkdir(outDir, { recursive: true });
await cli.compilePack(srcTmp, outDir, { yaml: false, log: false });
await rm(srcTmp, { recursive: true, force: true });
console.log(` ✓ zbudowano LevelDB: ${newRel}/`);
if (REPLACE) {
await rm(abs, { force: true });
if (pack.path !== newRel) {
pack.path = newRel;
manifestDirty = true;
}
console.log(
` ✓ usunięto ${path.basename(abs)}` +
(manifestDirty ? ", zaktualizowano ścieżkę w module.json" : "")
);
}
}
if (manifestDirty && WRITE && REPLACE) {
await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
}
}
const cli = WRITE ? await loadCli() : null; // CLI potrzebny tylko do budowania
console.log(
WRITE
? `Przepakowuję (${REPLACE ? "z podmianą .db i module.json" : "obok, .db zostają"})`
: "DRY-RUN — plan (dodaj --write aby zbudować, --replace aby podmienić):"
);
for (const t of targets) await processModule(cli, t);
console.log("\nGotowe.");
+779
View File
@@ -0,0 +1,779 @@
#!/usr/bin/env python3
"""
nas-dedup-copy.py
Szybkie, deduplikujące kopiowanie z dysku źródłowego na NAS (przez zamontowany
katalog docelowy, np. NFS z TrueNAS).
Realizuje:
1. DEDUP W ŹRÓDLE: pliki grupowane po ROZMIARZE; hashujemy TYLKO te, których
rozmiar z czymś koliduje. Identyczna treść (rozmiar+hash) kopiowana raz.
Nazwy i ścieżki nie mają znaczenia — liczy się TREŚĆ.
2. DEDUP WZGLĘDEM ISTNIEJĄCYCH DANYCH (--against DIR, można podać wiele razy):
jeśli treść pliku ze źródła już leży w którymś ze wskazanych katalogów
(np. na NAS-ie, pod dowolną nazwą), plik jest POMIJANY.
3. FILTR: pomija pliki < MIN_SIZE (domyślnie 1 MiB), CHYBA że jpg/jpeg/png/gif.
4. SPRAWDZENIE MIEJSCA: po dedup+filtrze liczy realny rozmiar do skopiowania
i porównuje z wolnym miejscem na woluminie docelowym. Bez miejsca -> stop.
5. HEARTBEAT: co --heartbeat sekund (domyślnie 120) wypisuje na stderr znak
życia: etap, WSZYSTKIE równolegle przetwarzane pliki z postępem każdego,
postęp całości etapu, tempo i ETA.
6. WIELOWĄTKOWOŚĆ:
--workers N (domyślnie 4) — wątki KOPIOWANIA i hashowania --against.
--hash-workers M (domyślnie 1) — wątki hashowania ŹRÓDŁA.
UWAGA: zwiększaj tylko gdy źródło to SSD. Na HDD
równoległe odczyty = seek-thrashing = WOLNIEJ.
7. PLIK STANU (--state stan.json, OPCJONALNY):
- cache HASHY: każdy policzony hash ląduje w pliku stanu (klucz: ścieżka,
walidacja: rozmiar + mtime). Po przerwaniu i restarcie pliki niezmienione
NIE są hashowane ponownie — ani źródło, ani --against.
- rejestr SKOPIOWANYCH: ukończone kopie są odnotowane; restart pomija je
bez dotykania NFS (szybciej niż stat na tysiącach plików).
- zapis ATOMOWY (tmp + rename) co --state-interval sekund (domyślnie 30),
na końcu każdego etapu oraz przy SIGINT (Ctrl+C) i SIGTERM.
- plik przerwany w połowie KOPIOWANIA nie jest w rejestrze -> po restarcie
zostanie skopiowany od zera (niepełny plik w celu ma zły rozmiar, więc
złapie go też --skip-existing). Przerwany w połowie HASH liczy się od zera.
- stan jest wiązany z backendem hasha (xxh64/sha256); zmiana backendu
unieważnia cache hashy (rejestr kopii zostaje).
Optymalizacja hashowania (kluczowa dla prędkości):
- Plik ŹRÓDŁA hashujemy tylko, gdy jego rozmiar koliduje z innym plikiem
źródła LUB występuje wśród istniejących danych (--against).
- Plik ISTNIEJĄCY (--against) hashujemy tylko, gdy jego rozmiar występuje
w zestawie do skopiowania. Reszty nie dotykamy (nie może być duplikatem).
- Wynik dedupu jest DETERMINISTYCZNY niezależnie od liczby wątków.
Bezpieczeństwo:
- Źródło i katalogi --against są tylko CZYTANE. Skrypt NICZEGO nie usuwa.
- Domyślnie: analiza + raport, potem pyta o potwierdzenie (--yes pomija).
- --dry-run: sama analiza. --skip-existing (domyślnie ON): wznawianie po przerwaniu.
Przykłady:
# pełny zestaw na noc: dedup vs NAS, 6 wątków, stan zapisywany:
./nas-dedup-copy.py --source /mnt/source --dest /mnt/pve/truenas-nfs/import \\
--against /mnt/pve/truenas-nfs --yes --workers 6 \\
--state /root/import-stan.json
# po przerwaniu: to samo polecenie — hashe i kopie wracają z pliku stanu.
"""
import argparse
import json
import os
import signal
import sys
import stat
import shutil
import hashlib
import threading
import time
from collections import Counter, defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
# --- backend hasha: xxhash jeśli dostępny (szybki), inaczej sha256 ---
try:
import xxhash
def make_hasher():
return xxhash.xxh64()
HASH_NAME = "xxh64"
except ImportError:
def make_hasher():
return hashlib.sha256()
HASH_NAME = "sha256 (zainstaluj 'xxhash' dla szybszego dedupu)"
HASH_BACKEND = HASH_NAME.split()[0] # "xxh64" / "sha256"
DEFAULT_KEEP_EXTS = {".jpg", ".jpeg", ".png", ".gif"}
MIB = 1 << 20
COPY_CHUNK = 4 * MIB
HB_MAX_FILES = 4 # ile równoległych plików pokazywać w linii heartbeatu
STATE_VERSION = 1
def human(n):
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(n) < 1024.0:
return f"{n:,.1f} {unit}"
n /= 1024.0
return f"{n:,.1f} PiB"
# ====================== PLIK STANU ======================
class State:
"""Trwały stan w JSON: cache hashy (walidacja size+mtime) i rejestr
ukończonych kopii. Zapis atomowy (tmp + os.replace). Thread-safe.
Gdy path=None, wszystkie operacje są no-op (tryb bez stanu)."""
def __init__(self, path):
self.path = path
self.lock = threading.Lock()
self.dirty = False
self.hash_hits = 0
self.copy_hits = 0
self.data = {
"version": STATE_VERSION,
"hash_backend": HASH_BACKEND,
"hashes": {}, # abspath -> {"size": int, "mtime": float, "hash": str}
"copied": {}, # relpath -> {"size": int}
}
if path and os.path.exists(path):
self._load()
def _load(self):
try:
with open(self.path, "r", encoding="utf-8") as f:
loaded = json.load(f)
except (OSError, json.JSONDecodeError) as e:
print(f"UWAGA: plik stanu nieczytelny ({e}) — zaczynam z pustym stanem.",
file=sys.stderr)
return
if loaded.get("version") != STATE_VERSION:
print("UWAGA: niezgodna wersja pliku stanu — zaczynam z pustym stanem.",
file=sys.stderr)
return
if loaded.get("hash_backend") != HASH_BACKEND:
print(f"UWAGA: plik stanu ma hashe {loaded.get('hash_backend')}, "
f"a działamy na {HASH_BACKEND} — cache hashy odrzucony, "
f"rejestr kopii zachowany.", file=sys.stderr)
loaded["hashes"] = {}
loaded["hash_backend"] = HASH_BACKEND
self.data = loaded
self.data.setdefault("hashes", {})
self.data.setdefault("copied", {})
print(f"Stan wczytany: {len(self.data['hashes']):,} hashy w cache, "
f"{len(self.data['copied']):,} plików odnotowanych jako skopiowane.",
file=sys.stderr)
# --- hashe ---
def get_hash(self, path, size, mtime):
if not self.path:
return None
with self.lock:
e = self.data["hashes"].get(path)
if e and e["size"] == size and e["mtime"] == mtime:
self.hash_hits += 1
return e["hash"]
return None
def put_hash(self, path, size, mtime, digest):
if not self.path:
return
with self.lock:
self.data["hashes"][path] = {"size": size, "mtime": mtime, "hash": digest}
self.dirty = True
# --- kopie ---
def is_copied(self, rel, size):
if not self.path:
return False
with self.lock:
e = self.data["copied"].get(rel)
if e and e["size"] == size:
self.copy_hits += 1
return True
return False
def put_copied(self, rel, size):
if not self.path:
return
with self.lock:
self.data["copied"][rel] = {"size": size}
self.dirty = True
# --- zapis ---
def save(self, force=False):
if not self.path:
return
with self.lock:
if not self.dirty and not force:
return
tmp = self.path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.data, f)
os.replace(tmp, self.path) # atomowo
self.dirty = False
except OSError as e:
print(f"UWAGA: nie mogę zapisać pliku stanu: {e}", file=sys.stderr)
STATE = State(None) # podmieniane w main()
def state_saver_loop(interval, stop_event):
while not stop_event.wait(interval):
STATE.save()
# ====================== HEARTBEAT ======================
class Progress:
"""Współdzielony stan postępu. Wiele wątków-workerów pisze (każdy o swoim
pliku), wątek heartbeat czyta i drukuje zbiorczą linię."""
def __init__(self):
self.lock = threading.Lock()
self.phase = "start"
self.detail = ""
self.current = {} # path -> [done, total, op] — pliki w locie
self.items_done = 0
self.items_total = 0
self.bytes_done = 0 # bajty UKOŃCZONYCH plików w etapie
self.bytes_total = 0
self.phase_start = time.monotonic()
self._hb_bytes = 0
self._hb_time = time.monotonic()
def set_phase(self, phase, items_total=0, bytes_total=0, detail=""):
with self.lock:
self.phase = phase
self.detail = detail
self.current = {}
self.items_done = 0
self.items_total = items_total
self.bytes_done = 0
self.bytes_total = bytes_total
self.phase_start = time.monotonic()
self._hb_bytes = 0
self._hb_time = time.monotonic()
def start_file(self, path, size, op):
with self.lock:
self.current[path] = [0, size, op]
def advance(self, path, nbytes):
with self.lock:
cur = self.current.get(path)
if cur:
cur[0] += nbytes
def finish_file(self, path):
with self.lock:
cur = self.current.pop(path, None)
if cur:
self.bytes_done += cur[1]
self.items_done += 1
def tick(self, items=1, add_bytes=0):
"""Licznik dla pominięć/etapów bez czytania danych (np. cache hit)."""
with self.lock:
self.items_done += items
self.bytes_done += add_bytes
def line(self):
with self.lock:
now = time.monotonic()
inflight = sum(c[0] for c in self.current.values())
done_now = self.bytes_done + inflight
dt = max(now - self._hb_time, 1e-6)
rate = (done_now - self._hb_bytes) / dt
self._hb_bytes = done_now
self._hb_time = now
elapsed = int(now - self.phase_start)
parts = [f"etap: {self.phase}"]
if self.detail:
parts.append(self.detail)
if self.items_total:
parts.append(f"pliki {self.items_done:,}/{self.items_total:,}")
elif self.items_done:
parts.append(f"przetworzono {self.items_done:,} plików")
if self.bytes_total:
pct = 100.0 * done_now / self.bytes_total
parts.append(f"dane {human(done_now)}/{human(self.bytes_total)} ({pct:.1f}%)")
shown = 0
for path, (done, total, op) in list(self.current.items()):
if shown >= HB_MAX_FILES:
parts.append(f"(+{len(self.current) - shown} innych w locie)")
break
if total:
fpct = 100.0 * done / total
parts.append(f"{op}: {path} [{human(done)}/{human(total)} = {fpct:.1f}%]")
else:
parts.append(f"{op}: {path}")
shown += 1
if rate > 1 and (self.current or self.bytes_total):
parts.append(f"tempo {human(rate)}/s")
if self.bytes_total and done_now < self.bytes_total:
eta = (self.bytes_total - done_now) / rate
parts.append(f"ETA etapu ~{int(eta // 60)}m{int(eta % 60):02d}s")
parts.append(f"czas etapu {elapsed // 60}m{elapsed % 60:02d}s")
return " | ".join(parts)
PROGRESS = Progress()
def heartbeat_loop(interval, stop_event):
while not stop_event.wait(interval):
ts = time.strftime("%H:%M:%S")
print(f"[ŻYJĘ {ts}] {PROGRESS.line()}", file=sys.stderr, flush=True)
# ====================== LOGIKA ======================
def hash_file(path, chunk_size=MIB):
h = make_hasher()
with open(path, "rb") as f:
for block in iter(lambda: f.read(chunk_size), b""):
h.update(block)
PROGRESS.advance(path, len(block))
return h.hexdigest()
def cached_hash(path, size):
"""Hash z cache stanu (walidacja size+mtime) albo policzony i zapisany."""
try:
mtime = os.stat(path).st_mtime
except OSError:
raise
d = STATE.get_hash(path, size, mtime)
if d is not None:
PROGRESS.tick(items=0, add_bytes=size) # licz jako "zrobione bajty" bez czytania
return d
d = hash_file(path)
STATE.put_hash(path, size, mtime, d)
return d
def parallel_hash(files, workers, progress_every=500, label="hash"):
"""Hashuje listę (abspath, relpath, size) w `workers` wątkach.
Zwraca dict abspath -> hexdigest (brak klucza = błąd odczytu).
Postęp per-plik raportowany do PROGRESS; hashe idą przez cache stanu."""
digests = {}
done_counter = [0]
counter_lock = threading.Lock()
def job(f):
p, _rel, size = f
PROGRESS.start_file(p, size, "hashuję")
try:
d = cached_hash(p, size)
except OSError:
d = None
PROGRESS.finish_file(p)
with counter_lock:
done_counter[0] += 1
if done_counter[0] % progress_every == 0:
print(f" ... {label} {done_counter[0]}/{len(files)}", file=sys.stderr)
return p, d
if workers <= 1:
for f in files:
p, d = job(f)
if d is not None:
digests[p] = d
return digests
with ThreadPoolExecutor(max_workers=workers) as ex:
for fut in as_completed(ex.submit(job, f) for f in files):
p, d = fut.result()
if d is not None:
digests[p] = d
return digests
def scan(root_dir):
"""Zwraca (abspath, relpath, size) dla zwykłych plików + liczbę błędów odczytu."""
files = []
errors = 0
for root, _dirs, names in os.walk(root_dir, onerror=lambda e: None):
for name in names:
p = os.path.join(root, name)
try:
st = os.lstat(p)
except OSError:
errors += 1
continue
if not stat.S_ISREG(st.st_mode):
continue # symlinki, urządzenia, potoki — pomijamy
files.append((p, os.path.relpath(p, root_dir), st.st_size))
PROGRESS.tick()
return files, errors
# ---- rejestr pominięć (thread-safe): kategoria -> lista (ścieżka, szczegół) ----
_SKIP_LOCK = threading.Lock()
_SKIPPED = defaultdict(list)
def add_skip(category, path, detail=""):
with _SKIP_LOCK:
_SKIPPED[category].append((path, detail))
def print_skipped(skipped_list_path=None):
"""Wypisuje raport pominięć pogrupowany po kategorii.
Bez skipped_list_path: pełna lista na stdout.
Z skipped_list_path: pełna lista do pliku, na stdout tylko liczniki per kategoria."""
with _SKIP_LOCK:
if not _SKIPPED:
return
total = sum(len(v) for v in _SKIPPED.values())
print(f"\n============ POMINIĘTE PLIKI ({total:,}) ============")
if skipped_list_path:
try:
with open(skipped_list_path, "w", encoding="utf-8") as fh:
for cat in sorted(_SKIPPED):
items = _SKIPPED[cat]
fh.write(f"# {cat} ({len(items)})\n")
for path, detail in items:
fh.write(f"{path}\t{detail}\n" if detail else f"{path}\n")
fh.write("\n")
for cat in sorted(_SKIPPED):
print(f" {cat:<28}: {len(_SKIPPED[cat]):,}")
print(f" (pełna lista zapisana do: {skipped_list_path})")
except OSError as e:
print(f" BŁĄD zapisu {skipped_list_path}: {e} — wypisuję na stdout:")
skipped_list_path = None
if not skipped_list_path:
for cat in sorted(_SKIPPED):
items = _SKIPPED[cat]
print(f"\n {cat} ({len(items)}):")
for path, detail in items:
print(f" {path}" + (f" [{detail}]" if detail else ""))
print("========================================")
def apply_filter(files, min_size, keep_exts, only_exts=None):
kept, skipped, skipped_bytes = [], 0, 0
skipped_ext = 0
for p, rel, size in files:
ext = os.path.splitext(p)[1].lower()
if only_exts is not None and ext not in only_exts:
skipped_ext += 1
add_skip("poza listą --only-exts", p, f"rozszerzenie '{ext or '(brak)'}'")
continue
if size < min_size and ext not in keep_exts:
skipped += 1
skipped_bytes += size
add_skip("filtr rozmiaru", p, f"{human(size)} < próg {human(min_size)}")
continue
kept.append((p, rel, size))
return kept, skipped, skipped_bytes, skipped_ext
def build_existing_index(existing_files, source_sizes, workers):
"""Index treści istniejących danych: {(size, hash)}.
Hashuje tylko pliki, których rozmiar występuje w imporcie. Równolegle."""
to_hash = [f for f in existing_files if f[2] in source_sizes]
PROGRESS.set_phase("hash istniejących (--against)",
items_total=len(to_hash),
bytes_total=sum(f[2] for f in to_hash),
detail=f"{workers} wątk.")
if to_hash:
print(f" Istniejące dane: {len(to_hash)} plików o pasującym rozmiarze do zahashowania",
file=sys.stderr)
digests = parallel_hash(to_hash, workers, label="hash istniejących")
STATE.save()
index = set()
for p, _rel, size in to_hash:
d = digests.get(p)
if d is not None:
index.add((size, d))
return index
def dedup(kept, existing_index, existing_sizes, hash_workers):
"""Dedup źródła + odsianie treści już obecnych w existing_index.
Hashe liczone RÓWNOLEGLE (przez cache stanu), selekcja SEKWENCYJNIE
w kolejności skanu. Zwraca (unique, dup_internal, dup_existing, hashed_src)."""
size_count = Counter(f[2] for f in kept)
need_hash = [f for f in kept if size_count[f[2]] > 1 or f[2] in existing_sizes]
PROGRESS.set_phase("hash źródła (dedup)",
items_total=len(need_hash),
bytes_total=sum(f[2] for f in need_hash),
detail=f"hash tylko dla kolizji rozmiaru, {hash_workers} wątk.")
digests = parallel_hash(need_hash, hash_workers, label="hash źródła")
STATE.save()
seen = set()
unique = []
dup_internal = 0
dup_existing = 0
hashed_src = 0
for p, rel, size in kept:
needs = size_count[size] > 1 or size in existing_sizes
if needs:
hashed_src += 1
d = digests.get(p)
key = (size, d) if d is not None else None # nieczytelny -> unikat, nie gub
else:
key = (size, None) # unikalny rozmiar i brak w istniejących = na pewno unikat
if key is not None and key in existing_index:
dup_existing += 1
add_skip("już na NAS (--against)", p, f"treść identyczna, {human(size)}")
continue
if key is not None and key in seen:
dup_internal += 1
add_skip("duplikat w źródle", p, f"kopia wcześniejszego pliku, {human(size)}")
continue
if key is not None:
seen.add(key)
unique.append((p, rel, size))
return unique, dup_internal, dup_existing, hashed_src
def copy_file_with_progress(src, dst, chunk_size=COPY_CHUNK):
"""Jak shutil.copy2, ale chunkami z raportowaniem postępu bieżącego pliku."""
with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
for block in iter(lambda: fsrc.read(chunk_size), b""):
fdst.write(block)
PROGRESS.advance(src, len(block))
shutil.copystat(src, dst, follow_symlinks=False)
def do_copy(unique, dest, skip_existing, workers):
PROGRESS.set_phase("kopiowanie",
items_total=len(unique),
bytes_total=sum(f[2] for f in unique),
detail=f"{workers} wątk.")
n = len(unique)
state_lock = threading.Lock()
stats = {"copied": 0, "copied_bytes": 0, "skipped": 0, "done": 0}
def job(f):
p, rel, size = f
# 1) rejestr stanu — bez dotykania NFS
if STATE.is_copied(rel, size):
with state_lock:
stats["skipped"] += 1
stats["done"] += 1
add_skip("już skopiowany (rejestr stanu)", p, f"rel={rel}")
PROGRESS.tick(add_bytes=size)
return
dpath = os.path.join(dest, rel)
os.makedirs(os.path.dirname(dpath), exist_ok=True)
# 2) klasyczny skip-existing (stat na celu)
if skip_existing and os.path.exists(dpath):
try:
if os.path.getsize(dpath) == size:
STATE.put_copied(rel, size)
with state_lock:
stats["skipped"] += 1
stats["done"] += 1
add_skip("już w celu (ten sam rozmiar)", p, f"cel={dpath}")
PROGRESS.tick(add_bytes=size)
return
except OSError:
pass
PROGRESS.start_file(p, size, "kopiuję")
try:
copy_file_with_progress(p, dpath)
STATE.put_copied(rel, size)
with state_lock:
stats["copied"] += 1
stats["copied_bytes"] += size
except OSError as e:
print(f" BŁĄD kopiowania {p}: {e}", file=sys.stderr)
PROGRESS.finish_file(p)
with state_lock:
stats["done"] += 1
if stats["done"] % 200 == 0:
print(f" ... kopiuję {stats['done']}/{n} ({human(stats['copied_bytes'])})",
file=sys.stderr)
if workers <= 1:
for f in unique:
job(f)
else:
with ThreadPoolExecutor(max_workers=workers) as ex:
list(ex.map(job, unique))
STATE.save()
return stats["copied"], stats["copied_bytes"], stats["skipped"]
def install_signal_handlers():
"""SIGINT/SIGTERM: zapisz stan i wyjdź. Wątki-workery są daemonami
ThreadPoolExecutora tylko pośrednio, więc kończymy twardo — plik w połowie
kopiowany zostanie dokończony przy następnym biegu (zły rozmiar w celu)."""
def handler(signum, _frame):
print(f"\nPrzerwano sygnałem {signal.Signals(signum).name} — zapisuję stan...",
file=sys.stderr)
STATE.save(force=True)
print("Stan zapisany. Uruchom ponownie z tym samym --state, aby wznowić.",
file=sys.stderr)
os._exit(130)
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGTERM, handler)
def main():
global STATE
ap = argparse.ArgumentParser(
description="Deduplikujące kopiowanie na NAS z filtrem, dedupem względem istniejących danych, plikiem stanu i sprawdzeniem miejsca.")
ap.add_argument("--source", required=True, help="Katalog źródłowy (dysk wpięty w host).")
ap.add_argument("--dest", required=True, help="Katalog docelowy (np. /mnt/pve/truenas-nfs/import).")
ap.add_argument("--against", action="append", default=[],
help="Katalog z ISTNIEJĄCYMI danymi (np. cały mount NAS). Treść już tam obecna nie jest kopiowana. Można podać wiele razy.")
ap.add_argument("--min-size", type=float, default=1.0,
help="Próg w MiB; pliki mniejsze pomijane (domyślnie 1). Wyjątki: --keep-exts.")
ap.add_argument("--only-exts", default=None, metavar="EXT,EXT",
help="Kopiuj WYŁĄCZNIE pliki z jednym z podanych rozszerzeń (po przecinku, np. 'mkv,mp4,iso'). Reszta pomijana PRZED hashowaniem. Filtr --min-size dalej obowiązuje.")
ap.add_argument("--keep-exts", default="jpg,jpeg,png,gif",
help="Rozszerzenia kopiowane niezależnie od rozmiaru (po przecinku).")
ap.add_argument("--skipped-list", default=None, metavar="PLIK",
help="Zapisz pełną listę pominiętych plików do pliku; na stdout tylko liczniki per kategoria. Bez tej opcji pełna lista idzie na stdout.")
ap.add_argument("--reserve", type=float, default=1.0,
help="Margines wolnego miejsca w GiB do zostawienia na NAS (domyślnie 1).")
ap.add_argument("--workers", type=int, default=4,
help="Wątki kopiowania i hashowania --against (NFS). Domyślnie 4.")
ap.add_argument("--hash-workers", type=int, default=1,
help="Wątki hashowania ŹRÓDŁA. Domyślnie 1. Zwiększ tylko dla SSD; na HDD więcej = wolniej (seeki).")
ap.add_argument("--state", default=None, metavar="PLIK.json",
help="Plik stanu JSON: cache hashy + rejestr skopiowanych. Przerwany bieg wznawia się bez powtarzania pracy.")
ap.add_argument("--state-interval", type=float, default=30.0,
help="Co ile sekund autozapis pliku stanu (domyślnie 30).")
ap.add_argument("--dry-run", action="store_true", help="Tylko analiza + raport, bez kopiowania.")
ap.add_argument("--yes", action="store_true", help="Nie pytaj o potwierdzenie.")
ap.add_argument("--no-skip-existing", action="store_true",
help="Nie pomijaj plików już obecnych w celu po ścieżce (domyślnie pomija = wznawianie).")
ap.add_argument("--heartbeat", type=float, default=120.0,
help="Co ile sekund wypisywać znak życia na stderr (domyślnie 120; 0 = wyłącz).")
args = ap.parse_args()
source = os.path.abspath(args.source)
dest = os.path.abspath(args.dest)
min_size = int(args.min_size * MIB)
keep_exts = {("." + e.strip().lower().lstrip(".")) for e in args.keep_exts.split(",") if e.strip()}
only_exts = None
if args.only_exts:
only_exts = {("." + e.strip().lower().lstrip(".")) for e in args.only_exts.split(",") if e.strip()}
if not only_exts:
sys.exit("BŁĄD: --only-exts podane, ale lista rozszerzeń pusta.")
reserve = int(args.reserve * (1 << 30))
skip_existing = not args.no_skip_existing
workers = max(1, args.workers)
hash_workers = max(1, args.hash_workers)
if not os.path.isdir(source):
sys.exit(f"BŁĄD: źródło nie istnieje lub nie jest katalogiem: {source}")
os.makedirs(dest, exist_ok=True)
STATE = State(os.path.abspath(args.state) if args.state else None)
install_signal_handlers()
print(f"Źródło : {source}")
print(f"Cel : {dest}")
if args.against:
print(f"Dedup vs : {', '.join(os.path.abspath(a) for a in args.against)}")
if only_exts:
print(f"Tylko ext : {sorted(only_exts)}")
print(f"Filtr : pomijaj < {human(min_size)} poza {sorted(keep_exts)}")
print(f"Hash : {HASH_NAME}")
print(f"Wątki : kopiowanie/against={workers}, hash źródła={hash_workers}")
print(f"Stan : {STATE.path or 'brak (bez wznawiania)'}")
if args.heartbeat > 0:
print(f"Puls : co {args.heartbeat:.0f} s na stderr\n")
else:
print("Puls : wyłączony\n")
stop_hb = threading.Event()
if args.heartbeat > 0:
threading.Thread(target=heartbeat_loop, args=(args.heartbeat, stop_hb), daemon=True).start()
if STATE.path:
threading.Thread(target=state_saver_loop,
args=(max(5.0, args.state_interval), stop_hb), daemon=True).start()
try:
print("[1/4] Skanuję źródło...", file=sys.stderr)
PROGRESS.set_phase("skanowanie źródła", detail=source)
all_files, scan_errors = scan(source)
total_scanned = len(all_files)
total_scanned_bytes = sum(f[2] for f in all_files)
print("[2/4] Filtruję...", file=sys.stderr)
kept, skipped_small, skipped_bytes, skipped_ext = apply_filter(
all_files, min_size, keep_exts, only_exts)
source_sizes = set(f[2] for f in kept)
existing_index = set()
existing_sizes = set()
existing_total = 0
if args.against:
print("[3/4] Skanuję istniejące dane i buduję index treści...", file=sys.stderr)
existing_files = []
for d in args.against:
PROGRESS.set_phase("skanowanie --against", detail=os.path.abspath(d))
ef, _err = scan(os.path.abspath(d))
existing_files.extend(ef)
existing_total = len(existing_files)
existing_sizes = set(f[2] for f in existing_files)
existing_index = build_existing_index(existing_files, source_sizes, workers)
else:
print("[3/4] (pominięto — brak --against)", file=sys.stderr)
print("[4/4] Deduplikuję źródło i odsiewam treści już obecne...", file=sys.stderr)
unique, dup_internal, dup_existing, hashed_src = dedup(
kept, existing_index, existing_sizes, hash_workers)
PROGRESS.set_phase("raport / oczekiwanie")
to_copy_bytes = sum(f[2] for f in unique)
usage = shutil.disk_usage(dest)
fits = (to_copy_bytes + reserve) <= usage.free
# --- RAPORT ---
print("\n================ RAPORT ================")
print(f"Plików w źródle : {total_scanned:,} ({human(total_scanned_bytes)})")
if only_exts:
print(f"Pominięte (--only-exts) : {skipped_ext:,}")
print(f"Pominięte filtrem (<{human(min_size)}) : {skipped_small:,} ({human(skipped_bytes)})")
print(f"Po filtrze : {len(kept):,}")
if args.against:
print(f"Istniejące dane (skan) : {existing_total:,} plików")
print(f"Odsiane (już na NAS) : {dup_existing:,}")
print(f"Duplikaty w źródle : {dup_internal:,}")
print(f"Zahashowano plików źródła : {hashed_src:,}")
if STATE.path:
print(f"Hashe z cache stanu : {STATE.hash_hits:,}")
print(f"UNIKALNE do skopiowania : {len(unique):,} ({human(to_copy_bytes)})")
if scan_errors:
print(f"Błędy odczytu przy skanie : {scan_errors}")
print("----------------------------------------")
print(f"Wolne miejsce na celu : {human(usage.free)}")
print(f"Potrzeba (+margines {human(reserve)}): {human(to_copy_bytes + reserve)}")
print(f"MIEŚCI SIĘ : {'TAK' if fits else 'NIE'}")
print("========================================\n")
if not fits:
STATE.save(force=True)
sys.exit("PRZERWANO: za mało miejsca na woluminie docelowym. "
"Zwolnij miejsce, zmień --dest, albo zaostrz filtr (--min-size).")
if args.dry_run:
STATE.save(force=True)
print("Dry-run: nic nie skopiowano (hashe trafiły do pliku stanu).")
print_skipped(args.skipped_list)
return
if not args.yes:
ans = input(f"Skopiować {len(unique):,} plików ({human(to_copy_bytes)}) do {dest}? [t/N] ").strip().lower()
if ans not in ("t", "tak", "y", "yes"):
STATE.save(force=True)
print("Anulowano (stan zapisany).")
return
print("\nKopiuję...", file=sys.stderr)
copied, copied_bytes, skipped_ex = do_copy(unique, dest, skip_existing, workers)
print("\n================ KONIEC ================")
print(f"Skopiowano : {copied:,} plików ({human(copied_bytes)})")
if skip_existing:
print(f"Pominięto (były) : {skipped_ex:,}")
if STATE.path:
print(f" w tym z rejestru stanu: {STATE.copy_hits:,}")
print("========================================")
print_skipped(args.skipped_list)
finally:
stop_hb.set()
STATE.save(force=True)
if __name__ == "__main__":
main()
+2 -1
View File
@@ -54,7 +54,8 @@ Hooks.once("ready", () => {
window._gkAutoLoadoutHookId = Hooks.on("createItem", async (item, opts, userId) => { window._gkAutoLoadoutHookId = Hooks.on("createItem", async (item, opts, userId) => {
try { try {
if (item?.type !== "archetype") return; if (item?.type !== "archetype") return;
const src = item?.flags?.core?.sourceId || ""; // v12+: pochodzenie z paczki przeniesione do _stats.compendiumSource (flags.core.sourceId deprecated)
const src = item?._stats?.compendiumSource || item?.flags?.core?.sourceId || "";
const isFromOurPack = src.startsWith(PACK_SRC_PREFIX); const isFromOurPack = src.startsWith(PACK_SRC_PREFIX);
const isOurGKByName = item?.name === "Grey Knight (Archetype)"; const isOurGKByName = item?.name === "Grey Knight (Archetype)";
if (!isFromOurPack && !isOurGKByName) return; if (!isFromOurPack && !isOurGKByName) return;
+2 -2
View File
@@ -21,13 +21,13 @@
luminosity:0.5, attenuation:0.35, saturation:0, contrast:0, shadows:0, luminosity:0.5, attenuation:0.35, saturation:0, contrast:0, shadows:0,
animation:{ type:"pulse", speed:3, intensity:4, reverse:false } } animation:{ type:"pulse", speed:3, intensity:4, reverse:false } }
}); });
await canvas.perception.update({ lighting: { refresh: true } }); await canvas.perception.update({ refreshLighting: true });
}; };
const setGlowOff = async () => { const setGlowOff = async () => {
const prev = token.document.getFlag(FLAG_SCOPE, FLAG_KEY); const prev = token.document.getFlag(FLAG_SCOPE, FLAG_KEY);
if (prev) { await token.document.update({ light: prev }); await token.document.unsetFlag(FLAG_SCOPE, FLAG_KEY); } if (prev) { await token.document.update({ light: prev }); await token.document.unsetFlag(FLAG_SCOPE, FLAG_KEY); }
else { await token.document.update({ light: { dim:0, bright:0, color:null } }); } else { await token.document.update({ light: { dim:0, bright:0, color:null } }); }
await canvas.perception.update({ lighting: { refresh: true } }); await canvas.perception.update({ refreshLighting: true });
}; };
if (wasDisabled) { await setGlowOn(); ChatMessage.create({ content: `<b>Aegis:</b> Warding Runes <span style="color:#66ccff">ENGAGED</span>. (Akcja)` }); } if (wasDisabled) { await setGlowOn(); ChatMessage.create({ content: `<b>Aegis:</b> Warding Runes <span style="color:#66ccff">ENGAGED</span>. (Akcja)` }); }
else { await setGlowOff(); ChatMessage.create({ content: `<b>Aegis:</b> Warding Runes DISENGAGED.` }); } else { await setGlowOff(); ChatMessage.create({ content: `<b>Aegis:</b> Warding Runes DISENGAGED.` }); }
+5 -2
View File
@@ -52,7 +52,9 @@ async function gkResolveActorFromMessage(doc) {
} }
async function gkIsPsychicMessage(doc) { async function gkIsPsychicMessage(doc) {
try { try {
const raw = `${doc.flavor || ""} ${TextEditor?.stripHTML(doc.content || "") || ""}`.toLowerCase(); // v13: TextEditor przeniesiony do foundry.applications.ux (global deprecated) — z fallbackiem na v11/v12
const _TE = foundry.applications?.ux?.TextEditor?.implementation ?? globalThis.TextEditor;
const raw = `${doc.flavor || ""} ${_TE?.stripHTML?.(doc.content || "") || ""}`.toLowerCase();
const keys = ["psychic","psychic mastery","psychic power","moc psioniczna","smite","hammerhand","sanctuary","shrouding","might of titan"]; const keys = ["psychic","psychic mastery","psychic power","moc psioniczna","smite","hammerhand","sanctuary","shrouding","might of titan"];
if (keys.some(k=>raw.includes(k))) return true; if (keys.some(k=>raw.includes(k))) return true;
const flagsStr = JSON.stringify(doc.flags || {}).toLowerCase(); const flagsStr = JSON.stringify(doc.flags || {}).toLowerCase();
@@ -105,7 +107,8 @@ Hooks.once("ready", () => {
const ok = await ensureHasItemByName(actor, "Smite", "psychicPower"); const ok = await ensureHasItemByName(actor, "Smite", "psychicPower");
if (!ok) { if (!ok) {
ui.notifications?.warn("Smite not found in available packs. Please add a Librarius power manually."); ui.notifications?.warn("Smite not found in available packs. Please add a Librarius power manually.");
game.ui?.sidebar?.tabs?.compendium?.activate(); // globalny obiekt to `ui`, nie `game.ui`; activateTab działa w v12/v13
ui.sidebar?.activateTab?.("compendium");
} }
} }
} catch (e) { console.error("gk-greyknight — Brotherhood prompt:", e); } } catch (e) { console.error("gk-greyknight — Brotherhood prompt:", e); }
+1 -1
View File
@@ -188,7 +188,7 @@ Hooks.on("getSceneControlButtons", (controls) => {
button: true, button: true,
visible: true, visible: true,
order: 999, order: 999,
onClick: () => GKFocusPanel.toggle() onChange: () => GKFocusPanel.toggle()
}; };
} }
}); });
@@ -1,24 +0,0 @@
{"name":"Lunar-class Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Lunar-class Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":80},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Archetypal line cruiser—balanced, dependable, easily refitted. Backbone of many squadrons.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 24"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Lunar-class Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"0b59c38ddda63f42","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Secutor-class Monitor-Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Secutor-class Monitor-Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":3,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":68,"power":82},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Mechanicus monitor with powerful dorsals and redundant shielding; anchors actions.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 151"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Secutor-class Monitor-Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"0eb071148d375807","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Lathe-class Monitor-Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Lathe-class Monitor-Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":3,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":68,"power":82},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Lathes-forged variant emphasising resilient sanctified systems.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 152"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Lathe-class Monitor-Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"104965869ffa36bb","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Frigate Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Frigate Hull (Empty)","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":24,"manoeuvre":1,"detection":1,"speed":10,"space":40,"power":45},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":24},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Frigate Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"1f954166473bedf3","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Conquest-class Star Galleon","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Conquest-class Star Galleon","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":75,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Heavily built merchant galleon with strong batteries and hull.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 25"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Conquest-class Star Galleon","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"287a7cd15c95031f","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Goliath-class Factory Ship","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Goliath-class Factory Ship","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":75,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Roving manufactorum and refinery; long autonomous deployments.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 30"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Goliath-class Factory Ship","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"2c612305a7bb853f","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Escort Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Escort Hull (Empty)","size":"Escort","slots":{"prow":1,"dorsal":0,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":18,"manoeuvre":2,"detection":1,"speed":10,"space":35,"power":40},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":18},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Escort Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"4412029d26021383","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Hazeroth-class Privateer","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Hazeroth-class Privateer","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":38,"power":45},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Knife-fast raider with reinforced internals and stripped armour. Favoured for ambushes and commerce raiding.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Rogue Trader Core Rulebook, p. 195"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Hazeroth-class Privateer","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"521f6e630bd813a4","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Tyrant-class Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Tyrant-class Cruiser","size":"Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":80},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Macrobattery-focused cruiser optimised for crushing broadsides.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 153"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Tyrant-class Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"6774443321f24e87","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Universe-class Mass Conveyor","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Universe-class Mass Conveyor","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"},{"tag":"cargo-bay","min":4,"label":"Cargo Holds"},{"tag":"aux-plasma","min":1,"label":"Auxiliary Plasma Generator"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":80,"power":60},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Super-massive hauler for colony loads and crusade materiel; logistics linchpin.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 31"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Universe-class Mass Conveyor","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"6836e238cce8d9fc","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Cobra-class Destroyer (Renegade)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Cobra-class Destroyer (Renegade)","size":"Escort","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":36,"power":42},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Ex-Navy Cobra in privateer hands with non-standard prow refits.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 66"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Cobra-class Destroyer (Renegade)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"6da4418bdbd2206c","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Defiant-class Light Cruiser (Carrier)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Defiant-class Light Cruiser (Carrier)","size":"Light Cruiser","slots":{"prow":1,"dorsal":1,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":11,"integrity":32,"manoeuvre":0,"detection":1,"speed":9,"space":60,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":0,"notes":"Light carrier with significant attack craft; excellent for recon and strikes.","combat":{"size":"colossal","speed":9,"wounds":{"value":0,"bonus":0,"max":32},"resilience":{"bonus":0,"total":11},"defence":{"bonus":2},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Defiant-class Light Cruiser (Carrier)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"73eed7c83ea60340","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Orion-class Star Clipper","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Orion-class Star Clipper","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Fast passenger/light-cargo clipper for courier missions and risky runs.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Into the Storm, p. 150"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Orion-class Star Clipper","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"750ee1998f698e08","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Vagabond-class Merchant Trader","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Vagabond-class Merchant Trader","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":72,"power":68},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Modular cargobays and adaptable fittings; a chartist workhorse. Simple to keep running in frontier yards; heavily customised.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Rogue Trader Core Rulebook, p. 195"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Vagabond-class Merchant Trader","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"95fcf7b99d705bd1","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Cruiser Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Cruiser Hull (Empty)","size":"Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":40,"manoeuvre":-1,"detection":1,"speed":8,"space":70,"power":80},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":40},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Cruiser Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"96a0dacd77d672bb","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Jericho-class Pilgrim Vessel","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Jericho-class Pilgrim Vessel","size":"Cruiser","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":12,"integrity":36,"manoeuvre":-1,"detection":1,"speed":8,"space":75,"power":68},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":-1,"notes":"Immense conversions for pilgrim passages and reliquary cargo. Cavernous holds and chapels; range and capacity over armour and speed.","combat":{"size":"colossal","speed":8,"wounds":{"value":0,"bonus":0,"max":36},"resilience":{"bonus":0,"total":12},"defence":{"bonus":2},"fly":0},"source":"Rogue Trader Core Rulebook, p. 195"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Jericho-class Pilgrim Vessel","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"a08b1d9ef251ec0b","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Cobra-class Destroyer","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Cobra-class Destroyer","size":"Escort","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":36,"power":42},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Torpedo raider hunting in packs; strikes, then disengages to reload.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 66"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Cobra-class Destroyer","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"b518e981d69324aa","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Havoc-class Merchant Raider","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Havoc-class Merchant Raider","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":9,"integrity":22,"manoeuvre":2,"detection":1,"speed":10,"space":38,"power":45},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":2,"notes":"Hybrid carrier-raider balancing battery weight with cargo. Less protected than Navy hulls but brutal on traffic.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":22},"resilience":{"bonus":0,"total":9},"defence":{"bonus":3},"fly":0},"source":"Rogue Trader Core Rulebook, p. 196"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Havoc-class Merchant Raider","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"c4c69cf1b895c0e1","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Light Cruiser Hull (Empty)","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Light Cruiser Hull (Empty)","size":"Light Cruiser","slots":{"prow":1,"dorsal":1,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":11,"integrity":32,"manoeuvre":0,"detection":1,"speed":9,"space":60,"power":70},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":0,"notes":"Empty hull. Add Components from Items pack.","combat":{"size":"colossal","speed":9,"wounds":{"value":0,"bonus":0,"max":32},"resilience":{"bonus":0,"total":11},"defence":{"bonus":2},"fly":0}}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Light Cruiser Hull (Empty)","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"c5ca1fc87803b111","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Dauntless-class Light Cruiser","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Dauntless-class Light Cruiser","size":"Light Cruiser","slots":{"prow":1,"dorsal":2,"port":1,"starboard":1,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":11,"integrity":32,"manoeuvre":0,"detection":1,"speed":9,"space":62,"power":72},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":0,"notes":"Versatile patrol cruiser—good acceleration and flexible refits. Often leads Passage Watch patrols.","combat":{"size":"colossal","speed":9,"wounds":{"value":0,"bonus":0,"max":32},"resilience":{"bonus":0,"total":11},"defence":{"bonus":2},"fly":0},"source":"Battlefleet Koronus, p. 36"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Dauntless-class Light Cruiser","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"c8975bb7181d8e12","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Falchion-class Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Falchion-class Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Modernised frigate with improved drives for frontier scouting.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 28"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Falchion-class Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"d3853b0270deaf3f","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Sword-class Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Sword-class Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Reliable escort with accurate dorsal lasers and stout drives. Squadrons screen convoys and prosecute pirates.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 28"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Sword-class Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"ec53303961ef4fd4","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Turbulent-class Heavy Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Turbulent-class Heavy Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Strengthened frame and broader arcs; trades agility for staying power.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Battlefleet Koronus, p. 28"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Turbulent-class Heavy Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"f1ea3416a6d0d27e","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
{"name":"Tempest-class Strike Frigate","type":"vehicle","img":"icons/svg/ship.svg","folder":null,"sort":0,"ownership":{"default":0},"flags":{"core":{"sheetClass":"wg-voidships-builder.VoidshipSheet"},"wg-voidships-builder":{"isVoidship":true,"hull":{"class":"Tempest-class Strike Frigate","size":"Frigate","slots":{"prow":1,"dorsal":1,"port":0,"starboard":0,"keel":0},"requirements":[{"tag":"bridge","min":1,"label":"Bridge"},{"tag":"reactor","min":1,"label":"Plasma Reactor"},{"tag":"gellar","min":1,"label":"Gellar Field"},{"tag":"warp","min":1,"label":"Warp Drive"}]},"base":{"armour":10,"integrity":28,"manoeuvre":1,"detection":1,"speed":10,"space":42,"power":48},"crew":{"command":1,"pilot":1,"sensors":1,"engineering":2,"gunnery":2,"security":2,"flightDeck":0,"passengers":0},"rtRaw":{"mnvr":1,"notes":"Close-action brawler for boardings and short-range gunnery. Reinforced prow and over-tuned drives.","combat":{"size":"colossal","speed":10,"wounds":{"value":0,"bonus":0,"max":28},"resilience":{"bonus":0,"total":10},"defence":{"bonus":3},"fly":0},"source":"Rogue Trader Core Rulebook, p. 196"}}},"system":{},"effects":[],"items":[],"prototypeToken":{"name":"Tempest-class Strike Frigate","actorLink":true,"texture":{"src":"icons/svg/ship.svg"},"disposition":1,"displayName":0},"_id":"fb874877eacc7a08","systemId":"wrath-and-glory","systemVersion":"7.1.1"}
@@ -1,10 +0,0 @@
{"_id":"BRIDGECLV0000001","name":"Command Bridge (Civilian)","type":"gear","img":"icons/svg/book.svg","flags":{"wg-voidships-builder":{"component":{"kind":"bridge","subtype":"civilian","tags":["bridge","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":1,"detection":1,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Standard command bridge and cogitators.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"REACTORPLSM00001","name":"Plasma Reactor (Civilian)","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"component":{"kind":"reactor","subtype":"plasma","tags":["reactor","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":4,"power":-10},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":10}}}},"system":{"quantity":1,"rarity":"Common","description":"Primary plasma reactor for voidship operations.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"GELLARSTD0000001","name":"Gellar Field Projector","type":"gear","img":"icons/svg/temple.svg","flags":{"wg-voidships-builder":{"component":{"kind":"gellar","subtype":"standard","tags":["gellar","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":3},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Essential protection in the Immaterium.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"WARPDRVMILO00001","name":"Miloslav Warp Drive (Civilian)","type":"gear","img":"icons/svg/wing.svg","flags":{"wg-voidships-builder":{"component":{"kind":"drive","subtype":"warp","tags":["drive","warp","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":3,"power":6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Reliable civilian warp drive.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"AUGURARRAY000001","name":"Augur Array (Civilian)","type":"gear","img":"icons/svg/mystery-man.svg","flags":{"wg-voidships-builder":{"component":{"kind":"sensor","subtype":"augur","tags":["sensor","auspex","aetherics"]},"voidOnly":true,"voidship":{"slot":"Aetherics","use":{"space":1,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":2,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Standard augur/sensor package.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"HULLPLATE0000001","name":"Reinforced Hull Plating","type":"gear","img":"icons/svg/armor.svg","flags":{"wg-voidships-builder":{"component":{"kind":"armour","subtype":"plating","tags":["hull","armour","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":1,"power":0},"mods":{"armour":2,"integrity":4,"manoeuvre":-1,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Additional armour plates and bracing.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"CARGOHOLD0000001","name":"Cargo Hold & Lighter Bay","type":"gear","img":"icons/svg/crate.svg","flags":{"wg-voidships-builder":{"component":{"kind":"cargo-bay","subtype":"standard","tags":["cargo-bay","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":-5,"power":1},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":-5,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"General-purpose cargo hold with lighter handling.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"AUXPLASMA0000001","name":"Auxiliary Plasma Generator","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"component":{"kind":"plasma","subtype":"aux-generator","tags":["aux-plasma","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":-6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":6}}}},"system":{"quantity":1,"rarity":"Common","description":"Auxiliary plasma reactor increasing available power.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"MACROPROW0000001","name":"Macrobattery (Prow)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","prow"]},"voidOnly":true,"voidship":{"slot":"Prow","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Prow macrobattery.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
{"_id":"MACRODORS0000001","name":"Macrobattery (Dorsal)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","dorsal"]},"voidOnly":true,"voidship":{"slot":"Dorsal","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Dorsal macrobattery.","value":0,"keywords":[],"effect":"","test":""},"effects":[]}
@@ -385,7 +385,8 @@ async _onDropItem(event, data) {
// Register the sheet // Register the sheet
Hooks.once("init", () => { Hooks.once("init", () => {
Actors.registerSheet(VB_MOD, VoidshipSheet, { // v13: kolekcje dokumentów pod foundry.documents.collections (global Actors deprecated) — fallback na v12
(foundry.documents?.collections?.Actors ?? Actors).registerSheet(VB_MOD, VoidshipSheet, {
types: ["vehicle"], types: ["vehicle"],
makeDefault: false, makeDefault: false,
label: "Voidship (W&G)" label: "Voidship (W&G)"
+3 -1
View File
@@ -3,7 +3,9 @@ Hooks.once('init', async () => {
if (typeof Handlebars?.registerHelper === "function") { if (typeof Handlebars?.registerHelper === "function") {
Handlebars.registerHelper("json", (ctx) => JSON.stringify(ctx ?? {}, null, 2)); Handlebars.registerHelper("json", (ctx) => JSON.stringify(ctx ?? {}, null, 2));
} }
await loadTemplates(["modules/wg-voidships-builder/templates/voidship-sheet.hbs"]); // v13: loadTemplates przeniesiony do foundry.applications.handlebars (global deprecated) — fallback na v12
const _loadTemplates = foundry.applications?.handlebars?.loadTemplates ?? loadTemplates;
await _loadTemplates(["modules/wg-voidships-builder/templates/voidship-sheet.hbs"]);
console.debug("[VB] templates preloaded"); console.debug("[VB] templates preloaded");
} catch (e) { } catch (e) {
console.warn("wg-voidships-builder: init failed", e); console.warn("wg-voidships-builder: init failed", e);