bondage-college-mirr/BondageClub/Scripts/PortalLink.js
2025-03-30 15:34:31 +02:00

621 lines
22 KiB
JavaScript

"use strict";
/**
* This file contains everything needed to add remote-style functions
* (a.k.a PortalLink compatibility) to an asset, both as a transmitter
* or a reciever item.
* For transmitters, you'll need to call the following {@link ExtendedItemScriptHookCallbacks}
* callbacks:
* - {@link PortalLinkTransmitterInit}
* - {@link PortalLinkTransmitterLoad}
* - {@link PortalLinkTransmitterDraw}
* - {@link PortalLinkTransmitterExit}
*
* Recievers have their own set of matching functions:
* - {@link PortalLinkRecieverInit}
* - {@link PortalLinkRecieverLoad}
* - {@link PortalLinkRecieverDraw}
* - {@link PortalLinkRecieverExit}
*
* That should be enough to give your asset the basic "sync code" UI handling,
* a reciever will only be detected if it supports at least one function (see below).
*
* ## PortalLink functions
*
* They are handled by a set of attributes, which the reciever needs to add to its
* {@link AssetDefinition.Attribute} to declare it supports it. The transmitter will
* detect those (via {@link PortalLinkGetFunctions}) and use them in its UI.
*
* Currently supported PortalLink functions (and their required attributes) are:
*
* - `PortalLinkFunctionLock` and `PortalLinkFunctionUnlock`:
* Those two functions are supported when the `PortalLinkLockable` attribute exists
* on the reciever, and enable remote-locking.
*
* - `PortalLinkFunctionCycleChastity`:
* Enabled when the `PortalLinkChastity${string}` attribute exists the reciever, and
* allows the reciever's chastity state to be cycled;
*
* @note Right now only modular items are supported, and `${string}` in the attribute
* is used to look up the module name ({@link ModularItemModuleConfig.Name}) to cycle
* on the reciever.
*
* - `PortalLinkFunctionActivity${ActivityName}`;
* Enabled when the `PortalLinkActivity${ActivityName}` attribute exists on the reciever,
* and allows the transmitter to perform the activity named `${ActivityName}` through
* the transmitter. By default activities will happen on the group the reciever is in,
* but you can use the `PortalLinkTarget${AssetGroupItemName}` to change the target.
*
* Since attributes are customizable at both the asset and option/module-level, you can
* get pretty involved combinations working. See the PortalPanties and how cycling the
* chastity changes the activity target for a good example.
*
* ## Low-level details
*
* The low-level protocol "exchange" itself is done in the following way:
* function discovery is made with {@link PortalLinkGetFunctions}, {@link PortalLinkPublishMessage} is
* called when a button is clicked from the transmitter UI on the senders' side, and its counterpart
* {@link PortalLinkProcessMessage} is called when a PortalLink message is recieved on the target's side.
* If you want to add more functions, those are the main three you should look at.
*/
/** Max length of sync codes */
const PortalLinkCodeLength = 8;
/** Regex string for what consitutes a valid sync code */
const PortalLinkCodeText = `[0-9a-f]{${PortalLinkCodeLength}}`;
/** Same thing but in regex form for quick .test and .match */
const PortalLinkCodeRegex = RegExp(PortalLinkCodeText);
/** The DOM ID for the sync code field */
const PortalLinkCodeInputID = "PortalLinkCode";
/**
* Parameters for the button grid
* @type {CommonGenerateGridParameters}
*/
const PortalLinkFunctionGrid = {
x: 1100,
y: 680,
width: 875,
height: 300,
itemWidth: 200,
itemHeight: 64,
};
//#region PortalLink reciever
/** @type {ExtendedItemScriptHookCallbacks.Load<ExtendedItemData<any>>} */
function PortalLinkRecieverLoadHook(data, originalFunction) {
if (originalFunction) originalFunction();
const item = DialogFocusItem;
const code = item.Property.PortalLinkCode;
const input = ElementCreateInput(PortalLinkCodeInputID, "text", code, PortalLinkCodeLength);
if (input) {
input.autocomplete = "off";
input.pattern = PortalLinkCodeText;
input.addEventListener("input", (event) => PortalLinkCodeChanged(Player, item, true));
}
}
/** @type {ExtendedItemScriptHookCallbacks.Draw<ExtendedItemData<any>>} */
function PortalLinkRecieverDrawHook(data, originalFunction) {
originalFunction();
PortalLinkSyncCodeInputDraw(true);
}
/** @type {ExtendedItemScriptHookCallbacks.Click<ExtendedItemData<any>>} */
function PortalLinkRecieverClickHook(data, originalFunction) {
originalFunction();
if (MouseIn(1885, 25, 90, 90)) {
return;
}
PortalLinkSyncCodeInputClick(true);
}
/** @type {ExtendedItemScriptHookCallbacks.Exit<ExtendedItemData<any>>} */
function PortalLinkRecieverExitHook(data, originalFunction) {
if (originalFunction) originalFunction();
const C = CharacterGetCurrent();
const Item = DialogFocusItem;
const code = ElementValue(PortalLinkCodeInputID);
if (code.trim().match(PortalLinkCodeRegex)) {
Item.Property.PortalLinkCode = code;
ChatRoomCharacterItemUpdate(C, Item.Asset.Group.Name);
}
ElementRemove(PortalLinkCodeInputID);
}
//#endregion
//#region PortalLink transmitter
/** @type {PortalLinkStatus} */
let PortalLinkTransmitterStatus = "PortalLinkInvalidCode";
/** @type {number | null} */
let PortalLinkTransmitterLastLinkCheck = null;
/** @type {ExtendedItemScriptHookCallbacks.Load<ExtendedItemData<any>>} */
function PortalLinkTransmitterLoadHook(data, originalFunction) {
originalFunction();
const item = DialogFocusItem;
const C = CharacterGetCurrent();
DialogExtendedMessage = AssetTextGet((!C.IsPlayer() || !C.CanInteract()) ? "PortalLinkInaccessibleLabel" : "PortalLinkScreenLabel");
const code = item.Property.PortalLinkCode;
const assets = PortalLinkGetItemsWithCode(code);
// Force-establish if the code matches, otherwise trigger a link refresh
if (assets.length === 1) {
PortalLinkTransmitterStatus = "PortalLinkEstablished";
PortalLinkTransmitterLastLinkCheck = CurrentTime;
} else {
PortalLinkTransmitterCheckLinkStatus(C, true);
}
const input = ElementCreateInput(PortalLinkCodeInputID, "text", code, PortalLinkCodeLength);
if (input) {
input.autocomplete = "off";
input.pattern = PortalLinkCodeText;
input.addEventListener("input", (event) => PortalLinkCodeChanged(Player, item, false));
}
}
/** @type {ExtendedItemScriptHookCallbacks.Draw<ExtendedItemData<any>>} */
function PortalLinkTransmitterDrawHook(data, originalFunction) {
originalFunction();
const C = CharacterGetCurrent();
// Check the link status each frame
PortalLinkTransmitterCheckLinkStatus(C);
// Draw the header and item
if (!C.IsPlayer() || !C.CanInteract()) {
// Only if we're the tablet wearer and we can interact with it
return;
}
PortalLinkSyncCodeInputDraw(false);
// Display buttons only if the link is correct
if (PortalLinkTransmitterStatus !== "PortalLinkEstablished") return;
const linked = PortalLinkGetItemsWithCode(PortalLinkGetTransmitterCode(C));
if (linked.length === 0) return;
const [target, item] = linked[0];
if (!target || !item) return;
const functions = PortalLinkGetFunctions(item);
if (functions.length <= 0) {
DrawText(AssetTextGet("PortalLinkNoFunctionsLabel"), 1500, 640, "white");
return;
}
DrawText(AssetTextGet("PortalLinkAvailableFunctionsLabel"), 1500, 640, "white");
CommonGenerateGrid(functions, 0, PortalLinkFunctionGrid, (func, x, y, w, h) => {
const bg = (MouseHovering(x, y, w, h) ? "cyan" : "white");
const buttonLabel = AssetTextGet(func + "Label");
DrawButton(x, y, w, h, buttonLabel, bg, null);
return false;
});
}
/** @type {ExtendedItemScriptHookCallbacks.Click<ExtendedItemData<any>>} */
function PortalLinkTransmitterClickHook(data, originalFunction) {
originalFunction();
if (MouseIn(1885, 25, 90, 90)) {
return;
}
const C = CharacterGetCurrent();
if (!C.IsPlayer() || !C.CanInteract()) {
// Only if we're the tablet wearer and we can interact with it
return;
}
PortalLinkSyncCodeInputClick(false);
// Click buttons only if the link is correct
if (PortalLinkTransmitterStatus !== "PortalLinkEstablished") return;
const linked = PortalLinkGetItemsWithCode(PortalLinkGetTransmitterCode(C));
if (linked.length === 0) return;
const [target, item] = linked[0];
if (!target || !item) return;
const functions = PortalLinkGetFunctions(item);
if (functions.length <= 0) return;
CommonGenerateGrid(functions, 0, PortalLinkFunctionGrid, (func, x, y, w, h) => {
if (MouseIn(x, y, w, h)) {
PortalLinkPublishMessage(target, item, func);
return true;
}
return false;
});
}
/** @type {ExtendedItemScriptHookCallbacks.Exit<ExtendedItemData<any>>} */
function PortalLinkTransmitterExitHook(data, originalFunction) {
ElementRemove(PortalLinkCodeInputID);
if (originalFunction) originalFunction();
}
//#endregion
//#region PortalLink helpers
/** @type {RectTuple} */
const PortalLinkRandomCodeButton = [1200, 550, 50, 50];
/** @type {RectTuple} */
const PortalLinkCopyCodeButton = [1258, 550, 50, 50];
/** @type {RectTuple} */
const PortalLinkPasteCodeButton = [1258, 550, 50, 50];
/** @type {Record<PortalLinkStatus, string>} */
const PortalLinkStatusColors = {
"PortalLinkEstablished": "lime",
"PortalLinkInvalidCode": "red",
"PortalLinkClipboardError": "red",
"PortalLinkValidCode": "lime",
"PortalLinkTargetNotFound": "orange",
"PortalLinkDuplicateCode": "orange",
"PortalLinkSearching0": "yellow",
"PortalLinkSearching1": "yellow",
"PortalLinkSearching2": "yellow",
"PortalLinkSearching3": "yellow",
};
/**
* Draw the sync code UI depending on the mode.
* Reciever has Random and Copy to clipboard buttons, transmitter has
* Copy from clipboard and link status label.
*
* @param {boolean} reciever - Whether it's in reciever or transmitter mode
*/
function PortalLinkSyncCodeInputDraw(reciever) {
MainCanvas.textAlign = "right";
DrawText(AssetTextGet("PortalLinkSyncCodeLabel"), 1330, 520, "white");
MainCanvas.textAlign = "center";
ElementPosition(PortalLinkCodeInputID, 1480, 510, 264);
const code = ElementValue(PortalLinkCodeInputID);
if (reciever) {
DrawButton(...PortalLinkRandomCodeButton, "", "white", "", "Generate random code");
DrawImageEx("Icons/Random.png", MainCanvas, PortalLinkRandomCodeButton[0], PortalLinkRandomCodeButton[1], { Width: PortalLinkRandomCodeButton[2], Height: PortalLinkRandomCodeButton[3], });
DrawButton(...PortalLinkCopyCodeButton, "", "white", "", "Copy code to clipboard", !PortalLinkCodeRegex.test(code));
DrawImageEx("Icons/Export.png", MainCanvas, PortalLinkCopyCodeButton[0], PortalLinkCopyCodeButton[1], { Width: PortalLinkCopyCodeButton[2], Height: PortalLinkCopyCodeButton[3], });
PortalLinkTransmitterStatus = PortalLinkCodeRegex.test(code) ? "PortalLinkValidCode" : "PortalLinkInvalidCode";
MainCanvas.textAlign = "left";
DrawText(AssetTextGet(PortalLinkTransmitterStatus), 1342, 580, PortalLinkStatusColors[PortalLinkTransmitterStatus]);
MainCanvas.textAlign = "center";
} else {
DrawButton(...PortalLinkPasteCodeButton, "", "white", "", "Copy code from clipboard");
DrawImageEx("Icons/Import.png", MainCanvas, PortalLinkPasteCodeButton[0], PortalLinkPasteCodeButton[1], { Width: PortalLinkPasteCodeButton[2], Height: PortalLinkPasteCodeButton[3], });
MainCanvas.textAlign = "left";
DrawText(AssetTextGet(PortalLinkTransmitterStatus), 1342, 580, PortalLinkStatusColors[PortalLinkTransmitterStatus]);
MainCanvas.textAlign = "center";
}
}
function PortalLinkSyncCodeInputClick(reciever) {
if (reciever) {
if (MouseIn(...PortalLinkRandomCodeButton)) {
let newCode = "";
while (newCode.length < PortalLinkCodeLength) {
newCode += CommonRandomItemFromList("", "0123456789abcdef".split(""));
}
ElementValue(PortalLinkCodeInputID, newCode);
PortalLinkCodeChanged(Player, DialogFocusItem, reciever);
return;
}
const code = ElementValue(PortalLinkCodeInputID);
if (MouseIn(...PortalLinkCopyCodeButton) && code.length > 0) {
navigator.clipboard
.writeText(ElementValue(PortalLinkCodeInputID))
.catch(err => {
PortalLinkTransmitterStatus = "PortalLinkClipboardError";
console.error("Clipboard write error: " + err);
});
return;
}
} else {
if (MouseIn(...PortalLinkPasteCodeButton)) {
navigator.clipboard.readText()
.then(txt => {
ElementValue(PortalLinkCodeInputID, txt);
PortalLinkCodeChanged(Player, DialogFocusItem, reciever);
})
.catch(err => {
PortalLinkTransmitterStatus = "PortalLinkClipboardError";
console.error("Clipboard read error: " + err);
});
return;
}
}
}
/**
* Input listener for changes to the sync code field
* @param {Character} C - The character wearing the item
* @param {Item} Item - The item being changed
* @param {boolean} reciever - Whether the called is a reciever or not
*/
function PortalLinkCodeChanged(C, Item, reciever) {
const target = /** @type {HTMLInputElement} */ (document.getElementById(PortalLinkCodeInputID));
const linkCode = target.value.trim();
// Save the code and check the link
if (PortalLinkCodeRegex.test(linkCode)) {
Item.Property.PortalLinkCode = linkCode;
ChatRoomCharacterItemUpdate(C, Item.Asset.Group.Name);
} else {
// Reset the code, without spamming update messages
const update = Item.Property.PortalLinkCode !== "";
Item.Property.PortalLinkCode = "";
if (update)
ChatRoomCharacterItemUpdate(C, Item.Asset.Group.Name);
}
if (!reciever)
PortalLinkTransmitterCheckLinkStatus(C, true);
}
/**
* Get the transmitter sync code from a character
* @param {Character} C
*/
function PortalLinkGetTransmitterCode(C) {
const tablet = InventoryGet(C, "ItemHandheld");
if (!tablet || tablet.Asset.Name !== "PortalTablet") return;
return tablet.Property.PortalLinkCode;
}
/**
* Get the list of all items that match a given sync code in the chatroom.
* @param {string} linkCode
* @returns {[Character, Item][]}
*/
function PortalLinkGetItemsWithCode(linkCode) {
if (!PortalLinkCodeRegex.test(linkCode)) return [];
/** @type {[Character, Item][]} */
let assets = [];
// For each target wearing PortalLink-compatible assets, filter out those assets into a tuple of [character, item]
for (const char of ChatRoomCharacter) {
/** @type {[Character, Item][]} */
const found = char.Appearance.filter(item => {
const supportsPortalLink = InventoryGetItemProperty(item, "Attribute").some(attr => attr.startsWith("PortalLink"));
return (supportsPortalLink && InventoryGetItemProperty(item, "PortalLinkCode") === linkCode);
}).map(item => [char, item]);
if (found.length <= 0) continue;
assets = assets.concat(found);
}
return assets;
}
/**
* Checks the transmitter's link status with its reciever.
* @param {Character} C - The character wearing the transmitter
* @param {boolean} newLink - Whether it's a new link being setup
*/
function PortalLinkTransmitterCheckLinkStatus(C, newLink=false) {
if (!C.IsPlayer()) return;
const linkCode = PortalLinkGetTransmitterCode(C);
if (!PortalLinkCodeRegex.test(linkCode)) {
PortalLinkTransmitterStatus = "PortalLinkInvalidCode";
return;
}
if (newLink || PortalLinkTransmitterLastLinkCheck === null) {
// Start a random 1-5s delay for fake-searching
PortalLinkTransmitterLastLinkCheck = CurrentTime + (Math.random() * 4000) + 1000;
PortalLinkTransmitterStatus = `PortalLinkSearching0`;
return;
} else if (PortalLinkTransmitterStatus === "PortalLinkEstablished") {
// We have established a link, bail out
return;
} else if (Math.abs(CurrentTime - PortalLinkTransmitterLastLinkCheck) > 8000) {
// Force us out of a rogue timer. That will trigger a new search.
PortalLinkTransmitterLastLinkCheck = null;
return;
} else if (CurrentTime <= PortalLinkTransmitterLastLinkCheck) {
// Wait until the delay expires, animating the status
let lastIdx = parseInt(PortalLinkTransmitterStatus.substring("PortalLinkSearching".length + 1));
if (!CommonIsNumeric(lastIdx)) lastIdx = 0;
const interval = Math.floor((PortalLinkTransmitterLastLinkCheck - CurrentTime) / 100);
lastIdx = 3 - ((lastIdx + interval) % 4);
PortalLinkTransmitterStatus = `PortalLinkSearching${lastIdx}`;
return;
}
const assets = PortalLinkGetItemsWithCode(linkCode);
if (assets.length === 0 && linkCode) {
PortalLinkTransmitterStatus = "PortalLinkTargetNotFound";
return;
} else if (assets.length === 0) {
PortalLinkTransmitterStatus = "PortalLinkInvalidCode";
return;
} else if (assets.length > 1) {
PortalLinkTransmitterStatus = "PortalLinkDuplicateCode";
return;
}
PortalLinkTransmitterStatus = "PortalLinkEstablished";
}
/**
* Gathers the list of available functions for a given asset.
* @param {Item} item
*/
function PortalLinkGetFunctions(item) {
if (!item) return [];
const attrs = InventoryGetItemProperty(item, "Attribute").filter(a => a.startsWith("PortalLink"));
/** @type {PortalLinkFunction[]} */
const features = [];
for (const attr of attrs) {
const locked = InventoryGetItemProperty(item, "Effect").includes("Lock");
if (attr === "PortalLinkLockable") {
features.push(locked ? "PortalLinkFunctionUnlock" : "PortalLinkFunctionLock");
} else if (attr.startsWith("PortalLinkChastity")) {
features.push("PortalLinkFunctionCycleChastity");
} else if (attr.startsWith("PortalLinkActivity")) {
const act = /** @type {ActivityName} */ (attr.substring("PortalLinkActivity".length));
let attrTarget = attrs.find(a => a.startsWith("PortalLinkTarget"));
if (!attrTarget) {
console.warn("No target specified for PortalLinkActivity. Add PortalLinkTarget${groupName} to your assets' attributes");
continue;
}
features.push(`PortalLinkFunctionActivity${act}`);
}
}
return features;
}
/**
* Broadcast an hidden ProcessLink message to the chatroom
* @param {Character} target
* @param {Item} item
* @param {PortalLinkFunction} func
*/
function PortalLinkPublishMessage(target, item, func) {
if (!target || !item || !func) return;
const Dictionary = new DictionaryBuilder()
.sourceCharacter(Player)
.targetCharacterName(target)
.asset(item.Asset, "AssetName", item.Craft && item.Craft.Name)
.build();
ServerSend("ChatRoomChat", { Content: func, Type: "Hidden", Dictionary });
DialogLeave();
}
/**
*
* @param {Character} sender
* @param {Item} item
*/
function PortalLinkCycleChastityModule(sender, item) {
const attr = InventoryGetItemProperty(item, "Attribute").find(a => a.startsWith("PortalLinkChastity"));
if (!attr) return;
const chastityTarget = attr.substring("PortalLinkChastity".length);
if (!chastityTarget) {
console.debug(`${item.Asset.Group.Name}:${item.Asset.Name}: missing target for PortalLinkChastity attribute`);
return;
}
if (item.Asset.Archetype === "modular") {
const modularData = ModularItemDataLookup[`${item.Asset.Group.Name}${item.Asset.Name}`];
if (!modularData) return;
const shield = modularData.modules.find(m => m.Name === chastityTarget);
if (!shield) return;
const typeRecord = (item.Property && item.Property.TypeRecord) || {};
const shieldIndex = typeRecord[shield.Key] || 0;
const idx = (shieldIndex + 1) % shield.Options.length;
ExtendedItemSetOptionByRecord(Player, item, { [shield.Key]: idx }, { push: true, C_Source: sender });
ModularItemPublishAction(modularData, Player, item, shield.Options[idx], shield.Options[shieldIndex]);
}
}
/**
* The handler for processing the hidden PortalLink messages
* @param {Character} sender
* @param {ServerChatRoomMessage} data
*/
function PortalLinkProcessMessage(sender, data) {
if (!CommonIsObject(sender) || !CommonIsObject(data)) return;
const senderCode = PortalLinkGetTransmitterCode(sender);
const tablet = InventoryGet(sender, "ItemHandheld");
if (!tablet || tablet.Asset.Name !== "PortalTablet") return;
const assetRef = /** @type {AssetReferenceDictionaryEntry} */ (data.Dictionary.find(e => IsAssetReferenceDictionaryEntry(e)));
if (!assetRef) return;
// Only proceed if our current item matches what was sent and its link code matches with the senders'
const item = InventoryGet(Player, assetRef.GroupName);
if (!item || item.Asset.Name !== assetRef.AssetName || item.Property.PortalLinkCode !== senderCode) return;
// Use a special name reference because none of (Target|Destination)Character(Name)? cuts it, somehow
const playerNameRef = sender.MemberNumber === Player.MemberNumber ? CharacterPronoun(Player, "Possessive", false) : `${CharacterNickname(Player)}${InterfaceTextGet("'s")}`;
const builder = new DictionaryBuilder()
.sourceCharacter(sender)
.targetCharacter(Player)
.text("RecieverCharacter", playerNameRef)
.asset(item.Asset, "AssetName", item.Craft && item.Craft.Name);
const func = /** @type {PortalLinkFunction} */ (data.Content);
if (func.startsWith("PortalLinkFunctionActivity")) {
const actName = func.substring("PortalLinkFunctionActivity".length);
const act = AssetGetActivity("Female3DCG", actName);
if (!act) return;
const attrTarget = InventoryGetItemProperty(item, "Attribute").find(attr => attr.startsWith("PortalLinkTarget"));
if (!attrTarget) {
console.warn("No target specified for PortalLinkActivity. Add PortalLinkTarget${groupName} to your assets' attributes");
return;
}
const group = AssetGroupGet("Female3DCG", /** @type {AssetGroupName} */ (attrTarget.substring("PortalLinkTarget".length)));
if (!group || !group.IsItem()) return;
ActivityRun(sender, Player, group, { Activity: act, Item: tablet, Group: tablet.Asset.Group.Name }, false);
builder.focusGroup(group.Name);
} else {
switch (func) {
case "PortalLinkFunctionLock": {
InventoryLock(Player, item, "PortalLinkPadlock", sender.MemberNumber);
}
break;
case "PortalLinkFunctionUnlock": {
InventoryUnlock(Player, item);
}
break;
case "PortalLinkFunctionCycleChastity":
PortalLinkCycleChastityModule(sender, item);
// We handled updating the appearance and sending the chat message, bail out.
return;
default:
console.debug(`Unhandled ${func}, ignoring`);
return;
}
}
// If we get here then we successfully reacted to the requested function
ChatRoomCharacterItemUpdate(Player, item.Asset.Group.Name);
CharacterRefresh(Player, true);
ChatRoomPublishCustomAction(data.Content, false, builder.build());
}
//#endregion