Initial commit
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Migration to v0.4.0 SpellData schema (numbered 0.2.0 per planning doc).
|
||||
*
|
||||
* Operations:
|
||||
* - For each Item of type 'spell' (in world & embedded on actors):
|
||||
* • If `components.symbols` is empty AND `complexityLevel > 0`,
|
||||
* leave symbols empty (cannot infer names) but log so user can fix.
|
||||
* • If `targets` matches a recognised AoE pattern, populate `areaOfEffect`.
|
||||
* • If legacy `overcasting` text is non-empty AND `overcastOptions` is empty,
|
||||
* seed `overcastOptions[0] = { description: <text>, manaPerStep: 1 }`.
|
||||
* - Add `attributes.blood = { value: 0, max: 0 }` to characters missing it
|
||||
* (DataModel default usually handles this; explicit safety net).
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
import type { MigrationStep } from './index';
|
||||
|
||||
interface LegacySpellSystem {
|
||||
targets?: string;
|
||||
overcasting?: string;
|
||||
overcastOptions?: Array<{ description: string; manaPerStep: number }>;
|
||||
areaOfEffect?: { shape: string; x: number; y: number; unit: string };
|
||||
components?: { symbols?: string[] };
|
||||
complexityLevel?: number;
|
||||
}
|
||||
|
||||
const AOE_PATTERNS: Array<{ re: RegExp; build: (m: RegExpMatchArray) => { shape: string; x: number; y: number; unit: string } }> = [
|
||||
// "3 × 8 m" / "3x8 m" → rectangle
|
||||
{ re: /(\d+)\s*[×x]\s*(\d+)\s*m/i, build: (m) => ({ shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: 'm' }) },
|
||||
// "promień 5 m" → sphere
|
||||
{ re: /promień\s+(\d+)\s*m/i, build: (m) => ({ shape: 'sphere', x: Number(m[1]), y: 0, unit: 'm' }) },
|
||||
// "stożek 10 m" → cone
|
||||
{ re: /stoż\w+\s+(\d+)\s*m/i, build: (m) => ({ shape: 'cone', x: Number(m[1]), y: 0, unit: 'm' }) },
|
||||
// "linia 15 m" → line
|
||||
{ re: /linia\s+(\d+)\s*m/i, build: (m) => ({ shape: 'line', x: Number(m[1]), y: 0, unit: 'm' }) },
|
||||
];
|
||||
|
||||
function migrateSpellDoc(doc: any): Record<string, unknown> | null {
|
||||
const sys = (doc.system ?? {}) as LegacySpellSystem;
|
||||
const updates: Record<string, unknown> = {};
|
||||
let dirty = false;
|
||||
|
||||
// AoE inference
|
||||
const isPoint = !sys.areaOfEffect || (sys.areaOfEffect.shape === 'point' && !sys.areaOfEffect.x);
|
||||
if (isPoint && sys.targets) {
|
||||
for (const { re, build } of AOE_PATTERNS) {
|
||||
const m = sys.targets.match(re);
|
||||
if (m) {
|
||||
updates['system.areaOfEffect'] = build(m);
|
||||
dirty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overcast text → structured option
|
||||
const noStructuredOvercast = !sys.overcastOptions || sys.overcastOptions.length === 0;
|
||||
if (sys.overcasting && noStructuredOvercast) {
|
||||
updates['system.overcastOptions'] = [{ description: sys.overcasting, manaPerStep: 1 }];
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// Witch symbols audit log
|
||||
if ((sys.complexityLevel ?? 0) > 0 && (!sys.components?.symbols || sys.components.symbols.length === 0)) {
|
||||
console.warn(`${SYSTEM_ID} | Spell "${doc.name}" has complexityLevel=${sys.complexityLevel} but no symbols list; please populate components.symbols manually.`);
|
||||
}
|
||||
|
||||
return dirty ? updates : null;
|
||||
}
|
||||
|
||||
async function migrateAllSpells(): Promise<void> {
|
||||
// World items
|
||||
const worldItems = (game.items?.contents ?? []) as any[];
|
||||
for (const item of worldItems) {
|
||||
if (item.type !== 'spell') continue;
|
||||
const updates = migrateSpellDoc(item);
|
||||
if (updates) await item.update(updates);
|
||||
}
|
||||
|
||||
// Actor-embedded items
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const actor of actors) {
|
||||
const items = (actor.items?.contents ?? []) as any[];
|
||||
for (const item of items) {
|
||||
if (item.type !== 'spell') continue;
|
||||
const updates = migrateSpellDoc(item);
|
||||
if (updates) await item.update(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBloodPool(): Promise<void> {
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const actor of actors) {
|
||||
if (actor.type !== 'character') continue;
|
||||
const blood = actor.system?.attributes?.blood;
|
||||
if (!blood || typeof blood.max !== 'number') {
|
||||
await actor.update({ 'system.attributes.blood': { value: 0, max: 0 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const migration_0_2_0: MigrationStep = {
|
||||
version: '0.4.0',
|
||||
description: 'Expand SpellData schema, add Blood Pool to characters',
|
||||
run: async () => {
|
||||
await migrateAllSpells();
|
||||
await ensureBloodPool();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Migration to v1.2.0.
|
||||
*
|
||||
* Operations:
|
||||
* - Set default `flags['hbm-rpg-v3'].zealRegenBonus = 0` on all character actors
|
||||
* that don't have it (so the combat-turn helper can always read a number).
|
||||
* - Backfill NPC `attributes.{mana, zeal, blood}` for any NPC actors stored
|
||||
* pre-v1.1.2 (the schema defaults handle it on read, but persist on save).
|
||||
* - Best-effort: scan world & embedded talents for descriptions matching
|
||||
* "+1 Zapał" / "regeneracja zapału" and attach a transferable ActiveEffect
|
||||
* that adds `flags.hbm-rpg-v3.zealRegenBonus = 1` (only if no AE present).
|
||||
*/
|
||||
|
||||
import type { MigrationStep } from './index';
|
||||
|
||||
const ZEAL_REGEN_PATTERNS = [
|
||||
/\+\s*1\s*zapa[łl]/i,
|
||||
/regeneracj\w+\s+zapa[łl]u/i,
|
||||
/odzyskuje\s+\+?1\s+zapa[łl]/i,
|
||||
];
|
||||
|
||||
function shouldAttachZealRegenAE(item: any): boolean {
|
||||
if (item?.type !== 'talent') return false;
|
||||
const text = `${item.system?.description ?? ''} ${item.system?.effect ?? ''}`;
|
||||
if (!text.trim()) return false;
|
||||
if (!ZEAL_REGEN_PATTERNS.some((re) => re.test(text))) return false;
|
||||
// Skip if this talent already has an AE targeting the flag.
|
||||
for (const ef of item.effects ?? []) {
|
||||
for (const ch of ef.changes ?? []) {
|
||||
if (ch.key === 'flags.hbm-rpg-v3.zealRegenBonus') return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function attachZealRegenAE(item: any): Promise<void> {
|
||||
await item.createEmbeddedDocuments('ActiveEffect', [{
|
||||
name: '+1 regeneracja Zapału',
|
||||
icon: 'icons/svg/lightning.svg',
|
||||
transfer: true,
|
||||
disabled: false,
|
||||
changes: [{
|
||||
key: 'flags.hbm-rpg-v3.zealRegenBonus',
|
||||
value: '1',
|
||||
mode: 2, // CONST.ACTIVE_EFFECT_MODES.ADD
|
||||
priority: 20,
|
||||
}],
|
||||
}]);
|
||||
}
|
||||
|
||||
async function migrateActorFlags(): Promise<void> {
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const a of actors) {
|
||||
if (a.type !== 'character') continue;
|
||||
const cur = a.getFlag?.('hbm-rpg-v3', 'zealRegenBonus');
|
||||
if (cur == null) {
|
||||
await a.setFlag('hbm-rpg-v3', 'zealRegenBonus', 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateTalents(): Promise<void> {
|
||||
// World talents
|
||||
const worldItems = (game.items?.contents ?? []) as any[];
|
||||
for (const it of worldItems) {
|
||||
if (shouldAttachZealRegenAE(it)) await attachZealRegenAE(it);
|
||||
}
|
||||
// Actor-embedded talents
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const a of actors) {
|
||||
for (const it of a.items ?? []) {
|
||||
if (shouldAttachZealRegenAE(it)) await attachZealRegenAE(it);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function backfillNpcResources(): Promise<void> {
|
||||
const actors = (game.actors?.contents ?? []) as any[];
|
||||
for (const a of actors) {
|
||||
if (a.type !== 'npc') continue;
|
||||
const sys = a.system as any;
|
||||
const update: Record<string, unknown> = {};
|
||||
if (sys.attributes?.mana == null) update['system.attributes.mana'] = { value: 0, max: 0, maxPerSpell: 0 };
|
||||
if (sys.attributes?.zeal == null) update['system.attributes.zeal'] = { value: 0, max: 0 };
|
||||
if (sys.attributes?.blood == null) update['system.attributes.blood'] = { value: 0, max: 0 };
|
||||
if (Object.keys(update).length > 0) await a.update(update);
|
||||
}
|
||||
}
|
||||
|
||||
export const migration_1_2_0: MigrationStep = {
|
||||
version: '1.2.0',
|
||||
description: 'v1.2.0: zealRegenBonus flag, NPC resource backfill, ActiveEffect attachment for +1 Zeal talents',
|
||||
async run() {
|
||||
await migrateActorFlags();
|
||||
await backfillNpcResources();
|
||||
await migrateTalents();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* World data migration runner. Compares stored `flags.hbm.lastMigratedVersion`
|
||||
* against the system version and applies any pending migrations in order.
|
||||
*
|
||||
* Each migration module exports `run(): Promise<void>` and a numeric `targetVersion`
|
||||
* (semver-style string). Migrations are idempotent.
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
import { migration_0_2_0 } from './0.2.0';
|
||||
import { migration_1_2_0 } from './1.2.0';
|
||||
|
||||
export interface MigrationStep {
|
||||
version: string; // version this migration brings the world TO
|
||||
description: string;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
const MIGRATIONS: MigrationStep[] = [
|
||||
migration_0_2_0,
|
||||
migration_1_2_0,
|
||||
];
|
||||
|
||||
function compareSemver(a: string, b: string): number {
|
||||
const pa = a.split('.').map(Number);
|
||||
const pb = b.split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const da = pa[i] ?? 0;
|
||||
const db = pb[i] ?? 0;
|
||||
if (da !== db) return da - db;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function runPendingMigrations(): Promise<void> {
|
||||
if (!game.user?.isGM) return;
|
||||
const sys = game.system as unknown as { version: string };
|
||||
const currentVersion = sys.version ?? '0.0.0';
|
||||
const last = (game.settings.get(SYSTEM_ID, 'lastMigratedVersion') as string | undefined) ?? '0.0.0';
|
||||
|
||||
if (compareSemver(last, currentVersion) >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${SYSTEM_ID} | Running migrations: ${last} → ${currentVersion}`);
|
||||
for (const step of MIGRATIONS) {
|
||||
if (compareSemver(last, step.version) < 0 && compareSemver(step.version, currentVersion) <= 0) {
|
||||
console.log(`${SYSTEM_ID} | Migration ${step.version}: ${step.description}`);
|
||||
try {
|
||||
await step.run();
|
||||
} catch (err) {
|
||||
console.error(`${SYSTEM_ID} | Migration ${step.version} failed`, err);
|
||||
ui.notifications?.error(`HbM migration ${step.version} failed — see console.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await game.settings.set(SYSTEM_ID, 'lastMigratedVersion', currentVersion);
|
||||
ui.notifications?.info(`HbM RPG v3 migrated to ${currentVersion}.`);
|
||||
}
|
||||
|
||||
export function registerMigrationSettings(): void {
|
||||
game.settings.register(SYSTEM_ID, 'lastMigratedVersion', {
|
||||
name: 'Last migrated system version',
|
||||
scope: 'world',
|
||||
config: false,
|
||||
type: String,
|
||||
default: '0.0.0',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user