bondage-college-mirr/BondageClub/Scripts/Character.js
Jean-Baptiste Emmanuel Zorg 347644356d Move all the access list checks into a single function
This adds nicer methods to the Character object for checking someone
against the various player/character access lists.
2025-03-29 17:42:56 +01:00

2461 lines
86 KiB
JavaScript

"use strict";
/** @type Character[] */
var Character = [];
var CharacterNextId = 1;
/** @type {Map<BlindEffectName, number>} */
const CharacterBlindLevels = new Map([
/** Reserve `BlindTotal` for situations where the blindness level should never
* be lowered by crafted properties (e.g. while in VR) */
["BlindTotal", 99],
["BlindHeavy", 3],
["BlindNormal", 2],
["BlindLight", 1],
]);
/** @type {Map<DeafEffectName, number>} */
const CharacterDeafLevels = new Map([
["DeafTotal", 4],
["DeafHeavy", 3],
["DeafNormal", 2],
["DeafLight", 1],
]);
/** @type {Map<BlurEffectName, number>} */
const CharacterBlurLevels = new Map([
["BlurTotal", 50],
["BlurHeavy", 20],
["BlurNormal", 8],
["BlurLight", 3],
]);
const Difficulty = {
ROLEPLAY: 0,
REGULAR: 1,
HARDCORE: 2,
EXTREME: 3,
};
/**
* An enum representing the various character archetypes
* ONLINE: The player, or a character representing another online player
* NPC: Any NPC
* SIMPLE: Any simple character, generally used internally and not to represent an actual in-game character
* @type {Record<"ONLINE"|"NPC"|"SIMPLE"|"PLAYER", CharacterType>}
*/
var CharacterType = {
ONLINE: "online",
NPC: "npc",
SIMPLE: "simple",
PLAYER: "player",
};
/**
* A record mapping screen names to functions for returning {@link CharacterGetCurrent} characters.
* @type {Record<string, () => null | Character>}
*/
var CharacterGetCurrentHandlers = {
Appearance: () => CharacterAppearanceSelection,
Crafting: () => CraftingPreview,
Shop2: () => Shop2InitVars.Preview,
};
function CharacterCreatePlayer() {
Player = /** @type {PlayerCharacter} */ (CharacterCreate("Female3DCG", CharacterType.PLAYER, ""));
// We reset that here because a lot of checks depends on this being true
// Our old 0-ID'ed character was the player dummy before login
const oldId = Player.ID;
Player.ID = 0;
Character[0] = Player;
Character.splice(oldId, 1);
CharacterNextId--;
CharacterLoadCSVDialog(Player, { module: "Character", screen: "Player", name: "Player" });
}
/**
* Loads a character into the buffer, creates it if it does not exist
* @param {IAssetFamily} CharacterAssetFamily - Name of the asset family of the character
* @param {CharacterType} Type - The character type
* @param {string} CharacterID - An unique identifier for the character
* @returns {Character} - The newly loaded character
*/
function CharacterCreate(CharacterAssetFamily, Type, CharacterID) {
const id = CharacterNextId++;
// Prepares the character sheet
/** @type {Character} */
const NewCharacter = {
ID: id,
CharacterID: CharacterID,
AssetFamily: CharacterAssetFamily,
AccountName: "",
ItemPermission: 0,
Ownership: null,
Lovership: [],
// @ts-expect-error Initialized later
ArousalSettings: {},
Crafting: [],
Hooks: null,
Name: "",
Type,
Owner: "",
Lover: "",
Money: 0,
Inventory: [],
Appearance: [],
_Stage: "0",
get Stage() {
return this._Stage;
},
set Stage(value) {
if (this._Stage === value) {
return;
}
this._Stage = value;
if (DialogMenuMode === "dialog") {
DialogMenuMapping.dialog.Reload();
}
},
_CurrentDialog: "",
get CurrentDialog() {
return this._CurrentDialog;
},
set CurrentDialog(value) {
if (this._CurrentDialog === value) {
return;
}
this._CurrentDialog = value;
if (DialogMenuMode === "dialog") {
DialogSetStatus(value);
}
},
ClickedOption: null,
Dialog: [],
Reputation: [],
Skill: [],
DrawAppearance: [],
Effect: [],
Tints: [],
Attribute: [],
FocusGroup: null,
Canvas: null,
CanvasBlink: null,
MustDraw: false,
BlinkFactor: Math.round(Math.random() * 10) + 10,
AllowItem: true,
BlockItems: /** @type {never} */([]),
LimitedItems: /** @type {never} */([]),
FavoriteItems: /** @type {never} */([]),
HiddenItems: /** @type {never} */([]),
PermissionItems: {},
WhiteList: [],
BlackList: [],
HeightModifier: 0,
HeightRatio: 1,
HasHiddenItems: false,
SavedColors: GetDefaultSavedColors(),
PoseMapping: {},
get Pose() {
return Object.values(this.PoseMapping);
},
set Pose(poses) {
if (typeof poses === "string") {
poses = [poses];
}
this.DrawPoseMapping = PoseToMapping.Scalar(poses, "Character.Pose");
},
ActivePoseMapping: {},
get ActivePose() {
return Object.values(this.ActivePoseMapping);
},
set ActivePose(poses) {
if (typeof poses === "string") {
poses = [poses];
}
this.ActivePoseMapping = PoseToMapping.Scalar(poses, "Character.ActivePose");
},
DrawPoseMapping: {},
get DrawPose() {
return Object.values(this.DrawPoseMapping);
},
set DrawPose(poses) {
if (typeof poses === "string") {
poses = [poses];
}
this.DrawPoseMapping = PoseToMapping.Scalar(poses, "Character.DrawPose");
},
AllowedActivePoseMapping: {},
get AllowedActivePose() {
/** @type {AssetPoseName[]} */
const ret = [];
Object.values(this.AllowedActivePoseMapping).forEach(poses => ret.push(...poses));
return ret;
},
set AllowedActivePose(poses) {
if (typeof poses === "string") {
poses = [poses];
}
this.AllowedActivePoseMapping = PoseToMapping.Array(poses, "Character.AllowedActivePose");
},
CanTalk: function () {
const GagEffect = SpeechTransformGagGarbleIntensity(this);
return (GagEffect <= 0);
},
CanWalk: function () {
return (
!this.HasEffect("Freeze")
&& !this.HasEffect("Tethered")
&& !this.HasEffect("Mounted")
);
},
CanKneel: function () {
return CharacterCanKneel(this);
},
CanInteract: function () {
return !this.HasEffect("Block");
},
CanChangeOwnClothes: function () {
return this.CanChangeClothesOn(this);
},
CanChangeClothesOn: function (C) {
if (this.IsPlayer() && C.IsPlayer()) {
return (
!C.IsRestrained() &&
!ManagementIsClubSlave() &&
OnlineGameAllowChange() &&
AsylumGGTSAllowChange(this) &&
!LogQuery("BlockChange", "Rule") &&
(!LogQuery("BlockChange", "OwnerRule") || !Player.IsFullyOwned())
);
} else {
return (
this.CanInteract() &&
C.MemberNumber != null &&
C.AllowItem &&
!C.IsEnclose() &&
!(InventoryGet(CurrentCharacter, "ItemNeck") !== null &&
InventoryGet(CurrentCharacter, "ItemNeck").Asset.Name == "ClubSlaveCollar")
);
}
},
IsRestrained: function () {
return (
this.HasEffect("Freeze") ||
this.HasEffect("Block") ||
this.HasEffect("BlockWardrobe")
);
},
/** Look for blindness effects and return the worst (limited by settings), Light: 1, Normal: 2, Heavy: 3 */
GetBlindLevel: function (eyesOnly = false) {
let blindLevel = 0;
const eyes1 = InventoryGet(this, "Eyes");
const eyes2 = InventoryGet(this, "Eyes2");
if (eyes1 && eyes1.Property && eyes1.Property.Expression && eyes2 && eyes2.Property && eyes2.Property.Expression) {
if ((eyes1.Property.Expression === "Closed") && (eyes2.Property.Expression === "Closed")) {
blindLevel += DialogFacialExpressionsSelectedBlindnessLevel;
}
}
if (!eyesOnly) {
const effects = CharacterGetEffects(this, ["ItemHead", "ItemHood", "ItemNeck", "ItemDevices"], true);
blindLevel += effects.reduce((Start, EffectName) => Start + (CharacterBlindLevels.get(/**@type {BlindEffectName} */(EffectName)) || 0), 0);
blindLevel += InventoryCraftCount(this, "Thick");
blindLevel -= InventoryCraftCount(this, "Thin");
}
// Light sensory deprivation setting limits blindness
if (this.IsPlayer() && this.GameplaySettings && this.GameplaySettings.SensDepChatLog == "SensDepLight") {
return Math.max(0, Math.min(2, blindLevel));
} else {
return Math.max(0, Math.min(3, blindLevel));
}
},
GetBlurLevel: function() {
if ((this.IsPlayer() && this.GraphicsSettings && !this.GraphicsSettings.AllowBlur) || CommonPhotoMode) {
return 0;
}
let blurLevel = 0;
for (const item of this.Appearance) {
for (const [effect, level] of CharacterBlurLevels.entries()) {
if (InventoryItemHasEffect(item, effect)) {
blurLevel += level;
break; // Only count the highest blur level defined on the item
}
}
}
return blurLevel;
},
IsLocked: function () {
return this.HasEffect("Lock");
},
IsBlind: function () {
return this.GetBlindLevel() > 0;
},
IsEnclose: function () {
return this.HasEffect("Enclose") || (this.HasEffect("OneWayEnclose") && this.IsPlayer());
},
IsMounted: function () {
return this.HasEffect("Mounted");
},
IsChaste: function () {
return this.HasEffect("Chaste") || this.HasEffect("BreastChaste");
},
IsVulvaChaste: function () {
return this.HasEffect("Chaste");
},
IsPlugged: function () {
return this.HasEffect("IsPlugged");
},
IsButtChaste: function () {
return this.Effect.includes("ButtChaste");
},
IsBreastChaste: function () {
return this.HasEffect("BreastChaste");
},
IsShackled: function () {
return this.HasEffect("Shackled");
},
IsSlow: function () {
return this.GetSlowLevel() > 0;
},
GetSlowLevel: function () {
// Respect immunity setting for the player
if (this.IsPlayer() && /** @type {PlayerCharacter} */(this).RestrictionSettings.SlowImmunity)
return 0;
let slowness = 0;
let hasSlowEffect = false;
if (this.HasEffect("Slow")) {
slowness++;
hasSlowEffect = true;
}
if (this.PoseMapping.BodyFull === "AllFours") slowness += 2;
else if (this.IsKneeling()) slowness += 1;
// Increase the slow level for each "Heavy" item and reduce it for each "Light" item
slowness += InventoryCraftCount(this, "Heavy");
slowness -= InventoryCraftCount(this, "Light");
// Short-circuit to save on the following checks
if (slowness === 0) return 0;
const effects = CharacterGetEffects(this, undefined, true);
// Count all slow effects applied. Minus one for the one we've seen above
slowness += effects.reduce((s, c) => s + (c === "Slow" ? 1 : 0), 0) - (hasSlowEffect ? 1 : 0);
return Math.max(0, slowness);
},
IsEgged: function () {
return this.HasEffect("Egged");
},
IsMouthBlocked: function () {
return this.HasEffect("BlockMouth");
},
IsMouthOpen: function () {
return this.HasEffect("OpenMouth");
},
IsVulvaFull: function () {
return this.HasEffect("FillVulva");
},
IsAssFull: function () {
return this.Effect.includes("IsPlugged");
},
IsFixedHead: function () {
return this.Effect.includes("FixedHead");
},
IsOwned: function () {
if (AsylumGGTSGetLevel(this) >= 6) return "ggts";
if (this.IsNpc() && this.Owner === Player.Name) return "player";
if (this.IsNpc() && this.Owner !== Player.Name && NPCEventGet(this, "EndDomTrial")) return "player";
if (this.Ownership && this.Ownership.MemberNumber) return "online";
// NPC-owner with trial completed
if (this.Owner && this.Owner.trim().startsWith("NPC-")) return "npc";
if (this.IsPlayer()) {
// NPC-owner while in trial
let trialing = PrivateCharacter.find(c => NPCEventGet(c, "EndSubTrial") > 0);
if (trialing && trialing !== this) return "npc";
}
return false;
},
IsOwnedByCharacter: function (C) {
switch (this.IsOwned()) {
case "ggts":
return false;
case "npc": {
if (!C.IsNpc()) return false;
if (this.Owner.replace("NPC-", "").trim() === C.Name)
return true;
let trialing = PrivateCharacter.find(c => NPCEventGet(c, "EndSubTrial") > 0);
if (trialing && trialing !== this) return true;
return false;
}
case "online":
return this.Ownership.MemberNumber === C.MemberNumber;
case "player":
return true;
default:
return false;
}
},
IsFullyOwned: function () {
switch (this.IsOwned()) {
case "ggts": return true;
case "npc":
return !!PrivateCharacter.find(c => NPCEventGet(c, "PlayerCollaring") > 0);
case "player":
return (NPCEventGet(this, "NPCCollaring") > 0);
case "online": return this.Ownership.Stage >= 1;
default:
return false;
}
},
IsOwnedByPlayer: function () {
return this.IsOwnedByCharacter(Player);
},
OwnerName: function () {
switch (this.IsOwned()) {
case "ggts": return TextGet("OwnerGGTS");
case "npc": {
if (this.IsFullyOwned()) {
return this.Owner.replace("NPC-", "");
}
const privateOwner = PrivateCharacter.find(c => NPCEventGet(c, "EndSubTrial") > 0);
return privateOwner?.Name ?? "";
}
case "online": return this.Ownership.Name;
case "player": return CharacterNickname(Player);
default:
return "";
}
},
OwnerNumber: function () {
if (this.IsOwned() === "online")
return this.Ownership.MemberNumber;
return -1;
},
IsOwnedByMemberNumber: function (memberNumber) {
return (memberNumber != -1 && this.OwnerNumber() === memberNumber);
},
OwnedSince: function () {
switch (this.IsOwned()) {
case "online":
return Math.floor((CurrentTime - this.Ownership.Start) / 86400000);
case "player": {
let Time = NPCEventGet(this, "NPCCollaring");
if (Time > 0) return Math.floor((CurrentTime - Time) / 86400000);
Time = NPCEventGet(this, "EndDomTrial");
if (Time > 0) {
if (Time > CurrentTime)
return Math.ceil((Time - CurrentTime) / 86400000);
else
return 0;
}
return -1;
}
case "npc": {
for (const npc of PrivateCharacter) {
let Time = NPCEventGet(npc, "PlayerCollaring");
if (Time > 0) return Math.floor((CurrentTime - Time) / 86400000);
Time = NPCEventGet(npc, "EndSubTrial");
if (Time > 0) {
if (Time > CurrentTime)
return Math.ceil((Time - CurrentTime) / 86400000);
else
return 0;
}
}
return -1;
}
}
return -1;
},
IsOwner: function () {
if (this.IsNpc() && !NPCEventGet(this, "PlayerCollaring")) return false;
return Player.IsOwnedByCharacter(this);
},
IsLover: function (C) {
return this.IsLoverOfCharacter(C);
},
IsLoverOfCharacter: function (C) {
const loves = this.GetLovership();
if (C.IsNpc()) {
const Love = loves.find(l => !l.MemberNumber && l.Name === C.Name);
if (Love == null) return false;
return Love.Start > 0;
}
return (
this.IsLoverOfMemberNumber(C.MemberNumber) ||
this.IsNpc() && (((this.Lover != null) && (this.Lover.trim() == C.Name)) || (NPCEventGet(this, "Girlfriend") > 0))
);
},
LoverName: function () {
// Only valid for NPCs. Online character can have many lovers
if (this.IsNpc() && this.Lover)
return this.Lover.replace("NPC-", "").trim();
return "";
},
IsLoverOfPlayer: function () {
return this.IsLoverOfCharacter(Player);
},
IsLoverOfMemberNumber: function (memberNumber) {
return this.GetLoversNumbers().indexOf(memberNumber) >= 0;
},
GetLoversNumbers: function (MembersOnly) {
return this.GetLovership(MembersOnly).map(l => l.MemberNumber ? l.MemberNumber : l.Name);
},
GetLovership: function (MembersOnly) {
/** @type {Lovership[]} */
let loves = [];
if (!(CommonIsArray(this.Lovership) || (this.IsNpc() && this.Lover != ""))) return loves;
if (this.IsNpc()) {
// NPC can only love the player at the moment. Reconstruct their lovership data
/** @type {Lovership} */
const love = { Name: this.Lover, MemberNumber: Player.MemberNumber };
let stages = /** @type {NPCEventType[]} */ (["Wife", "Fiancee", "Girlfriend"]);
for (const [idx, stage] of stages.entries()) {
love.Start = NPCEventGet(this, stage);
if (love.Start > 0) {
love.Stage = /** @type {0|1|2} */ (stages.length - 1 - idx);
break;
}
}
if (love.Start !== 0)
loves.push(love);
return loves;
}
for (let L of this.Lovership) {
if (typeof L.MemberNumber === "number") {
// Anything with a MemberNumber is a relationship with an online character, so properties should be fine
loves.push(L);
} else if (L.Name && (MembersOnly == null || MembersOnly == false)) {
// Otherwise, this is an NPC, so just go fetch their reconstructed data and use that as ours
const lover = PrivateCharacter.find(c => (c.IsPlayer() ? c.Name : `NPC-${c.Name}`) === L.Name);
if (!lover) continue;
const npcLove = lover.GetLovership().find(l => l.MemberNumber === Player.MemberNumber);
if (!npcLove) continue;
/** @type {Lovership} */
const love = { Name: L.Name.replace("NPC-", "") };
love.Stage = npcLove.Stage;
love.Start = npcLove.Start;
loves.push(love);
}
}
return loves;
},
GetDeafLevel: function () {
let deafLevel = 0;
for (const item of this.Appearance) {
for (const [effect, level] of CharacterDeafLevels.entries()) {
if (InventoryItemHasEffect(item, effect)) {
deafLevel += level;
break; // Only count the highest deafness level defined on the item
}
}
}
return deafLevel;
},
CanPickLocks: function () {
const CanAccessLockpicks = (this.CanInteract() || this.CanWalk()) && InventoryAvailable(this, "Lockpicks", "ItemMisc");
return CanAccessLockpicks || DialogLentLockpicks;
},
IsKneeling: function () {
return CommonIncludes(PoseAllKneeling, this.PoseMapping.BodyLower);
},
IsStanding: function () {
return CommonIncludes(PoseAllStanding, this.PoseMapping.BodyLower);
},
IsNaked: function () {
return CharacterIsNaked(this);
},
IsDeaf: function () {
return this.GetDeafLevel() > 0;
},
/* Check if one or more gag effects are active (thus bypassing the crafted small/large properties) */
IsGagged: function () {
return this.Effect.some(effect => effect in SpeechGagLevelLookup);
},
HasNoItem: function () {
return CharacterHasNoItem(this);
},
IsEdged: function () {
return CharacterIsEdged(this);
},
/** @type {() => this is PlayerCharacter} */
IsPlayer: function () {
return this.Type === CharacterType.PLAYER;
},
IsBirthday: function () {
if ((this.Creation == null) || (CurrentTime == null)) return false;
return ((new Date(this.Creation)).getDate() == (new Date(CurrentTime)).getDate()) &&
((new Date(this.Creation)).getMonth() == (new Date(CurrentTime)).getMonth()) &&
((new Date(this.Creation)).getFullYear() != (new Date(CurrentTime)).getFullYear());
},
IsSiblingOfCharacter: function(C) {
return this.IsOwnedByMemberNumber(C.OwnerNumber());
},
IsFamilyOfPlayer: function () {
return this.IsInFamilyOfMemberNumber(Player.MemberNumber);
},
IsInFamilyOfMemberNumber: function (MemberNum) {
if (this.MemberNumber === MemberNum) return false;
let C = ChatRoomCharacter.find(c => c.MemberNumber === MemberNum);
if (!C) C = PrivateCharacter.find(c => c.MemberNumber === MemberNum);
if (!C) C = Character.find(c => c.MemberNumber === MemberNum);
if (!C) return false;
// Check that we either share owners, are owned by this character, or that this character owns us
if (this.IsSiblingOfCharacter(C)) return true;
if (this.IsOwnedByCharacter(C)) return true;
if (C.IsOwnedByCharacter(this)) return true;
return false;
},
/** @type {() => this is Character} */
IsOnline: function () {
return this.Type === CharacterType.ONLINE;
},
/** @type {() => this is NPCCharacter} */
IsNpc: function () {
return this.Type === CharacterType.NPC;
},
IsSimple: function () {
return this.Type === CharacterType.SIMPLE;
},
GetDifficulty: function () {
return (
(this.Difficulty == null) ||
(this.Difficulty.Level == null) ||
(typeof this.Difficulty.Level !== "number") ||
(this.Difficulty.Level < 0) ||
(this.Difficulty.Level > 3)
) ? 1 : this.Difficulty.Level;
},
IsSuspended: function () {
return this.PoseMapping.BodyAddon === "Suspension" || this.Effect.includes("Suspended");
},
IsInverted: function () {
return this.PoseMapping.BodyAddon === "Suspension";
},
CanChangeToPose: function (Pose) {
return PoseCanChangeUnaided(this, Pose);
},
GetClumsiness: function () {
return CharacterGetClumsiness(this);
},
HasEffect: function(Effect) {
return this.Effect.includes(Effect);
},
HasTints: function() {
if (this.IsPlayer() && this.ImmersionSettings && !this.ImmersionSettings.AllowTints) {
return false;
}
return !CommonPhotoMode && this.Tints.length > 0;
},
GetTints: function() {
return CharacterGetTints(this);
},
HasAttribute: function(attribute) {
return this.Attribute.includes(attribute);
},
GetGenders: function () {
return this.Appearance.map(asset => asset.Asset.Gender).filter(a => a);
},
GetPronouns: function () {
const pronounItem = InventoryGet(this, "Pronouns");
const pronouns = pronounItem ? pronounItem.Asset.Name : "SheHer";
return /** @type {CharacterPronouns} */(pronouns);
},
HasPenis: function () {
return InventoryIsItemInList(this, "Pussy", ["Penis"]);
},
HasVagina: function () {
return InventoryIsItemInList(this, "Pussy", ["Pussy1", "Pussy2", "Pussy3"]);
},
IsFlatChested: function () {
return InventoryIsItemInList(this, "BodyUpper", ["FlatSmall", "FlatMedium"]);
},
WearingCollar: function () {
return InventoryGet(this, "ItemNeck") !== null;
},
// Adds a new hook with a Name (determines when the hook will happen, an Instance ID (used to differentiate between different hooks happening at the same time), and a function that is run when the hook is called)
RegisterHook: function (hookName, hookInstance, callback) {
if (!this.Hooks) this.Hooks = new Map();
let hooks = this.Hooks.get(hookName);
if (!hooks) {
hooks = new Map();
this.Hooks.set(hookName, hooks);
}
if (!hooks.has(hookInstance)) {
hooks.set(hookInstance, callback);
return true;
}
return false;
},
// Removes a hook based on hookName and hookInstance
UnregisterHook: function (hookName, hookInstance) {
if (!this.Hooks) return false;
const hooks = this.Hooks.get(hookName);
if (hooks && hooks.delete(hookInstance)) {
if (hooks.size == 0) {
this.Hooks.delete(hookName);
}
return true;
}
return false;
},
RunHooks: function (hookName) {
if (this.Hooks && typeof this.Hooks.get == "function") {
let hooks = this.Hooks.get(hookName);
if (hooks)
hooks.forEach((hook) => hook()); // If there's a hook, call it
}
},
HasOnGhostlist: function(target) {
return CharacterIsOnList(this, target, "ghost");
},
HasOnBlacklist: function(target) {
return CharacterIsOnList(this, target, "black");
},
HasOnWhitelist: function(target) {
return CharacterIsOnList(this, target, "white");
},
HasOnFriendlist: function(target) {
return CharacterIsOnList(this, target, "friend");
},
IsGhosted: function() {
return Player.HasOnGhostlist(this);
},
IsBlacklisted: function() {
return Player.HasOnBlacklist(this);
},
IsWhitelisted: function() {
return Player.HasOnWhitelist(this);
},
IsFriend: function() {
return Player.HasOnFriendlist(this);
}
};
// Add the character to the cache
Character.push(NewCharacter);
return NewCharacter;
}
/**
* Attributes a random name for the character, does not select a name in use
* @returns {string} - Nothing
*/
function CharacterGenerateRandomName() {
// Get the list of all currently known names
const CurrentNames = [];
CurrentNames.push(...Character.map(c => c.Name));
CurrentNames.push(...PrivateCharacter.map(c => c.Name));
// Filter those names out of our name bank
const PossibleNames = [...CharacterName].filter(n => !CurrentNames.includes(n));
// Pick one of the remaining names
return CommonRandomItemFromList("", PossibleNames);
}
/**
* Substitute name and pronoun fields in dialog.
* @param {Character} C - Character for which to build the dialog
* @returns {void} - Nothing
*/
function CharacterDialogSubstitution(C){
/** @type {CommonSubtituteSubstitution[]} */
let subst = [
["DialogCharacterName", CharacterNickname(C)],
["DialogPlayerName", CharacterNickname(Player)],
];
subst = subst.concat(ChatRoomPronounSubstitutions(C, "DialogCharacter", false));
subst = subst.concat(ChatRoomPronounSubstitutions(Player, "DialogPlayer", false));
C.Dialog.forEach(_=>{
if(_.Option) _.Option = CommonStringSubstitute(_.Option, subst);
if(_.Result) _.Result = CommonStringSubstitute(_.Result, subst);
});
}
/**
* Builds the dialog objects from the character CSV file
* @param {Character} C - Character for which to build the dialog
* @param {readonly string[][]} CSV - Content of the CSV file
* @param {string} functionPrefix - A prefix that will be added to functions that aren't part of the Dialog or ChatRoom "namespace"
* @param {boolean} reload - Perform a {@link DialogMenu.Reload} hard reset of the active `dialog` mode
* @returns {void} - Nothing
*/
function CharacterBuildDialog(C, CSV, functionPrefix, reload=true) {
C.Dialog = [];
function parseField(fieldContents) {
if (typeof fieldContents !== "string") return null;
const str = fieldContents;
if (str === "") return null;
return str;
}
// For each lines in the file
for (const L of CSV) {
if (typeof L[0] !== "string" || L[0] === "") continue;
// Creates a dialog object
/** @type {DialogLine} */
const D = {
Stage: parseField(L[0]),
NextStage: parseField(L[1]),
Option: parseField(L[2]),
Result: parseField(L[3]),
Function: parseField(L[4]),
Prerequisite: parseField(L[5]),
Group: parseField(L[6]),
Trait: parseField(L[7]),
};
// Prefix with the current screen unless this is a Dialog function or an online character
if (D.Function && D.Function !== "") {
// @ts-expect-error Not sure why the online || player check errors here
D.Function = (D.Function.startsWith("Dialog") ? "" : (C.IsOnline() || C.IsPlayer()) ? "ChatRoom" : functionPrefix) + D.Function;
}
C.Dialog.push(D);
}
if (reload && DialogMenuMode === "dialog") {
DialogMenuMapping.dialog.Reload(null, { reset: true });
}
}
/**
* Loads the content of a CSV file to build the character dialog. Can override the current screen.
* @param {Character} C - Character for which to build the dialog objects
* @param {DialogInfo} [info]
* @returns {void} - Nothing
*/
function CharacterLoadCSVDialog(C, info) {
if (!info && !C.DialogInfo) {
console.error(`cannot refresh dialog for character ${C.ID}`);
return;
} else if (info) {
C.DialogInfo = info;
} else {
// Just refresh the info we have
}
const FullPath = `Screens/${C.DialogInfo.module}/${C.DialogInfo.screen}/Dialog_${C.DialogInfo.name}.csv`;
function buildDialog() {
CharacterBuildDialog(C, CommonCSVCache[FullPath], C.DialogInfo.screen);
// Translate the dialog if needed and perform substitutions
TranslationLoadDialog(C, () => {
TranslationTranslateDialog(C);
CharacterDialogSubstitution(C);
if (C.IsPlayer()) {
for (const D of C.Dialog) {
if (typeof D.Result === "string")
PlayerDialog.set(D.Stage, D.Result);
}
}
});
}
// Finds the full path of the CSV file to use cache
if (CommonCSVCache[FullPath]) {
buildDialog();
return;
}
// Opens the file, parse it and returns the result it to build the dialog
CommonGet(FullPath, function () {
if (this.status == 200) {
CommonCSVCache[FullPath] = CommonParseCSV(this.responseText);
buildDialog();
}
});
}
/**
* Sets the clothes based on a character archetype
* @param {Character} C - Character to set the clothes for
* @param {"Maid" | "Mistress" | "Employee" | "AnimeGirl" | "Bunny" | "Succubus"} Archetype - Archetype to determine the clothes to put on
* @param {string} [ForceColor] - Color to use for the added clothes
* @returns {void} - Nothing
*/
function CharacterArchetypeClothes(C, Archetype, ForceColor) {
// Maid archetype
if (Archetype == "Maid") {
InventoryAdd(C, "MaidOutfit1", "Cloth", false);
InventoryAdd(C, "MaidOutfit2", "Cloth", false);
InventoryAdd(C, "MaidHairband1", "Hat", false);
InventoryAdd(C, "MaidLatex", "Cloth", false);
InventoryAdd(C, "MaidLatexHairband", "Hat", false);
if (Math.random() > 0.85) {
InventoryWear(C, "MaidOutfit2", "Cloth");
InventoryWear(C, "MaidHairband1", "Hat");
} else if (Math.random() > 0.75) {
InventoryWear(C, "MaidLatex", "Cloth");
InventoryGet(C, "Cloth").Color = ['#202020', '#B0B0B0', 'Default'];
InventoryWear(C, "MaidLatexHairband", "Hat");
} else {
InventoryWear(C, "MaidOutfit1", "Cloth");
InventoryWear(C, "MaidHairband1", "Hat");
}
if (InventoryGet(C, "Socks") == null) InventoryWear(C, "Socks4", "Socks", "#AAAAAA");
if (InventoryGet(C, "Shoes") == null) InventoryWear(C, "Shoes2", "Shoes", "#222222");
if ((InventoryGet(C, "Gloves") == null) && (Math.random() > 0.5)) InventoryWear(C, "Gloves1", "Gloves", "#AAAAAA");
InventoryRemove(C, "ClothAccessory");
InventoryRemove(C, "HairAccessory1");
InventoryRemove(C, "HairAccessory2");
InventoryRemove(C, "HairAccessory3");
InventoryRemove(C, "ClothLower");
C.AllowItem = (LogQuery("LeadSorority", "Maid"));
}
// Mistress archetype
if (Archetype == "Mistress") {
let ColorList = ["#333333", "#AA4444", "#AAAAAA"];
let Color = (ForceColor == null) ? CommonRandomItemFromList("", ColorList) : ForceColor;
CharacterAppearanceSetItem(C, "Hat", null);
InventoryAdd(C, "MistressGloves", "Gloves", false);
InventoryWear(C, "MistressGloves", "Gloves", Color);
InventoryAdd(C, "MistressBoots", "Shoes", false);
InventoryWear(C, "MistressBoots", "Shoes", Color);
InventoryAdd(C, "MistressTop", "Cloth", false);
InventoryWear(C, "MistressTop", "Cloth", Color);
InventoryAdd(C, "MistressBottom", "ClothLower", false);
InventoryWear(C, "MistressBottom", "ClothLower", Color);
InventoryAdd(C, "MistressPadlock", "ItemMisc", false);
InventoryAdd(C, "MistressTimerPadlock", "ItemMisc", false);
InventoryAdd(C, "MistressPadlockKey", "ItemMisc", false);
InventoryAdd(C, "DeluxeBoots", "Shoes", false);
InventoryRemove(C, "ClothAccessory");
InventoryRemove(C, "HairAccessory1");
InventoryRemove(C, "HairAccessory2");
InventoryRemove(C, "HairAccessory3");
InventoryRemove(C, "Socks");
}
// Employee archetype
if (Archetype == "Employee") {
InventoryAdd(C, "VirginKiller1", "Cloth", false);
CharacterAppearanceSetItem(C, "Cloth", C.Inventory[C.Inventory.length - 1].Asset);
CharacterAppearanceSetColorForGroup(C, "Default", "Cloth");
InventoryAdd(C, "Jeans1", "ClothLower", false);
CharacterAppearanceSetItem(C, "ClothLower", C.Inventory[C.Inventory.length - 1].Asset);
CharacterAppearanceSetColorForGroup(C, "Default", "ClothLower");
InventoryAdd(C, "SunGlasses1", "Glasses", false);
CharacterAppearanceSetItem(C, "Glasses", C.Inventory[C.Inventory.length - 1].Asset);
CharacterAppearanceSetColorForGroup(C, "Default", "Glasses");
}
// Anime girl archetype
if (Archetype == "AnimeGirl") {
CharacterNaked(C);
InventoryWear(C, "Panties1", "Panties", "#CCCCCC");
InventoryWear(C, "Bra1", "Bra", "#CCCCCC");
InventoryAdd(C, "AnimeGirl", "Cloth", false);
InventoryWear(C, "AnimeGirl", "Cloth");
InventoryAdd(C, "AnimeGirlNecklace", "Necklace", false);
InventoryWear(C, "AnimeGirlNecklace", "Necklace");
InventoryAdd(C, "AnimeGirlBoots", "Shoes", false);
InventoryWear(C, "AnimeGirlBoots", "Shoes");
InventoryAdd(C, "AnimeGirlTiara", "Hat", false);
InventoryWear(C, "AnimeGirlTiara", "Hat");
InventoryAdd(C, "AnimeGirlGloves", "Gloves", false);
InventoryWear(C, "AnimeGirlGloves", "Gloves");
InventoryAdd(C, "AnimeGirlWand", "ItemHandheld", false);
if (C.CanInteract()) {
if (Math.random() >= 0.5) InventoryWear(C, "AnimeGirlWand", "ItemHandheld");
else InventoryRemove(C, "ItemHandheld");
}
}
// Rope bunny archetype
if (Archetype == "Bunny") {
CharacterNaked(C);
InventoryWear(C, CommonRandomItemFromList(null, ["BunnySuit", "LatexBunnySuit"]), "Bra", CommonRandomItemFromList(null, ["Default", "#BBBBBB", "#222222", "#882222", "#BB8888", "#BB00BB"]));
InventoryWear(C, "BunnyCollarCuffs", "ClothAccessory");
InventoryWear(C, CommonRandomItemFromList(null, ["BunnyEars1", "BunnyEars2"]), "HairAccessory1");
InventoryWear(C, "BunnyTailStrap", "TailStraps");
if (Math.random() > 0.5) InventoryWear(C, "Pantyhose1", "Socks");
InventoryWear(C, CommonRandomItemFromList(null, ["AnkleStrapShoes", "StilettoHeels", "Shoes5"]), "Shoes");
}
// Succubus archetype
if (Archetype == "Succubus") {
CharacterNaked(C);
let Color = CommonRandomItemFromList(null, ["Default", "#222222", "#BBBBBB", "#882222"]);
InventoryWear(C, CommonRandomItemFromList(null, ["BondageDress1", "BondageDress2", "CorsetDress", "EveningGown", "Dress3"]), "Cloth", Color);
InventoryWear(C, CommonRandomItemFromList(null, ["CatEye", "CatEye2", "LargeSolid", "SuperstarBlurred", "UndershadowedSolid"]), "EyeShadow", Color);
if (Math.random() > 0.5) InventoryWear(C, CommonRandomItemFromList(null, ["GradientPantyhose", "Socks5", "Stockings1", "Stockings2"]), "Socks", Color);
InventoryWear(C, "SuccubusHorns", "HairAccessory1", Color);
InventoryWear(C, CommonRandomItemFromList(null, ["SuccubusTailStrap", "SuccubusHeartTailStrap"]), "TailStraps", Color);
InventoryWear(C, CommonRandomItemFromList(null, ["BatWings", "DevilWings", "SuccubusWings"]), "Wings", Color);
InventoryWear(C, CommonRandomItemFromList(null, ["AnkleStrapShoes", "StilettoHeels", "Shoes5", "CustomHeels", "ThighBoots"]), "Shoes", Color);
}
}
/**
* Loads an NPC into the character array. The appearance is randomized, and a type can be provided to dress them in a given style.
* @param {string} CharacterID - The unique identifier for the NPC
* @param {string} [NPCType] - The dialog used by the NPC. Defaults to CharacterID if not specified.
* @param {null | ModuleType} module
* @param {null | string} screen
* @returns {NPCCharacter} - The randomly generated NPC
*/
function CharacterLoadNPC(CharacterID, NPCType, module=null, screen=null) {
module ??= CurrentModule;
screen ??= CurrentScreen;
if (!NPCType) NPCType = CharacterID;
// Checks if the NPC already exists and returns it if it's the case
const duplicate = Character.find(c => c.CharacterID === CharacterID);
if (duplicate) return duplicate;
// Randomize the new character
const C = CharacterCreate("Female3DCG", CharacterType.NPC, CharacterID);
C.AccountName = NPCType;
CharacterLoadCSVDialog(C, { module: module, screen: screen, name: NPCType });
C.Name = CharacterGenerateRandomName();
CharacterAppearanceBuildAssets(C);
CharacterAppearanceFullRandom(C);
// Sets archetype clothes
if (NPCType.indexOf("Maid") >= 0) CharacterArchetypeClothes(C, "Maid");
if (NPCType.indexOf("Employee") >= 0) CharacterArchetypeClothes(C, "Employee");
if (NPCType.indexOf("Mistress") >= 0) CharacterArchetypeClothes(C, "Mistress");
// Returns the new character
return C;
}
/**
* Create a minimal character object
* @param {string} CharacterID - The account name to give to the character
* @returns {Character} - The created character
*/
function CharacterLoadSimple(CharacterID) {
// Checks if the character already exists and returns it if it's the case
const duplicate = Character.find(c => c.CharacterID === CharacterID);
if (duplicate) return duplicate;
// Create the new character
const C = CharacterCreate("Female3DCG", CharacterType.SIMPLE, CharacterID);
// Returns the new character
return C;
}
/**
* Sets up an online character
* @param {Character} Char - Online character to set up
* @param {ServerAccountDataSynced} data - Character data received
* @param {number} SourceMemberNumber - Source number of the refresh
*/
function CharacterOnlineRefresh(Char, data, SourceMemberNumber) {
if (!Char.IsPlayer() && Char.MemberNumber == SourceMemberNumber) {
Char.Title = ServerAccountDataSyncedValidate.Title(data.Title, Char);
Char.Nickname = ServerAccountDataSyncedValidate.Nickname(data.Nickname, Char);
Char.ItemPermission = ServerAccountDataSyncedValidate.ItemPermission(data.ItemPermission, Char);
Char.ArousalSettings = ServerAccountDataSyncedValidate.ArousalSettings(data.ArousalSettings, Char);
Char.OnlineSharedSettings = ServerAccountDataSyncedValidate.OnlineSharedSettings(data.OnlineSharedSettings, Char);
Char.Game = ServerAccountDataSyncedValidate.Game(data.Game, Char);
Char.MapData = ChatRoomMapViewInitializeCharacter(Char);
Char.MapData.Pos = ChatRoomMapViewValidatePos(data.MapData);
Char.Crafting = ServerAccountDataSyncedValidate.Crafting(data.Crafting, Char);
}
// Fully validated in the `ActivePose` setter
Char.ActivePose = /** @type {readonly AssetPoseName[]} */(data.ActivePose ?? []);
Char.LabelColor = ServerAccountDataSyncedValidate.LabelColor(data.LabelColor, Char);
Char.Creation = ServerAccountDataSyncedValidate.Creation(data.Creation, Char);
Char.Description = ServerAccountDataSyncedValidate.Description(data.Description, Char);
Char.Ownership = ServerAccountDataSyncedValidate.Ownership(data.Ownership, Char);
Char.Lovership = ServerAccountDataSyncedValidate.Lovership(data.Lovership, Char);
Char.Reputation = ServerAccountDataSyncedValidate.Reputation(data.Reputation, Char);
Char.BlockItems = /** @type {never} */([]);
Char.LimitedItems = /** @type {never} */([]);
Char.FavoriteItems = /** @type {never} */([]);
if (!Char.IsPlayer()) {
Char.PermissionItems = ServerUnPackItemPermissions(data, Char.GetDifficulty() >= 3).permissions;
Char.WhiteList = ServerAccountDataSyncedValidate.WhiteList(data.WhiteList, Char);
Char.BlackList = ServerAccountDataSyncedValidate.BlackList(data.BlackList, Char);
}
const oldPronouns = Char.GetPronouns();
const currentAppearance = Char.Appearance;
LoginPerformAppearanceFixups(data.Appearance);
ServerAppearanceLoadFromBundle(Char, "Female3DCG", data.Appearance, SourceMemberNumber);
CharacterAppearanceResolveSync(Char, currentAppearance);
if (Char.IsPlayer()) LoginValidCollar();
//if ((!Char.IsPlayer()) && ((Char.MemberNumber == SourceMemberNumber) || (Char.Inventory == null) || (Char.Inventory.length == 0))) InventoryLoad(Char, data.Inventory);
if ((!Char.IsPlayer()) && (Char.MemberNumber == SourceMemberNumber) && (data.InventoryData != null) && (data.InventoryData != "") && (data.InventoryData != Char.InventoryData)) {
Char.InventoryData = data.InventoryData;
const items = InventoryLoadCompressedData(data.InventoryData);
InventoryAddMany(Char, items, false);
}
const newPronouns = Char.GetPronouns();
if (oldPronouns !== undefined && oldPronouns !== newPronouns) {
// Reload the dialog so the new gender takes effect
// Don't reload in initialization (when oldPronouns is undefined), which breaks translation
CharacterLoadCSVDialog(Char, { module: "Online", screen: "ChatRoom", name: "Online" });
}
CharacterLoadEffect(Char);
CharacterRefresh(Char);
}
/**
* Loads an online character and flags it for a refresh if any data was changed
* @param {ServerAccountDataSynced} data - Character data received
* @param {number} SourceMemberNumber - Source number of the load trigger
* @returns {Character} - The reloaded character
*/
function CharacterLoadOnline(data, SourceMemberNumber) {
/** @type {Character} */
let Char = null;
// Check if the character already exists to reuse it
if (data.ID.toString() == Player.CharacterID) {
Char = Player;
} else {
Char = Character.find(c => c.CharacterID === data.ID);
}
// Decompresses data
if (typeof data.Description === "string" && data.Description.startsWith(ONLINE_PROFILE_DESCRIPTION_COMPRESSION_MAGIC)) {
data.Description = LZString.decompressFromUTF16(data.Description.substr(1));
}
if (Array.isArray(data.WhiteList)) {
data.WhiteList.sort((a, b) => a - b);
}
if (Array.isArray(data.BlackList)) {
data.BlackList.sort((a, b) => a - b);
}
// If the character isn't found
if (Char == null) {
// We delete the duplicate character if the person relogged.
const duplicate = Character.find(c => c.MemberNumber === data.MemberNumber);
if (duplicate) {
CharacterDelete(duplicate);
}
// Creates the new character from the online template
Char = CharacterCreate("Female3DCG", CharacterType.ONLINE, data.ID);
Char.Name = data.Name;
Char.Lover = (data.Lover != null) ? data.Lover : "";
Char.Owner = (data.Owner != null) ? data.Owner : "";
Char.Title = ServerAccountDataSyncedValidate.Title(data.Title);
Char.Nickname = data.Nickname;
Char.AccountName = "Online-" + data.ID.toString();
Char.MemberNumber = data.MemberNumber;
Char.Difficulty = data.Difficulty;
Char.AllowItem = false;
CharacterLoadCSVDialog(Char, { module: "Online", screen: "ChatRoom", name: "Online" });
CharacterOnlineRefresh(Char, data, SourceMemberNumber);
} else {
// If we must add a character, we refresh it
var Refresh = true;
if (ChatRoomData.Character != null)
for (let C = 0; C < ChatRoomData.Character.length; C++)
if (ChatRoomData.Character[C].ID.toString() == data.ID.toString()) {
Refresh = false;
break;
}
// Flags "refresh" if we need to redraw the character
if (!Refresh)
if ((Char.Description != data.Description) || (Char.Title != data.Title) || (Char.Nickname != data.Nickname) || (Char.LabelColor != data.LabelColor) || (ChatRoomData == null) || (ChatRoomData.Character == null))
Refresh = true;
else
for (let C = 0; C < ChatRoomData.Character.length; C++)
if (ChatRoomData.Character[C].ID == data.ID)
if (ChatRoomData.Character[C].Appearance.length != data.Appearance.length)
Refresh = true;
else
for (let A = 0; A < data.Appearance.length && !Refresh; A++) {
const Old = ChatRoomData.Character[C].Appearance[A];
const New = data.Appearance[A];
if ((New.Name != Old.Name) || (New.Group != Old.Group) || (New.Color != Old.Color)) Refresh = true;
else if ((New.Property != null) && (Old.Property != null) && (JSON.stringify(New.Property) != JSON.stringify(Old.Property))) Refresh = true;
else if (((New.Property != null) && (Old.Property == null)) || ((New.Property == null) && (Old.Property != null))) Refresh = true;
}
// Flags "refresh" if the ownership or lovership or inventory or blockitems or limiteditems has changed
if (!Refresh && (JSON.stringify(Char.ActivePose) !== JSON.stringify(data.ActivePose))) Refresh = true;
if (!Refresh && (JSON.stringify(Char.Ownership) !== JSON.stringify(data.Ownership))) Refresh = true;
if (!Refresh && (JSON.stringify(Char.Lovership) !== JSON.stringify(data.Lovership))) Refresh = true;
if (!Refresh && (JSON.stringify(Char.ArousalSettings) !== JSON.stringify(data.ArousalSettings))) Refresh = true;
if (!Refresh && (JSON.stringify(Char.OnlineSharedSettings) !== JSON.stringify(data.OnlineSharedSettings))) Refresh = true;
if (!Refresh && (JSON.stringify(Char.Game) !== JSON.stringify(data.Game))) Refresh = true;
if (!Refresh && (JSON.stringify(Char.MapData) !== JSON.stringify(data.MapData))) Refresh = true;
if (!Refresh && (JSON.stringify(Char.Crafting) !== JSON.stringify(data.Crafting))) Refresh = true;
if (!Refresh && Array.isArray(data.WhiteList) && (JSON.stringify(Char.WhiteList) !== JSON.stringify(data.WhiteList))) Refresh = true;
if (!Refresh && Array.isArray(data.BlackList) && (JSON.stringify(Char.BlackList) !== JSON.stringify(data.BlackList))) Refresh = true;
if (!Refresh && (data.BlockItems != null)) Refresh = true;
if (!Refresh && (data.LimitedItems != null)) Refresh = true;
if (!Refresh && (data.FavoriteItems != null)) Refresh = true;
// If we must refresh
if (Refresh) CharacterOnlineRefresh(Char, data, SourceMemberNumber);
}
// Returns the character
return Char;
}
/**
* Deletes a character from the cached list of characters
* @param {Character} C - The character to remove from the character cache
* @param {boolean} ClearCache - If we must clear the CSV cache or not (default to true)
* @returns {void} - Nothing
*/
function CharacterDelete(C, ClearCache=true) {
// Make sure the data is valid to delete
if (!C) return;
const idx = Character.findIndex(c => c.ID === C.ID);
if (idx < 0) return;
// Delete the cached dialog for that NPC.
// Note that this requires `CharacterDelete` to be called with the screen that created the NPC up.
// It's also debatable whether this does anything but always busting the cache, which is supposedly
// static and customized by `CharacterLoadCSVDialog`.
if (ClearCache) {
const FullPath = "Screens/" + CurrentModule + "/" + CurrentScreen + "/Dialog_" + C.AccountName + ".csv";
delete CommonCSVCache[FullPath];
}
// Removes the animation and character from the array
AnimationPurge(Character[idx], true);
Character.splice(idx, 1);
}
/**
* Deletes all online characters from the character array
* @returns {void} - Nothing
*/
function CharacterDeleteAllOnline() {
const onlines = Character.filter(c => c.AccountName.startsWith("Online-"));
for (const online of onlines) {
CharacterDelete(online);
}
}
/**
* Refreshes the list of effects for a character. Each effect can only be found once in the effect array
* @param {Character} C - Character for which to refresh the effect list
* @returns {void} - Nothing
*/
function CharacterLoadEffect(C) {
C.Effect = CharacterGetEffects(C);
CharacterLoadTints(C);
CharacterLoadAttributes(C);
}
/**
* Refreshes the character's attribute list
* @param {Character} C
* @returns {void} - Nothing
*/
function CharacterLoadAttributes(C) {
/** @type {Set<AssetAttribute>} */
const attributes = new Set();
C.Attribute = [];
for (const item of C.Appearance) {
const itemAttrs = InventoryGetItemProperty(item, "Attribute");
for (const attribute of itemAttrs) {
attributes.add(attribute);
}
}
C.Attribute = Array.from(attributes);
}
/**
* Returns a list of effects for a character from some or all groups
* @param {Character} C - The character to check
* @param {readonly AssetGroupName[]} [Groups=null] - Optional: The list of groups to consider. If none defined, check all groups
* @param {boolean} [AllowDuplicates=false] - Optional: If true, keep duplicates of the same effect provided they're taken from different groups
* @returns {EffectName[]} - A list of effects
*/
function CharacterGetEffects(C, Groups = null, AllowDuplicates = false) {
let totalEffects = [];
C.Appearance
.filter(A => !Array.isArray(Groups) || Groups.length == 0 || Groups.includes(A.Asset.Group.Name))
.forEach(item => {
let itemEffects = [];
if (item.Property && Array.isArray(item.Property.Effect)) {
CommonArrayConcatDedupe(itemEffects, item.Property.Effect);
}
CommonArrayConcatDedupe(itemEffects, item.Asset.Effect);
if (AllowDuplicates) {
totalEffects = totalEffects.concat(itemEffects);
} else {
CommonArrayConcatDedupe(totalEffects, itemEffects);
}
});
return totalEffects;
}
/**
* Loads a character's tints, resolving tint definitions against items from the character's appearance
* @param {Character} C - Character whose tints should be loaded
* @returns {void} - Nothing
*/
function CharacterLoadTints(C) {
// Tints on non-player characters don't have any effect right now, so don't bother loading them
if (!C.IsPlayer()) {
return;
}
/** @type {ResolvedTintDefinition[]} */
const tints = [];
for (const item of C.Appearance) {
tints.push(...InventoryGetItemProperty(item, "Tint").map(({Color, Strength, DefaultColor}) => ({Color, Strength, DefaultColor, Item: item})));
}
C.Tints = tints;
}
/**
* Loads a character's canvas by sorting its appearance and drawing it.
* @param {Character} C - Character to load the canvas for
* @returns {void} - Nothing
*/
function CharacterLoadCanvas(C) {
// Reset the property that tracks if wearing a hidden item
C.HasHiddenItems = false;
// We add a temporary appearance and pose here so that it can be modified by hooks. We copy the arrays so no hooks can alter the reference accidentally
C.DrawAppearance = AppearanceItemParse(CharacterAppearanceStringify(C));
C.DrawPoseMapping = { ...C.PoseMapping }; // Deep copy of pose record
// Run BeforeSortLayers hook
C.RunHooks("BeforeSortLayers");
// Generates a layer array from the character's appearance array, sorted by drawing order
C.AppearanceLayers = CharacterAppearanceSortLayers(C);
// Run AfterLoadCanvas hooks
C.RunHooks("AfterLoadCanvas");
// Sets the total height modifier for that character
CharacterAppearanceSetHeightModifiers(C);
// Reload the canvas
CharacterAppearanceBuildCanvas(C);
}
/**
* Reloads all character canvases in need of being redrawn.
* @returns {void} - Nothing
*/
function CharacterLoadCanvasAll() {
for (let C = 0; C < Character.length; C++)
if (Character[C].MustDraw) {
CharacterLoadCanvas(Character[C]);
Character[C].MustDraw = false;
}
}
/**
* Sets the current character to have a dialog with.
*
* @param {Character} C - Character to have a conversation with
* @returns {void} - Nothing
*/
function CharacterSetCurrent(C) {
CurrentCharacter = C;
DialogLoad();
}
/**
* Changes the character money and sync with the account server, factors in the cheaters version.
* @param {Character} C - Character for which we are altering the money amount
* @param {number} Value - Money to subtract/add
* @returns {void} - Nothing
*/
function CharacterChangeMoney(C, Value) {
C.Money = parseInt(C.Money) + parseInt(Value) * ((Value > 0) ? CheatFactor("DoubleMoney", 2) : 1);
ServerPlayerSync();
}
/**
* Refreshes the character parameters (Effects, poses, canvas, settings, etc.)
* @param {Character} C - Character to refresh
* @param {boolean} [Push=true] - Pushes the data to the server database if true or null.
* Note that this will *not* push appearance changes to the rest of the chatroom,
* which requires either {@link ChatRoomCharacterItemUpdate} or {@link ChatRoomCharacterUpdate}.
* @param {boolean} [RefreshDialog=true] - Refreshes the character dialog
* @returns {void} - Nothing
*/
function CharacterRefresh(C, Push = true, RefreshDialog = true) {
AnimationPurge(C, false);
CharacterLoadEffect(C);
PoseRefresh(C);
CharacterLoadCanvas(C);
// Label often looped through checks:
C.RunScripts = (
!C.IsOnline()
|| C.IsPlayer()
|| !(Player.OnlineSettings && Player.OnlineSettings.DisableAnimations)
) && (
!C.IsGhosted()
);
C.HasScriptedAssets = !!C.Appearance.find(CA => CA.Asset.DynamicScriptDraw);
if (C.IsPlayer()) {
// Grab the first custom background that we can find
const customBGItem = C.Appearance.find(item => item.Property?.CustomBlindBackground);
C.CustomBackground = customBGItem ? customBGItem.Property.CustomBlindBackground : undefined;
}
if (C.IsPlayer() && Push) {
ChatRoomRefreshChatSettings();
ServerPlayerAppearanceSync();
}
// Also refresh the current dialog menu if the refreshed character is the current character.
// An exception is made for the `dialog` menu mode, as the relevant dialog is _always_ stored on the dialog partner (which may or may not be `C`)
const Current = CharacterGetCurrent();
if (
RefreshDialog
&& Current
&& (C.ID === Current.ID || DialogMenuMode === "dialog")
) {
CharacterRefreshDialog(C);
}
}
/**
* Refresh the character's dialog state.
*
* This function restore consistency between its state variables and the new character appearance,
* like making sure the focused items are still present and deselecting them otherwise.
*
* @param {Character} C - Character to refresh
* @returns {void} - Nothing
*/
function CharacterRefreshDialog(C) {
// Get a reference to the currently focused item
const focusItem = C && C.FocusGroup ? InventoryGet(C, C.FocusGroup.Name) : null;
// Skip refreshing anything dialog-related when we're in the wardrobe
if (DialogIsInWardrobe()) return;
if (DialogMenuMode === "items" || DialogMenuMode === "activities" || DialogMenuMode === "permissions" || DialogMenuMode === "dialog") {
// We were looking at some inventory, perform a soft reload to update the UI
DialogMenuMapping[DialogMenuMode].Reload();
} else if (DialogMenuMode === "extended" || DialogMenuMode === "tighten") {
if (!focusItem) {
// Focused item was removed
DialogLeaveFocusItem();
return;
}
// We were looking at an extended screen
const wasLock = DialogFocusItem && DialogFocusItem.Asset.IsLock;
const sourceItem = wasLock ? DialogFocusSourceItem : DialogFocusItem;
const previousItem = sourceItem ? InventoryGet(C, sourceItem.Asset.Group.Name) : null;
if (!previousItem || previousItem.Asset.Name !== focusItem.Asset.Name) {
// Unable to reload the source item, or it's different now
DialogLeaveFocusItem();
return;
}
const lock = InventoryGetLock(focusItem);
if (wasLock && !lock) {
// The lock on the focused item was removed
DialogLeaveFocusItem();
return;
} else if (!focusItem.Asset.Extended) {
// Shouldn't happen but we don't want to open an extended screen on that
DialogChangeMode("items");
return;
}
// Replace the focus items from underneath us so we get the updated data
if (wasLock) {
DialogFocusItem = lock;
DialogFocusSourceItem = focusItem;
} else {
DialogFocusItem = focusItem;
}
// Reset the cached extended item requirement checks
if (DialogFocusItem.Asset.Extended) {
ExtendedItemRequirementCheckMessageMemo.clearCache();
}
} else if (DialogMenuMode === "colorItem") {
const itemRemovedOrDifferent = !focusItem || InventoryGetItemProperty(ItemColorItem, "Name") !== InventoryGetItemProperty(focusItem, "Name");
if (itemRemovedOrDifferent) {
ItemColorCancelAndExit();
DialogChangeMode("items");
return;
}
} else if (DialogMenuMode === "locking" || DialogMenuMode === "locked") {
if (!focusItem || DialogMenuMode === "locked" && !DialogCanCheckLock(C, focusItem)) {
// Focused item was removed, or lost its lock
DialogChangeMode("items");
} else {
// Refresh by resetting the mode
DialogChangeMode(DialogMenuMode);
}
} else if (DialogMenuMode === "crafted") {
// Automatically updates in DialogDraw()
}
}
/**
* Checks if a character is wearing items (restraints), the slave collar is ignored.
* @param {Character} C - Character to inspect the appearance of
* @returns {boolean} - Returns TRUE if the given character is wearing an item
*/
function CharacterHasNoItem(C) {
for (let A = 0; A < C.Appearance.length; A++)
if ((C.Appearance[A].Asset != null) && (C.Appearance[A].Asset.Group.Category == "Item"))
if (C.Appearance[A].Asset.Group.Name != "ItemNeck" || (C.Appearance[A].Asset.Group.Name == "ItemNeck" && !InventoryOwnerOnlyItem(C.Appearance[A])))
return false;
return true;
}
/**
* Checks if a character is naked
* @param {Character} C - Character to inspect the appearance of
* @returns {boolean} - Returns TRUE if the given character is naked
*/
function CharacterIsNaked(C) {
for (const A of C.Appearance)
if (
(A.Asset != null) &&
// Ignore items
(A.Asset.Group.Category == "Appearance") &&
// Ignore body parts
A.Asset.Group.AllowNone &&
// Always ignore all cosplay items
!A.Asset.BodyCosplay &&
!A.Asset.Group.BodyCosplay &&
// Ignore cosplay items if they are considered bodypart (BlockBodyCosplay)
(
C.IsNpc() ||
!(
A.Asset.Group.BodyCosplay &&
C.OnlineSharedSettings &&
C.OnlineSharedSettings.BlockBodyCosplay
)
)
)
return false;
return true;
}
/**
* Checks if a character is in underwear
* @param {Character} C - Character to inspect the appearance of
* @returns {boolean} - Returns TRUE if the given character is at most in underwear (can be naked)
*/
function CharacterIsInUnderwear(C) {
for (let A = 0; A < C.Appearance.length; A++)
if ((C.Appearance[A].Asset != null) && (C.Appearance[A].Asset.Group.Category == "Appearance") && C.Appearance[A].Asset.Group.AllowNone && !C.Appearance[A].Asset.BodyCosplay && !C.Appearance[A].Asset.Group.BodyCosplay)
if (!C.Appearance[A].Asset.Group.Underwear)
if (C.IsNpc() || !(C.Appearance[A].Asset.Group.BodyCosplay && C.OnlineSharedSettings && C.OnlineSharedSettings.BlockBodyCosplay))
return false;
return true;
}
/**
* Removes all appearance items from the character
* @param {Character} C - Character to undress
* @param {boolean} refresh - Whether to refresh the character afterwards and push changes to the server database
* @returns {void} - Nothing
*/
function CharacterNaked(C, refresh=true) {
CharacterAppearanceNaked(C);
if (refresh) {
CharacterRefresh(C);
}
}
/**
* Dresses the given character in random underwear
* @param {Character} C - Character to randomly dress
* @returns {void} - Nothing
*/
function CharacterRandomUnderwear(C) {
// Clear the current clothes
for (let A = C.Appearance.length - 1; A >= 0; A--)
if ((C.Appearance[A].Asset.Group.Category == "Appearance") && C.Appearance[A].Asset.Group.AllowNone) {
C.Appearance.splice(A, 1);
}
// Generate random undies at a random color
var Color = "";
for (const G of AssetGroup)
if ((G.Category == "Appearance") && G.Underwear && (G.IsDefault || (Math.random() < 0.2))) {
if (Color == "") Color = CommonRandomItemFromList("", G.ColorSchema);
const Group = G.Asset
.filter(A => InventoryAvailable(C, A.Name, G.Name));
if (Group.length > 0)
CharacterAppearanceSetItem(C, G.Name, Group[Math.floor(Group.length * Math.random())], Color);
}
// Refreshes the character
CharacterRefresh(C);
}
/**
* Removes all appearance items from the character except underwear
* @param {Character} C - Character to undress partially
* @param {readonly Item[]} Appearance - Appearance array to remove clothes from
* @returns {void} - Nothing
*/
function CharacterUnderwear(C, Appearance) {
CharacterAppearanceNaked(C);
for (let A = 0; A < Appearance.length; A++)
if ((Appearance[A].Asset != null) && Appearance[A].Asset.Group.Underwear && (Appearance[A].Asset.Group.Category == "Appearance"))
C.Appearance.push(Appearance[A]);
CharacterRefresh(C);
}
/**
* Redresses a character based on a given appearance array
* @param {Character} C - Character to redress
* @param {Array.<*>} Appearance - Appearance array to redress the character with
* @returns {void} - Nothing
*/
function CharacterDress(C, Appearance) {
if ((Appearance != null) && (Appearance.length > 0)) {
for (let A = 0; A < Appearance.length; A++)
if ((Appearance[A].Asset != null) && (Appearance[A].Asset.Group.Category == "Appearance"))
if (InventoryGet(C, Appearance[A].Asset.Group.Name) == null)
C.Appearance.push(Appearance[A]);
CharacterRefresh(C);
}
}
/**
* Removes all binding items from a given character
* @param {Character} C - Character to release
* @param {boolean} [Refresh=false] - do not call CharacterRefresh if false
* @returns {void} - Nothing
*/
function CharacterRelease(C, Refresh) {
for (let E = C.Appearance.length - 1; E >= 0; E--)
if (C.Appearance[E].Asset.IsRestraint) {
C.Appearance.splice(E, 1);
}
if (Refresh || Refresh == null) CharacterRefresh(C);
}
/**
* Releases a character from all locks matching the given lock name
* @param {Character} C - Character to release from the lock(s)
* @param {AssetLockType} LockName - Name of the lock to look for
* @returns {void} - Nothing
*/
function CharacterReleaseFromLock(C, LockName) {
for (let A = 0; A < C.Appearance.length; A++)
if ((C.Appearance[A].Property != null) && (C.Appearance[A].Property.LockedBy == LockName))
InventoryUnlock(C, C.Appearance[A]);
}
/**
* Releases a character from all restraints that are not locked
* @param {Character} C - Character to release
* @returns {void} - Nothing
*/
function CharacterReleaseNoLock(C) {
for (let E = C.Appearance.length - 1; E >= 0; E--)
if (C.Appearance[E].Asset.IsRestraint && ((C.Appearance[E].Property == null) || (C.Appearance[E].Property.LockedBy == null))) {
C.Appearance.splice(E, 1);
}
CharacterRefresh(C);
}
/**
* Removes all items except for clothing and slave collars from the character
* @param {Character} C - Character to release
* @param {boolean} refresh - Whether to refresh the character afterwards and push changes to the server database
* @returns {void} - Nothing
*/
function CharacterReleaseTotal(C, refresh=true) {
for (let E = C.Appearance.length - 1; E >= 0; E--) {
if (C.Appearance[E].Asset.Group.Category != "Appearance") {
if (C.IsOwned() && C.Appearance[E].Asset.Name == "SlaveCollar") {
// Reset slave collar to the default model if it has a gameplay effect (such as gagging the player)
if (C.Appearance[E].Property && C.Appearance[E].Property.Effect && C.Appearance[E].Property.Effect.length > 0) {
C.Appearance[E].Property = CommonCloneDeep(InventoryItemNeckSlaveCollarTypes[0].Property);
}
}
else {
C.Appearance.splice(E, 1);
}
}
}
if (refresh) {
CharacterRefresh(C);
}
}
/**
* Gets the bonus amount of a given type for a given character (Kidnap league)
* @param {Character} C - Character for which we want to get the bonus amount
* @param {string} BonusType - Type/name of the bonus to look for
* @returns {number} - Active bonus amount for the bonus type
*/
function CharacterGetBonus(C, BonusType) {
var Bonus = 0;
for (let I = 0; I < C.Inventory.length; I++)
if ((C.Inventory[I].Asset != null) && (C.Inventory[I].Asset.Bonus != null) && (C.Inventory[I].Asset.Bonus == BonusType))
Bonus++;
return Bonus;
}
/**
* Restrains a character with random restraints. Some restraints are specifically disabled for randomization in their definition.
* @param {Character} C - The target character to restrain
* @param {"FEW"|"LOT"|"ALL"} [Ratio] - Amount of restraints to put on the character
* @param {boolean} [Refresh] - do not call CharacterRefresh if false
*/
function CharacterFullRandomRestrain(C, Ratio, Refresh) {
// Sets the ratio depending on the parameter
var RatioRare = 0.75;
var RatioNormal = 0.25;
if (Ratio != null) {
if (Ratio.trim().toUpperCase() == "FEW") { RatioRare = 1; RatioNormal = 0.5; }
if (Ratio.trim().toUpperCase() == "LOT") { RatioRare = 0.5; RatioNormal = 0; }
if (Ratio.trim().toUpperCase() == "ALL") { RatioRare = 0; RatioNormal = 0; }
}
// Apply each item if needed
if (InventoryGet(C, "ItemArms") == null) InventoryWearRandom(C, "ItemArms", null, false);
if ((Math.random() >= RatioRare) && (InventoryGet(C, "ItemHead") == null)) InventoryWearRandom(C, "ItemHead", null, false);
if ((Math.random() >= RatioNormal) && (InventoryGet(C, "ItemMouth") == null)) InventoryWearRandom(C, "ItemMouth", null, false);
if ((Math.random() >= RatioRare) && (InventoryGet(C, "ItemNeck") == null)) InventoryWearRandom(C, "ItemNeck", null, false);
if ((Math.random() >= RatioNormal) && (InventoryGet(C, "ItemLegs") == null)) InventoryWearRandom(C, "ItemLegs", null, false);
if ((Math.random() >= RatioNormal) && !C.IsKneeling() && (InventoryGet(C, "ItemFeet") == null)) InventoryWearRandom(C, "ItemFeet", null, false);
if (Refresh || Refresh == null) CharacterRefresh(C);
}
/**
* Sets a specific facial expression on the character's specified AssetGroup.
*
* If there's a timer, the expression will expire after it. Note that a timed expression cannot override another one.
*
* Be careful that "Eyes" for this function means both eyes. Use Eyes1/Eyes2 to target the left or right one only.
*
* @param {Character} C - Character for which to set the expression of
* @param {ExpressionGroupName | "Eyes1"} AssetGroup - Asset group for the expression
* @param {ExpressionName} Expression - Name of the expression to use
* @param {number} [Timer] - Optional: time the expression will last, in seconds. Will send a null expression to expression queue. If expression to set is null, this is ignored.
* @param {ItemColor} [Color] - Optional: color of the expression to set
* @param {boolean} [fromQueue] - Internal: used to skip queuing the expression change if it comes from the queued expressions
* @returns {void} - Nothing
*/
function CharacterSetFacialExpression(C, AssetGroup, Expression, Timer, Color, fromQueue=false) {
// A normal eye expression is triggered for both eyes
if (AssetGroup == "Eyes") CharacterSetFacialExpression(C, "Eyes2", Expression, Timer, Color);
if (AssetGroup == "Eyes1") AssetGroup = "Eyes";
const item = InventoryGet(C, AssetGroup);
if (!item || !item.Asset.Group.AllowExpression) return;
if (Expression != null && !item.Asset.Group.AllowExpression.includes(Expression)) return;
if (!item.Property) item.Property = {};
if (item.Property.Expression == Expression && (!Color || item.Color == Color)) return;
item.Property.Expression = Expression;
if (Color && CommonColorIsValid(Color)) item.Color = Color;
// Remove all queued expression for that group if it's not coming from the queue
if (!fromQueue && C.ExpressionQueue) {
C.ExpressionQueue = C.ExpressionQueue.filter(({ Group }) => Group !== AssetGroup);
}
// We should not have timers to null a null expression
if (Timer != null && Expression != null) {
TimerExpressionQueuePush(C, AssetGroup, Timer, null);
}
// The usual weird sync-dance: if we're in a chat room, then C isn't an NPC and we can skip doing a
// push-refresh in favor of only a local-refresh plus an expression update if its a transient/animated change,
// otherwise we do a whole character update to get the appearance saved.
const inChatRoom = ServerPlayerIsInChatRoom();
const isTransient = Timer != null || fromQueue;
CharacterRefresh(C, !inChatRoom && !isTransient, false);
// @ts-expect-error Not sure why the online || player check errors here
if (inChatRoom && (C.IsOnline() || C.IsPlayer())) {
if (isTransient || C.IsPlayer()) {
ChatRoomCharacterExpressionUpdate(C, AssetGroup);
} else {
ChatRoomCharacterUpdate(C);
}
}
}
/**
* Resets the character's facial expression to the default
* @param {Character} C - Character for which to reset the expression of
* @returns {void} - Nothing
*/
function CharacterResetFacialExpression(C) {
for (const item of C.Appearance) {
const group = item.Asset.Group;
if (group.IsAppearance() && group.AllowExpression) {
let name = /** @type {ExpressionGroupName | "Eyes1"} */ (group.Name);
if (name === "Eyes") {
// Set group name for CharacterSetFacialExpression to avoid overwriting right eye colour
name = "Eyes1";
}
const color = item.Color;
CharacterSetFacialExpression(C, name, null, null, color);
}
}
}
/**
* Checks if a given expression is allowed on a character
* @param {Character} C
* @param {Item} Item
* @param {ExpressionName} Expression
*/
function CharacterIsExpressionAllowed(C, Item, Expression) {
if (!C || !Item) return false;
const allowedExpr = InventoryGetItemProperty(Item, "AllowExpression", true);
const exprPres = InventoryGetItemProperty(Item, "ExpressionPrerequisite", true);
const exprPre = exprPres[allowedExpr.indexOf(Expression)];
return ((Expression == null || allowedExpr.includes(Expression)) && (!exprPre || InventoryPrerequisiteMessage(C, exprPre, Item.Asset) === ""));
}
/**
* Gets the currently selected character
* @returns {Character|null} - Currently selected character
*/
function CharacterGetCurrent() {
const getCharacter = CharacterGetCurrentHandlers[CurrentScreen];
if (getCharacter) {
return getCharacter();
} else {
return (Player.FocusGroup != null) ? Player : CurrentCharacter;
}
}
/**
* Compresses a character wardrobe from an array to a LZ string to use less storage space
* @param {readonly ItemBundle[][]} Wardrobe - Uncompressed wardrobe
* @returns {string} - The compressed wardrobe
*/
function CharacterCompressWardrobe(Wardrobe) {
if (CommonIsArray(Wardrobe) && (Wardrobe.length > 0)) {
var CompressedWardrobe = [];
for (let W = 0; W < Wardrobe.length; W++) {
/** @type {WardrobeItemBundle[]} */
var Arr = [];
if (Wardrobe[W] != null)
for (let A = 0; A < Wardrobe[W].length; A++)
Arr.push([Wardrobe[W][A].Name, Wardrobe[W][A].Group, Wardrobe[W][A].Color, Wardrobe[W][A].Property]);
CompressedWardrobe.push(Arr);
}
return LZString.compressToUTF16(JSON.stringify(CompressedWardrobe));
} else return "";
}
/**
* Decompresses a character wardrobe from a LZ String to an array if it was previously compressed (For backward compatibility with old
* wardrobes)
* @param {ItemBundle[][] | string} Wardrobe - The current wardrobe
* @returns {ItemBundle[][]} - The array of wardrobe items decompressed
*/
function CharacterDecompressWardrobe(Wardrobe) {
if (typeof Wardrobe !== "string") {
return Wardrobe;
}
/** @type {null | WardrobeItemBundle[][]} */
let compressedWardrobe = null;
try {
compressedWardrobe = JSON.parse(LZString.decompressFromUTF16(Wardrobe));
} catch (error) {
console.error("Failed to decompress the passed wardrobe", error);
}
if (!Array.isArray(compressedWardrobe)) {
return [];
}
return compressedWardrobe.map(outfit => {
return outfit.map(([ Name, Group, Color, Property ]) => {
if (typeof Property?.Type === "string" && !CommonIsObject(Property?.TypeRecord)) {
const asset = AssetGet("Female3DCG", Group, Name);
if (!asset) {
console.warn(`unable to find ${Group}/${Name}, ignoring`);
} else {
Property.TypeRecord = ExtendedItemTypeToRecord(asset, Property.Type);
}
}
return { Name, Group, Color, Property };
}).filter(o => o);
});
}
/**
* Checks if the character is wearing an item that has a specific attribute
* @param {Character} C - The character to test for
* @param {AssetAttribute} Attribute - The name of the attribute that must be allowed
* @returns {boolean} - TRUE if at least one item has that attribute
*/
function CharacterHasItemWithAttribute(C, Attribute) {
return C.Appearance.some(item => {
return InventoryGetItemProperty(item, "Attribute").includes(Attribute);
});
}
/**
* Checks if the character is wearing an item that allows for a specific activity
* @param {Character} C - The character to test for
* @param {ActivityName} Activity - The name of the activity that must be allowed
* @returns {Item[]} - A list of items allowing that activity
*/
function CharacterItemsForActivity(C, Activity) {
return C.Appearance.filter(item => {
return InventoryGetItemProperty(item, "AllowActivity").includes(Activity);
});
}
/**
* Checks if the character is wearing an item that allows for a specific activity
* @param {Character} C - The character to test for
* @param {ActivityName} Activity - The name of the activity that must be allowed
* @returns {boolean} - TRUE if at least one item allows that activity
*/
function CharacterHasItemForActivity(C, Activity) {
return CharacterItemsForActivity(C, Activity).length > 0;
}
/**
* Checks if the character is edged or not. The character is edged if every equipped vibrating item on an orgasm zone has the "Edged" effect
* @param {Character} C - The character to check
* @returns {boolean} - TRUE if the character is edged, FALSE otherwise
*/
function CharacterIsEdged(C) {
// Always returns false for others
if (!C.IsPlayer() || !C.Effect.includes("Edged")) return false;
// Get all zones that allow an orgasm
/** @type {AssetGroupItemName[]} */
let OrgasmZones = [];
for (let Group of AssetGroup)
if (Group.ArousalZoneID != null) {
let Zone = PreferenceGetArousalZone(C, Group.Name);
if (Zone.Orgasm && (Zone.Factor > 0))
OrgasmZones.push(Zone.Name);
}
// Get every vibrating item acting on an orgasm zone
const VibratingItems = C.Appearance
.filter(A => OrgasmZones.indexOf(A.Asset.ArousalZone) >= 0)
.filter(Item => Item
&& Item.Property
&& Array.isArray(Item.Property.Effect)
&& Item.Property.Effect.includes("Vibrating")
&& typeof Item.Property.Intensity === "number"
&& Item.Property.Intensity >= 0
);
// Return true if every vibrating item on an orgasm zone has the "Edged" effect
return !!VibratingItems.length && VibratingItems.every(Item => Item.Property.Effect && Item.Property.Effect.includes("Edged"));
}
/**
* Checks if the character is wearing an item flagged as a category in a blocked list
* @param {Character} C - The character to validate
* @param {readonly AssetCategory[]} BlockList - An array of strings to validate
* @returns {boolean} - TRUE if the character is wearing a blocked item, FALSE otherwise
*/
function CharacterHasBlockedItem(C, BlockList) {
if ((BlockList == null) || !CommonIsArray(BlockList) || (BlockList.length == 0)) return false;
for (let B = 0; B < BlockList.length; B++)
for (let A = 0; A < C.Appearance.length; A++)
if ((C.Appearance[A].Asset != null) && (C.Appearance[A].Asset.Category != null) && (C.Appearance[A].Asset.Category.indexOf(BlockList[B]) >= 0))
return true;
return false;
}
/**
* Retrieves the member numbers of the given character
* @param {Character} C - The character to retrieve the lovers numbers from
* @param {Boolean} [MembersOnly] - Whether to omit NPC lovers - defaults to false (NPCs will be included by default)
* @returns {Array<String | Number>} - A list of member numbers or NPC account names representing the lovers of the
* given character
*/
function CharacterGetLoversNumbers(C, MembersOnly) {
return C.GetLoversNumbers(MembersOnly);
}
/**
* Returns whether the character appears upside-down on the screen which may depend on the player's own inverted status
* @param {Character} C - The character to check
* @returns {boolean} - If TRUE, the character appears upside-down
*/
function CharacterAppearsInverted(C) {
return Player.GraphicsSettings && Player.GraphicsSettings.InvertRoom ? Player.IsInverted() != C.IsInverted() : C.IsInverted();
}
/**
* Checks whether the given character can kneel unaided
* @param {Character} C - The character to check
* @returns {boolean} - Returns true if the character is capable of kneeling unaided, false otherwise
*/
function CharacterCanKneel(C) {
return C.CanChangeToPose("Kneel");
}
/**
* Determines how much the character's view should be darkened based on their blind level. 1 is fully visible, 0 is pitch black.
* @param {Character} C - The character to check
* @param {boolean} [eyesOnly=false] - If TRUE only check whether the character has eyes closed, and ignore item effects
* @returns {number} - The number between 0 (dark) and 1 (bright) that determines screen darkness
*/
function CharacterGetDarkFactor(C, eyesOnly = false) {
let DarkFactor = 1.0;
const blindLevel = C.GetBlindLevel(eyesOnly);
if (blindLevel >= 3) DarkFactor = 0.0;
else if (CommonPhotoMode) DarkFactor = 1.0;
else if (blindLevel === 2) DarkFactor = 0.15;
else if (blindLevel === 1) DarkFactor = 0.3;
return DarkFactor;
}
/**
* Gets the array of color tints that should be applied for a character in RGBA format.
* @param {Character} C - The character
* @returns {RGBAColor[]} - A list of RGBA tints that are currently affecting the character
*/
function CharacterGetTints(C) {
if (!C.HasTints()) {
return [];
}
/** @type {RGBAColor[]} */
const tints = C.Tints.map(({Color, Strength, DefaultColor, Item}) => {
let colorIndex = 0;
if (typeof Color === "number") {
colorIndex = Color;
if (typeof Item.Color === "string") {
Color = Item.Color;
} else if (Array.isArray(Item.Color)) {
Color = Item.Color[Color] || "Default";
} else {
Color = "Default";
}
}
if (Color === "Default") {
if (!Item.Asset.DefaultColor.every(i => i === Item.Asset.Group.DefaultColor)) {
Color = Item.Asset.DefaultColor[colorIndex];
} else if (typeof DefaultColor === "string") {
Color = DefaultColor;
} else if (typeof Item.Asset.DefaultTint === "string") {
Color = Item.Asset.DefaultTint;
}
}
const {r, g, b} = DrawHexToRGB(Color);
return {r, g, b, a: Math.max(0, Math.min(Strength, 1))};
});
return tints.filter(({a}) => a > 0);
}
/**
* Gets the clumsiness level of a character. This represents dexterity when interacting with locks etc. and can have a
* maximum value of 5.
* @param {Character} C - The character to check
* @returns {number} - The clumsiness rating of the player, a number between 0 and 5 inclusive.
*/
function CharacterGetClumsiness(C) {
let clumsiness = 0;
if (!C.CanInteract()) clumsiness += 1;
const armItem = InventoryGet(C, "ItemArms");
if (armItem && armItem.Asset.IsRestraint && InventoryItemHasEffect(armItem, "Block")) clumsiness += 2;
const handItem = InventoryGet(C, "ItemHands");
if (handItem && handItem.Asset.IsRestraint && InventoryItemHasEffect(handItem, "Block")) clumsiness += 3;
return Math.min(clumsiness, 5);
}
/**
* Applies hooks to a character based on conditions
* Future hooks go here
* @param {Character} C - The character to check
* @param {boolean} IgnoreHooks - Whether to remove some hooks from the player (such as during character dialog).
* @returns {boolean} - If a hook was applied or removed
*/
function CharacterCheckHooks(C, IgnoreHooks) {
var refresh = false;
if (C && C.DrawAppearance) {
if (!IgnoreHooks && Player.Effect.includes("VRAvatars") && C.Effect.includes("HideRestraints")) {
// Then when that character enters the virtual world, register a hook to strip out restraint layers (if needed):
const hideRestraintsHook = () => {
C.DrawAppearance = C.DrawAppearance.filter((Layer) => !(Layer.Asset && Layer.Asset.IsRestraint));
delete C.DrawPoseMapping.BodyHands;
};
if (C.RegisterHook("BeforeSortLayers", "HideRestraints", hideRestraintsHook))
refresh = true;
} else if (C.UnregisterHook("BeforeSortLayers", "HideRestraints"))
refresh = true;
// We use Appearance there so that the hook's registration is stable.
// Otherwise it would get unregistered on a second draw pass as the asset has been correctly removed.
if (C.Appearance.some(a => a.Asset && a.Asset.NotVisibleOnScreen && a.Asset.NotVisibleOnScreen.includes(CurrentScreen))) {
const notVisibleOnScreenHook = () => {
C.DrawAppearance = C.DrawAppearance.filter(item => !item.Asset.NotVisibleOnScreen || !item.Asset.NotVisibleOnScreen.includes(CurrentScreen));
};
if (C.RegisterHook("BeforeSortLayers", "NotVisibleOnScreen", notVisibleOnScreenHook))
refresh = true;
} else {
if (C.UnregisterHook("BeforeSortLayers", "NotVisibleOnScreen"))
refresh = true;
}
// Hook for layer visibility
// Visibility is a string individual layers have. If an item has any layers with visibility, it should have the LayerVisibility: true property
// We basically check the player's items and see if any are visible that have the LayerVisibility property.
let LayerVisibility = C.DrawAppearance.some(a => a.Asset && a.Asset.LayerVisibility);
if (LayerVisibility) {
// Fancy logic is to use a different hook for when the character is focused
const layerVisibilityHook = () => {
const inDialog = (CurrentCharacter != null);
C.AppearanceLayers = C.AppearanceLayers.filter((Layer) => (
!Layer.Visibility ||
(Layer.Visibility == "Player" && C.IsPlayer()) ||
(Layer.Visibility == "AllExceptPlayerDialog" && !(inDialog && C.IsPlayer())) ||
(Layer.Visibility == "Others" && !C.IsPlayer()) ||
(Layer.Visibility == "OthersExceptDialog" && !(inDialog && !C.IsPlayer())) ||
(Layer.Visibility == "Owner" && C.IsOwnedByPlayer()) ||
(Layer.Visibility == "Lovers" && C.IsLoverOfPlayer()) ||
(Layer.Visibility == "Mistresses" && LogQuery("ClubMistress", "Management"))
));
};
if (C.RegisterHook("AfterLoadCanvas", "LayerVisibilityDialog", layerVisibilityHook)) {
refresh = true;
}
} else if (C.UnregisterHook("AfterLoadCanvas", "LayerVisibility")) {
refresh = true;
}
}
if (refresh)
CharacterLoadCanvas(C);
return refresh;
}
/**
* Transfers an item from one character to another
* @param {Character} FromC - The character from which to pick the item
* @param {Character} ToC - The character on which we must put the item
* @param {AssetGroupName} Group - The item group to transfer (Cloth, Hat, etc.)
* @returns {void} - Nothing
*/
function CharacterTransferItem(FromC, ToC, Group, Refresh) {
let Item = InventoryGet(FromC, Group);
if (Item == null) return;
InventoryWear(ToC, Item.Asset.Name, Group, Item.Color, Item.Difficulty);
if (Refresh) CharacterRefresh(ToC);
}
/**
* Check if the given character can be aroused at all.
* @param {Character} C - The character to test
* @returns {boolean} That character can be aroused
*/
function CharacterHasArousalEnabled(C) {
return (C.ArousalSettings != null)
&& (C.ArousalSettings.Zone != null)
&& (C.ArousalSettings.Active != null)
&& (C.ArousalSettings.Active != "Inactive");
}
/**
* Clears a character's ownership.
*
* If the character is the player, this will also cleanup rules,
* owner-locked items, and trigger a server ownership break-up.
*
* @param {Character} C - The character breaking from their owner
* @param {boolean} push - Whether to push the data to the server
* @returns {void} - Nothing.
*/
function CharacterClearOwnership(C, push=true) {
if (C.IsPlayer()) {
const ownerType = C.IsOwned();
switch (ownerType) {
case "online":
ServerSend("AccountOwnership", { MemberNumber: C.Ownership.MemberNumber, Action: "Break" });
C.Owner = "";
C.Ownership = null;
break;
case "npc":
C.Owner = "";
C.Ownership = null;
ServerPlayerSync();
break;
}
LoginValidCollar();
LogDeleteGroup("OwnerRule");
}
C.Appearance = C.Appearance.filter(item => !item.Asset.OwnerOnly);
C.Appearance.forEach(item => ValidationSanitizeProperties(C, item));
CharacterRefresh(C);
}
/**
* Returns the nickname of a character, or the name if the nickname isn't valid
* Also validates if the character is a GGTS drone to alter her name
* @param {Character} C - The character breaking from their owner
* @returns {string} - The nickname to return
*/
function CharacterNickname(C) {
let Nick = C.Nickname;
if (Nick == null) Nick = "";
Nick = Nick.trim().substring(0, 20);
if ((Nick == "") || !ServerCharacterNicknameRegex.test(Nick)) Nick = C.Name;
return AsylumGGTSCharacterName(C, Nick);
}
/**
* Returns dialog text for a character based on their chosen pronouns. Default to She/Her entries
* @param {Character} C - The character to fetch dialog for
* @param {string} DialogKey - The type of dialog entry to look for
* @param {boolean} HideIdentity - Whether to use generic they/them pronouns
* @returns {string} - The text to use
*/
function CharacterPronoun(C, DialogKey, HideIdentity) {
let pronounName;
if (HideIdentity && ChatRoomSpace == ChatRoomSpaceType.MIXED || C == null) {
pronounName = "TheyThem";
} else {
pronounName = C.GetPronouns();
}
return InterfaceTextGet("Pronoun" + DialogKey + pronounName);
}
/**
* Returns the description text for the character's chosen pronouns. Default to She/Her
* @param {Character} C - The character to fetch text for
* @returns {string} - The text to use
*/
function CharacterPronounDescription(C) {
const pronounAsset = AssetGet(C.AssetFamily, "Pronouns", C.GetPronouns());
return pronounAsset.Description;
}
/* Update the given character's nickname.
*
* Note that changing any nickname but yours (ie. Player) is not supported.
*
* @param {Character} C - The character to change the nickname of.
* @param {string} Nick - The name to use as the new nickname. An empty string uses the character's real name.
* @return {string} null if the nickname was valid, or an explanation for why the nickname was rejected.
*/
function CharacterSetNickname(C, Nick) {
if (!C.IsPlayer()) return null;
Nick = Nick.trim();
if (Nick.length > 20) return "NicknameTooLong";
if (Nick.length > 0 && !ServerCharacterNicknameRegex.test(Nick)) return "NicknameInvalidChars";
if (C.Nickname != Nick) {
const oldNick = C.Nickname || C.Name;
C.Nickname = Nick;
if (C.IsPlayer()) {
ServerAccountUpdate.QueueData({ Nickname: Nick });
}
if (ServerPlayerIsInChatRoom()) {
// When in a chatroom, send a notification that the player updated their nick
const Dictionary = new DictionaryBuilder()
.sourceCharacter(C)
.destinationCharacter(C)
.text("OldNick", oldNick)
.text("NewNick", CharacterNickname(C))
.build();
ServerSend("ChatRoomChat", { Content: "CharacterNicknameUpdated", Type: "Action", Dictionary: Dictionary });
}
}
return null;
}
/**
* Updates the leash state on a character
*
* @param {Character} C
*/
function CharacterRefreshLeash(C) {
if (!C.IsPlayer()) return;
const leashes = Player.Appearance.filter(i => InventoryItemHasEffect(i, "Leash", true));
// We aren't wearing a leash anymore, break the link
// This can happen if someone removed the leash while in the process of being leashed away
if (leashes.length === 0) {
if (ChatRoomLeashPlayer !== null) {
ChatRoomLeashPlayer = null;
}
return;
}
// Make sure our current leasher can still leash us, since binding us to the room, or
// marking it as leash-blocking should break the leash.
if (ChatRoomLeashPlayer !== null && !ChatRoomCanBeLeashedBy(ChatRoomLeashPlayer, Player)) {
ChatRoomLeashPlayer = null;
}
// Check for a dynamic leash and update its state
const dynamicLeash = leashes.find(i => i.Asset.AllowEffect && i.Asset.AllowEffect.includes("IsLeashed"));
if (dynamicLeash) {
if (!dynamicLeash.Property) dynamicLeash.Property = {};
if (!Array.isArray(dynamicLeash.Property.Effect)) dynamicLeash.Property.Effect = [];
if (ChatRoomLeashPlayer !== null) {
dynamicLeash.Property.Effect.push("IsLeashed");
} else if (ChatRoomLeashPlayer === null) {
dynamicLeash.Property.Effect = dynamicLeash.Property.Effect.filter(e => e !== "IsLeashed");
}
ChatRoomCharacterUpdate(Player);
}
}
/**
* Create and return a character's script item, if appropriate
* @param {Character} C
* @returns {Item}
*/
function CharacterScriptGet(C) {
let script = InventoryGet(C, "ItemScript");
if (!script) {
InventoryWear(C, "Script", "ItemScript");
script = InventoryGet(C, "ItemScript");
}
script.Property = script.Property || {};
// Propagate change and try to reload the item. If the script permissions
// on the target were wrong, then it'll be null
CharacterScriptRefresh(C);
script = InventoryGet(C, "ItemScript");
return script;
}
/**
* Refresh the character's script
* @param {Character} C
*/
function CharacterScriptRefresh(C) {
ChatRoomCharacterUpdate(C);
}
/**
* Remove a character's script item
* @param {Character} C
*/
function CharacterScriptRemove(C) {
InventoryRemove(C, "ItemScript", true);
}
// Don't remove, these function are used too much downstream
/**
* Sets a new pose for the character
* @param {Character} C - Character for which to set the pose
* @param {null | AssetPoseName} poseName - Name of the pose to set as active or `null` to return to the default pose
* @param {boolean} [forceChange=false] - TRUE if the set pose(s) should overwrite current active pose(s)
* @returns {void} - Nothing
* @deprecated - Deprecated alias for {@link PoseSetActive}
*/
function CharacterSetActivePose(C, poseName, forceChange=false) {
return PoseSetActive(C, poseName, forceChange);
}
/**
* Checks whether the given character can change to the named pose (without aid by default).
* @param {Character} C - The character to check
* @param {AssetPoseName} poseName - The name of the pose to check for
* @returns {boolean} - Returns true if the character has no conflicting items and is not prevented from changing to
* the provided pose
* @deprecated Superseded by {@link PoseCanChangeUnaided}
*/
function CharacterCanChangeToPose(C, poseName) {
return PoseCanChangeUnaided(C, poseName);
}
/**
* Check a character against another one's access lists
* @param {Character} listOwner - The character to check against
* @param {Character | number} listTarget - The character or member number to check for
* @param {"black" | "white" | "ghost" | "friend"} list
*/
function CharacterIsOnList(listOwner, listTarget, list) {
const num = typeof listTarget === "number" ? listTarget : listTarget.MemberNumber;
switch (list) {
case "black":
return listOwner.BlackList.includes(num);
case "white":
return listOwner.WhiteList.includes(num);
case "ghost":
return listOwner.IsPlayer() && listOwner.GhostList.includes(num);
case "friend":
return listOwner.IsPlayer() && listOwner.FriendList.includes(num);
}
}