Initial
This commit is contained in:
68
resources/app/client/data/collections/actors.js
Normal file
68
resources/app/client/data/collections/actors.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The singleton collection of Actor documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.actors.
|
||||
* @extends {WorldCollection}
|
||||
* @category - Collections
|
||||
*
|
||||
* @see {@link Actor} The Actor document
|
||||
* @see {@link ActorDirectory} The ActorDirectory sidebar directory
|
||||
*
|
||||
* @example Retrieve an existing Actor by its id
|
||||
* ```js
|
||||
* let actor = game.actors.get(actorId);
|
||||
* ```
|
||||
*/
|
||||
class Actors extends WorldCollection {
|
||||
/**
|
||||
* A mapping of synthetic Token Actors which are currently active within the viewed Scene.
|
||||
* Each Actor is referenced by the Token.id.
|
||||
* @type {Record<string, Actor>}
|
||||
*/
|
||||
get tokens() {
|
||||
if ( !canvas.ready || !canvas.scene ) return {};
|
||||
return canvas.scene.tokens.reduce((obj, t) => {
|
||||
if ( t.actorLink ) return obj;
|
||||
obj[t.id] = t.actor;
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "Actor";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @param {Document|object} document
|
||||
* @param {FromCompendiumOptions} [options]
|
||||
* @param {boolean} [options.clearPrototypeToken=true] Clear prototype token data to allow default token settings to
|
||||
* be applied.
|
||||
* @returns {object}
|
||||
*/
|
||||
fromCompendium(document, options={}) {
|
||||
const data = super.fromCompendium(document, options);
|
||||
|
||||
// Clear prototype token data.
|
||||
if ( (options.clearPrototypeToken !== false) && ("prototypeToken" in data) ) {
|
||||
const settings = game.settings.get("core", DefaultTokenConfig.SETTING) ?? {};
|
||||
foundry.data.PrototypeToken.schema.apply(function(v) {
|
||||
if ( typeof v !== "object" ) foundry.utils.setProperty(data.prototypeToken, this.fieldPath, undefined);
|
||||
}, settings, { partial: true });
|
||||
}
|
||||
|
||||
// Re-associate imported Active Effects which are sourced to Items owned by this same Actor
|
||||
if ( data._id ) {
|
||||
const ownItemIds = new Set(data.items.map(i => i._id));
|
||||
for ( let effect of data.effects ) {
|
||||
if ( !effect.origin ) continue;
|
||||
const effectItemId = effect.origin.split(".").pop();
|
||||
if ( ownItemIds.has(effectItemId) ) {
|
||||
effect.origin = `Actor.${data._id}.Item.${effectItemId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
11
resources/app/client/data/collections/cards.js
Normal file
11
resources/app/client/data/collections/cards.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* The collection of Cards documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.cards.
|
||||
* @extends {WorldCollection}
|
||||
* @see {@link Cards} The Cards document
|
||||
*/
|
||||
class CardStacks extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Cards";
|
||||
}
|
||||
78
resources/app/client/data/collections/combats.js
Normal file
78
resources/app/client/data/collections/combats.js
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The singleton collection of Combat documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.combats.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Combat} The Combat document
|
||||
* @see {@link CombatTracker} The CombatTracker sidebar directory
|
||||
*/
|
||||
class CombatEncounters extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Combat";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide the settings object which configures the Combat document
|
||||
* @type {object}
|
||||
*/
|
||||
static get settings() {
|
||||
return game.settings.get("core", Combat.CONFIG_SETTING);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get directory() {
|
||||
return ui.combat;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get an Array of Combat instances which apply to the current canvas scene
|
||||
* @type {Combat[]}
|
||||
*/
|
||||
get combats() {
|
||||
return this.filter(c => (c.scene === null) || (c.scene === game.scenes.current));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The currently active Combat instance
|
||||
* @type {Combat}
|
||||
*/
|
||||
get active() {
|
||||
return this.combats.find(c => c.active);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The currently viewed Combat encounter
|
||||
* @type {Combat|null}
|
||||
*/
|
||||
get viewed() {
|
||||
return ui.combat?.viewed ?? null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* When a Token is deleted, remove it as a combatant from any combat encounters which included the Token
|
||||
* @param {string} sceneId The Scene id within which a Token is being deleted
|
||||
* @param {string} tokenId The Token id being deleted
|
||||
* @protected
|
||||
*/
|
||||
async _onDeleteToken(sceneId, tokenId) {
|
||||
for ( let combat of this ) {
|
||||
const toDelete = [];
|
||||
for ( let c of combat.combatants ) {
|
||||
if ( (c.sceneId === sceneId) && (c.tokenId === tokenId) ) toDelete.push(c.id);
|
||||
}
|
||||
if ( toDelete.length ) await combat.deleteEmbeddedDocuments("Combatant", toDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
899
resources/app/client/data/collections/compendium-collection.js
Normal file
899
resources/app/client/data/collections/compendium-collection.js
Normal file
@@ -0,0 +1,899 @@
|
||||
/**
|
||||
* @typedef {SocketRequest} ManageCompendiumRequest
|
||||
* @property {string} action The request action.
|
||||
* @property {PackageCompendiumData|string} data The compendium creation data, or the ID of the compendium to delete.
|
||||
* @property {object} [options] Additional options.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {SocketResponse} ManageCompendiumResponse
|
||||
* @property {ManageCompendiumRequest} request The original request.
|
||||
* @property {PackageCompendiumData|string} result The compendium creation data, or the collection name of the
|
||||
* deleted compendium.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A collection of Document objects contained within a specific compendium pack.
|
||||
* Each Compendium pack has its own associated instance of the CompendiumCollection class which contains its contents.
|
||||
* @extends {DocumentCollection}
|
||||
* @abstract
|
||||
* @see {Game#packs}
|
||||
*
|
||||
* @param {object} metadata The compendium metadata, an object provided by game.data
|
||||
*/
|
||||
class CompendiumCollection extends DirectoryCollectionMixin(DocumentCollection) {
|
||||
constructor(metadata) {
|
||||
super([]);
|
||||
|
||||
/**
|
||||
* The compendium metadata which defines the compendium content and location
|
||||
* @type {object}
|
||||
*/
|
||||
this.metadata = metadata;
|
||||
|
||||
/**
|
||||
* A subsidiary collection which contains the more minimal index of the pack
|
||||
* @type {Collection<string, object>}
|
||||
*/
|
||||
this.index = new foundry.utils.Collection();
|
||||
|
||||
/**
|
||||
* A subsidiary collection which contains the folders within the pack
|
||||
* @type {Collection<string, Folder>}
|
||||
*/
|
||||
this.#folders = new CompendiumFolderCollection(this);
|
||||
|
||||
/**
|
||||
* A debounced function which will clear the contents of the Compendium pack if it is not accessed frequently.
|
||||
* @type {Function}
|
||||
* @private
|
||||
*/
|
||||
this._flush = foundry.utils.debounce(this.clear.bind(this), this.constructor.CACHE_LIFETIME_SECONDS * 1000);
|
||||
|
||||
// Initialize a provided Compendium index
|
||||
this.#indexedFields = new Set(this.documentClass.metadata.compendiumIndexFields);
|
||||
for ( let i of metadata.index ) {
|
||||
i.uuid = this.getUuid(i._id);
|
||||
this.index.set(i._id, i);
|
||||
}
|
||||
delete metadata.index;
|
||||
for ( let f of metadata.folders.sort((a, b) => a.sort - b.sort) ) {
|
||||
this.#folders.set(f._id, new Folder.implementation(f, {pack: this.collection}));
|
||||
}
|
||||
delete metadata.folders;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The amount of time that Document instances within this CompendiumCollection are held in memory.
|
||||
* Accessing the contents of the Compendium pack extends the duration of this lifetime.
|
||||
* @type {number}
|
||||
*/
|
||||
static CACHE_LIFETIME_SECONDS = 300;
|
||||
|
||||
/**
|
||||
* The named game setting which contains Compendium configurations.
|
||||
* @type {string}
|
||||
*/
|
||||
static CONFIG_SETTING = "compendiumConfiguration";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The canonical Compendium name - comprised of the originating package and the pack name
|
||||
* @type {string}
|
||||
*/
|
||||
get collection() {
|
||||
return this.metadata.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner image for this Compendium pack, or the default image for the pack type if no image is set.
|
||||
* @returns {string|null|void}
|
||||
*/
|
||||
get banner() {
|
||||
if ( this.metadata.banner === undefined ) return CONFIG[this.metadata.type]?.compendiumBanner;
|
||||
return this.metadata.banner;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the Application class which provides an interface to interact with this compendium content.
|
||||
* @type {typeof Application}
|
||||
*/
|
||||
applicationClass = Compendium;
|
||||
|
||||
/**
|
||||
* The set of Compendium Folders
|
||||
*/
|
||||
#folders;
|
||||
|
||||
get folders() {
|
||||
return this.#folders;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
get maxFolderDepth() {
|
||||
return super.maxFolderDepth - 1;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the Folder that this Compendium is displayed within
|
||||
* @returns {Folder|null}
|
||||
*/
|
||||
get folder() {
|
||||
return game.folders.get(this.config.folder) ?? null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign this CompendiumCollection to be organized within a specific Folder.
|
||||
* @param {Folder|string|null} folder The desired Folder within the World or null to clear the folder
|
||||
* @returns {Promise<void>} A promise which resolves once the transaction is complete
|
||||
*/
|
||||
async setFolder(folder) {
|
||||
const current = this.config.folder;
|
||||
|
||||
// Clear folder
|
||||
if ( folder === null ) {
|
||||
if ( current === null ) return;
|
||||
return this.configure({folder: null});
|
||||
}
|
||||
|
||||
// Set folder
|
||||
if ( typeof folder === "string" ) folder = game.folders.get(folder);
|
||||
if ( !(folder instanceof Folder) ) throw new Error("You must pass a valid Folder or Folder ID.");
|
||||
if ( folder.type !== "Compendium" ) throw new Error(`Folder "${folder.id}" is not of the required Compendium type`);
|
||||
if ( folder.id === current ) return;
|
||||
await this.configure({folder: folder.id});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the sort order for this Compendium
|
||||
* @returns {number}
|
||||
*/
|
||||
get sort() {
|
||||
return this.config.sort ?? 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getVisibleTreeContents() {
|
||||
return this.index.contents;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static _sortStandard(a, b) {
|
||||
return a.sort - b.sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the compendium configuration data for this pack
|
||||
* @type {object}
|
||||
*/
|
||||
get config() {
|
||||
const setting = game.settings.get("core", "compendiumConfiguration");
|
||||
const config = setting[this.collection] || {};
|
||||
/** @deprecated since v11 */
|
||||
if ( "private" in config ) {
|
||||
if ( config.private === true ) config.ownership = {PLAYER: "LIMITED", ASSISTANT: "OWNER"};
|
||||
delete config.private;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
get documentName() {
|
||||
return this.metadata.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track whether the Compendium Collection is locked for editing
|
||||
* @type {boolean}
|
||||
*/
|
||||
get locked() {
|
||||
return this.config.locked ?? (this.metadata.packageType !== "world");
|
||||
}
|
||||
|
||||
/**
|
||||
* The visibility configuration of this compendium pack.
|
||||
* @type {Record<CONST.USER_ROLES, CONST.DOCUMENT_OWNERSHIP_LEVELS>}
|
||||
*/
|
||||
get ownership() {
|
||||
return this.config.ownership ?? this.metadata.ownership ?? {...Module.schema.getField("packs.ownership").initial};
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this Compendium pack visible to the current game User?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get visible() {
|
||||
return this.getUserLevel() >= CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER;
|
||||
}
|
||||
|
||||
/**
|
||||
* A convenience reference to the label which should be used as the title for the Compendium pack.
|
||||
* @type {string}
|
||||
*/
|
||||
get title() {
|
||||
return this.metadata.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index fields which should be loaded for this compendium pack
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
get indexFields() {
|
||||
const coreFields = this.documentClass.metadata.compendiumIndexFields;
|
||||
const configFields = CONFIG[this.documentName].compendiumIndexFields || [];
|
||||
return new Set([...coreFields, ...configFields]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track which document fields have been indexed for this compendium pack
|
||||
* @type {Set<string>}
|
||||
* @private
|
||||
*/
|
||||
#indexedFields;
|
||||
|
||||
/**
|
||||
* Has this compendium pack been fully indexed?
|
||||
* @type {boolean}
|
||||
*/
|
||||
get indexed() {
|
||||
return this.indexFields.isSubset(this.#indexedFields);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get(key, options) {
|
||||
this._flush();
|
||||
return super.get(key, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
set(id, document) {
|
||||
if ( document instanceof Folder ) {
|
||||
return this.#folders.set(id, document);
|
||||
}
|
||||
this._flush();
|
||||
this.indexDocument(document);
|
||||
return super.set(id, document);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
delete(id) {
|
||||
this.index.delete(id);
|
||||
return super.delete(id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
clear() {
|
||||
for ( const doc of this.values() ) {
|
||||
if ( !Object.values(doc.apps).some(app => app.rendered) ) super.delete(doc.id);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load the Compendium index and cache it as the keys and values of the Collection.
|
||||
* @param {object} [options] Options which customize how the index is created
|
||||
* @param {string[]} [options.fields] An array of fields to return as part of the index
|
||||
* @returns {Promise<Collection>}
|
||||
*/
|
||||
async getIndex({fields=[]}={}) {
|
||||
const cls = this.documentClass;
|
||||
|
||||
// Maybe reuse the existing index if we have already indexed all fields
|
||||
const indexFields = new Set([...this.indexFields, ...fields]);
|
||||
if ( indexFields.isSubset(this.#indexedFields) ) return this.index;
|
||||
|
||||
// Request the new index from the server
|
||||
const index = await cls.database.get(cls, {
|
||||
query: {},
|
||||
index: true,
|
||||
indexFields: Array.from(indexFields),
|
||||
pack: this.collection
|
||||
}, game.user);
|
||||
|
||||
// Assign the index to the collection
|
||||
for ( let i of index ) {
|
||||
const x = this.index.get(i._id);
|
||||
const indexed = x ? foundry.utils.mergeObject(x, i) : i;
|
||||
indexed.uuid = this.getUuid(indexed._id);
|
||||
this.index.set(i._id, indexed);
|
||||
}
|
||||
|
||||
// Record that the pack has been indexed
|
||||
console.log(`${vtt} | Constructed index of ${this.collection} Compendium containing ${this.index.size} entries`);
|
||||
this.#indexedFields = indexFields;
|
||||
return this.index;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get a single Document from this Compendium by ID.
|
||||
* The document may already be locally cached, otherwise it is retrieved from the server.
|
||||
* @param {string} id The requested Document id
|
||||
* @returns {Promise<Document>|undefined} The retrieved Document instance
|
||||
*/
|
||||
async getDocument(id) {
|
||||
if ( !id ) return undefined;
|
||||
const cached = this.get(id);
|
||||
if ( cached instanceof foundry.abstract.Document ) return cached;
|
||||
const documents = await this.getDocuments({_id: id});
|
||||
return documents.length ? documents.shift() : null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Load multiple documents from the Compendium pack using a provided query object.
|
||||
* @param {object} query A database query used to retrieve documents from the underlying database
|
||||
* @returns {Promise<Document[]>} The retrieved Document instances
|
||||
*
|
||||
* @example Get Documents that match the given value only.
|
||||
* ```js
|
||||
* await pack.getDocuments({ type: "weapon" });
|
||||
* ```
|
||||
*
|
||||
* @example Get several Documents by their IDs.
|
||||
* ```js
|
||||
* await pack.getDocuments({ _id__in: arrayOfIds });
|
||||
* ```
|
||||
*
|
||||
* @example Get Documents by their sub-types.
|
||||
* ```js
|
||||
* await pack.getDocuments({ type__in: ["weapon", "armor"] });
|
||||
* ```
|
||||
*/
|
||||
async getDocuments(query={}) {
|
||||
const cls = this.documentClass;
|
||||
const documents = await cls.database.get(cls, {query, pack: this.collection}, game.user);
|
||||
for ( let d of documents ) {
|
||||
if ( d.invalid && !this.invalidDocumentIds.has(d.id) ) {
|
||||
this.invalidDocumentIds.add(d.id);
|
||||
this._source.push(d);
|
||||
}
|
||||
else this.set(d.id, d);
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the ownership level that a User has for this Compendium pack.
|
||||
* @param {documents.User} user The user being tested
|
||||
* @returns {number} The ownership level in CONST.DOCUMENT_OWNERSHIP_LEVELS
|
||||
*/
|
||||
getUserLevel(user=game.user) {
|
||||
const levels = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
let level = levels.NONE;
|
||||
for ( const [role, l] of Object.entries(this.ownership) ) {
|
||||
if ( user.hasRole(role) ) level = Math.max(level, levels[l]);
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Test whether a certain User has a requested permission level (or greater) over the Compendium pack
|
||||
* @param {documents.BaseUser} user The User being tested
|
||||
* @param {string|number} permission The permission level from DOCUMENT_OWNERSHIP_LEVELS to test
|
||||
* @param {object} options Additional options involved in the permission test
|
||||
* @param {boolean} [options.exact=false] Require the exact permission level requested?
|
||||
* @returns {boolean} Does the user have this permission level over the Compendium pack?
|
||||
*/
|
||||
testUserPermission(user, permission, {exact=false}={}) {
|
||||
const perms = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
const level = user.isGM ? perms.OWNER : this.getUserLevel(user);
|
||||
const target = (typeof permission === "string") ? (perms[permission] ?? perms.OWNER) : permission;
|
||||
return exact ? level === target : level >= target;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import a Document into this Compendium Collection.
|
||||
* @param {Document} document The existing Document you wish to import
|
||||
* @param {object} [options] Additional options which modify how the data is imported.
|
||||
* See {@link ClientDocumentMixin#toCompendium}
|
||||
* @returns {Promise<Document>} The imported Document instance
|
||||
*/
|
||||
async importDocument(document, options={}) {
|
||||
if ( !(document instanceof this.documentClass) && !(document instanceof Folder) ) {
|
||||
const err = Error(`You may not import a ${document.constructor.name} Document into the ${this.collection} Compendium which contains ${this.documentClass.name} Documents.`);
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
options.clearOwnership = options.clearOwnership ?? (this.metadata.packageType === "world");
|
||||
const data = document.toCompendium(this, options);
|
||||
|
||||
return document.constructor.create(data, {pack: this.collection});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import a Folder into this Compendium Collection.
|
||||
* @param {Folder} folder The existing Folder you wish to import
|
||||
* @param {object} [options] Additional options which modify how the data is imported.
|
||||
* @param {boolean} [options.importParents=true] Import any parent folders which are not already present in the Compendium
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async importFolder(folder, {importParents=true, ...options}={}) {
|
||||
if ( !(folder instanceof Folder) ) {
|
||||
const err = Error(`You may not import a ${folder.constructor.name} Document into the folders collection of the ${this.collection} Compendium.`);
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const toCreate = [folder];
|
||||
if ( importParents ) toCreate.push(...folder.getParentFolders().filter(f => !this.folders.has(f.id)));
|
||||
await Folder.createDocuments(toCreate, {pack: this.collection, keepId: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Import an array of Folders into this Compendium Collection.
|
||||
* @param {Folder[]} folders The existing Folders you wish to import
|
||||
* @param {object} [options] Additional options which modify how the data is imported.
|
||||
* @param {boolean} [options.importParents=true] Import any parent folders which are not already present in the Compendium
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async importFolders(folders, {importParents=true, ...options}={}) {
|
||||
if ( folders.some(f => !(f instanceof Folder)) ) {
|
||||
const err = Error(`You can only import Folder documents into the folders collection of the ${this.collection} Compendium.`);
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const toCreate = new Set(folders);
|
||||
if ( importParents ) {
|
||||
for ( const f of folders ) {
|
||||
for ( const p of f.getParentFolders() ) {
|
||||
if ( !this.folders.has(p.id) ) toCreate.add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Folder.createDocuments(Array.from(toCreate), {pack: this.collection, keepId: true});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Fully import the contents of a Compendium pack into a World folder.
|
||||
* @param {object} [options={}] Options which modify the import operation. Additional options are forwarded to
|
||||
* {@link WorldCollection#fromCompendium} and {@link Document.createDocuments}
|
||||
* @param {string|null} [options.folderId] An existing Folder _id to use.
|
||||
* @param {string} [options.folderName] A new Folder name to create.
|
||||
* @returns {Promise<Document[]>} The imported Documents, now existing within the World
|
||||
*/
|
||||
async importAll({folderId=null, folderName="", ...options}={}) {
|
||||
let parentFolder;
|
||||
|
||||
// Optionally, create a top level folder
|
||||
if ( CONST.FOLDER_DOCUMENT_TYPES.includes(this.documentName) ) {
|
||||
|
||||
// Re-use an existing folder
|
||||
if ( folderId ) parentFolder = game.folders.get(folderId, {strict: true});
|
||||
|
||||
// Create a new Folder
|
||||
if ( !parentFolder ) {
|
||||
parentFolder = await Folder.create({
|
||||
name: folderName || this.title,
|
||||
type: this.documentName,
|
||||
parent: null,
|
||||
color: this.folder?.color ?? null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load all content
|
||||
const folders = this.folders;
|
||||
const documents = await this.getDocuments();
|
||||
ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllStart", {
|
||||
number: documents.length,
|
||||
folderNumber: folders.size,
|
||||
type: game.i18n.localize(this.documentClass.metadata.label),
|
||||
folder: parentFolder.name
|
||||
}));
|
||||
|
||||
// Create any missing Folders
|
||||
const folderCreateData = folders.map(f => {
|
||||
if ( game.folders.has(f.id) ) return null;
|
||||
const data = f.toObject();
|
||||
|
||||
// If this folder has no parent folder, assign it to the new folder
|
||||
if ( !data.folder ) data.folder = parentFolder.id;
|
||||
return data;
|
||||
}).filter(f => f);
|
||||
await Folder.createDocuments(folderCreateData, {keepId: true});
|
||||
|
||||
// Prepare import data
|
||||
const collection = game.collections.get(this.documentName);
|
||||
const createData = documents.map(doc => {
|
||||
const data = collection.fromCompendium(doc, options);
|
||||
|
||||
// If this document has no folder, assign it to the new folder
|
||||
if ( !data.folder) data.folder = parentFolder.id;
|
||||
return data;
|
||||
});
|
||||
|
||||
// Create World Documents in batches
|
||||
const chunkSize = 100;
|
||||
const nBatches = Math.ceil(createData.length / chunkSize);
|
||||
let created = [];
|
||||
for ( let n=0; n<nBatches; n++ ) {
|
||||
const chunk = createData.slice(n*chunkSize, (n+1)*chunkSize);
|
||||
const docs = await this.documentClass.createDocuments(chunk, options);
|
||||
created = created.concat(docs);
|
||||
}
|
||||
|
||||
// Notify of success
|
||||
ui.notifications.info(game.i18n.format("COMPENDIUM.ImportAllFinish", {
|
||||
number: created.length,
|
||||
folderNumber: folders.size,
|
||||
type: game.i18n.localize(this.documentClass.metadata.label),
|
||||
folder: parentFolder.name
|
||||
}));
|
||||
return created;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Provide a dialog form that prompts the user to import the full contents of a Compendium pack into the World.
|
||||
* @param {object} [options={}] Additional options passed to the Dialog.confirm method
|
||||
* @returns {Promise<Document[]|boolean|null>} A promise which resolves in the following ways: an array of imported
|
||||
* Documents if the "yes" button was pressed, false if the "no" button was pressed, or
|
||||
* null if the dialog was closed without making a choice.
|
||||
*/
|
||||
async importDialog(options={}) {
|
||||
|
||||
// Render the HTML form
|
||||
const collection = CONFIG[this.documentName]?.collection?.instance;
|
||||
const html = await renderTemplate("templates/sidebar/apps/compendium-import.html", {
|
||||
folderName: this.title,
|
||||
keepId: options.keepId ?? false,
|
||||
folders: collection?._formatFolderSelectOptions() ?? []
|
||||
});
|
||||
|
||||
// Present the Dialog
|
||||
options.jQuery = false;
|
||||
return Dialog.confirm({
|
||||
title: `${game.i18n.localize("COMPENDIUM.ImportAll")}: ${this.title}`,
|
||||
content: html,
|
||||
render: html => {
|
||||
const form = html.querySelector("form");
|
||||
form.elements.folder.addEventListener("change", event => {
|
||||
form.elements.folderName.disabled = !!event.currentTarget.value;
|
||||
}, { passive: true });
|
||||
},
|
||||
yes: html => {
|
||||
const form = html.querySelector("form");
|
||||
return this.importAll({
|
||||
folderId: form.elements.folder.value,
|
||||
folderName: form.folderName.value,
|
||||
keepId: form.keepId.checked
|
||||
});
|
||||
},
|
||||
options
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a Document to the index, capturing its relevant index attributes
|
||||
* @param {Document} document The document to index
|
||||
*/
|
||||
indexDocument(document) {
|
||||
let index = this.index.get(document.id);
|
||||
const data = document.toObject();
|
||||
if ( index ) foundry.utils.mergeObject(index, data, {insertKeys: false, insertValues: false});
|
||||
else {
|
||||
index = this.#indexedFields.reduce((obj, field) => {
|
||||
foundry.utils.setProperty(obj, field, foundry.utils.getProperty(data, field));
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
index.img = data.thumb ?? data.img;
|
||||
index._id = data._id;
|
||||
index.uuid = document.uuid;
|
||||
this.index.set(document.id, index);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Prompt the gamemaster with a dialog to configure ownership of this Compendium pack.
|
||||
* @returns {Promise<Record<string, string>>} The configured ownership for the pack
|
||||
*/
|
||||
async configureOwnershipDialog() {
|
||||
if ( !game.user.isGM ) throw new Error("You do not have permission to configure ownership for this Compendium pack");
|
||||
const current = this.ownership;
|
||||
const levels = {
|
||||
"": game.i18n.localize("COMPENDIUM.OwnershipInheritBelow"),
|
||||
NONE: game.i18n.localize("OWNERSHIP.NONE"),
|
||||
LIMITED: game.i18n.localize("OWNERSHIP.LIMITED"),
|
||||
OBSERVER: game.i18n.localize("OWNERSHIP.OBSERVER"),
|
||||
OWNER: game.i18n.localize("OWNERSHIP.OWNER")
|
||||
};
|
||||
const roles = {
|
||||
ASSISTANT: {label: "USER.RoleAssistant", value: current.ASSISTANT, levels: { ...levels }},
|
||||
TRUSTED: {label: "USER.RoleTrusted", value: current.TRUSTED, levels: { ...levels }},
|
||||
PLAYER: {label: "USER.RolePlayer", value: current.PLAYER, levels: { ...levels }}
|
||||
};
|
||||
delete roles.PLAYER.levels[""];
|
||||
await Dialog.wait({
|
||||
title: `${game.i18n.localize("OWNERSHIP.Title")}: ${this.metadata.label}`,
|
||||
content: await renderTemplate("templates/sidebar/apps/compendium-ownership.hbs", {roles}),
|
||||
default: "ok",
|
||||
close: () => null,
|
||||
buttons: {
|
||||
reset: {
|
||||
label: game.i18n.localize("COMPENDIUM.OwnershipReset"),
|
||||
icon: '<i class="fas fa-undo"></i>',
|
||||
callback: () => this.configure({ ownership: undefined })
|
||||
},
|
||||
ok: {
|
||||
label: game.i18n.localize("OWNERSHIP.Configure"),
|
||||
icon: '<i class="fas fa-check"></i>',
|
||||
callback: async html => {
|
||||
const fd = new FormDataExtended(html.querySelector("form.compendium-ownership-dialog"));
|
||||
let ownership = Object.entries(fd.object).reduce((obj, [r, l]) => {
|
||||
if ( l ) obj[r] = l;
|
||||
return obj;
|
||||
}, {});
|
||||
ownership.GAMEMASTER = "OWNER";
|
||||
await this.configure({ownership});
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { jQuery: false });
|
||||
return this.ownership;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Compendium Management */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Activate the Socket event listeners used to receive responses to compendium management events.
|
||||
* @param {Socket} socket The active game socket.
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("manageCompendium", response => {
|
||||
const { request } = response;
|
||||
switch ( request.action ) {
|
||||
case "create":
|
||||
CompendiumCollection.#handleCreateCompendium(response);
|
||||
break;
|
||||
case "delete":
|
||||
CompendiumCollection.#handleDeleteCompendium(response);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid Compendium modification action ${request.action} provided.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Compendium Collection using provided metadata.
|
||||
* @param {object} metadata The compendium metadata used to create the new pack
|
||||
* @param {object} options Additional options which modify the Compendium creation request
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
static async createCompendium(metadata, options={}) {
|
||||
if ( !game.user.isGM ) return ui.notifications.error("You do not have permission to modify this compendium pack");
|
||||
const response = await SocketInterface.dispatch("manageCompendium", {
|
||||
action: "create",
|
||||
data: metadata,
|
||||
options: options
|
||||
});
|
||||
|
||||
return this.#handleCreateCompendium(response);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Generate a UUID for a given primary document ID within this Compendium pack
|
||||
* @param {string} id The document ID to generate a UUID for
|
||||
* @returns {string} The generated UUID, in the form of "Compendium.<collection>.<documentName>.<id>"
|
||||
*/
|
||||
getUuid(id) {
|
||||
return `Compendium.${this.collection}.${this.documentName}.${id}`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Assign configuration metadata settings to the compendium pack
|
||||
* @param {object} configuration The object of compendium settings to define
|
||||
* @returns {Promise} A Promise which resolves once the setting is updated
|
||||
*/
|
||||
configure(configuration={}) {
|
||||
const settings = game.settings.get("core", "compendiumConfiguration");
|
||||
const config = this.config;
|
||||
for ( const [k, v] of Object.entries(configuration) ) {
|
||||
if ( v === undefined ) delete config[k];
|
||||
else config[k] = v;
|
||||
}
|
||||
settings[this.collection] = config;
|
||||
return game.settings.set("core", this.constructor.CONFIG_SETTING, settings);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Delete an existing world-level Compendium Collection.
|
||||
* This action may only be performed for world-level packs by a Gamemaster User.
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
async deleteCompendium() {
|
||||
this.#assertUserCanManage();
|
||||
this.apps.forEach(app => app.close());
|
||||
const response = await SocketInterface.dispatch("manageCompendium", {
|
||||
action: "delete",
|
||||
data: this.metadata.name
|
||||
});
|
||||
|
||||
return CompendiumCollection.#handleDeleteCompendium(response);
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Duplicate a compendium pack to the current World.
|
||||
* @param {string} label A new Compendium label
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
async duplicateCompendium({label}={}) {
|
||||
this.#assertUserCanManage({requireUnlocked: false});
|
||||
label = label || this.title;
|
||||
const metadata = foundry.utils.mergeObject(this.metadata, {
|
||||
name: label.slugify({strict: true}),
|
||||
label: label
|
||||
}, {inplace: false});
|
||||
return this.constructor.createCompendium(metadata, {source: this.collection});
|
||||
}
|
||||
|
||||
/* ----------------------------------------- */
|
||||
|
||||
/**
|
||||
* Validate that the current user is able to modify content of this Compendium pack
|
||||
* @returns {boolean}
|
||||
* @private
|
||||
*/
|
||||
#assertUserCanManage({requireUnlocked=true}={}) {
|
||||
const config = this.config;
|
||||
let err;
|
||||
if ( !game.user.isGM ) err = new Error("You do not have permission to modify this compendium pack");
|
||||
if ( requireUnlocked && config.locked ) {
|
||||
err = new Error("You cannot modify content in this compendium pack because it is locked.");
|
||||
}
|
||||
if ( err ) {
|
||||
ui.notifications.error(err.message);
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Migrate a compendium pack.
|
||||
* This operation re-saves all documents within the compendium pack to disk, applying the current data model.
|
||||
* If the document type has system data, the latest system data template will also be applied to all documents.
|
||||
* @returns {Promise<CompendiumCollection>}
|
||||
*/
|
||||
async migrate() {
|
||||
this.#assertUserCanManage();
|
||||
ui.notifications.info(`Beginning migration for Compendium pack ${this.collection}, please be patient.`);
|
||||
await SocketInterface.dispatch("manageCompendium", {
|
||||
type: this.collection,
|
||||
action: "migrate",
|
||||
data: this.collection,
|
||||
options: { broadcast: false }
|
||||
});
|
||||
ui.notifications.info(`Successfully migrated Compendium pack ${this.collection}.`);
|
||||
return this;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async updateAll(transformation, condition=null, options={}) {
|
||||
await this.getDocuments();
|
||||
options.pack = this.collection;
|
||||
return super.updateAll(transformation, condition, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritDoc */
|
||||
_onModifyContents(action, documents, result, operation, user) {
|
||||
super._onModifyContents(action, documents, result, operation, user);
|
||||
Hooks.callAll("updateCompendium", this, documents, operation, user.id);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a response from the server where a compendium was created.
|
||||
* @param {ManageCompendiumResponse} response The server response.
|
||||
* @returns {CompendiumCollection}
|
||||
*/
|
||||
static #handleCreateCompendium({ result }) {
|
||||
game.data.packs.push(result);
|
||||
const pack = new this(result);
|
||||
game.packs.set(pack.collection, pack);
|
||||
pack.apps.push(new Compendium({collection: pack}));
|
||||
ui.compendium.render();
|
||||
return pack;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a response from the server where a compendium was deleted.
|
||||
* @param {ManageCompendiumResponse} response The server response.
|
||||
* @returns {CompendiumCollection}
|
||||
*/
|
||||
static #handleDeleteCompendium({ result }) {
|
||||
const pack = game.packs.get(result);
|
||||
if ( !pack ) throw new Error(`Compendium pack '${result}' did not exist to be deleted.`);
|
||||
game.data.packs.findSplice(p => p.id === result);
|
||||
game.packs.delete(result);
|
||||
ui.compendium.render();
|
||||
return pack;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Deprecations and Compatibility */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get private() {
|
||||
foundry.utils.logCompatibilityWarning("CompendiumCollection#private is deprecated in favor of the new "
|
||||
+ "CompendiumCollection#ownership, CompendiumCollection#getUserLevel, CompendiumCollection#visible properties");
|
||||
return !this.visible;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since v11
|
||||
* @ignore
|
||||
*/
|
||||
get isOpen() {
|
||||
foundry.utils.logCompatibilityWarning("CompendiumCollection#isOpen is deprecated and will be removed in V13");
|
||||
return this.apps.some(app => app._state > Application.RENDER_STATES.NONE);
|
||||
}
|
||||
}
|
||||
37
resources/app/client/data/collections/compendium-folders.js
Normal file
37
resources/app/client/data/collections/compendium-folders.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* A Collection of Folder documents within a Compendium pack.
|
||||
*/
|
||||
class CompendiumFolderCollection extends DocumentCollection {
|
||||
constructor(pack, data=[]) {
|
||||
super(data);
|
||||
this.pack = pack;
|
||||
}
|
||||
|
||||
/**
|
||||
* The CompendiumPack instance which contains this CompendiumFolderCollection
|
||||
* @type {CompendiumPack}
|
||||
*/
|
||||
pack;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
get documentName() {
|
||||
return "Folder";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
render(force, options) {
|
||||
this.pack.render(force, options);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
async updateAll(transformation, condition=null, options={}) {
|
||||
options.pack = this.collection;
|
||||
return super.updateAll(transformation, condition, options);
|
||||
}
|
||||
}
|
||||
30
resources/app/client/data/collections/compendium-packs.js
Normal file
30
resources/app/client/data/collections/compendium-packs.js
Normal file
@@ -0,0 +1,30 @@
|
||||
class CompendiumPacks extends DirectoryCollectionMixin(Collection) {
|
||||
|
||||
/**
|
||||
* Get a Collection of Folders which contain Compendium Packs
|
||||
* @returns {Collection<Folder>}
|
||||
*/
|
||||
get folders() {
|
||||
return game.folders.reduce((collection, folder) => {
|
||||
if ( folder.type === "Compendium" ) {
|
||||
collection.set(folder.id, folder);
|
||||
}
|
||||
return collection;
|
||||
}, new foundry.utils.Collection());
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getVisibleTreeContents() {
|
||||
return this.contents.filter(pack => pack.visible);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static _sortAlphabetical(a, b) {
|
||||
if ( a.metadata && b.metadata ) return a.metadata.label.localeCompare(b.metadata.label, game.i18n.lang);
|
||||
else return super._sortAlphabetical(a, b);
|
||||
}
|
||||
}
|
||||
21
resources/app/client/data/collections/fog.js
Normal file
21
resources/app/client/data/collections/fog.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The singleton collection of FogExploration documents which exist within the active World.
|
||||
* @extends {WorldCollection}
|
||||
* @see {@link FogExploration} The FogExploration document
|
||||
*/
|
||||
class FogExplorations extends WorldCollection {
|
||||
static documentName = "FogExploration";
|
||||
|
||||
/**
|
||||
* Activate Socket event listeners to handle for fog resets
|
||||
* @param {Socket} socket The active web socket connection
|
||||
* @internal
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("resetFog", ({sceneId}) => {
|
||||
if ( sceneId === canvas.id ) {
|
||||
canvas.fog._handleReset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
54
resources/app/client/data/collections/folder.js
Normal file
54
resources/app/client/data/collections/folder.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The singleton collection of Folder documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.folders.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Folder} The Folder document
|
||||
*/
|
||||
class Folders extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Folder";
|
||||
|
||||
/**
|
||||
* Track which Folders are currently expanded in the UI
|
||||
*/
|
||||
_expanded = {};
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_onModifyContents(action, documents, result, operation, user) {
|
||||
if ( operation.render ) {
|
||||
const folderTypes = new Set(documents.map(f => f.type));
|
||||
for ( const type of folderTypes ) {
|
||||
if ( type === "Compendium" ) ui.sidebar.tabs.compendium.render(false);
|
||||
else {
|
||||
const collection = game.collections.get(type);
|
||||
collection.render(false, {renderContext: `${action}${this.documentName}`, renderData: result});
|
||||
}
|
||||
}
|
||||
if ( folderTypes.has("JournalEntry") ) this._refreshJournalEntrySheets();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Refresh the display of any active JournalSheet instances where the folder list will change.
|
||||
* @private
|
||||
*/
|
||||
_refreshJournalEntrySheets() {
|
||||
for ( let app of Object.values(ui.windows) ) {
|
||||
if ( !(app instanceof JournalSheet) ) continue;
|
||||
app.submit();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
render(force, options={}) {
|
||||
console.warn("The Folders collection is not directly rendered");
|
||||
}
|
||||
}
|
||||
13
resources/app/client/data/collections/items.js
Normal file
13
resources/app/client/data/collections/items.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* The singleton collection of Item documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.items.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Item} The Item document
|
||||
* @see {@link ItemDirectory} The ItemDirectory sidebar directory
|
||||
*/
|
||||
class Items extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Item";
|
||||
}
|
||||
176
resources/app/client/data/collections/journal.js
Normal file
176
resources/app/client/data/collections/journal.js
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The singleton collection of JournalEntry documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.journal.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link JournalEntry} The JournalEntry document
|
||||
* @see {@link JournalDirectory} The JournalDirectory sidebar directory
|
||||
*/
|
||||
class Journal extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "JournalEntry";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Interaction Dialogs */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Display a dialog which prompts the user to show a JournalEntry or JournalEntryPage to other players.
|
||||
* @param {JournalEntry|JournalEntryPage} doc The JournalEntry or JournalEntryPage to show.
|
||||
* @returns {Promise<JournalEntry|JournalEntryPage|void>}
|
||||
*/
|
||||
static async showDialog(doc) {
|
||||
if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return;
|
||||
if ( !doc.isOwner ) return ui.notifications.error("JOURNAL.ShowBadPermissions", {localize: true});
|
||||
if ( game.users.size < 2 ) return ui.notifications.warn("JOURNAL.ShowNoPlayers", {localize: true});
|
||||
|
||||
const users = game.users.filter(u => u.id !== game.userId);
|
||||
const ownership = Object.entries(CONST.DOCUMENT_OWNERSHIP_LEVELS);
|
||||
if ( !doc.isEmbedded ) ownership.shift();
|
||||
const levels = [
|
||||
{level: CONST.DOCUMENT_META_OWNERSHIP_LEVELS.NOCHANGE, label: "OWNERSHIP.NOCHANGE"},
|
||||
...ownership.map(([name, level]) => ({level, label: `OWNERSHIP.${name}`}))
|
||||
];
|
||||
const isImage = (doc instanceof JournalEntryPage) && (doc.type === "image");
|
||||
const html = await renderTemplate("templates/journal/dialog-show.html", {users, levels, isImage});
|
||||
|
||||
return Dialog.prompt({
|
||||
title: game.i18n.format("JOURNAL.ShowEntry", {name: doc.name}),
|
||||
label: game.i18n.localize("JOURNAL.ActionShow"),
|
||||
content: html,
|
||||
render: html => {
|
||||
const form = html.querySelector("form");
|
||||
form.elements.allPlayers.addEventListener("change", event => {
|
||||
const checked = event.currentTarget.checked;
|
||||
form.querySelectorAll('[name="players"]').forEach(i => {
|
||||
i.checked = checked;
|
||||
i.disabled = checked;
|
||||
});
|
||||
});
|
||||
},
|
||||
callback: async html => {
|
||||
const form = html.querySelector("form");
|
||||
const fd = new FormDataExtended(form).object;
|
||||
const users = fd.allPlayers ? game.users.filter(u => !u.isSelf) : fd.players.reduce((arr, id) => {
|
||||
const u = game.users.get(id);
|
||||
if ( u && !u.isSelf ) arr.push(u);
|
||||
return arr;
|
||||
}, []);
|
||||
if ( !users.length ) return;
|
||||
const userIds = users.map(u => u.id);
|
||||
if ( fd.ownership > -2 ) {
|
||||
const ownership = doc.ownership;
|
||||
if ( fd.allPlayers ) ownership.default = fd.ownership;
|
||||
for ( const id of userIds ) {
|
||||
if ( fd.allPlayers ) {
|
||||
if ( (id in ownership) && (ownership[id] <= fd.ownership) ) delete ownership[id];
|
||||
continue;
|
||||
}
|
||||
if ( ownership[id] === CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE ) ownership[id] = fd.ownership;
|
||||
ownership[id] = Math.max(ownership[id] ?? -Infinity, fd.ownership);
|
||||
}
|
||||
await doc.update({ownership}, {diff: false, recursive: false, noHook: true});
|
||||
}
|
||||
if ( fd.imageOnly ) return this.showImage(doc.src, {
|
||||
users: userIds,
|
||||
title: doc.name,
|
||||
caption: fd.showImageCaption ? doc.image.caption : undefined,
|
||||
showTitle: fd.showImageTitle,
|
||||
uuid: doc.uuid
|
||||
});
|
||||
return this.show(doc, {force: true, users: userIds});
|
||||
},
|
||||
rejectClose: false,
|
||||
options: {jQuery: false}
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Show the JournalEntry or JournalEntryPage to connected players.
|
||||
* By default, the document will only be shown to players who have permission to observe it.
|
||||
* If the force parameter is passed, the document will be shown to all players regardless of normal permission.
|
||||
* @param {JournalEntry|JournalEntryPage} doc The JournalEntry or JournalEntryPage to show.
|
||||
* @param {object} [options] Additional options to configure behaviour.
|
||||
* @param {boolean} [options.force=false] Display the entry to all players regardless of normal permissions.
|
||||
* @param {string[]} [options.users] An optional list of user IDs to show the document to. Otherwise it will
|
||||
* be shown to all connected clients.
|
||||
* @returns {Promise<JournalEntry|JournalEntryPage|void>} A Promise that resolves back to the shown document once the
|
||||
* request is processed.
|
||||
* @throws {Error} If the user does not own the document they are trying to show.
|
||||
*/
|
||||
static show(doc, {force=false, users=[]}={}) {
|
||||
if ( !((doc instanceof JournalEntry) || (doc instanceof JournalEntryPage)) ) return;
|
||||
if ( !doc.isOwner ) throw new Error(game.i18n.localize("JOURNAL.ShowBadPermissions"));
|
||||
const strings = Object.fromEntries(["all", "authorized", "selected"].map(k => [k, game.i18n.localize(k)]));
|
||||
return new Promise(resolve => {
|
||||
game.socket.emit("showEntry", doc.uuid, {force, users}, () => {
|
||||
Journal._showEntry(doc.uuid, force);
|
||||
ui.notifications.info(game.i18n.format("JOURNAL.ActionShowSuccess", {
|
||||
title: doc.name,
|
||||
which: users.length ? strings.selected : force ? strings.all : strings.authorized
|
||||
}));
|
||||
return resolve(doc);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Share an image with connected players.
|
||||
* @param {string} src The image URL to share.
|
||||
* @param {ShareImageConfig} [config] Image sharing configuration.
|
||||
*/
|
||||
static showImage(src, {users=[], ...options}={}) {
|
||||
game.socket.emit("shareImage", {image: src, users, ...options});
|
||||
const strings = Object.fromEntries(["all", "selected"].map(k => [k, game.i18n.localize(k)]));
|
||||
ui.notifications.info(game.i18n.format("JOURNAL.ImageShowSuccess", {
|
||||
which: users.length ? strings.selected : strings.all
|
||||
}));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Socket Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Open Socket listeners which transact JournalEntry data
|
||||
* @param {Socket} socket The open websocket
|
||||
*/
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("showEntry", this._showEntry.bind(this));
|
||||
socket.on("shareImage", ImagePopout._handleShareImage);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle a received request to show a JournalEntry or JournalEntryPage to the current client
|
||||
* @param {string} uuid The UUID of the document to display for other players
|
||||
* @param {boolean} [force=false] Display the document regardless of normal permissions
|
||||
* @internal
|
||||
*/
|
||||
static async _showEntry(uuid, force=false) {
|
||||
let entry = await fromUuid(uuid);
|
||||
const options = {tempOwnership: force, mode: JournalSheet.VIEW_MODES.MULTIPLE, pageIndex: 0};
|
||||
const { OBSERVER } = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
if ( entry instanceof JournalEntryPage ) {
|
||||
options.mode = JournalSheet.VIEW_MODES.SINGLE;
|
||||
options.pageId = entry.id;
|
||||
// Set temporary observer permissions for this page.
|
||||
if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = OBSERVER;
|
||||
entry = entry.parent;
|
||||
}
|
||||
else if ( entry instanceof JournalEntry ) {
|
||||
if ( entry.getUserLevel(game.user) < OBSERVER ) entry.ownership[game.userId] = OBSERVER;
|
||||
}
|
||||
else return;
|
||||
if ( !force && !entry.visible ) return;
|
||||
|
||||
// Show the sheet with the appropriate mode
|
||||
entry.sheet.render(true, options);
|
||||
}
|
||||
}
|
||||
29
resources/app/client/data/collections/macros.js
Normal file
29
resources/app/client/data/collections/macros.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The singleton collection of Macro documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.macros.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Macro} The Macro document
|
||||
* @see {@link MacroDirectory} The MacroDirectory sidebar directory
|
||||
*/
|
||||
class Macros extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Macro";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get directory() {
|
||||
return ui.macros;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
fromCompendium(document, options={}) {
|
||||
const data = super.fromCompendium(document, options);
|
||||
if ( options.clearOwnership ) data.author = game.user.id;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
80
resources/app/client/data/collections/messages.js
Normal file
80
resources/app/client/data/collections/messages.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The singleton collection of ChatMessage documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.messages.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link ChatMessage} The ChatMessage document
|
||||
* @see {@link ChatLog} The ChatLog sidebar directory
|
||||
*/
|
||||
class Messages extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "ChatMessage";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @returns {SidebarTab}
|
||||
* */
|
||||
get directory() {
|
||||
return ui.chat;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
render(force=false) {}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* If requested, dispatch a Chat Bubble UI for the newly created message
|
||||
* @param {ChatMessage} message The ChatMessage document to say
|
||||
* @private
|
||||
*/
|
||||
sayBubble(message) {
|
||||
const {content, style, speaker} = message;
|
||||
if ( speaker.scene === canvas.scene.id ) {
|
||||
const token = canvas.tokens.get(speaker.token);
|
||||
if ( token ) canvas.hud.bubbles.say(token, content, {
|
||||
cssClasses: style === CONST.CHAT_MESSAGE_STYLES.EMOTE ? ["emote"] : []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle export of the chat log to a text file
|
||||
* @private
|
||||
*/
|
||||
export() {
|
||||
const log = this.contents.map(m => m.export()).join("\n---------------------------\n");
|
||||
let date = new Date().toDateString().replace(/\s/g, "-");
|
||||
const filename = `fvtt-log-${date}.txt`;
|
||||
saveDataToFile(log, "text/plain", filename);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Allow for bulk deletion of all chat messages, confirm first with a yes/no dialog.
|
||||
* @see {@link Dialog.confirm}
|
||||
*/
|
||||
async flush() {
|
||||
return Dialog.confirm({
|
||||
title: game.i18n.localize("CHAT.FlushTitle"),
|
||||
content: `<h4>${game.i18n.localize("AreYouSure")}</h4><p>${game.i18n.localize("CHAT.FlushWarning")}</p>`,
|
||||
yes: async () => {
|
||||
await this.documentClass.deleteDocuments([], {deleteAll: true});
|
||||
const jumpToBottomElement = document.querySelector(".jump-to-bottom");
|
||||
jumpToBottomElement.classList.add("hidden");
|
||||
},
|
||||
options: {
|
||||
top: window.innerHeight - 150,
|
||||
left: window.innerWidth - 720
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
59
resources/app/client/data/collections/playlists.js
Normal file
59
resources/app/client/data/collections/playlists.js
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* The singleton collection of Playlist documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.playlists.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Playlist} The Playlist document
|
||||
* @see {@link PlaylistDirectory} The PlaylistDirectory sidebar directory
|
||||
*/
|
||||
class Playlists extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Playlist";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the subset of Playlist documents which are currently playing
|
||||
* @type {Playlist[]}
|
||||
*/
|
||||
get playing() {
|
||||
return this.filter(s => s.playing);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform one-time initialization to begin playback of audio.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async initialize() {
|
||||
await game.audio.unlock;
|
||||
for ( let playlist of this ) {
|
||||
for ( let sound of playlist.sounds ) sound.sync();
|
||||
}
|
||||
ui.playlists?.render();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to a Scene to determine whether to trigger changes to Playlist documents.
|
||||
* @param {Scene} scene The Scene document being updated
|
||||
* @param {Object} data The incremental update data
|
||||
*/
|
||||
async _onChangeScene(scene, data) {
|
||||
const currentScene = game.scenes.active;
|
||||
const p0 = currentScene?.playlist;
|
||||
const s0 = currentScene?.playlistSound;
|
||||
const p1 = ("playlist" in data) ? game.playlists.get(data.playlist) : scene.playlist;
|
||||
const s1 = "playlistSound" in data ? p1?.sounds.get(data.playlistSound) : scene.playlistSound;
|
||||
const soundChange = (p0 !== p1) || (s0 !== s1);
|
||||
if ( soundChange ) {
|
||||
if ( s0 ) await s0.update({playing: false});
|
||||
else if ( p0 ) await p0.stopAll();
|
||||
if ( s1 ) await s1.update({playing: true});
|
||||
else if ( p1 ) await p1.playAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
112
resources/app/client/data/collections/scenes.js
Normal file
112
resources/app/client/data/collections/scenes.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The singleton collection of Scene documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.scenes.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Scene} The Scene document
|
||||
* @see {@link SceneDirectory} The SceneDirectory sidebar directory
|
||||
*/
|
||||
class Scenes extends WorldCollection {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "Scene";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Scene which is currently active
|
||||
* @type {Scene}
|
||||
*/
|
||||
get active() {
|
||||
return this.find(s => s.active);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the current Scene target.
|
||||
* This is the viewed scene if the canvas is active, otherwise it is the currently active scene.
|
||||
* @type {Scene}
|
||||
*/
|
||||
get current() {
|
||||
const canvasInitialized = canvas.ready || game.settings.get("core", "noCanvas");
|
||||
return canvasInitialized ? this.viewed : this.active;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return a reference to the Scene which is currently viewed
|
||||
* @type {Scene}
|
||||
*/
|
||||
get viewed() {
|
||||
return this.find(s => s.isView);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle preloading the art assets for a Scene
|
||||
* @param {string} sceneId The Scene id to begin loading
|
||||
* @param {boolean} push Trigger other connected clients to also preload Scene resources
|
||||
*/
|
||||
async preload(sceneId, push=false) {
|
||||
if ( push ) return game.socket.emit("preloadScene", sceneId, () => this.preload(sceneId));
|
||||
let scene = this.get(sceneId);
|
||||
const promises = [];
|
||||
|
||||
// Preload sounds
|
||||
if ( scene.playlistSound?.path ) promises.push(foundry.audio.AudioHelper.preloadSound(scene.playlistSound.path));
|
||||
else if ( scene.playlist?.playbackOrder.length ) {
|
||||
const first = scene.playlist.sounds.get(scene.playlist.playbackOrder[0]);
|
||||
if ( first ) promises.push(foundry.audio.AudioHelper.preloadSound(first.path));
|
||||
}
|
||||
|
||||
// Preload textures without expiring current ones
|
||||
promises.push(TextureLoader.loadSceneTextures(scene, {expireCache: false}));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Event Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("preloadScene", sceneId => this.instance.preload(sceneId));
|
||||
socket.on("pullToScene", this._pullToScene);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle requests pulling the current User to a specific Scene
|
||||
* @param {string} sceneId
|
||||
* @private
|
||||
*/
|
||||
static _pullToScene(sceneId) {
|
||||
const scene = game.scenes.get(sceneId);
|
||||
if ( scene ) scene.view();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Importing and Exporting */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @inheritdoc */
|
||||
fromCompendium(document, { clearState=true, clearSort=true, ...options }={}) {
|
||||
const data = super.fromCompendium(document, { clearSort, ...options });
|
||||
if ( clearState ) delete data.active;
|
||||
if ( clearSort ) {
|
||||
data.navigation = false;
|
||||
delete data.navOrder;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
41
resources/app/client/data/collections/settings.js
Normal file
41
resources/app/client/data/collections/settings.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The Collection of Setting documents which exist within the active World.
|
||||
* This collection is accessible as game.settings.storage.get("world")
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link Setting} The Setting document
|
||||
*/
|
||||
class WorldSettings extends WorldCollection {
|
||||
|
||||
/** @override */
|
||||
static documentName = "Setting";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get directory() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* World Settings Methods */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the Setting document with the given key.
|
||||
* @param {string} key The setting key
|
||||
* @returns {Setting} The Setting
|
||||
*/
|
||||
getSetting(key) {
|
||||
return this.find(s => s.key === key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the serialized value of the world setting as a string
|
||||
* @param {string} key The setting key
|
||||
* @returns {string|null} The serialized setting string
|
||||
*/
|
||||
getItem(key) {
|
||||
return this.getSetting(key)?.value ?? null;
|
||||
}
|
||||
}
|
||||
41
resources/app/client/data/collections/tables.js
Normal file
41
resources/app/client/data/collections/tables.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The singleton collection of RollTable documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.tables.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link RollTable} The RollTable document
|
||||
* @see {@link RollTableDirectory} The RollTableDirectory sidebar directory
|
||||
*/
|
||||
class RollTables extends WorldCollection {
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Properties */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "RollTable";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get directory() {
|
||||
return ui.tables;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Register world settings related to RollTable documents
|
||||
*/
|
||||
static registerSettings() {
|
||||
|
||||
// Show Player Cursors
|
||||
game.settings.register("core", "animateRollTable", {
|
||||
name: "TABLE.AnimateSetting",
|
||||
hint: "TABLE.AnimateSettingHint",
|
||||
scope: "world",
|
||||
config: true,
|
||||
type: new foundry.data.fields.BooleanField({initial: true})
|
||||
});
|
||||
}
|
||||
}
|
||||
141
resources/app/client/data/collections/users.js
Normal file
141
resources/app/client/data/collections/users.js
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* The singleton collection of User documents which exist within the active World.
|
||||
* This Collection is accessible within the Game object as game.users.
|
||||
* @extends {WorldCollection}
|
||||
*
|
||||
* @see {@link User} The User document
|
||||
*/
|
||||
class Users extends WorldCollection {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
/**
|
||||
* The User document of the currently connected user
|
||||
* @type {User|null}
|
||||
*/
|
||||
this.current = this.current || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialize the Map object and all its contained documents
|
||||
* @private
|
||||
* @override
|
||||
*/
|
||||
_initialize() {
|
||||
super._initialize();
|
||||
|
||||
// Flag the current user
|
||||
this.current = this.get(game.data.userId) || null;
|
||||
if ( this.current ) this.current.active = true;
|
||||
|
||||
// Set initial user activity state
|
||||
for ( let activeId of game.data.activeUsers || [] ) {
|
||||
this.get(activeId).active = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
static documentName = "User";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get the users with player roles
|
||||
* @returns {User[]}
|
||||
*/
|
||||
get players() {
|
||||
return this.filter(u => !u.isGM && u.hasRole("PLAYER"));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get one User who is an active Gamemaster (non-assistant if possible), or null if no active GM is available.
|
||||
* This can be useful for workflows which occur on all clients, but where only one user should take action.
|
||||
* @type {User|null}
|
||||
*/
|
||||
get activeGM() {
|
||||
const activeGMs = game.users.filter(u => u.active && u.isGM);
|
||||
activeGMs.sort((a, b) => (b.role - a.role) || a.id.compare(b.id)); // Alphanumeric sort IDs without using localeCompare
|
||||
return activeGMs[0] || null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Socket Listeners and Handlers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
static _activateSocketListeners(socket) {
|
||||
socket.on("userActivity", this._handleUserActivity);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle receipt of activity data from another User connected to the Game session
|
||||
* @param {string} userId The User id who generated the activity data
|
||||
* @param {ActivityData} activityData The object of activity data
|
||||
* @private
|
||||
*/
|
||||
static _handleUserActivity(userId, activityData={}) {
|
||||
const user = game.users.get(userId);
|
||||
if ( !user || user.isSelf ) return;
|
||||
|
||||
// Update User active state
|
||||
const active = "active" in activityData ? activityData.active : true;
|
||||
if ( user.active !== active ) {
|
||||
user.active = active;
|
||||
game.users.render();
|
||||
ui.nav.render();
|
||||
Hooks.callAll("userConnected", user, active);
|
||||
}
|
||||
|
||||
// Everything below here requires the game to be ready
|
||||
if ( !game.ready ) return;
|
||||
|
||||
// Set viewed scene
|
||||
const sceneChange = ("sceneId" in activityData) && (activityData.sceneId !== user.viewedScene);
|
||||
if ( sceneChange ) {
|
||||
user.viewedScene = activityData.sceneId;
|
||||
ui.nav.render();
|
||||
}
|
||||
|
||||
if ( "av" in activityData ) {
|
||||
game.webrtc.settings.handleUserActivity(userId, activityData.av);
|
||||
}
|
||||
|
||||
// Everything below requires an active canvas
|
||||
if ( !canvas.ready ) return;
|
||||
|
||||
// User control deactivation
|
||||
if ( (active === false) || (user.viewedScene !== canvas.id) ) {
|
||||
canvas.controls.updateCursor(user, null);
|
||||
canvas.controls.updateRuler(user, null);
|
||||
user.updateTokenTargets([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cursor position
|
||||
if ( "cursor" in activityData ) {
|
||||
canvas.controls.updateCursor(user, activityData.cursor);
|
||||
}
|
||||
|
||||
// Was it a ping?
|
||||
if ( "ping" in activityData ) {
|
||||
canvas.controls.handlePing(user, activityData.cursor, activityData.ping);
|
||||
}
|
||||
|
||||
// Ruler measurement
|
||||
if ( "ruler" in activityData ) {
|
||||
canvas.controls.updateRuler(user, activityData.ruler);
|
||||
}
|
||||
|
||||
// Token targets
|
||||
if ( "targets" in activityData ) {
|
||||
user.updateTokenTargets(activityData.targets);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user