mirror of
https://gitgud.io/BondageProjects/Bondage-College.git
synced 2025-04-25 17:59:34 +00:00
This adds nicer methods to the Character object for checking someone against the various player/character access lists.
2461 lines
86 KiB
JavaScript
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);
|
|
}
|
|
}
|