This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import BasePackage from "./base-package.mjs";
import * as fields from "../data/fields.mjs";
import AdditionalTypesField from "./sub-types.mjs";
/**
* The data schema used to define Module manifest files.
* Extends the basic PackageData schema with some additional module-specific fields.
* @property {boolean} [coreTranslation] Does this module provide a translation for the core software?
* @property {boolean} [library] A library module provides no user-facing functionality and is solely
* for use by other modules. Loaded before any system or module scripts.
* @property {Record<string, string[]>} [documentTypes] Additional document subtypes provided by this module.
*/
export default class BaseModule extends BasePackage {
/** @inheritDoc */
static defineSchema() {
const parentSchema = super.defineSchema();
return Object.assign({}, parentSchema, {
coreTranslation: new fields.BooleanField(),
library: new fields.BooleanField(),
documentTypes: new AdditionalTypesField()
});
}
/** @override */
static type = "module";
/**
* The default icon used for this type of Package.
* @type {string}
*/
static icon = "fa-plug";
}

View File

@@ -0,0 +1,826 @@
import DataModel from "../abstract/data.mjs";
import * as fields from "../data/fields.mjs";
import {
COMPENDIUM_DOCUMENT_TYPES, DOCUMENT_OWNERSHIP_LEVELS,
PACKAGE_AVAILABILITY_CODES,
PACKAGE_TYPES,
SYSTEM_SPECIFIC_COMPENDIUM_TYPES,
USER_ROLES
} from "../constants.mjs";
import {isNewerVersion, logCompatibilityWarning, mergeObject} from "../utils/module.mjs";
import BaseFolder from "../documents/folder.mjs";
import {ObjectField} from "../data/fields.mjs";
import {DataModelValidationFailure} from "../data/validation-failure.mjs";
/**
* A custom SchemaField for defining package compatibility versions.
* @property {string} minimum The Package will not function before this version
* @property {string} verified Verified compatible up to this version
* @property {string} maximum The Package will not function after this version
*/
export class PackageCompatibility extends fields.SchemaField {
constructor(options) {
super({
minimum: new fields.StringField({required: false, blank: false, initial: undefined}),
verified: new fields.StringField({required: false, blank: false, initial: undefined}),
maximum: new fields.StringField({required: false, blank: false, initial: undefined})
}, options);
}
}
/* -------------------------------------------- */
/**
* A custom SchemaField for defining package relationships.
* @property {RelatedPackage[]} systems Systems that this Package supports
* @property {RelatedPackage[]} requires Packages that are required for base functionality
* @property {RelatedPackage[]} recommends Packages that are recommended for optimal functionality
*/
export class PackageRelationships extends fields.SchemaField {
/** @inheritdoc */
constructor(options) {
super({
systems: new PackageRelationshipField(new RelatedPackage({packageType: "system"})),
requires: new PackageRelationshipField(new RelatedPackage()),
recommends: new PackageRelationshipField(new RelatedPackage()),
conflicts: new PackageRelationshipField(new RelatedPackage()),
flags: new fields.ObjectField()
}, options);
}
}
/* -------------------------------------------- */
/**
* A SetField with custom casting behavior.
*/
class PackageRelationshipField extends fields.SetField {
/** @override */
_cast(value) {
return value instanceof Array ? value : [value];
}
}
/* -------------------------------------------- */
/**
* A custom SchemaField for defining a related Package.
* It may be required to be a specific type of package, by passing the packageType option to the constructor.
*/
export class RelatedPackage extends fields.SchemaField {
constructor({packageType, ...options}={}) {
let typeOptions = {choices: PACKAGE_TYPES, initial:"module"};
if ( packageType ) typeOptions = {choices: [packageType], initial: packageType};
super({
id: new fields.StringField({required: true, blank: false}),
type: new fields.StringField(typeOptions),
manifest: new fields.StringField({required: false, blank: false, initial: undefined}),
compatibility: new PackageCompatibility(),
reason: new fields.StringField({required: false, blank: false, initial: undefined})
}, options);
}
}
/* -------------------------------------------- */
/**
* A custom SchemaField for defining the folder structure of the included compendium packs.
*/
export class PackageCompendiumFolder extends fields.SchemaField {
constructor({depth=1, ...options}={}) {
const schema = {
name: new fields.StringField({required: true, blank: false}),
sorting: new fields.StringField({required: false, blank: false, initial: undefined,
choices: BaseFolder.SORTING_MODES}),
color: new fields.ColorField(),
packs: new fields.SetField(new fields.StringField({required: true, blank: false}))
};
if ( depth < 4 ) schema.folders = new fields.SetField(new PackageCompendiumFolder(
{depth: depth+1, options}));
super(schema, options);
}
}
/* -------------------------------------------- */
/**
* A special ObjectField which captures a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS.
*/
export class CompendiumOwnershipField extends ObjectField {
/** @inheritdoc */
static get _defaults() {
return mergeObject(super._defaults, {
initial: {PLAYER: "OBSERVER", ASSISTANT: "OWNER"},
validationError: "is not a mapping of USER_ROLES to DOCUMENT_OWNERSHIP_LEVELS"
});
}
/** @override */
_validateType(value, options) {
for ( let [k, v] of Object.entries(value) ) {
if ( !(k in USER_ROLES) ) throw new Error(`Compendium ownership key "${k}" is not a valid choice in USER_ROLES`);
if ( !(v in DOCUMENT_OWNERSHIP_LEVELS) ) throw new Error(`Compendium ownership value "${v}" is not a valid
choice in DOCUMENT_OWNERSHIP_LEVELS`);
}
}
}
/* -------------------------------------------- */
/**
* A special SetField which provides additional validation and initialization behavior specific to compendium packs.
*/
export class PackageCompendiumPacks extends fields.SetField {
/** @override */
_cleanType(value, options) {
return value.map(v => {
v = this.element.clean(v, options);
if ( v.path ) v.path = v.path.replace(/\.db$/, ""); // Strip old NEDB extensions
else v.path = `packs/${v.name}`; // Auto-populate a default pack path
return v;
})
}
/* ---------------------------------------- */
/** @override */
initialize(value, model, options={}) {
const packs = new Set();
const packageName = model._source.id;
for ( let v of value ) {
try {
const pack = this.element.initialize(v, model, options);
pack.packageType = model.constructor.type;
pack.packageName = packageName;
pack.id = `${model.constructor.type === "world" ? "world" : packageName}.${pack.name}`;
packs.add(pack);
} catch(err) {
logger.warn(err.message);
}
}
return packs;
}
/* ---------------------------------------- */
/**
* Extend the logic for validating the complete set of packs to ensure uniqueness.
* @inheritDoc
*/
_validateElements(value, options) {
const packNames = new Set();
const duplicateNames = new Set();
const packPaths = new Set();
const duplicatePaths = new Set();
for ( const pack of value ) {
if ( packNames.has(pack.name) ) duplicateNames.add(pack.name);
packNames.add(pack.name);
if ( pack.path ) {
if ( packPaths.has(pack.path) ) duplicatePaths.add(pack.path);
packPaths.add(pack.path);
}
}
return super._validateElements(value, {...options, duplicateNames, duplicatePaths});
}
/* ---------------------------------------- */
/**
* Validate each individual compendium pack, ensuring its name and path are unique.
* @inheritDoc
*/
_validateElement(value, {duplicateNames, duplicatePaths, ...options}={}) {
if ( duplicateNames.has(value.name) ) {
return new DataModelValidationFailure({
invalidValue: value.name,
message: `Duplicate Compendium name "${value.name}" already declared by some other pack`,
unresolved: true
});
}
if ( duplicatePaths.has(value.path) ) {
return new DataModelValidationFailure({
invalidValue: value.path,
message: `Duplicate Compendium path "${value.path}" already declared by some other pack`,
unresolved: true
});
}
return this.element.validate(value, options);
}
}
/* -------------------------------------------- */
/**
* The data schema used to define a Package manifest.
* Specific types of packages extend this schema with additional fields.
*/
export default class BasePackage extends DataModel {
/**
* @param {PackageManifestData} data Source data for the package
* @param {object} [options={}] Options which affect DataModel construction
*/
constructor(data, options={}) {
const {availability, locked, exclusive, owned, tags, hasStorage} = data;
super(data, options);
/**
* An availability code in PACKAGE_AVAILABILITY_CODES which defines whether this package can be used.
* @type {number}
*/
this.availability = availability ?? this.constructor.testAvailability(this);
/**
* A flag which tracks whether this package is currently locked.
* @type {boolean}
*/
this.locked = locked ?? false;
/**
* A flag which tracks whether this package is a free Exclusive pack
* @type {boolean}
*/
this.exclusive = exclusive ?? false;
/**
* A flag which tracks whether this package is owned, if it is protected.
* @type {boolean|null}
*/
this.owned = owned ?? false;
/**
* A set of Tags that indicate what kind of Package this is, provided by the Website
* @type {string[]}
*/
this.tags = tags ?? [];
/**
* A flag which tracks if this package has files stored in the persistent storage folder
* @type {boolean}
*/
this.hasStorage = hasStorage ?? false;
}
/**
* Define the package type in CONST.PACKAGE_TYPES that this class represents.
* Each BasePackage subclass must define this attribute.
* @virtual
* @type {string}
*/
static type = "package";
/**
* The type of this package instance. A value in CONST.PACKAGE_TYPES.
* @type {string}
*/
get type() {
return this.constructor.type;
}
/**
* The canonical identifier for this package
* @return {string}
* @deprecated
*/
get name() {
logCompatibilityWarning("You are accessing BasePackage#name which is now deprecated in favor of id.",
{since: 10, until: 13});
return this.id;
}
/**
* A flag which defines whether this package is unavailable to be used.
* @type {boolean}
*/
get unavailable() {
return this.availability > PACKAGE_AVAILABILITY_CODES.UNVERIFIED_GENERATION;
}
/**
* Is this Package incompatible with the currently installed core Foundry VTT software version?
* @type {boolean}
*/
get incompatibleWithCoreVersion() {
return this.constructor.isIncompatibleWithCoreVersion(this.availability);
}
/**
* Test if a given availability is incompatible with the core version.
* @param {number} availability The availability value to test.
* @returns {boolean}
*/
static isIncompatibleWithCoreVersion(availability) {
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
return (availability >= codes.REQUIRES_CORE_DOWNGRADE) && (availability <= codes.REQUIRES_CORE_UPGRADE_UNSTABLE);
}
/**
* The named collection to which this package type belongs
* @type {string}
*/
static get collection() {
return `${this.type}s`;
}
/** @inheritDoc */
static defineSchema() {
const optionalString = {required: false, blank: false, initial: undefined};
return {
// Package metadata
id: new fields.StringField({required: true, blank: false, validate: this.validateId}),
title: new fields.StringField({required: true, blank: false}),
description: new fields.StringField({required: true}),
authors: new fields.SetField(new fields.SchemaField({
name: new fields.StringField({required: true, blank: false}),
email: new fields.StringField(optionalString),
url: new fields.StringField(optionalString),
discord: new fields.StringField(optionalString),
flags: new fields.ObjectField(),
})),
url: new fields.StringField(optionalString),
license: new fields.StringField(optionalString),
readme: new fields.StringField(optionalString),
bugs: new fields.StringField(optionalString),
changelog: new fields.StringField(optionalString),
flags: new fields.ObjectField(),
media: new fields.SetField(new fields.SchemaField({
type: new fields.StringField(optionalString),
url: new fields.StringField(optionalString),
caption: new fields.StringField(optionalString),
loop: new fields.BooleanField({required: false, blank: false, initial: false}),
thumbnail: new fields.StringField(optionalString),
flags: new fields.ObjectField(),
})),
// Package versioning
version: new fields.StringField({required: true, blank: false, initial: "0"}),
compatibility: new PackageCompatibility(),
// Included content
scripts: new fields.SetField(new fields.StringField({required: true, blank: false})),
esmodules: new fields.SetField(new fields.StringField({required: true, blank: false})),
styles: new fields.SetField(new fields.StringField({required: true, blank: false})),
languages: new fields.SetField(new fields.SchemaField({
lang: new fields.StringField({required: true, blank: false, validate: Intl.getCanonicalLocales,
validationError: "must be supported by the Intl.getCanonicalLocales function"
}),
name: new fields.StringField({required: false}),
path: new fields.StringField({required: true, blank: false}),
system: new fields.StringField(optionalString),
module: new fields.StringField(optionalString),
flags: new fields.ObjectField(),
})),
packs: new PackageCompendiumPacks(new fields.SchemaField({
name: new fields.StringField({required: true, blank: false, validate: this.validateId}),
label: new fields.StringField({required: true, blank: false}),
banner: new fields.StringField({...optionalString, nullable: true}),
path: new fields.StringField({required: false}),
type: new fields.StringField({required: true, blank: false, choices: COMPENDIUM_DOCUMENT_TYPES,
validationError: "must be a value in CONST.COMPENDIUM_DOCUMENT_TYPES"}),
system: new fields.StringField(optionalString),
ownership: new CompendiumOwnershipField(),
flags: new fields.ObjectField(),
}, {validate: BasePackage.#validatePack})),
packFolders: new fields.SetField(new PackageCompendiumFolder()),
// Package relationships
relationships: new PackageRelationships(),
socket: new fields.BooleanField(),
// Package downloading
manifest: new fields.StringField(),
download: new fields.StringField({required: false, blank: false, initial: undefined}),
protected: new fields.BooleanField(),
exclusive: new fields.BooleanField(),
persistentStorage: new fields.BooleanField(),
}
}
/* -------------------------------------------- */
/**
* Check the given compatibility data against the current installation state and determine its availability.
* @param {Partial<PackageManifestData>} data The compatibility data to test.
* @param {object} [options]
* @param {ReleaseData} [options.release] A specific software release for which to test availability.
* Tests against the current release by default.
* @returns {number}
*/
static testAvailability({ compatibility }, { release }={}) {
release ??= globalThis.release ?? game.release;
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
const {minimum, maximum, verified} = compatibility;
const isGeneration = version => Number.isInteger(Number(version));
// Require a certain minimum core version.
if ( minimum && isNewerVersion(minimum, release.version) ) {
const generation = Number(minimum.split(".").shift());
const isStable = generation <= release.maxStableGeneration;
const exists = generation <= release.maxGeneration;
if ( isStable ) return codes.REQUIRES_CORE_UPGRADE_STABLE;
return exists ? codes.REQUIRES_CORE_UPGRADE_UNSTABLE : codes.UNKNOWN;
}
// Require a certain maximum core version.
if ( maximum ) {
const compatible = isGeneration(maximum)
? release.generation <= Number(maximum)
: !isNewerVersion(release.version, maximum);
if ( !compatible ) return codes.REQUIRES_CORE_DOWNGRADE;
}
// Require a certain compatible core version.
if ( verified ) {
const compatible = isGeneration(verified)
? Number(verified) >= release.generation
: !isNewerVersion(release.version, verified);
const sameGeneration = release.generation === Number(verified.split(".").shift());
if ( compatible ) return codes.VERIFIED;
return sameGeneration ? codes.UNVERIFIED_BUILD : codes.UNVERIFIED_GENERATION;
}
// FIXME: Why do we not check if all of this package's dependencies are satisfied?
// Proposal: Check all relationships.requires and set MISSING_DEPENDENCY if any dependencies are not VERIFIED,
// UNVERIFIED_BUILD, or UNVERIFIED_GENERATION, or if they do not satisfy the given compatibility range for the
// relationship.
// No compatible version is specified.
return codes.UNKNOWN;
}
/* -------------------------------------------- */
/**
* Test that the dependencies of a package are satisfied as compatible.
* This method assumes that all packages in modulesCollection have already had their own availability tested.
* @param {Collection<string,Module>} modulesCollection A collection which defines the set of available modules
* @returns {Promise<boolean>} Are all required dependencies satisfied?
* @internal
*/
async _testRequiredDependencies(modulesCollection) {
const requirements = this.relationships.requires;
for ( const {id, type, manifest, compatibility} of requirements ) {
if ( type !== "module" ) continue; // Only test modules
let pkg;
// If the requirement specifies an explicit remote manifest URL, we need to load it
if ( manifest ) {
try {
pkg = await this.constructor.fromRemoteManifest(manifest, {strict: true});
} catch(err) {
return false;
}
}
// Otherwise the dependency must belong to the known modulesCollection
else pkg = modulesCollection.get(id);
if ( !pkg ) return false;
// Ensure that the package matches the required compatibility range
if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) ) return false;
// Test compatibility of the dependency
if ( pkg.unavailable ) return false;
}
return true;
}
/* -------------------------------------------- */
/**
* Test compatibility of a package's supported systems.
* @param {Collection<string, System>} systemCollection A collection which defines the set of available systems.
* @returns {Promise<boolean>} True if all supported systems which are currently installed
* are compatible or if the package has no supported systems.
* Returns false otherwise, or if no supported systems are
* installed.
* @internal
*/
async _testSupportedSystems(systemCollection) {
const systems = this.relationships.systems;
if ( !systems?.size ) return true;
let supportedSystem = false;
for ( const { id, compatibility } of systems ) {
const pkg = systemCollection.get(id);
if ( !pkg ) continue;
if ( !this.constructor.testDependencyCompatibility(compatibility, pkg) || pkg.unavailable ) return false;
supportedSystem = true;
}
return supportedSystem;
}
/* -------------------------------------------- */
/**
* Determine if a dependency is within the given compatibility range.
* @param {PackageCompatibility} compatibility The compatibility range declared for the dependency, if any
* @param {BasePackage} dependency The known dependency package
* @returns {boolean} Is the dependency compatible with the required range?
*/
static testDependencyCompatibility(compatibility, dependency) {
if ( !compatibility ) return true;
const {minimum, maximum} = compatibility;
if ( minimum && isNewerVersion(minimum, dependency.version) ) return false;
if ( maximum && isNewerVersion(dependency.version, maximum) ) return false;
return true;
}
/* -------------------------------------------- */
/** @inheritDoc */
static cleanData(source={}, { installed, ...options }={}) {
// Auto-assign language name
for ( let l of source.languages || [] ) {
l.name = l.name ?? l.lang;
}
// Identify whether this package depends on a single game system
let systemId = undefined;
if ( this.type === "system" ) systemId = source.id;
else if ( this.type === "world" ) systemId = source.system;
else if ( source.relationships?.systems?.length === 1 ) systemId = source.relationships.systems[0].id;
// Auto-configure some package data
for ( const pack of source.packs || [] ) {
if ( !pack.system && systemId ) pack.system = systemId; // System dependency
if ( typeof pack.ownership === "string" ) pack.ownership = {PLAYER: pack.ownership};
}
/**
* Clean unsupported non-module dependencies in requires or recommends.
* @deprecated since v11
*/
["requires", "recommends"].forEach(rel => {
const pkgs = source.relationships?.[rel];
if ( !Array.isArray(pkgs) ) return;
const clean = [];
for ( const pkg of pkgs ) {
if ( !pkg.type || (pkg.type === "module") ) clean.push(pkg);
}
const diff = pkgs.length - clean.length;
if ( diff ) {
source.relationships[rel] = clean;
this._logWarning(
source.id,
`The ${this.type} "${source.id}" has a ${rel} relationship on a non-module, which is not supported.`,
{ since: 11, until: 13, stack: false, installed });
}
});
return super.cleanData(source, options);
}
/* -------------------------------------------- */
/**
* Validate that a Package ID is allowed.
* @param {string} id The candidate ID
* @throws An error if the candidate ID is invalid
*/
static validateId(id) {
const allowed = /^[A-Za-z0-9-_]+$/;
if ( !allowed.test(id) ) {
throw new Error("Package and compendium pack IDs may only be alphanumeric with hyphens or underscores.");
}
const prohibited = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
if ( prohibited.test(id) ) throw new Error(`The ID "${id}" uses an operating system prohibited value.`);
}
/* -------------------------------------------- */
/**
* Validate a single compendium pack object
* @param {PackageCompendiumData} packData Candidate compendium packs data
* @throws An error if the data is invalid
*/
static #validatePack(packData) {
if ( SYSTEM_SPECIFIC_COMPENDIUM_TYPES.includes(packData.type) && !packData.system ) {
throw new Error(`The Compendium pack "${packData.name}" of the "${packData.type}" type must declare the "system"`
+ " upon which it depends.");
}
}
/* -------------------------------------------- */
/**
* A wrapper around the default compatibility warning logger which handles some package-specific interactions.
* @param {string} packageId The package ID being logged
* @param {string} message The warning or error being logged
* @param {object} options Logging options passed to foundry.utils.logCompatibilityWarning
* @param {object} [options.installed] Is the package installed?
* @internal
*/
static _logWarning(packageId, message, { installed, ...options }={}) {
logCompatibilityWarning(message, options);
if ( installed ) globalThis.packages?.warnings?.add(packageId, {type: this.type, level: "warning", message});
}
/* -------------------------------------------- */
/**
* A set of package manifest keys that are migrated.
* @type {Set<string>}
*/
static migratedKeys = new Set([
/** @deprecated since 10 until 13 */
"name", "dependencies", "minimumCoreVersion", "compatibleCoreVersion"
]);
/* -------------------------------------------- */
/** @inheritdoc */
static migrateData(data, { installed }={}) {
this._migrateNameToId(data, {since: 10, until: 13, stack: false, installed});
this._migrateDependenciesNameToId(data, {since: 10, until: 13, stack: false, installed});
this._migrateToRelationships(data, {since: 10, until: 13, stack: false, installed});
this._migrateCompatibility(data, {since: 10, until: 13, stack: false, installed});
this._migrateMediaURL(data, {since: 11, until: 13, stack: false, installed});
this._migrateOwnership(data, {since: 11, until: 13, stack: false, installed});
this._migratePackIDs(data, {since: 12, until: 14, stack: false, installed});
this._migratePackEntityToType(data, {since: 9, stack: false, installed});
return super.migrateData(data);
}
/* -------------------------------------------- */
/** @internal */
static _migrateNameToId(data, logOptions) {
if ( data.name && !data.id ) {
data.id = data.name;
delete data.name;
if ( this.type !== "world" ) {
const warning = `The ${this.type} "${data.id}" is using "name" which is deprecated in favor of "id"`;
this._logWarning(data.id, warning, logOptions);
}
}
}
/* -------------------------------------------- */
/** @internal */
static _migrateDependenciesNameToId(data, logOptions) {
if ( data.relationships ) return;
if ( data.dependencies ) {
let hasDependencyName = false;
for ( const dependency of data.dependencies ) {
if ( dependency.name && !dependency.id ) {
hasDependencyName = true;
dependency.id = dependency.name;
delete dependency.name;
}
}
if ( hasDependencyName ) {
const msg = `The ${this.type} "${data.id}" contains dependencies using "name" which is deprecated in favor of "id"`;
this._logWarning(data.id, msg, logOptions);
}
}
}
/* -------------------------------------------- */
/** @internal */
static _migrateToRelationships(data, logOptions) {
if ( data.relationships ) return;
data.relationships = {
requires: [],
systems: []
};
// Dependencies -> Relationships.Requires
if ( data.dependencies ) {
for ( const d of data.dependencies ) {
const relationship = {
"id": d.id,
"type": d.type,
"manifest": d.manifest,
"compatibility": {
"compatible": d.version
}
};
d.type === "system" ? data.relationships.systems.push(relationship) : data.relationships.requires.push(relationship);
}
const msg = `The ${this.type} "${data.id}" contains "dependencies" which is deprecated in favor of "relationships.requires"`;
this._logWarning(data.id, msg, logOptions);
delete data.dependencies;
}
// V9: system -> relationships.systems
else if ( data.system && (this.type === "module") ) {
data.system = data.system instanceof Array ? data.system : [data.system];
const newSystems = data.system.map(id => ({id})).filter(s => !data.relationships.systems.find(x => x.id === s.id));
data.relationships.systems = data.relationships.systems.concat(newSystems);
const msg = `${this.type} "${data.id}" contains "system" which is deprecated in favor of "relationships.systems"`;
this._logWarning(data.id, msg, logOptions);
delete data.system;
}
}
/* -------------------------------------------- */
/** @internal */
static _migrateCompatibility(data, logOptions) {
if ( !data.compatibility && (data.minimumCoreVersion || data.compatibleCoreVersion) ) {
this._logWarning(data.id, `The ${this.type} "${data.id}" is using the old flat core compatibility fields which `
+ `are deprecated in favor of the new "compatibility" object`,
logOptions);
data.compatibility = {
minimum: data.minimumCoreVersion,
verified: data.compatibleCoreVersion
};
delete data.minimumCoreVersion;
delete data.compatibleCoreVersion;
}
}
/* -------------------------------------------- */
/** @internal */
static _migrateMediaURL(data, logOptions) {
if ( !data.media ) return;
let hasMediaLink = false;
for ( const media of data.media ) {
if ( "link" in media ) {
hasMediaLink = true;
media.url = media.link;
delete media.link;
}
}
if ( hasMediaLink ) {
const msg = `${this.type} "${data.id}" declares media.link which is unsupported, media.url should be used`;
this._logWarning(data.id, msg, logOptions);
}
}
/* -------------------------------------------- */
/** @internal */
static _migrateOwnership(data, logOptions) {
if ( !data.packs ) return;
let hasPrivatePack = false;
for ( const pack of data.packs ) {
if ( pack.private && !("ownership" in pack) ) {
pack.ownership = {PLAYER: "LIMITED", ASSISTANT: "OWNER"};
hasPrivatePack = true;
}
delete pack.private;
}
if ( hasPrivatePack ) {
const msg = `${this.type} "${data.id}" uses pack.private which has been replaced with pack.ownership`;
this._logWarning(data.id, msg, logOptions);
}
return data;
}
/* -------------------------------------------- */
/** @internal */
static _migratePackIDs(data, logOptions) {
if ( !data.packs ) return;
for ( const pack of data.packs ) {
const slugified = pack.name.replace(/[^A-Za-z0-9-_]/g, "");
if ( pack.name !== slugified ) {
const msg = `The ${this.type} "${data.id}" contains a pack with an invalid name "${pack.name}". `
+ "Pack names containing any character that is non-alphanumeric or an underscore will cease loading in "
+ "version 14 of the software.";
pack.name = slugified;
this._logWarning(data.id, msg, logOptions);
}
}
}
/* -------------------------------------------- */
/** @internal */
static _migratePackEntityToType(data, logOptions) {
if ( !data.packs ) return;
let hasPackEntity = false;
for ( const pack of data.packs ) {
if ( ("entity" in pack) && !("type" in pack) ) {
pack.type = pack.entity;
hasPackEntity = true;
}
delete pack.entity;
}
if ( hasPackEntity ) {
const msg = `${this.type} "${data.id}" uses pack.entity which has been replaced with pack.type`;
this._logWarning(data.id, msg, logOptions);
}
}
/* -------------------------------------------- */
/**
* Retrieve the latest Package manifest from a provided remote location.
* @param {string} manifestUrl A remote manifest URL to load
* @param {object} options Additional options which affect package construction
* @param {boolean} [options.strict=true] Whether to construct the remote package strictly
* @return {Promise<ServerPackage>} A Promise which resolves to a constructed ServerPackage instance
* @throws An error if the retrieved manifest data is invalid
*/
static async fromRemoteManifest(manifestUrl, {strict=true}={}) {
throw new Error("Not implemented");
}
}

View File

@@ -0,0 +1,150 @@
import BasePackage from "./base-package.mjs";
import * as fields from "../data/fields.mjs";
import AdditionalTypesField from "./sub-types.mjs";
/**
* @typedef {import("./sub-types.mjs").DocumentTypesConfiguration} DocumentTypesConfiguration
*/
/**
* The data schema used to define System manifest files.
* Extends the basic PackageData schema with some additional system-specific fields.
* @property {DocumentTypesConfiguration} [documentTypes] Additional document subtypes provided by this system.
* @property {string} [background] A web URL or local file path which provides a default background banner for
* worlds which are created using this system
* @property {string} [initiative] A default initiative formula used for this system
* @property {number} [grid] The default grid settings to use for Scenes in this system
* @property {number} [grid.type] A default grid type to use for Scenes in this system
* @property {number} [grid.distance] A default distance measurement to use for Scenes in this system
* @property {string} [grid.units] A default unit of measure to use for distance measurement in this system
* @property {number} [grid.diagonals] The default rule used by this system for diagonal measurement on square grids
* @property {string} [primaryTokenAttribute] An Actor data attribute path to use for Token primary resource bars
* @property {string} [secondaryTokenAttribute] An Actor data attribute path to use for Token secondary resource bars
*/
export default class BaseSystem extends BasePackage {
/** @inheritDoc */
static defineSchema() {
return Object.assign({}, super.defineSchema(), {
documentTypes: new AdditionalTypesField(),
background: new fields.StringField({required: false, blank: false}),
initiative: new fields.StringField(),
grid: new fields.SchemaField({
type: new fields.NumberField({required: true, choices: Object.values(CONST.GRID_TYPES),
initial: CONST.GRID_TYPES.SQUARE, validationError: "must be a value in CONST.GRID_TYPES"}),
distance: new fields.NumberField({required: true, nullable: false, positive: true, initial: 1}),
units: new fields.StringField({required: true}),
diagonals: new fields.NumberField({required: true, choices: Object.values(CONST.GRID_DIAGONALS),
initial: CONST.GRID_DIAGONALS.EQUIDISTANT, validationError: "must be a value in CONST.GRID_DIAGONALS"}),
}),
primaryTokenAttribute: new fields.StringField(),
secondaryTokenAttribute: new fields.StringField()
});
}
/** @inheritdoc */
static type = "system";
/**
* The default icon used for this type of Package.
* @type {string}
*/
static icon = "fa-dice";
/**
* Does the system template request strict type checking of data compared to template.json inferred types.
* @type {boolean}
*/
strictDataCleaning = false;
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* Static initializer block for deprecated properties.
*/
static {
/**
* Shim grid distance and units.
* @deprecated since v12
*/
Object.defineProperties(this.prototype, Object.fromEntries(
Object.entries({
gridDistance: "grid.distance",
gridUnits: "grid.units"
}).map(([o, n]) => [o, {
get() {
const msg = `You are accessing BasePackage#${o} which has been migrated to BasePackage#${n}.`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return foundry.utils.getProperty(this, n);
},
set(v) {
const msg = `You are accessing BasePackage#${o} which has been migrated to BasePackage#${n}.`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return foundry.utils.setProperty(this, n, v);
},
configurable: true
}])
));
}
/* -------------------------------------------- */
/** @override */
static migratedKeys = (function() {
return BasePackage.migratedKeys.union(new Set([
/** @deprecated since 12 until 14 */
"gridDistance", "gridUnits"
]));
})();
/* ---------------------------------------- */
/** @inheritdoc */
static migrateData(data, options) {
/**
* Migrate grid distance and units.
* @deprecated since v12
*/
for ( const [oldKey, [newKey, apply]] of Object.entries({
gridDistance: ["grid.distance", d => Math.max(d.gridDistance || 0, 1)],
gridUnits: ["grid.units", d => d.gridUnits || ""]
})) {
if ( (oldKey in data) && !foundry.utils.hasProperty(data, newKey) ) {
foundry.utils.setProperty(data, newKey, apply(data));
delete data[oldKey];
const warning = `The ${this.type} "${data.id}" is using "${oldKey}" which is deprecated in favor of "${newKey}".`;
this._logWarning(data.id, warning, {since: 12, until: 14, stack: false, installed: options.installed});
}
}
return super.migrateData(data, options);
}
/* ---------------------------------------- */
/** @inheritdoc */
static shimData(data, options) {
/**
* Shim grid distance and units.
* @deprecated since v12
*/
for ( const [oldKey, newKey] of Object.entries({
gridDistance: "grid.distance",
gridUnits: "grid.units"
})) {
if ( !data.hasOwnProperty(oldKey) && foundry.utils.hasProperty(data, newKey) ) {
Object.defineProperty(data, oldKey, {
get: () => {
const msg = `You are accessing BasePackage#${oldKey} which has been migrated to BasePackage#${newKey}.`;
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return foundry.utils.getProperty(data, newKey);
},
set: value => foundry.utils.setProperty(data, newKey, value),
configurable: true
});
}
}
return super.shimData(data, options);
}
}

View File

@@ -0,0 +1,119 @@
import BasePackage from "./base-package.mjs";
import * as fields from "../data/fields.mjs";
import {WORLD_JOIN_THEMES} from "../constants.mjs";
/**
* The data schema used to define World manifest files.
* Extends the basic PackageData schema with some additional world-specific fields.
* @property {string} system The game system name which this world relies upon
* @property {string} coreVersion The version of the core software for which this world has been migrated
* @property {string} systemVersion The version of the game system for which this world has been migrated
* @property {string} [background] A web URL or local file path which provides a background banner image
* @property {string} [nextSession] An ISO datetime string when the next game session is scheduled to occur
* @property {boolean} [resetKeys] Should user access keys be reset as part of the next launch?
* @property {boolean} [safeMode] Should the world launch in safe mode?
* @property {string} [joinTheme] The theme to use for this world's join page.
*/
export default class BaseWorld extends BasePackage {
/** @inheritDoc */
static defineSchema() {
return Object.assign({}, super.defineSchema(), {
system: new fields.StringField({required: true, blank: false}),
background: new fields.StringField({required: false, blank: false}),
joinTheme: new fields.StringField({
required: false, initial: undefined, nullable: false, blank: false, choices: WORLD_JOIN_THEMES
}),
coreVersion: new fields.StringField({required: true, blank: false}),
systemVersion: new fields.StringField({required: true, blank: false, initial: "0"}),
lastPlayed: new fields.StringField(),
playtime: new fields.NumberField({integer: true, min: 0, initial: 0}),
nextSession: new fields.StringField({blank: false, nullable: true, initial: null}),
resetKeys: new fields.BooleanField({required: false, initial: undefined}),
safeMode: new fields.BooleanField({required: false, initial: undefined}),
version: new fields.StringField({required: true, blank: false, nullable: true, initial: null})
});
}
/** @override */
static type = "world";
/**
* The default icon used for this type of Package.
* @type {string}
*/
static icon = "fa-globe-asia";
/** @inheritDoc */
static migrateData(data) {
super.migrateData(data);
// Legacy compatibility strings
data.compatibility = data.compatibility || {};
if ( data.compatibility.maximum === "1.0.0" ) data.compatibility.maximum = undefined;
if ( data.coreVersion && !data.compatibility.verified ) {
data.compatibility.minimum = data.compatibility.verified = data.coreVersion;
}
return data;
}
/* -------------------------------------------- */
/**
* Check the given compatibility data against the current installation state and determine its availability.
* @param {Partial<PackageManifestData>} data The compatibility data to test.
* @param {object} [options]
* @param {ReleaseData} [options.release] A specific software release for which to test availability.
* Tests against the current release by default.
* @param {Collection<string, Module>} [options.modules] A specific collection of modules to test availability
* against. Tests against the currently installed modules by
* default.
* @param {Collection<string, System>} [options.systems] A specific collection of systems to test availability
* against. Tests against the currently installed systems by
* default.
* @param {number} [options.systemAvailabilityThreshold] Ignore the world's own core software compatibility and
* instead defer entirely to the system's core software
* compatibility, if the world's availability is less than
* this.
* @returns {number}
*/
static testAvailability(data, { release, modules, systems, systemAvailabilityThreshold }={}) {
systems ??= globalThis.packages?.System ?? game.systems;
modules ??= globalThis.packages?.Module ?? game.modules;
const { relationships } = data;
const codes = CONST.PACKAGE_AVAILABILITY_CODES;
systemAvailabilityThreshold ??= codes.UNKNOWN
// If the World itself is incompatible for some reason, report that directly.
const wa = super.testAvailability(data, { release });
if ( this.isIncompatibleWithCoreVersion(wa) ) return wa;
// If the System is missing or incompatible, report that directly.
const system = data.system instanceof foundry.packages.BaseSystem ? data.system : systems.get(data.system);
if ( !system ) return codes.MISSING_SYSTEM;
const sa = system.availability;
// FIXME: Why do we only check if the system is incompatible with the core version or UNKNOWN?
// Proposal: If the system is anything but VERIFIED, UNVERIFIED_BUILD, or UNVERIFIED_GENERATION, we should return
// the system availability.
if ( system.incompatibleWithCoreVersion || (sa === codes.UNKNOWN) ) return sa;
// Test the availability of all required modules.
const checkedModules = new Set();
// TODO: We do not need to check system requirements here if the above proposal is implemented.
const requirements = [...relationships.requires.values(), ...system.relationships.requires.values()];
for ( const r of requirements ) {
if ( (r.type !== "module") || checkedModules.has(r.id) ) continue;
const module = modules.get(r.id);
if ( !module ) return codes.MISSING_DEPENDENCY;
// FIXME: Why do we only check if the module is incompatible with the core version?
// Proposal: We should check the actual compatibility information for the relationship to ensure that the module
// satisfies it.
if ( module.incompatibleWithCoreVersion ) return codes.REQUIRES_DEPENDENCY_UPDATE;
checkedModules.add(r.id);
}
// Inherit from the System availability in certain cases.
if ( wa <= systemAvailabilityThreshold ) return sa;
return wa;
}
}

View File

@@ -0,0 +1,76 @@
/** @module packages */
export {default as BasePackage} from "./base-package.mjs";
export {default as BaseWorld} from "./base-world.mjs";
export {default as BaseSystem} from "./base-system.mjs";
export {default as BaseModule} from "./base-module.mjs";
export {PackageCompatibility, RelatedPackage} from "./base-package.mjs";
/* ---------------------------------------- */
/* Type Definitions */
/* ---------------------------------------- */
/**
* @typedef {Object} PackageAuthorData
* @property {string} name The author name
* @property {string} [email] The author email address
* @property {string} [url] A website url for the author
* @property {string} [discord] A Discord username for the author
*/
/**
* @typedef {Object} PackageCompendiumData
* @property {string} name The canonical compendium name. This should contain no spaces or special characters
* @property {string} label The human-readable compendium name
* @property {string} path The local relative path to the compendium source directory. The filename should match
* the name attribute
* @property {string} type The specific document type that is contained within this compendium pack
* @property {string} [system] Denote that this compendium pack requires a specific game system to function properly
*/
/**
* @typedef {Object} PackageLanguageData
* @property {string} lang A string language code which is validated by Intl.getCanonicalLocales
* @property {string} name The human-readable language name
* @property {string} path The relative path to included JSON translation strings
* @property {string} [system] Only apply this set of translations when a specific system is being used
* @property {string} [module] Only apply this set of translations when a specific module is active
*/
/**
* @typedef {Object} RelatedPackage
* @property {string} id The id of the related package
* @property {string} type The type of the related package
* @property {string} [manifest] An explicit manifest URL, otherwise learned from the Foundry web server
* @property {PackageCompatibility} [compatibility] The compatibility data with this related Package
* @property {string} [reason] The reason for this relationship
*/
/**
* @typedef {Object} PackageManifestData
* The data structure of a package manifest. This data structure is extended by BasePackage subclasses to add additional
* type-specific fields.
* [[include:full-manifest.md]]
*
* @property {string} id The machine-readable unique package id, should be lower-case with no spaces or special characters
* @property {string} title The human-readable package title, containing spaces and special characters
* @property {string} [description] An optional package description, may contain HTML
* @property {PackageAuthorData[]} [authors] An array of author objects who are co-authors of this package. Preferred to the singular author field.
* @property {string} [url] A web url where more details about the package may be found
* @property {string} [license] A web url or relative file path where license details may be found
* @property {string} [readme] A web url or relative file path where readme instructions may be found
* @property {string} [bugs] A web url where bug reports may be submitted and tracked
* @property {string} [changelog] A web url where notes detailing package updates are available
* @property {string} version The current package version
* @property {PackageCompatibility} [compatibility] The compatibility of this version with the core Foundry software
* @property {string[]} [scripts] An array of urls or relative file paths for JavaScript files which should be included
* @property {string[]} [esmodules] An array of urls or relative file paths for ESModule files which should be included
* @property {string[]} [styles] An array of urls or relative file paths for CSS stylesheet files which should be included
* @property {PackageLanguageData[]} [languages] An array of language data objects which are included by this package
* @property {PackageCompendiumData[]} [packs] An array of compendium packs which are included by this package
* @property {PackageRelationships} [relationships] An organized object of relationships to other Packages
* @property {boolean} [socket] Whether to require a package-specific socket namespace for this package
* @property {string} [manifest] A publicly accessible web URL which provides the latest available package manifest file. Required in order to support module updates.
* @property {string} [download] A publicly accessible web URL where the source files for this package may be downloaded. Required in order to support module installation.
* @property {boolean} [protected=false] Whether this package uses the protected content access system.
*/

View File

@@ -0,0 +1,56 @@
import {getType, mergeObject} from "../utils/helpers.mjs";
import {ObjectField} from "../data/fields.mjs";
/**
* @typedef {Record<string, Record<string, object>>} DocumentTypesConfiguration
*/
/**
* A special [ObjectField]{@link ObjectField} available to packages which configures any additional Document subtypes
* provided by the package.
*/
export default class AdditionalTypesField extends ObjectField {
/** @inheritDoc */
static get _defaults() {
return mergeObject(super._defaults, {
readonly: true,
validationError: "is not a valid sub-types configuration"
});
}
/* ----------------------------------------- */
/** @inheritDoc */
_validateType(value, options={}) {
super._validateType(value, options);
for ( const [documentName, subtypes] of Object.entries(value) ) {
const cls = getDocumentClass(documentName);
if ( !cls ) throw new Error(`${this.validationError}: '${documentName}' is not a valid Document type`);
if ( !cls.hasTypeData ) {
throw new Error(`${this.validationError}: ${documentName} Documents do not support sub-types`);
}
if ( getType(subtypes) !== "Object" ) throw new Error(`Malformed ${documentName} documentTypes declaration`);
for ( const [type, config] of Object.entries(subtypes) ) this.#validateSubtype(cls, type, config);
}
}
/* ----------------------------------------- */
/**
* Validate a single defined document subtype.
* @param {typeof Document} documentClass The document for which the subtype is being registered
* @param {string} type The requested subtype name
* @param {object} config The provided subtype configuration
* @throws {Error} An error if the subtype is invalid or malformed
*/
#validateSubtype(documentClass, type, config) {
const dn = documentClass.documentName;
if ( documentClass.metadata.coreTypes.includes(type) ) {
throw new Error(`"${type}" is a reserved core type for the ${dn} document`);
}
if ( getType(config) !== "Object" ) {
throw new Error(`Malformed "${type}" subtype declared for ${dn} documentTypes`);
}
}
}