diff --git a/CHANGELOG.md b/CHANGELOG.md index 611c682..eda5a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.1 - Initiative first ! +## 1.1.0 - Initiative first ! - Added initiative system : - Now use the score rule (the real one if you prefer) - Added global modifiers for Characters, Adversary and Minons in the combat tracker : Confrontation types, Prepared @@ -20,6 +20,7 @@ - Xp not in curriculum are now rounded up (down before) - No more automation in stats for Npc (these cheaters !) - 20Q Pushed the step3 item's limit to 20 (10 previous) +- Added System migration stuff ## 1.0.0 - First public release - Removed the 0ds if no skill point diff --git a/system/scripts/actor.js b/system/scripts/actor.js index 4151d01..c0f8799 100644 --- a/system/scripts/actor.js +++ b/system/scripts/actor.js @@ -96,12 +96,6 @@ export class ActorL5r5e extends Actor { if (data.void_points.value > data.void_points.max) { data.void_points.value = data.void_points.max; } - - // *** Migration stuff *** - // TODO remove in patch 1.1+ - if (data.prepared === undefined) { - data.prepared = true; - } } } } diff --git a/system/scripts/actors/twenty-questions.js b/system/scripts/actors/twenty-questions.js index cb9be25..371ee93 100644 --- a/system/scripts/actors/twenty-questions.js +++ b/system/scripts/actors/twenty-questions.js @@ -184,7 +184,7 @@ export class TwentyQuestions { const actorDatas = actor.data.data; // already 20q struct ? - if (actorDatas.twenty_questions?.step1?.clan) { + if (!isObjectEmpty(actorDatas.twenty_questions)) { this.data = { ...this.data, ...actorDatas.twenty_questions, @@ -216,6 +216,8 @@ export class TwentyQuestions { const actorDatas = actor.data.data; const formData = this.data; + this.data.generated = true; + const status = parseInt(formData.step1.social_status) + parseInt(formData.step18.heritage_add_status); const glory = diff --git a/system/scripts/handlebars.js b/system/scripts/handlebars.js new file mode 100644 index 0000000..15e191d --- /dev/null +++ b/system/scripts/handlebars.js @@ -0,0 +1,99 @@ +/** + * Custom Handlebars for L5R5e + */ +export const RegisterHandlebars = function () { + /* ------------------------------------ */ + /* Localizations */ + /* ------------------------------------ */ + Handlebars.registerHelper("localizeSkill", function (categoryId, skillId) { + const key = "l5r5e.skills." + categoryId.toLowerCase() + "." + skillId.toLowerCase(); + return game.i18n.localize(key); + }); + + Handlebars.registerHelper("localizeSkillId", function (skillId) { + const key = "l5r5e.skills." + CONFIG.l5r5e.skills.get(skillId.toLowerCase()) + "." + skillId.toLowerCase(); + return game.i18n.localize(key); + }); + + Handlebars.registerHelper("localizeRing", function (ringId) { + const key = "l5r5e.rings." + ringId.toLowerCase(); + return game.i18n.localize(key); + }); + + Handlebars.registerHelper("localizeStanceTip", function (ringId) { + const key = "l5r5e.conflict.stances." + ringId.toLowerCase() + "tip"; + return game.i18n.localize(key); + }); + + Handlebars.registerHelper("localizeTechnique", function (techniqueName) { + return game.i18n.localize("l5r5e.techniques." + techniqueName.toLowerCase()); + }); + + /* ------------------------------------ */ + /* Utility */ + /* ------------------------------------ */ + /** + * Json - Display a object in textarea (for debug) + */ + Handlebars.registerHelper("json", function (...objects) { + objects.pop(); // remove this function call + return new Handlebars.SafeString(objects.map((e) => ``)); + }); + + /** + * Add props "checked" if a and b are equal ({{radioChecked a b}} + */ + Handlebars.registerHelper("radioChecked", function (a, b) { + return a === b ? new Handlebars.SafeString('checked="checked"') : ""; + }); + + /** + * Utility conditional, usable in nested expression + * {{#ifCond (ifCond advancement.type '==' 'technique') '||' (ifCond item.data.technique_type '==' 'kata')}} + * {{#ifCond '["distinction","passion"]' 'includes' item.data.peculiarity_type}} + */ + Handlebars.registerHelper("ifCond", function (a, operator, b, options) { + let result = false; + switch (operator) { + case "==": + result = a == b; + break; + case "===": + result = a === b; + break; + case "!=": + result = a != b; + break; + case "!==": + result = a !== b; + break; + case "<": + result = a < b; + break; + case "<=": + result = a <= b; + break; + case ">": + result = a > b; + break; + case ">=": + result = a >= b; + break; + case "&&": + result = a && b; + break; + case "||": + result = a || b; + break; + case "includes": + result = a && b && a.includes(b); + break; + default: + break; + } + if (typeof options.fn === "function") { + return result ? options.fn(this) : options.inverse(this); + } + return result; + }); +}; diff --git a/system/scripts/hooks.js b/system/scripts/hooks.js index 87a607c..5d29337 100644 --- a/system/scripts/hooks.js +++ b/system/scripts/hooks.js @@ -21,6 +21,11 @@ export default class HooksL5r5e { * Do anything once the system is ready */ static ready() { + // Migration stuff + if (game.l5r5e.migrations.needUpdate()) { + game.l5r5e.migrations.migrateWorld(); + } + // Settings TN and EncounterType if (game.user.isGM) { new game.l5r5e.GmToolsDialog().render(true); diff --git a/system/scripts/main-l5r5e.js b/system/scripts/main-l5r5e.js index 18e3753..a9df18e 100644 --- a/system/scripts/main-l5r5e.js +++ b/system/scripts/main-l5r5e.js @@ -4,6 +4,7 @@ import { HelpersL5r5e } from "./helpers.js"; import { SocketHandlerL5r5e } from "./socket-handler.js"; import { RegisterSettings } from "./settings.js"; import { PreloadTemplates } from "./preloadTemplates.js"; +import { RegisterHandlebars } from "./handlebars.js"; import { HelpDialog } from "./help/help-dialog.js"; import HooksL5r5e from "./hooks.js"; // Actors @@ -31,11 +32,13 @@ import { PeculiaritySheetL5r5e } from "./items/peculiarity-sheet.js"; // JournalEntry import { JournalL5r5e } from "./journal.js"; import { BaseJournalSheetL5r5e } from "./journals/base-journal-sheet.js"; +// Specific +import { MigrationL5r5e } from "./migration.js"; /* ------------------------------------ */ /* Initialize system */ /* ------------------------------------ */ -Hooks.once("init", async function () { +Hooks.once("init", async () => { // ***** Initializing l5r5e ***** // Ascii art :p console.log( @@ -79,11 +82,15 @@ Hooks.once("init", async function () { ActorL5r5e, HelpDialog, sockets: new SocketHandlerL5r5e(), + migrations: MigrationL5r5e, }; // Register custom system settings RegisterSettings(); + // Register custom Handlebars Helpers + RegisterHandlebars(); + // Preload Handlebars templates await PreloadTemplates(); @@ -106,89 +113,6 @@ Hooks.once("init", async function () { // Journal Items.unregisterSheet("core", JournalSheet); Items.registerSheet("l5r5e", BaseJournalSheetL5r5e, { makeDefault: true }); - - // ***** Handlebars ***** - // for debug - Handlebars.registerHelper("json", function (...objects) { - objects.pop(); // remove this function call - return new Handlebars.SafeString(objects.map((e) => ``)); - }); - - // Add props "checked" if a and b are equal ({{radioChecked a b}} - Handlebars.registerHelper("radioChecked", function (a, b) { - return a === b ? new Handlebars.SafeString('checked="checked"') : ""; - }); - - Handlebars.registerHelper("localizeSkill", function (categoryId, skillId) { - const key = "l5r5e.skills." + categoryId.toLowerCase() + "." + skillId.toLowerCase(); - return game.i18n.localize(key); - }); - Handlebars.registerHelper("localizeSkillId", function (skillId) { - const key = "l5r5e.skills." + L5R5E.skills.get(skillId.toLowerCase()) + "." + skillId.toLowerCase(); - return game.i18n.localize(key); - }); - - Handlebars.registerHelper("localizeRing", function (ringId) { - const key = "l5r5e.rings." + ringId.toLowerCase(); - return game.i18n.localize(key); - }); - - Handlebars.registerHelper("localizeStanceTip", function (ringId) { - const key = "l5r5e.conflict.stances." + ringId.toLowerCase() + "tip"; - return game.i18n.localize(key); - }); - - Handlebars.registerHelper("localizeTechnique", function (techniqueName) { - return game.i18n.localize("l5r5e.techniques." + techniqueName.toLowerCase()); - }); - - // Utility conditional, usable in nested expression - // {{#ifCond (ifCond advancement.type '==' 'technique') '||' (ifCond item.data.technique_type '==' 'kata')}} - // {{#ifCond '["distinction","passion"]' 'includes' item.data.peculiarity_type}} - Handlebars.registerHelper("ifCond", function (a, operator, b, options) { - let result = false; - switch (operator) { - case "==": - result = a == b; - break; - case "===": - result = a === b; - break; - case "!=": - result = a != b; - break; - case "!==": - result = a !== b; - break; - case "<": - result = a < b; - break; - case "<=": - result = a <= b; - break; - case ">": - result = a > b; - break; - case ">=": - result = a >= b; - break; - case "&&": - result = a && b; - break; - case "||": - result = a || b; - break; - case "includes": - result = a && b && a.includes(b); - break; - default: - break; - } - if (typeof options.fn === "function") { - return result ? options.fn(this) : options.inverse(this); - } - return result; - }); }); /* ------------------------------------ */ diff --git a/system/scripts/migration.js b/system/scripts/migration.js new file mode 100644 index 0000000..d77236a --- /dev/null +++ b/system/scripts/migration.js @@ -0,0 +1,227 @@ +/** + * L5R Migration class + */ +export class MigrationL5r5e { + /** + * Version needed for migration stuff to trigger + * @type {string} + */ + static NEEDED_VERSION = "1.1.0"; + + /** + * Return true if the current world need some updates + * @returns {boolean} + */ + static needUpdate() { + const currentVersion = game.settings.get("l5r5e", "systemMigrationVersion"); + return currentVersion && isNewerVersion(MigrationL5r5e.NEEDED_VERSION, currentVersion); + } + + /** + * Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs + * @return {Promise} A Promise which resolves once the migration is completed + */ + static async migrateWorld() { + if (!game.user.isGM) { + return; + } + + ui.notifications.info( + `Applying L5R5e System Migration for version ${game.system.data.version}.` + + ` Please be patient and do not close your game or shut down your server.`, + { permanent: true } + ); + + // Migrate World Actors + for (let a of game.actors.entities) { + try { + const updateData = MigrationL5r5e._migrateActorData(a.data); + if (!isObjectEmpty(updateData)) { + console.log(`Migrating Actor entity ${a.name}`); + await a.update(updateData, { enforceTypes: false }); + } + } catch (err) { + err.message = `Failed L5R5e system migration for Actor ${a.name}: ${err.message}`; + console.error(err); + } + } + + // Migrate World Items + for (let i of game.items.entities) { + try { + const updateData = MigrationL5r5e._migrateItemData(i.data); + if (!isObjectEmpty(updateData)) { + console.log(`Migrating Item entity ${i.name}`); + await i.update(updateData, { enforceTypes: false }); + } + } catch (err) { + err.message = `Failed L5R5e system migration for Item ${i.name}: ${err.message}`; + console.error(err); + } + } + + // Migrate Actor Override Tokens + for (let s of game.scenes.entities) { + try { + const updateData = MigrationL5r5e._migrateSceneData(s.data); + if (!isObjectEmpty(updateData)) { + console.log(`Migrating Scene entity ${s.name}`); + await s.update(updateData, { enforceTypes: false }); + } + } catch (err) { + err.message = `Failed L5R5e system migration for Scene ${s.name}: ${err.message}`; + console.error(err); + } + } + + // Migrate World Compendium Packs + for (let p of game.packs) { + if (p.metadata.package !== "world") { + continue; + } + if (!["Actor", "Item", "Scene"].includes(p.metadata.entity)) { + continue; + } + await MigrationL5r5e._migrateCompendium(p); + } + + // Set the migration as complete + await game.settings.set("l5r5e", "systemMigrationVersion", game.system.data.version); + ui.notifications.info(`L5R5e System Migration to version ${game.system.data.version} completed!`, { + permanent: true, + }); + } + + /** + * Apply migration rules to all Entities within a single Compendium pack + * @param pack + * @return {Promise} + */ + static async _migrateCompendium(pack) { + const entity = pack.metadata.entity; + if (!["Actor", "Item", "Scene"].includes(entity)) { + return; + } + + // Unlock the pack for editing + const wasLocked = pack.locked; + await pack.configure({ locked: false }); + + // Begin by requesting server-side data model migration and get the migrated content + await pack.migrate(); + const content = await pack.getContent(); + + // Iterate over compendium entries - applying fine-tuned migration functions + for (let ent of content) { + let updateData = {}; + try { + switch (entity) { + case "Actor": + updateData = MigrationL5r5e._migrateActorData(ent.data); + break; + case "Item": + updateData = MigrationL5r5e._migrateItemData(ent.data); + break; + case "Scene": + updateData = MigrationL5r5e._migrateSceneData(ent.data); + break; + } + if (isObjectEmpty(updateData)) { + continue; + } + + // Save the entry, if data was changed + updateData["_id"] = ent._id; + await pack.updateEntity(updateData); + console.log(`Migrated ${entity} entity ${ent.name} in Compendium ${pack.collection}`); + } catch (err) { + // Handle migration failures + err.message = `Failed L5R5e system migration for entity ${ent.name} in pack ${pack.collection}: ${err.message}`; + console.error(err); + } + } + + // Apply the original locked status for the pack + pack.configure({ locked: wasLocked }); + console.log(`Migrated all ${entity} entities from Compendium ${pack.collection}`); + } + + /** + * Migrate a single Scene entity to incorporate changes to the data model of it's actor data overrides + * Return an Object of updateData to be applied + * @param {Object} scene The Scene data to Update + * @return {Object} The updateData to apply + */ + static _migrateSceneData(scene) { + const tokens = duplicate(scene.tokens); + return { + tokens: tokens.map((t) => { + if (!t.actorId || t.actorLink || !t.actorData.data) { + t.actorData = {}; + return t; + } + const token = new Token(t); + if (!token.actor) { + t.actorId = null; + t.actorData = {}; + } else if (!t.actorLink) { + const updateData = MigrationL5r5e._migrateActorData(token.data.actorData); + t.actorData = mergeObject(token.data.actorData, updateData); + } + return t; + }), + }; + } + + /** + * Migrate a single Actor entity to incorporate latest data model changes + * Return an Object of updateData to be applied + * @param {Actor} actor The actor to Update + * @return {Object} The updateData to apply + */ + static _migrateActorData(actor) { + const updateData = {}; + const actorData = actor.data; + + // ***** Start of 1.1.0 ***** + // Add "Prepared" in actor + if (actorData.prepared === undefined) { + updateData["data.prepared"] = true; + } + + // NPC are now without autostats, we need to save the value + if (actor.type === "npc") { + if (actorData.endurance < 1) { + updateData["data.endurance"] = (Number(actorData.rings.earth) + Number(actorData.rings.fire)) * 2; + updateData["data.composure"] = (Number(actorData.rings.earth) + Number(actorData.rings.water)) * 2; + updateData["data.focus"] = Number(actorData.rings.air) + Number(actorData.rings.fire); + updateData["data.vigilance"] = Math.ceil( + (Number(actorData.rings.air) + Number(actorData.rings.water)) / 2 + ); + } + } + // ***** End of 1.1.0 ***** + + return updateData; + } + + /** + * Scrub an Actor's system data, removing all keys which are not explicitly defined in the system template + * @param {Object} actorData The data object for an Actor + * @return {Object} The scrubbed Actor data + */ + static cleanActorData(actorData) { + const model = game.system.model.Actor[actorData.type]; + actorData.data = filterObject(actorData.data, model); + return actorData; + } + + /** + * Migrate a single Item entity to incorporate latest data model changes + * @param item + */ + static _migrateItemData(item) { + // Nothing for now + return {}; + } +} diff --git a/system/scripts/settings.js b/system/scripts/settings.js index 141adbb..ea58180 100644 --- a/system/scripts/settings.js +++ b/system/scripts/settings.js @@ -2,9 +2,20 @@ * Custom system settings register */ export const RegisterSettings = function () { - /** - * Settings set by Initiative Roll Dialog (GM only) - */ + /* ------------------------------------ */ + /* Update */ + /* ------------------------------------ */ + game.settings.register("l5r5e", "systemMigrationVersion", { + name: "System Migration Version", + scope: "world", + config: false, + type: String, + default: 0, + }); + + /* ------------------------------------ */ + /* Initiative Roll Dialog (GM only) */ + /* ------------------------------------ */ game.settings.register("l5r5e", "initiative.difficulty.hidden", { name: "Initiative difficulty is hidden", scope: "world", diff --git a/system/system.json b/system/system.json index af34ec3..8252528 100644 --- a/system/system.json +++ b/system/system.json @@ -2,7 +2,7 @@ "name": "l5r5e", "title": "Legend of the Five Rings (5th Edition)", "description": "This is a game system, multilingual in En/FR/ES, for Legend of the Five Rings (5th Edition) by Edge Studio
- Join the official Discord server: Official Discord
- Rejoignez la communauté Francophone: Francophone Discord
", - "version": "1.0.0", + "version": "1.1.0", "minimumCoreVersion": "0.7.9", "compatibleCoreVersion": "0.7.9", "manifestPlusVersion": "1.0.0", @@ -159,5 +159,5 @@ ], "url": "https://gitlab.com/teaml5r/l5r5e", "manifest": "https://gitlab.com/teaml5r/l5r5e/-/raw/master/system/system.json", - "download": "https://gitlab.com/teaml5r/l5r5e/-/jobs/artifacts/v1.0.0/raw/l5r5e.zip?job=build" + "download": "https://gitlab.com/teaml5r/l5r5e/-/jobs/artifacts/v1.1.0/raw/l5r5e.zip?job=build" }