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
+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 })
)
}
}
}