Import des persos du système précédent

This commit is contained in:
2026-05-06 21:31:03 +02:00
parent fbfb265570
commit 73a3381d2a
14 changed files with 3296 additions and 3 deletions
+212
View File
@@ -4188,3 +4188,215 @@ ol.item-list li.item .item-controls a.item-control:hover {
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;
}
+242
View File
@@ -4344,3 +4344,245 @@ ol.item-list {
from { transform-origin: var(--fx) var(--fy); transform: rotate(0deg); }
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;
}
+448
View File
@@ -134,8 +134,456 @@ var TEMPLATE_PARTIALS = [
"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
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", {
scope: "world",
config: false,
+3 -3
View File
File diff suppressed because one or more lines are too long
+17
View File
@@ -131,6 +131,23 @@
"CDE.InitiativeNPCSpeciality": "Première action (Aptitude) que vous escomptez effectuer",
"CDE.InitiativeRoll": "Jet d'initiative",
"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.InitiativeWheelOpen": "Ouvrir la Roue d'Initiative",
"CDE.InitiativeWheelHint": "Roue d'initiative Les Chroniques de l'Étrange",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,165 @@
{
"folder": "sRyI5qmuFAzCdgGq",
"name": "Lok Zi-Jyun",
"type": "npc",
"img": "https://assets.forge-vtt.com/630dd9fa56bd61d804eb1dec/tokenizer/CdE/lok_zi_jyun.Avatar.webp?1710026739974",
"system": {
"type": "",
"levelofthreat": 0,
"powerofnuisance": 0,
"initiative": 1,
"anti_initiative": 24,
"aptitudes": {
"physical": {
"value": 2,
"speciality": ""
},
"martial": {
"value": 3,
"speciality": ""
},
"mental": {
"value": 3,
"speciality": "Enquête"
},
"social": {
"value": 2,
"speciality": "Interrogatoire"
},
"spiritual": {
"value": 1,
"speciality": ""
}
},
"vitality": {
"value": 8,
"calcul": 0,
"note": ""
},
"hei": {
"value": 4,
"calcul": 0,
"note": ""
},
"description": "",
"threat": "0",
"nuisance": "0"
},
"prototypeToken": {
"name": "Lok Zi-Jyun",
"displayName": 0,
"actorLink": false,
"appendNumber": false,
"prependAdjective": false,
"texture": {
"src": "https://assets.forge-vtt.com/630dd9fa56bd61d804eb1dec/tokenizer/CdE/lok_zi_jyun.Token.webp?1710026739974",
"scaleX": 1,
"scaleY": 1,
"offsetX": 0,
"offsetY": 0,
"rotation": 0,
"tint": "#ffffff",
"anchorX": 0.5,
"anchorY": 0.5,
"fit": "contain",
"alphaThreshold": 0.75
},
"width": 1,
"height": 1,
"lockRotation": false,
"rotation": 0,
"alpha": 1,
"disposition": 1,
"displayBars": 0,
"bar1": {
"attribute": null
},
"bar2": {
"attribute": null
},
"light": {
"alpha": 0.5,
"angle": 360,
"bright": 0,
"coloration": 1,
"dim": 0,
"attenuation": 0.5,
"luminosity": 0.5,
"saturation": 0,
"contrast": 0,
"shadows": 0,
"animation": {
"type": null,
"speed": 5,
"intensity": 5,
"reverse": false
},
"darkness": {
"min": 0,
"max": 1
},
"color": null,
"negative": false,
"priority": 0
},
"sight": {
"enabled": true,
"range": 30,
"angle": 360,
"visionMode": "basic",
"color": null,
"attenuation": 0.1,
"brightness": 0,
"saturation": 0,
"contrast": 0
},
"detectionModes": [],
"flags": {},
"randomImg": false,
"occludable": {
"radius": 0
},
"ring": {
"enabled": false,
"colors": {
"ring": null,
"background": null
},
"effects": 1,
"subject": {
"scale": 1,
"texture": null
}
},
"turnMarker": {
"mode": 1,
"animation": null,
"src": null,
"disposition": false
},
"movementAction": null
},
"items": [],
"effects": [],
"flags": {},
"_stats": {
"systemId": "chroniquesdeletrange",
"systemVersion": "2.0.7",
"coreVersion": "13.351",
"createdTime": 1701299385970,
"modifiedTime": 1715368926185,
"lastModifiedBy": "oWO8UFosc6mCdtAr",
"compendiumSource": null,
"duplicateSource": null,
"exportSource": {
"worldId": "chroniques-de-letrange",
"uuid": "Actor.iqoRWF0cPvlsL9NY",
"coreVersion": "13.351",
"systemId": "chroniquesdeletrange",
"systemVersion": "2.4.0"
}
},
"ownership": {
"default": 0
}
}
@@ -0,0 +1,200 @@
{
"folder": "7Ezze35hGqYvFR27",
"name": "Sbire du démon",
"type": "npc",
"img": "https://assets.forge-vtt.com/630dd9fa56bd61d804eb1dec/tokenizer/CdE/sbire_du_d_mon.Avatar.webp?1701298726849",
"system": {
"type": "",
"levelofthreat": 0,
"powerofnuisance": 0,
"initiative": 1,
"anti_initiative": 24,
"aptitudes": {
"physical": {
"value": 3,
"speciality": ""
},
"martial": {
"value": 3,
"speciality": ""
},
"mental": {
"value": 2,
"speciality": ""
},
"social": {
"value": 2,
"speciality": ""
},
"spiritual": {
"value": 2,
"speciality": ""
}
},
"vitality": {
"value": 12,
"calcul": 0,
"note": ""
},
"hei": {
"value": 8,
"calcul": 0,
"note": ""
},
"description": "",
"threat": "0",
"nuisance": "0"
},
"prototypeToken": {
"name": "Sbire du démon",
"displayName": 0,
"actorLink": false,
"appendNumber": false,
"prependAdjective": false,
"texture": {
"src": "https://assets.forge-vtt.com/630dd9fa56bd61d804eb1dec/tokenizer/CdE/sbire_du_d_mon.Token.webp?1701298726849",
"scaleX": 1,
"scaleY": 1,
"offsetX": 0,
"offsetY": 0,
"rotation": 0,
"anchorX": 0.5,
"anchorY": 0.5,
"fit": "contain",
"tint": "#ffffff",
"alphaThreshold": 0.75
},
"width": 1,
"height": 1,
"lockRotation": false,
"rotation": 0,
"alpha": 1,
"disposition": -1,
"displayBars": 0,
"bar1": {
"attribute": null
},
"bar2": {
"attribute": null
},
"light": {
"alpha": 0.5,
"angle": 360,
"bright": 0,
"coloration": 1,
"dim": 0,
"attenuation": 0.5,
"luminosity": 0.5,
"saturation": 0,
"contrast": 0,
"shadows": 0,
"animation": {
"type": null,
"speed": 5,
"intensity": 5,
"reverse": false
},
"darkness": {
"min": 0,
"max": 1
},
"negative": false,
"priority": 0,
"color": null
},
"sight": {
"enabled": true,
"range": 30,
"angle": 360,
"visionMode": "basic",
"color": null,
"attenuation": 0.1,
"brightness": 0,
"saturation": 0,
"contrast": 0
},
"detectionModes": [],
"flags": {},
"randomImg": false,
"occludable": {
"radius": 0
},
"ring": {
"enabled": false,
"colors": {
"ring": null,
"background": null
},
"effects": 1,
"subject": {
"scale": 1,
"texture": null
}
},
"turnMarker": {
"mode": 1,
"animation": null,
"src": null,
"disposition": false
},
"movementAction": null
},
"items": [
{
"name": "Arme Crocs (3)",
"type": "supernatural",
"img": "https://assets.forge-vtt.com/630dd9fa56bd61d804eb1dec/systems/chroniquesdeletrange/images/capacites-surnaturelles/ki-rin.png",
"system": {
"reference": "LdB p. 350",
"description": "<h1>Arme</h1><p><span class=\"fontstyle0\">Bien des créatures disposent de cette Capacité surnaturelle, sous une forme ou une autre. Cornes, griffes, crocs, excroissances osseuses, flammes naturelles, projection de venin, etc., ces armes peuvent occasionner des dommages importants durant un affrontement.</span></p><p><span class=\"fontstyle0\">La valeur de larme sajoute aux dégâts que le </span><span class=\"fontstyle2\"><em>jiugwaai</em> </span><span class=\"fontstyle0\">inflige par son attaque. Selon la nature de larme, lattaque peut se faire au corps à corps (des griffes ou une aura glacée) ou à distance (un crachat acide ou un éclair) auquel cas la portée est indiquée.</span></p>",
"notes": "<h1>Vos notes…</h1>",
"supernatural": {
"reference": ""
}
},
"effects": [],
"folder": "Pd8AxjsdV0FjAUub",
"flags": {
"core": {}
},
"_stats": {
"systemId": "chroniquesdeletrange",
"systemVersion": "0.1.3",
"coreVersion": "13.351",
"createdTime": 1700491358365,
"modifiedTime": 1701298759309,
"lastModifiedBy": "oWO8UFosc6mCdtAr",
"compendiumSource": "Compendium.chroniquesdeletrange.capacites-surnaturelles.Item.As0zz39FZdgFTCUS",
"duplicateSource": null,
"exportSource": null
},
"_id": "7B1TO4mhlvcpFGvs",
"sort": 0,
"ownership": {
"default": 0
}
}
],
"effects": [],
"flags": {},
"_stats": {
"systemId": "chroniquesdeletrange",
"systemVersion": "2.0.2",
"coreVersion": "13.351",
"createdTime": 1701298702582,
"modifiedTime": 1704633831462,
"lastModifiedBy": "oWO8UFosc6mCdtAr",
"compendiumSource": null,
"duplicateSource": null,
"exportSource": {
"worldId": "chroniques-de-letrange",
"uuid": "Actor.zzYpLkjrIVDAmqPZ",
"coreVersion": "13.351",
"systemId": "chroniquesdeletrange",
"systemVersion": "2.4.0"
}
},
"ownership": {
"default": 0
}
}
@@ -0,0 +1,165 @@
{
"folder": "MkYhJmOo4OfONJvM",
"name": "Sbire Ganger",
"type": "npc",
"img": "https://assets.forge-vtt.com/630dd9fa56bd61d804eb1dec/tokenizer/CdE/sbire_ganger.Avatar.webp?1710234619398",
"system": {
"type": "",
"levelofthreat": 0,
"powerofnuisance": 0,
"initiative": 1,
"anti_initiative": 24,
"aptitudes": {
"physical": {
"value": 3,
"speciality": ""
},
"martial": {
"value": 3,
"speciality": ""
},
"mental": {
"value": 2,
"speciality": ""
},
"social": {
"value": 2,
"speciality": ""
},
"spiritual": {
"value": 2,
"speciality": ""
}
},
"vitality": {
"value": 12,
"calcul": 0,
"note": ""
},
"hei": {
"value": 8,
"calcul": 0,
"note": ""
},
"description": "",
"threat": "0",
"nuisance": "0"
},
"prototypeToken": {
"name": "Sbire Ganger",
"displayName": 0,
"actorLink": false,
"appendNumber": false,
"prependAdjective": false,
"texture": {
"src": "https://assets.forge-vtt.com/630dd9fa56bd61d804eb1dec/tokenizer/CdE/sbire_ganger.Token.webp?1710234619398",
"scaleX": 1,
"scaleY": 1,
"offsetX": 0,
"offsetY": 0,
"rotation": 0,
"tint": "#ffffff",
"anchorX": 0.5,
"anchorY": 0.5,
"fit": "contain",
"alphaThreshold": 0.75
},
"width": 1,
"height": 1,
"lockRotation": false,
"rotation": 0,
"alpha": 1,
"disposition": -1,
"displayBars": 0,
"bar1": {
"attribute": null
},
"bar2": {
"attribute": null
},
"light": {
"alpha": 0.5,
"angle": 360,
"bright": 0,
"coloration": 1,
"dim": 0,
"attenuation": 0.5,
"luminosity": 0.5,
"saturation": 0,
"contrast": 0,
"shadows": 0,
"animation": {
"type": null,
"speed": 5,
"intensity": 5,
"reverse": false
},
"darkness": {
"min": 0,
"max": 1
},
"color": null,
"negative": false,
"priority": 0
},
"sight": {
"enabled": true,
"range": 30,
"angle": 360,
"visionMode": "basic",
"color": null,
"attenuation": 0.1,
"brightness": 0,
"saturation": 0,
"contrast": 0
},
"detectionModes": [],
"flags": {},
"randomImg": false,
"occludable": {
"radius": 0
},
"ring": {
"enabled": false,
"colors": {
"ring": null,
"background": null
},
"effects": 1,
"subject": {
"scale": 1,
"texture": null
}
},
"turnMarker": {
"mode": 1,
"animation": null,
"src": null,
"disposition": false
},
"movementAction": null
},
"items": [],
"effects": [],
"flags": {},
"_stats": {
"systemId": "chroniquesdeletrange",
"systemVersion": "2.0.4",
"coreVersion": "13.351",
"createdTime": 1701298966616,
"modifiedTime": 1710234617883,
"lastModifiedBy": "oWO8UFosc6mCdtAr",
"compendiumSource": null,
"duplicateSource": null,
"exportSource": {
"worldId": "chroniques-de-letrange",
"uuid": "Actor.8WAURJbqWS0tgSbj",
"coreVersion": "13.351",
"systemId": "chroniquesdeletrange",
"systemVersion": "2.4.0"
}
},
"ownership": {
"default": 0
}
}
+10
View File
@@ -12,12 +12,22 @@
*/
import { SYSTEM_ID } from "./constants.js"
import { CDEMigrationApp } from "../ui/apps/migration-app.js"
/**
* Register all world/client settings for the system.
* Called during the "init" hook before sheets and data-models are set up.
*/
export 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", {
scope: "world",
config: false,
+412
View File
@@ -0,0 +1,412 @@
/**
* Chroniques de l'Étrange — Système FoundryVTT
*
* Chroniques de l'Étrange est un jeu de rôle édité par Antre-Monde Éditions.
* Ce système FoundryVTT est une implémentation indépendante et n'est pas
* affilié à Antre-Monde Éditions,
* mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
*
* @author LeRatierBretonnien
* @copyright 20242026 LeRatierBretonnien
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/
/**
* Migrates actor JSON from the legacy CDE system to the current system format.
*
* This module is pure logic — it does not interact with Foundry APIs directly.
* All transformation is done in-memory; the caller is responsible for creating
* Foundry documents from the returned data.
*/
// ── Element label → key ──────────────────────────────────────────────────────
const ELEMENT_LABEL_TO_KEY = {
"métal": "metal",
"metal": "metal",
"eau": "eau",
"terre": "terre",
"feu": "feu",
"bois": "bois",
}
/** Normalise a French element label to its system key (e.g. "Métal" → "metal"). */
function elementKey(label = "") {
return ELEMENT_LABEL_TO_KEY[label.toLowerCase().trim()] ?? "metal"
}
// ── Hei polarity label → key ──────────────────────────────────────────────────
function heiKey(label = "") {
const l = label.toLowerCase().trim()
if (l === "yin/yang" || l === "yinyang") return "yinyang"
if (l === "yang") return "yang"
return "yin"
}
// ── Spell discipline inference from speciality name ───────────────────────────
/** Maps French speciality labels (lowercase, accents stripped) → school key. */
const SPECIALITY_TO_DISCIPLINE = {
// internalcinnabar
"essence": "internalcinnabar",
"esprit": "internalcinnabar",
"mind": "internalcinnabar",
"purification": "internalcinnabar",
"manipulation": "internalcinnabar",
"aura": "internalcinnabar",
// alchemy
"acupuncture": "alchemy",
"élixirs": "alchemy",
"elixirs": "alchemy",
"poisons": "alchemy",
"arsenal": "alchemy",
"potions": "alchemy",
// masteryoftheway
"malédiction": "masteryoftheway",
"malediction": "masteryoftheway",
"transfiguration": "masteryoftheway",
"nécromancie": "masteryoftheway",
"necromancie": "masteryoftheway",
"contrôle climatique": "masteryoftheway",
"controle climatique": "masteryoftheway",
"magie d'or": "masteryoftheway",
"magie dor": "masteryoftheway",
// exorcism
"invocation": "exorcism",
"pistage": "exorcism",
"traçage": "exorcism",
"tracage": "exorcism",
"protection": "exorcism",
"châtiment": "exorcism",
"chatiment": "exorcism",
"domination": "exorcism",
// geomancy
"neutralisation": "geomancy",
"divination": "geomancy",
"prière terrestre": "geomancy",
"priere terrestre": "geomancy",
"prière céleste": "geomancy",
"priere celeste": "geomancy",
"géomancie": "geomancy",
"geomancie": "geomancy",
"feng shui": "geomancy",
"fungseoi": "geomancy",
}
/**
* Attempt to infer the magic school (discipline) from a spell's speciality name.
* Falls back to scanning the item name for school keywords if needed.
*/
function inferDiscipline(specialityName = "", itemName = "") {
const key = specialityName.toLowerCase().trim()
if (SPECIALITY_TO_DISCIPLINE[key]) return SPECIALITY_TO_DISCIPLINE[key]
// Fuzzy fallback: check item name for school markers
const name = itemName.toLowerCase()
if (name.includes("exorcis")) return "exorcism"
if (name.includes("géomanci") || name.includes("geomanci")) return "geomancy"
if (name.includes("alchimi")) return "alchemy"
if (name.includes("cinnabre") || name.includes("interne")) return "internalcinnabar"
if (name.includes("maîtrise") || name.includes("maitrise") || name.includes("tao")) return "masteryoftheway"
return "internalcinnabar"
}
// ── KungFu activation mapping ─────────────────────────────────────────────────
function mapActivation(oldActivation = "") {
const s = oldActivation.toLowerCase()
if (s.includes("infligés") || s.includes("infliges")) return "damage-inflicted"
if (s.includes("reçus") || s.includes("recus")) return "damage-received"
if (s.includes("réaction") || s.includes("reaction")) return "reaction"
if (s.includes("dés-fastes") || s.includes("des-fastes") || s.includes("fastes")) return "dice"
if (s.includes("aide")) return "action-aid"
if (s.includes("attaque") && s.includes("défense")) 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éfense") || s.includes("defense")) return "action-defense"
return "action-attack"
}
// ── Helpers ───────────────────────────────────────────────────────────────────
const DEFAULT_ACTOR_IMG = "icons/svg/mystery-man.svg"
const DEFAULT_ITEM_IMG = "icons/svg/item-bag.svg"
// ── Item migration ────────────────────────────────────────────────────────────
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 ?? {}
// Old system stored a nested `supernatural: { reference }` — prefer that reference if top-level is empty
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:
// Unknown item type: keep as generic equipment
return migrateEquipmentItem({ ...oldItem, type: "item" })
}
}
// ── Actor migration ───────────────────────────────────────────────────────────
function migrateCharacter(old) {
const s = old.system ?? {}
// aspects: keep only { chinese, label, value }
const aspect = {}
for (const [k, v] of Object.entries(s.aspect ?? {})) {
aspect[k] = { chinese: v.chinese ?? "", label: v.label ?? "", value: Number(v.value ?? 0) }
}
// skills: keep { label, specialities, value }
const skills = {}
for (const [k, v] of Object.entries(s.skills ?? {})) {
skills[k] = { label: v.label ?? "", specialities: v.specialities ?? "", value: Number(v.value ?? 0) }
}
// resources: keep { label, specialities, value, debt }
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) }
}
// components: keep { value }
const component = {}
for (const [k, v] of Object.entries(s.component ?? {})) {
component[k] = { value: v.value ?? "" }
}
// magics: keep { visible, value, speciality: { [key]: { check } } }
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 }
}
// threetreasures: strip `min` from heiyang/heiyin; keep dicelevel as-is
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) },
},
},
}
// biography (old separate field) merged into description
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),
}
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Migrate a single legacy actor JSON to the current system format.
*
* @param {object} oldJson Parsed JSON from the old system export
* @returns {{ name: string, type: string, img: string, system: object, items: object[] }}
* @throws {Error} if the actor type is unrecognised
*/
export 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}"`)
}
}
/**
* Parse one or more legacy JSON strings and return migrated actor data.
* Accepts a single actor object or an array of actor objects in one file.
*
* @param {string} jsonText Raw JSON text from a file
* @returns {Array<{ name, type, img, system, items }>}
*/
export 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)
}
+1
View File
@@ -15,3 +15,4 @@ export { CDELoksyuApp } from "./loksyu-app.js"
export { CDETinjiApp } from "./tinji-app.js"
export { updateLoksyuFromRoll, updateTinjiFromRoll } from "./singletons.js"
export { CDEWheelApp } from "./wheel-app.js"
export { CDEMigrationApp } from "./migration-app.js"
+148
View File
@@ -0,0 +1,148 @@
/**
* Chroniques de l'Étrange — Système FoundryVTT
*
* Chroniques de l'Étrange est un jeu de rôle édité par Antre-Monde Éditions.
* Ce système FoundryVTT est une implémentation indépendante et n'est pas
* affilié à Antre-Monde Éditions,
* mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
*
* @author LeRatierBretonnien
* @copyright 20242026 LeRatierBretonnien
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/
import { parseLegacyJson } from "../../migration/migrator.js"
const MIGRATION_TEMPLATE = "systems/fvtt-chroniques-de-l-etrange/templates/apps/cde-migration-app.html"
/**
* Dialog for importing legacy CDE actor JSON files into the current system.
*
* Accessible via the System Settings menu (registerMenu).
* Supports multi-file selection and shows a preview table before importing.
*/
export 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
// Avoid duplicates by 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 })
)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
{{! Migration Dialog — Chroniques de l'Étrange }}
<div class="cde-migration-body">
{{! Drop zone / file picker }}
<div class="cde-migration-drop-zone">
<i class="fa-solid fa-file-import cde-migration-drop-icon"></i>
<p class="cde-migration-drop-hint">{{ localize "CDE.MigrationDropHint" }}</p>
<label class="cde-migration-file-btn">
<i class="fa-solid fa-folder-open"></i>
{{ localize "CDE.MigrationChooseFiles" }}
<input type="file" class="cde-migration-file-input" accept=".json" multiple hidden>
</label>
</div>
{{! Preview table }}
{{#if hasPending}}
<div class="cde-migration-preview">
<div class="cde-migration-preview-header">
<span>{{ localize "CDE.MigrationPreviewTitle" }}</span>
<button type="button" class="cde-migration-clear-btn" data-action="clearFiles">
<i class="fa-solid fa-xmark"></i>
{{ localize "CDE.MigrationClear" }}
</button>
</div>
<table class="cde-migration-table">
<thead>
<tr>
<th></th>
<th>{{ localize "CDE.MigrationColName" }}</th>
<th>{{ localize "CDE.MigrationColType" }}</th>
<th>{{ localize "CDE.MigrationColItems" }}</th>
<th>{{ localize "CDE.MigrationColFile" }}</th>
</tr>
</thead>
<tbody>
{{#each pending}}
<tr>
<td><img src="{{img}}" class="cde-migration-thumb" alt=""></td>
<td class="cde-migration-name">{{name}}</td>
<td>
<span class="cde-migration-type-badge cde-migration-type-{{type}}">
{{#if (eq type "character")}}
<i class="fa-solid fa-user"></i> Personnage
{{else}}
<i class="fa-solid fa-skull"></i> PNJ
{{/if}}
</span>
</td>
<td class="cde-migration-items-count">{{items.length}}</td>
<td class="cde-migration-srcfile">{{_srcFile}}</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{! Errors }}
{{#if hasErrors}}
<ul class="cde-migration-errors">
{{#each errors}}
<li><i class="fa-solid fa-triangle-exclamation"></i> {{this}}</li>
{{/each}}
</ul>
{{/if}}
{{! Action bar }}
<div class="cde-migration-actions">
{{#if hasPending}}
<button type="button" class="cde-migration-import-btn" data-action="doImport">
<i class="fa-solid fa-download"></i>
{{ localize "CDE.MigrationImport" }} ({{count}})
</button>
{{else}}
<p class="cde-migration-hint">{{ localize "CDE.MigrationHint" }}</p>
{{/if}}
</div>
</div>