bondage-college-mirr/BondageClub/Screens/Room/Crafting/Crafting.js

2420 lines
82 KiB
JavaScript

"use strict";
// NOTE: Keep as `var` to enable `window`-based lookup
/** The background of the crafting screen. */
var CraftingBackground = "CraftingWorkshop";
/**
* The active subscreen within the crafting screen:
* * `"Slot"`: The main crafting screens wherein the {@link CraftingItem} is selected, created or destroyed.
* * `"Name"`: The main menu wherein the crafted item is customized, allowing for the specification of names, descriptions, colors, extended item types, _etc._
* * `"Color"`: A dedicated coloring screen for the crafted item.
* * `"Extended"`: The extended item menu.
* @type {CraftingMode}
*/
let CraftingMode = "Slot";
/** Whether selecting a crafted item in the crafting screen should destroy it. */
let CraftingDestroy = false;
/** The index of the selected crafted item within the crafting screen. */
let CraftingSlot = 0;
/**
* The currently selected crafted item in the crafting screen.
* @type {CraftingItemSelected | null}
*/
let CraftingSelectedItem = null;
/** An offset used for the pagination of {@link Player.Crafting}. */
let CraftingOffset = 0;
/**
* A list of all assets valid for crafting, potentially filtered by a user-provided keyword.
* @type {never}
* @deprecated
*/
let CraftingItemList = /** @type {never} */([]);
/**
* The character used for the crafting preview.
* @type {Character | null}
*/
let CraftingPreview = null;
/** Whether the crafting character preview should be naked or not. */
let CraftingNakedPreview = false;
/** Whether exiting the crafting menu should return you to the chatroom or, otherwise, the main hall. */
let CraftingReturnToChatroom = false;
/** List of item indices collected for swapping.
* @type {number[]}
*/
let CraftingReorderList = [];
/** @type {CraftingReorderType} */
let CraftingReorderMode = "None";
/**
* A record mapping all crafting-valid asset names to a list of matching elligble assets.
*
* Elligble assets are defined as crafting-valid assets with either a matching {@link Asset.Name} or {@link Asset.CraftGroup}.
*
* The first asset in each list is guaranteed to satisfy `Asset.Group.Name === Asset.DynamicGroupName` _if_ any of the list members satisfy this condition.
* @type {Record<string, Asset[]>}
*/
let CraftingAssets = {};
/** The separator used between different crafted items when serializing them. */
const CraftingSerializeItemSep = "§";
/** The separator used between fields within a single crafted item when serializing them. */
const CraftingSerializeFieldSep = "¶";
/**
* Regexp pattern for sanitizing to-be serialized crafted item string data by finding all
* special separator characters (see {@link CraftingSerializeItemSep} and {@link CraftingSerializeFieldSep}).
*/
const CraftingSerializeSanitize = new RegExp(`${CraftingSerializeItemSep}|${CraftingSerializeFieldSep}`);
/**
* Map crafting properties to their respective validation function.
* @type {Map<CraftingPropertyType, (asset: Asset) => boolean>}
*/
const CraftingPropertyMap = new Map([
["Normal", function(Item) { return true; }],
["Large", function(Item) { return CraftingItemHasEffect(Item, CommonKeys(SpeechGagLevelLookup)); }],
["Small", function(Item) { return CraftingItemHasEffect(Item, CommonKeys(SpeechGagLevelLookup)); }],
["Thick", function(Item) { return CraftingItemHasEffect(Item, [...CharacterBlindLevels.keys()]); }],
["Thin", function(Item) { return CraftingItemHasEffect(Item, [...CharacterBlindLevels.keys()]); }],
["Secure", function(Item) { return true; }],
["Loose", function(Item) { return true; }],
["Decoy", function(Item) { return true; }],
["Malleable", function(Item) { return true; }],
["Rigid", function(Item) { return true; }],
["Simple", function(Item) { return Item.AllowLock; }],
["Puzzling", function(Item) { return Item.AllowLock; }],
["Painful", function(Item) { return true; }],
["Comfy", function(Item) { return true; }],
["Strong", function(Item) { return Item.IsRestraint || (Item.Difficulty > 0); }],
["Flexible", function(Item) { return Item.IsRestraint || (Item.Difficulty > 0); }],
["Nimble", function(Item) { return Item.IsRestraint || (Item.Difficulty > 0); }],
["Arousing", function(Item) { return CraftingItemHasEffect(Item, ["Egged", "Vibrating"]); }],
["Dull", function(Item) { return CraftingItemHasEffect(Item, ["Egged", "Vibrating"]); } ],
["Edging", function(Item) { return CraftingItemHasEffect(Item, ["Egged", "Vibrating", "Chaste", "CanEdge", "BreastChaste"]); }],
["Heavy", function(Item) { return CraftingItemHasEffect(Item, ["Slow"]); }],
["Light", function(Item) { return CraftingItemHasEffect(Item, ["Slow"]); }],
]);
/**
* An enum with status codes for crafting validation.
* @property OK - The validation proceded without errors
* @property ERROR - The validation produced one or more errors that were successfully resolved
* @property CRITICAL_ERROR - The validation produced an unrecoverable error
* @type {{OK: 2, ERROR: 1, CRITICAL_ERROR: 0}}
*/
const CraftingStatusType = {
OK: 2,
ERROR: 1,
CRITICAL_ERROR: 0,
};
/**
* The Names of all locks that can be automatically applied to crafted items.
* An empty string implies the absence of a lock.
* @type {readonly (AssetLockType | "")[]}
*/
const CraftingLockList = ["", "MetalPadlock", "IntricatePadlock", "HighSecurityPadlock", "OwnerPadlock", "LoversPadlock", "FamilyPadlock", "MistressPadlock", "PandoraPadlock", "ExclusivePadlock"];
/**
* A set of item property names that should never be stored in {@link CraftingItem.ItemProperty}.
* @type {Set<keyof ItemProperties>}
*/
const CraftingPropertyExclude = new Set([
"HeartRate",
"TriggerCount",
"OrgasmCount",
"RuinedOrgasmCount",
"TimeWorn",
"TimeSinceLastOrgasm",
"BlinkState",
"AutoPunishUndoTime",
"NextShockTime",
]);
const CraftingID = /** @type {const} */({
root: "crafting-screen",
topBar: "crafting-top-bar",
header: "crafting-header",
menuBar: "crafting-menu-bar",
downloadButton: "crafting-download-button",
uploadButton: "crafting-upload-button",
acceptButton: "crafting-accept-button",
cancelButton: "crafting-cancel-button",
exitButton: "crafting-exit-button",
leftPanel: "crafting-left-panel",
assetButton: "crafting-asset-button",
assetPanel: "crafting-asset-panel",
assetGrid: "crafting-asset-grid",
assetSearch: "crafting-asset-search",
assetHeader: "crafting-asset-header",
padlockButton: "crafting-padlock-button",
padlockPanel: "crafting-padlock-panel",
padlockGrid: "crafting-padlock-grid",
padlockSearch: "crafting-padlock-search",
padlockHeader: "crafting-padlock-header",
propertyButton: "crafting-property-button",
propertyPanel: "crafting-property-panel",
propertyGrid: "crafting-property-grid",
propertySearch: "crafting-property-search",
propertyHeader: "crafting-property-header",
centerPanel: "crafting-center-panel",
undressButton: "crafting-undress-button",
rightPanel: "crafting-right-panel",
nameInput: "crafting-name-input",
nameLabel: "crafting-name-label",
descriptionInput: "crafting-description-input",
descriptionLabel: "crafting-description-label",
colorsButton: "crafting-colors-button",
colorsInput: "crafting-colors-input",
colorsLabel: "crafting-colors-label",
layeringInput: "crafting-layering-input",
layeringButton: "crafting-layering-button",
layeringLabel: "crafting-layering-label",
privateCheckbox: "crafting-private-checkbox",
privateLabel: "crafting-private-label",
extendedButton: "crafting-extended-button",
extendedLabel: "crafting-extended-label",
tightenButton: "crafting-tighten-button",
tightenLabel: "crafting-tighten-label",
asciiDescriptionCheckbox: "crafting-ascii-description-checkbox",
asciidescriptionLabel: "crafting-ascii-description-label",
});
var CraftingDescription = {
/**
* Leading character for marking encoded extended crafted item descriptions.
* @readonly
*/
ExtendedDescriptionMarker: /** @type {const} */("\x00"),
/**
* Regex for representing legal UTF16 characters.
* Note the exclusion of control characters (except Newline aka `\n`), `§` (`\xA7`) and `¶` (`\xB6`).
* @readonly
*/
Pattern: /^([\n\x20-\xA6\xA8-\xB5\xB7-\uFFFF]+)?$/,
/**
* Regex for representing legal extended ASCII characters.
* Note the exclusion of control characters (except Newline aka `\n`), `§` (`\xA7`) and `¶` (`\xB6`).
* @readonly
*/
PatternASCII: /^([\n\x20-\xA6\xA8-\xB5\xB7-\xFF]+)?$/,
/**
* Decode and return the passed string if it consists of UTF16-encoded UTF8 characters.
*
* Encoded strings must be marked with a leading {@link CraftingDescription.ExtendedDescriptionMarker}; unencoded strings are returned unmodified.
* @param {string} description - The to-be decoded string
* @returns {string} - The decoded string
*/
Decode: function Decode(description) {
if (!description || typeof description !== "string") {
return "";
}
if (description.startsWith(CraftingDescription.ExtendedDescriptionMarker)) {
return Array.from(description.slice(1, 200)).flatMap(char => {
const id = char.charCodeAt(0);
const bit1 = Math.floor(id / 256);
const bit2 = id - bit1 * 256;
return [bit1, bit2].filter(Boolean).map(i => String.fromCharCode(i));
}).join("");
} else {
return description.slice(0, 200);
}
},
/**
* Decode the passed string and return it as a list of valid {@link Element.append} nodes, converting `\n` characters into `<br>` elements.
* @param {string} description - The to-be decoded string
* @returns {(string | HTMLElement)[]} - The decoded string as a list of nodes
*/
DecodeToHTML: function DecodeToHTML(description) {
const descriptionParsed = CraftingDescription.Decode(description);
return descriptionParsed.split("\n").flatMap(_line => {
const line = _line.trim();
return line ? [line, document.createElement("br")] : [];
}).slice(0, -1);
},
/**
* Encode the passed crafted item description, extracting all UTF8 characters and encoding up to two of them into a single UTF16 character.
*
* The first character is marked with {@link CraftingDescription.ExtendedDescriptionMarker}
* @param {string} description - The initial length <=398 string of UTF8 characters
* @returns {string} - The length <=200 string of UTF16-encoded UTF8 characters
*/
Encode: function Encode(description) {
if (
!description
|| typeof description !== "string"
|| !description.match(CraftingDescription.PatternASCII)
) {
return "";
}
let ret = CraftingDescription.ExtendedDescriptionMarker;
let i = 0;
const iMax = Math.min(199, Math.ceil(description.length / 2));
while (i < iMax) {
const charCodeA = description.charCodeAt(i * 2);
const charCodeB = description.charCodeAt(1 + i * 2);
if (Number.isNaN(charCodeB)) {
ret += String.fromCharCode(charCodeA * 256);
} else {
ret += String.fromCharCode(charCodeA * 256 + charCodeB);
}
i++;
}
return ret;
},
};
/**
* Construct a record mapping all crafting-valid asset names to a list of matching elligble assets.
* Elligble assets are defined as crafting-valid assets with either a matching {@link Asset.Name} or {@link Asset.CraftGroup}.
* @see {@link CraftingAssets}
* @returns {Record<string, Asset[]>}
*/
function CraftingAssetsPopulate() {
/** @type {Record<string, Asset[]>} */
const ret = {};
/** @type {Record<string, Asset[]>} */
const craftGroups = {};
for (const a of Asset) {
if (!a.Group.IsItem() || a.IsLock || !a.Wear || !a.Enable) {
continue;
} else if (a.CraftGroup) {
craftGroups[a.CraftGroup] ??= [];
craftGroups[a.CraftGroup].push(a);
} else {
ret[a.Name] ??= [];
ret[a.Name].push(a);
}
}
for (const assetList of Object.values(craftGroups)) {
const names = new Set(assetList.map(a => a.Name));
for (const name of names) {
ret[name] ??= [];
ret[name].push(...assetList);
}
}
// Ensure that the first member satisfies `Asset.Group.Name === Asset.DynamicGroupName` if possible at all
for (const assetList of Object.values(ret)) {
assetList.sort((a1, a2) => {
if (a1.CraftGroup === a1.Name && a2.CraftGroup !== a2.Name) {
return -1;
} else if (a1.CraftGroup !== a1.Name && a2.CraftGroup === a2.Name) {
return 1;
} else if (a1.Group.Name === a1.DynamicGroupName && a2.Group.Name !== a2.DynamicGroupName) {
return -1;
} else if (a1.Group.Name !== a1.DynamicGroupName && a2.Group.Name === a2.DynamicGroupName) {
return 1;
} else {
return (
a1.Group.Category.localeCompare(a2.Group.Category)
|| a1.Group.Name.localeCompare(a2.Group.Name)
|| a1.Name.localeCompare(a2.Name)
);
}
});
}
return ret;
}
/**
* Returns TRUE if a crafting item has an effect from a list or allows that effect
* @param {Asset} Item - The item asset to validate
* @param {EffectName[]} Effect - The list of effects to validate
* @returns {Boolean} - TRUE if the item has that effect
*/
function CraftingItemHasEffect(Item, Effect) {
if (Item.Effect != null)
for (let E of Effect)
if (Item.Effect.indexOf(E) >= 0)
return true;
if (Item.AllowEffect != null)
for (let E of Effect)
if (Item.AllowEffect.indexOf(E) >= 0)
return true;
return false;
}
/**
* Shows the crating screen and remember if the entry came from an online chat room
* @param {boolean} FromChatRoom - TRUE if we come from an online chat room
* @returns {void} - Nothing
*/
function CraftingShowScreen(FromChatRoom) {
CraftingReturnToChatroom = FromChatRoom;
CommonSetScreen("Room", "Crafting");
}
var CraftingEventListeners = {
/**
* @private
* @type {(this: HTMLInputElement, ev: Event) => void}
*/
_ClickPrivate: function _ClickPrivate() {
if (CraftingSelectedItem) {
CraftingSelectedItem.Private = this.checked;
}
},
/**
* @private
* @type {(this: HTMLInputElement, ev: Event) => void}
*/
_InputLayering: function _InputLayering() {
if (CraftingSelectedItem) {
const value = (this.defaultValue !== this.value && !Number.isNaN(this.valueAsNumber)) ? this.valueAsNumber : undefined;
if (value !== CraftingSelectedItem.OverridePriority) {
CraftingSelectedItem.ItemProperty.OverridePriority = value;
CraftingUpdatePreview();
}
}
},
/**
* @private
* @type {(this: HTMLInputElement, ev: Event) => void}
*/
_ChangeName: function _ChangeName() {
if (CraftingSelectedItem) {
CraftingSelectedItem.Name = this.value.trim() || this.defaultValue;
}
},
/**
* @private
* @type {(this: HTMLTextAreaElement, ev: Event) => void}
*/
_ChangeDescription: function _ChangeDescription() {
if (CraftingSelectedItem) {
const asciiDescriptionCheckbox = /** @type {null | HTMLInputElement} */(document.getElementById(CraftingID.asciiDescriptionCheckbox));
if (asciiDescriptionCheckbox?.checked) {
CraftingSelectedItem.Description = CraftingDescription.Encode(this.value.trim());
} else {
CraftingSelectedItem.Description = this.value.trim();
}
}
},
/**
* @private
* @type {(this: HTMLTextAreaElement | HTMLInputElement, ev: Event) => void}
*/
_InputDescription: function _InputDescription() {
this.setCustomValidity(this.value.match(this.dataset.pattern) ? "" : "patternMismatch");
},
/**
* @private
* @type {(this: HTMLInputElement, ev: Event) => void}
*/
_ChangeColor: function _ChangeColor() {
if (CraftingSelectedItem) {
const value = this.value.trim() || this.defaultValue;
if (value !== CraftingSelectedItem.Color) {
CraftingSelectedItem.Color = value;
CraftingUpdatePreview();
}
if (!this.checkValidity()) {
this.value = this.defaultValue;
}
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickExtended: function _ClickExtended() {
if (CraftingPreview && CraftingSelectedItem?.Asset?.Extended) {
const item = InventoryGet(CraftingPreview, CraftingSelectedItem.Asset.DynamicGroupName);
if (item) {
DialogExtendItem(item);
CraftingModeSet("Extended");
}
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickTighten: function _ClickTighten() {
if (!CraftingPreview || !CraftingSelectedItem?.Asset.AllowTighten) {
return;
}
const item = InventoryGet(CraftingPreview, CraftingSelectedItem.Asset.DynamicGroupName);
if (item) {
// Make sure that all expected modifiers are present so that the difficulty factor can easily be extracted afterwards by extracting a deterministic value
item.Craft.Property = CraftingSelectedItem.Property;
item.Difficulty = (
item.Asset.Difficulty
+ SkillGetLevel(Player, "Bondage")
+ (item.Craft?.Property === "Secure" ? 4 : 0)
+ CraftingSelectedItem.DifficultyFactor
);
DialogSetTightenLoosenItem(item);
CraftingModeSet("Tighten");
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickLayering: function _ClickLayering() {
if (CraftingPreview && CraftingSelectedItem?.Asset) {
const item = InventoryGet(CraftingPreview, CraftingSelectedItem.Asset.DynamicGroupName);
if (item) {
Layering.Init(item, CraftingPreview, {
x: Layering.DisplayDefault.x,
y: Layering.DisplayDefault.y - 10,
w: Layering.DisplayDefault.w + 10,
h: Layering.DisplayDefault.h + 10,
buttonGap: 15,
});
CraftingModeSet("OverridePriority");
}
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickColors: function _ClickColors() {
if (CraftingPreview && CraftingSelectedItem?.Asset) {
const item = InventoryGet(CraftingPreview, CraftingSelectedItem.Asset.DynamicGroupName);
if (item) {
CraftingModeSet("Color");
ItemColorLoad(CraftingPreview, item, 1200, 25, 775, 950, true);
ItemColorOnExit((c, i) => {
CraftingSelectedItem.Color = (Array.isArray(i.Color) ? i.Color.join(",") : i.Color) || "Default";
ElementValue(CraftingID.colorsInput, CraftingSelectedItem.Color);
CraftingModeSet("Name");
});
}
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickUndress: function _ClickUndress() {
CraftingNakedPreview = !CraftingNakedPreview;
CraftingUpdatePreview();
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickAccept: function _ClickAccept() {
// blur the active element in order to trigger any `change` event listeners
document.activeElement?.dispatchEvent(new Event("blur"));
Player.Crafting[CraftingSlot] = CraftingConvertSelectedToItem();
CraftingSaveServer();
CraftingExit(false);
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickExit: function _ClickExit() {
CraftingExit(false);
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickUpload: function _ClickUpload() {
const settingsString = prompt(TextGet("UploadPrompt"));
if (settingsString == null) {
return; // The user explicitly clicked cancel; abort without further warning
} else if (!settingsString) {
alert(TextGet("UploadFailure"));
return;
}
const craft = CommonJSONParse(LZString.decompressFromBase64(settingsString));
if (!craft) {
alert(TextGet("UploadFailure"));
return;
}
const status = CraftingValidate(craft, null, true, true);
switch (status) {
case CraftingStatusType.ERROR:
case CraftingStatusType.OK:
CraftingExitResetElements();
CraftingSelectedItem = CraftingConvertItemToSelected(craft);
document.querySelector(`#${CraftingID.assetGrid} [name='${CraftingSelectedItem.Asset.Name}'][data-group='${CraftingSelectedItem.Asset.DynamicGroupName}']`)?.dispatchEvent(new Event("click"));
alert(TextGet("UploadSucces"));
return;
case CraftingStatusType.CRITICAL_ERROR:
alert(TextGet("UploadFailure"));
return;
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickDownload: function _ClickDownload() {
const craft = CraftingConvertSelectedToItem();
navigator.clipboard.writeText(LZString.compressToBase64(JSON.stringify(craft)));
alert(TextGet("DownloadSucces"));
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickExpand: function _ClickExpand() {
if (this.getAttribute("aria-expanded") !== "true") {
return;
}
// Focus the search input in the side pannel if it exists
const panel = document.querySelector(`#${this.getAttribute("aria-controls")} > .crafting-grid`);
const activeRadio = panel?.querySelector(`[aria-checked='true']`);
if (activeRadio) {
activeRadio.scrollIntoView();
} else {
panel?.scrollTo({ top: 0 });
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickProperty: function _ClickProperty() {
CraftingSelectedItem.Property = /** @type {CraftingPropertyType} */(this.name);
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickPadlock: function _ClickPadlock() {
const newLock = this.getAttribute("aria-checked") === "true" ? AssetGet("Female3DCG", "ItemMisc", this.name) : null;
const needsRefresh = (!newLock && CraftingSelectedItem.Lock) || (newLock && !CraftingSelectedItem);
CraftingSelectedItem.Lock = newLock;
if (needsRefresh) {
CraftingUpdatePreview();
}
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickAsset: function _ClickAsset() {
const assets = CraftingAssets[this.name];
if (!assets) {
return;
}
// Only relevant when switching between two assets from within the `Name` subscreen
const needsPropertyUpdate = !CraftingSelectedItem.Asset || CraftingSelectedItem.Asset !== assets[0];
CraftingSelectedItem.Assets = assets;
// Set the various input fields
const [nameInput, colorsInput, descriptionInput, priorityInput, privateInput, asciiDescriptionCheckbox] = /** @type {HTMLInputElement[]} */([
document.getElementById(CraftingID.nameInput),
document.getElementById(CraftingID.colorsInput),
document.getElementById(CraftingID.descriptionInput),
document.getElementById(CraftingID.layeringInput),
document.getElementById(CraftingID.privateCheckbox),
document.getElementById(CraftingID.asciiDescriptionCheckbox),
]);
priorityInput.defaultValue = priorityInput.placeholder = (CraftingSelectedItem.Asset.DrawingPriority ?? CraftingSelectedItem.Asset.Group.DrawingPriority).toString();
colorsInput.defaultValue = colorsInput.placeholder = CraftingSelectedItem.Asset.DefaultColor.join(",");
nameInput.defaultValue = nameInput.placeholder = CraftingSelectedItem.Asset.Description;
nameInput.value = CraftingSelectedItem.Name;
descriptionInput.value = CraftingDescription.Decode(CraftingSelectedItem.Description);
privateInput.checked = CraftingSelectedItem.Private;
const hasExtendedDescription = CraftingSelectedItem.Description.startsWith(CraftingDescription.ExtendedDescriptionMarker);
if (asciiDescriptionCheckbox.checked != hasExtendedDescription) {
asciiDescriptionCheckbox.click();
}
// Either we're switching between two distinct assets (in which case color and the likes cannot safely be assumed to be compatible) or we're just initializing everything after opening the `Name` subscreen
if (needsPropertyUpdate) {
CraftingSelectedItem.ItemProperty = {};
priorityInput.value = priorityInput.defaultValue;
colorsInput.value = CraftingSelectedItem.Color = colorsInput.defaultValue;
} else {
priorityInput.value = typeof CraftingSelectedItem.OverridePriority === "number" ? CraftingSelectedItem.OverridePriority.toString() : priorityInput.defaultValue;
colorsInput.value = CraftingSelectedItem.Color;
}
// Re-enable the accept button as we now have an asset selected
document.getElementById(CraftingID.acceptButton)?.setAttribute("aria-disabled", "false");
// Disable the buttons for all invalid properties
document.querySelectorAll(`#${CraftingID.propertyGrid} [name]`).forEach(e => {
const propertyType = /** @type {CraftingPropertyType} */(e.getAttribute("name"));
const callback = CraftingPropertyMap.get(propertyType);
e.setAttribute("aria-disabled", propertyType === "Normal" || (callback && CraftingSelectedItem.Assets.some(a => callback(a))) ? "false" : "true");
});
// Set the item property, falling back to `Normal` of an invalid property is selected
const propertyCallback = CraftingPropertyMap.get(CraftingSelectedItem.Property);
if (propertyCallback && CraftingSelectedItem.Assets.some(a => propertyCallback(a))) {
document.querySelector(`#${CraftingID.propertyGrid} [name='${CraftingSelectedItem.Property}'][aria-checked='false']`)?.dispatchEvent(new Event("click"));
} else {
document.querySelector(`#${CraftingID.propertyGrid} [name='Normal'][aria-checked='false']`)?.dispatchEvent(new Event("click"));
}
// Disable the extended item config button for non-extended items
const [extendedButton, colorButton, layeringButton, tightenButton] = /** @type {HTMLButtonElement[]} */([
document.getElementById(CraftingID.extendedButton),
document.getElementById(CraftingID.colorsButton),
document.getElementById(CraftingID.layeringButton),
document.getElementById(CraftingID.tightenButton),
]);
extendedButton.disabled = !CraftingSelectedItem.Asset.Extended;
tightenButton.disabled = !CraftingSelectedItem.Asset.AllowTighten;
colorButton.disabled = colorsInput.disabled = !DialogCanColor(Player, { Asset: CraftingSelectedItem.Asset });
layeringButton.disabled = false;
priorityInput.disabled = false;
// Set the lock, removing any locks it the item does not support them
const allowLock = CraftingSelectedItem.Assets.some(a => a.AllowLock);
if (CraftingSelectedItem.Lock && allowLock) {
const lockButton = document.querySelector(`#${CraftingID.padlockGrid} [name='${CraftingSelectedItem.Lock.Name}'][aria-checked='false']`);
lockButton?.setAttribute("aria-disabled", "false");
lockButton?.dispatchEvent(new Event("click"));
} else {
const lockButton = document.querySelector(`#${CraftingID.padlockGrid} [name][aria-checked='true']`);
lockButton?.setAttribute("aria-disabled", "false");
lockButton?.dispatchEvent(new Event("click"));
}
// Disable the lock buttons if none of the items supports any lock
if (allowLock) {
document.querySelectorAll(`#${CraftingID.padlockGrid} [name]`).forEach(e => e.setAttribute("aria-disabled", "false"));
} else {
document.querySelectorAll(`#${CraftingID.padlockGrid} [name]`).forEach(e => e.setAttribute("aria-disabled", "true"));
}
CraftingUpdatePreview();
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: Event) => void}
*/
_ClickRadio: function _ClickRadio(ev) {
if (!CraftingSelectedItem) {
ev.stopImmediatePropagation();
return;
}
const sidePannel = this.closest(".crafting-panel");
const controlButton = document.querySelector(`[aria-controls='${sidePannel?.id}']`);
if (!sidePannel || !controlButton) {
return;
}
if (this.getAttribute("aria-checked") === "true") {
controlButton.innerHTML = this.innerHTML;
return;
} else {
controlButton.innerHTML = "";
}
switch (controlButton.id) {
case CraftingID.padlockButton:
ElementButton._ParseLabel(controlButton.id, TextGet("NoLock"), "bottom", { parent: controlButton });
ElementButton._ParseImage(controlButton.id, "./Icons/NoLock.png", { parent: controlButton });
break;
case CraftingID.propertyButton:
ElementButton._ParseLabel(controlButton.id, TextGet(`PropertyNormal`) + ": " + TextGet(`DescriptionNormal`), null, { parent: controlButton });
break;
}
},
/**
* @private
* @type {(this: HTMLInputElement, ev: Event) => Promise<void>}
*/
_InputSearch: async function _InputSearch() {
const query = this.value.toUpperCase().trim();
const searchResultCandidates = document.getElementById(this.getAttribute("aria-controls"));
searchResultCandidates?.querySelectorAll("button.button").forEach(button => {
const label = button.querySelector(".button-label");
if (label) {
const displayStyle = (button.getAttribute("aria-checked") === "true" || label.textContent.toUpperCase().includes(query)) ? false : true;
button.toggleAttribute("data-unload", displayStyle);
}
});
},
/**
* @private
* @type {(this: HTMLInputElement, ev: Event) => void}
*/
_ClickAsciiDescription: function _ClickAsciiDescription() {
const descriptionInput = /** @type {null | HTMLInputElement} */(document.getElementById(CraftingID.descriptionInput));
if (!descriptionInput) {
return;
}
descriptionInput.dataset.pattern = this.checked ? CraftingDescription.PatternASCII.source : CraftingDescription.Pattern.source;
descriptionInput.maxLength = this.checked ? 398 : 200;
descriptionInput.previousSibling.textContent = TextGet(this.checked ? "EnterDescriptionLong" : "EnterDescription");
if (descriptionInput.value.length > descriptionInput.maxLength) {
// Can't reliably update `ValidityState.tooLong` programmatically after changing the max length (even with input/change event dispatching),
// so as a work around just do it manually via a custom error
descriptionInput.setCustomValidity("tooLong");
} else if (descriptionInput.value.length <= descriptionInput.maxLength && descriptionInput.validationMessage === "tooLong") {
descriptionInput.setCustomValidity("");
}
descriptionInput.dispatchEvent(new InputEvent("input"));
},
/**
* @private
* @type {(this: HTMLButtonElement, ev: MouseEvent) => void}
*/
_ClickGroup: function _ClickGroup(ev) {
const groupName = /** @type {AssetGroupItemName} */(this.name);
const assetList = document.getElementById(CraftingID.assetGrid);
if (!assetList) {
ev.stopImmediatePropagation();
return;
}
if (this.getAttribute("aria-checked") === "true") {
// Apply the filtering
for (const button of assetList.children) {
button.toggleAttribute("data-unload-group", button.getAttribute("data-group") !== groupName);
}
// Update the label of the asset panel
document.querySelector(`#${CraftingID.assetHeader} > span`)?.replaceChildren(
`${TextGet("SelectItem")} ${TextGet("SelectItemSuffix")} ${this.getAttribute("aria-label").toLocaleLowerCase()}`,
);
// Make sure that the asset panel is open and scroll to the top
document.querySelector(`#${CraftingID.assetButton}[aria-checked="false"]`)?.dispatchEvent(new MouseEvent("click"));
assetList.scrollTo({ top: 0 });
} else {
document.querySelector(`#${CraftingID.assetHeader} > span`)?.replaceChildren(TextGet("SelectItem"));
for (const button of assetList.children) {
button.toggleAttribute("data-unload-group", false);
}
const checked = assetList.querySelector("[aria-checked='true']");
if (checked) {
checked.scrollIntoView({ behavior: "instant" });
} else {
assetList.scrollTo({ top: 0 });
}
}
},
/**
* @private
* @type {(this: HTMLInputElement, ev: FocusEvent) => Promise<void>}
*/
_FocusSearchAsset: async function _FocusSearchAsset(ev) {
const focusGrid = document.getElementById(CraftingID.centerPanel);
if (!focusGrid) {
ev.stopImmediatePropagation();
return;
}
const group = /** @type {"ALL" | AssetGroupItemName} */(focusGrid.querySelector("[role='radio'][aria-checked='true']")?.getAttribute("name") ?? "ALL");
const cachedGroup = this.getAttribute("data-group");
if (cachedGroup === group) {
return;
}
let options = CraftingElements._SearchCache.get(group);
if (!options) {
const searchResults = document.getElementById(this.getAttribute("aria-controls"));
const query = group === "ALL" ? ".button-label" : `[data-group='${group}'] .button-label`;
options = Array.from(searchResults?.querySelectorAll(query) ?? []).map(e => ElementCreate({ tag: "option", attributes: { value: e.textContent }}));
CraftingElements._SearchCache.set(group, options);
}
this.list?.replaceChildren(...options);
this.setAttribute("data-group", group);
},
/**
* @private
* @type {(this: HTMLInputElement, ev: FocusEvent) => Promise<void>}
*/
_FocusSearch: async function _FocusSearch(ev) {
if (this.list?.options.length) {
return;
}
const searchResults = document.getElementById(this.getAttribute("aria-controls"));
const options = Array.from(searchResults?.querySelectorAll(".button-label") ?? []).map(e => ElementCreate({ tag: "option", attributes: { value: e.textContent }}));
this.list?.replaceChildren(...options);
},
};
var CraftingElements = {
/**
* @private
* @param {string} id
* @param {string} controls
* @param {string} placeholder
* @returns {HTMLInputElement}
*/
_SearchInput: function _SearchInput(id, controls, placeholder, assetSearch=false) {
return ElementCreate({
tag: "input",
attributes: {
type: "search",
id,
placeholder,
list: `${id}-datalist`,
size: 0,
"aria-controls": controls,
},
dataAttributes: {
group: undefined, // Initialized and managed by the `focus` event listener for asset searches
},
eventListeners: {
input: CraftingEventListeners._InputSearch,
focus: assetSearch ? CraftingEventListeners._FocusSearchAsset : CraftingEventListeners._FocusSearch,
},
children: [
{ tag: "datalist", attributes: { id: `${id}-datalist` } },
],
});
},
/**
* @type {Map<"ALL" | AssetGroupItemName, readonly HTMLOptionElement[]>}
*/
_SearchCache: new Map(),
/**
* @private
* @param {string} id
* @param {(this: HTMLButtonElement, ev: Event) => any} onClick
* @param {null | Asset} asset
* @param {null | Partial<Record<string, string | number | boolean>>} attributes
* @param {null | string} label
* @param {null | readonly (string | Node)[]} children
* @param {null | Asset} asset
* @param {boolean} first
* @returns {HTMLButtonElement}
*/
_RadioButton: function _RadioButton(id, onClick, asset, attributes=null, label=null, children=null, first=false) {
/** @type {HTMLButtonElement} */
let ret;
if (asset) {
ret = ElementButton.CreateForAsset(
id, asset, null, CraftingEventListeners._ClickRadio,
{ role: "radio", tooltip: [] },
{ button: { attributes: { tabindex: first ? 0 : -1 }, parent: ElementNoParent }},
);
} else {
ret = ElementButton.Create(
id,
CraftingEventListeners._ClickRadio,
{ label, role: "radio" },
{ button: { children, attributes: { tabindex: first ? 0 : -1, ...(attributes ?? {}) }, parent: ElementNoParent }},
);
}
ret.addEventListener("click", onClick);
return ret;
},
};
/**
* Loads the club crafting room in slot selection mode, creates a dummy character for previews
* @returns {void} - Nothing
*/
function CraftingLoad() {
Player.Crafting ??= [];
// Re-enable previously disabled items if the player now owns them
for (const item of Player.Crafting) {
if (item == null) {
continue;
}
const asset = CraftingAssets[item.Item]?.[0];
if (item.Disabled && asset && InventoryAvailable(Player, item.Name, asset.DynamicGroupName)) {
delete item.Disabled;
}
}
// Abort if we're loading an already-loaded screen
if (CraftingPreview) {
return;
}
CraftingPreview = CharacterLoadSimple(`CraftingPreview-${Player.MemberNumber}`);
CraftingPreview.Appearance = [...Player.Appearance];
CraftingPreview.Crafting = JSON.parse(JSON.stringify(Player.Crafting));
// Declare the preview character as being owned/loved by the player so any owner-/lover-related validation checks pass
CraftingPreview.Owner = Player.Name;
CraftingPreview.Ownership = { MemberNumber: Player.MemberNumber, Name: Player.Name, Start: CommonTime(), Stage: 1 };
CraftingPreview.Lovership = [
{ MemberNumber: Player.MemberNumber, Name: Player.Name, Start: CommonTime(), Stage: 2 },
];
// @ts-expect-error: partially initialized interface
CraftingPreview.OnlineSharedSettings = {
ItemsAffectExpressions: false,
};
CharacterReleaseTotal(CraftingPreview);
const parent = ElementCreate({
tag: "div",
style: { display: "none" },
attributes: { id: CraftingID.root, "screen-generated": CurrentScreen, "aria-busy": "true" },
classList: ["HideOnPopup"],
parent: document.body,
});
TextScreenCache?.loadedPromise.then(async () => {
ElementCreate({
tag: "div",
attributes: { id: CraftingID.topBar },
parent,
children: [
{ tag: "h1", attributes: { id: CraftingID.header }, children: [TextGet("SelectName")] },
ElementMenu.Create(
CraftingID.menuBar,
[
ElementButton.Create(CraftingID.exitButton, CraftingEventListeners._ClickExit, { tooltip: TextGet("Exit") }),
ElementButton.Create(CraftingID.cancelButton, CraftingEventListeners._ClickExit, { tooltip: TextGet("Cancel") }),
ElementButton.Create(
CraftingID.acceptButton, CraftingEventListeners._ClickAccept,
{
disabled: true,
tooltip: [
TextGet("Accept"),
ElementCreate({ tag: "span", children: [TextGet("AcceptInvalid")], attributes: { id: `${CraftingID.acceptButton}-tooltip-disabled` } }),
],
},
),
ElementButton.Create(CraftingID.uploadButton, CraftingEventListeners._ClickUpload, { tooltip: TextGet("Upload") }),
ElementButton.Create(CraftingID.downloadButton, CraftingEventListeners._ClickDownload, { tooltip: TextGet("Download") }),
ElementButton.Create(
CraftingID.undressButton, CraftingEventListeners._ClickUndress,
{ tooltip: TextGet("Undress"), role: "menuitemcheckbox" },
{ button: { attributes: { "aria-checked": CraftingNakedPreview ? "true" : "false" } } },
),
],
{ direction: "rtl" },
),
],
});
// Go home eslint, you're drunk
// eslint-disable-next-line no-unused-expressions
ElementMenu.Create(
CraftingID.leftPanel,
[
ElementButton.Create(
CraftingID.propertyButton, CraftingEventListeners._ClickExpand,
{ role: "menuitemradio" },
{ button: {
attributes: { "aria-expanded": "false", "aria-controls": CraftingID.propertyPanel },
children: [TextGet(`PropertyNormal`) + ": " + TextGet(`DescriptionNormal`)],
}},
),
ElementButton.Create(
CraftingID.assetButton, CraftingEventListeners._ClickExpand,
{ role: "menuitemradio", label: TextGet("SelectItem"), image: "./Icons/NoCraft.png" },
{ button: { attributes: { "aria-expanded": "false", "aria-controls": CraftingID.assetPanel } } },
),
ElementButton.Create(
CraftingID.padlockButton, CraftingEventListeners._ClickExpand,
{ role: "menuitemradio", label: TextGet("NoLock"), image: "./Icons/NoLock.png" },
{ button: { attributes: { "aria-expanded": "false", "aria-controls": CraftingID.padlockPanel } } },
),
{
tag: "div",
attributes: {
id: CraftingID.propertyPanel,
"aria-labelledby": CraftingID.propertyHeader,
},
classList: ["crafting-panel"],
children: [
{
tag: "label",
classList: ["crafting-label"],
attributes: { id: CraftingID.propertyHeader },
children: [
{ tag: "span", children: [TextGet("SelectProperty")] },
CraftingElements._SearchInput(CraftingID.propertySearch, CraftingID.propertyGrid, TextGet("FilterProperty")),
],
},
{
tag: "div",
classList: ["crafting-grid", "scroll-box"],
attributes: { id: CraftingID.propertyGrid, role: "radiogroup", "aria-required": "true" },
children: Array.from(CraftingPropertyMap.keys()).map((property, i) => {
return CraftingElements._RadioButton(
`${CraftingID.propertyButton}-${property}`,
CraftingEventListeners._ClickProperty,
null,
{ name: property },
TextGet(`Property${property}`),
[TextGet(`Property${property}`) + ": " + TextGet(`Description${property}`)],
i === 0,
);
}),
},
],
},
{
tag: "div",
attributes: {
id: CraftingID.assetPanel,
"aria-labelledby": CraftingID.assetHeader,
},
classList: ["crafting-panel"],
children: [
{
tag: "label",
classList: ["crafting-label"],
attributes: { id: CraftingID.assetHeader },
children: [
{ tag: "span", children: [TextGet("SelectItem")] },
CraftingElements._SearchInput(CraftingID.assetSearch, CraftingID.assetGrid, TextGet("FilterAsset"), true),
],
},
{
tag: "div",
classList: ["crafting-grid", "scroll-box"],
attributes: { id: CraftingID.assetGrid, role: "radiogroup", "aria-required": "true" },
children: CraftingItemListBuild().map((a, i) => {
return CraftingElements._RadioButton(
CraftingID.assetButton,
CraftingEventListeners._ClickAsset,
a,
null,
null,
null,
i === 0,
);
}),
},
],
},
{
tag: "div",
attributes: {
id: CraftingID.padlockPanel,
"aria-labelledby": CraftingID.padlockHeader,
"aria-orientation": "vertical",
},
classList: ["crafting-panel"],
children: [
{
tag: "label",
classList: ["crafting-label"],
attributes: { id: CraftingID.padlockHeader },
children: [
{ tag: "span", children: [TextGet("SelectLock")] },
CraftingElements._SearchInput(CraftingID.padlockSearch, CraftingID.padlockGrid, TextGet("FilterLock")),
],
},
{
tag: "div",
classList: ["crafting-grid", "scroll-box"],
attributes: { id: CraftingID.padlockGrid, role: "radiogroup" },
children: CraftingLockList.filter(name => !!name && InventoryAvailable(Player, name, "ItemMisc")).map((name, i) => {
const a = AssetGet("Female3DCG", "ItemMisc", name);
return CraftingElements._RadioButton(
`${CraftingID.padlockButton}-${a.Name}`, CraftingEventListeners._ClickPadlock, a, null, null, null, i === 0,
);
}).sort((a1, a2) => a1.name.localeCompare(a2.name)),
},
],
},
],
undefined,
{ menu: { attributes: { "aria-orientation": "vertical" }, parent } },
),
parent.append(DialogFocusGroup.Create(CraftingID.centerPanel, CraftingEventListeners._ClickGroup, { useDynamicGroupName: true }));
ElementCreate({
tag: "div",
parent,
attributes: { id: CraftingID.rightPanel },
classList: ["scroll-box"],
children: [
{
tag: "label",
attributes: { id: CraftingID.nameLabel },
classList: ["crafting-label"],
children: [
{ tag: "span", children: [TextGet("EnterName")] },
{
// NOTE: Perform the pattern checking via JS as there are differences in how plain JS `RegExp` and `HTMLInputElement.pattern`
// handle their pattern matching with characters larger than /u+FFFF
tag: "input",
attributes: { id: CraftingID.nameInput, type: "input", maxLength: "30", size: 0 },
dataAttributes: { pattern: CraftingDescription.Pattern.source },
eventListeners: {
change: CraftingEventListeners._ChangeName,
input: CraftingEventListeners._InputDescription,
},
},
],
},
{
tag: "label",
attributes: { id: CraftingID.descriptionLabel },
classList: ["crafting-label"],
children: [
{ tag: "span", children: [TextGet("EnterDescription")] },
{
tag: "textarea",
attributes: { id: CraftingID.descriptionInput, maxLength: "200", size: 0 },
dataAttributes: { pattern: CraftingDescription.Pattern.source },
eventListeners: {
change: CraftingEventListeners._ChangeDescription,
input: CraftingEventListeners._InputDescription,
},
},
],
},
{
tag: "label",
attributes: { id: CraftingID.colorsLabel },
classList: ["crafting-label"],
children: [
{ tag: "span", children: [TextGet("EnterColor")] },
{
tag: "input",
attributes: { id: CraftingID.colorsInput, type: "text", size: 0, disabled: "true" },
eventListeners: { change: CraftingEventListeners._ChangeColor },
},
ElementButton.Create(CraftingID.colorsButton, CraftingEventListeners._ClickColors, { disabled: true }),
],
},
{
tag: "label",
attributes: { id: CraftingID.layeringLabel },
classList: ["crafting-label"],
children: [
{
tag: "input",
attributes: { id: CraftingID.layeringInput, type: "number", min: -99, max: 99, inputmode: "numeric", size: 0, disabled: "true" },
eventListeners: { blur: ElementNumberInputBlur, wheel: ElementNumberInputWheel, input: CraftingEventListeners._InputLayering },
},
ElementButton.Create(CraftingID.layeringButton, CraftingEventListeners._ClickLayering, { disabled: true }),
{ tag: "span", children: [TextGet("EnterPriority")] },
],
},
{
tag: "label",
attributes: { id: CraftingID.privateLabel },
classList: ["crafting-label"],
children: [
ElementCheckbox.Create(CraftingID.privateCheckbox, CraftingEventListeners._ClickPrivate),
{ tag: "span", children: [TextGet("EnterPrivate")] },
],
},
{
tag: "label",
attributes: { id: CraftingID.extendedLabel },
classList: ["crafting-label"],
children: [
ElementButton.Create(CraftingID.extendedButton, CraftingEventListeners._ClickExtended, { disabled: true }),
{ tag: "span", children: [TextGet("EnterType")] },
],
},
{
tag: "label",
attributes: { id: CraftingID.tightenLabel },
classList: ["crafting-label"],
children: [
ElementButton.Create(CraftingID.tightenButton, CraftingEventListeners._ClickTighten, { disabled: true }),
{ tag: "span", children: [TextGet("EnterTighten")] },
],
},
{
tag: "label",
attributes: { id: CraftingID.asciidescriptionLabel },
classList: ["crafting-label"],
children: [
ElementCheckbox.Create(CraftingID.asciiDescriptionCheckbox, CraftingEventListeners._ClickAsciiDescription),
{ tag: "span", children: [TextGet("EnterExtendedDescription")] },
],
},
],
});
parent.setAttribute("aria-busy", "false");
});
}
/**
* Update the crafting character preview image, applies the item on all possible body parts
*/
function CraftingUpdatePreview() {
CraftingPreview.Appearance = Player.Appearance.slice();
CharacterReleaseTotal(CraftingPreview, false);
if (CraftingNakedPreview) CharacterNaked(CraftingPreview, false);
if (!CraftingSelectedItem) return;
const Craft = CraftingConvertSelectedToItem();
const FoundGroups = new Set();
const RelevantAssets = (CraftingAssets[Craft.Item] ?? []).filter(a => {
if (FoundGroups.has(a.DynamicGroupName)) {
return false;
} else {
FoundGroups.add(a.DynamicGroupName);
return true;
}
});
for (const RelevantAsset of RelevantAssets) {
InventoryWear(CraftingPreview, RelevantAsset.Name, RelevantAsset.DynamicGroupName, null, null, Player.MemberNumber, Craft, false);
// Hack for the stuff in ItemAddons, since there's no way to resolve their prerequisites
if (RelevantAsset.Prerequisite.includes("OnBed")) {
const bedType = RelevantAsset.Name.includes("Medical") ? "MedicalBed" : "Bed";
const bed = AssetGet(CraftingPreview.AssetFamily, "ItemDevices", bedType);
InventoryWear(CraftingPreview, bed.Name, bed.DynamicGroupName, null, null, Player.MemberNumber, null, false);
}
}
CharacterRefresh(CraftingPreview, false, false);
}
/**
* Run the club crafting room if all possible modes
* @returns {void} - Nothing
*/
function CraftingRun() {
// In slot selection mode, we show the slots to select from
if (CraftingMode == "Slot") {
let BGColor;
let TrashCancel = false;
switch (CraftingReorderMode) {
case "None":
BGColor = CraftingDestroy ? "Pink" : "White";
break;
case "Select":
BGColor = "Yellow";
break;
case "Place":
BGColor = "Grey";
break;
}
DrawButton(1475, 15, 90, 90, "", "White", "Icons/Prev.png", TextGet("Previous"));
DrawButton(1580, 15, 90, 90, "", "White", "Icons/Next.png", TextGet("Next"));
DrawButton(1685, 15, 90, 90, "", "White", "Icons/Swap.png", TextGet("Reorder"));
DrawButton(1895, 15, 90, 90, "", "White", "Icons/Exit.png", TextGet("Exit"));
if (CraftingReorderMode == "Select") {
DrawText(`${TextGet("ReorderSelect")} ${CraftingReorderList.length}`, 737, 60, "White", "Black");
} else if (CraftingReorderMode == "Place") {
DrawText(`${TextGet("ReorderPlace")} ${CraftingReorderList.length}`, 737, 60, "White", "Black");
} else if (CraftingDestroy) {
DrawText(`${TextGet("SelectDestroy")} ${Math.floor(CraftingOffset / 20) + 1} / ${80 / 20}.`, 737, 60, "White", "Black");
} else {
DrawText(`${TextGet("SelectSlot")} ${Math.floor(CraftingOffset / 20) + 1} / ${80 / 20}.`, 737, 60, "White", "Black");
TrashCancel = true;
}
if (TrashCancel) {
DrawButton(1790, 15, 90, 90, "", "White", "Icons/Trash.png", TextGet("Destroy"));
} else {
DrawButton(1790, 15, 90, 90, "", "White", "Icons/Cancel.png", TextGet("Cancel"));
}
for (let S = CraftingOffset; S < CraftingOffset + 20; S++) {
let X = ((S - CraftingOffset) % 4) * 500 + 15;
let Y = Math.floor((S - CraftingOffset) / 4) * 180 + 130;
let Craft = Player.Crafting[S];
switch (CraftingReorderMode) {
case "Select":
BGColor = CraftingReorderList.includes (S) ? "Chartreuse" : "Yellow";
break;
case "Place":
BGColor = CraftingReorderList.includes (S) ? "Green" : "Grey";
break;
default:
break;
}
if (!Craft) {
DrawButton(X, Y, 470, 140, TextGet("EmptySlot"), BGColor);
} else {
DrawButton(X, Y, 470, 140, "", BGColor);
DrawTextFit(Craft.Name, X + 295, Y + 25, 315, "Black", "Silver");
const asset = CraftingAssets[Craft.Item]?.[0];
const groupName = asset?.DynamicGroupName;
if (groupName && InventoryAvailable(Player, Craft.Item, groupName)) {
DrawImageResize("Assets/" + Player.AssetFamily + "/" + groupName + "/Preview/" + asset.Name + ".png", X + 3, Y + 3, 135, 135);
DrawTextFit(asset.Description, X + 295, Y + 70, 315, "Black", "Silver");
DrawTextFit(TextGet("Property" + Craft.Property), X + 295, Y + 115, 315, "Black", "Silver");
if ((Craft.Lock != null) && (Craft.Lock != ""))
DrawImageResize("Assets/" + Player.AssetFamily + "/ItemMisc/Preview/" + Craft.Lock + ".png", X + 70, Y + 70, 70, 70);
}
}
}
}
if (CraftingMode == "Name") {
DrawCharacter(CraftingPreview, 775, 100, 0.9, false);
}
// In color mode, the player can change the color of each parts of the item
if (CraftingMode == "Color") {
DrawText(TextGet("SelectColor"), 600, 60, "White", "Black");
DrawCharacter(CraftingPreview, -100, 100, 2, false);
DrawCharacter(CraftingPreview, 700, 100, 0.9, false);
DrawButton(880, 900, 90, 90, "", "white", `Icons/${CraftingNakedPreview ? "Dress" : "Naked"}.png`);
ItemColorDraw(CraftingPreview, CraftingSelectedItem.Asset.DynamicGroupName, 1200, 25, 775, 950, true);
}
// Need the `DialogFocusItem` check here as there's a bit of a race condition
if (CraftingMode == "Extended" && DialogFocusItem) {
CommonCallFunctionByNameWarn(`Inventory${DialogFocusItem.Asset.Group.Name}${DialogFocusItem.Asset.Name}Draw`);
DrawButton(1885, 25, 90, 90, "", "White", "Icons/Exit.png");
DrawCharacter(CraftingPreview, 500, 100, 0.9, false);
}
if (CraftingMode == "Tighten" && DialogTightenLoosenItem) {
TightenLoosenItemDraw();
DrawButton(1885, 25, 90, 90, "", "White", "Icons/Exit.png");
DrawCharacter(CraftingPreview, 500, 100, 0.9, false);
}
if (CraftingMode == "OverridePriority") {
DrawCharacter(CraftingPreview, 500, 100, 0.9, false);
}
}
/** @type {ScreenFunctions["Resize"]} */
function CraftingResize(load) {
switch (CraftingMode) {
case "OverridePriority":
Layering.Resize(load);
break;
case "Name":
ElementPositionFixed(CraftingID.root, 15, 15, 1970, 970);
break;
}
}
/** @type {ScreenFunctions["Unload"]} */
function CraftingUnload() {
switch (CraftingMode) {
case "OverridePriority":
Layering.Unload();
break;
case "Name": {
const elem = document.getElementById(CraftingID.root);
if (elem) {
elem.style.display = "none";
}
break;
}
}
}
/**
* Update {@link CraftingSelectedItem.ItemProperties} with a select few properties from the passed item.
* @param {Item} item - The item whose properties should be coppied.
* @returns {void}
*/
function CraftingUpdateFromItem(item) {
if (!CraftingSelectedItem || !item.Property) {
return;
}
if (item.Property.TypeRecord) {
CraftingSelectedItem.TypeRecord = item.Property.TypeRecord;
}
/** @type {Set<keyof ItemProperties>} */
const keys = new Set(["OverridePriority"]);
if (item.Asset.Archetype) {
const options = ExtendedItemGatherOptions(item);
for (const option of options) {
if (option.OptionType === "VariableHeightOption") {
keys.add("OverrideHeight");
}
for (const key of CommonKeys(option.ParentData.baselineProperty || {})) {
if (!CraftingPropertyExclude.has(key)) {
keys.add(key);
}
}
}
}
// Basic property validation is conducted later on via CraftingValidate
for (const key of /** @type {Set<string>} */(keys)) {
if (item.Property[key] != null) {
CraftingSelectedItem.ItemProperty[key] = item.Property[key];
}
}
}
/**
* Return a list of all searchable asset names.
* @returns {string[]}
*/
function CraftingGetAllAssetNames() {
/** @type {Set<string>} */
const visited = new Set();
return Object.values(CraftingAssets).flat().sort((asset1, asset2) => {
return asset1.Description.localeCompare(asset2.Description);
}).filter((asset) => {
const status = InventoryAvailable(Player, asset.Name, asset.Group.Name) && !visited.has(asset.Description);
if (status) {
visited.add(asset.Description);
}
return status;
}).map((asset) => asset.Description);
}
/**
* Sets the new mode and creates or removes the inputs
* @param {CraftingMode} NewMode - The new mode to set
* @returns {void} - Nothing
*/
function CraftingModeSet(NewMode) {
CraftingUnload();
CraftingDestroy = false;
if (CraftingMode == "Slot" && NewMode != "Slot") {
CraftingReorderModeSet ("None");
}
CraftingMode = NewMode;
if (NewMode === "Name") {
const root = document.getElementById(CraftingID.root);
if (root) {
root.style.display = "";
}
CraftingResize(false);
if (CraftingSelectedItem.Asset) {
// Select the asset
document.querySelector(`#${CraftingID.assetGrid} [name='${CraftingSelectedItem.Asset.Name}'][data-group='${CraftingSelectedItem.Asset.DynamicGroupName}']`)?.dispatchEvent(new Event("click"));
} else {
// Open the side pannel and manually updating the crafting preview to clear it of old items
document.querySelector(`#${CraftingID.assetButton}[aria-checked='false']`)?.dispatchEvent(new Event("click"));
CraftingUpdatePreview();
}
// Most mobile phones only trigger click events if a touchstart event originates from within the DOM element.
// For those that do not, the mixture of DOM and canvas buttons can be problematic, as a lingering touch can _instantly_ trigger a click if a DOM button is created where a canvas button previously was.
// As a workaround, disable the exit button for ~100 ms on mobile when (re-)entering the `Name` subscreen
if (CommonIsMobile && root) {
ElementClickTimeout(root);
}
}
}
/**
* Serialize a single crafted item into a string in order to prepare it for server saving
* @param {CraftingItem} craft The crafted item
* @returns {string} The serialized crafted item
* @see {@link CraftingSaveServer}
*/
function CraftingSerialize(craft) {
/** @type {string[]} */
const stringData = [
craft.Item,
(craft.Property == null) ? "" : craft.Property,
(craft.Lock == null) ? "" : craft.Lock,
(craft.Name == null) ? "" : craft.Name.substring(0, 30),
(craft.Description == null) ? "" : craft.Description.substring(0, 200),
(craft.Color == null) ? "" : craft.Color,
(craft.Private) ? "T" : "",
"", // Old field as used by the deprecated `Type` crafted craft property, DO NOT REMOVE!
"", // Old field as used by the deprecated `OverridePriority` crafted craft property, DO NOT REMOVE!
(craft.ItemProperty == null) ? "" : JSON.stringify(craft.ItemProperty),
(craft.TypeRecord == null) ? "" : JSON.stringify(craft.TypeRecord),
(!craft.DifficultyFactor) ? "" : craft.DifficultyFactor.toString(),
];
return stringData.map(i => i.replace(CraftingSerializeSanitize, "")).join(CraftingSerializeFieldSep);
}
/**
* Prepares a compressed packet of the crafting data and sends it to the server
* @returns {void} - Nothing
*/
function CraftingSaveServer() {
if (Player.Crafting == null) return;
let P = Player.Crafting.map(C => (C == null) ? "" : CraftingSerialize(C)).join(CraftingSerializeItemSep);
while ((P.length >= 1) && (P.substring(P.length - 1) == CraftingSerializeItemSep))
P = P.substring(0, P.length - 1);
const Obj = { Crafting: LZString.compressToUTF16(P) };
ServerAccountUpdate.QueueData(Obj, true);
}
/**
* Deserialize a single crafted item from a string in order to parse data received from the server.
* @param {string} craftString The serialized crafted item
* @returns {null | CraftingItem} The crafted item or `null` if either its {@link CraftingItem.Item} or {@link CraftingItem.Name} property is invalid
* @see {@link CraftingDecompressServerData}
*/
function CraftingDeserialize(craftString) {
const [
Item,
Property,
Lock,
Name,
Description,
Color,
Private,
Type,
OverridePriority,
ItemProperty,
TypeRecord,
DifficultyFactor,
] = craftString.split(CraftingSerializeFieldSep);
/** @type {CraftingItem} */
const craft = {
Item,
Name,
Description,
Color,
Property: /** @type {CraftingPropertyType} */(Property) || "Normal",
Lock: /** @type {AssetLockType} */(Lock),
Private: Private === "T",
ItemProperty: ItemProperty ? CommonJSONParse(ItemProperty) : {},
Type: Type || null,
TypeRecord: TypeRecord ? CommonJSONParse(TypeRecord) : null,
DifficultyFactor: DifficultyFactor ? Number.parseInt(DifficultyFactor, 10) : undefined,
};
const priority = Number.parseInt(OverridePriority);
if (!Number.isNaN(priority)) {
craft.ItemProperty.OverridePriority = priority;
}
return (craft.Item && craft.Name) ? craft : null;
}
/**
* Deserialize and unpack the crafting data from the server.
* @param {string | undefined | (null | CraftingItem)[]} Data The serialized crafting data or already-decompressed crafting item list
* @returns {(null | CraftingItem)[]}
*/
function CraftingDecompressServerData(Data) {
// Arrays are returned right away, only strings can be parsed
if (Array.isArray(Data)) return Data;
if (typeof Data !== "string") return [];
// Decompress the data
let DecompressedData = null;
try {
DecompressedData = LZString.decompressFromUTF16(Data);
} catch(err) {
DecompressedData = null;
}
if (DecompressedData == null) {
console.warn("An error occured while decompressing Crafting data, entries have been reset.");
return [];
}
// Builds the craft array to assign to the player
return DecompressedData.split(CraftingSerializeItemSep).map(CraftingDeserialize);
}
/**
* Loads the server packet and creates the crafting array for the player
* @param {string | (null | CraftingItem)[]} Packet - The packet or already-decompressed crafting item list
* @returns {void} - Nothing
*/
function CraftingLoadServer(Packet) {
Player.Crafting = [];
let Refresh = false;
/** @type {Record<number, unknown>} */
const CriticalErrors = {};
const data = CraftingDecompressServerData(Packet);
for (const [i, item] of CommonEnumerate(data)) {
if (item == null) {
Player.Crafting.push(null);
continue;
}
// Make sure that the item is a valid craft
switch (CraftingValidate(item, undefined, undefined, true)) {
case CraftingStatusType.OK:
Player.Crafting.push(item);
break;
case CraftingStatusType.ERROR:
Player.Crafting.push(item);
Refresh = true;
break;
case CraftingStatusType.CRITICAL_ERROR:
Player.Crafting.push(null);
Refresh = true;
CriticalErrors[i] = (item);
break;
}
// Too many items, skip the rest
if (Player.Crafting.length >= 80) break;
}
/**
* One or more validation errors were encountered that were successfully resolved;
* push the fixed items back to the server */
if (Refresh) {
const nCritical = Object.keys(CriticalErrors).length;
if (nCritical > 0) {
console.warn(`Removing ${nCritical} corrupted crafted items`, CriticalErrors);
}
CraftingSaveServer();
}
}
/**
* Advance to the next crafting reordering mode, or set the mode to the specified value.
* @param {CraftingReorderType} newmode - The mode to set. If null, advance to next mode.
*/
function CraftingReorderModeSet(newmode=null)
{
let pushcrafts = true;
if (newmode == null) {
switch (CraftingReorderMode) {
case "None":
newmode = "Select";
break;
case "Select":
if (CraftingReorderList.length <= 0) {
// If selection list is empty, flip back to
// "None"; skip unnecessary network traffic.
pushcrafts = false;
newmode = "None";
} else {
newmode = "Place";
}
break;
case "Place":
newmode = "None";
break;
}
}
if (newmode == "None" && CraftingReorderMode != "None") {
/*
* We may have been in the middle of reordering things.
* Commit the current state, and empty the list.
*/
if (pushcrafts) {
CraftingSaveServer();
}
CraftingReorderList = [];
}
CraftingReorderMode = newmode;
}
/**
* Handles clicks in the crafting room.
* @type {ScreenFunctions["Click"]}
*/
function CraftingClick(event) {
// Can always exit or cancel
if (MouseIn(1895, 15, 90, 90) && !["Color", "Extended", "OverridePriority", "Name"].includes(CraftingMode)) CraftingExit();
if (MouseIn(1790, 15, 90, 90) && !["Color", "Extended", "Slot", "OverridePriority", "Name"].includes(CraftingMode)) return CraftingModeSet("Slot");
// In slot mode, we can select which item slot to craft
if (CraftingMode == "Slot") {
// Four-ish pages of slots
if (MouseIn(1475, 15, 90, 90)) {
CraftingOffset = CraftingOffset - 20;
if (CraftingOffset < 0) CraftingOffset = 80 - 20;
} else if (MouseIn(1580, 15, 90, 90)) {
CraftingOffset = CraftingOffset + 20;
if (CraftingOffset >= 80) CraftingOffset = 0;
}
// Enter/Exit destroy item mode; or exit reorder mode.
if (MouseIn(1790, 15, 90, 90)) {
if (CraftingReorderMode != "None") {
CraftingReorderModeSet ("None");
} else {
CraftingDestroy = !CraftingDestroy;
}
}
// Craft slot reordering mode.
if (MouseIn (1675, 15, 90, 90)) {
if (CraftingDestroy) CraftingDestroy = false;
CraftingReorderModeSet(); // Advance mode
}
// Scan 20 items for clicks
for (let S = 0; S < 20; S++) {
// If the box was clicked
let X = (S % 4) * 500 + 15;
let Y = Math.floor(S / 4) * 180 + 130;
const Craft = Player.Crafting[S + CraftingOffset];
if (!MouseIn(X, Y, 470, 140)) continue;
// Reorder, destroy, edit or create a new crafting item
if (CraftingReorderMode == "Select") {
// If the index isn't present, add it. If it
// is present, delete it. This has the effect
// of toggling the slot in the UI.
const idx = CraftingReorderList.indexOf (S + CraftingOffset);
if (idx >= 0) {
CraftingReorderList.splice (idx, 1);
} else {
CraftingReorderList.push (S + CraftingOffset);
}
} else if (CraftingReorderMode == "Place") {
// Swap the slot clicked with the first entry in the list.
const idx = CraftingReorderList.shift();
const item = Player.Crafting[S + CraftingOffset];
Player.Crafting[S + CraftingOffset] = Player.Crafting[idx];
Player.Crafting[idx] = item;
if (CraftingReorderList.length <= 0) {
// List exhausted; commit changes and end reorder mode.
CraftingReorderModeSet ("None");
}
} else if (CraftingDestroy) {
if (Craft) {
if (S + CraftingOffset < Player.Crafting.length) Player.Crafting[S + CraftingOffset] = null;
CraftingSaveServer();
}
} else if (Craft) {
CraftingSlot = S + CraftingOffset;
CraftingSelectedItem = CraftingConvertItemToSelected(Craft);
CraftingModeSet("Name");
} else {
CraftingSlot = S + CraftingOffset;
CraftingSelectedItem = {
Name: "",
Description: "",
DifficultyFactor: 0,
Color: "Default",
Assets: [],
get Asset() {
return this.Assets[0];
},
Property: "Normal",
Lock: null,
Private: false,
TypeRecord: null,
ItemProperty: {},
get OverridePriority() {
return this.ItemProperty.OverridePriority;
},
set OverridePriority(value) {
if (value == null) {
delete this.ItemProperty.OverridePriority;
} else {
this.ItemProperty.OverridePriority = value;
}
},
};
CraftingModeSet("Name");
}
}
return;
}
// In color selection mode, we allow picking a color
if (CraftingMode == "Color") {
if (MouseIn(880, 900, 90, 90)) {
CraftingNakedPreview = !CraftingNakedPreview;
CraftingUpdatePreview();
} else if (MouseIn(1200, 25, 775, 950)) {
ItemColorClick(CraftingPreview, CraftingSelectedItem.Asset.DynamicGroupName, 1200, 25, 775, 950, true);
setTimeout(CraftingRefreshPreview, 100);
}
return;
}
// Need the `DialogFocusItem` check here as there's a bit of a race condition
if (CraftingMode == "Extended" && DialogFocusItem) {
CommonCallFunctionByNameWarn(`Inventory${DialogFocusItem.Asset.Group.Name}${DialogFocusItem.Asset.Name}Click`);
}
if (CraftingMode == "Tighten" && DialogTightenLoosenItem) {
TightenLoosenItemClick();
}
if (CraftingMode == "OverridePriority") {
return;
}
}
/**
* Refreshes the preview model with a slight delay so the item color process is done
* @returns {void} - Nothing
* */
function CraftingRefreshPreview() {
let Item = InventoryGet(CraftingPreview, CraftingSelectedItem.Asset.DynamicGroupName);
if ((Item != null) && (Item.Color != null)) {
CraftingSelectedItem.Color = Array.isArray(Item.Color) ? Item.Color.join(",") : Item.Color || "";
CraftingUpdatePreview();
}
}
/**
* Converts the currently selected item into a crafting item.
* @return {CraftingItem}
* */
function CraftingConvertSelectedToItem() {
return {
Item: (CraftingSelectedItem.Asset == null) ? "" : CraftingSelectedItem.Asset.Name,
Property: CraftingSelectedItem.Property,
Lock: (CraftingSelectedItem.Lock == null) ? "" : /**@type {AssetLockType}*/(CraftingSelectedItem.Lock.Name),
Name: CraftingSelectedItem.Name,
Description: CraftingSelectedItem.Description,
Color: CraftingSelectedItem.Color,
Private: CraftingSelectedItem.Private,
TypeRecord: CraftingSelectedItem.TypeRecord || null,
DifficultyFactor: CraftingSelectedItem.DifficultyFactor || undefined,
ItemProperty: CraftingSelectedItem.ItemProperty,
};
}
/**
* Convert a crafting item to its selected format.
* @param {CraftingItem} Craft
* @returns {CraftingItemSelected}
*/
function CraftingConvertItemToSelected(Craft) {
return {
Name: Craft.Name,
Description: Craft.Description,
DifficultyFactor: Craft.DifficultyFactor ?? 0,
Color: Craft.Color,
Private: Craft.Private,
TypeRecord: Craft.TypeRecord || null,
Property: Craft.Property,
Assets: CraftingAssets[Craft.Item] ?? [],
get Asset() {
return this.Assets[0];
},
Lock: Craft.Lock && InventoryAvailable(Player, Craft.Lock, "ItemMisc") ? AssetGet(Player.AssetFamily, "ItemMisc", Craft.Lock) : null,
ItemProperty: Craft.ItemProperty ? Craft.ItemProperty : {},
get OverridePriority() {
return this.ItemProperty.OverridePriority;
},
set OverridePriority(value) {
if (value == null) {
delete this.ItemProperty.OverridePriority;
} else {
this.ItemProperty.OverridePriority = value;
}
},
};
}
/** Restore the DOM elements of the `Name` subscreen to their default state. */
function CraftingExitResetElements() {
// Reset the various input fields to their default
const [nameInput, colorsInput, descriptionInput, priorityInput, privateInput] = /** @type {HTMLInputElement[]} */([
document.getElementById(CraftingID.nameInput),
document.getElementById(CraftingID.colorsInput),
document.getElementById(CraftingID.descriptionInput),
document.getElementById(CraftingID.layeringInput),
document.getElementById(CraftingID.privateCheckbox),
]);
nameInput.value = nameInput.defaultValue = nameInput.placeholder = "";
colorsInput.value = colorsInput.defaultValue = colorsInput.placeholder = "";
priorityInput.value = priorityInput.defaultValue = priorityInput.placeholder = "0";
descriptionInput.value = "";
descriptionInput.setCustomValidity("");
privateInput.checked = false;
document.querySelector(`#${CraftingID.asciiDescriptionCheckbox}[checked='true']`)?.dispatchEvent(new Event("click"));
// Select the `Normal` property and disable all others
document.querySelector(`#${CraftingID.propertyGrid} [name='Normal'][aria-checked='false']`)?.dispatchEvent(new Event("click"));
document.querySelectorAll(`#${CraftingID.propertyGrid} [name]:not([name='Normal'])`).forEach(e => e.setAttribute("aria-disabled", "true"));
// Deselect the active lock and disable them all
document.querySelector(`#${CraftingID.padlockGrid} [name][aria-checked='true']`)?.dispatchEvent(new Event("click"));
document.querySelectorAll(`#${CraftingID.padlockGrid} [name]`).forEach(e => e.setAttribute("aria-disabled", "true"));
// Deselect the asset and disable the accept button
const assetSelected = document.querySelector(`#${CraftingID.assetGrid} [aria-checked='true']`);
assetSelected?.setAttribute("aria-checked", "false");
assetSelected?.setAttribute("tabindex", "-1");
document.querySelector(`#${CraftingID.assetGrid} [name]`)?.setAttribute("tabindex", "0");
document.getElementById(CraftingID.acceptButton)?.setAttribute("aria-disabled", "true");
// Open the asset-based side panel and reset the styling of its control button
const assetControlButton = document.getElementById(CraftingID.assetButton);
assetControlButton.innerHTML = "";
ElementButton._ParseLabel(assetControlButton.id, TextGet("SelectItem"), "bottom", { parent: assetControlButton });
ElementButton._ParseImage(assetControlButton.id, "./Icons/NoCraft.png", { parent: assetControlButton });
if (assetControlButton.getAttribute("aria-checked") === "false") {
assetControlButton.dispatchEvent(new Event("click"));
}
// Disable all buttons that _must_ have an asset selected
const [extendedButton, colorButton, layeringButton, tightenButton] = /** @type {(HTMLButtonElement)[]} */([
document.getElementById(CraftingID.extendedButton),
document.getElementById(CraftingID.colorsButton),
document.getElementById(CraftingID.layeringButton),
document.getElementById(CraftingID.tightenButton),
]);
extendedButton.disabled = true;
tightenButton.disabled = true;
colorButton.disabled = true;
colorsInput.disabled = true;
layeringButton.disabled = true;
priorityInput.disabled = true;
// Clear all search inputs and undo their filtering
const searchInputs = /** @type {NodeListOf<HTMLInputElement>} */(document.querySelectorAll(`#${CraftingID.leftPanel} input[type='search']`));
searchInputs.forEach((searchInp) => searchInp.value ||= "");
const focusGroup = document.querySelector(`#${CraftingID.centerPanel} [role='radio'][aria-checked='true']`);
if (focusGroup) {
focusGroup.dispatchEvent(new MouseEvent("click"));
document.querySelectorAll(`#${CraftingID.assetGrid} [data-unload-group]`).forEach(e => e.toggleAttribute("data-unload-group", false));
}
// Close the side pannel
document.querySelector(`#${CraftingID.leftPanel} > [aria-checked='true']`)?.dispatchEvent(new Event("click"));
}
/**
* When the player exits the crafting room
* @satisfies {ScreenFunctions["Exit"]}
* @param {boolean} allowPanelClose - Whether an exit call in the `Name` mode is allowed to close the side panels before performing a proper exit of the subscreen
*/
function CraftingExit(allowPanelClose=true) {
// Return to the `Name` sub-screen, if already there move to the `Slot` sub-screen and if already there exit the crafting screen
switch (CraftingMode) {
case "OverridePriority":
Layering.Exit();
return;
case "Color":
ItemColorExitClick();
return;
case "Tighten":
case "Extended":
DialogLeaveFocusItem();
return;
case "Name": {
const activePanel = document.querySelector(`#${CraftingID.leftPanel} > [aria-checked='true']`);
const activeGroup = document.querySelector(`#${CraftingID.centerPanel} [aria-checked='true']`);
if ((activePanel || activeGroup) && allowPanelClose) {
activePanel?.dispatchEvent(new MouseEvent("click"));
activeGroup?.dispatchEvent(new MouseEvent("click"));
} else {
CraftingExitResetElements();
CraftingUnload();
CraftingModeSet("Slot");
CraftingSelectedItem = null;
}
return;
}
case "Slot": {
ElementRemove(CraftingID.root);
CharacterDelete(CraftingPreview);
CraftingElements._SearchCache.clear();
CraftingPreview = null;
CraftingOffset = 0;
CraftingDestroy = false;
CraftingReorderModeSet("None");
if (CraftingReturnToChatroom) {
CommonSetScreen("Online", "ChatRoom");
} else {
CommonSetScreen("Room", "MainHall");
}
return;
}
}
}
/**
* Applies the craft to all matching items
* @param {CraftingItem} Craft
* @param {Asset} Item
*/
function CraftingAppliesToItem(Craft, Item) {
// Validates the craft asset
if (!Craft || !Item) return false;
const elligbleAssets = CraftingAssets[Craft.Item] ?? [];
return elligbleAssets.includes(Item);
}
/**
* Builds the item list from the player inventory, filters by the search box content
* @returns {Asset[]} - Nothing
*/
function CraftingItemListBuild() {
const assets = new Set(Object.values(CraftingAssets).map(i => i[0]));
return Array.from(assets).filter(a => {
return InventoryAvailable(Player, a.Name, a.DynamicGroupName);
}).sort((a1, a2) => {
return a1.Description.localeCompare(a2.Description);
});
}
/**
* A record with tools for validating {@link CraftingItem} properties.
* @type {Record<keyof CraftingItem, CratingValidationStruct>}
* @see {@link CratingValidationStruct}
* @todo Let the Validate/GetDefault functions take the respective attribute rather than the entire {@link CraftingItem}
*/
const CraftingValidationRecord = {
Color: {
Validate: function(craft, asset) {
if (typeof craft.Color !== "string") {
return false;
} else if ((craft.Color === "") || (asset == null)) {
return true;
} else {
const Colors = craft.Color.replace(" ", "").split(",");
return Colors.every((c) => CommonIsColor(c) || (c === "Default"));
}
},
GetDefault: function(craft, asset) {
if ((typeof craft.Color !== "string") || (asset == null)) {
return "";
} else {
const Colors = craft.Color.replace(" ", "").split(",");
const ColorsNew = Colors.map((c, i) => CommonIsColor(c) ? c : asset.DefaultColor[i] || "Default");
return ColorsNew.join(",");
}
},
StatusCode: CraftingStatusType.ERROR,
},
Description: {
Validate: (c, a) => typeof c.Description === "string",
GetDefault: (c, a) => "",
StatusCode: CraftingStatusType.ERROR,
},
DifficultyFactor: {
Validate: function (c, a) {
return (c.DifficultyFactor == null || CommonIsInteger(c.DifficultyFactor, -100, 4)) ? true : false;
},
GetDefault: function (c, a) {
return CommonIsInteger(c.DifficultyFactor) ? CommonClamp(c.DifficultyFactor, -100, 4) : undefined;
},
StatusCode: CraftingStatusType.ERROR,
},
Disabled: {
Validate: function (c, a) {
return c.Disabled == null || typeof c.Disabled === "boolean";
},
GetDefault: function (c, a) {
return undefined;
},
StatusCode: CraftingStatusType.ERROR,
},
Item: {
Validate: (c, a, checkPlayerInventory=false) => {
if (checkPlayerInventory) {
const groupName = CraftingAssets[c.Item]?.[0]?.DynamicGroupName;
return groupName ? InventoryAvailable(Player, c.Item, groupName) : false;
} else {
return Asset.some((i) => i.Name === c.Item);
}
},
GetDefault: (c, a, checkPlayerInventory=false) => {
if (checkPlayerInventory) {
return Asset.find((i) => i.Name === c.Item)?.Name ?? a?.Name ?? null;
} else {
return a?.Name ?? null;
}
},
StatusCode: CraftingStatusType.CRITICAL_ERROR,
},
Lock: {
Validate: function (c, a, checkPlayerInventory=false) {
if ((a != null) && (!a.AllowLock)) {
return (c.Lock === "");
} else if (c.Lock === "") {
return true;
}
const isValidLock = CraftingLockList.includes(c.Lock);
if (checkPlayerInventory) {
return isValidLock && InventoryAvailable(Player, c.Lock, "ItemMisc");
} else {
return isValidLock;
}
},
GetDefault: (c, a) => "",
StatusCode: CraftingStatusType.ERROR,
},
MemberName: {
Validate: (c, a) => c.MemberName == null || typeof c.MemberName === "string",
GetDefault: (c, a) => null,
StatusCode: CraftingStatusType.ERROR,
},
MemberNumber: {
Validate: (c, a) => c.MemberNumber == null || typeof c.MemberNumber === "number",
GetDefault: (c, a) => null,
StatusCode: CraftingStatusType.ERROR,
},
Name: {
Validate: (c, a) => c.Name && typeof c.Name === "string",
GetDefault: (c, a) => a ? a.Description : "Crafted Item",
StatusCode: CraftingStatusType.ERROR,
},
OverridePriority: {
Validate: (c, a) => (c.OverridePriority == null) || Number.isInteger(c.OverridePriority),
GetDefault: (c, a) => null,
StatusCode: CraftingStatusType.ERROR,
},
Private: {
Validate: (c, a) => typeof c.Private === "boolean",
GetDefault: (c, a) => false,
StatusCode: CraftingStatusType.ERROR,
},
Property: {
Validate: function (c, a) {
if (a == null) {
return CraftingPropertyMap.has(c.Property);
} else {
const Allow = CraftingPropertyMap.get(c.Property);
return (Allow !== undefined) ? Allow(a) : false;
}
},
GetDefault: (c, a) => "Normal",
StatusCode: CraftingStatusType.ERROR,
},
ItemProperty: {
Validate: function (c, a) {
const property = c.ItemProperty;
if (property == null) {
return true;
} else if (!CommonIsObject(property)) {
return false;
} else if (!a) {
return true;
}
// TODO: Add a better way of validating subscreen properties rather than just unconditionally
// allowing `OverrideHeight`.
/** @type {ItemProperties} */
const baseline = {
OverrideHeight: null,
};
if (a.Archetype) {
const data = ExtendedItemGetData(a, a.Archetype);
if (data && data.baselineProperty) {
Object.assign(baseline, data.baselineProperty);
}
}
for (const [key, value] of Object.entries(property)) {
if (value == null) {
continue;
} else if (CraftingPropertyExclude.has(/** @type {keyof ItemProperties} */(key))) {
return false;
} else if (key === "OverridePriority") {
if (Number.isInteger(value)) {
continue;
} else if (CommonIsObject(value)) {
const layers = a.Layer.map(l => l.Name);
for (const [layerName, priority] of Object.entries(value)) {
if (!(layers.includes(layerName) && Number.isInteger(priority))) {
return false;
}
}
} else {
return false;
}
} else if (typeof value !== typeof baseline[key]) {
return false;
}
}
return true;
},
GetDefault: function (c, a) {
const property = c.ItemProperty;
if (!CommonIsObject(property) || !a) {
return {};
}
/** @type {ItemProperties} */
const baseline = {
OverrideHeight: null,
};
if (a.Archetype) {
let data = ExtendedItemGetData(a, a.Archetype);
if (data && data.baselineProperty) {
Object.assign(baseline, data.baselineProperty);
}
}
const ret = {};
for (const [key, value] of Object.entries(property)) {
if (value == null || CraftingPropertyExclude.has(/** @type {keyof ItemProperties} */(key))) {
continue;
} else if (key === "OverridePriority" && Number.isInteger(value)) {
ret[key] = value;
} else if (key === "OverridePriority" && CommonIsObject(value)) {
ret[key] = {};
const layers = a.Layer.map(l => l.Name);
for (const [layerName, priority] of Object.entries(value)) {
if (layers.includes(layerName) && Number.isInteger(priority)) {
ret[key][layerName] = priority;
}
}
} else if (typeof value === typeof baseline[key]) {
ret[key] = value;
}
}
return ret;
},
StatusCode: CraftingStatusType.ERROR,
},
// NOTE: More thorough `TypeRecord` validation is performed by the extended item `...Init` functions
TypeRecord: {
Validate: function (c, a) {
const typeRecord = c.TypeRecord;
if (typeRecord == null) {
return true;
} else if (!CommonIsObject(typeRecord)) {
return false;
} else if (a == null) {
return true;
} else if (!a.Archetype) {
return typeRecord == null;
} else {
return true;
}
},
GetDefault: function (c, a) {
if (a == null || !a.Archetype) {
return null;
} else {
return {};
}
},
StatusCode: CraftingStatusType.ERROR,
},
/** @deprecated */
Type: {
Validate: function (c, a) {
return c.Type == null || typeof c.Type === "string";
},
GetDefault: function (c, a) {
return null;
},
StatusCode: CraftingStatusType.ERROR,
}
};
/**
* Validate and sanitinize crafting properties of the passed item inplace.
* @param {CraftingItem} Craft - The crafted item properties or `null`
* @param {Asset | null} asset - The matching Asset. Will be extracted from the player inventory if `null`
* @param {boolean} Warn - Whether a warning should logged whenever the crafting validation fails
* @param {boolean} checkPlayerInventory - Whether or not the player must own the crafted item's underlying asset
* @return {CraftingStatusType} - One of the {@link CraftingStatusType} status codes; 0 denoting an unrecoverable validation error
*/
function CraftingValidate(Craft, asset=null, Warn=true, checkPlayerInventory=false) {
if (Craft == null) {
return CraftingStatusType.CRITICAL_ERROR;
}
/** @type {Map<string, CraftingStatusType>} */
const StatusMap = new Map();
const Name = Craft.Name;
// Manually search for the Asset if it has not been provided
/** @type {readonly Asset[]} */
let assets;
if (asset == null) {
assets = CraftingAssets[Craft.Item] ?? [];
if (assets.length === 0) {
StatusMap.set("Item", CraftingStatusType.CRITICAL_ERROR);
}
} else {
assets = [asset];
}
if (asset != null && Craft.TypeRecord == null && typeof Craft.Type === "string") {
Craft.TypeRecord = ExtendedItemTypeToRecord(asset, Craft.Type);
}
/**
* Check all legal attributes.
* If `Asset == null` at this point then let all Asset-requiring checks pass, as we
* can't properly validate them. Note that this will introduce the potential for false negatives.
*/
for (const [AttrName, {Validate, GetDefault, StatusCode}] of Object.entries(CraftingValidationRecord)) {
if (!assets.some(a => Validate(Craft, a, checkPlayerInventory))) {
const AttrValue = (typeof Craft[AttrName] === "string") ? `"${Craft[AttrName]}"` : Craft[AttrName];
if (Warn) {
console.warn(`Invalid "Craft.${AttrName}" value for crafted item "${Name}": ${AttrValue}`);
}
Craft[AttrName] = GetDefault(Craft, asset, checkPlayerInventory);
StatusMap.set(AttrName, StatusCode);
} else {
StatusMap.set(AttrName, CraftingStatusType.OK);
}
}
// If the Asset has been explicetly passed then `Craft.Item` errors are fully recoverable,
// though the player should actually own the item
if (assets.length > 0 && StatusMap.get("Item") === CraftingStatusType.CRITICAL_ERROR) {
StatusMap.set("Item", CraftingStatusType.ERROR);
if (checkPlayerInventory && !InventoryAvailable(Player, assets[0].Name, assets[0].DynamicGroupName)) {
Craft.Disabled = true;
}
}
// Check for extra attributes
const LegalAttributes = Object.keys(CraftingValidationRecord);
for (const AttrName of Object.keys(Craft)) {
if (!LegalAttributes.includes(AttrName)) {
if (Warn) {
console.warn(`Invalid extra "Craft.${AttrName}" attribute for crafted item "${Name}"`);
}
delete Craft[AttrName];
StatusMap.set(AttrName, CraftingStatusType.ERROR);
}
}
return /** @type {CraftingStatusType} */(Math.min(...StatusMap.values()));
}