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,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;
}
}

View 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";
}

View 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);
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
});
}
}

View 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");
}
}

View 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";
}

View 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);
}
}

View 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;
}
}

View 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
}
});
}
}

View 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();
}
}
}

View 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;
}
}

View 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;
}
}

View 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})
});
}
}

View 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);
}
}
}