Compare commits

..

No commits in common. "master" and "v.1" have entirely different histories.
master ... v.1

13 changed files with 230 additions and 8361 deletions

1
.gitignore vendored
View File

@ -1 +0,0 @@
node_modules

View File

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

View File

@ -1,10 +1,3 @@
# 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]].
* Unstable doc: https://code.fleshless.org/mute/MBCHC/wiki/unstable [[https://code.fleshless.org/mute/MBCHC/wiki]]
## 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.

468
ambient.d.ts vendored
View File

@ -1,468 +0,0 @@
interface ServerChatRoomMessage {
MBCHC_ID?: number
}
namespace BCE {
interface Matcher {
Tester: RegExp
Criteria?: {
TargetIsPlayer?: boolean
SenderIsPlayer?: boolean
DictionaryMatchers?: Array<OBJ<string>>
}
}
interface Trigger {
Event: string
Type: 'Emote' | 'Activity' | 'Action'
Matchers: Matcher[]
}
interface Patcher {
timer: number | undefined
patches: Array<[RegExp, string]>
cfs: {[k in 'anim' | 'pose']: () => Iterable<string>}
gen: (comp_func: () => Iterable<string>) => (this: Optional<ICommand, 'Tag'>) => undefined
copy(t: Trigger): Trigger
patch(): true | undefined
}
}
type SUBCOMMANDS = {
[k: string]: {
desc: string
args?: {[n: string]: OBJ}
cb: (mbchc: Window['MBCHC'], args: string[], __: unknown, ___?: unknown) => void
}
};
interface Window {
MBCHC: {
loader: () => void
DO_DATA: {
zones: OBJ<string>
verbs: {
[verb: string]: {
[zone: string]: {
self: string[]
others: string[]
}
}
}
}
MAP_ACTIONS: {
[verb: string]: {
[zones: string]: {
all?: string
self?: string
others?: string
}
}
}
MAP_ZONES: {[zone: string]: string[]}
LOADED: boolean
NEXT_MESSAGE: number
LOG_MESSAGES: boolean
LAST_HACKED: number | undefined
version: string
VERSION: string
Settings: Settings.Methods
TZ: TZ_Cache
AUTOHACK_ENABLED: boolean
RE_PREF_ACTIVITY_ME: RegExp
RE_PREF_ACTIVITY: RegExp
RE_ACT_CIDS: RegExp
RE_LAST_WORD: RegExp
RE_LAST_LETTER: RegExp
RE_ACTIVITY: RegExp
UTC_OFFSET: number
calculate_maps(): void
normalise_message(text: string, options?: OBJ<boolean>): string
donate_data(target: string): void
run_activity(char: Character, ag: AssetGroupItemName, action: ActivityName): void
send_activity(message: string): void
set_timezone(args: string[]): number | undefined
command_mbchc(this: Optional<ICommand, 'Tag'>, args: string, msg: string, parsed: string[]): undefined
command_activity(this: Optional<ICommand, 'Tag'>, args: string, msg: string, parsed: string[]): undefined
command_do(this: Optional<ICommand, 'Tag'>, args: string, msg: string, parsed: string[]): undefined
}
bcModSdk?: import('./node_modules/bondage-club-mod-sdk/dist/bcmodsdk.d.ts').ModSDKGlobalAPI;
FBC_VERSION?: string
bce_ActivityTriggers?: BCE.Trigger[]
bce_EventExpressions?: OBJ
}
type OBJ<T = unknown> = Record<string, T>;
type FUN = (...args: never[]) => unknown;
/**
* Stands for "Value or Function". Not remotely ideal, but the best I can come up with.
*/
type VOF<F extends FUN> = undefined | boolean | number | bigint | string | symbol | OBJ | Set<unknown> | Map<unknown, unknown> | FP.Interval | F;
/**
* I can write Haskell in every language.
* I was unbelievably tempted to go with emojis here, but I'm not a monster of this caliber.
* @see https://8fw.me/hell/a8aaaefcb6e30ec4b2e398c70d49b72a6b53232f.png
*/
namespace FP {
interface Interval {
proxy: object // eslint-disable-line @typescript-eslint/ban-types
min: number
max: number
mini: boolean
maxi: boolean
has(_: unknown, x: string): boolean
}
interface Pipeline<T> {
proxy: PipelineProxy<T>
me<N>(iterable: Iterable<N>): PipelineProxy<N>
[Symbol.iterator](): Iterator<T>
rdc<R>(initial: R, func: (accumulator: R, value: T) => R): R
any(func: (v: T) => boolean): boolean
all(func: (v: T) => boolean): boolean
map<R>(func: (value: T) => R): PipelineProxy<R>
sel(func: (value: T) => boolean): PipelineProxy<T>
}
interface PipelineProxy<T> extends Pipeline<T> {
[i: number]: T | undefined
}
/**
* Creates an iteration pipeline.
*/
type P = <T>(iterable: Iterable<T>) => PipelineProxy<T>;
/**
* A silly helper to kinda c[au]rry values. Basically equivalent to:
* `const a = value; return func(a)`
* Stands for "Carry, Use, Return".
*/
type cur = <T, R>(value: T, func: (value: T) => R) => R;
/**
* Convert `null` to `undefined`.
*/ // eslint-disable-next-line @typescript-eslint/ban-types
type n2u = <T>(value: T | undefined | null) => T | undefined;
/**
* Enumerate keys of an object. Type-safe, also shorter.
*/
type enu = <O extends OBJ>(object: O) => Array<keyof O>;
/**
* Like `carry()` but does nothing if `value` is `null` or `undefined`.
* Something like an `Option<>` when you want to just pass `undefined` along, but
* run a function on actual values. Also coverts `null` to `undefined`.
*/ // eslint-disable-next-line @typescript-eslint/ban-types
type val = <T, R>(value: T | null | undefined, func: (value: T) => R) => R | undefined;
/**
* `+` as a function
*/
type add = (x: number, y: number) => number;
/**
* `-` as a function
*/
type sub = (x: number, y: number) => number;
/**
* `>` as a function
*/
type cgt = (x: number, y: number) => boolean;
/**
* `>=` as a function
*/
type cge = (x: number, y: number) => boolean;
/**
* `<` as a function
*/
type clt = (x: number, y: number) => boolean;
/**
* `<=` as a function
*/
type cle = (x: number, y: number) => boolean;
/**
* Converts a string into a base 10 integer. Not only is it shorter than `Number.parseInt`,
* it also converts `NaN` to `undefined`, because I find it much easier to only have
* one nil value for everything.
*/
type int = (text: string) => number | undefined;
/**
* This is a type assertion helper for Typescript. Probably not very useful in general.
*/
type fun = <F extends FUN>(func: unknown) => func is F;
/**
* Takes anything, ignores non-functions, calls functions with supplied parameters.
*/
type run = <F extends FUN>(functions_or_values: Array<VOF<F>>, ...args: Parameters<F>) => true;
/**
* Takes anything, ignores non-functions, calls functions with `undefined` as a single parameter.
*/
type yes = (...args: Array<VOF<(_: undefined) => unknown>>) => true;
/**
* Takes anything, ignores non-functions, calls functions with a single provided parameter.
* Always returns the parameter itself.
*/
type mut = <T>(value: T, ...args: Array<VOF<(value: T) => unknown>>) => T;
/**
* `delete` as a function
*/ // eslint-disable-next-line @typescript-eslint/ban-types
type del = <T extends object>(object: T, property: keyof T) => T;
/**
* Short for `assert`. Throws, if value is `undefined`, or if `condition(value)` is false.
*/
type ass = <T>(error: string, value: T | undefined, condition?: (value: T) => boolean) => T | never;
/**
* Casts value as a given prototype or returns undefined.
*/
type asa = <T>(prototype: new () => T, value: unknown) => T | undefined;
/**
* `catch` as a function. Short for `rescue`.
*/
type rsc = (operation: (_: undefined) => unknown, exception_handler: (error: unknown) => unknown) => true;
/**
* Returns a proxy for `in` operator.
* Use it like `2 in rng(0, 4)`.
*/ // eslint-disable-next-line @typescript-eslint/ban-types
type rng = (min: number, max: number, min_inclusive?: boolean, max_inclusive?: boolean) => object;
/**
* Something slightly better than `Boolean()`.
* True is `null`, `undefined`, `false`, empty strings, sets, maps, arrays and objects.
* Short for "empty".
*/
type m_t = (value: unknown) => boolean;
/**
* `while()` as a function. Executes the second callback while the first one is true.
* Always bound by max number of iterations.
* Returns the last value from the action or undefined if the action never ran.
*/
type loo = <T>(max: number, condition: (_: undefined) => boolean, action: (_: undefined) => T) => T | undefined;
}
namespace SDK {
type GDPT<F> = import('./node_modules/bondage-club-mod-sdk/dist/bcmodsdk.d.ts').GetDotedPathType<typeof globalThis, F>;
type Void<F extends FUN> = (...args: Parameters<F>) => unknown;
type Hook = <F extends string>(name: F, hook: Void<GDPT<F>>) => () => unknown;
}
namespace Cons {
type MS = {w: 'warn', i: 'info', d: 'debug', l: 'log'};
type E = (error: unknown) => true;
type F = (message: string) => true;
interface Wrap {
readonly ms: MS
e: E
w: F
i: F
d: F
l: F
gen: (m: keyof MS) => F
}
}
namespace Settings {
/**
* The whole `Player.OnlineSettings`.
*/
type V0 = PlayerOnlineSettings & {MBCHC?: {timezones?: Record<number, number>}};
/**
* Specifically `MBCHC` inside `Player.ExtensionSettings`.
*/
interface V1 {
TZ: Record<number, number>
}
interface Methods {
migrate_0_1(v0: V0): true
save(func?: (v1: V1) => unknown): true
replace(new_v1: V1): true
'purge!'(): true
get v0(): V0 | undefined
get v1(): V1
}
}
/**
* We need a place to cache the timezones instead of the `Character` object itself.
*/
interface TZ_Cache {
map: Map<number, number>
RE: RegExp
parse(description: string | undefined): number | undefined
memo(member_number: number, description?: string | undefined): number | undefined
lookup(character: Character): number | undefined
}
interface Utils {
remove_loader_hook: (() => unknown) | undefined
RE: {SPACES: RegExp, REL: {L: RegExp, R: RegExp}, '@': [RegExp, RegExp]}
RGB: {Polly: string, Mute: string}
ACT: string,
get crc(): Character[]
get ic(): HTMLTextAreaElement | undefined
cid(character: Character): number | undefined
dn(character: Character): string
current(): string
style<T>(query: string, func: (s: CSSStyleDeclaration) => T): T | undefined
inform(html: string): true
report(error: unknown): true
/**
* Splits a string into words by continuous whitespace sequences. Some examples:
* ```
* "" => [""]
* " " => ["", ""]
* "f g" => ["f", "g"]
* " f g " => ["", "f", "g", ""]
* ```
*/
split(text: string): string[]
abs2char(index: number): Character
rel2char(target: string): Character
cid2char(cid: number): Character
target2char(target: string): Character
mkdiv(html?: string): HTMLDivElement
bell(): true
targets(me2?: boolean, check_perms?: boolean): Set<string>
complete_mbchc(this: Optional<ICommand, 'Tag'>): undefined
complete_do_target(actions: {self: unknown, others: unknown}): Set<string>
complete_do(this: Optional<ICommand, 'Tag'>): undefined
replace_me(_match: string, _offset: number, whole: string): string
scroll(): true
get scrolled(): boolean
rescroll(func: (_: undefined) => unknown): true
}
interface Complete {
S_OPTS: {behavior: 'instant'}
/**
* The suggestions panel.
* Its structure is (outer div) -> (container div) -> (multiple suggestion div elements)
*/
e: HTMLDivElement
/**
* The container div.
*/
get div(): Element | undefined
/**
* Longest common prefix, or an empty string for an empty set.
*/
lcp(candidates: Set<string>): string
/**
* !WARNING! Mutate the given set in accordance with the input.
* Returns the input with completion done and removes all failed candidates.
* If the set is empty, the completion failed and the result is the unmodified input.
* If the set has more than one value, these are all new candidates, and the input was completed with the lcp.
* Otherwise, the only successful candidate will remain in the set and the input was completed to it.
*/
complete_word(input: string, candidates: Set<string>, ignore_case?: boolean | undefined): string
/**
* Show the suggestions to the user.
*/
hint(candidates: Set<string>): true
/**
* Returns false if the suggestion panel is visible.
*/
get hidden(): boolean
/**
* Makes the suggestions panel disappear.
*/
hide(): true
/**
* The whole deal. Will read and modify the chat input window.
* Takes a callback that will receive current input split into words
* and should return all possible candidates for the word being completed.
* Note: if the input ends with whitespace, the last word will be empty.
*/
complete(func: (words: string[]) => Set<string>, ignore_case?: boolean | undefined): true
/**
* Case-insensitive complete.
*/
icomplete(func: (words: string[]) => Set<string>): true
}
/**
* So, this is what happens. We have two modes: input mode and history mode. In the history mode the element is read-only.
* In the input mode:
* If the input is empty, we just scroll the history as usual
* Otherwise, we build a history set using the input as prefix
* The history set filters through the history, keeping only unique lines that start with the prefix along with indices
* But we exclude lines that match the input exactly
* It keeps the original input in a separate place too
* If the set is empty, we bell out
* Otherwise, we enter the history mode and invoke the first search
* In the history mode:
* We search up or down, using the index as a starting point for the next match, treating the set as a ring
* If the found line is the same as the current line, we bell out
* Upon finding a next match, we replace the input with its text and set the index appropriately
* We exit the history mode using the InputChat keydown handler, on Tab, Escape or Enter
* Escape restores the saved input, discarding the history line
* Tab keeps the current text and unlocks the element, allowing it to be edited
* Enter keeps the current text and sends it as the message as usual
*/
interface InputHistory {
/**
* Whether the chat log is scrolled to the end when we enter history mode.
*/
bottom: boolean | undefined
/**
* The initial user input when we enter the history mode.
*/
input: string | undefined
/**
* The indices of the lines that match the prefix.
*/
ids: Set<number> | undefined
/**
* Specific key handlers.
*/
key: Record<string, (event: KeyboardEvent, textarea: HTMLTextAreaElement) => true>
/**
* Set or unset the readonly mode on the input.
*/
icro(textarea: HTMLTextAreaElement, readonly: boolean): true
/**
* Enter the history mode.
*/
enter(textarea: HTMLTextAreaElement, input: string, bottom: boolean, ids: Set<number>): true
/**
* Exit the history mode and proc the key event.
*/
exit(textarea: HTMLTextAreaElement, e: KeyboardEvent): true
}
// FIXME spread around readonly where appropriate

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,25 +0,0 @@
{
"include": [
"node_modules/bc-stubs/bc/**/*.d.ts",
"node_modules/bondage-club-mod-sdk/dist/**/*.d.ts",
"ambient.d.ts",
"mbchc.mjs",
"server.js"
],
"compilerOptions": {
"target": "es2023",
"allowJs": true,
"checkJs": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"strict": true,
"noEmit": true
}
}

View File

@ -1,667 +1,248 @@
// ==UserScript== // ==UserScript==
// @name MBCHC // @name MBCHC
// @version dev.8 // @version dev.1
// @description Mute's Bondage Club Hacks Collection // @description Mute's Bondage Club Hacks Collection
// @author codename.mute@proton.me // @author codename.mute@proton.me
// @namespace https://code.fleshless.org/mute/
// @homepage https://code.fleshless.org/mute/MBCHC // @homepage https://code.fleshless.org/mute/MBCHC
// @updateURL https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js // @namespace https://code.fleshless.org/mute/
// @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 MBCHC loading" if (!window.AsylumGGTSSAddItems) throw "AsylumGGTSSAddItems() not found, aborting"
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"}},
// action zone // Static data
"wiggle|shake": {"Arms,Breast,Boots,Butt,Ears,Feet,Hands,Nose,Pelvis,Torso": {self: "Wiggle"}}, window.MBCHC = {
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
// action target // Loader
"whisper": {Ears: {others: "Whisper"}}, window.AsylumGGTSSAddItems = function() {
"choke": {Neck: {all: "Choke"}}, if (!window.MBCHC.LOADED) {
"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 // Save originals hopefully after patching
"item": { window.MBCHC.orig_ChatRoomMessageInvolvesPlayer = window.ChatRoomMessageInvolvesPlayer
"Breast,Butt,Feet,Legs": {all: "SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem|Inject"}, window.MBCHC.orig_ChatRoomReceiveSuitcaseMoney = window.ChatRoomReceiveSuitcaseMoney
"Nipples,Pelvis": {all: "SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem"}, window.MBCHC.orig_ChatRoomSendChat = window.ChatRoomSendChat
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)
// Hooks // Functions
this.remove_fbc_hook = this.before("MainRun", () => window.bce_ActivityTriggers && this.patch_fbc()) window.MBCHC.HIDE_ALL = window.MBCHC.HIDE_SPECIAL.concat(window.MBCHC.HIDE_BODY).concat(window.MBCHC.HIDE_CLOTHES).concat(window.MBCHC.HIDE_ITEMS)
this.after("CharacterOnlineRefresh", char => this.update_char(char)) window.MBCHC.make_my_anal_hook_hide_body = function() {
this.after("ChatRoomReceiveSuitcaseMoney", () => { let item = window.InventoryGet(window.Player, "ItemButt")
if (this.AUTOHACK_ENABLED && this.LAST_HACKED) { if (!item || !item.Asset || !item.Asset.Name) throw "butt seems empty"
window.CurrentCharacter = this.cid2char(this.LAST_HACKED) if (item.Asset.Name !== "AnalHook") throw "butt seems occupied by something other than the anal hook"
this.LAST_HACKED = null if (!item.Property.Type || item.Property.Type !== "Hair") throw "anal hook seems not tied to hair"
window.ChatRoomTryToTakeSuitcase() item.Property = {Type: "Hair", Hide: this.HIDE_ALL}
} window.CharacterRefresh(window.Player, true, true)
}) }
this.before("ChatRoomSendChat", () => { window.MBCHC.donate_data = function(id) {
let input = window.ElementValue("InputChat") if (id == window.Player.MemberNumber) throw "recipient must not be you"
if (!input.startsWith("@@@") && input.startsWith("@")) { const char = window.ChatRoomCharacter.find( c => c.MemberNumber == id )
input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY) if (!char) throw "recipient not found"
input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me) if (!char.IsRestrained()) throw "recipient must be bound"
window.ElementValue("InputChat", input) const cost = (Math.random() * 10 + 15).toFixed(0)
} if (window.Player.Money < cost) throw "not enough money"
}) window.CharacterChangeMoney(window.Player, -cost)
this.after("ChatRoomSendChat", () => { window.ServerSend("ChatRoomChat", {Content: "ReceiveSuitcaseMoney", Type: "Hidden", Target: id})
const history = window.ChatRoomLastMessage return({cost: cost, name: (char.Nickname || char.Name)})
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) {
this.before("ChatRoomDrawCharacterOverlay", (C, CharX, CharY, Zoom, Pos) => { try {
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) char.FocusGroup = window.AssetGroupGet(char.AssetFamily, ag)
if (window.ChatRoomHideIconState < 1 && C.MBCHC_LOCAL && "number" === typeof C.MBCHC_LOCAL.TZ) { if (!char.FocusGroup) throw "invalid AssetGroup"
const hours = new Date(window.CommonTime() + this.UTC_OFFSET + C.MBCHC_LOCAL.TZ * 60 * 60 * 1000).getHours() let activity = window.ActivityAllowedForGroup(char, char.FocusGroup.Name).find( a => a.Name === action)
window.DrawTextFit(hours < 10 ? "0" + hours.toString() : hours.toString(), CharX + 200 * Zoom, CharY + 25 * Zoom, 46 * Zoom, "white", "black") if (!activity) throw "invalid activity"
} window.ActivityRun(char, activity)
}) } finally {
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)) char.FocusGroup = null
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()) window.MBCHC.send_activity = function(msg) {
this.after("ChatRoomResize", () => { window.ServerSend("ChatRoomChat", {Type: "Action", Content: "lef_wie234jf_owhwi", Dictionary: [{Tag: 'MISSING PLAYER DIALOG: lef_wie234jf_owhwi', Text: msg}]})
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 // Command actions
window.ElementPositionFix("TextAreaChatLog", fontsize, 1005, 66, 988, 630) window.MBCHC.action_title = function(args) {
window.ElementPositionFix(this.COMP_HINT.id, fontsize, 1005, 701, 988, 200) let title = args.shift()
this.COMP_HINT.style.display = "flex" if (!title || !title.length || title.length < 1) throw "empty title"
} if (title.length > 16) throw "title too long"
}) if (!title.match(window.MBCHC.RE_TITLE)) throw "invalid title"
document.addEventListener("keydown", event => this.focus_chat(event)) let first = title.at(0).toLocaleUpperCase()
this.SDK.hookFunction("ChatRoomKeyDown", 0, (nextargs, next) => { // this fires on chat input events let rest = title.slice(1).toLocaleLowerCase()
let [event] = nextargs title = first + rest
window.MBCHC.complete_hint_hide() window.TitleSet(title)
if ((window.KeyPress == 33) || (window.KeyPress == 34)) { // better history // TODO: this needs much more work. at least don't push a second title
event.preventDefault() // we need to patch the text cache
return(window.MBCHC.history(window.KeyPress - 33)) // we need to check for other players' custom titles
} window.TitleList.push({Name: title, Requirement: () => {return true}})
if (window.MBCHC.HISTORY_MODE) { }
window.ChatRoomLastMessage.pop() window.MBCHC.action_donate = function(args) {
window.MBCHC.HISTORY_MODE = false let id = Number.parseInt(args.shift())
} if (isNaN(id)) throw "empty or invalid member number"
return(next(nextargs)) let result = window.MBCHC.donate_data(id)
}) 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"})`)
}
// Chat room handlers // Hooks
window.ChatRoomRegisterMessageHandler({ Priority: -220, Description: "MBCHC preprocessor", Callback: (data, sender, msg, metadata) => { window.ChatRoomMessageInvolvesPlayer = function(data) {
data.MBCHC_ID = this.NEXT_MESSAGE if (!data.MBCHC_ID) {
this.NEXT_MESSAGE += 1 data.MBCHC_ID = window.MBCHC.NEXT_MESSAGE
if (this.LOG_MESSAGES) console.debug({data, sender, msg, metadata}) window.MBCHC.NEXT_MESSAGE += 1
}}) if (window.MBCHC.LOG_MESSAGES) console.debug(data)
window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC room enter hook", if (("ReceiveSuitcaseMoney" === data.Content) && ("Hidden" === data.Type)) { window.MBCHC.LAST_HACKED = data.Sender }
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", }
Callback: (data, sender, msg, metadata) => { if (("Hidden" === data.Type) && ("MBCHC" === data.Content)) return this.receive(data) } window.ChatRoomReceiveSuitcaseMoney = function() {
}) let result = window.MBCHC.orig_ChatRoomReceiveSuitcaseMoney()
window.ChatRoomRegisterMessageHandler({ Priority: -219, Description: "MBCHC autohack lookup", if (window.MBCHC.AUTOHACK_ENABLED && window.MBCHC.LAST_HACKED) {
Callback: (data, sender, msg, metadata) => { if (("Hidden" === data.Type) && ("ReceiveSuitcaseMoney" === data.Content)) this.LAST_HACKED = data.Sender } window.CurrentCharacter = {MemberNumber: window.MBCHC.LAST_HACKED}
}) 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())
}
// footer // Actions
this.LOADED = true window.CommandCombine(window.MBCHC.COMMANDS)
this.log("info", `loaded version ${this.VERSION}`) if (window.bcModSdk) window.bcModSdk.registerMod("MBCHC", window.MBCHC.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? window.MBCHC.LOADED = true
if (("Online" === window.CurrentModule) && ("ChatRoom" === window.CurrentScreen)) { console.info(window.MBCHC.log("loaded version " + window.MBCHC.VERSION))
window.ChatRoomCharacter.forEach(c => this.update_char(c)) }
this.player_enters_room() return(window.MBCHC.orig_AsylumGGTSSAddItems())
} } // 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 */
})() })()

View File

@ -1,667 +0,0 @@
// ==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 */
})()

592
mbchc.mjs
View File

@ -1,592 +0,0 @@
// Take a look at the .d.ts for comments.
export const version = '108.13.1'
const W = window, D = W.document, /**fuck money*/$ = undefined, /**@type {''}*/$S = '', /**@type {{}}*/$O = {}, /**@type {Set<string>}*/$Ss = new Set() // /**@type {readonly []}*/$A = [],
const/**@type {TextDictionaryEntry}*/MISSING_PLAYER_DIALOG = {Tag: 'MISSING TEXT IN "Interface.csv": ', Text: '\u200C'} // Zero-width non-joiner, used to break up ligatures, does nothing here, but an empty string is a falsey value
const/**@type {FP.cur}*/cur = (v, f) => f(v)
const/**@type {FP.n2u}*/n2u = v => v === null ? $ : v
const/**@type {FP.enu}*/enu = o => Object.keys(o)
const/**@type {FP.val}*/val = (v, f) => v === $ || v === null ? $ : f(v)
const/**@type {FP.add}*/add = (x, y) => x + y
const/**@type {FP.sub}*/sub = (x, y) => x - y
const/**@type {FP.cgt}*/cgt = (x, y) => x > y
const/**@type {FP.cge}*/cge = (x, y) => x >= y
const/**@type {FP.clt}*/clt = (x, y) => x < y
const/**@type {FP.cle}*/cle = (x, y) => x <= y
const/**@type {FP.int}*/int = s => cur(Number.parseInt(s, 10), n => Number.isNaN(n) ? $ : n)
/**@type {FP.fun}*/const fun = f => typeof f === 'function'
const/**@type {FP.run}*/run = (fs, ...args) => {fs.forEach(f => fun(f) && void f(...args)); return true}
const/**@type {FP.yes}*/yes = (...args) => run(args, $)
const/**@type {FP.mut}*/mut = (v, ...args) => run(args, v) && v
const/**@type {FP.del}*/del = (o, p) => mut(o, Reflect.deleteProperty(o, p))
const/**@type {FP.ass}*/ass = (x, v, c = Boolean) => {if (v === $ || !c(v)) throw new Error(x); return v}
const/**@type {FP.asa}*/asa = (p, v) => v instanceof p ? v : $
const/**@type {FP.rsc}*/rsc = (f, r) => {try {f($)} catch (x) {r(x)} return true}
const/**@type {FP.Interval}*/range = new (class { proxy = new Proxy($O, this); min = 0; max = 0; mini = false; maxi = false
/**@type {FP.Interval['has']}*/has = (_, x) => val(int(x), x => (this.mini ? cge : cgt)(x, this.min) && (this.maxi ? cle : clt)(x, this.max)) ?? false
})()
const/**@type {FP.rng}*/rng = (min, max, mini = true, maxi = true) => mut(range.proxy, Object.assign(range, {min, max, mini, maxi}))
const/**@type {FP.m_t}*/m_t = v => { if (typeof v === 'string') return v.length === 0
if (v === $ || typeof v === 'object') return v === $ || v === null
|| ['length', 'size'].some(n => n in v && cur(/**@type {OBJ}*/(v)[n], x => typeof x === 'number' && x === 0))
|| (Object.getPrototypeOf(v) === Object.prototype && Reflect.ownKeys(v).length === 0)
return typeof v === 'boolean' && !v
}
//const/**@type {FP.loo}*/loo = (m, c, a) => {let r; for (let n = 0; c($) && n < m; n++) r = a($); return r}
/**@template T*/const Pipe = /**@implements {FP.Pipeline}*/class PipeClass {
/**@type {FP.Pipeline<T>['proxy']}*/proxy = new Proxy(/**@type {this & Record<number, T | undefined>}*/(this), {get(t, n, r) {return cur(typeof n === 'string' && int(n), i => {
if (typeof i !== 'number' || i < 0) return Reflect.get(t, n, r)
let c = 0; for (const v of t) if (++c > i) return v; return $
})}})
constructor(/**@type {Iterable<T>}*/iterable) {this.iterable = iterable}
/**@type {FP.Pipeline<T>['me']}*/me(iterable) {return (new PipeClass(iterable)).proxy}
[Symbol.iterator]() {return this.iterable[Symbol.iterator]()}
/**@type {FP.Pipeline<T>['rdc']}*/rdc(i, f) {let ax = i; for (const v of this) ax = f(ax, v); return ax}
/**@type {FP.Pipeline<T>['any']}*/any(f) {for (const v of this) if (f(v)) return true; return false}
/**@type {FP.Pipeline<T>['all']}*/all(f) {for (const v of this) if (!f(v)) return false; return true}
/**@type {FP.Pipeline<T>['map']}*/map(f) {return this.me((function*(i, f) {for (const v of i) yield f(v)})(this, f))}
/**@type {FP.Pipeline<T>['sel']}*/sel(f) {return this.me((function*(i, f) {for (const v of i) if (f(v)) yield v})(this, f))}
}
const/**@type {FP.P}*/P = I => (new Pipe(I)).proxy
const/**@type {Cons.Wrap}*/CW = new (class {
/**@type {Cons.MS}*/ms = {w: 'warn', i: 'info', d: 'debug', l: 'log'}; w = this.gen('w'); i = this.gen('i'); d = this.gen('d'); l = this.gen('l')
/**@type {Cons.E}*/e = x => yes(void console.error(x))
/**@type {Cons.Wrap['gen']}*/gen(m) {return msg => yes(void console[this.ms[m]](`MBCHC: ${msg}`))}
})()
const/**@type {Settings.Methods}*/Settings = { // FIXME separate a proper V1 type from an unknown object in the ExtensionSettings
/**I hate change*/migrate_0_1(v0) { if (v0.MBCHC === $) return true
val(v0.MBCHC.timezones, tz => this.save(v1 => v1.TZ = {...tz, ...v1.TZ}))
W.ServerAccountUpdate.QueueData({OnlineSettings: del(v0, 'MBCHC')})
return CW.w('MBCHC: settings migration done (v0 -> v1). This should never happen again on the same account.')
},
save(f = $) {W.Player.ExtensionSettings['MBCHC'] ||= {}; f?.(this.v1); return yes(void W.ServerPlayerExtensionSettingsSync('MBCHC'))},
replace: v1 => yes(void (W.Player.ExtensionSettings['MBCHC'] = v1), void Settings.save()),
'purge!': () => Settings.replace(/** @type {Settings.V1} */({})),
get v0() {return Reflect.get(W.Player, 'OnlineSettings')},
get v1() {return mut(/**@type {Settings.V1}*/(W.Player.ExtensionSettings['MBCHC']) ?? {}, v1 => { // we need to check and repair the whole object every time we access it
v1.TZ ||= {}
})},
}
const/**@type {TZ_Cache}*/TZ = { map: new Map(), RE: /(?:gmt|utc)\s*([+-])\s*(\d\d?)/i,
parse: desc => val(desc, v => val(TZ.RE.exec(v), m => val(int(`${m[1] ?? $S}${m[2] ?? $S}`), n => n in rng(-12, 12) ? n : $))),
memo: (cid, desc = $) => val(Settings.v1.TZ[cid] ?? TZ.parse(desc), n => mut(n, TZ.map.set(cid, n))),
lookup: c => val(U.cid(c), cid => TZ.map.get(cid) ?? TZ.memo(cid, c.Description)),
}
const/**@type {Utils}*/U = { remove_loader_hook: $, RGB: {Polly: '#81b1e7', Mute: '#6c2132'}, ACT: `${CommandsKey}activity `,
RE: { SPACES: /\s+/gu, REL: {L: /^<+$/, R: /^>+$/}, '@': [/^@/, /^@@/] },
get crc() {return W.ChatRoomCharacter},
get ic() {return asa(HTMLTextAreaElement, D.querySelector('#InputChat'))},
cid: c => c.MemberNumber,
dn: c => W.CharacterNickname(c),
current: () => `${W.CurrentModule}/${W.CurrentScreen}`,
style: (q, f) => cur(D.querySelector(q), e => e instanceof HTMLElement ? f(e.style) : $),
inform: html => yes(void W.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`)),
report: x => U.inform(String(x)) && CW.e(x),
split: text => text.split(U.RE.SPACES),
abs2char: pos => ass(`invalid position ${pos}`, U.crc[pos]),
rel2char: t => cur(ass('can\'t find my position', U.crc.findIndex(char => char.IsPlayer()), n => n >= 0), me =>
cur(ass(`failed to parse target "${t}"`, U.RE.REL.L.test(t) ? sub : U.RE.REL.R.test(t) ? add : $)(me, t.length), pos =>
cur(pos % U.crc.length, p => U.abs2char(p < 0 ? p + U.crc.length : p)))),
cid2char: id => id === U.cid(W.Player) ? W.Player : ass(`character ${id} not found in the room`, U.crc.find(c => id === U.cid(c))),
target2char(target) { let t = target.trim(); const /**@type {Set<Character>}*/f = new Set() // FIXME Target should be low case (take a look at this later)
if (m_t(t)) return W.Player
if (t.at(0) === '=') return U.cid2char(ass(`invalid member number "${target}"`, int(t.slice(1))))
if ('<>'.includes(t.at(0) ?? '-')) return U.rel2char(t)
const n = int(t)
if (n !== $ && n.toString() === t) { // We got a number
if (n in rng(0, 9)) return U.abs2char(n)
if (n in rng(11, 15)) return U.abs2char(n - 11)
if (n in rng(21, 25)) return U.abs2char(n - 16)
U.crc.filter(c => U.cid(c)?.toString().includes(t)).forEach(c => f.add(c)) // or union with `new Set(array)`
}
if (t.at(0) === '@') t = t.slice(1)
U.crc.filter(c => c.Name.toLocaleLowerCase().includes(t)).forEach(c => f.add(c))
U.crc.filter(c => val(c.Nickname, nn => nn.toLocaleLowerCase().includes(t))).forEach(c => f.add(c))
const found = [...f.keys()]
ass(`target "${target}": multiple matches (${found.map(c => `${U.cid(c)}|${c.Name}|${c.Nickname ?? c.Name}`).join(',')})`, found.length, n => n < 2) // make the list better
return ass(`target "${target}": no match`, found[0])
},
mkdiv: html => mut(D.createElement('div'), e => html === $ || (e.innerHTML = html)),
bell: () => yes(U.style('#InputChat', s => s.outline = 'solid red'), void setTimeout(() => {U.style('#InputChat', s => s.outline = $S)}, 100)),
targets: (reject_player = false, check_perms = false) => mut(new Set(), r => {
const wrap_text = (/**@type {string}*/text) => int(text) === $ ? text : `@${text}`
const wrap_int = (/**@type {number}*/i) => i in rng(0, 9) || i in rng(11, 15) || i in rng(21, 25) ? `=${i}` : i.toString()
U.crc.filter(c => !(reject_player && c.IsPlayer()) && !(check_perms && !W.ServerChatRoomGetAllowItem(W.Player, c))).forEach(c => {
U.split(c.Name).forEach(t => r.add(wrap_text(t)))
val(c.Nickname, n => U.split(n))?.forEach(t => r.add(wrap_text(t)))
val(U.cid(c), cid => r.add(wrap_int(cid)))
})
r.delete($S)
}),
complete_mbchc() {C.icomplete(ws =>
ws.length < 2 ? new Set([`${CommandsKey}${this.Tag}`])
: ws.length < 3 ? new Set(Object.keys(SUBCOMMANDS_MBCHC)) // FIXME no idea why enu() thinks there's a number in there
: val(ws[1], w => val(SUBCOMMANDS_MBCHC[w], sub => val(sub.args, as => val(enu(as)[ws.length - 3], a =>
a === 'TARGET' ? U.targets(true) : a === '[TARGET]' ? U.targets() : $)))) ?? $Ss
)},
complete_do_target(actions) {
if (m_t(actions)) return $Ss
if (m_t(actions.others)) return val(U.cid(W.Player), cid => new Set([cid.toString()])) ?? $Ss // Target is always the player
return U.targets(m_t(actions.self), true)
},
complete_do() {C.icomplete(ws => {
if (ws.length < 2) return new Set([`${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 = (ws[1] ?? $S).toLocaleLowerCase()
const DD = W.MBCHC.DO_DATA
if (ws.length < 3) return new Set(enu(DD.verbs).map(String)) // Complete verb
const ags = DD.verbs[low]
if (ags === $) return $Ss
low = (ws[2] ?? $S).toLocaleLowerCase()
if (ws.length < 4) { // Complete zone or target
if (enu(ags).length < 2) return val(ags[enu(ags)[0] ?? $S], t => U.complete_do_target(t)) ?? $Ss // Zone implied, complete target
return new Set(Object.entries(DD.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone, _ag]) => zone))
}
if (ws.length < 5) { // Complete target where it belongs
if (enu(ags).length < 2) return $Ss // Zone implied, target already given
return val(ags[DD.zones[low] ?? $S], t => U.complete_do_target(t)) ?? $Ss
}
return $Ss
})},
replace_me: (_, __, whole) => cur(whole.slice(1), t => `${U.ACT}<${U.cid(W.Player)}:>SourceCharacter${t.startsWith('\'') || t.startsWith(' ') ? $S : ' '}`),
scroll: () => yes(void W.ElementScrollToEnd('TextAreaChatLog')),
get scrolled() {return W.ElementIsScrolledToEnd('TextAreaChatLog')},
rescroll: f => cur(U.scrolled, s => yes(f, s && U.scroll())),
}
const/**@type {SUBCOMMANDS}*/SUBCOMMANDS_MBCHC = {
autohack: {desc: 'toggle the autohack feature', cb: mbchc => void U.inform(`Autohack is now ${((mbchc.AUTOHACK_ENABLED = !mbchc.AUTOHACK_ENABLED)) ? 'enabled' : 'disabled'}`)},
donate: {desc: 'buy data and send it to recipient', args: {TARGET: $O}, cb: (mbchc, args) => void mbchc.donate_data(args[0] ?? $S)},
tz: {desc: 'set target\'s UTC offset', args: {OFFSET: $O, '[TARGET]': $O}, cb: (mbchc, args) => mbchc.set_timezone(args)},
'purge!': {desc: 'delete MBCHC online saved data', cb: () => Settings['purge!']()},
}
const/**@type {Complete}*/C = { S_OPTS: {behavior: 'instant'},
e: mut(Object.assign(U.mkdiv(), {id: 'mbchcCompHint'}), div => void div.append(U.mkdiv())),
get div() {return C.e.firstElementChild ?? $},
lcp: cs => m_t(cs) ? $S : mut({p: ''}, r => void cur(P(cs), P => {for (let on = true, i = Math.max(...P.map(o => o.length)); on && i > 0; i -= 1) {
cur((P[0] ?? $S).slice(0, i), p => P.all(o => o.startsWith(p)) && yes(r.p = p) && (on = false))
}})).p,
complete_word: (i, cs, ci = false) => cur(void cs.forEach(c => (ci ? c.toLocaleLowerCase() : c).startsWith(ci ? i.toLocaleLowerCase() : i) || cs.delete(c)), _ => cs.size < 2 ? P(cs)[0] ?? i : C.lcp(cs)),
hint: cs => m_t(cs) || yes(_ => {
yes(C.e.style.display = 'block') && C.div?.replaceChildren(...[...cs].sort().reverse().map(U.mkdiv))
W.ElementSetDataAttribute(C.e.id, 'colortheme', W.Player.ChatSettings?.ColorTheme ?? 'Light')
U.rescroll(_ => void W.ChatRoomResize(false))
C.div?.lastElementChild?.scrollIntoView(C.S_OPTS)
}),
get hidden() {return C.e.parentElement === null || C.e.style.display === 'none'},
hide: () => C.hidden || yes(C.e.style.display = 'none', void W.ChatRoomResize(false)),
complete(f, ci = false) {
const e = ass('Somehow #InputChat is broken', U.ic)
if (e.selectionStart !== e.selectionEnd) return U.bell()
const input = e.value
const before_cursor = input.slice(0, e.selectionStart)
const words = U.split(before_cursor)
const cs = f(words)
const word = words.at(-1) ?? ''
const cword = C.complete_word(word, cs, ci)
const additions = cword.slice(word.length)
if (!m_t(additions)) e.setRangeText(additions, e.selectionStart, e.selectionEnd, 'end')
if (cs.size > 1) C.hint(cs)
if (cs.size === 1) e.setRangeText(' ', e.selectionStart, e.selectionEnd, 'end')
if (cs.size === 0) U.bell()
return true
},
icomplete: f => C.complete(f, true),
}
const/**@type {InputHistory}*/H = { input: undefined, ids: undefined, bottom: undefined, // FIXME ids don't need to be a set, but I'm too tired right now
key: {
Escape: (_, ic) => yes(val(H.input, i => ic.value = i)),
ArrowLeft: (_, ic) => yes(void ic.setSelectionRange(0, 0)),
},
icro: (ic, ro) => yes(ic.readOnly = ro, val(ic.parentElement?.parentElement?.dataset, d => ro ? d['mbchcMode'] = 'h' : del(d, 'mbchcMode'))),
enter: (ic, i, b, is) => yes(H.input = i, H.bottom = b, H.ids = is, H.icro(ic, true), b && U.scroll()),
exit: (ic, e) => yes(H.icro(ic, false), H.key[e.key]?.(e, ic), val(H.bottom, b => b && U.scroll()), W.ChatRoomLastMessageIndex = W.ChatRoomLastMessage.length)
}
ass('MBCHC found, aborting loading', W.MBCHC === $)
ass('AsylumGGTSSAddItems() not found, aborting MBCHC loading', W.AsylumGGTSSAddItems)
const sdk = ass('SDK not found, please load with (or after) FUSAM or any other mod that uses SDK', W.bcModSdk)
const mod = sdk.registerMod({name: 'MBCHC', fullName: 'Mute\'s Bondage Club Hacks Collection', version, repository: 'https://code.fleshless.org/mute/MBCHC/'})
const/**@type {SDK.Hook}*/prior = (name, f) => mod.hookFunction(name, 0, (na, n) => rsc(_ => f(...na), CW.e) && n(na)) // eslint-disable-line @typescript-eslint/no-unsafe-return
const/**@type {SDK.Hook}*/after = (name, f) => mod.hookFunction(name, 0, (na, n) => mut(n(na), rsc(_ => f(...na), CW.e))) // eslint-disable-line @typescript-eslint/no-unsafe-return
// ^ type-safe, new and much improved, but still work in progress
// =================================================================================
// v legacy mess, also type-safe now?
/**@type {Window['MBCHC']}*/W.MBCHC = {
version, /** Just in case someone used it for anything @deprecated*/VERSION: version,
Settings, TZ,
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
LOADED: false,
AUTOHACK_ENABLED: false,
/**@type {number | undefined}*/LAST_HACKED: $,
RE_PREF_ACTIVITY_ME: /^@/,
RE_PREF_ACTIVITY: /^@@/,
RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/,
RE_LAST_WORD: /(^|\s)(\S*)$/,
RE_LAST_LETTER: /\w$/,
RE_ACTIVITY: new RegExp(`^${CommandsKey}activity `),
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'],
},
DO_DATA: {verbs: {}, zones: {}},
calculate_maps() {
for (const [verbs, data] of Object.entries(this.MAP_ACTIONS)) {
const/**@type {{[k: string]: {self: string[]; others: string[]}}}*/unwound = {}
for (const [zones, actions] of Object.entries(data)) {
const all = val(actions.all, a => a.split('|')) ?? []
const processed = {self: val(actions.self, s => [...s.split('|'), ...all]) ?? all, others: val(actions.others, o => [...o.split('|'), ...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
},
normalise_message(/**@type {string}*/text, /**@type {OBJ<boolean>}*/options = $O) {
let result = text
if (options['trim'] ?? false) result = result.trim()
if (options['low'] ?? false) result = result.toLocaleLowerCase()
if (options['up'] ?? false) {
const first = result.at(0)?.toLocaleUpperCase() ?? $S
const rest = result.slice(1)
result = first + rest
}
if ((options['dot'] ?? false) && this.RE_LAST_LETTER.test(result)) result = `${result}.`
return result
},
donate_data(/**@type {string}*/target) {
const char = U.target2char(target)
ass('target must not be you', !char.IsPlayer())
ass('target must be bound', char.IsRestrained())
const cost = Math.round(((Math.random() * 10) + 15))
ass('not enough money', W.Player.Money >= cost)
W.CharacterChangeMoney(W.Player, -cost)
W.ServerSend('ChatRoomChat', {Content: 'ReceiveSuitcaseMoney', Type: 'Hidden', Target: U.cid(char)})
W.ChatRoomMessage({Sender: ass('...', U.cid(W.Player)), Type: 'Action', Content: `You've bought data for $${cost} and sent it to ${U.dn(char)}.`, Dictionary: [MISSING_PLAYER_DIALOG]})
},
run_activity(/**@type {Character}*/char, /**@type {AssetGroupItemName}*/ag, /**@type {ActivityName}*/action) {
try {
ass('activities disabled in this room', W.ActivityAllowed())
ass('no permissions', W.ServerChatRoomGetAllowItem(W.Player, char))
char.FocusGroup = ass('invalid AssetGroup', n2u(W.AssetGroupGet(char.AssetFamily, ag)))
const activity = ass('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 // eslint-disable-line unicorn/no-null
}
},
send_activity(/**@type {string}*/message) { let Content = message; const /**@type {ChatMessageDictionary}*/Dictionary = [MISSING_PLAYER_DIALOG]
val(this.RE_ACT_CIDS.exec(Content), cids => {
Content = Content.replace(this.RE_ACT_CIDS, $S)
val(cids[1], cid => val(int(cid), n => Dictionary.push({SourceCharacter: n})))
val(cids[2], cid => val(int(cid), n => Dictionary.push({TargetCharacter: n})))
})
W.ServerSend('ChatRoomChat', {Type: 'Action', Content, Dictionary})
},
set_timezone(/**@type {string[]}*/args) {
const tz = ass(`invalid offset "${args[0]}"`, int(args[0] ?? $S))
ass('offset should be [-12,12]', tz in rng(-12, 12))
const char = U.target2char(args[1] ?? $S)
return val(U.cid(char), cid => Settings.save(v1 => v1.TZ[cid] = tz) && TZ.memo(cid))
},
command_mbchc(argline, cmdline, args) {
const mbchc = W.MBCHC
try { // `this` is command object
if (m_t(args)) return void U.inform(Object.entries(SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `<div>/mbchc ${cmd} ${val(sub.args, a => enu(a).join(' ')) ?? $S}: ${sub.desc}</div>`).join($S))
const cmd = String(args.shift())
const sub = ass(`unknown subcommand "${cmd}"`, SUBCOMMANDS_MBCHC[cmd])
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
} catch (x) {
U.report(x)
}
},
command_activity(argline, cmdline, _) {
const mbchc = W.MBCHC
if (!m_t(argline.trim())) {
try { // `this` is command object
const message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, $S), {trim: true, dot: true, up: true})
mbchc.send_activity(message)
} catch (x) {
U.report(x)
}
}
},
command_do(_argline, _cmdline, args) {
const mbchc = W.MBCHC
try { // `this` is command object
if (m_t(args)) return void U.inform('<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>' + enu(mbchc.MAP_ACTIONS).join(', ') + '<div>Available zones:</div>' + enu(mbchc.DO_DATA.zones).join(', '))
let [verb, zone, target] = args
const zones = ass(`unknown verb "${verb}"`, mbchc.DO_DATA.verbs[verb ?? $S])
if (enu(zones).length === 1) {
if (target === $) target = zone
zone = mbchc.MAP_ZONES[enu(zones)[0] ?? $S]?.[0]
}
zone = ass('zone missing', zone)
const ag = ass(`unknown zone "${zone}"`, mbchc.DO_DATA.zones[zone])
const types = ass(`zone "${zone}" invalid for "${verb}"`, zones[ag])
let/**@type {Character}*/char = W.Player
if (target !== $ && (m_t(types.self) || !m_t(types.others))) char = U.target2char(target)
const type = char.IsPlayer() ? 'self' : 'others'
const available = W.ActivityAllowedForGroup(char, /**@type {AssetGroupItemName}*/(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 = ass(`zone "${zone}" invalid for ("${verb}" "${type}")`, types[type])
const action = ass(`invalid action (${verb} ${zone} ${target})`, actions.find(name => available.find(a => a.Activity?.Name === name)))
mbchc.run_activity(char, /**@type {AssetGroupItemName}*/(ag), /**@type {ActivityName}*/(action))
} catch (x) { U.report(x) }
},
loader() {
U.remove_loader_hook === $ || yes(void U.remove_loader_hook(), U.remove_loader_hook = $)
if (this.LOADED) return
val(Settings.v0, v0 => Settings.migrate_0_1(v0))
// Calculated values
const/**@type {ICommand[]}*/COMMANDS = [
{Tag: 'mbchc', Description: ': Utility functions ("/mbchc" for help)', Action: this.command_mbchc, AutoComplete: U.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: U.complete_do},
]
const/**@type (e: Event) => void*/css_hook = e => void cur(/**@type {HTMLStyleElement}*/(e.target), css => U.scroll() && void css.removeEventListener('load', css_hook))
mut(D.createElement('style'), c => void D.head.append(c), c => void c.addEventListener('load', css_hook), c => c.textContent = `
#TextAreaChatLog .mbchc { background-color: ${U.RGB.Polly}; margin-left: -0.4em; padding-left: 0.4em; }
#TextAreaChatLog[data-colortheme^="dark"] .mbchc { background-color: ${U.RGB.Mute}; }
#${C.e.id} { display: none; text-align: right; }
#${C.e.id} > div { overflow: auto; position: absolute; bottom: 0; right: 0; max-height: 100%; padding: 0 0.5ex; background-color: ${U.RGB.Polly}; color: black; }
#${C.e.id}[data-colortheme^="dark"] > div { background-color: ${U.RGB.Mute}; color: white; }
#${C.e.id} > div div { margin: 0.25ex 0; }
#chat-room-div #TextAreaChatLog::before { content: ''; display: block; height: 100%; background: repeating-linear-gradient(135deg, transparent 0 20px, #333 2px 22px); }
#chat-room-div[data-mbchc-mode="h"] #TextAreaChatLog::after {
content: '⟨𝘗𝘨𝘜𝘱/𝘋𝘯/↕⟩ 𝗌𝖼𝗋𝗈𝗅𝗅 ⇅ ⟨𝘌𝘯𝘵𝘦𝘳⟩ 𝗌𝖾𝗇𝖽 ↵ ⟨𝘛𝘢𝘣/↔/⌫⟩ 𝖾𝖽𝗂𝗍 ⌨ ⟨𝘌𝘴𝘤⟩ 𝖺𝖻𝗈𝗋𝗍 ⟲\\A' attr(data-mbchc-h-h); whitespace: pre;
display: block; position: sticky; z-index: 1; bottom: 0; background: black; color: orange; padding: 0 0.2ex; animation: 0.2s cubic-bezier(0.19, 1, 0.22, 1) mbchc_hh_show;
}
#InputChat:read-only { background: black; color: orange; }
@keyframes mbchc_hh_show { from {transform: translateX(-100%);} to {transform: translateX(0);} }
`) // will always scroll the chat on CSS load, I can't be fucked to make it conditional
// Actions
this.calculate_maps()
W.CommandCombine(COMMANDS)
// Hooks
after('ChatRoomReceiveSuitcaseMoney', () => {
if (this.AUTOHACK_ENABLED && this.LAST_HACKED !== $) {
W.CurrentCharacter = U.cid2char(this.LAST_HACKED)
this.LAST_HACKED = $
W.ChatRoomTryToTakeSuitcase()
}
})
prior('ChatRoomSendChat', () => val(U.ic, ic => !ic.value.startsWith('@@@') && ic.value.startsWith('@') && (ic.value = ic.value.replace(U.RE['@'][1], U.ACT).replace(U.RE['@'][0], U.replace_me))))
after('ChatRoomSendChat', () => { // FIXME actually make history a ring buffer of a given size. clear the array and push every string into it, compacting sequential equal strings into one.
const history = W.ChatRoomLastMessage
if ((history.length > 1) && (history.at(-1) === history.at(-2))) {
history.pop()
W.ChatRoomLastMessageIndex -= 1
}
})
prior('ChatRoomCharacterViewDrawOverlay', (C, CX, CY, Z) => {
// if (w.ChatRoomHideIconState < 1 && C.MBCHC) {
// w.DrawRect(CharX + (175 * Zoom), CharY, 50 * Zoom, 50 * Zoom, C.MBCHC.VERSION === w.Player.MBCHC.VERSION ? U.RGB.Polly : U.RGB.Mute)
// }
val(TZ.lookup(C), ctz => W.ChatRoomHideIconState < 1 && void cur(new Date(W.CommonTime() + this.UTC_OFFSET + (ctz * 60 * 60 * 1000)).getHours(), hr =>
void W.DrawTextFit(`${hr < 10 ? '0' : ''}${hr}`, CX + (Z * 200), CY + (Z * 25), Z * 46, 'white', 'black')))
})
const/**@type {(this: HTMLTextAreaElement, e: KeyboardEvent) => void}*/ickd = function(e) {
C.hide()
const ic = this // eslint-disable-line unicorn/no-this-assignment,@typescript-eslint/no-this-alias
if (ic.readOnly && !e.repeat) switch (e.key) { // FIXME maybe deal with modifiers
case 'ArrowUp': case 'ArrowDown': W.ChatRoomScrollHistory(e.key === 'ArrowUp'); break
case 'Escape': case 'Tab': case 'ArrowRight': case 'ArrowLeft':
e.stopImmediatePropagation()
e.preventDefault() // falls through
case 'Enter': case 'Backspace': H.exit(ic, e) // no default
}
}
after('ChatRoomCreateElement', () => { // This thing runs on every frame actually.
C.e.parentElement ?? D.body.append(C.e)
val(U.ic, ic => ic.dataset['mbchc'] ?? void ic.addEventListener('keydown', ickd) ?? (ic.dataset['mbchc'] = 'yes'))
})
prior('ChatRoomClearAllElements', () => C.hide() && void C.e.remove())
D.addEventListener('click', _ => C.hide()) // downstream handlers can capture clicks, but I can't be fucked to be honest
after('ChatRoomResize', () => {
if (W.CharacterGetCurrent() === null && W.CurrentScreen === 'ChatRoom' && U.ic !== $ && D.querySelector('#TextAreaChatLog') !== null && !C.hidden) { // Upstream
W.ElementPositionFix(C.e.id, ChatRoomFontSize, 800, 65, 200, 835)
}
})
mod.hookFunction('ChatRoomScrollHistory', 0, ([up]) => void val(U.ic, ic => { const input = ic.value, history = W.ChatRoomLastMessage
// FIXME we'll make it better when the history is a proper ring buffer
if (!ic.readOnly) {
const/**@type {Map<string,number>}*/map = new Map()
const/**@type {(l: string, i: number, I: string) => boolean}*/ cond = m_t(input) ? (_, i, __) => i > 0 : (l, _, i) => l !== i && l.startsWith(i)
if (m_t(history.reduce((ax, l, i) => cond(l, i, input) ? ax.set(l, i) : ax, map))) return U.bell()
val(asa(HTMLDivElement, D.querySelector('#TextAreaChatLog')), t => t.dataset['mbchcHH'] = `𝗵𝗶𝘀𝘁𝗼𝗿𝘆: ${m_t(input) ? 'Everything' : `Prefix: ${input}`}`)
H.enter(ic, input, U.scrolled, new Set(map.values()))
}
if (ic.readOnly) { // this can't be an else, because we mutate state above. To be honest, this will always be true, but I want to make sure.
if (H.ids === $) return U.bell() // shouldn't happen?
let found = -1
let first = -1
let last = -1
for (const i of H.ids) { // these aren't necessarily in order
if (up) {
if (i < W.ChatRoomLastMessageIndex && i > found) found = i // the largest i that is less than index
if (i > last) last = i
} else {
if (first < 0 || i < first) first = i
if (i > W.ChatRoomLastMessageIndex && (i < found || found < 0)) found = i // the smallest i that is greater than index
}
}
if (found < 0) found = up ? last : first
const line = history[found] ?? $S
if (line === input) return U.bell()
ic.value = line
W.ChatRoomLastMessageIndex = found
}
return true
}))
// 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 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
CW.i(`loaded version ${version}`)
},
}
U.current() === 'Character/Login' ? U.remove_loader_hook = prior('AsylumGGTSSAddItems', () => void W.MBCHC.loader()) : W.MBCHC.loader()
const/**@type {BCE.Patcher}*/BP = { timer: undefined, patches: [[/^\^('s)?( )?/g, '^SourceCharacter$1\\s+'], [/([^\\])\$/g, '$1\\.?$$']],
cfs: { anim: () => enu(W.bce_EventExpressions ?? $O), pose: () => W.PoseFemale3DCG.map(p => p.Name) },
gen(f) {return function() {C.icomplete(ws => ws.length < 2 ? new Set([`${CommandsKey}${this.Tag}`]) : ws.length < 3 ? new Set(f()) : $Ss)}},
copy: t => ({Type: 'Action', Event: t.Event, Matchers: t.Matchers.map(m => ({Tester: new RegExp(BP.patches.reduce((ax, [f, r]) => ax.replaceAll(f, r), m.Tester.source), $S)}))}),
patch: () => val(W.bce_ActivityTriggers, ts => val(W.FBC_VERSION, _ =>
yes(void clearInterval(BP.timer), void enu(BP.cfs).forEach(t => val(W.GetCommands().find(c => t === c.Tag), cmd => cmd.AutoComplete = BP.gen(BP.cfs[t]))), void ts.forEach(t => t.Type === 'Emote' && ts.push(BP.copy(t))))
)),
}
BP.timer = W.setInterval(BP.patch, 100)

5818
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,94 +0,0 @@
{
"name": "mbchc",
"version": "108.13.1",
"description": "Mute's Bondage Club Hacks Collection",
"author": "Mute",
"private": true,
"type": "module",
"devDependencies": {
"bc-stubs": "^108.0.0",
"bondage-club-mod-sdk": "^1.2.0",
"typescript": "^5.5.2",
"xo": "^0.56.0"
},
"license": "SEE LICENSE IN LICENSE",
"eslintConfig": {
"root": true,
"extends": ["xo", "xo-typescript", "plugin:unicorn/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": ["./jsconfig.json"] },
"plugins": ["@typescript-eslint", "unicorn"],
"rules": {
"@typescript-eslint/brace-style": "off",
"@typescript-eslint/comma-dangle": ["error", "only-multiline"],
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/lines-between-class-members": "off",
"@typescript-eslint/member-delimiter-style": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-confusing-void-expression": ["error", {
"ignoreVoidOperator": true
}],
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-meaningless-void-operator": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/object-curly-spacing": "off",
"@typescript-eslint/padding-line-between-statements": "off",
"@typescript-eslint/semi": "off",
"@typescript-eslint/space-before-function-paren": "off",
"@typescript-eslint/strict-boolean-expressions": ["error", {
"allowString": false,
"allowNumber": false,
"allowNullableObject": false
}],
"array-element-newline": "off",
"brace-style": "off",
"camelcase": "off",
"capitalized-comments": "off",
"complexity": "off",
"curly": "off",
"generator-star-spacing": "off",
"max-nested-callbacks": "off",
"max-params": "off",
"max-statements-per-line": "off",
"new-cap": "off",
"no-mixed-spaces-and-tabs": ["error", "smart-tabs"],
"no-return-assign": "off",
"no-unused-expressions": "off",
"no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
}],
"no-void": "off",
"padding-line-between-statements": "off",
"object-curly-newline": "off",
"one-var": "off",
"one-var-declaration-per-line": "off",
"semi": "off",
"space-before-function-paren": "off",
"spaced-comment": "off",
"unicorn/catch-error-name": ["error", {"name": "x"}],
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-callback-reference": "off",
"unicorn/no-array-for-each": "off",
"unicorn/no-array-reduce": "off",
"unicorn/no-nested-ternary": "off",
"unicorn/prevent-abbreviations": ["error", {
"allowList": {"cur": true, "args": true, "func": true, "val": true, "mod": true, "msg": true, "i": true, "e": true}
}],
"unicorn/switch-case-braces": ["error", "avoid"],
"fake/fuck-commas": "off"
},
"overrides": [
{
"files": ["*.d.ts"],
"rules": {
"@typescript-eslint/semi": "error",
"no-unused-vars": "off"
}
}
]
}
}

View File

@ -1,31 +0,0 @@
import {readFileSync} from 'node:fs'
import {createServer} from 'node:http'
import {argv} from 'node:process'
const config = {host: '127.0.0.1', port: 9696, filename: argv[2] ?? 'mbchc.mjs'}
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 = {
'Content-Type': 'text/javascript',
'Cache-Control': 'no-cache',
...h_cors
}
/** @type {Record<string,((request: import('node:http').ServerResponse) => void)>} */ const resp = {
GET(rx) { rx.writeHead(200, h_all); rx.write(readFileSync(config.filename)) },
OPTIONS(rx) { rx.writeHead(204, h_cors) },
}
const server = createServer((rq, rx) => {
const handler = resp[rq.method ?? '']
handler !== undefined && (new URL(`http://${config.host}:${config.port}${rq.url}`)).pathname === '/' ? handler(rx) : rx.writeHead(400)
rx.end(() => void console.log('%s %d %s %s', (new Date()).toISOString(), rx.statusCode, rq.method, rq.url))
})
server.listen(config.port, config.host, () => void console.log(`Server started at http://${config.host}:${config.port} for ${config.filename}`))