Compare commits

69 Commits

Author SHA1 Message Date
fe4248eb8b hotfix: R107 makes the check redundant (it was never a great idea tbh) 2024-08-18 18:32:09 +00:00
d87bfde6f9 Update README.md 2024-06-29 17:18:15 +00:00
90231cb2ae dev.12: R105 fixes
a massive diff due to infrastructure work
the completion pane moved to the left of chat
no other intended changes for users
2024-06-29 17:09:25 +00:00
b0961f4fb8 dev.11 - @ and @@ fix 2024-06-05 17:33:55 +00:00
d30945201d dev.10
* Removed the patch to disable FBC tighten/loosen input, it wasn't doing anything for a while now.
* Come on peeps, deprecation doesn't mean what you think it means.
2024-02-29 18:17:49 +00:00
378d0af8d3 dev.9
* Fixed MISSING PLAYER DIALOG in donate subcommand
* Removed the club version check, it really doesn't do anything
* Removed the broken disappear subcommand
* Removed the mostly useless title subcommand
* Removed the versions subcommand, it was superceded by /versions
* Took another dive in the keystroke catcher, should fix all known conflicts
* Disabled the indicator square
* Thought about moving the time digits to a proper place, but ultimately decided to leave them be for now
2023-12-19 01:15:39 +00:00
526b51e158 ASHWGA, might as well make it a module.
Included is a minimal linter infrastructure
and a server script because [epic rant removed]
2023-12-17 03:52:43 +00:00
ff377fb709 Update 'README.md' 2023-06-18 02:15:22 +00:00
e4f7ce0560 dev.8
* R93
* /do fix
2023-06-18 02:07:49 +00:00
2bf6695af0 no changes, unindent by one level 2023-06-18 02:00:47 +00:00
0f1195d92b no changes beyond indentation 2023-06-18 01:51:26 +00:00
d3852d6e63 massive diff: no changes, just reindent 2022-12-04 14:16:28 +00:00
2f87b283ed dev.7
* R86
* external sdk
* sdk 1.1.0
* /activity fix
2022-11-30 12:10:04 +00:00
89eafdfab3 trunk: sdk 1.1.0 2022-11-28 23:48:08 +00:00
0d1f074c43 sdk 1.1.0 2022-11-28 23:17:26 +00:00
247134e04b external sdk; /activity fix 2022-11-21 21:50:38 +00:00
46fb29bcec let's factor out sdk into an external js 2022-11-21 09:25:52 +00:00
45f6f80493 dev.6
* R86Beta1 compatibility
* patch by Lyra for /do item actions
* removed handheld penetration patch until R86 release
* time zone regexp accepts spaces now
* fixed a bug with UTC+0
* better global keydown interception
* exception handlers for hooks, shouldn't break the whole club now if something goes wrong
* general cleanups and refactor
2022-11-13 16:40:33 +00:00
eaf4e3f26f so refactor, much changes
mostly improved syntax
added exception handling for hooks
hopefully fixed keydown interceptor
2022-11-12 13:06:54 +00:00
337eb83d47 Update 'mbchc-local.user.js'
/me grumbles
2022-11-11 02:14:59 +00:00
3a97857fc1 more beta fixes, removed the penetration patch for now 2022-11-11 00:03:09 +00:00
a9c6e5fd35 R86Beta1 2022-11-10 22:22:12 +00:00
247c908f46 tz fix + allow spaces
0 is falsy, who would have thunk. Will now accept spaces like "UTC - 7" because people do write like this.
2022-11-10 12:07:51 +00:00
1cc9ac18d7 Update 'mbchc-local.user.js' 2022-11-03 18:39:43 +00:00
e690960782 Update 'mbchc-local.user.js' 2022-11-03 18:20:44 +00:00
634eddbd2a Update 'mbchc-local.user.js'
patch by Lyra, fixes `/do item` behaviour
2022-11-03 18:17:20 +00:00
d43e3cdeed Update 'AUTHORS' 2022-11-03 18:05:13 +00:00
5c1d2f6397 dev.5
I thought I was being clever with Intl API, but it was a mistake, had to make it dumb and simple instead. Fixes time display with non-US locales.
2022-10-27 22:50:08 +00:00
5c534b1537 Update 'mbchc-local.user.js'
locales are bullshit
2022-10-27 17:27:42 +00:00
41b2029efd dev.4
chat room handlers instead of a function hook, this fixes communication breakdown
2022-10-24 18:03:26 +00:00
f911c05073 Update 'mbchc-local.user.js'
chat room handlers
2022-10-22 12:07:11 +00:00
8d1516f1a7 dev.3
backported trunk version
no new functionality
fixes and cleanups
2022-10-20 11:02:02 +00:00
3e3f891016 R85 update 2022-10-18 11:46:16 +00:00
ef41dd1dce maintenance update
* s/bce/fbc where is makes sense
* quick and dirty keydown filtering
* R84
* penetrators now use AllowActivity instead of Attribute
2022-09-29 18:00:14 +00:00
4930e5111b Update 'mbchc-local.user.js' 2022-08-11 00:56:40 +00:00
c76ad63d96 Update 'mbchc-dev.user.js'
disallow /do in rooms with blocked activities; autocomplete improvements
2022-07-20 23:30:36 +00:00
18095495bb Update 'mbchc-dev.user.js'
BCE expressions needed more love; also stripped a space in chats like "@'s ... "
2022-07-20 21:27:20 +00:00
faf4e95499 Update 'mbchc-dev.user.js'
well this is embarrassing
2022-07-20 20:06:24 +00:00
839ad8f0b3 Update 'mbchc-dev.user.js'
let's try this again
2022-07-20 20:01:35 +00:00
62def6bb3e sdk conflict hopefully fixed 2022-07-20 11:48:24 +00:00
3f4e43adbc Update 'README.md' 2022-07-20 08:21:10 +00:00
29e9d84ceb Update 'mbchc-dev.user.js'
BCE IM support
2022-07-20 08:14:32 +00:00
b99e28502b Update 'mbchc-dev.user.js'
well fuck
2022-07-20 08:01:47 +00:00
621dea9959 dev.2
an entirely new script, very little in common with dev.1
2022-07-20 06:22:21 +00:00
0145147333 Update 'mbchc-local.user.js' 2022-07-18 04:25:30 +00:00
6fbe13b17a Update 'mbchc-local.user.js'
* bugfix (Nickname undefined)
* history will now search by input
2022-07-16 15:03:53 +00:00
c73bffc4bb Update 'mbchc-local.user.js' 2022-07-15 16:28:26 +00:00
537c538211 Update 'mbchc-local.user.js' 2022-07-15 00:09:12 +00:00
faf2714810 Update 'mbchc-local.user.js' 2022-07-14 04:28:37 +00:00
87cf188c64 Update 'mbchc-local.user.js'
hint needs more work, won't show at all for now
2022-07-12 03:53:07 +00:00
c872444b14 Update 'mbchc-local.user.js' 2022-07-11 19:20:43 +00:00
69193e0861 Update 'mbchc-local.user.js' 2022-07-11 15:40:03 +00:00
18702fa607 Update 'mbchc-local.user.js' 2022-07-11 02:42:17 +00:00
f7e571b26a Update 'mbchc-local.user.js' 2022-07-10 07:07:09 +00:00
561bedc606 Update 'mbchc-local.user.js' 2022-07-09 17:52:03 +00:00
a0e482c536 Update 'mbchc-local.user.js' 2022-07-09 02:33:08 +00:00
071ca9a8e1 Update 'mbchc-local.user.js' 2022-07-09 01:56:25 +00:00
1fa1c8c951 Update 'mbchc-local.user.js'
also cleanup
2022-07-08 19:32:31 +00:00
d30d0c1408 Update 'README.md' 2022-07-08 03:10:05 +00:00
8d6669795b Update 'mbchc-local.user.js' 2022-07-07 23:34:05 +00:00
a140c3a301 Update 'mbchc-local.user.js' 2022-07-07 23:10:18 +00:00
7611721459 select background colour depending on current theme 2022-07-06 21:56:18 +00:00
1d8e3b100b Update 'mbchc-local.user.js' 2022-07-06 07:02:59 +00:00
e6a35024c8 Update 'mbchc-local.user.js' 2022-07-05 17:53:00 +00:00
6663c2eede Update 'mbchc-local.user.js' 2022-07-05 01:04:07 +00:00
1f45cb04e0 Update 'mbchc-local.user.js' 2022-07-04 22:22:47 +00:00
c29d264520 Update 'mbchc-local.user.js' 2022-07-04 22:19:38 +00:00
ee52aa45a8 mbchc-local.user.js (trunk) 2022-07-04 22:07:57 +00:00
a06791c039 Update 'README.md' 2022-07-02 16:21:27 +00:00
13 changed files with 8049 additions and 230 deletions

1
.gitignore vendored
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -1 +1,2 @@
Mute Mute
Lyra

View File

@@ -1,3 +1,10 @@
Mute's Bondage Club Hacks Collection # Mute's Bondage Club Hacks Collection
This document is updated with stable releases, for additional documentation please consult [[the wiki|https://code.fleshless.org/mute/MBCHC/wiki/Home]].
[[https://code.fleshless.org/mute/MBCHC/wiki]] * Unstable doc: https://code.fleshless.org/mute/MBCHC/wiki/unstable
## PSA
The only supported version is unstable, don't use any other.
## Feedback
We are available at the club in a private room named `MBCHC`. If Mute isn't there, leave a message.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
jsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"include": [
"node_modules/bc-stubs/bc/**/*.d.ts",
"node_modules/bondage-club-mod-sdk/dist/**/*.d.ts",
"typedef.d.ts",
"mbchc.mjs",
"server.js"
],
"compilerOptions": {
"lib": [
"es2022",
"DOM"
],
"checkJs": true,
"strict": false,
"noImplicitOverride": true
}
}

View File

@@ -1,248 +1,667 @@
// ==UserScript== // ==UserScript==
// @name MBCHC // @name MBCHC
// @version dev.1 // @version dev.8
// @description Mute's Bondage Club Hacks Collection // @description Mute's Bondage Club Hacks Collection
// @author codename.mute@proton.me // @author codename.mute@proton.me
// @homepage https://code.fleshless.org/mute/MBCHC
// @namespace https://code.fleshless.org/mute/ // @namespace https://code.fleshless.org/mute/
// @homepage https://code.fleshless.org/mute/MBCHC
// @updateURL https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js
// @downloadURL https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js
// @match https://bondageprojects.elementfx.com/R* // @match https://bondageprojects.elementfx.com/R*
// @match https://www.bondageprojects.elementfx.com/R* // @match https://www.bondageprojects.elementfx.com/R*
// @match https://bondage-europe.com/R* // @match https://bondage-europe.com/R*
// @match https://www.bondage-europe.com/R* // @match https://www.bondage-europe.com/R*
// @match http://localhost:*/*
// @match http://127.0.0.1:*/*
// @grant none // @grant none
// ==/UserScript== // ==/UserScript==
(function() { (function() {
'use strict'; "use strict";
if (!window.AsylumGGTSSAddItems) throw "AsylumGGTSSAddItems() not found, aborting" if (!window.AsylumGGTSSAddItems) throw "AsylumGGTSSAddItems() not found, aborting MBCHC loading"
if (window.MBCHC) throw "MBCHC found, aborting loading"
window.MBCHC = {
VERSION: "dev.8",
TARGET_VERSION: "R93",
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
RETHROW: false,
LOADED: false,
AUTOHACK_ENABLED: false,
LAST_HACKED: null,
HISTORY_MODE: false,
RE_TITLE: /^[a-zA-Z]+$/,
RE_PREF_ACTIVITY_ME: /^@/,
RE_PREF_ACTIVITY: /^@@/,
RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/,
RE_TZ: /(?:GMT|UTC)\s*([+-])\s*(\d\d?)/i,
RE_ALL_LEFT: /^<+$/,
RE_ALL_RIGHT: /^>+$/,
RE_SPACES: /\s{2,}/g,
RE_LAST_WORD: /(^|\s)([^\s]*)$/,
RE_LAST_LETTER: /[\w]$/,
RGB_MUTE: "#6c2132",
RGB_POLLY: "#81b1e7",
UTC_OFFSET: new Date().getTimezoneOffset() * 60 * 1000,
HIDE_SPECIAL: ["Activity","Emoticon"],
HIDE_BODY: ["Blush","BodyLower","BodyUpper","Eyebrows","Eyes","Eyes2","Face","Fluids","HairBack","HairFront","Hands","Head","LeftHand","Mouth","Nipples","Pussy","RightHand"],
HIDE_CLOTHES: [
"Cloth","ClothAccessory","Necklace","Suit","ClothLower","SuitLower","Bra","Corset","Panties",
"Socks","RightAnklet","LeftAnklet","Garters","Shoes","Hat","HairAccessory3","HairAccessory1","HairAccessory2",
"Gloves","Bracelet","Glasses","Mask","TailStraps","Wings"
],
HIDE_ITEMS: [
"ItemMisc","ItemEars","ItemHead","ItemNose","ItemHood","ItemAddon","ItemMouth","ItemMouth2","ItemMouth3",
"ItemArms","ItemNeckAccessories","ItemNeck","ItemNeckRestraints","ItemNipples","ItemNipplesPiercings","ItemBreast","ItemTorso","ItemTorso2",
"ItemHands","ItemPelvis","ItemVulva","ItemVulvaPiercings",
"ItemDevices","ItemLegs","ItemFeet","ItemBoots"
],
MAP_ACTIONS: { //ActivityFemale3DCG
// action
"nod|yes": {Head: {self: "Nod"}},
"no": {Head: {self: "Wiggle"}},
"moan": {Mouth: {self: "MoanGag"}},
"mumble": {Mouth: {self: "MoanGagTalk"}},
"whimper": {Mouth: {self: "MoanGagWhimper"}},
"groan": {Mouth: {self: "MoanGagGroan"}},
"scream": {Mouth: {self: "MoanGagAngry"}},
"giggle": {Mouth: {self: "MoanGagGiggle"}},
"struggle": {Arms: {self: "StruggleArms"}},
"thrash": {Legs: {self: "StruggleLegs"}},
// Static data // action zone
window.MBCHC = { "wiggle|shake": {"Arms,Breast,Boots,Butt,Ears,Feet,Hands,Nose,Pelvis,Torso": {self: "Wiggle"}},
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
LOADED: false,
VERSION: 'dev',
AUTOHACK_ENABLED: false,
LAST_HACKED: null,
RE_TITLE: /^[a-zA-Z]+$/,
HIDE_SPECIAL: ["Activity","Emoticon"],
HIDE_BODY: ["Blush","BodyLower","BodyUpper","Eyebrows","Eyes","Eyes2","Face","Fluids","HairBack","HairFront","Hands","Head","Mouth","Nipples","Pussy"],
HIDE_CLOTHES: [
"Cloth","ClothAccessory","Necklace","Suit","ClothLower","SuitLower","Bra","Corset","Panties",
"Socks","RightAnklet","LeftAnklet","Garters","Shoes","Hat","HairAccessory3","HairAccessory1","HairAccessory2",
"Gloves","Bracelet","Glasses","Mask","TailStraps","Wings"
],
HIDE_ITEMS: [
"ItemMisc","ItemEars","ItemHead","ItemNose","ItemHood","ItemAddon","ItemMouth","ItemMouth2","ItemMouth3",
"ItemArms","ItemNeckAccessories","ItemNeck","ItemNeckRestraints","ItemNipples","ItemNipplesPiercings","ItemBreast","ItemTorso",
"ItemHands","ItemPelvis","ItemVulva","ItemVulvaPiercings",
"ItemDevices","ItemLegs","ItemFeet","ItemBoots"
],
COMMANDS: [
{ Tag: "disappear",
Description: ": Become invisible; requires anal hook (hair)",
Action: (argline, cmdline, args) => {
try {
window.MBCHC.make_my_anal_hook_hide_body()
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
},
},
{ Tag: "title",
Description: "[Title]: (WIP) Set a custom title (one short word, letters only)",
Action: (argline, cmdline, args) => {
try {
window.MBCHC.action_title(args)
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
},
},
{ Tag: "donate",
Description: "[MemberNumber]: Buy data and send it to recipient",
Action: (argline, cmdline, args) => {
try {
window.MBCHC.action_donate(args)
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
},
},
{ Tag: "autohack",
Description: ": Toggle autohack mode",
Action: (argline, cmdline, args) => {
try {
window.MBCHC.action_autohack()
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
},
},
{ Tag: "nod",
Description: ": Nod",
Action: (argline, cmdline, args) => {
try {
window.MBCHC.run_activity(window.Player, "ItemHead", "Nod")
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
},
},
{ Tag: "shake",
Description: ": Shake your head",
Action: (argline, cmdline, args) => {
try {
window.MBCHC.run_activity(window.Player, "ItemHead", "Wiggle")
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
},
},
{ Tag: "shrug",
Description: ": Shrug",
Action: (argline, cmdline, args) => {
try {
window.MBCHC.send_activity(`${window.CharacterNickname(window.Player)} shrugs.`)
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
},
},
{ Tag: "myself",
Description: "[Message]: Send a custom activity as yourself (or \"@Message\")",
Action: (argline, cmdline, args) => {
if (!window.MBCHC.empty(argline)) {
try {
let message = window.MBCHC.add_full_stop(argline)
window.MBCHC.send_activity(`${window.CharacterNickname(window.Player)} ${message}`)
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
}
},
},
{ Tag: "activity",
Description: "[Message]: Send a custom activity (or \"@@Message\")",
Action: (argline, cmdline, args) => {
if (!window.MBCHC.empty(argline)) {
try {
let message = window.MBCHC.add_full_stop(window.MBCHC.capitalise(argline))
window.MBCHC.send_activity(message)
} catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
}
},
},
],
log: function(msg) {return("MBCHC: " + msg.toString())},
empty: function(text) {
if (!text) return(true)
if (String(text).trim().length < 1) return(true)
return(false)
},
add_full_stop: function(text) {
if (text.endsWith('.')) return(text)
return(`${text}.`)
},
capitalise: function(text, lower = false) {
let first = text.at(0).toLocaleUpperCase()
let rest = text.slice(1)
if (lower) rest = rest.toLocaleLowerCase()
return(first + rest)
},
// we need this one here, this is our main loading hook
orig_AsylumGGTSSAddItems: window.AsylumGGTSSAddItems,
} // MBCHC
// Loader // action target
window.AsylumGGTSSAddItems = function() { "whisper": {Ears: {others: "Whisper"}},
if (!window.MBCHC.LOADED) { "choke": {Neck: {all: "Choke"}},
"brush": {Head: {all: "TakeCare"}},
"french": {Mouth: {others: "FrenchKiss"}},
"sit": {Legs: {others: "Sit"}},
"rim": {Butt: {others: "MasturbateTongue"}},
"press": {Butt: {others: "Step"}},
"rest": {Torso: {others: "Step"}},
"pet": {Head: {all: "Pet"}},
"boop": {Nose: {all: "Pet"}},
"cuddle": {Arms: {others: "Cuddle"}},
"nuzzle": {Nose: {others: "Cuddle"}},
"grab": {Arms: {others: "Grope"}},
"clean": {Mouth: {all: "Caress"}},
"lap": {Legs: {others: "RestHead"}},
"lean": {Breast: {others: "RestHead"}},
"peck": {Mouth: {others: "PoliteKiss"}},
// Save originals hopefully after patching // action zone target
window.MBCHC.orig_ChatRoomMessageInvolvesPlayer = window.ChatRoomMessageInvolvesPlayer "item": {
window.MBCHC.orig_ChatRoomReceiveSuitcaseMoney = window.ChatRoomReceiveSuitcaseMoney "Breast,Butt,Feet,Legs": {all: "SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem|Inject"},
window.MBCHC.orig_ChatRoomSendChat = window.ChatRoomSendChat "Nipples,Pelvis": {all: "SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem"},
Arms: {all: "SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem|Inject"},
Boots: {all: "SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem"},
"Ears,Mouth": {all: "TickleItem|RubItem|RollItem"},
"Hood,Nose": {all: "TickleItem|RubItem"},
Neck: {all: "TickleItem|RubItem|RollItem|Inject"},
Torso: {all: "SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem"},
Vulva: {all: "SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem"},
VulvaPiercings: {all: "SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem|Inject"},
},
"kiss": {
Mouth: {others: "GagKiss|Kiss|GaggedKiss"},
"Boots,Hands": {self: "PoliteKiss", others: "PoliteKiss|GaggedKiss"},
"Arms,Breast,Nipples": {self: "Kiss", others: "Kiss|GaggedKiss"},
"Butt,Ears,Feet,Head,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings": {others: "Kiss|GaggedKiss"}
},
"smooch": {"Hands,Boots": {all: "Kiss"}},
"nibble|chew": {"Arms,Hands,Boots,Mouth,Nipples": {all: "Nibble"}, "Ears,Feet,Legs,Neck,Nose,Pelvis,Torse,Vulva,VulvaPiercings": {others: "Nibble"}},
"slap|spank": {"Head,Breast,Vulva,VulvaPiercings": {all: "Slap"}, "Arms,Boots,Butt,Feet,Hands,Legs,Pelvis,Torso": {all: "Spank"}},
"tickle": {"Arms,Boots,Breast,Feet,Legs,Neck,Pelvis,Torso": {all: "Tickle"}},
"massage": {"Arms,Boots,Feet,Legs,Neck,Pelvis,Torso": {all: "MassageHands"}},
"lick": {"Arms,Boots,Breast,Hands,Mouth,Nipples": {all: "Lick"}, "Ears,Feet,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings": {others: "Lick"}},
"suck": {"Nipples,Hands,Boots": {all: "Suck"}},
"bite": {"Arms,Boots,Feet,Hands,Legs,Mouth": {all: "Bite"}, "Breast,Butt,Ears,Head,Neck,Nipples,Nose,Torso": {others: "Bite"}},
"pinch": {"Arms,Ears,Nipples,Nose,Pelvis": {all: "Pinch"}},
"clamp": {Mouth: {all: "HandGag"}, Nose: {all: "Choke"}},
"step": {"Breast,Neck,Pelvis": {others: "Step"}},
"pull": {"Head,Nose,Nipples": {all: "Pull"}},
"grope": {"Butt,Breast": {all: "Grope"}, "Feet,Legs,Pelvis": {others: "Grope"}},
"rub": {"Head,Torso": {others: "Rub"}, Nose: {all: "Rub"}, Legs: {self: "Wiggle"}, Hands: {self: "Caress"}},
"caress": {Hands: {others: "Caress"}, "Arms,Breast,Butt,Ears,Feet,Head,Legs,Neck,Nipples,Nose,Pelvis,Torso,Vulva,VulvaPiercings": {all: "Caress"}},
"polish": {"Hands,Boots": {all: "TakeCare"}},
"foot": {"Head,Nose": {others: "Step"}, "Torso,Boots": {others: "MassageFeet"}, "Vulva,VulvaPiercings": {others: "MasturbateFoot"}},
"fist": {"Vulva,Butt": {all: "MasturbateFist"}},
"fuck": {"Mouth,Vulva,Butt": {others: "PenetrateSlow"}}, //peg?
"pound": {"Mouth,Vulva,Butt": {others: "PenetrateFast"}},
"tongue": {"Vulva,VulvaPiercings": {others: "MasturbateTongue"}},
"finger": {"Breast,Butt,Vulva,VulvaPiercings": {all: "MasturbateHand"}},
},
MAP_ZONES: {
"ItemBoots": ["foot", "feet", "boot", "boots", "shoe", "shoes", "toes", "toenails", "sole", "soles", "heel", "heels"],
"ItemFeet": ["leg", "legs", "ankle", "ankles"],
"ItemLegs": ["hips", "hip", "thighs", "thigh"],
"ItemVulva": ["vulva", "pussy"],
"ItemVulvaPiercings": ["clit", "clitoris"],
"ItemButt": ["butt", "ass"],
"ItemPelvis": ["tummy", "pelvis"],
"ItemTorso": ["body", "torso", "back", "ribs"],
"ItemBreast": ["breast", "breasts", "boob", "boobs", "booby", "boobie", "boobies", "tit", "tits", "titty", "tittie", "titties"],
"ItemNipples": ["nip", "nips", "nipple", "nipples"],
"ItemHands": ["hand", "hands", "fingers", "fingernails", "nails"],
"ItemArms": ["arm", "arms", "elbow", "elbows"],
"ItemNeck": ["neck"],
"ItemMouth": ["mouth", "lip", "lips", "teeth", "tongue", "gag", "cheek", "cheeks"],
"ItemNose": ["nose", "nostrils"],
"ItemEars": ["ear", "ears", "earlobe", "earlobes"],
"ItemHead": ["head", "face", "hair", "eyes", "forehead"],
},
FBC_TESTER_PATCHES: [
[/^\^('s)?( )?/g, "^SourceCharacter$1\\s+"],
[/([^\\])\$/g, "$1\\.?$$"],
],
SUBCOMMANDS_MBCHC: {
"versions": {desc: "show the mod versions across the room", cb: mbchc => mbchc.inform(mbchc.gather_versions().map(c => `<div><b>${c.name}</b> (${c.cid}): ${c.version}</div>`).join(""))},
"autohack": {desc: "toggle the autohack feature", cb: mbchc => mbchc.inform(`Autohack is now ${(mbchc.AUTOHACK_ENABLED = !mbchc.AUTOHACK_ENABLED) ? "enabled" : "disabled"}`)},
"disappear": {desc: "become invisible (requires anal hook -> hair)", cb: mbchc => mbchc.disappear()},
"donate": {desc: "Buy data and send it to recipient", args: {TARGET: {}}, cb: (mbchc, args) => mbchc.donate_data(args[0])},
"title": {desc: "set a custom title (<b>WIP</b>)", args: {TITLE: {}}, cb: (mbchc, args) => mbchc.title(args[0])},
"tz": {desc: "set target's UTC offset", args: {OFFSET: {}, "[TARGET]": {}}, cb: (mbchc, args) => mbchc.set_timezone(args)},
"purge!": {desc: "delete MBCHC online saved data", cb: mbchc => {if (window.Player.OnlineSettings.MBCHC) {delete window.Player.OnlineSettings.MBCHC; mbchc.save_settings()}}},
},
ensure: function(error, callback) {
let result = callback.call(this)
if (!result) throw error
return(result)
},
calculate_maps: function() {
this.DO_DATA = {verbs: {}, zones: {}}
for (let [verbs, data] of Object.entries(this.MAP_ACTIONS)) {
let unwound = {}
for (let [zones, actions] of Object.entries(data)) {
let all = (actions.all) ? actions.all.split("|") : []
let processed = {self: (actions.self) ? actions.self.split("|").concat(all) : all, others: (actions.others) ? actions.others.split("|").concat(all) : all}
for (let zone of zones.split(",")) unwound[`Item${zone}`] = processed
}
for (let verb of verbs.split("|")) this.DO_DATA.verbs[verb] = unwound
}
for (let [ag, zones] of Object.entries(this.MAP_ZONES)) for (let zone of zones) this.DO_DATA.zones[zone] = ag
},
settings: function(setting = null) {
let settings = window.Player.OnlineSettings.MBCHC || {}
return(setting ? settings[setting] : settings)
},
save_settings: function(cb = null) {
if (cb) {
if (!window.Player.OnlineSettings.MBCHC) window.Player.OnlineSettings.MBCHC = {}
cb.call(this, window.Player.OnlineSettings.MBCHC)
}
window.ServerAccountUpdate.QueueData({OnlineSettings: window.Player.OnlineSettings})
},
log: function(level, msg) {console[level]("MBCHC: " + String(msg))},
empty: function(text) {
if (!text) return(true)
if (String(text).trim().length < 1) return(true)
return(false)
},
normalise_message: function(text, options = {}) {
let result = text
if (options.trim) result = result.trim()
if (options.low) result = result.toLocaleLowerCase()
if (options.up) {
let first = result.at(0).toLocaleUpperCase()
let rest = result.slice(1)
result = first + rest
}
if (options.dot && result.match(this.RE_LAST_LETTER)) result = `${result}.`
return(result)
},
tokenise: function(text) { return text.replace(this.RE_SPACES, " ").split(" ") },
inform: function(html, timeout = 60000) { window.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`, timeout) },
report: function(x) {
this.inform(`Error: ${x.toString()}`)
if (this.RETHROW) throw x
},
in: function(x, floor, ceiling) { return((x >= floor) && (x <= ceiling)) },
cid2char: function(cid) {
cid = Number.parseInt(cid)
if (cid === window.Player.cid) return(window.Player)
return(this.ensure(`character ${cid} not found in the room`, () => window.ChatRoomCharacter.find(c => c.cid === cid)))
},
pos2char: function(pos) {
if (!this.in(pos, 0, window.ChatRoomCharacter.length - 1)) throw `invalid position ${pos}`
return(window.ChatRoomCharacter[pos])
},
rel2char: function(target) {
let me = this.ensure("can't find my position", () => window.ChatRoomCharacter.findIndex(char => char.IsPlayer()) + 1) - 1 // 0 is falsy, but is valid index
let pos = null
if (target.match(this.RE_ALL_LEFT)) pos = me - target.length
if (target.match(this.RE_ALL_RIGHT)) pos = me + target.length
if (null === pos) throw `failed to parse target "${target}"`
pos = pos % window.ChatRoomCharacter.length
if (pos < 0) pos = pos + window.ChatRoomCharacter.length
return(this.pos2char(pos))
},
target2char: function(target) { // target should be lowcase
let input = target
if (this.empty(target)) return(window.Player)
let int = Number.parseInt(target)
target = String(target)
let found = []
if (target.startsWith("=")) return(this.cid2char(target.slice(1)))
if (target.startsWith("<") || target.startsWith(">")) return(this.rel2char(target))
if (!isNaN(int) && int.toString() === target) { // we got a number
if (this.in(int, 0, 9)) return(this.pos2char(int))
if (this.in(int, 11, 15)) return(this.pos2char(int - 11))
if (this.in(int, 21, 25)) return(this.pos2char(int - 16))
found.push(...window.ChatRoomCharacter.filter(c => c.cid.toString().indexOf(target) > -1))
}
if (target.startsWith("@")) target = target.slice(1)
found.push(...window.ChatRoomCharacter.filter(c => c.Name.toLocaleLowerCase().indexOf(target) > -1))
found.push(...window.ChatRoomCharacter.filter(c => c.Nickname && (c.Nickname.toLocaleLowerCase().indexOf(target) > -1)))
let map = {}
found.forEach(c => {if (!map[c.cid]) map[c.cid] = c} )
found = Object.values(map)
if (found.length < 1) throw `target "${input}": no match`
if (found.length > 1) throw `target "${input}": multiple matches (${found.map(c => `${c.cid}|${c.Name}|${c.Nickname || c.Name}`).join(",")})`
return(found[0])
},
char2targets: function(char) {
let [result, cid] = [new Set(), char.cid.toString()]
result.add(cid).add(`=${cid}`)
this.tokenise(char.Name).forEach(t => {result.add(t); result.add(`@${t}`)})
if (char.Nickname) this.tokenise(char.Nickname).forEach(t => {result.add(t); result.add(`@${t}`)})
return result
},
donate_data: function(target) {
let char = this.target2char(target)
if (char.IsPlayer()) throw "target must not be you"
if (!char.IsRestrained()) throw "target must be bound"
const cost = Math.round((Math.random() * 10 + 15))
if (window.Player.Money < cost) throw "not enough money"
window.CharacterChangeMoney(window.Player, -cost)
window.ServerSend("ChatRoomChat", {Content: "ReceiveSuitcaseMoney", Type: "Hidden", Target: char.cid})
window.ChatRoomMessage({Sender: window.Player.cid, Type: "Action", Content: `You've bought data for $${cost} and sent it to ${char.dn}.`, Dictionary: [{Tag: "MISSING PLAYER DIALOG: ", Text: ""}]})
},
run_activity: function(char, ag, action) { try {
if (!window.ActivityAllowed()) throw "activities disabled in this room"
if (!window.ServerChatRoomGetAllowItem(window.Player, char)) throw "no permissions"
char.FocusGroup = this.ensure("invalid AssetGroup", () => window.AssetGroupGet(char.AssetFamily, ag))
let activity = this.ensure("invalid activity", () => window.ActivityAllowedForGroup(char, char.FocusGroup.Name, true).find(a => a.Name === action || a.Activity?.Name === action))
if ((activity.Name || activity.Activity.Name).endsWith("Item")) {
const item = this.ensure("no toy found", () => window.Player.Inventory.find(i => i.Asset?.Name === "SpankingToys" && i.Asset.Group?.Name === char.FocusGroup.Name && window.AssetSpankingToys.DynamicActivity(char) === (activity.Name || activity.Activity.Name)))
window.DialogPublishAction(char, item)
} else window.ActivityRun(window.Player, char, char.FocusGroup, activity)
} finally {char.FocusGroup = null} },
replace_me: function(match, offset, string) {
let text = string.slice(1)
let suffix = " "
if (text.startsWith("'") || text.startsWith(" ")) suffix = ""
return `${window.MBCHC.PREF_ACTIVITY}<${window.Player.cid}:>SourceCharacter${suffix}`
},
cid2dict: function(type, cid) { return({Tag: `${type}Character`, MemberNumber: cid, Text: this.cid2char(cid).dn}) },
send_activity: function(msg) {
let dict = [{Tag: "MISSING PLAYER DIALOG: ", Text: "\u200C"}] // zero-width non-joiner, used to break up ligatures, does nothing here, but an empty string is a falsy value
let cids = msg.match(this.RE_ACT_CIDS)
if (cids) {
msg = msg.replace(this.RE_ACT_CIDS, "")
if (cids[1]) dict.push(this.cid2dict("Source", cids[1]))
if (cids[2]) dict.push(this.cid2dict("Target", cids[2]), this.cid2dict("Destination", cids[2]))
}
window.ServerSend("ChatRoomChat", {Type: "Action", Content: msg, Dictionary: dict})
},
receive: function(data) {
let char = this.cid2char(data.Sender)
if (char.IsPlayer()) return true // this is our own message, sent back to us
let payload = this.ensure("Empty message", () => data.Dictionary[0])
switch (payload.type) {
case "greetings": case "hello":
char.MBCHC = payload.value
if ("greetings" === payload.type) this.hello(char)
break
default: // if we don't know the type it may be from a newer version
}
return true
},
hello: function(char = null) {
let payload = {type: "greetings", value: window.Player.MBCHC}
if (char) payload.type = "hello"
let message = {Content: "MBCHC", Type: "Hidden", Dictionary: [payload]}
if (char) message.Target = char.cid
window.ServerSend("ChatRoomChat", message)
},
disappear: function() {
let item = window.InventoryGet(window.Player, "ItemButt")
if (!item || !item.Asset || !item.Asset.Name) throw "butt seems empty"
if (item.Asset.Name !== "AnalHook") throw "butt seems occupied by something other than the anal hook"
if (!item.Property.Type || item.Property.Type !== "Hair") throw "anal hook seems not tied to hair"
item.Property = {Type: "Hair", Hide: this.HIDE_ALL}
window.CharacterRefresh(window.Player, true, true)
},
title: function(title) { // WIP
if (this.empty(title)) throw "empty title"
title = this.normalise_message(title, {trim: true, up: true, low: true})
if (title.length > 16) throw "title too long"
if (!title.match(this.RE_TITLE)) throw "invalid title"
window.TitleSet(title)
//window.TitleList.push({Name: title, Requirement: () => true}) // check for existing first
},
copy_fbc_trigger: function(trigger) {
let result = {
Type: "Action",
Event: trigger.Event,
Matchers: trigger.Matchers.map(m => ({Tester: new RegExp(this.FBC_TESTER_PATCHES.reduce((ax,[f,r]) => ax.replaceAll(f,r), m.Tester.source), "u")}))
}
return(result)
},
patch_fbc: function() {
this.remove_fbc_hook()
delete this.remove_fbc_hook
window.bce_ActivityTriggers.push(...window.bce_ActivityTriggers.filter(t => "Emote" === t.Type).map(t => this.copy_fbc_trigger(t)))
/* (["anim", "pose"]).forEach(tag => {let cmd = window.Commands.find(c => tag === c.Tag); if (cmd) cmd.AutoComplete = this[`complete_fbc_${tag}`]}) */ // this line explodes, don't ask me why
let cmd = window.Commands.find(c => "anim" === c.Tag)
if (cmd) cmd.AutoComplete = this.complete_fbc_anim
cmd = window.Commands.find(c => "pose" === c.Tag)
if (cmd) cmd.AutoComplete = this.complete_fbc_pose
},
gather_versions: function() { return(window.ChatRoomCharacter.filter(c => c.MBCHC).map(c => ({name: c.dn, cid: c.cid, version: c.MBCHC.VERSION}))) },
find_timezone: function(char) {
const timezones = this.settings("timezones")
if (timezones && "number" === typeof timezones[char.cid]) return(timezones[char.cid])
const match = (char.Description) ? char.Description.match(this.RE_TZ) : null
const int = match ? Number.parseInt(match[1] + match[2]) : 42
if (this.in(int, -12, 12)) return(int)
return(null)
},
player_enters_room: function() { // or if the mod is loaded while player is in the room
this.hello()
},
set_timezone: function(args) {
let tz = Number.parseInt(args[0])
if (isNaN(tz)) throw `invalid offset "${args[0]}"`
if (!this.in(tz, -12, 12)) throw "offset should be [-12,12]"
let char = this.target2char(args[1])
char.MBCHC_LOCAL.TZ = tz
this.save_settings(s => {if (!s.timezones) s.timezones = {}; s.timezones[char.cid] = tz})
},
update_char: function(char) {
char.cid = char.MemberNumber // Club ID (shorter)
char.dn = window.CharacterNickname(char) // DisplayName (shortcut)
if (!char.MBCHC_LOCAL) char.MBCHC_LOCAL = {}
if (!char.MBCHC_LOCAL.TZ) char.MBCHC_LOCAL.TZ = this.find_timezone(char)
},
command_mbchc: function(argline, cmdline, args) { const mbchc = window.MBCHC; try { // `this` is command object
if (args.length < 1) return(mbchc.inform(Object.entries(mbchc.SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `<div>/mbchc ${cmd} ${sub.args ? Object.keys(sub.args).join(" ") : ""}: ${sub.desc}</div>`).join("")))
let cmd = String(args.shift())
let sub = mbchc.ensure(`unknown subcommand "${cmd}"`, () => mbchc.SUBCOMMANDS_MBCHC[cmd])
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
} catch (x) { mbchc.report(x) } },
command_activity: function(argline, cmdline, args) { const mbchc = window.MBCHC; if (!mbchc.empty(argline)) { try { // `this` is command object
let message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, ''), {trim: true, dot: true, up: true})
mbchc.send_activity(message)
} catch (x) { mbchc.report(x) } } },
command_do: function(argline, cmdline, args) { const mbchc = window.MBCHC; try { // `this` is command object
if (args.length < 1) return(mbchc.inform("<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>" + Object.keys(mbchc.MAP_ACTIONS).join(", ") + "<div>Available zones:</div>" + Object.keys(mbchc.DO_DATA.zones).join(", ")))
let [verb, zone, target] = args
let zones = mbchc.ensure(`unknown verb "${verb}"`, () => mbchc.DO_DATA.verbs[verb])
if (1 === Object.keys(zones).length) {
if (!target) target = zone
zone = mbchc.MAP_ZONES[Object.keys(zones)[0]][0]
}
if (!zone) throw "zone missing"
let ag = mbchc.ensure(`unknown zone "${zone}"`, () => mbchc.DO_DATA.zones[zone])
let types = mbchc.ensure(`zone "${zone}" invalid for "${verb}"`, () => zones[ag])
let char = window.Player
if (target && ((types.self.length < 1) || (types.others.length > 0))) char = mbchc.target2char(target)
let type = char.IsPlayer() ? "self" : "others"
let available = window.ActivityAllowedForGroup(char, ag)
let toy = window.InventoryGet(window.Player, "ItemHands")
if (toy && toy.Asset.Name === "SpankingToys") available.push(window.AssetAllActivities(char.AssetFamily).find(a => a.Name === window.InventorySpankingToysGetActivity?.(window.Player)))
let actions = mbchc.ensure(`zone "${zone}" invalid for ("${verb}" "${type}")`, () => types[type])
let action = mbchc.ensure(`invalid action (${verb} ${zone} ${target})`, () => actions.find(name => available.find(a => a.Name === name || a.Activity?.Name === name)))
mbchc.run_activity(char, ag, action)
} catch (x) { mbchc.report(x) } },
bell: function() {
setTimeout(() => {document.getElementById("InputChat").style.outline = ""}, 100)
document.getElementById("InputChat").style.outline = "solid red"
},
complete: function(options, space = true) {
if (options.length < 1) return(this.bell())
if (options.length > 1) {
let width = Math.max(...options.map(o => o.length))
let pref = null
for (let i = width; i > 0; i -= 1) { let test = options[0].slice(0, i); if (options.every(o => o.startsWith(test))) {pref = test; break} }
if (pref) this.complete([pref], false)
this.complete_hint(options)
} else window.ElementValue("InputChat", document.getElementById("InputChat").value.replace(this.RE_LAST_WORD, `$1${options[0]}${space ? " " : ""}`))
},
complete_hint: function(options) {
this.COMP_HINT.innerHTML = options.sort().map(s => `<div>${s}</div>`).join("")
this.COMP_HINT.style.display = "flex"
window.ElementSetDataAttribute(this.COMP_HINT.id, "colortheme", (window.Player.ChatSettings.ColorTheme || "Light"))
let rescroll = window.ElementIsScrolledToEnd("TextAreaChatLog")
window.ChatRoomResize(false)
if (rescroll) window.ElementScrollToEnd("TextAreaChatLog")
},
comp_hint_visible: function() {return(this.COMP_HINT.parentElement && "flex" === this.COMP_HINT.style.display)},
complete_hint_hide: function(options) {if (!this.comp_hint_visible()) return; this.COMP_HINT.style.display = "none"; window.ChatRoomResize(false)},
complete_target: function(token, me2 = true, check_perms = false) {
let [locase, found] = [token.toLocaleLowerCase(), new Set()]
for (let c of window.ChatRoomCharacter) {
if ((c.IsPlayer() && !me2) || (check_perms && !window.ServerChatRoomGetAllowItem(window.Player, c))) continue
this.char2targets(c).forEach(s => {if (s.toLocaleLowerCase().startsWith(locase)) found.add(s)})
}
this.complete(Array.from(found))
},
complete_common: function() {
let input = document.getElementById("InputChat").value
return([this, input, this.tokenise(input)])
},
complete_mbchc: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
let subname = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return(mbchc.complete(Object.keys(mbchc.SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname)))) // complete subcommand name
let sub = mbchc.SUBCOMMANDS_MBCHC[subname]
if (sub && sub.args) {
let argname = Object.keys(sub.args)[tokens.length - 3]
if ("TARGET" === argname) return(mbchc.complete_target(tokens[tokens.length - 1], false))
if ("[TARGET]" === argname) return(mbchc.complete_target(tokens[tokens.length - 1]), true)
}
},
complete_do_target: function(actions, token) {
if (!actions) return
let me2 = (actions.self.length > 0)
if (me2 && actions.others.length < 1) return(this.complete([window.Player.cid.toString()])) // target is always the player
this.complete_target(token, me2, true)
},
complete_do: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
// now, we *could* run a filter to exclude impossible activities, but it isn't very useful, and also seems like a lot of CPU to iterate over every action on every zone of every char in the room
let low = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return(mbchc.complete(Object.keys(mbchc.DO_DATA.verbs).filter(c => c.startsWith(low)))) // complete verb
let ags = mbchc.DO_DATA.verbs[low]
if (!ags) return(mbchc.bell())
low = tokens[2].toLocaleLowerCase()
if (tokens.length < 4) { // complete zone or target
if (Object.keys(ags).length < 2) return(mbchc.complete_do_target(ags[Object.keys(ags)[0]], tokens[2])) // zone implied, complete target
let zones = Object.entries(mbchc.DO_DATA.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone,ag]) => zone)
return(mbchc.complete(zones))
}
if (tokens.length < 5) { // complete target where it belongs
if (Object.keys(ags).length < 2) return // zone implied, target already given
return(mbchc.complete_do_target(ags[mbchc.DO_DATA.zones[low]], tokens[3]))
}
mbchc.bell()
},
complete_fbc_anim: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
if (tokens.length > 2) return(mbchc.bell())
let anim = tokens[1].toLocaleLowerCase()
return(mbchc.complete(Object.keys(window.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim))))
},
complete_fbc_pose: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
let pose = tokens[tokens.length - 1].toLocaleLowerCase()
return(mbchc.complete(window.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose))))
},
history: function(down) {
let [text, history] = [window.ElementValue("InputChat"), window.ChatRoomLastMessage]
if (!this.HISTORY_MODE) {history.push(text); this.HISTORY_MODE = true}
let ids = history.map((t,i) => [t,i]).filter(([t,i]) => t.startsWith(history[history.length - 1])).map(([t,i]) => i)
if (!down) ids.reverse()
let found = ids.find(id => (down) ? id > window.ChatRoomLastMessageIndex : id < window.ChatRoomLastMessageIndex)
if (!found) return(this.bell())
window.ElementValue("InputChat", history[found])
window.ChatRoomLastMessageIndex = found
},
focus_chat_whitelist(event) {
if (event.ctrlKey && "v" === event.key) return true // ctrl+V should paste
return false
},
focus_chat(event) { // TODO: this is not ideal, but it will have to do for now
if (event.repeat) return // only unique presses please
if ("inline" !== document.getElementById("InputChat")?.style.display) return // input chat missing
if (["InputChat","bce-message-input"].includes(document.activeElement.id)) return // focus already set
if ([event.altKey, event.ctrlKey, event.metaKey].some(i => i) && !this.focus_chat_whitelist(event)) return // alt, ctrl and meta should all be false
window.ElementFocus("InputChat")
},
loader() {
if (this.remove_load_hook) {
this.remove_load_hook()
delete this.remove_load_hook
}
if (this.LOADED) return
// Calculated values
const COMMANDS = [
{ Tag: "mbchc", Description: ": Utility functions (\"/mbchc\" for help)", Action: this.command_mbchc, AutoComplete: this.complete_mbchc },
{ Tag: "activity", Description: "[Message]: Send a custom activity (or \"@@Message\", or \"@Message\" as yourself)", Action: this.command_activity },
{ Tag: "do", Description: ": Do an activity, as if clicked on its button (\"/do\" for help)", Action: this.command_do, AutoComplete: this.complete_do },
]
this.HIDE_ALL = this.HIDE_SPECIAL.concat(this.HIDE_BODY).concat(this.HIDE_CLOTHES).concat(this.HIDE_ITEMS)
this.CommandsKey = CommandsKey /* eslint-disable-line no-undef */ // window.CommandsKey is undefined
this.RE_ACTIVITY = RegExp(`^${this.CommandsKey}activity `)
this.PREF_ACTIVITY = `${this.CommandsKey}activity `
this.COMP_HINT = document.createElement("div")
this.COMP_HINT.id = "mbchcCompHint"
let css = document.createElement("style")
css.type = "text/css"
css.textContent = `
#TextAreaChatLog .mbchc, #TextAreaChatLog .mbchc {
background-color: ${this.RGB_POLLY};
}
#TextAreaChatLog[data-colortheme="dark"] .mbchc, #TextAreaChatLog[data-colortheme="dark2"] .mbchc {
background-color: ${this.RGB_MUTE};
}
#${this.COMP_HINT.id} {
flex-flow: column wrap;
overflow: auto;
display: none;
background-color: ${this.RGB_POLLY};
color: black;
}
#${this.COMP_HINT.id}[data-colortheme="dark"], #${this.COMP_HINT.id}[data-colortheme="dark2"] {
background-color: ${this.RGB_MUTE};
color: white;
}
#${this.COMP_HINT.id} div {
margin: 0 0.5ex;
}
`
document.head.appendChild(css)
// Actions
this.calculate_maps()
window.Player.MBCHC = {VERSION: this.VERSION}
window.CommandCombine(COMMANDS)
// Functions // Hooks
window.MBCHC.HIDE_ALL = window.MBCHC.HIDE_SPECIAL.concat(window.MBCHC.HIDE_BODY).concat(window.MBCHC.HIDE_CLOTHES).concat(window.MBCHC.HIDE_ITEMS) this.remove_fbc_hook = this.before("MainRun", () => window.bce_ActivityTriggers && this.patch_fbc())
window.MBCHC.make_my_anal_hook_hide_body = function() { this.after("CharacterOnlineRefresh", char => this.update_char(char))
let item = window.InventoryGet(window.Player, "ItemButt") this.after("ChatRoomReceiveSuitcaseMoney", () => {
if (!item || !item.Asset || !item.Asset.Name) throw "butt seems empty" if (this.AUTOHACK_ENABLED && this.LAST_HACKED) {
if (item.Asset.Name !== "AnalHook") throw "butt seems occupied by something other than the anal hook" window.CurrentCharacter = this.cid2char(this.LAST_HACKED)
if (!item.Property.Type || item.Property.Type !== "Hair") throw "anal hook seems not tied to hair" this.LAST_HACKED = null
item.Property = {Type: "Hair", Hide: this.HIDE_ALL} window.ChatRoomTryToTakeSuitcase()
window.CharacterRefresh(window.Player, true, true) }
} })
window.MBCHC.donate_data = function(id) { this.before("ChatRoomSendChat", () => {
if (id == window.Player.MemberNumber) throw "recipient must not be you" let input = window.ElementValue("InputChat")
const char = window.ChatRoomCharacter.find( c => c.MemberNumber == id ) if (!input.startsWith("@@@") && input.startsWith("@")) {
if (!char) throw "recipient not found" input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY)
if (!char.IsRestrained()) throw "recipient must be bound" input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me)
const cost = (Math.random() * 10 + 15).toFixed(0) window.ElementValue("InputChat", input)
if (window.Player.Money < cost) throw "not enough money" }
window.CharacterChangeMoney(window.Player, -cost) })
window.ServerSend("ChatRoomChat", {Content: "ReceiveSuitcaseMoney", Type: "Hidden", Target: id}) this.after("ChatRoomSendChat", () => {
return({cost: cost, name: (char.Nickname || char.Name)}) const history = window.ChatRoomLastMessage
} if ((history.length > 1) && (history[history.length - 1] === history[history.length - 2])) {history.pop(); window.ChatRoomLastMessageIndex -= 1}
window.MBCHC.run_activity = function(char, ag, action) { })
try { this.before("ChatRoomDrawCharacterOverlay", (C, CharX, CharY, Zoom, Pos) => {
char.FocusGroup = window.AssetGroupGet(char.AssetFamily, ag) window.ChatRoomHideIconState < 1 && C.MBCHC && window.DrawRect(CharX + 175 * Zoom, CharY, 50 * Zoom, 50 * Zoom, C.MBCHC.VERSION === window.Player.MBCHC.VERSION ? this.RGB_POLLY : this.RGB_MUTE)
if (!char.FocusGroup) throw "invalid AssetGroup" if (window.ChatRoomHideIconState < 1 && C.MBCHC_LOCAL && "number" === typeof C.MBCHC_LOCAL.TZ) {
let activity = window.ActivityAllowedForGroup(char, char.FocusGroup.Name).find( a => a.Name === action) const hours = new Date(window.CommonTime() + this.UTC_OFFSET + C.MBCHC_LOCAL.TZ * 60 * 60 * 1000).getHours()
if (!activity) throw "invalid activity" window.DrawTextFit(hours < 10 ? "0" + hours.toString() : hours.toString(), CharX + 200 * Zoom, CharY + 25 * Zoom, 46 * Zoom, "white", "black")
window.ActivityRun(char, activity) }
} finally { })
char.FocusGroup = null this.after("ElementValue", (ID, Value, cc = window.CurrentCharacter) => "bce_LayerPriority" === ID && cc?.FocusGroup && window.InventoryGet(cc, cc.FocusGroup.Name)?.Difficulty.toString() === Value && window.InventoryLocked(cc, cc.FocusGroup.Name, true) && window.ElementSetAttribute(ID, "disabled", true))
} this.after("ChatRoomCreateElement", () => this.COMP_HINT.parentElement || document.body.appendChild(this.COMP_HINT))
} this.before("ChatRoomClearAllElements", () => !void this.complete_hint_hide() && this.COMP_HINT.remove())
window.MBCHC.send_activity = function(msg) { this.before("ChatRoomClick", () => this.complete_hint_hide())
window.ServerSend("ChatRoomChat", {Type: "Action", Content: "lef_wie234jf_owhwi", Dictionary: [{Tag: 'MISSING PLAYER DIALOG: lef_wie234jf_owhwi', Text: msg}]}) this.after("ChatRoomResize", () => {
} if (window.CharacterGetCurrent() == null && window.CurrentScreen == "ChatRoom" && document.getElementById("InputChat") && document.getElementById("TextAreaChatLog") && this.comp_hint_visible()) { // upstream
// Command actions const fontsize = ChatRoomFontSize /* eslint-disable-line no-undef */ // window.ChatRoomFontSize is undefined
window.MBCHC.action_title = function(args) { window.ElementPositionFix("TextAreaChatLog", fontsize, 1005, 66, 988, 630)
let title = args.shift() window.ElementPositionFix(this.COMP_HINT.id, fontsize, 1005, 701, 988, 200)
if (!title || !title.length || title.length < 1) throw "empty title" this.COMP_HINT.style.display = "flex"
if (title.length > 16) throw "title too long" }
if (!title.match(window.MBCHC.RE_TITLE)) throw "invalid title" })
let first = title.at(0).toLocaleUpperCase() document.addEventListener("keydown", event => this.focus_chat(event))
let rest = title.slice(1).toLocaleLowerCase() this.SDK.hookFunction("ChatRoomKeyDown", 0, (nextargs, next) => { // this fires on chat input events
title = first + rest let [event] = nextargs
window.TitleSet(title) window.MBCHC.complete_hint_hide()
// TODO: this needs much more work. at least don't push a second title if ((window.KeyPress == 33) || (window.KeyPress == 34)) { // better history
// we need to patch the text cache event.preventDefault()
// we need to check for other players' custom titles return(window.MBCHC.history(window.KeyPress - 33))
window.TitleList.push({Name: title, Requirement: () => {return true}}) }
} if (window.MBCHC.HISTORY_MODE) {
window.MBCHC.action_donate = function(args) { window.ChatRoomLastMessage.pop()
let id = Number.parseInt(args.shift()) window.MBCHC.HISTORY_MODE = false
if (isNaN(id)) throw "empty or invalid member number" }
let result = window.MBCHC.donate_data(id) return(next(nextargs))
window.ChatRoomSendLocal(`(You've bought data for $${result.cost} and sent it to ${result.name})`) })
}
window.MBCHC.action_autohack = function() {
window.MBCHC.AUTOHACK_ENABLED = !window.MBCHC.AUTOHACK_ENABLED
window.ChatRoomSendLocal(`(Autohack is now ${window.MBCHC.AUTOHACK_ENABLED ? "enabled" : "disabled"})`)
}
// Hooks // Chat room handlers
window.ChatRoomMessageInvolvesPlayer = function(data) { window.ChatRoomRegisterMessageHandler({ Priority: -220, Description: "MBCHC preprocessor", Callback: (data, sender, msg, metadata) => {
if (!data.MBCHC_ID) { data.MBCHC_ID = this.NEXT_MESSAGE
data.MBCHC_ID = window.MBCHC.NEXT_MESSAGE this.NEXT_MESSAGE += 1
window.MBCHC.NEXT_MESSAGE += 1 if (this.LOG_MESSAGES) console.debug({data, sender, msg, metadata})
if (window.MBCHC.LOG_MESSAGES) console.debug(data) }})
if (("ReceiveSuitcaseMoney" === data.Content) && ("Hidden" === data.Type)) { window.MBCHC.LAST_HACKED = data.Sender } window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC room enter hook",
} Callback: (data, sender, msg, metadata) => { if (("Action" === data.Type) && ("ServerEnter" === data.Content) && (data.Sender === window.Player.cid)) this.player_enters_room() }
return(window.MBCHC.orig_ChatRoomMessageInvolvesPlayer(data)) })
} window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC specific consumer",
window.ChatRoomReceiveSuitcaseMoney = function() { Callback: (data, sender, msg, metadata) => { if (("Hidden" === data.Type) && ("MBCHC" === data.Content)) return this.receive(data) }
let result = window.MBCHC.orig_ChatRoomReceiveSuitcaseMoney() })
if (window.MBCHC.AUTOHACK_ENABLED && window.MBCHC.LAST_HACKED) { window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC autohack lookup",
window.CurrentCharacter = {MemberNumber: window.MBCHC.LAST_HACKED} Callback: (data, sender, msg, metadata) => { if (("Hidden" === data.Type) && ("ReceiveSuitcaseMoney" === data.Content)) this.LAST_HACKED = data.Sender }
window.MBCHC.LAST_HACKED = null })
window.ChatRoomTryToTakeSuitcase()
}
return(result)
}
window.ChatRoomSendChat = function() {
let input = window.ElementValue("InputChat")
if (!input.startsWith("@@@")) {
input = input.replace(/^@@/, "/activity ")
input = input.replace(/^@/, "/myself ")
window.ElementValue("InputChat", input)
}
return(window.MBCHC.orig_ChatRoomSendChat())
}
// Actions // footer
window.CommandCombine(window.MBCHC.COMMANDS) this.LOADED = true
if (window.bcModSdk) window.bcModSdk.registerMod("MBCHC", window.MBCHC.VERSION) this.log("info", `loaded version ${this.VERSION}`)
window.MBCHC.LOADED = true if (window.GameVersion !== this.TARGET_VERSION) this.log("warn", `Game version doesn't match the target ("${this.TARGET_VERSION}"), beware of incompatibilities`) // TODO: betas are like R86Beta1; cheat?
console.info(window.MBCHC.log("loaded version " + window.MBCHC.VERSION)) if (("Online" === window.CurrentModule) && ("ChatRoom" === window.CurrentScreen)) {
} window.ChatRoomCharacter.forEach(c => this.update_char(c))
return(window.MBCHC.orig_AsylumGGTSSAddItems()) this.player_enters_room()
} // Loader }
},
preloader() {
this.SDK = window.bcModSdk.registerMod({name:"MBCHC",fullName:"Mute's Bondage Club Hacks Collection",version:this.VERSION,repository:"https://code.fleshless.org/mute/MBCHC/"})
this.before = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs,next) => {try {cb?.(...nextargs)} catch (x) {console.error(x)} finally {return next(nextargs)}})
this.after = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs,next) => {const result = next(nextargs); try {cb?.(...nextargs)} catch (x) {console.error(x)} finally {return result}})
if (window.CurrentModule && window.CurrentScreen && !("Character" === window.CurrentModule && "Login" === window.CurrentScreen)) return this.loader()
this.remove_load_hook = this.before("AsylumGGTSSAddItems", () => this.loader())
}
} // MBCHC
fetch("https://code.fleshless.org/mute/MBCHC/raw/branch/master/bondage-club-mod-sdk-1.1.0.js").then(r=>r.text()).then(r=>eval(r)).then(_=>window.MBCHC.preloader()).catch(x=>console.error(x)) /* eslint-disable-line no-eval */
})() })()

667
mbchc-local.user.js Normal file
View File

@@ -0,0 +1,667 @@
// ==UserScript==
// @name MBCHC-local
// @version trunk
// @description Mute's Bondage Club Hacks Collection (development version)
// @author codename.mute@proton.me
// @namespace https://code.fleshless.org/mute/
// @homepage https://code.fleshless.org/mute/MBCHC
// @updateURL https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-local.user.js
// @downloadURL https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-local.user.js
// @match https://bondageprojects.elementfx.com/R*
// @match https://www.bondageprojects.elementfx.com/R*
// @match https://bondage-europe.com/R*
// @match https://www.bondage-europe.com/R*
// @match http://localhost:*/*
// @match http://127.0.0.1:*/*
// @grant none
// ==/UserScript==
(function() {
"use strict";
if (!window.AsylumGGTSSAddItems) throw "AsylumGGTSSAddItems() not found, aborting MBCHC loading"
if (window.MBCHC) throw "MBCHC found, aborting loading"
window.MBCHC = {
VERSION: "trunk",
TARGET_VERSION: "R86",
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
RETHROW: false,
LOADED: false,
AUTOHACK_ENABLED: false,
LAST_HACKED: null,
HISTORY_MODE: false,
RE_TITLE: /^[a-zA-Z]+$/,
RE_PREF_ACTIVITY_ME: /^@/,
RE_PREF_ACTIVITY: /^@@/,
RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/,
RE_TZ: /(?:GMT|UTC)\s*([+-])\s*(\d\d?)/i,
RE_ALL_LEFT: /^<+$/,
RE_ALL_RIGHT: /^>+$/,
RE_SPACES: /\s{2,}/g,
RE_LAST_WORD: /(^|\s)([^\s]*)$/,
RE_LAST_LETTER: /[\w]$/,
RGB_MUTE: "#6c2132",
RGB_POLLY: "#81b1e7",
UTC_OFFSET: new Date().getTimezoneOffset() * 60 * 1000,
HIDE_SPECIAL: ["Activity","Emoticon"],
HIDE_BODY: ["Blush","BodyLower","BodyUpper","Eyebrows","Eyes","Eyes2","Face","Fluids","HairBack","HairFront","Hands","Head","LeftHand","Mouth","Nipples","Pussy","RightHand"],
HIDE_CLOTHES: [
"Cloth","ClothAccessory","Necklace","Suit","ClothLower","SuitLower","Bra","Corset","Panties",
"Socks","RightAnklet","LeftAnklet","Garters","Shoes","Hat","HairAccessory3","HairAccessory1","HairAccessory2",
"Gloves","Bracelet","Glasses","Mask","TailStraps","Wings"
],
HIDE_ITEMS: [
"ItemMisc","ItemEars","ItemHead","ItemNose","ItemHood","ItemAddon","ItemMouth","ItemMouth2","ItemMouth3",
"ItemArms","ItemNeckAccessories","ItemNeck","ItemNeckRestraints","ItemNipples","ItemNipplesPiercings","ItemBreast","ItemTorso","ItemTorso2",
"ItemHands","ItemPelvis","ItemVulva","ItemVulvaPiercings",
"ItemDevices","ItemLegs","ItemFeet","ItemBoots"
],
MAP_ACTIONS: { //ActivityFemale3DCG
// action
"nod|yes": {Head: {self: "Nod"}},
"no": {Head: {self: "Wiggle"}},
"moan": {Mouth: {self: "MoanGag"}},
"mumble": {Mouth: {self: "MoanGagTalk"}},
"whimper": {Mouth: {self: "MoanGagWhimper"}},
"groan": {Mouth: {self: "MoanGagGroan"}},
"scream": {Mouth: {self: "MoanGagAngry"}},
"giggle": {Mouth: {self: "MoanGagGiggle"}},
"struggle": {Arms: {self: "StruggleArms"}},
"thrash": {Legs: {self: "StruggleLegs"}},
// action zone
"wiggle|shake": {"Arms,Breast,Boots,Butt,Ears,Feet,Hands,Nose,Pelvis,Torso": {self: "Wiggle"}},
// action target
"whisper": {Ears: {others: "Whisper"}},
"choke": {Neck: {all: "Choke"}},
"brush": {Head: {all: "TakeCare"}},
"french": {Mouth: {others: "FrenchKiss"}},
"sit": {Legs: {others: "Sit"}},
"rim": {Butt: {others: "MasturbateTongue"}},
"press": {Butt: {others: "Step"}},
"rest": {Torso: {others: "Step"}},
"pet": {Head: {all: "Pet"}},
"boop": {Nose: {all: "Pet"}},
"cuddle": {Arms: {others: "Cuddle"}},
"nuzzle": {Nose: {others: "Cuddle"}},
"grab": {Arms: {others: "Grope"}},
"clean": {Mouth: {all: "Caress"}},
"lap": {Legs: {others: "RestHead"}},
"lean": {Breast: {others: "RestHead"}},
"peck": {Mouth: {others: "PoliteKiss"}},
// action zone target
"item": {
"Breast,Butt,Feet,Legs": {all: "SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem|Inject"},
"Nipples,Pelvis": {all: "SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem"},
Arms: {all: "SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem|Inject"},
Boots: {all: "SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem"},
"Ears,Mouth": {all: "TickleItem|RubItem|RollItem"},
"Hood,Nose": {all: "TickleItem|RubItem"},
Neck: {all: "TickleItem|RubItem|RollItem|Inject"},
Torso: {all: "SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem"},
Vulva: {all: "SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem"},
VulvaPiercings: {all: "SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem|Inject"},
},
"kiss": {
Mouth: {others: "GagKiss|Kiss|GaggedKiss"},
"Boots,Hands": {self: "PoliteKiss", others: "PoliteKiss|GaggedKiss"},
"Arms,Breast,Nipples": {self: "Kiss", others: "Kiss|GaggedKiss"},
"Butt,Ears,Feet,Head,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings": {others: "Kiss|GaggedKiss"}
},
"smooch": {"Hands,Boots": {all: "Kiss"}},
"nibble|chew": {"Arms,Hands,Boots,Mouth,Nipples": {all: "Nibble"}, "Ears,Feet,Legs,Neck,Nose,Pelvis,Torse,Vulva,VulvaPiercings": {others: "Nibble"}},
"slap|spank": {"Head,Breast,Vulva,VulvaPiercings": {all: "Slap"}, "Arms,Boots,Butt,Feet,Hands,Legs,Pelvis,Torso": {all: "Spank"}},
"tickle": {"Arms,Boots,Breast,Feet,Legs,Neck,Pelvis,Torso": {all: "Tickle"}},
"massage": {"Arms,Boots,Feet,Legs,Neck,Pelvis,Torso": {all: "MassageHands"}},
"lick": {"Arms,Boots,Breast,Hands,Mouth,Nipples": {all: "Lick"}, "Ears,Feet,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings": {others: "Lick"}},
"suck": {"Nipples,Hands,Boots": {all: "Suck"}},
"bite": {"Arms,Boots,Feet,Hands,Legs,Mouth": {all: "Bite"}, "Breast,Butt,Ears,Head,Neck,Nipples,Nose,Torso": {others: "Bite"}},
"pinch": {"Arms,Ears,Nipples,Nose,Pelvis": {all: "Pinch"}},
"clamp": {Mouth: {all: "HandGag"}, Nose: {all: "Choke"}},
"step": {"Breast,Neck,Pelvis": {others: "Step"}},
"pull": {"Head,Nose,Nipples": {all: "Pull"}},
"grope": {"Butt,Breast": {all: "Grope"}, "Feet,Legs,Pelvis": {others: "Grope"}},
"rub": {"Head,Torso": {others: "Rub"}, Nose: {all: "Rub"}, Legs: {self: "Wiggle"}, Hands: {self: "Caress"}},
"caress": {Hands: {others: "Caress"}, "Arms,Breast,Butt,Ears,Feet,Head,Legs,Neck,Nipples,Nose,Pelvis,Torso,Vulva,VulvaPiercings": {all: "Caress"}},
"polish": {"Hands,Boots": {all: "TakeCare"}},
"foot": {"Head,Nose": {others: "Step"}, "Torso,Boots": {others: "MassageFeet"}, "Vulva,VulvaPiercings": {others: "MasturbateFoot"}},
"fist": {"Vulva,Butt": {all: "MasturbateFist"}},
"fuck": {"Mouth,Vulva,Butt": {others: "PenetrateSlow"}}, //peg?
"pound": {"Mouth,Vulva,Butt": {others: "PenetrateFast"}},
"tongue": {"Vulva,VulvaPiercings": {others: "MasturbateTongue"}},
"finger": {"Breast,Butt,Vulva,VulvaPiercings": {all: "MasturbateHand"}},
},
MAP_ZONES: {
"ItemBoots": ["foot", "feet", "boot", "boots", "shoe", "shoes", "toes", "toenails", "sole", "soles", "heel", "heels"],
"ItemFeet": ["leg", "legs", "ankle", "ankles"],
"ItemLegs": ["hips", "hip", "thighs", "thigh"],
"ItemVulva": ["vulva", "pussy"],
"ItemVulvaPiercings": ["clit", "clitoris"],
"ItemButt": ["butt", "ass"],
"ItemPelvis": ["tummy", "pelvis"],
"ItemTorso": ["body", "torso", "back", "ribs"],
"ItemBreast": ["breast", "breasts", "boob", "boobs", "booby", "boobie", "boobies", "tit", "tits", "titty", "tittie", "titties"],
"ItemNipples": ["nip", "nips", "nipple", "nipples"],
"ItemHands": ["hand", "hands", "fingers", "fingernails", "nails"],
"ItemArms": ["arm", "arms", "elbow", "elbows"],
"ItemNeck": ["neck"],
"ItemMouth": ["mouth", "lip", "lips", "teeth", "tongue", "gag", "cheek", "cheeks"],
"ItemNose": ["nose", "nostrils"],
"ItemEars": ["ear", "ears", "earlobe", "earlobes"],
"ItemHead": ["head", "face", "hair", "eyes", "forehead"],
},
FBC_TESTER_PATCHES: [
[/^\^('s)?( )?/g, "^SourceCharacter$1\\s+"],
[/([^\\])\$/g, "$1\\.?$$"],
],
SUBCOMMANDS_MBCHC: {
"versions": {desc: "show the mod versions across the room", cb: mbchc => mbchc.inform(mbchc.gather_versions().map(c => `<div><b>${c.name}</b> (${c.cid}): ${c.version}</div>`).join(""))},
"autohack": {desc: "toggle the autohack feature", cb: mbchc => mbchc.inform(`Autohack is now ${(mbchc.AUTOHACK_ENABLED = !mbchc.AUTOHACK_ENABLED) ? "enabled" : "disabled"}`)},
"disappear": {desc: "become invisible (requires anal hook -> hair)", cb: mbchc => mbchc.disappear()},
"donate": {desc: "Buy data and send it to recipient", args: {TARGET: {}}, cb: (mbchc, args) => mbchc.donate_data(args[0])},
"title": {desc: "set a custom title (<b>WIP</b>)", args: {TITLE: {}}, cb: (mbchc, args) => mbchc.title(args[0])},
"tz": {desc: "set target's UTC offset", args: {OFFSET: {}, "[TARGET]": {}}, cb: (mbchc, args) => mbchc.set_timezone(args)},
"purge!": {desc: "delete MBCHC online saved data", cb: mbchc => {if (window.Player.OnlineSettings.MBCHC) {delete window.Player.OnlineSettings.MBCHC; mbchc.save_settings()}}},
},
ensure: function(error, callback) {
let result = callback.call(this)
if (!result) throw error
return(result)
},
calculate_maps: function() {
this.DO_DATA = {verbs: {}, zones: {}}
for (let [verbs, data] of Object.entries(this.MAP_ACTIONS)) {
let unwound = {}
for (let [zones, actions] of Object.entries(data)) {
let all = (actions.all) ? actions.all.split("|") : []
let processed = {self: (actions.self) ? actions.self.split("|").concat(all) : all, others: (actions.others) ? actions.others.split("|").concat(all) : all}
for (let zone of zones.split(",")) unwound[`Item${zone}`] = processed
}
for (let verb of verbs.split("|")) this.DO_DATA.verbs[verb] = unwound
}
for (let [ag, zones] of Object.entries(this.MAP_ZONES)) for (let zone of zones) this.DO_DATA.zones[zone] = ag
},
settings: function(setting = null) {
let settings = window.Player.OnlineSettings.MBCHC || {}
return(setting ? settings[setting] : settings)
},
save_settings: function(cb = null) {
if (cb) {
if (!window.Player.OnlineSettings.MBCHC) window.Player.OnlineSettings.MBCHC = {}
cb.call(this, window.Player.OnlineSettings.MBCHC)
}
window.ServerAccountUpdate.QueueData({OnlineSettings: window.Player.OnlineSettings})
},
log: function(level, msg) {console[level]("MBCHC: " + String(msg))},
empty: function(text) {
if (!text) return(true)
if (String(text).trim().length < 1) return(true)
return(false)
},
normalise_message: function(text, options = {}) {
let result = text
if (options.trim) result = result.trim()
if (options.low) result = result.toLocaleLowerCase()
if (options.up) {
let first = result.at(0).toLocaleUpperCase()
let rest = result.slice(1)
result = first + rest
}
if (options.dot && result.match(this.RE_LAST_LETTER)) result = `${result}.`
return(result)
},
tokenise: function(text) { return text.replace(this.RE_SPACES, " ").split(" ") },
inform: function(html, timeout = 60000) { window.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`, timeout) },
report: function(x) {
this.inform(`Error: ${x.toString()}`)
if (this.RETHROW) throw x
},
in: function(x, floor, ceiling) { return((x >= floor) && (x <= ceiling)) },
cid2char: function(cid) {
cid = Number.parseInt(cid)
if (cid === window.Player.cid) return(window.Player)
return(this.ensure(`character ${cid} not found in the room`, () => window.ChatRoomCharacter.find(c => c.cid === cid)))
},
pos2char: function(pos) {
if (!this.in(pos, 0, window.ChatRoomCharacter.length - 1)) throw `invalid position ${pos}`
return(window.ChatRoomCharacter[pos])
},
rel2char: function(target) {
let me = this.ensure("can't find my position", () => window.ChatRoomCharacter.findIndex(char => char.IsPlayer()) + 1) - 1 // 0 is falsy, but is valid index
let pos = null
if (target.match(this.RE_ALL_LEFT)) pos = me - target.length
if (target.match(this.RE_ALL_RIGHT)) pos = me + target.length
if (null === pos) throw `failed to parse target "${target}"`
pos = pos % window.ChatRoomCharacter.length
if (pos < 0) pos = pos + window.ChatRoomCharacter.length
return(this.pos2char(pos))
},
target2char: function(target) { // target should be lowcase
let input = target
if (this.empty(target)) return(window.Player)
let int = Number.parseInt(target)
target = String(target)
let found = []
if (target.startsWith("=")) return(this.cid2char(target.slice(1)))
if (target.startsWith("<") || target.startsWith(">")) return(this.rel2char(target))
if (!isNaN(int) && int.toString() === target) { // we got a number
if (this.in(int, 0, 9)) return(this.pos2char(int))
if (this.in(int, 11, 15)) return(this.pos2char(int - 11))
if (this.in(int, 21, 25)) return(this.pos2char(int - 16))
found.push(...window.ChatRoomCharacter.filter(c => c.cid.toString().indexOf(target) > -1))
}
if (target.startsWith("@")) target = target.slice(1)
found.push(...window.ChatRoomCharacter.filter(c => c.Name.toLocaleLowerCase().indexOf(target) > -1))
found.push(...window.ChatRoomCharacter.filter(c => c.Nickname && (c.Nickname.toLocaleLowerCase().indexOf(target) > -1)))
let map = {}
found.forEach(c => {if (!map[c.cid]) map[c.cid] = c} )
found = Object.values(map)
if (found.length < 1) throw `target "${input}": no match`
if (found.length > 1) throw `target "${input}": multiple matches (${found.map(c => `${c.cid}|${c.Name}|${c.Nickname || c.Name}`).join(",")})`
return(found[0])
},
char2targets: function(char) {
let [result, cid] = [new Set(), char.cid.toString()]
result.add(cid).add(`=${cid}`)
this.tokenise(char.Name).forEach(t => {result.add(t); result.add(`@${t}`)})
if (char.Nickname) this.tokenise(char.Nickname).forEach(t => {result.add(t); result.add(`@${t}`)})
return result
},
donate_data: function(target) {
let char = this.target2char(target)
if (char.IsPlayer()) throw "target must not be you"
if (!char.IsRestrained()) throw "target must be bound"
const cost = Math.round((Math.random() * 10 + 15))
if (window.Player.Money < cost) throw "not enough money"
window.CharacterChangeMoney(window.Player, -cost)
window.ServerSend("ChatRoomChat", {Content: "ReceiveSuitcaseMoney", Type: "Hidden", Target: char.cid})
window.ChatRoomMessage({Sender: window.Player.cid, Type: "Action", Content: `You've bought data for $${cost} and sent it to ${char.dn}.`, Dictionary: [{Tag: "MISSING PLAYER DIALOG: ", Text: ""}]})
},
run_activity: function(char, ag, action) { try {
if (!window.ActivityAllowed()) throw "activities disabled in this room"
if (!window.ServerChatRoomGetAllowItem(window.Player, char)) throw "no permissions"
char.FocusGroup = this.ensure("invalid AssetGroup", () => window.AssetGroupGet(char.AssetFamily, ag))
let activity = this.ensure("invalid activity", () => window.ActivityAllowedForGroup(char, char.FocusGroup.Name, true).find(a => a.Name === action || a.Activity?.Name === action))
if ((activity.Name || activity.Activity.Name).endsWith("Item")) {
const item = this.ensure("no toy found", () => window.Player.Inventory.find(i => i.Asset?.Name === "SpankingToys" && i.Asset.Group?.Name === char.FocusGroup.Name && window.AssetSpankingToys.DynamicActivity(char) === (activity.Name || activity.Activity.Name)))
window.DialogPublishAction(char, item)
} else window.ActivityRun(char, activity)
} finally {char.FocusGroup = null} },
replace_me: function(match, offset, string) {
let text = string.slice(1)
let suffix = " "
if (text.startsWith("'") || text.startsWith(" ")) suffix = ""
return `${window.MBCHC.PREF_ACTIVITY}<${window.Player.cid}:>SourceCharacter${suffix}`
},
cid2dict: function(type, cid) { return({Tag: `${type}Character`, MemberNumber: cid, Text: this.cid2char(cid).dn}) },
send_activity: function(msg) {
let dict = [{Tag: "MISSING PLAYER DIALOG: ", Text: "\u200C"}] // zero-width non-joiner, used to break up ligatures, does nothing here, but an empty string is a falsy value
let cids = msg.match(this.RE_ACT_CIDS)
if (cids) {
msg = msg.replace(this.RE_ACT_CIDS, "")
if (cids[1]) dict.push(this.cid2dict("Source", cids[1]))
if (cids[2]) dict.push(this.cid2dict("Target", cids[2]), this.cid2dict("Destination", cids[2]))
}
window.ServerSend("ChatRoomChat", {Type: "Action", Content: msg, Dictionary: dict})
},
receive: function(data) {
let char = this.cid2char(data.Sender)
if (char.IsPlayer()) return true // this is our own message, sent back to us
let payload = this.ensure("Empty message", () => data.Dictionary[0])
switch (payload.type) {
case "greetings": case "hello":
char.MBCHC = payload.value
if ("greetings" === payload.type) this.hello(char)
break
default: // if we don't know the type it may be from a newer version
}
return true
},
hello: function(char = null) {
let payload = {type: "greetings", value: window.Player.MBCHC}
if (char) payload.type = "hello"
let message = {Content: "MBCHC", Type: "Hidden", Dictionary: [payload]}
if (char) message.Target = char.cid
window.ServerSend("ChatRoomChat", message)
},
disappear: function() {
let item = window.InventoryGet(window.Player, "ItemButt")
if (!item || !item.Asset || !item.Asset.Name) throw "butt seems empty"
if (item.Asset.Name !== "AnalHook") throw "butt seems occupied by something other than the anal hook"
if (!item.Property.Type || item.Property.Type !== "Hair") throw "anal hook seems not tied to hair"
item.Property = {Type: "Hair", Hide: this.HIDE_ALL}
window.CharacterRefresh(window.Player, true, true)
},
title: function(title) { // WIP
if (this.empty(title)) throw "empty title"
title = this.normalise_message(title, {trim: true, up: true, low: true})
if (title.length > 16) throw "title too long"
if (!title.match(this.RE_TITLE)) throw "invalid title"
window.TitleSet(title)
//window.TitleList.push({Name: title, Requirement: () => true}) // check for existing first
},
copy_fbc_trigger: function(trigger) {
let result = {
Type: "Action",
Event: trigger.Event,
Matchers: trigger.Matchers.map(m => ({Tester: new RegExp(this.FBC_TESTER_PATCHES.reduce((ax,[f,r]) => ax.replaceAll(f,r), m.Tester.source), "u")}))
}
return(result)
},
patch_fbc: function() {
this.remove_fbc_hook()
delete this.remove_fbc_hook
window.bce_ActivityTriggers.push(...window.bce_ActivityTriggers.filter(t => "Emote" === t.Type).map(t => this.copy_fbc_trigger(t)))
/* (["anim", "pose"]).forEach(tag => {let cmd = window.Commands.find(c => tag === c.Tag); if (cmd) cmd.AutoComplete = this[`complete_fbc_${tag}`]}) */ // this line explodes, don't ask me why
let cmd = window.Commands.find(c => "anim" === c.Tag)
if (cmd) cmd.AutoComplete = this.complete_fbc_anim
cmd = window.Commands.find(c => "pose" === c.Tag)
if (cmd) cmd.AutoComplete = this.complete_fbc_pose
},
gather_versions: function() { return(window.ChatRoomCharacter.filter(c => c.MBCHC).map(c => ({name: c.dn, cid: c.cid, version: c.MBCHC.VERSION}))) },
find_timezone: function(char) {
const timezones = this.settings("timezones")
if (timezones && "number" === typeof timezones[char.cid]) return(timezones[char.cid])
const match = (char.Description) ? char.Description.match(this.RE_TZ) : null
const int = match ? Number.parseInt(match[1] + match[2]) : 42
if (this.in(int, -12, 12)) return(int)
return(null)
},
player_enters_room: function() { // or if the mod is loaded while player is in the room
this.hello()
},
set_timezone: function(args) {
let tz = Number.parseInt(args[0])
if (isNaN(tz)) throw `invalid offset "${args[0]}"`
if (!this.in(tz, -12, 12)) throw "offset should be [-12,12]"
let char = this.target2char(args[1])
char.MBCHC_LOCAL.TZ = tz
this.save_settings(s => {if (!s.timezones) s.timezones = {}; s.timezones[char.cid] = tz})
},
update_char: function(char) {
char.cid = char.MemberNumber // Club ID (shorter)
char.dn = window.CharacterNickname(char) // DisplayName (shortcut)
if (!char.MBCHC_LOCAL) char.MBCHC_LOCAL = {}
if (!char.MBCHC_LOCAL.TZ) char.MBCHC_LOCAL.TZ = this.find_timezone(char)
},
command_mbchc: function(argline, cmdline, args) { const mbchc = window.MBCHC; try { // `this` is command object
if (args.length < 1) return(mbchc.inform(Object.entries(mbchc.SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `<div>/mbchc ${cmd} ${sub.args ? Object.keys(sub.args).join(" ") : ""}: ${sub.desc}</div>`).join("")))
let cmd = String(args.shift())
let sub = mbchc.ensure(`unknown subcommand "${cmd}"`, () => mbchc.SUBCOMMANDS_MBCHC[cmd])
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
} catch (x) { mbchc.report(x) } },
command_activity: function(argline, cmdline, args) { const mbchc = window.MBCHC; if (!mbchc.empty(argline)) { try { // `this` is command object
let message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, ''), {trim: true, dot: true, up: true})
mbchc.send_activity(message)
} catch (x) { mbchc.report(x) } } },
command_do: function(argline, cmdline, args) { const mbchc = window.MBCHC; try { // `this` is command object
if (args.length < 1) return(mbchc.inform("<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>" + Object.keys(mbchc.MAP_ACTIONS).join(", ") + "<div>Available zones:</div>" + Object.keys(mbchc.DO_DATA.zones).join(", ")))
let [verb, zone, target] = args
let zones = mbchc.ensure(`unknown verb "${verb}"`, () => mbchc.DO_DATA.verbs[verb])
if (1 === Object.keys(zones).length) {
if (!target) target = zone
zone = mbchc.MAP_ZONES[Object.keys(zones)[0]][0]
}
if (!zone) throw "zone missing"
let ag = mbchc.ensure(`unknown zone "${zone}"`, () => mbchc.DO_DATA.zones[zone])
let types = mbchc.ensure(`zone "${zone}" invalid for "${verb}"`, () => zones[ag])
let char = window.Player
if (target && ((types.self.length < 1) || (types.others.length > 0))) char = mbchc.target2char(target)
let type = char.IsPlayer() ? "self" : "others"
let available = window.ActivityAllowedForGroup(char, ag)
let toy = window.InventoryGet(window.Player, "ItemHands")
if (toy && toy.Asset.Name === "SpankingToys") available.push(window.AssetAllActivities(char.AssetFamily).find(a => a.Name === window.InventorySpankingToysGetActivity?.(window.Player)))
let actions = mbchc.ensure(`zone "${zone}" invalid for ("${verb}" "${type}")`, () => types[type])
let action = mbchc.ensure(`invalid action (${verb} ${zone} ${target})`, () => actions.find(name => available.find(a => a.Name === name || a.Activity?.Name === name)))
mbchc.run_activity(char, ag, action)
} catch (x) { mbchc.report(x) } },
bell: function() {
setTimeout(() => {document.getElementById("InputChat").style.outline = ""}, 100)
document.getElementById("InputChat").style.outline = "solid red"
},
complete: function(options, space = true) {
if (options.length < 1) return(this.bell())
if (options.length > 1) {
let width = Math.max(...options.map(o => o.length))
let pref = null
for (let i = width; i > 0; i -= 1) { let test = options[0].slice(0, i); if (options.every(o => o.startsWith(test))) {pref = test; break} }
if (pref) this.complete([pref], false)
this.complete_hint(options)
} else window.ElementValue("InputChat", document.getElementById("InputChat").value.replace(this.RE_LAST_WORD, `$1${options[0]}${space ? " " : ""}`))
},
complete_hint: function(options) {
this.COMP_HINT.innerHTML = options.sort().map(s => `<div>${s}</div>`).join("")
this.COMP_HINT.style.display = "flex"
window.ElementSetDataAttribute(this.COMP_HINT.id, "colortheme", (window.Player.ChatSettings.ColorTheme || "Light"))
let rescroll = window.ElementIsScrolledToEnd("TextAreaChatLog")
window.ChatRoomResize(false)
if (rescroll) window.ElementScrollToEnd("TextAreaChatLog")
},
comp_hint_visible: function() {return(this.COMP_HINT.parentElement && "flex" === this.COMP_HINT.style.display)},
complete_hint_hide: function(options) {if (!this.comp_hint_visible()) return; this.COMP_HINT.style.display = "none"; window.ChatRoomResize(false)},
complete_target: function(token, me2 = true, check_perms = false) {
let [locase, found] = [token.toLocaleLowerCase(), new Set()]
for (let c of window.ChatRoomCharacter) {
if ((c.IsPlayer() && !me2) || (check_perms && !window.ServerChatRoomGetAllowItem(window.Player, c))) continue
this.char2targets(c).forEach(s => {if (s.toLocaleLowerCase().startsWith(locase)) found.add(s)})
}
this.complete(Array.from(found))
},
complete_common: function() {
let input = document.getElementById("InputChat").value
return([this, input, this.tokenise(input)])
},
complete_mbchc: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
let subname = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return(mbchc.complete(Object.keys(mbchc.SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname)))) // complete subcommand name
let sub = mbchc.SUBCOMMANDS_MBCHC[subname]
if (sub && sub.args) {
let argname = Object.keys(sub.args)[tokens.length - 3]
if ("TARGET" === argname) return(mbchc.complete_target(tokens[tokens.length - 1], false))
if ("[TARGET]" === argname) return(mbchc.complete_target(tokens[tokens.length - 1]), true)
}
},
complete_do_target: function(actions, token) {
if (!actions) return
let me2 = (actions.self.length > 0)
if (me2 && actions.others.length < 1) return(this.complete([window.Player.cid.toString()])) // target is always the player
this.complete_target(token, me2, true)
},
complete_do: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
// now, we *could* run a filter to exclude impossible activities, but it isn't very useful, and also seems like a lot of CPU to iterate over every action on every zone of every char in the room
let low = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return(mbchc.complete(Object.keys(mbchc.DO_DATA.verbs).filter(c => c.startsWith(low)))) // complete verb
let ags = mbchc.DO_DATA.verbs[low]
if (!ags) return(mbchc.bell())
low = tokens[2].toLocaleLowerCase()
if (tokens.length < 4) { // complete zone or target
if (Object.keys(ags).length < 2) return(mbchc.complete_do_target(ags[Object.keys(ags)[0]], tokens[2])) // zone implied, complete target
let zones = Object.entries(mbchc.DO_DATA.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone,ag]) => zone)
return(mbchc.complete(zones))
}
if (tokens.length < 5) { // complete target where it belongs
if (Object.keys(ags).length < 2) return // zone implied, target already given
return(mbchc.complete_do_target(ags[mbchc.DO_DATA.zones[low]], tokens[3]))
}
mbchc.bell()
},
complete_fbc_anim: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
if (tokens.length > 2) return(mbchc.bell())
let anim = tokens[1].toLocaleLowerCase()
return(mbchc.complete(Object.keys(window.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim))))
},
complete_fbc_pose: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
let pose = tokens[tokens.length - 1].toLocaleLowerCase()
return(mbchc.complete(window.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose))))
},
history: function(down) {
let [text, history] = [window.ElementValue("InputChat"), window.ChatRoomLastMessage]
if (!this.HISTORY_MODE) {history.push(text); this.HISTORY_MODE = true}
let ids = history.map((t,i) => [t,i]).filter(([t,i]) => t.startsWith(history[history.length - 1])).map(([t,i]) => i)
if (!down) ids.reverse()
let found = ids.find(id => (down) ? id > window.ChatRoomLastMessageIndex : id < window.ChatRoomLastMessageIndex)
if (!found) return(this.bell())
window.ElementValue("InputChat", history[found])
window.ChatRoomLastMessageIndex = found
},
focus_chat_whitelist(event) {
if (event.ctrlKey && "v" === event.key) return true // ctrl+V should paste
return false
},
focus_chat(event) { // TODO: this is not ideal, but it will have to do for now
if (event.repeat) return // only unique presses please
if ("inline" !== document.getElementById("InputChat")?.style.display) return // input chat missing
if (["InputChat","bce-message-input"].includes(document.activeElement.id)) return // focus already set
if ([event.altKey, event.ctrlKey, event.metaKey].some(i => i) && !this.focus_chat_whitelist(event)) return // alt, ctrl and meta should all be false
window.ElementFocus("InputChat")
},
loader() {
if (this.remove_load_hook) {
this.remove_load_hook()
delete this.remove_load_hook
}
if (this.LOADED) return
// Calculated values
const COMMANDS = [
{ Tag: "mbchc", Description: ": Utility functions (\"/mbchc\" for help)", Action: this.command_mbchc, AutoComplete: this.complete_mbchc },
{ Tag: "activity", Description: "[Message]: Send a custom activity (or \"@@Message\", or \"@Message\" as yourself)", Action: this.command_activity },
{ Tag: "do", Description: ": Do an activity, as if clicked on its button (\"/do\" for help)", Action: this.command_do, AutoComplete: this.complete_do },
]
this.HIDE_ALL = this.HIDE_SPECIAL.concat(this.HIDE_BODY).concat(this.HIDE_CLOTHES).concat(this.HIDE_ITEMS)
this.CommandsKey = CommandsKey /* eslint-disable-line no-undef */ // window.CommandsKey is undefined
this.RE_ACTIVITY = RegExp(`^${this.CommandsKey}activity `)
this.PREF_ACTIVITY = `${this.CommandsKey}activity `
this.COMP_HINT = document.createElement("div")
this.COMP_HINT.id = "mbchcCompHint"
let css = document.createElement("style")
css.type = "text/css"
css.textContent = `
#TextAreaChatLog .mbchc, #TextAreaChatLog .mbchc {
background-color: ${this.RGB_POLLY};
}
#TextAreaChatLog[data-colortheme="dark"] .mbchc, #TextAreaChatLog[data-colortheme="dark2"] .mbchc {
background-color: ${this.RGB_MUTE};
}
#${this.COMP_HINT.id} {
flex-flow: column wrap;
overflow: auto;
display: none;
background-color: ${this.RGB_POLLY};
color: black;
}
#${this.COMP_HINT.id}[data-colortheme="dark"], #${this.COMP_HINT.id}[data-colortheme="dark2"] {
background-color: ${this.RGB_MUTE};
color: white;
}
#${this.COMP_HINT.id} div {
margin: 0 0.5ex;
}
`
document.head.appendChild(css)
// Actions
this.calculate_maps()
window.Player.MBCHC = {VERSION: this.VERSION}
window.CommandCombine(COMMANDS)
// Hooks
this.remove_fbc_hook = this.before("MainRun", () => window.bce_ActivityTriggers && this.patch_fbc())
this.after("CharacterOnlineRefresh", char => this.update_char(char))
this.after("ChatRoomReceiveSuitcaseMoney", () => {
if (this.AUTOHACK_ENABLED && this.LAST_HACKED) {
window.CurrentCharacter = this.cid2char(this.LAST_HACKED)
this.LAST_HACKED = null
window.ChatRoomTryToTakeSuitcase()
}
})
this.before("ChatRoomSendChat", () => {
let input = window.ElementValue("InputChat")
if (!input.startsWith("@@@") && input.startsWith("@")) {
input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY)
input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me)
window.ElementValue("InputChat", input)
}
})
this.after("ChatRoomSendChat", () => {
const history = window.ChatRoomLastMessage
if ((history.length > 1) && (history[history.length - 1] === history[history.length - 2])) {history.pop(); window.ChatRoomLastMessageIndex -= 1}
})
this.before("ChatRoomDrawCharacterOverlay", (C, CharX, CharY, Zoom, Pos) => {
window.ChatRoomHideIconState < 1 && C.MBCHC && window.DrawRect(CharX + 175 * Zoom, CharY, 50 * Zoom, 50 * Zoom, C.MBCHC.VERSION === window.Player.MBCHC.VERSION ? this.RGB_POLLY : this.RGB_MUTE)
if (window.ChatRoomHideIconState < 1 && C.MBCHC_LOCAL && "number" === typeof C.MBCHC_LOCAL.TZ) {
const hours = new Date(window.CommonTime() + this.UTC_OFFSET + C.MBCHC_LOCAL.TZ * 60 * 60 * 1000).getHours()
window.DrawTextFit(hours < 10 ? "0" + hours.toString() : hours.toString(), CharX + 200 * Zoom, CharY + 25 * Zoom, 46 * Zoom, "white", "black")
}
})
this.after("ElementValue", (ID, Value, cc = window.CurrentCharacter) => "bce_LayerPriority" === ID && cc?.FocusGroup && window.InventoryGet(cc, cc.FocusGroup.Name)?.Difficulty.toString() === Value && window.InventoryLocked(cc, cc.FocusGroup.Name, true) && window.ElementSetAttribute(ID, "disabled", true))
this.after("ChatRoomCreateElement", () => this.COMP_HINT.parentElement || document.body.appendChild(this.COMP_HINT))
this.before("ChatRoomClearAllElements", () => !void this.complete_hint_hide() && this.COMP_HINT.remove())
this.before("ChatRoomClick", () => this.complete_hint_hide())
this.after("ChatRoomResize", () => {
if (window.CharacterGetCurrent() == null && window.CurrentScreen == "ChatRoom" && document.getElementById("InputChat") && document.getElementById("TextAreaChatLog") && this.comp_hint_visible()) { // upstream
const fontsize = ChatRoomFontSize /* eslint-disable-line no-undef */ // window.ChatRoomFontSize is undefined
window.ElementPositionFix("TextAreaChatLog", fontsize, 1005, 66, 988, 630)
window.ElementPositionFix(this.COMP_HINT.id, fontsize, 1005, 701, 988, 200)
this.COMP_HINT.style.display = "flex"
}
})
document.addEventListener("keydown", event => this.focus_chat(event))
this.SDK.hookFunction("ChatRoomKeyDown", 0, (nextargs, next) => { // this fires on chat input events
let [event] = nextargs
window.MBCHC.complete_hint_hide()
if ((window.KeyPress == 33) || (window.KeyPress == 34)) { // better history
event.preventDefault()
return(window.MBCHC.history(window.KeyPress - 33))
}
if (window.MBCHC.HISTORY_MODE) {
window.ChatRoomLastMessage.pop()
window.MBCHC.HISTORY_MODE = false
}
return(next(nextargs))
})
// Chat room handlers
window.ChatRoomRegisterMessageHandler({ Priority: -220, Description: "MBCHC preprocessor", Callback: (data, sender, msg, metadata) => {
data.MBCHC_ID = this.NEXT_MESSAGE
this.NEXT_MESSAGE += 1
if (this.LOG_MESSAGES) console.debug({data, sender, msg, metadata})
}})
window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC room enter hook",
Callback: (data, sender, msg, metadata) => { if (("Action" === data.Type) && ("ServerEnter" === data.Content) && (data.Sender === window.Player.cid)) this.player_enters_room() }
})
window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC specific consumer",
Callback: (data, sender, msg, metadata) => { if (("Hidden" === data.Type) && ("MBCHC" === data.Content)) return this.receive(data) }
})
window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC autohack lookup",
Callback: (data, sender, msg, metadata) => { if (("Hidden" === data.Type) && ("ReceiveSuitcaseMoney" === data.Content)) this.LAST_HACKED = data.Sender }
})
// footer
this.LOADED = true
this.log("info", `loaded version ${this.VERSION}`)
if (window.GameVersion !== this.TARGET_VERSION) this.log("warn", `Game version doesn't match the target ("${this.TARGET_VERSION}"), beware of incompatibilities`) // TODO: betas are like R86Beta1; cheat?
if (("Online" === window.CurrentModule) && ("ChatRoom" === window.CurrentScreen)) {
window.ChatRoomCharacter.forEach(c => this.update_char(c))
this.player_enters_room()
}
},
preloader() {
this.SDK = window.bcModSdk.registerMod({name:"MBCHC",fullName:"Mute's Bondage Club Hacks Collection",version:this.VERSION,repository:"https://code.fleshless.org/mute/MBCHC/"})
this.before = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs,next) => {try {cb?.(...nextargs)} catch (x) {console.error(x)} finally {return next(nextargs)}})
this.after = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs,next) => {const result = next(nextargs); try {cb?.(...nextargs)} catch (x) {console.error(x)} finally {return result}})
if (window.CurrentModule && window.CurrentScreen && !("Character" === window.CurrentModule && "Login" === window.CurrentScreen)) return this.loader()
this.remove_load_hook = this.before("AsylumGGTSSAddItems", () => this.loader())
}
} // MBCHC
fetch("https://code.fleshless.org/mute/MBCHC/raw/branch/master/bondage-club-mod-sdk-1.1.0.js").then(r=>r.text()).then(r=>eval(r)).then(_=>window.MBCHC.preloader()).catch(x=>console.error(x)) /* eslint-disable-line no-eval */
})()

800
mbchc.mjs Normal file
View File

@@ -0,0 +1,800 @@
export {}
/** @typedef {import('bondage-club-mod-sdk').ModSDKGlobalAPI} ModSDKGlobalAPI */
/** @type {Window & typeof globalThis & {MBCHC?: any, bcModSdk?: ModSDKGlobalAPI, bce_ActivityTriggers?: any, bce_EventExpressions?: any}} */
const w = window
/**
* A silly helper to memorise values in callbacks
* @template V, R
* @param {V} v Value to memorise
* @param {function(V): R} cb Callback
* @returns {R} Return value of the callback
*/
const take = (v, cb) => cb(v)
/**
* Takes a DOM query and passes the element's style into a callback, returning its result
* @template T
* @param {string} q Query
* @param {function(CSSStyleDeclaration): T} cb Callback
* @returns {T | void} Return value of the callback, if it was called
*/
const style = (q, cb) => take(document.querySelector(q), E => E && E instanceof HTMLElement && E.style ? cb(E.style) : undefined)
/**
* @returns {string} Current view
*/
const current = () => `${w.CurrentModule}/${w.CurrentScreen}`
/**
* @param {Character} char
* @returns {number}
*/
const cid = char => char.MemberNumber
// Zero-width non-joiner, used to break up ligatures, does nothing here, but an empty string is a falsey value
const MISSING_PLAYER_DIALOG = {Tag: 'MISSING TEXT IN "Interface.csv": ', Text: '\u200C'}
if (w.MBCHC) throw new Error('MBCHC found, aborting loading')
w.MBCHC = {
VERSION: 'dev.12',
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
RETHROW: false,
LOADED: false,
AUTOHACK_ENABLED: false,
LAST_HACKED: null,
HISTORY_MODE: false,
RE_PREF_ACTIVITY_ME: /^@/,
RE_PREF_ACTIVITY: /^@@/,
RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/,
RE_TZ: /(?:gmt|utc)\s*([+-])\s*(\d\d?)/i,
RE_ALL_LEFT: /^<+$/,
RE_ALL_RIGHT: /^>+$/,
RE_SPACES: /\s{2,}/g,
RE_LAST_WORD: /(^|\s)(\S*)$/,
RE_LAST_LETTER: /\w$/,
RGB_MUTE: '#6c2132',
RGB_POLLY: '#81b1e7',
UTC_OFFSET: new Date().getTimezoneOffset() * 60 * 1000,
MAP_ACTIONS: { // ActivityFemale3DCG
// action
'nod|yes': {Head: {self: 'Nod'}},
no: {Head: {self: 'Wiggle'}},
moan: {Mouth: {self: 'MoanGag'}},
mumble: {Mouth: {self: 'MoanGagTalk'}},
whimper: {Mouth: {self: 'MoanGagWhimper'}},
groan: {Mouth: {self: 'MoanGagGroan'}},
scream: {Mouth: {self: 'MoanGagAngry'}},
giggle: {Mouth: {self: 'MoanGagGiggle'}},
struggle: {Arms: {self: 'StruggleArms'}},
thrash: {Legs: {self: 'StruggleLegs'}},
// Action zone
'wiggle|shake': {'Arms,Breast,Boots,Butt,Ears,Feet,Hands,Nose,Pelvis,Torso': {self: 'Wiggle'}},
// Action target
whisper: {Ears: {others: 'Whisper'}},
choke: {Neck: {all: 'Choke'}},
brush: {Head: {all: 'TakeCare'}},
french: {Mouth: {others: 'FrenchKiss'}},
sit: {Legs: {others: 'Sit'}},
rim: {Butt: {others: 'MasturbateTongue'}},
press: {Butt: {others: 'Step'}},
rest: {Torso: {others: 'Step'}},
pet: {Head: {all: 'Pet'}},
boop: {Nose: {all: 'Pet'}},
cuddle: {Arms: {others: 'Cuddle'}},
nuzzle: {Nose: {others: 'Cuddle'}},
grab: {Arms: {others: 'Grope'}},
clean: {Mouth: {all: 'Caress'}},
lap: {Legs: {others: 'RestHead'}},
lean: {Breast: {others: 'RestHead'}},
peck: {Mouth: {others: 'PoliteKiss'}},
// Action zone target
item: {
'Breast,Butt,Feet,Legs': {all: 'SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem|Inject'},
'Nipples,Pelvis': {all: 'SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem'},
Arms: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem|Inject'},
Boots: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem'},
'Ears,Mouth': {all: 'TickleItem|RubItem|RollItem'},
'Hood,Nose': {all: 'TickleItem|RubItem'},
Neck: {all: 'TickleItem|RubItem|RollItem|Inject'},
Torso: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem'},
Vulva: {all: 'SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem'},
VulvaPiercings: {all: 'SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem|Inject'},
},
kiss: {
Mouth: {others: 'GagKiss|Kiss|GaggedKiss'},
'Boots,Hands': {self: 'PoliteKiss', others: 'PoliteKiss|GaggedKiss'},
'Arms,Breast,Nipples': {self: 'Kiss', others: 'Kiss|GaggedKiss'},
'Butt,Ears,Feet,Head,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {others: 'Kiss|GaggedKiss'},
},
smooch: {'Hands,Boots': {all: 'Kiss'}},
'nibble|chew': {'Arms,Hands,Boots,Mouth,Nipples': {all: 'Nibble'}, 'Ears,Feet,Legs,Neck,Nose,Pelvis,Torse,Vulva,VulvaPiercings': {others: 'Nibble'}},
'slap|spank': {'Head,Breast,Vulva,VulvaPiercings': {all: 'Slap'}, 'Arms,Boots,Butt,Feet,Hands,Legs,Pelvis,Torso': {all: 'Spank'}},
tickle: {'Arms,Boots,Breast,Feet,Legs,Neck,Pelvis,Torso': {all: 'Tickle'}},
massage: {'Arms,Boots,Feet,Legs,Neck,Pelvis,Torso': {all: 'MassageHands'}},
lick: {'Arms,Boots,Breast,Hands,Mouth,Nipples': {all: 'Lick'}, 'Ears,Feet,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {others: 'Lick'}},
suck: {'Nipples,Hands,Boots': {all: 'Suck'}},
bite: {'Arms,Boots,Feet,Hands,Legs,Mouth': {all: 'Bite'}, 'Breast,Butt,Ears,Head,Neck,Nipples,Nose,Torso': {others: 'Bite'}},
pinch: {'Arms,Ears,Nipples,Nose,Pelvis': {all: 'Pinch'}},
clamp: {Mouth: {all: 'HandGag'}, Nose: {all: 'Choke'}},
step: {'Breast,Neck,Pelvis': {others: 'Step'}},
pull: {'Head,Nose,Nipples': {all: 'Pull'}},
grope: {'Butt,Breast': {all: 'Grope'}, 'Feet,Legs,Pelvis': {others: 'Grope'}},
rub: {'Head,Torso': {others: 'Rub'}, Nose: {all: 'Rub'}, Legs: {self: 'Wiggle'}, Hands: {self: 'Caress'}},
caress: {Hands: {others: 'Caress'}, 'Arms,Breast,Butt,Ears,Feet,Head,Legs,Neck,Nipples,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {all: 'Caress'}},
polish: {'Hands,Boots': {all: 'TakeCare'}},
foot: {'Head,Nose': {others: 'Step'}, 'Torso,Boots': {others: 'MassageFeet'}, 'Vulva,VulvaPiercings': {others: 'MasturbateFoot'}},
fist: {'Vulva,Butt': {all: 'MasturbateFist'}},
fuck: {'Mouth,Vulva,Butt': {others: 'PenetrateSlow'}}, // Peg?
pound: {'Mouth,Vulva,Butt': {others: 'PenetrateFast'}},
tongue: {'Vulva,VulvaPiercings': {others: 'MasturbateTongue'}},
finger: {'Breast,Butt,Vulva,VulvaPiercings': {all: 'MasturbateHand'}},
},
MAP_ZONES: {
ItemBoots: ['foot', 'feet', 'boot', 'boots', 'shoe', 'shoes', 'toes', 'toenails', 'sole', 'soles', 'heel', 'heels'],
ItemFeet: ['leg', 'legs', 'ankle', 'ankles'],
ItemLegs: ['hips', 'hip', 'thighs', 'thigh'],
ItemVulva: ['vulva', 'pussy'],
ItemVulvaPiercings: ['clit', 'clitoris'],
ItemButt: ['butt', 'ass'],
ItemPelvis: ['tummy', 'pelvis'],
ItemTorso: ['body', 'torso', 'back', 'ribs'],
ItemBreast: ['breast', 'breasts', 'boob', 'boobs', 'booby', 'boobie', 'boobies', 'tit', 'tits', 'titty', 'tittie', 'titties'],
ItemNipples: ['nip', 'nips', 'nipple', 'nipples'],
ItemHands: ['hand', 'hands', 'fingers', 'fingernails', 'nails'],
ItemArms: ['arm', 'arms', 'elbow', 'elbows'],
ItemNeck: ['neck'],
ItemMouth: ['mouth', 'lip', 'lips', 'teeth', 'tongue', 'gag', 'cheek', 'cheeks'],
ItemNose: ['nose', 'nostrils'],
ItemEars: ['ear', 'ears', 'earlobe', 'earlobes'],
ItemHead: ['head', 'face', 'hair', 'eyes', 'forehead'],
},
FBC_TESTER_PATCHES: [
[/^\^('s)?( )?/g, '^SourceCharacter$1\\s+'],
[/([^\\])\$/g, '$1\\.?$$'],
],
SUBCOMMANDS_MBCHC: {
autohack: {desc: 'toggle the autohack feature', cb: mbchc => mbchc.inform(`Autohack is now ${(mbchc.AUTOHACK_ENABLED = !mbchc.AUTOHACK_ENABLED) ? 'enabled' : 'disabled'}`)}, // eslint-disable-line no-cond-assign
donate: {desc: 'Buy data and send it to recipient', args: {TARGET: {}}, cb: (mbchc, args) => mbchc.donate_data(args[0])},
tz: {desc: 'set target\'s UTC offset', args: {OFFSET: {}, '[TARGET]': {}}, cb: (mbchc, args) => mbchc.set_timezone(args)},
'purge!': {desc: 'delete MBCHC online saved data', cb(mbchc) {
if (w.Player.OnlineSettings.MBCHC) {
delete w.Player.OnlineSettings.MBCHC
mbchc.save_settings()
}
}},
},
ensure(text, callback) {
const result = callback.call(this)
if (!result) throw new Error(text)
return (result)
},
calculate_maps() {
this.DO_DATA = {verbs: {}, zones: {}}
for (const [verbs, data] of Object.entries(this.MAP_ACTIONS)) {
const unwound = {}
for (const [zones, actions] of Object.entries(data)) {
const all = (actions.all) ? actions.all.split('|') : []
const processed = {self: (actions.self) ? actions.self.split('|').concat(all) : all, others: (actions.others) ? actions.others.split('|').concat(all) : all}
for (const zone of zones.split(',')) unwound[`Item${zone}`] = processed
}
for (const verb of verbs.split('|')) this.DO_DATA.verbs[verb] = unwound
}
for (const [ag, zones] of Object.entries(this.MAP_ZONES)) for (const zone of zones) this.DO_DATA.zones[zone] = ag
},
settings(setting = null) {
const settings = w.Player.OnlineSettings.MBCHC || {}
return (setting ? settings[setting] : settings)
},
save_settings(cb = null) {
if (cb) {
if (!w.Player.OnlineSettings.MBCHC) w.Player.OnlineSettings.MBCHC = {}
cb.call(this, w.Player.OnlineSettings.MBCHC)
}
w.ServerAccountUpdate.QueueData({OnlineSettings: w.Player.OnlineSettings})
},
log(level, message) {
console[level]('MBCHC: ' + String(message))
},
empty(text) {
if (!text) return (true)
if (String(text).trim().length === 0) return (true)
return (false)
},
normalise_message(text, options = {}) {
let result = text
if (options.trim) result = result.trim()
if (options.low) result = result.toLocaleLowerCase()
if (options.up) {
const first = result.at(0).toLocaleUpperCase()
const rest = result.slice(1)
result = first + rest
}
if (options.dot && this.RE_LAST_LETTER.test(result)) result = `${result}.`
return (result)
},
tokenise(text) {
return text.replace(this.RE_SPACES, ' ').split(' ')
},
inform(html, timeout = 60_000) {
w.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`, timeout)
},
report(x) {
this.inform(`${x.toString()}`)
if (this.RETHROW) throw x
},
in(x, floor, ceiling) {
return ((x >= floor) && (x <= ceiling))
},
cid2char(id) {
id = Number.parseInt(id, 10)
if (id === cid(w.Player)) return (w.Player)
return (this.ensure(`character ${id} not found in the room`, () => w.ChatRoomCharacter.find(c => cid(c) === id)))
},
pos2char(pos) {
if (!this.in(pos, 0, w.ChatRoomCharacter.length - 1)) throw new Error(`invalid position ${pos}`)
return (w.ChatRoomCharacter[pos])
},
rel2char(target) {
const me = this.ensure('can\'t find my position', () => w.ChatRoomCharacter.findIndex(char => char.IsPlayer()) + 1) - 1 // 0 is falsey, but is valid index
let pos = null
if (this.RE_ALL_LEFT.test(target)) pos = me - target.length
if (this.RE_ALL_RIGHT.test(target)) pos = me + target.length
if (pos === null) throw new Error(`failed to parse target "${target}"`)
pos %= w.ChatRoomCharacter.length
if (pos < 0) pos += w.ChatRoomCharacter.length
return (this.pos2char(pos))
},
target2char(target) { // Target should be lowcase
const input = target
if (this.empty(target)) return (w.Player)
const int = Number.parseInt(target, 10)
target = String(target)
let found = []
if (target.startsWith('=')) return (this.cid2char(target.slice(1)))
if (target.startsWith('<') || target.startsWith('>')) return (this.rel2char(target))
if (!Number.isNaN(int) && int.toString() === target) { // We got a number
if (this.in(int, 0, 9)) return (this.pos2char(int))
if (this.in(int, 11, 15)) return (this.pos2char(int - 11))
if (this.in(int, 21, 25)) return (this.pos2char(int - 16))
found.push(...w.ChatRoomCharacter.filter(c => cid(c).toString().includes(target)))
}
if (target.startsWith('@')) target = target.slice(1)
found.push(...w.ChatRoomCharacter.filter(c => c.Name.toLocaleLowerCase().includes(target)))
found.push(...w.ChatRoomCharacter.filter(c => c.Nickname && (c.Nickname.toLocaleLowerCase().includes(target)))) // eslint-disable-line unicorn/no-array-push-push
const map = {}
for (const c of found) {
if (!map[cid(c)]) map[cid(c)] = c
}
found = Object.values(map)
if (found.length === 0) throw new Error(`target "${input}": no match`)
if (found.length > 1) throw new Error(`target "${input}": multiple matches (${found.map(c => `${cid(c)}|${c.Name}|${c.Nickname || c.Name}`).join(',')})`)
return (found[0])
},
char2targets(char) {
const [result, id] = [new Set(), cid(char).toString()]
result.add(id).add(`=${id}`)
for (const t of this.tokenise(char.Name)) {
result.add(t)
result.add(`@${t}`)
}
if (char.Nickname) for (const t of this.tokenise(char.Nickname)) {
result.add(t)
result.add(`@${t}`)
}
return result
},
donate_data(target) {
const char = this.target2char(target)
if (char.IsPlayer()) throw new Error('target must not be you')
if (!char.IsRestrained()) throw new Error('target must be bound')
const cost = Math.round(((Math.random() * 10) + 15))
if (w.Player.Money < cost) throw new Error('not enough money')
w.CharacterChangeMoney(w.Player, -cost)
w.ServerSend('ChatRoomChat', {Content: 'ReceiveSuitcaseMoney', Type: 'Hidden', Target: cid(char)})
w.ChatRoomMessage({Sender: cid(w.Player), Type: 'Action', Content: `You've bought data for $${cost} and sent it to ${char.dn}.`, Dictionary: [MISSING_PLAYER_DIALOG]})
},
run_activity(char, ag, action) {
try {
if (!w.ActivityAllowed()) throw new Error('activities disabled in this room')
if (!w.ServerChatRoomGetAllowItem(w.Player, char)) throw new Error('no permissions')
char.FocusGroup = this.ensure('invalid AssetGroup', () => w.AssetGroupGet(char.AssetFamily, ag))
const activity = this.ensure('invalid activity', () => w.ActivityAllowedForGroup(char, char.FocusGroup.Name).find(a => a.Activity?.Name === action))
//if ((activity.Name || activity.Activity.Name).endsWith('Item')) {
// const item = this.ensure('no toy found', () => w.Player.Inventory.find(i => i.Asset?.Name === 'SpankingToys' && i.Asset.Group?.Name === char.FocusGroup.Name && w.AssetSpankingToys.DynamicActivity(char) === (activity.Name || activity.Activity.Name)))
// w.DialogPublishAction(char, item)
//} else w.ActivityRun(w.Player, char, char.FocusGroup, activity)
w.ActivityRun(w.Player, char, char.FocusGroup, activity)
} finally {
char.FocusGroup = null
}
},
replace_me(match, offset, string) {
const text = string.slice(1)
let suffix = ' '
if (text.startsWith('\'') || text.startsWith(' ')) suffix = ''
return `${w.MBCHC.PREF_ACTIVITY}<${cid(w.Player)}:>SourceCharacter${suffix}`
},
cid2dict(type, cid) {
return ({Tag: `${type}Character`, MemberNumber: cid, Text: this.cid2char(cid).dn})
},
send_activity(message) {
const dict = [MISSING_PLAYER_DIALOG]
const cids = message.match(this.RE_ACT_CIDS)
if (cids) {
message = message.replace(this.RE_ACT_CIDS, '')
if (cids[1]) dict.push(this.cid2dict('Source', cids[1]))
if (cids[2]) dict.push(this.cid2dict('Target', cids[2]), this.cid2dict('Destination', cids[2]))
}
w.ServerSend('ChatRoomChat', {Type: 'Action', Content: message, Dictionary: dict})
},
//receive(data) {
// const char = this.cid2char(data.Sender)
// if (char.IsPlayer()) return true // This is our own message, sent back to us
// const payload = this.ensure('Empty message', () => data.Dictionary[0])
// switch (payload.type) {
// case 'greetings': case 'hello': {
// char.MBCHC = payload.value
// if (payload.type === 'greetings') this.hello(char)
// break
// }
// default: // If we don't know the type it may be from a newer version
// }
// return true
//},
//hello(char = null) {
// const payload = {type: 'greetings', value: w.Player.MBCHC}
// if (char) payload.type = 'hello'
// const message = {Content: 'MBCHC', Type: /** @type {const} */ ('Hidden'), Dictionary: [payload]}
// if (char) message.Target = char.cid
// w.ServerSend('ChatRoomChat', message)
//},
copy_fbc_trigger(trigger) {
const result = {
Type: 'Action',
Event: trigger.Event,
Matchers: trigger.Matchers.map(m => ({Tester: new RegExp(this.FBC_TESTER_PATCHES.reduce((ax, [f, r]) => ax.replaceAll(f, r), m.Tester.source), 'u')})),
}
return (result)
},
patch_fbc() {
this.remove_fbc_hook()
delete this.remove_fbc_hook
w.bce_ActivityTriggers.push(...w.bce_ActivityTriggers.filter(t => t.Type === 'Emote').map(t => this.copy_fbc_trigger(t)))
/* (["anim", "pose"]).forEach(tag => {let cmd = w.Commands.find(c => tag === c.Tag); if (cmd) cmd.AutoComplete = this[`complete_fbc_${tag}`]}) */ // this line explodes, because it needs a semicolon in front if it
let cmd = w.Commands.find(c => c.Tag === 'anim')
if (cmd) cmd.AutoComplete = this.complete_fbc_anim
cmd = w.Commands.find(c => c.Tag === 'pose')
if (cmd) cmd.AutoComplete = this.complete_fbc_pose
},
find_timezone(char) {
const timezones = this.settings('timezones')
if (timezones && typeof timezones[cid(char)] === 'number') return (timezones[cid(char)])
const match = (char.Description) ? char.Description.match(this.RE_TZ) : null
const int = match ? Number.parseInt(match[1] + match[2], 10) : 42
if (this.in(int, -12, 12)) return (int)
return (null)
},
//player_enters_room() { // Or if the mod is loaded while player is in the room
// this.hello()
//},
set_timezone(args) {
const tz = Number.parseInt(args[0], 10)
if (Number.isNaN(tz)) throw new Error(`invalid offset "${args[0]}"`)
if (!this.in(tz, -12, 12)) throw new Error('offset should be [-12,12]')
const char = this.target2char(args[1])
char.MBCHC_LOCAL.TZ = tz
this.save_settings(s => {
if (!s.timezones) s.timezones = {}
s.timezones[cid(char)] = tz
})
},
update_char(char) {
//char.cid = char.MemberNumber // Club ID (shorter)
char.dn = w.CharacterNickname(char) // DisplayName (shortcut)
if (!char.MBCHC_LOCAL) char.MBCHC_LOCAL = {}
if (!char.MBCHC_LOCAL.TZ) char.MBCHC_LOCAL.TZ = this.find_timezone(char)
},
command_mbchc(argline, cmdline, args) {
const mbchc = w.MBCHC
try { // `this` is command object
if (args.length === 0) return (mbchc.inform(Object.entries(mbchc.SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `<div>/mbchc ${cmd} ${sub.args ? Object.keys(sub.args).join(' ') : ''}: ${sub.desc}</div>`).join('')))
const cmd = String(args.shift())
const sub = mbchc.ensure(`unknown subcommand "${cmd}"`, () => mbchc.SUBCOMMANDS_MBCHC[cmd])
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
} catch (error) {
mbchc.report(error)
}
},
command_activity(argline, cmdline, _) {
const mbchc = w.MBCHC
if (!mbchc.empty(argline)) {
try { // `this` is command object
const message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, ''), {trim: true, dot: true, up: true})
mbchc.send_activity(message)
} catch (error) {
mbchc.report(error)
}
}
},
command_do(argline, cmdline, args) {
const mbchc = w.MBCHC
try { // `this` is command object
if (args.length === 0) return (mbchc.inform('<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>' + Object.keys(mbchc.MAP_ACTIONS).join(', ') + '<div>Available zones:</div>' + Object.keys(mbchc.DO_DATA.zones).join(', ')))
let [verb, zone, target] = args
const zones = mbchc.ensure(`unknown verb "${verb}"`, () => mbchc.DO_DATA.verbs[verb])
if (Object.keys(zones).length === 1) {
if (!target) target = zone
zone = mbchc.MAP_ZONES[Object.keys(zones)[0]][0]
}
if (!zone) throw new Error('zone missing')
const ag = mbchc.ensure(`unknown zone "${zone}"`, () => mbchc.DO_DATA.zones[zone])
const types = mbchc.ensure(`zone "${zone}" invalid for "${verb}"`, () => zones[ag])
let char = w.Player
if (target && ((types.self.length === 0) || (types.others.length > 0))) char = mbchc.target2char(target)
const type = char.IsPlayer() ? 'self' : 'others'
const available = w.ActivityAllowedForGroup(char, ag)
//const toy = w.InventoryGet(w.Player, 'ItemHands')
//if (toy && toy.Asset.Name === 'SpankingToys') available.push(w.AssetAllActivities(char.AssetFamily).find(a => a.Name === w.InventorySpankingToysGetActivity?.(w.Player)))
const actions = mbchc.ensure(`zone "${zone}" invalid for ("${verb}" "${type}")`, () => types[type])
const action = mbchc.ensure(`invalid action (${verb} ${zone} ${target})`, () => actions.find(name => available.find(a => a.Activity?.Name === name)))
mbchc.run_activity(char, ag, action)
} catch (error) {
mbchc.report(error)
}
},
bell() {
setTimeout(() => {
style('#InputChat', s => s.outline = '')
}, 100)
style('#InputChat', s => s.outline = 'solid red')
},
complete(options, space = true) {
if (options.length === 0) return (this.bell())
if (options.length > 1) {
const width = Math.max(...options.map(o => o.length))
let pref = null
for (let i = width; i > 0; i -= 1) {
const test = options[0].slice(0, i)
if (options.every(o => o.startsWith(test))) {
pref = test
break
}
}
if (pref) this.complete([pref], false)
this.comp_hint(options)
} else w.ElementValue('InputChat', w.ElementValue('InputChat').replace(this.RE_LAST_WORD, `$1${options[0]}${space ? ' ' : ''}`))
},
/**
* Displays strings as completion hint
* @param {string[]} options List of words to display. The order will be modified without copy.
* @returns {void}
*/
comp_hint(options) {
if (options.length === 0) return
this.COMP_HINT.innerHTML = '<div>' + options.sort().reverse().map(s => `<div>${s}</div>`).join('') + '</div>'
this.COMP_HINT.style.display = 'block'
w.ElementSetDataAttribute(this.COMP_HINT.id, 'colortheme', (w.Player.ChatSettings.ColorTheme || 'Light'))
const rescroll = w.ElementIsScrolledToEnd('TextAreaChatLog')
w.ChatRoomResize(false)
if (rescroll) w.ElementScrollToEnd('TextAreaChatLog')
this.COMP_HINT.firstChild?.lastChild?.scrollIntoView({behaviour: 'instant'})
},
/**
* Returns true if the completion box is attached to body and its display isn't none
* @returns {boolean}
*/
comp_hint_visible() {
return (this.COMP_HINT.parentElement && this.COMP_HINT.style.display !== 'none')
},
comp_hint_hide() {
if (!this.comp_hint_visible()) return
this.COMP_HINT.style.display = 'none'
w.ChatRoomResize(false)
},
complete_target(token, me2 = true, check_perms = false) {
const [locase, found] = [token.toLocaleLowerCase(), new Set()]
for (const c of w.ChatRoomCharacter) {
if ((c.IsPlayer() && !me2) || (check_perms && !w.ServerChatRoomGetAllowItem(w.Player, c))) continue
for (const s of this.char2targets(c)) {
if (s.toLocaleLowerCase().startsWith(locase)) found.add(s)
}
}
this.complete(Array.from(found))
},
complete_common() {
// w.ElementValue('InputChat') will strip the trailing whitespace
const E = document.querySelector('#InputChat')
if (!(E && E instanceof HTMLTextAreaElement)) throw new Error('somehow InputChat is broken')
return ([this, E.value, this.tokenise(E.value)])
},
complete_mbchc(_args, _locase, _cmdline) {
const [mbchc, _input, tokens] = w.MBCHC.complete_common(); // `this` is command object
if (tokens.length === 0) return
if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
const subname = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return (mbchc.complete(Object.keys(mbchc.SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname)))) // Complete subcommand name
const sub = mbchc.SUBCOMMANDS_MBCHC[subname]
if (sub && sub.args) {
const argname = Object.keys(sub.args)[tokens.length - 3]
if (argname === 'TARGET') return (mbchc.complete_target(tokens.at(-1), false))
if (argname === '[TARGET]') return (mbchc.complete_target(tokens.at(-1)), true)
}
},
complete_do_target(actions, token) {
if (!actions) return
const me2 = (actions.self.length > 0)
if (me2 && actions.others.length === 0) return (this.complete([cid(w.Player).toString()])) // Target is always the player
this.complete_target(token, me2, true)
},
complete_do(_args, _locase, _cmdline) {
const [mbchc, _input, tokens] = w.MBCHC.complete_common(); // `this` is command object
if (tokens.length === 0) return
if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
// Now, we *could* run a filter to exclude impossible activities, but it isn't very useful, and also seems like a lot of CPU to iterate over every action on every zone of every char in the room
let low = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return (mbchc.complete(Object.keys(mbchc.DO_DATA.verbs).filter(c => c.startsWith(low)))) // Complete verb
const ags = mbchc.DO_DATA.verbs[low]
if (!ags) return (mbchc.bell())
low = tokens[2].toLocaleLowerCase()
if (tokens.length < 4) { // Complete zone or target
if (Object.keys(ags).length < 2) return (mbchc.complete_do_target(ags[Object.keys(ags)[0]], tokens[2])) // Zone implied, complete target
const zones = Object.entries(mbchc.DO_DATA.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone, _ag]) => zone)
return (mbchc.complete(zones))
}
if (tokens.length < 5) { // Complete target where it belongs
if (Object.keys(ags).length < 2) return // Zone implied, target already given
return (mbchc.complete_do_target(ags[mbchc.DO_DATA.zones[low]], tokens[3]))
}
mbchc.bell()
},
complete_fbc_anim(_args, _locase, _cmdline) {
const [mbchc, _input, tokens] = w.MBCHC.complete_common(); // `this` is command object
if (tokens.length === 0) return
if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
if (tokens.length > 2) return (mbchc.bell())
const anim = tokens[1].toLocaleLowerCase()
return (mbchc.complete(Object.keys(w.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim))))
},
complete_fbc_pose(_args, _locase, _cmdline) {
const [mbchc, _input, tokens] = w.MBCHC.complete_common(); // `this` is command object
if (tokens.length === 0) return
if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
const pose = tokens.at(-1).toLocaleLowerCase()
return (mbchc.complete(w.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose))))
},
history(down) {
const [text, history] = [w.ElementValue('InputChat'), w.ChatRoomLastMessage]
if (!this.HISTORY_MODE) {
history.push(text)
this.HISTORY_MODE = true
}
const ids = history.map((t, i) => ({t, i})).filter(r => r.t.startsWith(history.at(-1))).map(r => r.i)
if (!down) ids.reverse()
const found = ids.find(id => (down) ? id > w.ChatRoomLastMessageIndex : id < w.ChatRoomLastMessageIndex)
if (!found) return (this.bell())
w.ElementValue('InputChat', history[found])
w.ChatRoomLastMessageIndex = found
},
focus_chat_checks() { // we only want to catch chatlog and canvas (no map though) keypresses
if (document.activeElement === document.body) return true
if (document.activeElement.id !== 'MainCanvas') return false
return !w.ChatRoomMapViewIsActive()
},
focus_chat_whitelist(event) {
if (event.ctrlKey && event.key === 'v') return true // Ctrl+V should paste
return false
},
focus_chat(event) {
if (event.repeat) return // Only unique presses please
if (!this.focus_chat_checks()) return
if ([event.altKey, event.ctrlKey, event.metaKey].some(Boolean) && !this.focus_chat_whitelist(event)) return // Alt, ctrl and meta should all be false
if (style('#InputChat', s => s.display) !== 'inline') return // Input chat missing
w.ElementFocus('InputChat')
},
loader() {
if (this.remove_load_hook) {
this.remove_load_hook()
delete this.remove_load_hook
}
if (this.LOADED) return
// Calculated values
const COMMANDS = [
{Tag: 'mbchc', Description: ': Utility functions ("/mbchc" for help)', Action: this.command_mbchc, AutoComplete: this.complete_mbchc},
{Tag: 'activity', Description: '[Message]: Send a custom activity (or "@@Message", or "@Message" as yourself)', Action: this.command_activity},
{Tag: 'do', Description: ': Do an activity, as if clicked on its button ("/do" for help)', Action: this.command_do, AutoComplete: this.complete_do},
]
this.CommandsKey = CommandsKey /* eslint-disable-line no-undef */ // window.CommandsKey is undefined
this.RE_ACTIVITY = new RegExp(`^${this.CommandsKey}activity `)
this.PREF_ACTIVITY = `${this.CommandsKey}activity `
this.COMP_HINT = document.createElement('div')
this.COMP_HINT.id = 'mbchcCompHint'
const css = document.createElement('style')
css.textContent = `
#TextAreaChatLog .mbchc, #TextAreaChatLog .mbchc {
background-color: ${this.RGB_POLLY};
}
#TextAreaChatLog[data-colortheme="dark"] .mbchc, #TextAreaChatLog[data-colortheme="dark2"] .mbchc {
background-color: ${this.RGB_MUTE};
}
#${this.COMP_HINT.id} {
display: none;
text-align: right;
}
#${this.COMP_HINT.id} > div {
overflow: auto;
position: absolute;
bottom: 0;
right: 0;
max-height: 100%;
padding: 0 0.5ex;
background-color: ${this.RGB_POLLY};
color: black;
}
#${this.COMP_HINT.id}[data-colortheme="dark"] > div, #${this.COMP_HINT.id}[data-colortheme="dark2"] > div {
background-color: ${this.RGB_MUTE};
color: white;
}
#${this.COMP_HINT.id} > div div {
margin: 0.25ex 0;
}
`
document.head.append(css)
// Actions
this.calculate_maps()
//w.Player.MBCHC = {VERSION: this.VERSION}
w.CommandCombine(COMMANDS)
// Hooks
this.remove_fbc_hook = this.before('MainRun', () => w.bce_ActivityTriggers && this.patch_fbc())
this.after('CharacterOnlineRefresh', char => this.update_char(char))
this.after('ChatRoomReceiveSuitcaseMoney', () => {
if (this.AUTOHACK_ENABLED && this.LAST_HACKED) {
w.CurrentCharacter = this.cid2char(this.LAST_HACKED)
this.LAST_HACKED = null
w.ChatRoomTryToTakeSuitcase()
}
})
this.before('ChatRoomSendChat', () => {
let input = w.ElementValue('InputChat')
if (!input.startsWith('@@@') && input.startsWith('@')) {
input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY)
input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me)
w.ElementValue('InputChat', input)
}
})
this.after('ChatRoomSendChat', () => {
const history = w.ChatRoomLastMessage
if ((history.length > 1) && (history.at(-1) === history.at(-2))) {
history.pop()
w.ChatRoomLastMessageIndex -= 1
}
})
this.before('ChatRoomCharacterViewDrawOverlay', (C, CharX, CharY, Zoom, _Pos) => {
// if (w.ChatRoomHideIconState < 1 && C.MBCHC) {
// w.DrawRect(CharX + (175 * Zoom), CharY, 50 * Zoom, 50 * Zoom, C.MBCHC.VERSION === w.Player.MBCHC.VERSION ? this.RGB_POLLY : this.RGB_MUTE)
// }
if (w.ChatRoomHideIconState < 1 && C.MBCHC_LOCAL && typeof C.MBCHC_LOCAL.TZ === 'number') {
const hours = new Date(w.CommonTime() + this.UTC_OFFSET + (C.MBCHC_LOCAL.TZ * 60 * 60 * 1000)).getHours()
w.DrawTextFit(hours < 10 ? '0' + hours.toString() : hours.toString(), CharX + (200 * Zoom), CharY + (25 * Zoom), 46 * Zoom, 'white', 'black')
}
})
this.after('ChatRoomCreateElement', () => this.COMP_HINT.parentElement || document.body.append(this.COMP_HINT))
this.before('ChatRoomClearAllElements', () => {
this.comp_hint_hide()
this.COMP_HINT.remove()
})
this.before('ChatRoomClick', () => {
this.comp_hint_hide()
})
this.after('ChatRoomResize', () => {
if (w.CharacterGetCurrent() === null && w.CurrentScreen === 'ChatRoom' && document.querySelector('#InputChat') && document.querySelector('#TextAreaChatLog') && this.comp_hint_visible()) { // Upstream
const fontsize = ChatRoomFontSize /* eslint-disable-line no-undef */ // window.ChatRoomFontSize is undefined
//w.ElementPositionFix('TextAreaChatLog', fontsize, 1005, 66, 988, 630)
//w.ElementPositionFix(this.COMP_HINT.id, fontsize, 1005, 701, 988, 200)
w.ElementPositionFix(this.COMP_HINT.id, fontsize, 800, 65, 200, 835)
//this.COMP_HINT.style.display = 'flex'
}
})
document.addEventListener('keydown', event => this.focus_chat(event))
this.SDK.hookFunction('ChatRoomKeyDown', 0, (nextargs, next) => { // This fires on chat input events
const [event] = nextargs
w.MBCHC.comp_hint_hide()
if ((w.KeyPress === 33) || (w.KeyPress === 34)) { // Better history
event.preventDefault()
return (w.MBCHC.history(w.KeyPress - 33))
}
if (w.MBCHC.HISTORY_MODE) {
w.ChatRoomLastMessage.pop()
w.MBCHC.HISTORY_MODE = false
}
return (next(nextargs))
})
// Chat room handlers
w.ChatRoomRegisterMessageHandler({Priority: -220, Description: 'MBCHC preprocessor', Callback: (data, sender, message, metadata) => {
data.MBCHC_ID = this.NEXT_MESSAGE
this.NEXT_MESSAGE += 1
if (this.LOG_MESSAGES) console.debug({data, sender, msg: message, metadata})
return false
}})
//w.ChatRoomRegisterMessageHandler({Priority: -219, Description: 'MBCHC room enter hook',
// Callback: (data, _sender, _message, _metadata) => {
// if ((data.Type === 'Action') && (data.Content === 'ServerEnter') && (data.Sender === cid(w.Player))) this.player_enters_room()
// return false
// },
//})
//w.ChatRoomRegisterMessageHandler({Priority: -219, Description: 'MBCHC specific consumer',
// Callback: (data, _sender, _message, _metadata) => {
// if ((data.Type === 'Hidden') && (data.Content === 'MBCHC')) return this.receive(data)
// return false
// },
//})
w.ChatRoomRegisterMessageHandler({Priority: -219, Description: 'MBCHC autohack lookup',
Callback: (data, _sender, _message, _metadata) => {
if ((data.Type === 'Hidden') && (data.Content === 'ReceiveSuitcaseMoney')) this.LAST_HACKED = data.Sender
return false
},
})
// Footer
this.LOADED = true
this.log('info', `loaded version ${this.VERSION}`)
if ((w.CurrentModule === 'Online') && (w.CurrentScreen === 'ChatRoom')) {
for (const c of w.ChatRoomCharacter) this.update_char(c)
//this.player_enters_room()
}
},
preloader() {
if (!w.AsylumGGTSSAddItems) throw new Error('AsylumGGTSSAddItems() not found, aborting MBCHC loading')
if (!w.bcModSdk) throw new Error('SDK not found, please load with (or after) FUSAM')
this.SDK = w.bcModSdk.registerMod({name: 'MBCHC', fullName: 'Mute\'s Bondage Club Hacks Collection', version: this.VERSION, repository: 'https://code.fleshless.org/mute/MBCHC/'})
this.before = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs, next) => {
try {
cb?.(...nextargs)
} catch (error) {
console.error(error)
}
return next(nextargs)
})
this.after = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs, next) => {
const result = next(nextargs);
try {
cb?.(...nextargs)
} catch (error) {
console.error(error)
}
return result
})
// for some reason many (not all) hooks don't work if the mod is loaded in the room
// to be honest I have no idea what's going on. the hooks get registered, they just don't get called by SDK.
// if (current() === 'Online/ChatRoom') throw new Error('please do not load in a chat room')
if (current() === 'Character/Login') {
this.remove_load_hook = this.before('AsylumGGTSSAddItems', () => this.loader())
} else this.loader()
},
}
w.MBCHC.preloader()

5817
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "mbchc",
"version": "0.0.12",
"description": "Mute's Bondage Club Hacks Collection",
"type": "module",
"devDependencies": {
"xo": "^0.56.0",
"bc-stubs": "^105.0.0",
"bondage-club-mod-sdk": "^1.2.0"
},
"license": "SEE LICENSE IN LICENSE.",
"xo": {
"env": [
"browser",
"node"
],
"rules": {
"brace-style": "off",
"camelcase": "off",
"capitalized-comments": "off",
"curly": "off",
"max-params": "off",
"max-statements-per-line": "off",
"new-cap": "off",
"no-return-assign": "off",
"no-unused-expressions": "off",
"no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
}],
"padding-line-between-statements": "off",
"semi": "off",
"spaced-comment": "off",
"unicorn/no-array-reduce": "off",
"fake/fuck-commas": "off"
}
}
}

33
server.js Normal file
View File

@@ -0,0 +1,33 @@
import {readFileSync} from 'node:fs'
import {createServer} from 'node:http'
const config = {host: '127.0.0.1', port: 9696}
const h_cors = {
'Access-Control-Max-Age': '86400',
'Access-Control-Allow-Private-Network': 'true',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
// 'Access-Control-Allow-Credentials': 'false', // omit this header to disallow
}
const h_all = Object.assign({
'Content-Type': 'text/javascript',
'Cache-Control': 'no-cache',
}, h_cors)
/**
* @typedef {import('node:http').ServerResponse} ServerResponse
* @type {Record<string,function(ServerResponse):void>}
*/
const resp = {
GET(rx) { rx.writeHead(200, h_all); rx.write(readFileSync('./mbchc.mjs')) },
OPTIONS(rx) { rx.writeHead(204, h_cors) },
}
const server = createServer((rq, rx) => {
resp[rq.method] && (new URL(`http://${config.host}:${config.port}${rq.url}`)).pathname === '/' ? resp[rq.method](rx) : rx.writeHead(400)
rx.end()
console.log('%s %d %s %s', (new Date()).toISOString(), rx.statusCode, rq.method, rq.url)
})
server.listen(config.port, config.host, () => console.log(`Server started at http://${config.host}:${config.port}`))

10
typedef.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/consistent-type-definitions */
interface PlayerOnlineSettings {
MBCHC?: any;
}
interface ServerChatRoomMessage {
MBCHC_ID?: number;
}