Compare commits
8 Commits
bc49286f91
..
14.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9617005a5c | |||
| 73a3381d2a | |||
| fbfb265570 | |||
| eda9b77f46 | |||
| 64ab54daf3 | |||
| 0e1594773b | |||
| 7f5beb401e | |||
| a606d62904 |
@@ -9,3 +9,5 @@ node_modules/
|
|||||||
chroniquesdeletrange.lock
|
chroniquesdeletrange.lock
|
||||||
*.pdf
|
*.pdf
|
||||||
*.github/
|
*.github/
|
||||||
|
regles.txt
|
||||||
|
regles.txt
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
# Les Chroniques de l'étrange pour FoundryVTT
|
# Chroniques de l'étrange — Système FoundryVTT
|
||||||
|
|
||||||
|
Système [Foundry VTT](https://foundryvtt.com) pour **Chroniques de l'Etrange**, le jeu de rôle d'[Antre Monde Éditions](https://antremonde.fr).
|
||||||
|
|
||||||
|
Copyright 2025-2026 Antre Monde Editions All rights reserved
|
||||||
|
|
||||||
|
Chroniques de l'ETrange is a game written by Romain d'Huissier and Cédric Lameire. The authors retain their moral rights to this work in both print and digital formats.
|
||||||
|
|
||||||
|
This system for FoundryVTT has been approved and authorized by Antre-Monde Edition
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
Implémentation du JDR Les Chroniques de l'Etrange de Antre-Monde éditions.
|
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Analyze all JSON files in packs-src/ for text quality issues."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
|
|
||||||
BASE = Path("/home/morr/work/uberwald/fvtt-chroniques-de-l-etrange")
|
|
||||||
PACKS = BASE / "packs-src"
|
|
||||||
REGLES = BASE / "regles.txt"
|
|
||||||
|
|
||||||
# Load PDF text
|
|
||||||
pdf_lines = REGLES.read_text(encoding="utf-8").splitlines()
|
|
||||||
pdf_text = REGLES.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
issues = []
|
|
||||||
|
|
||||||
# ---------- helpers ----------
|
|
||||||
|
|
||||||
def strip_html(html):
|
|
||||||
"""Remove HTML tags and return plain text."""
|
|
||||||
return re.sub(r'<[^>]+>', '', html or '')
|
|
||||||
|
|
||||||
def check_unclosed_tags(html):
|
|
||||||
"""Returns list of unclosed/mismatched tags."""
|
|
||||||
open_tags = re.findall(r'<([a-zA-Z][a-zA-Z0-9]*)[^>]*>', html)
|
|
||||||
close_tags = re.findall(r'</([a-zA-Z][a-zA-Z0-9]*)>', html)
|
|
||||||
issues_found = []
|
|
||||||
# basic: count opens vs closes for block-level tags
|
|
||||||
for tag in ['ul', 'ol', 'li', 'p', 'div', 'strong', 'em', 'b', 'i']:
|
|
||||||
opens = open_tags.count(tag)
|
|
||||||
closes = close_tags.count(tag)
|
|
||||||
if opens != closes:
|
|
||||||
issues_found.append(f"<{tag}>: {opens} open, {closes} close")
|
|
||||||
return issues_found
|
|
||||||
|
|
||||||
def has_bad_newlines(html):
|
|
||||||
"""Check for literal \\n inside HTML strings that would render as bad breaks."""
|
|
||||||
# In JSON, \n is a newline. In HTML strings, raw newlines can be bad.
|
|
||||||
return '\n' in html
|
|
||||||
|
|
||||||
def looks_truncated(text):
|
|
||||||
"""Heuristics for truncation - text ends without proper punctuation."""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
plain = strip_html(text).strip()
|
|
||||||
if not plain:
|
|
||||||
return False
|
|
||||||
# ends without sentence-ending punctuation
|
|
||||||
if plain and plain[-1] not in '.!?»)':
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def looks_truncated_strict(text):
|
|
||||||
"""Stricter: ends mid-word or mid-sentence."""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
plain = strip_html(text).strip()
|
|
||||||
if not plain:
|
|
||||||
return False
|
|
||||||
# ends mid-word (no space before end, no punctuation)
|
|
||||||
last_char = plain[-1] if plain else ''
|
|
||||||
if last_char.isalpha() or last_char in ',;:-(':
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_field(data, path):
|
|
||||||
"""Get nested field value by dot-path."""
|
|
||||||
parts = path.split('.')
|
|
||||||
cur = data
|
|
||||||
for p in parts:
|
|
||||||
if isinstance(cur, dict):
|
|
||||||
cur = cur.get(p)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if cur is None:
|
|
||||||
return None
|
|
||||||
return cur
|
|
||||||
|
|
||||||
def search_pdf(keyword, context=300):
|
|
||||||
"""Search PDF text for a keyword and return surrounding context."""
|
|
||||||
# clean keyword for searching
|
|
||||||
kw = re.sub(r'<[^>]+>', '', keyword).strip()
|
|
||||||
if len(kw) < 10:
|
|
||||||
return None
|
|
||||||
# take last 30 chars of plain text as search key
|
|
||||||
search_key = kw[-30:].strip()
|
|
||||||
# normalize whitespace
|
|
||||||
search_key_norm = re.sub(r'\s+', ' ', search_key)
|
|
||||||
|
|
||||||
# Try to find in PDF
|
|
||||||
idx = pdf_text.find(search_key_norm)
|
|
||||||
if idx == -1:
|
|
||||||
# try shorter
|
|
||||||
search_key_norm = re.sub(r'\s+', ' ', kw[-20:].strip())
|
|
||||||
idx = pdf_text.find(search_key_norm)
|
|
||||||
if idx == -1:
|
|
||||||
# try even shorter
|
|
||||||
search_key_norm = re.sub(r'\s+', ' ', kw[-15:].strip())
|
|
||||||
idx = pdf_text.find(search_key_norm)
|
|
||||||
|
|
||||||
if idx == -1:
|
|
||||||
return None
|
|
||||||
|
|
||||||
start = max(0, idx - 50)
|
|
||||||
end = min(len(pdf_text), idx + len(search_key_norm) + context)
|
|
||||||
return pdf_text[start:end].replace('\n', ' ')
|
|
||||||
|
|
||||||
def get_all_html_fields(data, prefix=""):
|
|
||||||
"""Recursively yield (field_path, value) for all string fields containing HTML."""
|
|
||||||
if isinstance(data, dict):
|
|
||||||
for k, v in data.items():
|
|
||||||
path = f"{prefix}.{k}" if prefix else k
|
|
||||||
if isinstance(v, str) and ('<' in v or len(v) > 50):
|
|
||||||
yield path, v
|
|
||||||
elif isinstance(v, (dict, list)):
|
|
||||||
yield from get_all_html_fields(v, path)
|
|
||||||
elif isinstance(data, list):
|
|
||||||
for i, v in enumerate(data):
|
|
||||||
yield from get_all_html_fields(v, f"{prefix}[{i}]")
|
|
||||||
|
|
||||||
# ---------- fields to check ----------
|
|
||||||
|
|
||||||
IMPORTANT_FIELDS = [
|
|
||||||
"system.description",
|
|
||||||
"system.effects",
|
|
||||||
"system.examples",
|
|
||||||
"system.components",
|
|
||||||
"system.notes",
|
|
||||||
"system.style",
|
|
||||||
"system.techniques.technique1.technique",
|
|
||||||
"system.techniques.technique2.technique",
|
|
||||||
"system.techniques.technique3.technique",
|
|
||||||
]
|
|
||||||
|
|
||||||
# ---------- main scan ----------
|
|
||||||
|
|
||||||
json_files = sorted(PACKS.rglob("*.json"))
|
|
||||||
print(f"Scanning {len(json_files)} JSON files...", flush=True)
|
|
||||||
|
|
||||||
for jf in json_files:
|
|
||||||
rel = str(jf.relative_to(PACKS))
|
|
||||||
try:
|
|
||||||
data = json.loads(jf.read_text(encoding="utf-8"))
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": "(file)",
|
|
||||||
"issue": "json_parse_error",
|
|
||||||
"current_text": str(e),
|
|
||||||
"correct_continuation": None,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
item_name = data.get("name", "(unnamed)")
|
|
||||||
|
|
||||||
# Check all relevant fields
|
|
||||||
for field in IMPORTANT_FIELDS:
|
|
||||||
val = get_field(data, field)
|
|
||||||
if not val or not isinstance(val, str):
|
|
||||||
continue
|
|
||||||
|
|
||||||
plain = strip_html(val).strip()
|
|
||||||
|
|
||||||
# 1. Check truncation (strict)
|
|
||||||
if looks_truncated_strict(val):
|
|
||||||
pdf_context = search_pdf(val)
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": field,
|
|
||||||
"issue": "truncated",
|
|
||||||
"item_name": item_name,
|
|
||||||
"current_end": f"...{plain[-100:]}",
|
|
||||||
"current_full_preview": f"{plain[:200]}",
|
|
||||||
"correct_continuation": pdf_context,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 2. Check bad newlines in HTML strings
|
|
||||||
if has_bad_newlines(val):
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": field,
|
|
||||||
"issue": "unwanted_newlines",
|
|
||||||
"item_name": item_name,
|
|
||||||
"current_text": val[:300],
|
|
||||||
"correct_continuation": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 3. Check malformed HTML
|
|
||||||
html_errors = check_unclosed_tags(val)
|
|
||||||
if html_errors:
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": field,
|
|
||||||
"issue": "malformed_html",
|
|
||||||
"item_name": item_name,
|
|
||||||
"html_errors": html_errors,
|
|
||||||
"current_text": val[:300],
|
|
||||||
"correct_continuation": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 4. Check system.style (plain text field, can also be truncated)
|
|
||||||
style_val = get_field(data, "system.style")
|
|
||||||
if style_val and isinstance(style_val, str):
|
|
||||||
plain_style = style_val.strip()
|
|
||||||
if plain_style and plain_style[-1] not in '.!?»)':
|
|
||||||
pdf_context = search_pdf(plain_style)
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": "system.style",
|
|
||||||
"issue": "truncated",
|
|
||||||
"item_name": item_name,
|
|
||||||
"current_end": f"...{plain_style[-100:]}",
|
|
||||||
"current_full_preview": f"{plain_style[:200]}",
|
|
||||||
"correct_continuation": pdf_context,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 5. Bleeding content: look for HTML tags in non-HTML fields
|
|
||||||
for field in ["system.style", "system.reference", "system.speciality"]:
|
|
||||||
val = get_field(data, field)
|
|
||||||
if val and isinstance(val, str) and '<' in val:
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": field,
|
|
||||||
"issue": "html_in_plain_field",
|
|
||||||
"item_name": item_name,
|
|
||||||
"current_text": val[:300],
|
|
||||||
"correct_continuation": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 6. Check for text outside HTML tags in description-like fields (bleeding)
|
|
||||||
for field in ["system.description", "system.effects", "system.examples", "system.components", "system.notes"]:
|
|
||||||
val = get_field(data, field)
|
|
||||||
if not val or not isinstance(val, str):
|
|
||||||
continue
|
|
||||||
# Strip all HTML and check if leading text is outside tags
|
|
||||||
# e.g., "<p>foo</p> some leaked text <p>bar</p>"
|
|
||||||
# Check if there's text before the first tag
|
|
||||||
stripped = val.strip()
|
|
||||||
if stripped and not stripped.startswith('<'):
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": field,
|
|
||||||
"issue": "text_outside_html_tags",
|
|
||||||
"item_name": item_name,
|
|
||||||
"current_text": val[:300],
|
|
||||||
"correct_continuation": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 7. Check technique fields for bleeding (multiple paragraphs that shouldn't be there)
|
|
||||||
for tkey in ["technique1", "technique2", "technique3"]:
|
|
||||||
tech = get_field(data, f"system.techniques.{tkey}")
|
|
||||||
if not tech:
|
|
||||||
continue
|
|
||||||
tech_text = tech.get("technique", "")
|
|
||||||
if tech_text:
|
|
||||||
plain = strip_html(tech_text).strip()
|
|
||||||
# Check for suspiciously long techniques that might have bled content
|
|
||||||
# Techniques with multiple <p> blocks may be fine, but flag very long ones
|
|
||||||
p_count = tech_text.count('</p>')
|
|
||||||
if p_count > 3:
|
|
||||||
issues.append({
|
|
||||||
"file": rel,
|
|
||||||
"field": f"system.techniques.{tkey}.technique",
|
|
||||||
"issue": "possible_bleeding_content",
|
|
||||||
"item_name": item_name,
|
|
||||||
"paragraph_count": p_count,
|
|
||||||
"current_text": tech_text[:400],
|
|
||||||
"correct_continuation": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
print(f"Found {len(issues)} potential issues.", flush=True)
|
|
||||||
|
|
||||||
# ---------- output ----------
|
|
||||||
|
|
||||||
out_json = BASE / "compendium-issues.json"
|
|
||||||
out_txt = BASE / "compendium-issues.txt"
|
|
||||||
|
|
||||||
with open(out_json, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(issues, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
# Group by issue type for summary
|
|
||||||
from collections import defaultdict
|
|
||||||
by_type = defaultdict(list)
|
|
||||||
by_file = defaultdict(list)
|
|
||||||
for issue in issues:
|
|
||||||
by_type[issue['issue']].append(issue)
|
|
||||||
by_file[issue['file']].append(issue)
|
|
||||||
|
|
||||||
with open(out_txt, 'w', encoding='utf-8') as f:
|
|
||||||
f.write("=" * 80 + "\n")
|
|
||||||
f.write("COMPENDIUM TEXT QUALITY REPORT\n")
|
|
||||||
f.write("Les Chroniques de l'Étrange — FoundryVTT\n")
|
|
||||||
f.write("=" * 80 + "\n\n")
|
|
||||||
|
|
||||||
f.write(f"Total files scanned: {len(json_files)}\n")
|
|
||||||
f.write(f"Total issues found: {len(issues)}\n\n")
|
|
||||||
|
|
||||||
f.write("SUMMARY BY ISSUE TYPE:\n")
|
|
||||||
for itype, ilist in sorted(by_type.items()):
|
|
||||||
f.write(f" {itype}: {len(ilist)}\n")
|
|
||||||
f.write("\n")
|
|
||||||
|
|
||||||
f.write("=" * 80 + "\n")
|
|
||||||
f.write("DETAILED ISSUES BY FILE\n")
|
|
||||||
f.write("=" * 80 + "\n\n")
|
|
||||||
|
|
||||||
for fpath in sorted(by_file.keys()):
|
|
||||||
f.write(f"\n--- {fpath} ---\n")
|
|
||||||
for issue in by_file[fpath]:
|
|
||||||
f.write(f" FIELD: {issue['field']}\n")
|
|
||||||
f.write(f" ISSUE: {issue['issue']}\n")
|
|
||||||
if issue.get('item_name'):
|
|
||||||
f.write(f" ITEM: {issue['item_name']}\n")
|
|
||||||
if issue.get('current_end'):
|
|
||||||
f.write(f" END: {issue['current_end']}\n")
|
|
||||||
if issue.get('current_full_preview'):
|
|
||||||
f.write(f" TEXT: {issue['current_full_preview'][:200]}\n")
|
|
||||||
if issue.get('current_text'):
|
|
||||||
f.write(f" TEXT: {issue['current_text'][:200]}\n")
|
|
||||||
if issue.get('html_errors'):
|
|
||||||
f.write(f" HTML ERRORS: {issue['html_errors']}\n")
|
|
||||||
if issue.get('correct_continuation'):
|
|
||||||
f.write(f" PDF: {issue['correct_continuation'][:300]}\n")
|
|
||||||
f.write("\n")
|
|
||||||
|
|
||||||
print(f"Reports written to:\n {out_json}\n {out_txt}", flush=True)
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import json, re
|
|
||||||
from pathlib import Path
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
BASE = Path("/home/morr/work/uberwald/fvtt-chroniques-de-l-etrange")
|
|
||||||
PACKS = BASE / "packs-src"
|
|
||||||
pdf_text = (BASE / "regles.txt").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
WATERMARK_RE = re.compile(
|
|
||||||
r's\s*c\s*r\s*a\s*l\s*e\s*l|les\s+chroniqu|de\s+l.etrange|chr.niqu|hr\s+ng',
|
|
||||||
re.IGNORECASE)
|
|
||||||
|
|
||||||
def strip_html(html):
|
|
||||||
return re.sub(r'<[^>]+>', '', html or '').strip()
|
|
||||||
|
|
||||||
def has_watermark_bleed(text):
|
|
||||||
plain = strip_html(text)
|
|
||||||
return bool(WATERMARK_RE.search(plain))
|
|
||||||
|
|
||||||
def has_bad_newlines(text):
|
|
||||||
lines = text.split('\n')
|
|
||||||
if len(lines) <= 1:
|
|
||||||
return False
|
|
||||||
for line in lines:
|
|
||||||
s = line.strip()
|
|
||||||
if s and not re.match(r'^<[/a-zA-Z]', s) and not s.endswith('>') and len(s) > 3:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def looks_truncated(text):
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
plain = strip_html(text).strip()
|
|
||||||
plain_clean = re.sub(r'\s+[a-z]\s+[a-z]\s+[a-z]{1,2}\s+.*$', '', plain).strip()
|
|
||||||
if not plain_clean:
|
|
||||||
plain_clean = plain
|
|
||||||
last = plain_clean[-1] if plain_clean else ''
|
|
||||||
return last.isalpha() or last in ',;:-('
|
|
||||||
|
|
||||||
def get_field(data, path):
|
|
||||||
parts = path.split('.')
|
|
||||||
cur = data
|
|
||||||
for p in parts:
|
|
||||||
if isinstance(cur, dict):
|
|
||||||
cur = cur.get(p)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if cur is None:
|
|
||||||
return None
|
|
||||||
return cur
|
|
||||||
|
|
||||||
def pdf_search(keyword_text, context=500):
|
|
||||||
plain = strip_html(keyword_text)
|
|
||||||
plain = re.sub(r'\s+[a-z]\s+[a-z]\s+[a-z]{1,2}\s+.*$', '', plain).strip()
|
|
||||||
if len(plain) < 15:
|
|
||||||
return None
|
|
||||||
for suffix_len in [40, 30, 20, 15]:
|
|
||||||
suffix = re.sub(r'\s+', ' ', plain[-suffix_len:]).strip()
|
|
||||||
if len(suffix) < 10:
|
|
||||||
continue
|
|
||||||
idx = pdf_text.find(suffix)
|
|
||||||
if idx != -1:
|
|
||||||
snippet = pdf_text[idx:min(len(pdf_text), idx + len(suffix) + context)]
|
|
||||||
snippet = re.sub(r'\n+', ' ', snippet)
|
|
||||||
snippet = re.sub(r'\s{3,}', ' ', snippet)
|
|
||||||
return snippet[:600]
|
|
||||||
return None
|
|
||||||
|
|
||||||
issues = []
|
|
||||||
all_files = sorted(PACKS.rglob("*.json"))
|
|
||||||
print(f"Scanning {len(all_files)} files...", flush=True)
|
|
||||||
|
|
||||||
HTML_FIELDS = [
|
|
||||||
"system.description",
|
|
||||||
"system.effects",
|
|
||||||
"system.examples",
|
|
||||||
"system.components",
|
|
||||||
"system.notes",
|
|
||||||
"system.techniques.technique1.technique",
|
|
||||||
"system.techniques.technique2.technique",
|
|
||||||
"system.techniques.technique3.technique",
|
|
||||||
]
|
|
||||||
PLAIN_FIELDS = ["system.style"]
|
|
||||||
|
|
||||||
for jf in sorted(all_files):
|
|
||||||
rel = str(jf.relative_to(PACKS))
|
|
||||||
try:
|
|
||||||
data = json.loads(jf.read_text(encoding="utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
issues.append({"file": rel, "field": "(file)", "issue": "json_error",
|
|
||||||
"item_name": "?", "current_text": str(e)})
|
|
||||||
continue
|
|
||||||
|
|
||||||
name = data.get("name", "?")
|
|
||||||
|
|
||||||
def add_issue(field, issue_type, **kwargs):
|
|
||||||
issues.append({"file": rel, "field": field, "issue": issue_type,
|
|
||||||
"item_name": name, **kwargs})
|
|
||||||
|
|
||||||
for field in HTML_FIELDS + PLAIN_FIELDS:
|
|
||||||
val = get_field(data, field)
|
|
||||||
if not val or not isinstance(val, str) or not val.strip():
|
|
||||||
continue
|
|
||||||
plain = strip_html(val).strip()
|
|
||||||
|
|
||||||
if has_watermark_bleed(val):
|
|
||||||
pdf_ctx = pdf_search(val)
|
|
||||||
add_issue(field, "bleeding_watermark",
|
|
||||||
current_text=val[:400],
|
|
||||||
plain_text=plain[:300],
|
|
||||||
pdf_context=pdf_ctx)
|
|
||||||
|
|
||||||
elif looks_truncated(val):
|
|
||||||
is_ingredient = 'cde-ingredients' in rel
|
|
||||||
if is_ingredient and len(plain) < 30:
|
|
||||||
add_issue(field, "truncated_or_short",
|
|
||||||
current_text=plain,
|
|
||||||
note="May be legitimate (ingredient quantity)",
|
|
||||||
pdf_context=pdf_search(plain))
|
|
||||||
else:
|
|
||||||
pdf_ctx = pdf_search(val)
|
|
||||||
add_issue(field, "truncated",
|
|
||||||
current_end=plain[-120:],
|
|
||||||
current_preview=plain[:200],
|
|
||||||
pdf_context=pdf_ctx)
|
|
||||||
|
|
||||||
if has_bad_newlines(val):
|
|
||||||
add_issue(field, "unwanted_newlines",
|
|
||||||
current_text=val[:400],
|
|
||||||
plain_text=plain[:300])
|
|
||||||
|
|
||||||
for tkey in ['technique1', 'technique2', 'technique3']:
|
|
||||||
tech = get_field(data, f"system.techniques.{tkey}")
|
|
||||||
if not tech:
|
|
||||||
continue
|
|
||||||
t_text = tech.get("technique", "")
|
|
||||||
if not t_text:
|
|
||||||
continue
|
|
||||||
plain_t = strip_html(t_text)
|
|
||||||
activation_count = plain_t.count("Activation :")
|
|
||||||
if activation_count > 1:
|
|
||||||
add_issue(f"system.techniques.{tkey}.technique",
|
|
||||||
"bleeding_multiple_techniques",
|
|
||||||
activation_count=activation_count,
|
|
||||||
current_text=t_text[:500],
|
|
||||||
note=f"{activation_count} 'Activation :' markers found")
|
|
||||||
if ("Style" in plain_t or "Orientation :" in plain_t) and len(plain_t) > 300:
|
|
||||||
add_issue(f"system.techniques.{tkey}.technique",
|
|
||||||
"bleeding_style_or_orientation",
|
|
||||||
current_text=t_text[:500],
|
|
||||||
note="Contains 'Style' or 'Orientation' markers inside technique text")
|
|
||||||
|
|
||||||
print(f"Found {len(issues)} issues.", flush=True)
|
|
||||||
|
|
||||||
out_json = BASE / "compendium-issues.json"
|
|
||||||
out_txt = BASE / "compendium-issues.txt"
|
|
||||||
|
|
||||||
with open(out_json, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(issues, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
by_type = defaultdict(list)
|
|
||||||
by_file = defaultdict(list)
|
|
||||||
for iss in issues:
|
|
||||||
by_type[iss['issue']].append(iss)
|
|
||||||
by_file[iss['file']].append(iss)
|
|
||||||
|
|
||||||
with open(out_txt, 'w', encoding='utf-8') as f:
|
|
||||||
w = f.write
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
w("COMPENDIUM TEXT QUALITY REPORT\n")
|
|
||||||
w("Les Chroniques de l'Etrange — FoundryVTT\n")
|
|
||||||
w("=" * 80 + "\n\n")
|
|
||||||
w(f"Files scanned: {len(all_files)}\n")
|
|
||||||
w(f"Files with issues: {len(by_file)}\n")
|
|
||||||
w(f"Total issues: {len(issues)}\n\n")
|
|
||||||
w("SUMMARY BY ISSUE TYPE:\n")
|
|
||||||
for itype, ilist in sorted(by_type.items(), key=lambda x: -len(x[1])):
|
|
||||||
w(f" {itype:50s} {len(ilist):3d}\n")
|
|
||||||
w("\nFILES WITH ISSUES:\n")
|
|
||||||
for fpath in sorted(by_file.keys()):
|
|
||||||
types = sorted(set(i['issue'] for i in by_file[fpath]))
|
|
||||||
w(f" {fpath} [{', '.join(types)}]\n")
|
|
||||||
w("\n")
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
w("DETAILED ISSUES\n")
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
for itype in ['bleeding_watermark', 'bleeding_multiple_techniques',
|
|
||||||
'bleeding_style_or_orientation', 'truncated',
|
|
||||||
'unwanted_newlines', 'truncated_or_short']:
|
|
||||||
ilist = by_type.get(itype, [])
|
|
||||||
if not ilist:
|
|
||||||
continue
|
|
||||||
w(f"\n{'─'*80}\n")
|
|
||||||
w(f"ISSUE TYPE: {itype} ({len(ilist)} occurrences)\n")
|
|
||||||
w(f"{'─'*80}\n")
|
|
||||||
for iss in ilist:
|
|
||||||
w(f"\n File: {iss['file']}\n")
|
|
||||||
w(f" Item: {iss.get('item_name','?')}\n")
|
|
||||||
w(f" Field: {iss['field']}\n")
|
|
||||||
if iss.get('note'):
|
|
||||||
w(f" Note: {iss['note']}\n")
|
|
||||||
if iss.get('current_end'):
|
|
||||||
w(f" Ends: ...{iss['current_end']}\n")
|
|
||||||
if iss.get('current_preview'):
|
|
||||||
w(f" Text: {iss['current_preview'][:200]}\n")
|
|
||||||
if iss.get('current_text'):
|
|
||||||
ct = iss['current_text']
|
|
||||||
w(f" Text: {ct[:300]}\n")
|
|
||||||
if iss.get('pdf_context'):
|
|
||||||
w(f" PDF>>: {iss['pdf_context'][:400]}\n")
|
|
||||||
|
|
||||||
print(f"Written: {out_json}\n {out_txt}", flush=True)
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Final comprehensive analysis including missing beginnings and garbled content."""
|
|
||||||
|
|
||||||
import json, re
|
|
||||||
from pathlib import Path
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
BASE = Path("/home/morr/work/uberwald/fvtt-chroniques-de-l-etrange")
|
|
||||||
PACKS = BASE / "packs-src"
|
|
||||||
pdf_text = (BASE / "regles.txt").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
WATERMARK_RE = re.compile(
|
|
||||||
r's\s*c\s*r\s*a\s*l\s*e\s*l|les\s+chroniqu|de\s+l.etrange|chr.niqu|hr\s+ng',
|
|
||||||
re.IGNORECASE)
|
|
||||||
|
|
||||||
def strip_html(html):
|
|
||||||
return re.sub(r'<[^>]+>', '', html or '').strip()
|
|
||||||
|
|
||||||
def has_watermark_bleed(text):
|
|
||||||
plain = strip_html(text)
|
|
||||||
return bool(WATERMARK_RE.search(plain))
|
|
||||||
|
|
||||||
def has_bad_newlines(text):
|
|
||||||
lines = text.split('\n')
|
|
||||||
if len(lines) <= 1:
|
|
||||||
return False
|
|
||||||
for line in lines:
|
|
||||||
s = line.strip()
|
|
||||||
if s and not re.match(r'^<[/a-zA-Z]', s) and not s.endswith('>') and len(s) > 3:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def looks_truncated(text):
|
|
||||||
"""Text appears cut off at the end."""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
plain = strip_html(text).strip()
|
|
||||||
plain_clean = re.sub(r'\s+[a-z]\s+[a-z]\s+[a-z]{1,2}\s+.*$', '', plain).strip()
|
|
||||||
if not plain_clean:
|
|
||||||
plain_clean = plain
|
|
||||||
last = plain_clean[-1] if plain_clean else ''
|
|
||||||
return last.isalpha() or last in ',;:-('
|
|
||||||
|
|
||||||
def looks_missing_beginning(text):
|
|
||||||
"""Text starts mid-sentence (lowercase, or starts with punctuation)."""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
plain = strip_html(text).strip()
|
|
||||||
if not plain:
|
|
||||||
return False
|
|
||||||
first_char = plain[0]
|
|
||||||
# Starts with lowercase letter (unlikely to be intentional)
|
|
||||||
if first_char.islower():
|
|
||||||
return True
|
|
||||||
# Starts with a bullet/list item that makes no sense
|
|
||||||
if re.match(r'^(et|ou|de|du|des|les|la|le|un|une|à|au|aux|mais|car|si|que)\s', plain, re.IGNORECASE):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_garbled_page_layout(text):
|
|
||||||
"""Detects when text is broken into single-letter paragraphs (PDF artifact)."""
|
|
||||||
# Pattern: multiple single-letter <p> tags = garbled page layout
|
|
||||||
single_p = re.findall(r'<p>([a-zA-Z0-9])</p>', text)
|
|
||||||
if len(single_p) >= 5:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_field(data, path):
|
|
||||||
parts = path.split('.')
|
|
||||||
cur = data
|
|
||||||
for p in parts:
|
|
||||||
if isinstance(cur, dict):
|
|
||||||
cur = cur.get(p)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if cur is None:
|
|
||||||
return None
|
|
||||||
return cur
|
|
||||||
|
|
||||||
def pdf_search(keyword_text, context=500):
|
|
||||||
"""Search PDF text after the given keyword."""
|
|
||||||
plain = strip_html(keyword_text)
|
|
||||||
plain = re.sub(r'\s+[a-z]\s+[a-z]\s+[a-z]{1,2}\s+.*$', '', plain).strip()
|
|
||||||
if len(plain) < 15:
|
|
||||||
return None
|
|
||||||
for suffix_len in [40, 30, 20, 15]:
|
|
||||||
suffix = re.sub(r'\s+', ' ', plain[-suffix_len:]).strip()
|
|
||||||
if len(suffix) < 10:
|
|
||||||
continue
|
|
||||||
idx = pdf_text.find(suffix)
|
|
||||||
if idx != -1:
|
|
||||||
snippet = pdf_text[idx:min(len(pdf_text), idx + len(suffix) + context)]
|
|
||||||
snippet = re.sub(r'\n+', ' ', snippet)
|
|
||||||
snippet = re.sub(r'\s{3,}', ' ', snippet)
|
|
||||||
return snippet[:600]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def pdf_search_forward(keyword_text, context=500):
|
|
||||||
"""Search PDF text BEFORE the start of the given text (find what precedes it)."""
|
|
||||||
plain = strip_html(keyword_text)
|
|
||||||
plain = re.sub(r'\s+', ' ', plain[:60]).strip()
|
|
||||||
if len(plain) < 15:
|
|
||||||
return None
|
|
||||||
# Search for the prefix
|
|
||||||
for prefix_len in [50, 40, 30, 20]:
|
|
||||||
prefix = re.sub(r'\s+', ' ', plain[:prefix_len]).strip()
|
|
||||||
if len(prefix) < 10:
|
|
||||||
continue
|
|
||||||
idx = pdf_text.find(prefix)
|
|
||||||
if idx != -1:
|
|
||||||
# Get text before this position
|
|
||||||
start = max(0, idx - context)
|
|
||||||
snippet = pdf_text[start:idx + len(prefix)]
|
|
||||||
snippet = re.sub(r'\n+', ' ', snippet)
|
|
||||||
snippet = re.sub(r'\s{3,}', ' ', snippet)
|
|
||||||
return snippet[-400:]
|
|
||||||
return None
|
|
||||||
|
|
||||||
issues = []
|
|
||||||
all_files = sorted(PACKS.rglob("*.json"))
|
|
||||||
print(f"Scanning {len(all_files)} files...", flush=True)
|
|
||||||
|
|
||||||
HTML_FIELDS = [
|
|
||||||
"system.description",
|
|
||||||
"system.effects",
|
|
||||||
"system.examples",
|
|
||||||
"system.components",
|
|
||||||
"system.notes",
|
|
||||||
"system.techniques.technique1.technique",
|
|
||||||
"system.techniques.technique2.technique",
|
|
||||||
"system.techniques.technique3.technique",
|
|
||||||
]
|
|
||||||
PLAIN_FIELDS = ["system.style"]
|
|
||||||
|
|
||||||
for jf in sorted(all_files):
|
|
||||||
rel = str(jf.relative_to(PACKS))
|
|
||||||
try:
|
|
||||||
data = json.loads(jf.read_text(encoding="utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
issues.append({"file": rel, "field": "(file)", "issue": "json_error",
|
|
||||||
"item_name": "?", "current_text": str(e)})
|
|
||||||
continue
|
|
||||||
|
|
||||||
name = data.get("name", "?")
|
|
||||||
|
|
||||||
def add_issue(field, issue_type, **kwargs):
|
|
||||||
issues.append({"file": rel, "field": field, "issue": issue_type,
|
|
||||||
"item_name": name, **kwargs})
|
|
||||||
|
|
||||||
for field in HTML_FIELDS + PLAIN_FIELDS:
|
|
||||||
val = get_field(data, field)
|
|
||||||
if not val or not isinstance(val, str) or not val.strip():
|
|
||||||
continue
|
|
||||||
plain = strip_html(val).strip()
|
|
||||||
|
|
||||||
# --- Garbled page layout ---
|
|
||||||
if is_garbled_page_layout(val):
|
|
||||||
add_issue(field, "garbled_page_layout",
|
|
||||||
current_text=val[:400],
|
|
||||||
note="Text broken into single-character <p> tags — PDF layout artifact")
|
|
||||||
continue # other checks not useful
|
|
||||||
|
|
||||||
# --- Watermark bleeding ---
|
|
||||||
if has_watermark_bleed(val):
|
|
||||||
pdf_ctx = pdf_search(val)
|
|
||||||
add_issue(field, "bleeding_watermark",
|
|
||||||
current_text=val[:400],
|
|
||||||
plain_text=plain[:300],
|
|
||||||
pdf_context=pdf_ctx)
|
|
||||||
|
|
||||||
# --- Missing beginning ---
|
|
||||||
if looks_missing_beginning(val):
|
|
||||||
pdf_ctx = pdf_search_forward(val)
|
|
||||||
add_issue(field, "missing_beginning",
|
|
||||||
current_start=plain[:150],
|
|
||||||
pdf_context_before=pdf_ctx)
|
|
||||||
|
|
||||||
# --- Truncation ---
|
|
||||||
if looks_truncated(val):
|
|
||||||
is_ingredient = 'cde-ingredients' in rel
|
|
||||||
if is_ingredient and len(plain) < 30:
|
|
||||||
add_issue(field, "truncated_or_short",
|
|
||||||
current_text=plain,
|
|
||||||
note="May be legitimate (ingredient quantity/name)")
|
|
||||||
else:
|
|
||||||
pdf_ctx = pdf_search(val)
|
|
||||||
add_issue(field, "truncated",
|
|
||||||
current_end=plain[-120:],
|
|
||||||
current_preview=plain[:200],
|
|
||||||
pdf_context=pdf_ctx)
|
|
||||||
|
|
||||||
# --- Unwanted newlines ---
|
|
||||||
if has_bad_newlines(val):
|
|
||||||
add_issue(field, "unwanted_newlines",
|
|
||||||
current_text=val[:400],
|
|
||||||
plain_text=plain[:300])
|
|
||||||
|
|
||||||
# --- Technique-level checks ---
|
|
||||||
for tkey in ['technique1', 'technique2', 'technique3']:
|
|
||||||
tech = get_field(data, f"system.techniques.{tkey}")
|
|
||||||
if not tech:
|
|
||||||
continue
|
|
||||||
t_text = tech.get("technique", "")
|
|
||||||
if not t_text:
|
|
||||||
continue
|
|
||||||
plain_t = strip_html(t_text)
|
|
||||||
activation_count = plain_t.count("Activation :")
|
|
||||||
if activation_count > 1:
|
|
||||||
add_issue(f"system.techniques.{tkey}.technique",
|
|
||||||
"bleeding_multiple_techniques",
|
|
||||||
activation_count=activation_count,
|
|
||||||
current_text=t_text[:500],
|
|
||||||
note=f"{activation_count} 'Activation :' markers — multiple techniques merged")
|
|
||||||
if ("Style" in plain_t or "Orientation :" in plain_t) and len(plain_t) > 300:
|
|
||||||
add_issue(f"system.techniques.{tkey}.technique",
|
|
||||||
"bleeding_style_or_orientation",
|
|
||||||
current_text=t_text[:500],
|
|
||||||
note="Contains 'Style' or 'Orientation' markers — extra text from page layout")
|
|
||||||
|
|
||||||
print(f"Found {len(issues)} issues.", flush=True)
|
|
||||||
|
|
||||||
# Deduplicate (same file+field+issue_type)
|
|
||||||
seen = set()
|
|
||||||
deduped = []
|
|
||||||
for iss in issues:
|
|
||||||
key = (iss['file'], iss['field'], iss['issue'])
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
deduped.append(iss)
|
|
||||||
issues = deduped
|
|
||||||
print(f"After dedup: {len(issues)} issues.", flush=True)
|
|
||||||
|
|
||||||
out_json = BASE / "compendium-issues.json"
|
|
||||||
out_txt = BASE / "compendium-issues.txt"
|
|
||||||
|
|
||||||
with open(out_json, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(issues, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
by_type = defaultdict(list)
|
|
||||||
by_file = defaultdict(list)
|
|
||||||
for iss in issues:
|
|
||||||
by_type[iss['issue']].append(iss)
|
|
||||||
by_file[iss['file']].append(iss)
|
|
||||||
|
|
||||||
PRIORITY_ORDER = [
|
|
||||||
'garbled_page_layout',
|
|
||||||
'missing_beginning',
|
|
||||||
'bleeding_watermark',
|
|
||||||
'bleeding_multiple_techniques',
|
|
||||||
'bleeding_style_or_orientation',
|
|
||||||
'truncated',
|
|
||||||
'unwanted_newlines',
|
|
||||||
'truncated_or_short',
|
|
||||||
]
|
|
||||||
|
|
||||||
with open(out_txt, 'w', encoding='utf-8') as f:
|
|
||||||
w = f.write
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
w("COMPENDIUM TEXT QUALITY REPORT\n")
|
|
||||||
w("Les Chroniques de l'Etrange — FoundryVTT\n")
|
|
||||||
w("=" * 80 + "\n\n")
|
|
||||||
w(f"Files scanned: {len(all_files)}\n")
|
|
||||||
w(f"Files with issues: {len(by_file)}\n")
|
|
||||||
w(f"Total issues: {len(issues)}\n\n")
|
|
||||||
|
|
||||||
w("SUMMARY BY ISSUE TYPE:\n")
|
|
||||||
for itype in PRIORITY_ORDER:
|
|
||||||
ilist = by_type.get(itype, [])
|
|
||||||
if ilist:
|
|
||||||
w(f" {itype:50s} {len(ilist):3d}\n")
|
|
||||||
other_types = set(by_type.keys()) - set(PRIORITY_ORDER)
|
|
||||||
for itype in sorted(other_types):
|
|
||||||
ilist = by_type.get(itype, [])
|
|
||||||
if ilist:
|
|
||||||
w(f" {itype:50s} {len(ilist):3d}\n")
|
|
||||||
w("\n")
|
|
||||||
|
|
||||||
w("FILES WITH ISSUES:\n")
|
|
||||||
for fpath in sorted(by_file.keys()):
|
|
||||||
types = sorted(set(i['issue'] for i in by_file[fpath]))
|
|
||||||
w(f" {fpath}\n [{', '.join(types)}]\n")
|
|
||||||
w("\n")
|
|
||||||
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
w("DETAILED ISSUES (by priority)\n")
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
|
|
||||||
for itype in PRIORITY_ORDER + sorted(set(by_type.keys()) - set(PRIORITY_ORDER)):
|
|
||||||
ilist = by_type.get(itype, [])
|
|
||||||
if not ilist:
|
|
||||||
continue
|
|
||||||
w(f"\n{'─'*80}\n")
|
|
||||||
w(f"ISSUE TYPE: {itype} ({len(ilist)} occurrences)\n")
|
|
||||||
w(f"{'─'*80}\n")
|
|
||||||
for iss in ilist:
|
|
||||||
w(f"\n File: {iss['file']}\n")
|
|
||||||
w(f" Item: {iss.get('item_name','?')}\n")
|
|
||||||
w(f" Field: {iss['field']}\n")
|
|
||||||
if iss.get('note'):
|
|
||||||
w(f" Note: {iss['note']}\n")
|
|
||||||
if iss.get('current_start'):
|
|
||||||
w(f" Starts: {iss['current_start'][:150]}\n")
|
|
||||||
if iss.get('current_end'):
|
|
||||||
w(f" Ends: ...{iss['current_end']}\n")
|
|
||||||
if iss.get('current_preview'):
|
|
||||||
w(f" Text: {iss['current_preview'][:200]}\n")
|
|
||||||
if iss.get('current_text'):
|
|
||||||
ct = iss['current_text']
|
|
||||||
w(f" Text: {ct[:300]}\n")
|
|
||||||
if iss.get('plain_text'):
|
|
||||||
w(f" Plain: {iss['plain_text'][:200]}\n")
|
|
||||||
if iss.get('pdf_context'):
|
|
||||||
w(f" PDF>>: {iss['pdf_context'][:400]}\n")
|
|
||||||
if iss.get('pdf_context_before'):
|
|
||||||
w(f" <<PDF: {iss['pdf_context_before'][:400]}\n")
|
|
||||||
|
|
||||||
print(f"Written: {out_json}\n {out_txt}", flush=True)
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Final comprehensive analysis — clean version with bug fixes."""
|
|
||||||
|
|
||||||
import json, re
|
|
||||||
from pathlib import Path
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
BASE = Path("/home/morr/work/uberwald/fvtt-chroniques-de-l-etrange")
|
|
||||||
PACKS = BASE / "packs-src"
|
|
||||||
pdf_text = (BASE / "regles.txt").read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
WATERMARK_RE = re.compile(
|
|
||||||
r's\s*c\s*r\s*a\s*l\s*e\s*l|les\s+chroniqu|de\s+l.etrange|chr.niqu|hr\s+ng',
|
|
||||||
re.IGNORECASE)
|
|
||||||
|
|
||||||
def strip_html(html):
|
|
||||||
return re.sub(r'<[^>]+>', '', html or '').strip()
|
|
||||||
|
|
||||||
def has_watermark_bleed(text):
|
|
||||||
plain = strip_html(text)
|
|
||||||
return bool(WATERMARK_RE.search(plain))
|
|
||||||
|
|
||||||
def has_bad_newlines(text):
|
|
||||||
lines = text.split('\n')
|
|
||||||
if len(lines) <= 1:
|
|
||||||
return False
|
|
||||||
for line in lines:
|
|
||||||
s = line.strip()
|
|
||||||
if s and not re.match(r'^<[/a-zA-Z]', s) and not s.endswith('>') and len(s) > 3:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def looks_truncated(text):
|
|
||||||
"""Text appears cut off at the end."""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
plain = strip_html(text).strip()
|
|
||||||
if not plain:
|
|
||||||
return False
|
|
||||||
# Remove watermark garbage from end before checking
|
|
||||||
plain_clean = re.sub(r'\s+[a-z]\s+[a-z]\s+[a-z]{1,2}\s+.*$', '', plain).strip()
|
|
||||||
if not plain_clean:
|
|
||||||
return False # FIX: was using original empty plain, now correctly returns False
|
|
||||||
last = plain_clean[-1]
|
|
||||||
return last.isalpha() or last in ',;:-('
|
|
||||||
|
|
||||||
def looks_missing_beginning(text):
|
|
||||||
"""Text starts mid-sentence (truly lowercase first char only)."""
|
|
||||||
if not text:
|
|
||||||
return False
|
|
||||||
plain = strip_html(text).strip()
|
|
||||||
if not plain:
|
|
||||||
return False
|
|
||||||
# Only flag if truly starts lowercase (not French articles/prepositions)
|
|
||||||
first_char = plain[0]
|
|
||||||
return first_char.islower()
|
|
||||||
|
|
||||||
def is_garbled_page_layout(text):
|
|
||||||
"""Multiple single-letter <p> tags = garbled PDF artifact."""
|
|
||||||
single_p = re.findall(r'<p>([a-zA-Z0-9])</p>', text)
|
|
||||||
return len(single_p) >= 5
|
|
||||||
|
|
||||||
def get_field(data, path):
|
|
||||||
parts = path.split('.')
|
|
||||||
cur = data
|
|
||||||
for p in parts:
|
|
||||||
if isinstance(cur, dict):
|
|
||||||
cur = cur.get(p)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
if cur is None:
|
|
||||||
return None
|
|
||||||
return cur
|
|
||||||
|
|
||||||
def pdf_search_after(keyword_text, context=500):
|
|
||||||
"""Return PDF text after the given keyword."""
|
|
||||||
plain = strip_html(keyword_text)
|
|
||||||
plain = re.sub(r'\s+[a-z]\s+[a-z]\s+[a-z]{1,2}\s+.*$', '', plain).strip()
|
|
||||||
if len(plain) < 15:
|
|
||||||
return None
|
|
||||||
for suffix_len in [40, 30, 20, 15]:
|
|
||||||
suffix = re.sub(r'\s+', ' ', plain[-suffix_len:]).strip()
|
|
||||||
if len(suffix) < 10:
|
|
||||||
continue
|
|
||||||
idx = pdf_text.find(suffix)
|
|
||||||
if idx != -1:
|
|
||||||
snippet = pdf_text[idx:min(len(pdf_text), idx + len(suffix) + context)]
|
|
||||||
snippet = re.sub(r'\n+', ' ', snippet)
|
|
||||||
snippet = re.sub(r'\s{3,}', ' ', snippet)
|
|
||||||
return snippet[:600]
|
|
||||||
return None
|
|
||||||
|
|
||||||
def pdf_search_before(keyword_text, context=400):
|
|
||||||
"""Return PDF text before the given keyword."""
|
|
||||||
plain = strip_html(keyword_text)
|
|
||||||
plain_start = re.sub(r'\s+', ' ', plain[:60]).strip()
|
|
||||||
if len(plain_start) < 15:
|
|
||||||
return None
|
|
||||||
for prefix_len in [50, 40, 30, 20]:
|
|
||||||
prefix = re.sub(r'\s+', ' ', plain_start[:prefix_len]).strip()
|
|
||||||
if len(prefix) < 10:
|
|
||||||
continue
|
|
||||||
idx = pdf_text.find(prefix)
|
|
||||||
if idx != -1:
|
|
||||||
start = max(0, idx - context)
|
|
||||||
snippet = pdf_text[start:idx + len(prefix)]
|
|
||||||
snippet = re.sub(r'\n+', ' ', snippet)
|
|
||||||
snippet = re.sub(r'\s{3,}', ' ', snippet)
|
|
||||||
return snippet[-400:]
|
|
||||||
return None
|
|
||||||
|
|
||||||
issues = []
|
|
||||||
all_files = sorted(PACKS.rglob("*.json"))
|
|
||||||
print(f"Scanning {len(all_files)} files...", flush=True)
|
|
||||||
|
|
||||||
HTML_FIELDS = [
|
|
||||||
"system.description",
|
|
||||||
"system.effects",
|
|
||||||
"system.examples",
|
|
||||||
"system.components",
|
|
||||||
"system.notes",
|
|
||||||
"system.techniques.technique1.technique",
|
|
||||||
"system.techniques.technique2.technique",
|
|
||||||
"system.techniques.technique3.technique",
|
|
||||||
]
|
|
||||||
PLAIN_FIELDS = ["system.style"]
|
|
||||||
|
|
||||||
for jf in sorted(all_files):
|
|
||||||
rel = str(jf.relative_to(PACKS))
|
|
||||||
try:
|
|
||||||
data = json.loads(jf.read_text(encoding="utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
issues.append({"file": rel, "field": "(file)", "issue": "json_error",
|
|
||||||
"item_name": "?", "current_text": str(e)})
|
|
||||||
continue
|
|
||||||
|
|
||||||
name = data.get("name", "?")
|
|
||||||
|
|
||||||
def add(field, issue_type, **kwargs):
|
|
||||||
issues.append({"file": rel, "field": field, "issue": issue_type,
|
|
||||||
"item_name": name, **kwargs})
|
|
||||||
|
|
||||||
for field in HTML_FIELDS + PLAIN_FIELDS:
|
|
||||||
val = get_field(data, field)
|
|
||||||
if not val or not isinstance(val, str) or not val.strip():
|
|
||||||
continue
|
|
||||||
plain = strip_html(val).strip()
|
|
||||||
|
|
||||||
# Garbled page layout (skip other checks)
|
|
||||||
if is_garbled_page_layout(val):
|
|
||||||
add(field, "garbled_page_layout",
|
|
||||||
current_text=val[:400],
|
|
||||||
note="Text broken into single-character <p> tags — PDF layout artifact")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Watermark bleeding
|
|
||||||
if has_watermark_bleed(val):
|
|
||||||
add(field, "bleeding_watermark",
|
|
||||||
current_text=val[:400],
|
|
||||||
plain_text=plain[:300],
|
|
||||||
pdf_context=pdf_search_after(val))
|
|
||||||
|
|
||||||
# Missing beginning (only truly lowercase-starting)
|
|
||||||
if looks_missing_beginning(val):
|
|
||||||
add(field, "missing_beginning",
|
|
||||||
current_start=plain[:150],
|
|
||||||
pdf_context_before=pdf_search_before(val))
|
|
||||||
|
|
||||||
# Truncation
|
|
||||||
if looks_truncated(val):
|
|
||||||
# Skip empty ingredient placeholders
|
|
||||||
is_ingredient = 'cde-ingredients' in rel
|
|
||||||
if is_ingredient:
|
|
||||||
# Only flag if there's actually short meaningful text
|
|
||||||
if plain and len(plain) < 30:
|
|
||||||
add(field, "empty_or_short_ingredient",
|
|
||||||
current_text=plain,
|
|
||||||
note="Short ingredient description — check if intentional")
|
|
||||||
else:
|
|
||||||
add(field, "truncated",
|
|
||||||
current_end=plain[-120:],
|
|
||||||
current_preview=plain[:200],
|
|
||||||
pdf_context=pdf_search_after(val))
|
|
||||||
|
|
||||||
# Unwanted newlines
|
|
||||||
if has_bad_newlines(val):
|
|
||||||
add(field, "unwanted_newlines",
|
|
||||||
current_text=val[:400],
|
|
||||||
plain_text=plain[:200])
|
|
||||||
|
|
||||||
# Technique cross-checks
|
|
||||||
for tkey in ['technique1', 'technique2', 'technique3']:
|
|
||||||
tech = get_field(data, f"system.techniques.{tkey}")
|
|
||||||
if not tech:
|
|
||||||
continue
|
|
||||||
t_text = tech.get("technique", "")
|
|
||||||
if not t_text:
|
|
||||||
continue
|
|
||||||
plain_t = strip_html(t_text)
|
|
||||||
activation_count = plain_t.count("Activation :")
|
|
||||||
if activation_count > 1:
|
|
||||||
add(f"system.techniques.{tkey}.technique",
|
|
||||||
"bleeding_multiple_techniques",
|
|
||||||
activation_count=activation_count,
|
|
||||||
current_text=t_text[:500],
|
|
||||||
note=f"{activation_count} 'Activation :' markers — multiple techniques merged")
|
|
||||||
if ("Style" in plain_t or "Orientation :" in plain_t) and len(plain_t) > 300:
|
|
||||||
already = any(i['file'] == rel and i['field'] == f"system.techniques.{tkey}.technique"
|
|
||||||
and i['issue'] == 'bleeding_style_or_orientation' for i in issues)
|
|
||||||
if not already:
|
|
||||||
add(f"system.techniques.{tkey}.technique",
|
|
||||||
"bleeding_style_or_orientation",
|
|
||||||
current_text=t_text[:500],
|
|
||||||
note="Contains 'Style' or 'Orientation' — extra text from PDF page layout")
|
|
||||||
|
|
||||||
print(f"Found {len(issues)} issues.", flush=True)
|
|
||||||
|
|
||||||
out_json = BASE / "compendium-issues.json"
|
|
||||||
out_txt = BASE / "compendium-issues.txt"
|
|
||||||
|
|
||||||
with open(out_json, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(issues, f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
by_type = defaultdict(list)
|
|
||||||
by_file = defaultdict(list)
|
|
||||||
for iss in issues:
|
|
||||||
by_type[iss['issue']].append(iss)
|
|
||||||
by_file[iss['file']].append(iss)
|
|
||||||
|
|
||||||
PRIORITY_ORDER = [
|
|
||||||
'garbled_page_layout',
|
|
||||||
'missing_beginning',
|
|
||||||
'bleeding_watermark',
|
|
||||||
'bleeding_multiple_techniques',
|
|
||||||
'bleeding_style_or_orientation',
|
|
||||||
'truncated',
|
|
||||||
'unwanted_newlines',
|
|
||||||
'empty_or_short_ingredient',
|
|
||||||
]
|
|
||||||
|
|
||||||
with open(out_txt, 'w', encoding='utf-8') as f:
|
|
||||||
w = f.write
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
w("COMPENDIUM TEXT QUALITY REPORT\n")
|
|
||||||
w("Les Chroniques de l'Etrange — FoundryVTT\n")
|
|
||||||
w("=" * 80 + "\n\n")
|
|
||||||
w(f"Files scanned: {len(all_files)}\n")
|
|
||||||
w(f"Files with issues: {len(by_file)}\n")
|
|
||||||
w(f"Total issues: {len(issues)}\n\n")
|
|
||||||
|
|
||||||
w("SUMMARY BY ISSUE TYPE:\n")
|
|
||||||
for itype in PRIORITY_ORDER:
|
|
||||||
ilist = by_type.get(itype, [])
|
|
||||||
if ilist:
|
|
||||||
desc = {
|
|
||||||
'garbled_page_layout': 'text broken into single-char HTML tags (PDF artifact)',
|
|
||||||
'missing_beginning': 'field starts mid-word (lowercase start = truncated at front)',
|
|
||||||
'bleeding_watermark': '"Les Chroniques de l\'Étrange" watermark fragments in text',
|
|
||||||
'bleeding_multiple_techniques': 'multiple techniques merged into one field',
|
|
||||||
'bleeding_style_or_orientation': 'Style/Orientation text bled into technique field',
|
|
||||||
'truncated': 'field ends mid-sentence without proper punctuation',
|
|
||||||
'unwanted_newlines': 'raw newlines inside HTML string values',
|
|
||||||
'empty_or_short_ingredient': 'ingredient has empty or very short description',
|
|
||||||
}.get(itype, '')
|
|
||||||
w(f" {itype:45s} {len(ilist):3d} — {desc}\n")
|
|
||||||
|
|
||||||
w("\nFILES WITH ISSUES:\n")
|
|
||||||
for fpath in sorted(by_file.keys()):
|
|
||||||
types = sorted(set(i['issue'] for i in by_file[fpath]))
|
|
||||||
w(f" {fpath}\n [{', '.join(types)}]\n")
|
|
||||||
|
|
||||||
w("\n")
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
w("DETAILED ISSUES (by priority)\n")
|
|
||||||
w("=" * 80 + "\n")
|
|
||||||
|
|
||||||
for itype in PRIORITY_ORDER:
|
|
||||||
ilist = by_type.get(itype, [])
|
|
||||||
if not ilist:
|
|
||||||
continue
|
|
||||||
w(f"\n{'─'*80}\n")
|
|
||||||
w(f"ISSUE TYPE: {itype} ({len(ilist)} occurrences)\n")
|
|
||||||
w(f"{'─'*80}\n")
|
|
||||||
for iss in ilist:
|
|
||||||
w(f"\n File: {iss['file']}\n")
|
|
||||||
w(f" Item: {iss.get('item_name','?')}\n")
|
|
||||||
w(f" Field: {iss['field']}\n")
|
|
||||||
if iss.get('note'):
|
|
||||||
w(f" Note: {iss['note']}\n")
|
|
||||||
if iss.get('current_start'):
|
|
||||||
w(f" Starts: {iss['current_start'][:160]}\n")
|
|
||||||
if iss.get('current_end'):
|
|
||||||
w(f" Ends: ...{iss['current_end']}\n")
|
|
||||||
if iss.get('current_preview'):
|
|
||||||
w(f" Text: {iss['current_preview'][:200]}\n")
|
|
||||||
if iss.get('current_text'):
|
|
||||||
ct = iss['current_text']
|
|
||||||
w(f" Text: {ct[:300]}\n")
|
|
||||||
if iss.get('plain_text'):
|
|
||||||
w(f" Plain: {iss['plain_text'][:200]}\n")
|
|
||||||
if iss.get('pdf_context'):
|
|
||||||
w(f" PDF>>: {iss['pdf_context'][:400]}\n")
|
|
||||||
if iss.get('pdf_context_before'):
|
|
||||||
w(f" <<PDF: {iss['pdf_context_before'][:400]}\n")
|
|
||||||
|
|
||||||
print(f"Written: {out_json}\n {out_txt}", flush=True)
|
|
||||||
@@ -4188,3 +4188,271 @@ ol.item-list li.item .item-controls a.item-control:hover {
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* ===================================================================
|
||||||
|
Migration App
|
||||||
|
=================================================================== */
|
||||||
|
.cde-migration-app .window-content {
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.cde-migration-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
/* Drop zone */
|
||||||
|
.cde-migration-drop-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 28px 20px;
|
||||||
|
border: 2px dashed #1a2436;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(13, 21, 32, 0.6);
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cde-migration-drop-zone.is-dragover {
|
||||||
|
border-color: #4a9eff;
|
||||||
|
background: rgba(74, 158, 255, 0.15);
|
||||||
|
}
|
||||||
|
.cde-migration-drop-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: #4a9eff;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.cde-migration-drop-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7d94b8;
|
||||||
|
}
|
||||||
|
.cde-migration-file-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #4a9eff;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #4a9eff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.cde-migration-file-btn:hover {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
}
|
||||||
|
/* Preview section */
|
||||||
|
.cde-migration-preview {
|
||||||
|
border: 1px solid #1a2436;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cde-migration-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(13, 21, 32, 0.8);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #7d94b8;
|
||||||
|
}
|
||||||
|
.cde-migration-clear-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #1a2436;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #7d94b8;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.cde-migration-clear-btn:hover {
|
||||||
|
color: #e04444;
|
||||||
|
border-color: #e04444;
|
||||||
|
}
|
||||||
|
/* Preview table */
|
||||||
|
.cde-migration-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.cde-migration-table th {
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: rgba(13, 21, 32, 0.9);
|
||||||
|
color: #7d94b8;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #1a2436;
|
||||||
|
}
|
||||||
|
.cde-migration-table td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid rgba(26, 36, 54, 0.4);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.cde-migration-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.cde-migration-thumb {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 3px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.cde-migration-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f4;
|
||||||
|
}
|
||||||
|
.cde-migration-items-count {
|
||||||
|
text-align: center;
|
||||||
|
color: #7d94b8;
|
||||||
|
}
|
||||||
|
.cde-migration-srcfile {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #7d94b8;
|
||||||
|
max-width: 130px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Type badge */
|
||||||
|
.cde-migration-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cde-migration-type-badge.cde-migration-type-character {
|
||||||
|
background: rgba(74, 158, 255, 0.2);
|
||||||
|
color: #4a9eff;
|
||||||
|
border: 1px solid rgba(74, 158, 255, 0.4);
|
||||||
|
}
|
||||||
|
.cde-migration-type-badge.cde-migration-type-npc {
|
||||||
|
background: rgba(156, 77, 204, 0.2);
|
||||||
|
color: #c97ae0;
|
||||||
|
border: 1px solid rgba(156, 77, 204, 0.4);
|
||||||
|
}
|
||||||
|
/* Errors */
|
||||||
|
.cde-migration-errors {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.cde-migration-errors li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid rgba(224, 68, 68, 0.6);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(224, 68, 68, 0.1);
|
||||||
|
color: #e07070;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.cde-migration-errors li i {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
/* Bottom action bar */
|
||||||
|
.cde-migration-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.cde-migration-import-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #4a9eff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
.cde-migration-import-btn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
.cde-migration-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7d94b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cde-welcome-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #101622;
|
||||||
|
border: 1px solid #263853;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cde-welcome-logo {
|
||||||
|
width: 120px;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 0 8px rgba(74, 158, 255, 0.4));
|
||||||
|
}
|
||||||
|
.cde-welcome-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4a9eff;
|
||||||
|
text-shadow: 0 0 8px rgba(74, 158, 255, 0.5);
|
||||||
|
}
|
||||||
|
.cde-welcome-links {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7d94b8;
|
||||||
|
}
|
||||||
|
.cde-welcome-links a {
|
||||||
|
color: #00d4d4;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid rgba(0, 212, 212, 0.4);
|
||||||
|
}
|
||||||
|
.cde-welcome-links a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.cde-welcome-help-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 7px 18px;
|
||||||
|
background: #4a9eff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
}
|
||||||
|
.cde-welcome-help-btn:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4344,3 +4344,313 @@ ol.item-list {
|
|||||||
from { transform-origin: var(--fx) var(--fy); transform: rotate(0deg); }
|
from { transform-origin: var(--fx) var(--fy); transform: rotate(0deg); }
|
||||||
to { transform-origin: var(--fx) var(--fy); transform: rotate(360deg); }
|
to { transform-origin: var(--fx) var(--fy); transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Migration App
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
.cde-migration-app {
|
||||||
|
.window-content {
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop zone */
|
||||||
|
.cde-migration-drop-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 28px 20px;
|
||||||
|
border: 2px dashed @cde-border;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: fadeout(@cde-surface2, 40%);
|
||||||
|
text-align: center;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.is-dragover {
|
||||||
|
border-color: @cde-spell;
|
||||||
|
background: fadeout(@cde-spell, 85%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-drop-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
color: @cde-spell;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-drop-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: @cde-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-file-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid @cde-spell;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: @cde-spell;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: fadeout(@cde-spell, 80%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview section */
|
||||||
|
.cde-migration-preview {
|
||||||
|
border: 1px solid @cde-border;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: fadeout(@cde-surface2, 20%);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: @cde-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-clear-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid @cde-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: @cde-muted;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #e04444;
|
||||||
|
border-color: #e04444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview table */
|
||||||
|
.cde-migration-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
th {
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: fadeout(@cde-surface2, 10%);
|
||||||
|
color: @cde-muted;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid @cde-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-bottom: 1px solid fadeout(@cde-border, 60%);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-thumb {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 3px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: @cde-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-items-count {
|
||||||
|
text-align: center;
|
||||||
|
color: @cde-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-srcfile {
|
||||||
|
font-size: 10px;
|
||||||
|
color: @cde-muted;
|
||||||
|
max-width: 130px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type badge */
|
||||||
|
.cde-migration-type-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&.cde-migration-type-character {
|
||||||
|
background: fadeout(@cde-spell, 80%);
|
||||||
|
color: @cde-spell;
|
||||||
|
border: 1px solid fadeout(@cde-spell, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.cde-migration-type-npc {
|
||||||
|
background: fadeout(#9c4dcc, 80%);
|
||||||
|
color: #c97ae0;
|
||||||
|
border: 1px solid fadeout(#9c4dcc, 60%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Errors */
|
||||||
|
.cde-migration-errors {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid fadeout(#e04444, 40%);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: fadeout(#e04444, 90%);
|
||||||
|
color: #e07070;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
i { margin-top: 2px; flex-shrink: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom action bar */
|
||||||
|
.cde-migration-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-import-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: @cde-spell;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-migration-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: @cde-muted;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Welcome message
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
.cde-welcome-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: @cde-surface;
|
||||||
|
border: 1px solid @cde-border-hi;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-welcome-logo {
|
||||||
|
width: 120px;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 0 8px fade(@cde-spell, 40%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-welcome-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: @cde-spell;
|
||||||
|
text-shadow: 0 0 8px fade(@cde-spell, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-welcome-links {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: @cde-muted;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: @cde-item;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid fade(@cde-item, 40%);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cde-welcome-help-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 7px 18px;
|
||||||
|
background: @cde-spell;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,8 +134,456 @@ var TEMPLATE_PARTIALS = [
|
|||||||
"systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-wheel-app.html"
|
"systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-wheel-app.html"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// src/migration/migrator.js
|
||||||
|
var ELEMENT_LABEL_TO_KEY = {
|
||||||
|
"m\xE9tal": "metal",
|
||||||
|
"metal": "metal",
|
||||||
|
"eau": "eau",
|
||||||
|
"terre": "terre",
|
||||||
|
"feu": "feu",
|
||||||
|
"bois": "bois"
|
||||||
|
};
|
||||||
|
function elementKey(label = "") {
|
||||||
|
return ELEMENT_LABEL_TO_KEY[label.toLowerCase().trim()] ?? "metal";
|
||||||
|
}
|
||||||
|
function heiKey(label = "") {
|
||||||
|
const l = label.toLowerCase().trim();
|
||||||
|
if (l === "yin/yang" || l === "yinyang") return "yinyang";
|
||||||
|
if (l === "yang") return "yang";
|
||||||
|
return "yin";
|
||||||
|
}
|
||||||
|
var SPECIALITY_TO_DISCIPLINE = {
|
||||||
|
// internalcinnabar
|
||||||
|
"essence": "internalcinnabar",
|
||||||
|
"esprit": "internalcinnabar",
|
||||||
|
"mind": "internalcinnabar",
|
||||||
|
"purification": "internalcinnabar",
|
||||||
|
"manipulation": "internalcinnabar",
|
||||||
|
"aura": "internalcinnabar",
|
||||||
|
// alchemy
|
||||||
|
"acupuncture": "alchemy",
|
||||||
|
"\xE9lixirs": "alchemy",
|
||||||
|
"elixirs": "alchemy",
|
||||||
|
"poisons": "alchemy",
|
||||||
|
"arsenal": "alchemy",
|
||||||
|
"potions": "alchemy",
|
||||||
|
// masteryoftheway
|
||||||
|
"mal\xE9diction": "masteryoftheway",
|
||||||
|
"malediction": "masteryoftheway",
|
||||||
|
"transfiguration": "masteryoftheway",
|
||||||
|
"n\xE9cromancie": "masteryoftheway",
|
||||||
|
"necromancie": "masteryoftheway",
|
||||||
|
"contr\xF4le climatique": "masteryoftheway",
|
||||||
|
"controle climatique": "masteryoftheway",
|
||||||
|
"magie d'or": "masteryoftheway",
|
||||||
|
"magie dor": "masteryoftheway",
|
||||||
|
// exorcism
|
||||||
|
"invocation": "exorcism",
|
||||||
|
"pistage": "exorcism",
|
||||||
|
"tra\xE7age": "exorcism",
|
||||||
|
"tracage": "exorcism",
|
||||||
|
"protection": "exorcism",
|
||||||
|
"ch\xE2timent": "exorcism",
|
||||||
|
"chatiment": "exorcism",
|
||||||
|
"domination": "exorcism",
|
||||||
|
// geomancy
|
||||||
|
"neutralisation": "geomancy",
|
||||||
|
"divination": "geomancy",
|
||||||
|
"pri\xE8re terrestre": "geomancy",
|
||||||
|
"priere terrestre": "geomancy",
|
||||||
|
"pri\xE8re c\xE9leste": "geomancy",
|
||||||
|
"priere celeste": "geomancy",
|
||||||
|
"g\xE9omancie": "geomancy",
|
||||||
|
"geomancie": "geomancy",
|
||||||
|
"feng shui": "geomancy",
|
||||||
|
"fungseoi": "geomancy"
|
||||||
|
};
|
||||||
|
function inferDiscipline(specialityName = "", itemName = "") {
|
||||||
|
const key = specialityName.toLowerCase().trim();
|
||||||
|
if (SPECIALITY_TO_DISCIPLINE[key]) return SPECIALITY_TO_DISCIPLINE[key];
|
||||||
|
const name = itemName.toLowerCase();
|
||||||
|
if (name.includes("exorcis")) return "exorcism";
|
||||||
|
if (name.includes("g\xE9omanci") || name.includes("geomanci")) return "geomancy";
|
||||||
|
if (name.includes("alchimi")) return "alchemy";
|
||||||
|
if (name.includes("cinnabre") || name.includes("interne")) return "internalcinnabar";
|
||||||
|
if (name.includes("ma\xEEtrise") || name.includes("maitrise") || name.includes("tao")) return "masteryoftheway";
|
||||||
|
return "internalcinnabar";
|
||||||
|
}
|
||||||
|
function mapActivation(oldActivation = "") {
|
||||||
|
const s = oldActivation.toLowerCase();
|
||||||
|
if (s.includes("inflig\xE9s") || s.includes("infliges")) return "damage-inflicted";
|
||||||
|
if (s.includes("re\xE7us") || s.includes("recus")) return "damage-received";
|
||||||
|
if (s.includes("r\xE9action") || s.includes("reaction")) return "reaction";
|
||||||
|
if (s.includes("d\xE9s-fastes") || s.includes("des-fastes") || s.includes("fastes")) return "dice";
|
||||||
|
if (s.includes("aide")) return "action-aid";
|
||||||
|
if (s.includes("attaque") && s.includes("d\xE9fense")) return "action-attack-defense";
|
||||||
|
if (s.includes("attaque") && s.includes("defense")) return "action-attack-defense";
|
||||||
|
if (s.includes("attaque")) return "action-attack";
|
||||||
|
if (s.includes("d\xE9fense") || s.includes("defense")) return "action-defense";
|
||||||
|
return "action-attack";
|
||||||
|
}
|
||||||
|
var DEFAULT_ACTOR_IMG = "icons/svg/mystery-man.svg";
|
||||||
|
var DEFAULT_ITEM_IMG = "icons/svg/item-bag.svg";
|
||||||
|
function migrateEquipmentItem(oldItem) {
|
||||||
|
const s = oldItem.system ?? {};
|
||||||
|
return {
|
||||||
|
name: oldItem.name,
|
||||||
|
type: "item",
|
||||||
|
img: oldItem.img || DEFAULT_ITEM_IMG,
|
||||||
|
system: {
|
||||||
|
reference: s.reference ?? "",
|
||||||
|
description: s.description ?? "",
|
||||||
|
quantity: Number(s.quantity ?? 1),
|
||||||
|
weight: Number(s.weight ?? 0),
|
||||||
|
notes: s.notes ?? ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function migrateKungfuItem(oldItem) {
|
||||||
|
const s = oldItem.system ?? {};
|
||||||
|
const techs = s.techniques ?? {};
|
||||||
|
const migratedTechs = {};
|
||||||
|
for (const key of ["technique1", "technique2", "technique3"]) {
|
||||||
|
const t = techs[key] ?? {};
|
||||||
|
migratedTechs[key] = {
|
||||||
|
check: Boolean(t.check),
|
||||||
|
name: t.name ?? "",
|
||||||
|
activation: mapActivation(t.activation ?? ""),
|
||||||
|
technique: t.technique ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: oldItem.name,
|
||||||
|
type: "kungfu",
|
||||||
|
img: oldItem.img || DEFAULT_ITEM_IMG,
|
||||||
|
system: {
|
||||||
|
reference: s.reference ?? "",
|
||||||
|
description: s.description ?? "",
|
||||||
|
orientation: s.orientation || "yin",
|
||||||
|
aspect: s.aspect || "metal",
|
||||||
|
skill: s.skill || "kungfu",
|
||||||
|
speciality: s.speciality ?? "",
|
||||||
|
style: s.style ?? "",
|
||||||
|
techniques: migratedTechs,
|
||||||
|
notes: s.notes ?? ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function migrateSpellItem(oldItem) {
|
||||||
|
const s = oldItem.system ?? {};
|
||||||
|
return {
|
||||||
|
name: oldItem.name,
|
||||||
|
type: "spell",
|
||||||
|
img: oldItem.img || DEFAULT_ITEM_IMG,
|
||||||
|
system: {
|
||||||
|
reference: s.reference ?? "",
|
||||||
|
description: s.description ?? "",
|
||||||
|
specialityname: s.specialityname ?? "",
|
||||||
|
associatedelement: elementKey(s.associatedelement ?? ""),
|
||||||
|
heiType: heiKey(s.hei ?? ""),
|
||||||
|
heiCost: Number(s.heiCost ?? 0),
|
||||||
|
difficulty: Number(s.difficulty ?? 0),
|
||||||
|
realizationtimeritual: s.realizationtimeritual ?? "",
|
||||||
|
realizationtimeaccelerated: s.realizationtimeaccelerated ?? "",
|
||||||
|
flashback: s.flashback ?? "",
|
||||||
|
components: s.components ?? "",
|
||||||
|
effects: s.effects ?? "",
|
||||||
|
examples: s.examples ?? "",
|
||||||
|
notes: s.notes ?? "",
|
||||||
|
discipline: inferDiscipline(s.specialityname ?? "", oldItem.name ?? "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function migrateSupernaturalItem(oldItem) {
|
||||||
|
const s = oldItem.system ?? {};
|
||||||
|
const nestedRef = s.supernatural?.reference ?? "";
|
||||||
|
return {
|
||||||
|
name: oldItem.name,
|
||||||
|
type: "supernatural",
|
||||||
|
img: oldItem.img || DEFAULT_ITEM_IMG,
|
||||||
|
system: {
|
||||||
|
reference: s.reference || nestedRef,
|
||||||
|
description: s.description ?? "",
|
||||||
|
notes: s.notes ?? "",
|
||||||
|
heiType: "yin",
|
||||||
|
heiCost: 0,
|
||||||
|
trigger: "",
|
||||||
|
effects: ""
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function migrateItem(oldItem) {
|
||||||
|
switch (oldItem.type) {
|
||||||
|
case "item":
|
||||||
|
return migrateEquipmentItem(oldItem);
|
||||||
|
case "kungfu":
|
||||||
|
return migrateKungfuItem(oldItem);
|
||||||
|
case "spell":
|
||||||
|
return migrateSpellItem(oldItem);
|
||||||
|
case "supernatural":
|
||||||
|
return migrateSupernaturalItem(oldItem);
|
||||||
|
default:
|
||||||
|
return migrateEquipmentItem({ ...oldItem, type: "item" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function migrateCharacter(old) {
|
||||||
|
const s = old.system ?? {};
|
||||||
|
const aspect = {};
|
||||||
|
for (const [k, v] of Object.entries(s.aspect ?? {})) {
|
||||||
|
aspect[k] = { chinese: v.chinese ?? "", label: v.label ?? "", value: Number(v.value ?? 0) };
|
||||||
|
}
|
||||||
|
const skills = {};
|
||||||
|
for (const [k, v] of Object.entries(s.skills ?? {})) {
|
||||||
|
skills[k] = { label: v.label ?? "", specialities: v.specialities ?? "", value: Number(v.value ?? 0) };
|
||||||
|
}
|
||||||
|
const resources = {};
|
||||||
|
for (const [k, v] of Object.entries(s.resources ?? {})) {
|
||||||
|
resources[k] = { label: v.label ?? "", specialities: v.specialities ?? "", value: Number(v.value ?? 0), debt: Boolean(v.debt) };
|
||||||
|
}
|
||||||
|
const component = {};
|
||||||
|
for (const [k, v] of Object.entries(s.component ?? {})) {
|
||||||
|
component[k] = { value: v.value ?? "" };
|
||||||
|
}
|
||||||
|
const MAGIC_SPECIALITIES = {
|
||||||
|
internalcinnabar: ["essence", "mind", "purification", "manipulation", "aura"],
|
||||||
|
alchemy: ["acupuncture", "elixirs", "poisons", "arsenal", "potions"],
|
||||||
|
masteryoftheway: ["curse", "transfiguration", "necromancy", "climatecontrol", "goldenmagic"],
|
||||||
|
exorcism: ["invocation", "tracking", "protection", "punishment", "domination"],
|
||||||
|
geomancy: ["neutralization", "divination", "earthlyprayer", "heavenlyprayer", "fungseoi"]
|
||||||
|
};
|
||||||
|
const magics = {};
|
||||||
|
for (const [school, specs] of Object.entries(MAGIC_SPECIALITIES)) {
|
||||||
|
const om = s.magics?.[school] ?? {};
|
||||||
|
const speciality = {};
|
||||||
|
for (const spec of specs) {
|
||||||
|
speciality[spec] = { check: Boolean(om.speciality?.[spec]?.check) };
|
||||||
|
}
|
||||||
|
magics[school] = { visible: Boolean(om.visible), value: Number(om.value ?? 0), speciality };
|
||||||
|
}
|
||||||
|
const tt = s.threetreasures ?? {};
|
||||||
|
const threetreasures = {
|
||||||
|
heiyang: { value: Number(tt.heiyang?.value ?? 0), max: Number(tt.heiyang?.max ?? 0) },
|
||||||
|
heiyin: { value: Number(tt.heiyin?.value ?? 0), max: Number(tt.heiyin?.max ?? 0) },
|
||||||
|
dicelevel: {
|
||||||
|
level0d: {
|
||||||
|
san: { value: Number(tt.dicelevel?.level0d?.san?.value ?? 0), max: Number(tt.dicelevel?.level0d?.san?.max ?? 0) },
|
||||||
|
zing: { value: Number(tt.dicelevel?.level0d?.zing?.value ?? 0), max: Number(tt.dicelevel?.level0d?.zing?.max ?? 0) }
|
||||||
|
},
|
||||||
|
level1d: {
|
||||||
|
san: { value: Number(tt.dicelevel?.level1d?.san?.value ?? 0), max: Number(tt.dicelevel?.level1d?.san?.max ?? 0) },
|
||||||
|
zing: { value: Number(tt.dicelevel?.level1d?.zing?.value ?? 0), max: Number(tt.dicelevel?.level1d?.zing?.max ?? 0) }
|
||||||
|
},
|
||||||
|
level2d: {
|
||||||
|
san: { value: Number(tt.dicelevel?.level2d?.san?.value ?? 0), max: Number(tt.dicelevel?.level2d?.san?.max ?? 0) },
|
||||||
|
zing: { value: Number(tt.dicelevel?.level2d?.zing?.value ?? 0), max: Number(tt.dicelevel?.level2d?.zing?.max ?? 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const description = s.description || s.biography || "";
|
||||||
|
return {
|
||||||
|
name: old.name,
|
||||||
|
type: "character",
|
||||||
|
img: old.img || DEFAULT_ACTOR_IMG,
|
||||||
|
system: {
|
||||||
|
concept: s.concept ?? "",
|
||||||
|
guardian: parseInt(s.guardian ?? "0") || 0,
|
||||||
|
initiative: Number(s.initiative ?? 1),
|
||||||
|
anti_initiative: Number(s.anti_initiative ?? 24),
|
||||||
|
description,
|
||||||
|
aspect,
|
||||||
|
skills,
|
||||||
|
resources,
|
||||||
|
component,
|
||||||
|
magics,
|
||||||
|
threetreasures,
|
||||||
|
experience: {
|
||||||
|
value: Number(s.experience?.value ?? 0),
|
||||||
|
max: Number(s.experience?.max ?? 0),
|
||||||
|
min: Number(s.experience?.min ?? 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: (old.items ?? []).map(migrateItem)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function migrateNpc(old) {
|
||||||
|
const s = old.system ?? {};
|
||||||
|
const aptitudes = {};
|
||||||
|
for (const [k, v] of Object.entries(s.aptitudes ?? {})) {
|
||||||
|
aptitudes[k] = { value: Number(v.value ?? 0), speciality: v.speciality ?? "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: old.name,
|
||||||
|
type: "npc",
|
||||||
|
img: old.img || DEFAULT_ACTOR_IMG,
|
||||||
|
system: {
|
||||||
|
type: s.type ?? "",
|
||||||
|
// Old system had separate `levelofthreat`/`powerofnuisance` as numbers
|
||||||
|
// and string copies `threat`/`nuisance` — use the numeric fields
|
||||||
|
threat: Number(s.levelofthreat ?? s.threat ?? 0),
|
||||||
|
nuisance: Number(s.powerofnuisance ?? s.nuisance ?? 0),
|
||||||
|
initiative: Number(s.initiative ?? 1),
|
||||||
|
anti_initiative: Number(s.anti_initiative ?? 24),
|
||||||
|
aptitudes,
|
||||||
|
vitality: {
|
||||||
|
value: Number(s.vitality?.value ?? 0),
|
||||||
|
calcul: Number(s.vitality?.calcul ?? 0),
|
||||||
|
note: s.vitality?.note ?? ""
|
||||||
|
},
|
||||||
|
hei: {
|
||||||
|
value: Number(s.hei?.value ?? 0),
|
||||||
|
calcul: Number(s.hei?.calcul ?? 0),
|
||||||
|
note: s.hei?.note ?? ""
|
||||||
|
},
|
||||||
|
description: s.description ?? ""
|
||||||
|
},
|
||||||
|
items: (old.items ?? []).map(migrateItem)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function migrateActor(oldJson) {
|
||||||
|
switch (oldJson.type) {
|
||||||
|
case "character":
|
||||||
|
return migrateCharacter(oldJson);
|
||||||
|
case "npc":
|
||||||
|
return migrateNpc(oldJson);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown actor type "${oldJson.type}" in "${oldJson.name}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function parseLegacyJson(jsonText) {
|
||||||
|
const parsed = JSON.parse(jsonText);
|
||||||
|
if (typeof parsed !== "object" || parsed === null) {
|
||||||
|
throw new Error("Le fichier JSON doit contenir un objet acteur ou un tableau d'acteurs");
|
||||||
|
}
|
||||||
|
const actors = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
|
return actors.map(migrateActor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/ui/apps/migration-app.js
|
||||||
|
var MIGRATION_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-migration-app.html";
|
||||||
|
var CDEMigrationApp = class _CDEMigrationApp extends foundry.applications.api.HandlebarsApplicationMixin(
|
||||||
|
foundry.applications.api.ApplicationV2
|
||||||
|
) {
|
||||||
|
static DEFAULT_OPTIONS = {
|
||||||
|
id: "cde-migration-app",
|
||||||
|
classes: ["cde-migration-app"],
|
||||||
|
tag: "div",
|
||||||
|
window: {
|
||||||
|
title: "CDE.MigrationTitle",
|
||||||
|
icon: "fas fa-file-import",
|
||||||
|
resizable: false
|
||||||
|
},
|
||||||
|
position: { width: 560, height: "auto" },
|
||||||
|
actions: {
|
||||||
|
clearFiles: _CDEMigrationApp.#clearFiles,
|
||||||
|
doImport: _CDEMigrationApp.#doImport
|
||||||
|
}
|
||||||
|
};
|
||||||
|
static PARTS = {
|
||||||
|
form: { template: MIGRATION_TEMPLATE }
|
||||||
|
};
|
||||||
|
/** @type {Array<{name: string, type: string, img: string, system: object, items: object[], _srcFile: string}>} */
|
||||||
|
#pending = [];
|
||||||
|
/** @type {string[]} - error messages per file */
|
||||||
|
#errors = [];
|
||||||
|
async _prepareContext(options) {
|
||||||
|
return {
|
||||||
|
pending: this.#pending,
|
||||||
|
errors: this.#errors,
|
||||||
|
hasPending: this.#pending.length > 0,
|
||||||
|
hasErrors: this.#errors.length > 0,
|
||||||
|
count: this.#pending.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** After render, wire up the file input. */
|
||||||
|
_onRender(context, options) {
|
||||||
|
super._onRender(context, options);
|
||||||
|
const input = this.element.querySelector(".cde-migration-file-input");
|
||||||
|
input?.addEventListener("change", this.#onFileChange.bind(this));
|
||||||
|
const dropZone = this.element.querySelector(".cde-migration-drop-zone");
|
||||||
|
if (dropZone) {
|
||||||
|
dropZone.addEventListener("dragover", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add("is-dragover");
|
||||||
|
});
|
||||||
|
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("is-dragover"));
|
||||||
|
dropZone.addEventListener("drop", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove("is-dragover");
|
||||||
|
this.#processFiles(Array.from(e.dataTransfer.files));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async #onFileChange(event) {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
event.target.value = "";
|
||||||
|
await this.#processFiles(files);
|
||||||
|
}
|
||||||
|
async #processFiles(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.name.endsWith(".json")) {
|
||||||
|
this.#errors.push(game.i18n.format("CDE.MigrationErrorNotJson", { file: file.name }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const actors = parseLegacyJson(text);
|
||||||
|
for (const actor of actors) {
|
||||||
|
actor._srcFile = file.name;
|
||||||
|
if (!this.#pending.some((p) => p.name === actor.name)) {
|
||||||
|
this.#pending.push(actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.#errors.push(game.i18n.format("CDE.MigrationErrorParse", { file: file.name, error: err.message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
static async #clearFiles() {
|
||||||
|
this.#pending = [];
|
||||||
|
this.#errors = [];
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
static async #doImport() {
|
||||||
|
if (!this.#pending.length) return;
|
||||||
|
const created = [];
|
||||||
|
const failed = [];
|
||||||
|
for (const data of this.#pending) {
|
||||||
|
try {
|
||||||
|
const { _srcFile, ...actorData } = data;
|
||||||
|
const actor = await Actor.create(actorData);
|
||||||
|
created.push(actor.name);
|
||||||
|
} catch (err) {
|
||||||
|
failed.push(`${data.name}: ${err.message}`);
|
||||||
|
console.error(`CHRONIQUESDELETRANGE | Migration failed for "${data.name}":`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.#pending = [];
|
||||||
|
this.#errors = failed;
|
||||||
|
this.render();
|
||||||
|
if (created.length) {
|
||||||
|
ui.notifications.info(
|
||||||
|
game.i18n.format("CDE.MigrationSuccess", { count: created.length, names: created.join(", ") })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (failed.length) {
|
||||||
|
ui.notifications.warn(
|
||||||
|
game.i18n.format("CDE.MigrationPartialError", { count: failed.length })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// src/config/settings.js
|
// src/config/settings.js
|
||||||
function registerSettings() {
|
function registerSettings() {
|
||||||
|
game.settings.registerMenu(SYSTEM_ID, "migrationTool", {
|
||||||
|
name: "CDE.MigrationTitle",
|
||||||
|
label: "CDE.MigrationMenuLabel",
|
||||||
|
hint: "CDE.MigrationMenuHint",
|
||||||
|
icon: "fas fa-file-import",
|
||||||
|
type: CDEMigrationApp,
|
||||||
|
restricted: true
|
||||||
|
});
|
||||||
game.settings.register(SYSTEM_ID, "loksyuData", {
|
game.settings.register(SYSTEM_ID, "loksyuData", {
|
||||||
scope: "world",
|
scope: "world",
|
||||||
config: false,
|
config: false,
|
||||||
@@ -154,9 +602,36 @@ function registerSettings() {
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
});
|
});
|
||||||
|
game.settings.register(SYSTEM_ID, "welcomeSceneLoaded", {
|
||||||
|
scope: "world",
|
||||||
|
config: false,
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async function migrateIfNeeded() {
|
async function migrateIfNeeded() {
|
||||||
}
|
}
|
||||||
|
async function loadWelcomeSceneIfNeeded() {
|
||||||
|
if (!game.user.isGM) return;
|
||||||
|
if (game.settings.get(SYSTEM_ID, "welcomeSceneLoaded")) return;
|
||||||
|
try {
|
||||||
|
const pack = game.packs.get(`${SYSTEM_ID}.cde-scenes`);
|
||||||
|
if (!pack) return;
|
||||||
|
const index = await pack.getIndex();
|
||||||
|
const entry = index.find((e) => e.name === "Accueil");
|
||||||
|
if (!entry) return;
|
||||||
|
const existing = game.scenes.find((s) => s.name === "Accueil");
|
||||||
|
let scene = existing;
|
||||||
|
if (!scene) {
|
||||||
|
const doc = await pack.getDocument(entry._id);
|
||||||
|
[scene] = await Scene.createDocuments([doc.toObject()]);
|
||||||
|
}
|
||||||
|
await game.settings.set(SYSTEM_ID, "welcomeSceneLoaded", true);
|
||||||
|
await scene.activate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("CHRONIQUESDELETRANGE | loadWelcomeSceneIfNeeded failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// src/config/localize.js
|
// src/config/localize.js
|
||||||
function preLocalizeConfig() {
|
function preLocalizeConfig() {
|
||||||
@@ -2570,6 +3045,47 @@ function refreshAllRollActions() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/ui/apps/welcome.js
|
||||||
|
var HELP_JOURNAL_UUID = `Compendium.${SYSTEM_ID}.cde-help.JournalEntry.CDEGuideMain0001`;
|
||||||
|
async function showWelcomeMessage() {
|
||||||
|
const logo = `systems/${SYSTEM_ID}/images/logo_jeu.webp`;
|
||||||
|
const content = `
|
||||||
|
<div class="cde-welcome-message">
|
||||||
|
<img class="cde-welcome-logo" src="${logo}" alt="Les Chroniques de l'\xC9trange" />
|
||||||
|
<h2 class="cde-welcome-title">Les Chroniques de l'\xC9trange</h2>
|
||||||
|
<p class="cde-welcome-links">
|
||||||
|
Un jeu de r\xF4le \xE9dit\xE9 par
|
||||||
|
<a href="https://antre-monde.com/les-chroniques-de-letrengae/" target="_blank" rel="noopener">Antre-Monde \xC9ditions</a>
|
||||||
|
</p>
|
||||||
|
<p class="cde-welcome-links">
|
||||||
|
Syst\xE8me FoundryVTT r\xE9alis\xE9 par
|
||||||
|
<a href="https://www.uberwald.me" target="_blank" rel="noopener">LeRatierBretonnien</a>
|
||||||
|
</p>
|
||||||
|
<button type="button" class="cde-welcome-help-btn" data-action="open-cde-help">
|
||||||
|
<i class="fas fa-book-open"></i>
|
||||||
|
${game.i18n.localize("CDE.WelcomeOpenHelp")}
|
||||||
|
</button>
|
||||||
|
</div>`;
|
||||||
|
await ChatMessage.create({
|
||||||
|
content,
|
||||||
|
speaker: { alias: "Les Chroniques de l'\xC9trange" },
|
||||||
|
flags: { [SYSTEM_ID]: { welcome: true } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function injectWelcomeActions(_message, html) {
|
||||||
|
const el = html instanceof HTMLElement ? html : html[0] ?? html;
|
||||||
|
const btn = el?.querySelector?.("[data-action='open-cde-help']");
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const doc = await fromUuid(HELP_JOURNAL_UUID);
|
||||||
|
doc?.sheet?.render(true);
|
||||||
|
} catch {
|
||||||
|
game.packs.get(`${SYSTEM_ID}.cde-help`)?.render(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// src/system.js
|
// src/system.js
|
||||||
Hooks.once("i18nInit", preLocalizeConfig);
|
Hooks.once("i18nInit", preLocalizeConfig);
|
||||||
Hooks.once("init", async () => {
|
Hooks.once("init", async () => {
|
||||||
@@ -2655,7 +3171,9 @@ Hooks.once("init", async () => {
|
|||||||
});
|
});
|
||||||
Hooks.once("ready", async () => {
|
Hooks.once("ready", async () => {
|
||||||
await migrateIfNeeded();
|
await migrateIfNeeded();
|
||||||
|
await loadWelcomeSceneIfNeeded();
|
||||||
CDEWheelApp.registerHooks();
|
CDEWheelApp.registerHooks();
|
||||||
|
if (game.user.isGM) showWelcomeMessage();
|
||||||
});
|
});
|
||||||
Hooks.on("renderChatLog", (_app, html) => {
|
Hooks.on("renderChatLog", (_app, html) => {
|
||||||
const el = html instanceof HTMLElement ? html : html[0] ?? html;
|
const el = html instanceof HTMLElement ? html : html[0] ?? html;
|
||||||
@@ -2685,6 +3203,7 @@ Hooks.on("renderChatLog", (_app, html) => {
|
|||||||
});
|
});
|
||||||
Hooks.on("renderChatMessageHTML", (message, html) => {
|
Hooks.on("renderChatMessageHTML", (message, html) => {
|
||||||
injectRollActions(message, html);
|
injectRollActions(message, html);
|
||||||
|
if (message.flags?.[SYSTEM_ID]?.welcome) injectWelcomeActions(message, html);
|
||||||
});
|
});
|
||||||
Hooks.on("updateSetting", (setting) => {
|
Hooks.on("updateSetting", (setting) => {
|
||||||
if (!setting.key) return;
|
if (!setting.key) return;
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1010 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1024 KiB |
|
After Width: | Height: | Size: 918 KiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 968 KiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 902 KiB |
|
After Width: | Height: | Size: 939 KiB |
@@ -131,6 +131,23 @@
|
|||||||
"CDE.InitiativeNPCSpeciality": "Première action (Aptitude) que vous escomptez effectuer",
|
"CDE.InitiativeNPCSpeciality": "Première action (Aptitude) que vous escomptez effectuer",
|
||||||
"CDE.InitiativeRoll": "Jet d'initiative",
|
"CDE.InitiativeRoll": "Jet d'initiative",
|
||||||
"CDE.InitiativeSpeciality": "Première action (Compétence) que vous escomptez effectuer",
|
"CDE.InitiativeSpeciality": "Première action (Compétence) que vous escomptez effectuer",
|
||||||
|
"CDE.MigrationTitle": "Migration depuis l'ancien système",
|
||||||
|
"CDE.MigrationMenuLabel": "Importer des personnages",
|
||||||
|
"CDE.MigrationMenuHint": "Importer des fiches de personnage depuis l'ancien système CDE",
|
||||||
|
"CDE.MigrationHint": "Glissez-déposez des fichiers JSON ou cliquez pour les sélectionner.",
|
||||||
|
"CDE.MigrationDropHint": "Déposez vos fichiers JSON ici",
|
||||||
|
"CDE.MigrationChooseFiles": "Choisir des fichiers",
|
||||||
|
"CDE.MigrationPreviewTitle": "Personnages à importer",
|
||||||
|
"CDE.MigrationClear": "Vider",
|
||||||
|
"CDE.MigrationColName": "Nom",
|
||||||
|
"CDE.MigrationColType": "Type",
|
||||||
|
"CDE.MigrationColItems": "Objets",
|
||||||
|
"CDE.MigrationColFile": "Fichier source",
|
||||||
|
"CDE.MigrationImport": "Importer",
|
||||||
|
"CDE.MigrationSuccess": "{count} personnage(s) importé(s) : {names}",
|
||||||
|
"CDE.MigrationPartialError": "{count} personnage(s) n'ont pas pu être importés.",
|
||||||
|
"CDE.MigrationErrorNotJson": "Le fichier « {file} » n'est pas un fichier JSON.",
|
||||||
|
"CDE.MigrationErrorParse": "Erreur lors de la lecture de « {file} » : {error}",
|
||||||
"CDE.InitiativeWheel": "Roue d'Initiative",
|
"CDE.InitiativeWheel": "Roue d'Initiative",
|
||||||
"CDE.InitiativeWheelOpen": "Ouvrir la Roue d'Initiative",
|
"CDE.InitiativeWheelOpen": "Ouvrir la Roue d'Initiative",
|
||||||
"CDE.InitiativeWheelHint": "Roue d'initiative – Les Chroniques de l'Étrange",
|
"CDE.InitiativeWheelHint": "Roue d'initiative – Les Chroniques de l'Étrange",
|
||||||
@@ -415,5 +432,6 @@
|
|||||||
"CDE.TotalDamage": "Dommages",
|
"CDE.TotalDamage": "Dommages",
|
||||||
"CDE.WeaponRoll": "Jet d'arme",
|
"CDE.WeaponRoll": "Jet d'arme",
|
||||||
"CDE.RangePenalty": "Pénalité de portée",
|
"CDE.RangePenalty": "Pénalité de portée",
|
||||||
"CDE.SuccessTimesDamage": "succès × dégâts de base"
|
"CDE.SuccessTimesDamage": "succès × dégâts de base",
|
||||||
|
"CDE.WelcomeOpenHelp": "Ouvrir l'aide en ligne"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
{
|
||||||
|
"_id": "CDEGuideMain0001",
|
||||||
|
"_key": "!journal!CDEGuideMain0001",
|
||||||
|
"name": "Guide du Système CDE",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP01Intro",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP01Intro",
|
||||||
|
"name": "Bienvenue dans CDE",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Bienvenue dans les Chroniques de l'Étrange</h1>\n<p>Ce guide vous présente l'interface du système FoundryVTT pour <em>Chroniques de l'Étrange</em> (CDE), jeu de rôle d'enquête et d'action surnaturelle dans le Hong Kong contemporain, édité par Antre-Monde Éditions.</p>\n<h2>Structure du système</h2>\n<ul>\n<li><strong>Fiches de personnage (Fat Si)</strong> — Héros joueurs avec cinq aspects Wu Xing, compétences, Trois Trésors et équipement.</li>\n<li><strong>Fiches de PNJ</strong> — Créatures, dieux, fantômes et humains à l'usage du MJ.</li>\n<li><strong>Compendiums</strong> — Arts martiaux, sortilèges, équipements, PNJs, capacités surnaturelles, ingrédients, San Hei.</li>\n<li><strong>Outils de MJ</strong> — Roue d'initiative, compteurs Loksyu/Tin Ji, outil de migration.</li>\n</ul>\n<p>Naviguez dans ce journal via les onglets de page pour découvrir chaque aspect du système.</p>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 100000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP02WuXin",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP02WuXin",
|
||||||
|
"name": "Le Cycle Wu Xing",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Le Cycle Wu Xing (五行)</h1>\n<p>Le Wu Xing est le cœur du système de résolution. Cinq aspects — <strong>Métal (㊎)</strong>, <strong>Eau (㊌)</strong>, <strong>Terre (㊏)</strong>, <strong>Feu (㊋)</strong> et <strong>Bois (㊍)</strong> — définissent les capacités d'un personnage.</p>\n<h2>Faces des d10 et résultats</h2>\n<p>Chaque aspect est associé à deux faces du d10. Lors d'un jet, vous déclarez l'aspect utilisé avant de lancer vos dés. Chaque dé donne un résultat :</p>\n<ul>\n<li><strong>Succès</strong> — avancez vers votre objectif.</li>\n<li><strong>Dés-fastes (吉)</strong> — résultats favorables supplémentaires.</li>\n<li><strong>Dés-néfastes (凶)</strong> — complications.</li>\n<li><strong>Loksyu (落穗)</strong> — alimentent le compteur mondial de chance collective.</li>\n<li><strong>Tin Ji (天機)</strong> — alimentent le compteur de destin.</li>\n</ul>\n<h2>Correspondances</h2>\n<table>\n<thead><tr><th>Aspect</th><th>Faces d10</th><th>Caractère</th></tr></thead>\n<tbody>\n<tr><td>㊎ Métal</td><td>1 & 6</td><td>Agressif, passionné, combatif</td></tr>\n<tr><td>㊌ Eau</td><td>2 & 7</td><td>Souple, appliqué, adaptable</td></tr>\n<tr><td>㊏ Terre</td><td>3 & 8</td><td>Obstiné, résilient, endurant</td></tr>\n<tr><td>㊋ Feu</td><td>4 & 9</td><td>Chaleureux, créatif, empathique</td></tr>\n<tr><td>㊍ Bois</td><td>5 & 10</td><td>Intuitif, observateur, instinctif</td></tr>\n</tbody>\n</table>\n<p><em>La valeur d'un aspect (de 1 à 5) détermine le nombre de dés que vous lancez.</em></p>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 200000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP03Sheet",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP03Sheet",
|
||||||
|
"name": "La Fiche de Personnage",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>La Fiche de Personnage (Fat Si)</h1>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/character-sheet-nghang.png\" alt=\"Fiche de personnage — onglet Ng Hang\"/><figcaption>Onglet Ng Hang : les cinq aspects Wu Xing</figcaption></figure>\n<p>La fiche de personnage se décompose en sept onglets :</p>\n<ol>\n<li><strong>Description</strong> — Biographie, concept, gardien céleste.</li>\n<li><strong>Ng Hang</strong> — Les cinq aspects Wu Xing (valeur 1–5). Cliquez sur l'image du dé pour lancer.</li>\n<li><strong>Compétences</strong> — Les compétences générales et ressources.</li>\n<li><strong>Trois Trésors</strong> — Hei-Yang, Hei-Yin et les niveaux de dés.</li>\n<li><strong>Magies</strong> — Les cinq écoles de magie et leurs sortilèges.</li>\n<li><strong>Kung Fu</strong> — Arts martiaux possédés.</li>\n<li><strong>Équipement</strong> — Objets portés.</li>\n</ol>\n<h2>En-tête de fiche</h2>\n<p>En haut de la fiche se trouvent le <strong>concept du personnage</strong>, son <strong>gardien céleste</strong> (aspect dominant) et la zone d'<strong>initiative</strong> avec les boutons ±.</p>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/character-sheet-skills.png\" alt=\"Fiche de personnage — onglet Compétences\"/><figcaption>Onglet Compétences : compétences et ressources</figcaption></figure>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 300000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP04Treas",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP04Treas",
|
||||||
|
"name": "Les Trois Trésors",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Les Trois Trésors (三寶)</h1>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/character-sheet-treasures.png\" alt=\"Fiche de personnage — onglet Trois Trésors\"/><figcaption>Onglet Trois Trésors : Hei-Yang, Hei-Yin et niveaux de dés</figcaption></figure>\n<p>Les Trois Trésors représentent les réserves d'énergie vitale du personnage :</p>\n<h2>Hei-Yang (陽氣) et Hei-Yin (陰氣)</h2>\n<p>Ce sont les deux jauges de vitalité. Le Hei-Yang représente l'énergie active, le Hei-Yin l'énergie passive. Ensemble, ils forment le <strong>token attribute</strong> visible sur la carte.</p>\n<h2>Niveaux de dés</h2>\n<p>Les niveaux de dés (d4 → d6 → d8 → d10 → d12) reflètent la progression du personnage dans un aspect. Chaque niveau de dé confère un bonus ou un avantage supplémentaire.</p>\n<h2>Blessures</h2>\n<p>Les blessures s'accumulent et imposent des malus croissants :</p>\n<ul>\n<li>Blessé : −1 dé à tous les jets</li>\n<li>Gravement blessé : −2 dés</li>\n<li>État critique : −3 dés</li>\n</ul>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 400000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP05Magic",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP05Magic",
|
||||||
|
"name": "Magie",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Magie</h1>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/character-sheet-magics.png\" alt=\"Fiche de personnage — onglet Magies\"/><figcaption>Onglet Magies : les cinq écoles et leurs sortilèges</figcaption></figure>\n<p>CDE dispose de cinq écoles de magie, chacune divisée en cinq spécialités :</p>\n<ul>\n<li><strong>Cinabre Interne</strong> (内丹) — magie du souffle et du corps.</li>\n<li><strong>Alchimie</strong> (外丹) — préparations, potions et talismans matériels.</li>\n<li><strong>Maîtrise du Tao</strong> (道術) — maîtrise des principes cosmiques.</li>\n<li><strong>Exorcisme</strong> (驅魔) — combat contre les entités surnaturelles.</li>\n<li><strong>Géomancie</strong> (風水) — magie des lieux et de l'environnement.</li>\n</ul>\n<h2>Utiliser un sortilège</h2>\n<ol>\n<li>Cliquez sur l'icône dé du sort dans l'onglet Magies.</li>\n<li>Un dialog apparaît avec l'aspect associé, le coût en Hei et le nombre de dés.</li>\n<li>Validez pour effectuer le jet.</li>\n</ol>\n<p>Les sortilèges sont importés depuis le compendium <em>Sortilèges</em> et glissés sur la fiche.</p>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 500000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP06KungF",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP06KungF",
|
||||||
|
"name": "Arts Martiaux",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Arts Martiaux (武術)</h1>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/character-sheet-kungfu.png\" alt=\"Fiche de personnage — onglet Kung Fu\"/><figcaption>Onglet Kung Fu : arts martiaux possédés</figcaption></figure>\n<p>Les arts martiaux représentent les techniques de combat du personnage. Chaque art martial possède :</p>\n<ul>\n<li><strong>Un mode d'activation</strong> : passif, action d'attaque, réaction, etc.</li>\n<li><strong>Une description</strong> des effets en jeu.</li>\n</ul>\n<h2>Importer un art martial</h2>\n<p>Ouvrez le compendium <em>Arts Martiaux</em> et faites glisser une technique sur la fiche du personnage. Elle apparaît alors dans l'onglet Kung Fu.</p>\n<h2>Types d'activation</h2>\n<table>\n<thead><tr><th>Type</th><th>Déclencheur</th></tr></thead>\n<tbody>\n<tr><td>Passif (dés)</td><td>Toujours actif</td></tr>\n<tr><td>Action d'attaque</td><td>Lors d'une attaque</td></tr>\n<tr><td>Action de défense</td><td>Lors d'une défense</td></tr>\n<tr><td>Réaction</td><td>En réponse à un événement</td></tr>\n<tr><td>Dégâts infligés</td><td>Quand vous blessez</td></tr>\n</tbody>\n</table>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 600000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP07Items",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP07Items",
|
||||||
|
"name": "Équipement & Inventaire",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Équipement & Inventaire</h1>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/character-sheet-items.png\" alt=\"Fiche de personnage — onglet Équipement\"/><figcaption>Onglet Équipement : objets portés</figcaption></figure>\n<p>L'onglet Équipement liste tout ce que porte le personnage. Les objets sont classés en plusieurs catégories :</p>\n<ul>\n<li><strong>Armes</strong> — avec dégâts, distance et type.</li>\n<li><strong>Protections</strong> — armures et protections spirituelles.</li>\n<li><strong>San Hei (三氣)</strong> — objets magiques à charges.</li>\n<li><strong>Ingrédients</strong> — matériaux pour l'alchimie.</li>\n<li><strong>Équipement générique</strong> — tout autre objet.</li>\n</ul>\n<h2>Ajouter un objet</h2>\n<p>Faites glisser un objet depuis un compendium (Armes, Protections, San Hei, Ingrédients, Équipements) ou créez-en un avec le bouton <em>Créer</em> correspondant.</p>\n<p>Cliquez sur l'image d'un objet pour ouvrir sa fiche détaillée.</p>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 700000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP08NPCSH",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP08NPCSH",
|
||||||
|
"name": "Les PNJ",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Les Personnages Non-Joueurs (PNJ)</h1>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/npc-sheet.png\" alt=\"Fiche de PNJ\"/><figcaption>Fiche PNJ : type, nuisance, menace et aptitudes</figcaption></figure>\n<p>Les PNJ ont une fiche simplifiée par rapport aux personnages joueurs.</p>\n<h2>Caractéristiques</h2>\n<ul>\n<li><strong>Type de créature</strong> : Mortel, Démon, Esprit, Esprit animal, Fantôme, Jiugwaai, Dieu/Divinité.</li>\n<li><strong>Capacité de nuisance</strong> : Figurant, Sbire, Adversaire, Allié, Boss, Divinité.</li>\n<li><strong>Niveau de menace</strong> : Profane → Apprenti → Initié → Accompli → Renommé.</li>\n</ul>\n<h2>Aptitudes</h2>\n<p>Les PNJ ont quatre aptitudes (Physique, Martiale, Mentale, Sociale) avec une spécialité optionnelle chacune.</p>\n<h2>Capacités surnaturelles</h2>\n<p>Les PNJ peuvent avoir des capacités importées depuis le compendium <em>Capacités Surnaturelles</em>.</p>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 800000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP09Initi",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP09Initi",
|
||||||
|
"name": "Initiative & Combat",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Initiative & Combat</h1>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/initiative-wheel.png\" alt=\"Roue d'initiative\"/><figcaption>La Roue d'Initiative : 24 crans, couleurs Wu Xing</figcaption></figure>\n<h2>La Roue d'Initiative</h2>\n<p>La roue est un cercle de <strong>24 crans</strong> numérotés. L'initiative de chaque personnage est calculée comme suit :</p>\n<ul>\n<li><strong>Personnage joueur</strong> : Prouesse + valeur de compétence de la première action.</li>\n<li><strong>PNJ</strong> : Aptitude physique + aptitude de la première action.</li>\n</ul>\n<p>Les crans sont colorés selon le cycle Wu Xing (4 crans par couleur, 6 couleurs). <strong>Un effet qui dure 6 crans</strong> court jusqu'au prochain cran de la même couleur.</p>\n<h2>Ordre d'action</h2>\n<p>Les personnages agissent <strong>du numéro le plus élevé au plus bas</strong>. Après chaque action, le jeton avance dans le sens horaire du <strong>coût de l'action</strong> :</p>\n<table>\n<thead><tr><th>Action</th><th>Coût (crans)</th></tr></thead>\n<tbody>\n<tr><td>Défense</td><td>1</td></tr>\n<tr><td>Déplacement</td><td>2</td></tr>\n<tr><td>Attaque</td><td>3</td></tr>\n<tr><td>Retarder</td><td>6</td></tr>\n</tbody>\n</table>\n<h2>Accès à la roue</h2>\n<p>Ouvrez la roue depuis la barre latérale du chat (icône roue) ou via la console : <code>game.cde.CDEWheelApp.open()</code>.</p>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 900000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"_id": "CDEHelpP10Extra",
|
||||||
|
"_key": "!journal.pages!CDEGuideMain0001.CDEHelpP10Extra",
|
||||||
|
"name": "Loksyu, Tin Ji & Migration",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"content": "\n<h1>Loksyu, Tin Ji & Outils de MJ</h1>\n<h2>Loksyu (落穗) et Tin Ji (天機)</h2>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/loksyu-app.png\" alt=\"Application Loksyu\"/><figcaption>Application Loksyu : compteurs Yin/Yang et Tin Ji</figcaption></figure>\n<p>Ces deux compteurs sont <strong>partagés entre tous les joueurs</strong> et le MJ :</p>\n<ul>\n<li><strong>Loksyu</strong> — Se divise en Yin et Yang. Les jets de dés alimentent ces compteurs selon les résultats. Les joueurs peuvent puiser dans le Loksyu pour améliorer leurs jets.</li>\n<li><strong>Tin Ji</strong> — Le compteur de destin. Peut être dépensé pour des effets exceptionnels.</li>\n</ul>\n<p>Accédez via la barre du chat ou : <code>game.cde.CDELoksyuApp.open()</code></p>\n<h2>Migration de l'ancien système</h2>\n<figure><img src=\"systems/fvtt-chroniques-de-l-etrange/images/ui/migration-dialog.png\" alt=\"Outil de migration\"/><figcaption>Outil de migration : importation depuis l'ancien système</figcaption></figure>\n<p>Si vous possédez des fiches de personnage créées dans l'ancien système CDE (non maintenu), l'outil de migration les convertit automatiquement :</p>\n<ol>\n<li>Ouvrez <strong>Paramètres de la partie → Paramètres du système → Importer des personnages</strong>.</li>\n<li>Glissez les fichiers JSON des anciens personnages dans la zone de dépôt.</li>\n<li>Vérifiez l'aperçu et cliquez <strong>Importer</strong>.</li>\n</ol>\n<p>Les personnages migrés apparaissent dans la liste des Acteurs.</p>\n",
|
||||||
|
"format": 1,
|
||||||
|
"markdown": ""
|
||||||
|
},
|
||||||
|
"sort": 1000000,
|
||||||
|
"title": {
|
||||||
|
"show": true,
|
||||||
|
"level": 1
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"caption": ""
|
||||||
|
},
|
||||||
|
"video": {
|
||||||
|
"controls": true,
|
||||||
|
"volume": 0.5
|
||||||
|
},
|
||||||
|
"src": null,
|
||||||
|
"flags": {},
|
||||||
|
"ownership": {
|
||||||
|
"default": -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort": 0,
|
||||||
|
"ownership": {
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"flags": {},
|
||||||
|
"folder": null
|
||||||
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
MANIFEST-000006
|
MANIFEST-000035
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
2026/04/27-20:01:11.390845 7fed927fc6c0 Recovering log #4
|
2026/05/06-22:32:13.366106 7fe44efef6c0 Recovering log #33
|
||||||
2026/04/27-20:01:11.400505 7fed927fc6c0 Delete type=3 #2
|
2026/05/06-22:32:13.376874 7fe44efef6c0 Delete type=3 #31
|
||||||
2026/04/27-20:01:11.400599 7fed927fc6c0 Delete type=0 #4
|
2026/05/06-22:32:13.376974 7fe44efef6c0 Delete type=0 #33
|
||||||
|
2026/05/06-22:37:23.617629 7fe44cfeb6c0 Level-0 table #38: started
|
||||||
|
2026/05/06-22:37:23.617668 7fe44cfeb6c0 Level-0 table #38: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.624147 7fe44cfeb6c0 Delete type=0 #36
|
||||||
|
2026/05/06-22:37:23.643567 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
2026/04/27-17:47:13.055628 7f2779bff6c0 Delete type=3 #1
|
|
||||||
2026/04/27-17:47:13.058468 7f272b7fe6c0 Level-0 table #5: started
|
|
||||||
2026/04/27-17:47:13.061813 7f272b7fe6c0 Level-0 table #5: 1330 bytes OK
|
|
||||||
2026/04/27-17:47:13.067956 7f272b7fe6c0 Delete type=0 #3
|
|
||||||
2026/04/27-17:47:13.068111 7f272b7fe6c0 Manual compaction at level-0 from '!items!3aig6MWvZCRoWXPW' @ 72057594037927935 : 1 .. '!items!cXaQG1TBE0jzrbNt' @ 0 : 0; will stop at (end)
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000023
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
2026/05/06-22:32:13.427495 7fe44efef6c0 Recovering log #21
|
||||||
|
2026/05/06-22:32:13.437731 7fe44efef6c0 Delete type=3 #19
|
||||||
|
2026/05/06-22:32:13.437796 7fe44efef6c0 Delete type=0 #21
|
||||||
|
2026/05/06-22:37:23.643733 7fe44cfeb6c0 Level-0 table #26: started
|
||||||
|
2026/05/06-22:37:23.643771 7fe44cfeb6c0 Level-0 table #26: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.650510 7fe44cfeb6c0 Delete type=0 #24
|
||||||
|
2026/05/06-22:37:23.669664 7fe44cfeb6c0 Manual compaction at level-0 from '!journal!CDEGuideMain0001' @ 72057594037927935 : 1 .. '!journal.pages!CDEGuideMain0001.x83SZpLrbEi96PVQ' @ 0 : 0; will stop at (end)
|
||||||
|
2026/05/06-22:37:23.669706 7fe44cfeb6c0 Manual compaction at level-1 from '!journal!CDEGuideMain0001' @ 72057594037927935 : 1 .. '!journal.pages!CDEGuideMain0001.x83SZpLrbEi96PVQ' @ 0 : 0; will stop at (end)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
2026/05/06-22:24:45.849795 7fe44efef6c0 Recovering log #17
|
||||||
|
2026/05/06-22:24:45.867206 7fe44efef6c0 Delete type=3 #15
|
||||||
|
2026/05/06-22:24:45.867306 7fe44efef6c0 Delete type=0 #17
|
||||||
|
2026/05/06-22:30:22.773890 7fe44cfeb6c0 Level-0 table #22: started
|
||||||
|
2026/05/06-22:30:22.773918 7fe44cfeb6c0 Level-0 table #22: 0 bytes OK
|
||||||
|
2026/05/06-22:30:22.780047 7fe44cfeb6c0 Delete type=0 #20
|
||||||
|
2026/05/06-22:30:22.798899 7fe44cfeb6c0 Manual compaction at level-0 from '!journal!CDEGuideMain0001' @ 72057594037927935 : 1 .. '!journal.pages!CDEGuideMain0001.x83SZpLrbEi96PVQ' @ 0 : 0; will stop at (end)
|
||||||
|
2026/05/06-22:30:22.798944 7fe44cfeb6c0 Manual compaction at level-1 from '!journal!CDEGuideMain0001' @ 72057594037927935 : 1 .. '!journal.pages!CDEGuideMain0001.x83SZpLrbEi96PVQ' @ 0 : 0; will stop at (end)
|
||||||
@@ -1 +1 @@
|
|||||||
MANIFEST-000006
|
MANIFEST-000035
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
2026/04/27-20:01:11.418750 7fed93fff6c0 Recovering log #4
|
2026/05/06-22:32:13.390543 7fe44efef6c0 Recovering log #33
|
||||||
2026/04/27-20:01:11.428738 7fed93fff6c0 Delete type=3 #2
|
2026/05/06-22:32:13.400800 7fe44efef6c0 Delete type=3 #31
|
||||||
2026/04/27-20:01:11.428793 7fed93fff6c0 Delete type=0 #4
|
2026/05/06-22:32:13.400881 7fe44efef6c0 Delete type=0 #33
|
||||||
|
2026/05/06-22:37:23.636590 7fe44cfeb6c0 Level-0 table #38: started
|
||||||
|
2026/05/06-22:37:23.636613 7fe44cfeb6c0 Level-0 table #38: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.643393 7fe44cfeb6c0 Delete type=0 #36
|
||||||
|
2026/05/06-22:37:23.643628 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
2026/04/27-17:47:13.085519 7f27793fe6c0 Delete type=3 #1
|
2026/05/06-22:24:45.793005 7fe44efef6c0 Recovering log #29
|
||||||
2026/04/27-17:47:13.087023 7f272b7fe6c0 Level-0 table #5: started
|
2026/05/06-22:24:45.808875 7fe44efef6c0 Delete type=3 #27
|
||||||
2026/04/27-17:47:13.091030 7f272b7fe6c0 Level-0 table #5: 5923 bytes OK
|
2026/05/06-22:24:45.808947 7fe44efef6c0 Delete type=0 #29
|
||||||
2026/04/27-17:47:13.097545 7f272b7fe6c0 Delete type=0 #3
|
2026/05/06-22:30:22.754997 7fe44cfeb6c0 Level-0 table #34: started
|
||||||
2026/04/27-17:47:13.097759 7f272b7fe6c0 Manual compaction at level-0 from '!items!0NDBw1YB54q3hLH0' @ 72057594037927935 : 1 .. '!items!ykekdZlirabRobEF' @ 0 : 0; will stop at (end)
|
2026/05/06-22:30:22.755020 7fe44cfeb6c0 Level-0 table #34: 0 bytes OK
|
||||||
|
2026/05/06-22:30:22.760743 7fe44cfeb6c0 Delete type=0 #32
|
||||||
|
2026/05/06-22:30:22.773745 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
MANIFEST-000006
|
MANIFEST-000035
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
2026/04/27-20:01:11.433002 7fed92ffd6c0 Recovering log #4
|
2026/05/06-22:32:13.402637 7fe44e7ee6c0 Recovering log #33
|
||||||
2026/04/27-20:01:11.442974 7fed92ffd6c0 Delete type=3 #2
|
2026/05/06-22:32:13.413187 7fe44e7ee6c0 Delete type=3 #31
|
||||||
2026/04/27-20:01:11.443041 7fed92ffd6c0 Delete type=0 #4
|
2026/05/06-22:32:13.413261 7fe44e7ee6c0 Delete type=0 #33
|
||||||
|
2026/05/06-22:37:23.630418 7fe44cfeb6c0 Level-0 table #38: started
|
||||||
|
2026/05/06-22:37:23.630444 7fe44cfeb6c0 Level-0 table #38: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.636504 7fe44cfeb6c0 Delete type=0 #36
|
||||||
|
2026/05/06-22:37:23.643610 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
2026/04/27-17:47:13.116591 7f272bfff6c0 Delete type=3 #1
|
2026/05/06-22:24:45.812009 7fe44dfed6c0 Recovering log #29
|
||||||
2026/04/27-17:47:13.117666 7f272b7fe6c0 Level-0 table #5: started
|
2026/05/06-22:24:45.827425 7fe44dfed6c0 Delete type=3 #27
|
||||||
2026/04/27-17:47:13.121072 7f272b7fe6c0 Level-0 table #5: 559 bytes OK
|
2026/05/06-22:24:45.827509 7fe44dfed6c0 Delete type=0 #29
|
||||||
2026/04/27-17:47:13.127453 7f272b7fe6c0 Delete type=0 #3
|
2026/05/06-22:30:22.760828 7fe44cfeb6c0 Level-0 table #34: started
|
||||||
2026/04/27-17:47:13.127641 7f272b7fe6c0 Manual compaction at level-0 from '!items!HKq5ANSGiBIdcnki' @ 72057594037927935 : 1 .. '!items!HKq5ANSGiBIdcnki' @ 0 : 0; will stop at (end)
|
2026/05/06-22:30:22.760852 7fe44cfeb6c0 Level-0 table #34: 0 bytes OK
|
||||||
|
2026/05/06-22:30:22.766828 7fe44cfeb6c0 Delete type=0 #32
|
||||||
|
2026/05/06-22:30:22.773753 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
MANIFEST-000006
|
MANIFEST-000035
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
2026/04/27-20:01:11.327150 7fed93fff6c0 Recovering log #4
|
2026/05/06-22:32:13.317725 7fe44efef6c0 Recovering log #33
|
||||||
2026/04/27-20:01:11.338223 7fed93fff6c0 Delete type=3 #2
|
2026/05/06-22:32:13.328138 7fe44efef6c0 Delete type=3 #31
|
||||||
2026/04/27-20:01:11.338311 7fed93fff6c0 Delete type=0 #4
|
2026/05/06-22:32:13.328231 7fe44efef6c0 Delete type=0 #33
|
||||||
|
2026/05/06-22:37:23.590888 7fe44cfeb6c0 Level-0 table #38: started
|
||||||
|
2026/05/06-22:37:23.590952 7fe44cfeb6c0 Level-0 table #38: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.597751 7fe44cfeb6c0 Delete type=0 #36
|
||||||
|
2026/05/06-22:37:23.617391 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
2026/04/27-17:47:13.145290 7f2778bfd6c0 Delete type=3 #1
|
2026/05/06-22:24:45.678133 7fe44d7ec6c0 Recovering log #29
|
||||||
2026/04/27-17:47:13.146592 7f272b7fe6c0 Level-0 table #5: started
|
2026/05/06-22:24:45.699614 7fe44d7ec6c0 Delete type=3 #27
|
||||||
2026/04/27-17:47:13.150681 7f272b7fe6c0 Level-0 table #5: 32988 bytes OK
|
2026/05/06-22:24:45.699668 7fe44d7ec6c0 Delete type=0 #29
|
||||||
2026/04/27-17:47:13.157088 7f272b7fe6c0 Delete type=0 #3
|
2026/05/06-22:30:22.729240 7fe44cfeb6c0 Level-0 table #34: started
|
||||||
2026/04/27-17:47:13.157210 7f272b7fe6c0 Manual compaction at level-0 from '!items!2nKXEHLG0fXtSOdy' @ 72057594037927935 : 1 .. '!items!tlIc1bmIAbQeUwj7' @ 0 : 0; will stop at (end)
|
2026/05/06-22:30:22.729273 7fe44cfeb6c0 Level-0 table #34: 0 bytes OK
|
||||||
|
2026/05/06-22:30:22.735324 7fe44cfeb6c0 Delete type=0 #32
|
||||||
|
2026/05/06-22:30:22.748747 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
MANIFEST-000006
|
MANIFEST-000038
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
2026/04/27-20:01:11.445769 7fed937fe6c0 Recovering log #4
|
2026/05/06-22:32:13.415167 7fe44efef6c0 Recovering log #36
|
||||||
2026/04/27-20:01:11.456194 7fed937fe6c0 Delete type=3 #2
|
2026/05/06-22:32:13.424997 7fe44efef6c0 Delete type=3 #34
|
||||||
2026/04/27-20:01:11.456260 7fed937fe6c0 Delete type=0 #4
|
2026/05/06-22:32:13.425065 7fe44efef6c0 Delete type=0 #36
|
||||||
|
2026/05/06-22:37:23.794167 7fe44cfeb6c0 Level-0 table #41: started
|
||||||
|
2026/05/06-22:37:23.794211 7fe44cfeb6c0 Level-0 table #41: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.800534 7fe44cfeb6c0 Delete type=0 #39
|
||||||
|
2026/05/06-22:37:23.800731 7fe44cfeb6c0 Manual compaction at level-0 from '!actors!4ZjFZ1HoJV9mJStt' @ 72057594037927935 : 1 .. '!actors!zVpmacwoWEG8YTCQ' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
2026/04/27-17:47:13.175381 7f2778bfd6c0 Delete type=3 #1
|
2026/05/06-22:24:45.830634 7fe44efef6c0 Recovering log #32
|
||||||
2026/04/27-17:47:13.176524 7f272b7fe6c0 Level-0 table #5: started
|
2026/05/06-22:24:45.846170 7fe44efef6c0 Delete type=3 #30
|
||||||
2026/04/27-17:47:13.180270 7f272b7fe6c0 Level-0 table #5: 21686 bytes OK
|
2026/05/06-22:24:45.846232 7fe44efef6c0 Delete type=0 #32
|
||||||
2026/04/27-17:47:13.186334 7f272b7fe6c0 Delete type=0 #3
|
2026/05/06-22:30:22.780170 7fe44cfeb6c0 Level-0 table #37: started
|
||||||
2026/04/27-17:47:13.186548 7f272b7fe6c0 Manual compaction at level-0 from '!actors!4ZjFZ1HoJV9mJStt' @ 72057594037927935 : 1 .. '!actors!zVpmacwoWEG8YTCQ' @ 0 : 0; will stop at (end)
|
2026/05/06-22:30:22.780198 7fe44cfeb6c0 Level-0 table #37: 0 bytes OK
|
||||||
|
2026/05/06-22:30:22.786085 7fe44cfeb6c0 Delete type=0 #35
|
||||||
|
2026/05/06-22:30:22.798917 7fe44cfeb6c0 Manual compaction at level-0 from '!actors!4ZjFZ1HoJV9mJStt' @ 72057594037927935 : 1 .. '!actors!zVpmacwoWEG8YTCQ' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
MANIFEST-000006
|
MANIFEST-000035
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
2026/04/27-20:01:11.404989 7fed937fe6c0 Recovering log #4
|
2026/05/06-22:32:13.378942 7fe44dfed6c0 Recovering log #33
|
||||||
2026/04/27-20:01:11.415714 7fed937fe6c0 Delete type=3 #2
|
2026/05/06-22:32:13.388592 7fe44dfed6c0 Delete type=3 #31
|
||||||
2026/04/27-20:01:11.415769 7fed937fe6c0 Delete type=0 #4
|
2026/05/06-22:32:13.388647 7fe44dfed6c0 Delete type=0 #33
|
||||||
|
2026/05/06-22:37:23.624309 7fe44cfeb6c0 Level-0 table #38: started
|
||||||
|
2026/05/06-22:37:23.624349 7fe44cfeb6c0 Level-0 table #38: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.630330 7fe44cfeb6c0 Delete type=0 #36
|
||||||
|
2026/05/06-22:37:23.643593 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
2026/04/27-17:47:13.202702 7f27793fe6c0 Delete type=3 #1
|
2026/05/06-22:24:45.774814 7fe44e7ee6c0 Recovering log #29
|
||||||
2026/04/27-17:47:13.203697 7f272b7fe6c0 Level-0 table #5: started
|
2026/05/06-22:24:45.790576 7fe44e7ee6c0 Delete type=3 #27
|
||||||
2026/04/27-17:47:13.207185 7f272b7fe6c0 Level-0 table #5: 4830 bytes OK
|
2026/05/06-22:24:45.790660 7fe44e7ee6c0 Delete type=0 #29
|
||||||
2026/04/27-17:47:13.213948 7f272b7fe6c0 Delete type=0 #3
|
2026/05/06-22:30:22.748887 7fe44cfeb6c0 Level-0 table #34: started
|
||||||
2026/04/27-17:47:13.214169 7f272b7fe6c0 Manual compaction at level-0 from '!items!DC2kimCi9sWxqhXG' @ 72057594037927935 : 1 .. '!items!qzfAEhmvVxEMzm0k' @ 0 : 0; will stop at (end)
|
2026/05/06-22:30:22.748914 7fe44cfeb6c0 Level-0 table #34: 0 bytes OK
|
||||||
|
2026/05/06-22:30:22.754926 7fe44cfeb6c0 Delete type=0 #32
|
||||||
|
2026/05/06-22:30:22.773735 7fe44cfeb6c0 Manual compaction at level-0 from 'undefined' @ 72057594037927935 : 1 .. 'undefined' @ 0 : 0; will stop at (end)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
MANIFEST-000010
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
2026/05/06-22:32:13.440388 7fe44dfed6c0 Recovering log #8
|
||||||
|
2026/05/06-22:32:13.451363 7fe44dfed6c0 Delete type=3 #6
|
||||||
|
2026/05/06-22:32:13.451431 7fe44dfed6c0 Delete type=0 #8
|
||||||
|
2026/05/06-22:37:23.650643 7fe44cfeb6c0 Level-0 table #13: started
|
||||||
|
2026/05/06-22:37:23.650683 7fe44cfeb6c0 Level-0 table #13: 0 bytes OK
|
||||||
|
2026/05/06-22:37:23.656717 7fe44cfeb6c0 Delete type=0 #11
|
||||||
|
2026/05/06-22:37:23.669680 7fe44cfeb6c0 Manual compaction at level-0 from '!scenes!2C6gyZpvPxWlsVZi' @ 72057594037927935 : 1 .. '!scenes.levels!olYe9bhuXwRWQ8j7.defaultLevel0000' @ 0 : 0; will stop at (end)
|
||||||
|
2026/05/06-22:37:23.669713 7fe44cfeb6c0 Manual compaction at level-1 from '!scenes!2C6gyZpvPxWlsVZi' @ 72057594037927935 : 1 .. '!scenes.levels!olYe9bhuXwRWQ8j7.defaultLevel0000' @ 0 : 0; will stop at (end)
|
||||||