diff --git a/src/global.ts b/src/global.ts index 0d0284f5877..44d90965306 100644 --- a/src/global.ts +++ b/src/global.ts @@ -39,6 +39,7 @@ import type { ChatMessagePF2e } from "@module/chat-message/index.ts"; import type { ActorsPF2e } from "@module/collection/actors.ts"; import type { CombatantPF2e, EncounterPF2e } from "@module/encounter/index.ts"; import type { MacroPF2e } from "@module/macro.ts"; +import { MigrationRunner } from "@module/migration/index.ts"; import type { RuleElement, RuleElements } from "@module/rules/index.ts"; import type { UserPF2e } from "@module/user/index.ts"; import type { @@ -185,9 +186,11 @@ interface GamePF2e extends Game< xpFromEncounter: typeof xpFromEncounter; }; system: { + migrationRunner: MigrationRunner | null; moduleArt: ModuleArt; remigrate: typeof remigrate; sluggify: typeof sluggify; + worldNeedsMigration: boolean; generateItemName: (item: PhysicalItemPF2e) => string; }; variantRules: { diff --git a/src/module/actor/base.ts b/src/module/actor/base.ts index cac62eb26a1..d7d6ecd8adb 100644 --- a/src/module/actor/base.ts +++ b/src/module/actor/base.ts @@ -130,6 +130,8 @@ class ActorPF2e, options?: DocumentConstructionContext, ): this["_source"] { + // Record unpruned source data if a migration is necessary + if (game.pf2e.system.worldNeedsMigration && !this._migrationSource) { + Object.defineProperty(this, "_migrationSource", { + value: fu.deepClone(source), + writable: false, + enumerable: false, + }); + Object.seal(this._migrationSource); + } const initialized = super._initializeSource(source, options); if (options?.pack && initialized._id) { diff --git a/src/module/item/base/document.ts b/src/module/item/base/document.ts index 08d12c9bbfa..3fbe88f6cef 100644 --- a/src/module/item/base/document.ts +++ b/src/module/item/base/document.ts @@ -65,6 +65,8 @@ class ItemPF2e extends Item /** The item that granted this item, if any */ declare grantedBy: ItemPF2e | null; + declare _migrationSource?: this["_source"]; + static override getDefaultArtwork(itemData: foundry.documents.ItemSource): { img: ImageFilePath } { return { img: `systems/${SYSTEM_ID}/icons/default-icons/${itemData.type}.svg` as const }; } @@ -254,6 +256,22 @@ class ItemPF2e extends Item return this.toMessage(event, { create: true }); } + protected override _initializeSource( + data: this["_source"], + options?: foundry.abstract.DataModelConstructionContext, + ): this["_source"] { + // Record unpruned source data for world items if necessary + if (game.pf2e.system.worldNeedsMigration && !this.parent && !this._migrationSource) { + Object.defineProperty(this, "_migrationSource", { + value: fu.deepClone(data), + writable: false, + enumerable: false, + }); + Object.seal(this._migrationSource); + } + return super._initializeSource(data, options); + } + protected override _initialize(options?: Record): void { this.rules = []; this.specialOptions = []; diff --git a/src/module/migration/runner/index.ts b/src/module/migration/runner/index.ts index c77890bd745..5a329261d5b 100644 --- a/src/module/migration/runner/index.ts +++ b/src/module/migration/runner/index.ts @@ -126,7 +126,7 @@ export class MigrationRunner extends MigrationRunnerBase { } async #migrateItem(migrations: MigrationBase[], item: ItemPF2e): Promise { - const baseItem = this.#removeSpecialKeys(item.toObject()); + const baseItem = this.#removeSpecialKeys(fu.deepClone(item._migrationSource) ?? item.toObject()); try { return await this.getUpdatedItem(baseItem, migrations); @@ -144,7 +144,7 @@ export class MigrationRunner extends MigrationRunnerBase { options: { pack?: Maybe } = {}, ): Promise { const pack = options.pack; - const baseActor = this.#removeSpecialKeys(actor.toObject()); + const baseActor = this.#removeSpecialKeys(fu.deepClone(actor._migrationSource) ?? actor.toObject()); const updatedActor = await (async () => { try { diff --git a/src/scripts/hooks/ready.ts b/src/scripts/hooks/ready.ts index 5f230036e96..549d5cf0f20 100644 --- a/src/scripts/hooks/ready.ts +++ b/src/scripts/hooks/ready.ts @@ -3,7 +3,6 @@ import { resetActors } from "@actor/helpers.ts"; import { createFirstParty } from "@actor/party/helpers.ts"; import { MigrationSummary } from "@module/apps/migration-summary.ts"; import { SceneDarknessAdjuster } from "@module/apps/scene-darkness-adjuster.ts"; -import { MigrationList } from "@module/migration/index.ts"; import { MigrationRunner } from "@module/migration/runner/index.ts"; import { SetGamePF2e } from "@scripts/set-game-pf2e.ts"; import { activateSocketListener } from "@scripts/socket.ts"; @@ -50,8 +49,8 @@ export const Ready = { await createFirstParty(); // Perform migrations, if any - const migrationRunner = new MigrationRunner(MigrationList.constructFromVersion(currentVersion)); - if (migrationRunner.needsMigration()) { + const migrationRunner = game.pf2e.system.migrationRunner; + if (game.pf2e.system.worldNeedsMigration && migrationRunner) { if (currentVersion && currentVersion < MigrationRunner.MINIMUM_SAFE_VERSION) { ui.notifications.error( `Your PF2E system data is from too old a Foundry version and cannot be reliably migrated to the latest version. The process will be attempted, but errors may occur.`, @@ -61,6 +60,7 @@ export const Ready = { await migrationRunner.runMigration(); new MigrationSummary().render(true); } + game.pf2e.system.migrationRunner = null; // Update the world system version const previous = game.settings.get(SYSTEM_ID, "worldSystemVersion"); diff --git a/src/scripts/set-game-pf2e.ts b/src/scripts/set-game-pf2e.ts index ac4443539d7..413e317b0a1 100644 --- a/src/scripts/set-game-pf2e.ts +++ b/src/scripts/set-game-pf2e.ts @@ -1,6 +1,7 @@ import { Action } from "@actor/actions/index.ts"; import { AutomaticBonusProgression } from "@actor/character/automatic-bonus-progression.ts"; import { ElementalBlast } from "@actor/character/elemental-blast.ts"; +import { ActorSourcePF2e } from "@actor/data/index.ts"; import { CheckModifier, Modifier, StatisticModifier } from "@actor/modifiers.ts"; import { Coins, generateItemName } from "@item/physical/helpers.ts"; import { checkPrompt } from "@module/apps/check-prompt-generator.ts"; @@ -8,6 +9,8 @@ import { CompendiumBrowser } from "@module/apps/compendium-browser/browser.ts"; import { EffectsPanel } from "@module/apps/effects-panel.ts"; import { WorldClock } from "@module/apps/world-clock/index.ts"; import { StatusEffects } from "@module/canvas/status-effects.ts"; +import { MigrationList } from "@module/migration/index.ts"; +import { MigrationRunner } from "@module/migration/runner/index.ts"; import { RuleElement, RuleElements } from "@module/rules/index.ts"; import { DicePF2e } from "@scripts/dice.ts"; import { @@ -35,6 +38,7 @@ import { ModuleArt } from "@system/module-art.ts"; import { Predicate } from "@system/predication.ts"; import { TextEditorPF2e } from "@system/text-editor.ts"; import { sluggify } from "@util"; +import * as R from "remeda"; import { EarnIncomeDialog } from "./macros/earn-income.ts"; /** Expose public game.pf2e interface */ @@ -74,6 +78,28 @@ export const SetGamePF2e = { UNTYPED: "untyped", } as const; + // Get world schema version from game data as settings are not initalized yet + const currentVersion = ((): number => { + const storedSchemaVersion = + Number(game.data.settings.find((s) => s.key === "pf2e.worldSchemaVersion")?.value) || 0; + if (storedSchemaVersion) return storedSchemaVersion; + const minimumVersion = MigrationRunner.RECOMMENDED_SAFE_VERSION; + const actors = game.data.actors; + const getActorSchemaVersion = (actor: ActorSourcePF2e): number | null => { + const legacyValue = R.isPlainObject(actor.system.schema) + ? Number(actor.system.schema.version) || null + : null; + return Number(actor.system._migration?.version) || legacyValue; + }; + return actors.length === 0 + ? MigrationRunner.LATEST_SCHEMA_VERSION + : Math.max( + Math.min(...new Set(actors.map((a) => getActorSchemaVersion(a) ?? minimumVersion))), + minimumVersion, + ); + })(); + const migrationRunner = new MigrationRunner(MigrationList.constructFromVersion(currentVersion)); + const initSafe: Partial<(typeof game)["pf2e"]> = { Check: Check, CheckModifier, @@ -103,7 +129,14 @@ export const SetGamePF2e = { }, rollActionMacro, rollItemMacro, - system: { generateItemName, moduleArt: new ModuleArt(), remigrate, sluggify }, + system: { + generateItemName, + migrationRunner, + moduleArt: new ModuleArt(), + remigrate, + sluggify, + worldNeedsMigration: migrationRunner.needsMigration(), + }, variantRules: { AutomaticBonusProgression }, }; game.pf2e = fu.mergeObject(game.pf2e ?? {}, initSafe); diff --git a/types/foundry/client/game.d.mts b/types/foundry/client/game.d.mts index a145cebffeb..52dc9d3352a 100644 --- a/types/foundry/client/game.d.mts +++ b/types/foundry/client/game.d.mts @@ -52,6 +52,7 @@ export default class Game< macros: TMacro["_source"][]; messages: TChatMessage["_source"][]; packs: CompendiumMetadata[]; + settings: Setting["_source"][]; tables: foundry.documents.RollTableSource[]; users: TUser["_source"][]; version: string;