Compare commits

...

81 Commits
v.1 ... master

Author SHA1 Message Date
07c1fad5f3 Merge remote-tracking branch 'origin/testing'
108.13.1 mainly history fixes and improvements
2024-09-19 14:24:36 +00:00
78e2d6ebda R108 2024-09-19 14:11:26 +00:00
9a646716bf 107.13.1 scroll on css (works now), also made spacer visible 2024-09-08 15:31:33 +00:00
b9b1226e60 history improvements: backspace passthrough, hint rearranged 2024-09-06 21:34:29 +00:00
fb700c91cf better history hint 2024-09-04 15:30:03 +00:00
ee85533bd1 additional keys for history mode 2024-09-03 16:34:24 +00:00
c3d47fec77 Merge branch 'testing' 2024-08-26 14:52:29 +00:00
7e207b7e93 Indentation, amirite 2024-08-26 14:44:27 +00:00
1eaffc46be Adding elements to chat on every frame proved unwise, also made spellchecker happy. 2024-08-24 03:31:50 +00:00
966328327e stubs 107; history fixes 2024-08-22 18:13:04 +00:00
fe4248eb8b hotfix: R107 makes the check redundant (it was never a great idea tbh) 2024-08-18 18:32:09 +00:00
6034adae3c 107.13.0
another absolutely massive diff
* Disabled keyboard handler for focus management, the club got better at this
* Input history redone entirely, the code contains comments
* Completion now takes cursor position into account
* Dropped autoremoval of service messages in favour of future manual deletion
* Tons of typechecking cleanup and style improvements
* The code now has zero errors on the strictest TS settings I know
2024-08-18 01:15:56 +00:00
16308eccf1 105.13.0 semver; settings migration
[lots of internal change]
I'm determined to clean this mess up after all.
Instead of a total rewrite (which didn't work), I'll migrate the
code to JSDoc piece by piece. This is the first piece, setting up
some basic framework and testing out this and that.
Until I figure out how test this thing, I'll just ask peeps to import
the module from testing branch I guess.
Probably not gonna deal with anything new until I can turn "strict" on.
2024-07-13 21:55:45 +00:00
d87bfde6f9 Update README.md 2024-06-29 17:18:15 +00:00
90231cb2ae dev.12: R105 fixes
a massive diff due to infrastructure work
the completion pane moved to the left of chat
no other intended changes for users
2024-06-29 17:09:25 +00:00
b0961f4fb8 dev.11 - @ and @@ fix 2024-06-05 17:33:55 +00:00
d30945201d dev.10
* Removed the patch to disable FBC tighten/loosen input, it wasn't doing anything for a while now.
* Come on peeps, deprecation doesn't mean what you think it means.
2024-02-29 18:17:49 +00:00
378d0af8d3 dev.9
* Fixed MISSING PLAYER DIALOG in donate subcommand
* Removed the club version check, it really doesn't do anything
* Removed the broken disappear subcommand
* Removed the mostly useless title subcommand
* Removed the versions subcommand, it was superceded by /versions
* Took another dive in the keystroke catcher, should fix all known conflicts
* Disabled the indicator square
* Thought about moving the time digits to a proper place, but ultimately decided to leave them be for now
2023-12-19 01:15:39 +00:00
526b51e158 ASHWGA, might as well make it a module.
Included is a minimal linter infrastructure
and a server script because [epic rant removed]
2023-12-17 03:52:43 +00:00
ff377fb709 Update 'README.md' 2023-06-18 02:15:22 +00:00
e4f7ce0560 dev.8
* R93
* /do fix
2023-06-18 02:07:49 +00:00
2bf6695af0 no changes, unindent by one level 2023-06-18 02:00:47 +00:00
0f1195d92b no changes beyond indentation 2023-06-18 01:51:26 +00:00
d3852d6e63 massive diff: no changes, just reindent 2022-12-04 14:16:28 +00:00
2f87b283ed dev.7
* R86
* external sdk
* sdk 1.1.0
* /activity fix
2022-11-30 12:10:04 +00:00
89eafdfab3 trunk: sdk 1.1.0 2022-11-28 23:48:08 +00:00
0d1f074c43 sdk 1.1.0 2022-11-28 23:17:26 +00:00
247134e04b external sdk; /activity fix 2022-11-21 21:50:38 +00:00
46fb29bcec let's factor out sdk into an external js 2022-11-21 09:25:52 +00:00
45f6f80493 dev.6
* R86Beta1 compatibility
* patch by Lyra for /do item actions
* removed handheld penetration patch until R86 release
* time zone regexp accepts spaces now
* fixed a bug with UTC+0
* better global keydown interception
* exception handlers for hooks, shouldn't break the whole club now if something goes wrong
* general cleanups and refactor
2022-11-13 16:40:33 +00:00
eaf4e3f26f so refactor, much changes
mostly improved syntax
added exception handling for hooks
hopefully fixed keydown interceptor
2022-11-12 13:06:54 +00:00
337eb83d47 Update 'mbchc-local.user.js'
/me grumbles
2022-11-11 02:14:59 +00:00
3a97857fc1 more beta fixes, removed the penetration patch for now 2022-11-11 00:03:09 +00:00
a9c6e5fd35 R86Beta1 2022-11-10 22:22:12 +00:00
247c908f46 tz fix + allow spaces
0 is falsy, who would have thunk. Will now accept spaces like "UTC - 7" because people do write like this.
2022-11-10 12:07:51 +00:00
1cc9ac18d7 Update 'mbchc-local.user.js' 2022-11-03 18:39:43 +00:00
e690960782 Update 'mbchc-local.user.js' 2022-11-03 18:20:44 +00:00
634eddbd2a Update 'mbchc-local.user.js'
patch by Lyra, fixes `/do item` behaviour
2022-11-03 18:17:20 +00:00
d43e3cdeed Update 'AUTHORS' 2022-11-03 18:05:13 +00:00
5c1d2f6397 dev.5
I thought I was being clever with Intl API, but it was a mistake, had to make it dumb and simple instead. Fixes time display with non-US locales.
2022-10-27 22:50:08 +00:00
5c534b1537 Update 'mbchc-local.user.js'
locales are bullshit
2022-10-27 17:27:42 +00:00
41b2029efd dev.4
chat room handlers instead of a function hook, this fixes communication breakdown
2022-10-24 18:03:26 +00:00
f911c05073 Update 'mbchc-local.user.js'
chat room handlers
2022-10-22 12:07:11 +00:00
8d1516f1a7 dev.3
backported trunk version
no new functionality
fixes and cleanups
2022-10-20 11:02:02 +00:00
3e3f891016 R85 update 2022-10-18 11:46:16 +00:00
ef41dd1dce maintenance update
* s/bce/fbc where is makes sense
* quick and dirty keydown filtering
* R84
* penetrators now use AllowActivity instead of Attribute
2022-09-29 18:00:14 +00:00
4930e5111b Update 'mbchc-local.user.js' 2022-08-11 00:56:40 +00:00
c76ad63d96 Update 'mbchc-dev.user.js'
disallow /do in rooms with blocked activities; autocomplete improvements
2022-07-20 23:30:36 +00:00
18095495bb Update 'mbchc-dev.user.js'
BCE expressions needed more love; also stripped a space in chats like "@'s ... "
2022-07-20 21:27:20 +00:00
faf4e95499 Update 'mbchc-dev.user.js'
well this is embarrassing
2022-07-20 20:06:24 +00:00
839ad8f0b3 Update 'mbchc-dev.user.js'
let's try this again
2022-07-20 20:01:35 +00:00
62def6bb3e sdk conflict hopefully fixed 2022-07-20 11:48:24 +00:00
3f4e43adbc Update 'README.md' 2022-07-20 08:21:10 +00:00
29e9d84ceb Update 'mbchc-dev.user.js'
BCE IM support
2022-07-20 08:14:32 +00:00
b99e28502b Update 'mbchc-dev.user.js'
well fuck
2022-07-20 08:01:47 +00:00
621dea9959 dev.2
an entirely new script, very little in common with dev.1
2022-07-20 06:22:21 +00:00
0145147333 Update 'mbchc-local.user.js' 2022-07-18 04:25:30 +00:00
6fbe13b17a Update 'mbchc-local.user.js'
* bugfix (Nickname undefined)
* history will now search by input
2022-07-16 15:03:53 +00:00
c73bffc4bb Update 'mbchc-local.user.js' 2022-07-15 16:28:26 +00:00
537c538211 Update 'mbchc-local.user.js' 2022-07-15 00:09:12 +00:00
faf2714810 Update 'mbchc-local.user.js' 2022-07-14 04:28:37 +00:00
87cf188c64 Update 'mbchc-local.user.js'
hint needs more work, won't show at all for now
2022-07-12 03:53:07 +00:00
c872444b14 Update 'mbchc-local.user.js' 2022-07-11 19:20:43 +00:00
69193e0861 Update 'mbchc-local.user.js' 2022-07-11 15:40:03 +00:00
18702fa607 Update 'mbchc-local.user.js' 2022-07-11 02:42:17 +00:00
f7e571b26a Update 'mbchc-local.user.js' 2022-07-10 07:07:09 +00:00
561bedc606 Update 'mbchc-local.user.js' 2022-07-09 17:52:03 +00:00
a0e482c536 Update 'mbchc-local.user.js' 2022-07-09 02:33:08 +00:00
071ca9a8e1 Update 'mbchc-local.user.js' 2022-07-09 01:56:25 +00:00
1fa1c8c951 Update 'mbchc-local.user.js'
also cleanup
2022-07-08 19:32:31 +00:00
d30d0c1408 Update 'README.md' 2022-07-08 03:10:05 +00:00
8d6669795b Update 'mbchc-local.user.js' 2022-07-07 23:34:05 +00:00
a140c3a301 Update 'mbchc-local.user.js' 2022-07-07 23:10:18 +00:00
7611721459 select background colour depending on current theme 2022-07-06 21:56:18 +00:00
1d8e3b100b Update 'mbchc-local.user.js' 2022-07-06 07:02:59 +00:00
e6a35024c8 Update 'mbchc-local.user.js' 2022-07-05 17:53:00 +00:00
6663c2eede Update 'mbchc-local.user.js' 2022-07-05 01:04:07 +00:00
1f45cb04e0 Update 'mbchc-local.user.js' 2022-07-04 22:22:47 +00:00
c29d264520 Update 'mbchc-local.user.js' 2022-07-04 22:19:38 +00:00
ee52aa45a8 mbchc-local.user.js (trunk) 2022-07-04 22:07:57 +00:00
a06791c039 Update 'README.md' 2022-07-02 16:21:27 +00:00
13 changed files with 8361 additions and 230 deletions

1
.gitignore vendored
View File

@ -0,0 +1 @@
node_modules

View File

@ -1 +1,2 @@
Mute
Lyra

View File

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

468
ambient.d.ts vendored Normal file
View File

@ -0,0 +1,468 @@
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

25
jsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"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,248 +1,667 @@
// ==UserScript==
// @name MBCHC
// @version dev.1
// @version dev.8
// @description Mute's Bondage Club Hacks Collection
// @author codename.mute@proton.me
// @homepage https://code.fleshless.org/mute/MBCHC
// @namespace https://code.fleshless.org/mute/
// @homepage https://code.fleshless.org/mute/MBCHC
// @updateURL https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js
// @downloadURL https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js
// @match https://bondageprojects.elementfx.com/R*
// @match https://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"
"use strict";
if (!window.AsylumGGTSSAddItems) throw "AsylumGGTSSAddItems() not found, aborting MBCHC loading"
if (window.MBCHC) throw "MBCHC found, aborting loading"
window.MBCHC = {
VERSION: "dev.8",
TARGET_VERSION: "R93",
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
RETHROW: false,
LOADED: false,
AUTOHACK_ENABLED: false,
LAST_HACKED: null,
HISTORY_MODE: false,
RE_TITLE: /^[a-zA-Z]+$/,
RE_PREF_ACTIVITY_ME: /^@/,
RE_PREF_ACTIVITY: /^@@/,
RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/,
RE_TZ: /(?:GMT|UTC)\s*([+-])\s*(\d\d?)/i,
RE_ALL_LEFT: /^<+$/,
RE_ALL_RIGHT: /^>+$/,
RE_SPACES: /\s{2,}/g,
RE_LAST_WORD: /(^|\s)([^\s]*)$/,
RE_LAST_LETTER: /[\w]$/,
RGB_MUTE: "#6c2132",
RGB_POLLY: "#81b1e7",
UTC_OFFSET: new Date().getTimezoneOffset() * 60 * 1000,
HIDE_SPECIAL: ["Activity","Emoticon"],
HIDE_BODY: ["Blush","BodyLower","BodyUpper","Eyebrows","Eyes","Eyes2","Face","Fluids","HairBack","HairFront","Hands","Head","LeftHand","Mouth","Nipples","Pussy","RightHand"],
HIDE_CLOTHES: [
"Cloth","ClothAccessory","Necklace","Suit","ClothLower","SuitLower","Bra","Corset","Panties",
"Socks","RightAnklet","LeftAnklet","Garters","Shoes","Hat","HairAccessory3","HairAccessory1","HairAccessory2",
"Gloves","Bracelet","Glasses","Mask","TailStraps","Wings"
],
HIDE_ITEMS: [
"ItemMisc","ItemEars","ItemHead","ItemNose","ItemHood","ItemAddon","ItemMouth","ItemMouth2","ItemMouth3",
"ItemArms","ItemNeckAccessories","ItemNeck","ItemNeckRestraints","ItemNipples","ItemNipplesPiercings","ItemBreast","ItemTorso","ItemTorso2",
"ItemHands","ItemPelvis","ItemVulva","ItemVulvaPiercings",
"ItemDevices","ItemLegs","ItemFeet","ItemBoots"
],
MAP_ACTIONS: { //ActivityFemale3DCG
// action
"nod|yes": {Head: {self: "Nod"}},
"no": {Head: {self: "Wiggle"}},
"moan": {Mouth: {self: "MoanGag"}},
"mumble": {Mouth: {self: "MoanGagTalk"}},
"whimper": {Mouth: {self: "MoanGagWhimper"}},
"groan": {Mouth: {self: "MoanGagGroan"}},
"scream": {Mouth: {self: "MoanGagAngry"}},
"giggle": {Mouth: {self: "MoanGagGiggle"}},
"struggle": {Arms: {self: "StruggleArms"}},
"thrash": {Legs: {self: "StruggleLegs"}},
// Static data
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 zone
"wiggle|shake": {"Arms,Breast,Boots,Butt,Ears,Feet,Hands,Nose,Pelvis,Torso": {self: "Wiggle"}},
// Loader
window.AsylumGGTSSAddItems = function() {
if (!window.MBCHC.LOADED) {
// 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"}},
// Save originals hopefully after patching
window.MBCHC.orig_ChatRoomMessageInvolvesPlayer = window.ChatRoomMessageInvolvesPlayer
window.MBCHC.orig_ChatRoomReceiveSuitcaseMoney = window.ChatRoomReceiveSuitcaseMoney
window.MBCHC.orig_ChatRoomSendChat = window.ChatRoomSendChat
// 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(window.Player, char, char.FocusGroup, activity)
} finally {char.FocusGroup = null} },
replace_me: function(match, offset, string) {
let text = string.slice(1)
let suffix = " "
if (text.startsWith("'") || text.startsWith(" ")) suffix = ""
return `${window.MBCHC.PREF_ACTIVITY}<${window.Player.cid}:>SourceCharacter${suffix}`
},
cid2dict: function(type, cid) { return({Tag: `${type}Character`, MemberNumber: cid, Text: this.cid2char(cid).dn}) },
send_activity: function(msg) {
let dict = [{Tag: "MISSING PLAYER DIALOG: ", Text: "\u200C"}] // zero-width non-joiner, used to break up ligatures, does nothing here, but an empty string is a falsy value
let cids = msg.match(this.RE_ACT_CIDS)
if (cids) {
msg = msg.replace(this.RE_ACT_CIDS, "")
if (cids[1]) dict.push(this.cid2dict("Source", cids[1]))
if (cids[2]) dict.push(this.cid2dict("Target", cids[2]), this.cid2dict("Destination", cids[2]))
}
window.ServerSend("ChatRoomChat", {Type: "Action", Content: msg, Dictionary: dict})
},
receive: function(data) {
let char = this.cid2char(data.Sender)
if (char.IsPlayer()) return true // this is our own message, sent back to us
let payload = this.ensure("Empty message", () => data.Dictionary[0])
switch (payload.type) {
case "greetings": case "hello":
char.MBCHC = payload.value
if ("greetings" === payload.type) this.hello(char)
break
default: // if we don't know the type it may be from a newer version
}
return true
},
hello: function(char = null) {
let payload = {type: "greetings", value: window.Player.MBCHC}
if (char) payload.type = "hello"
let message = {Content: "MBCHC", Type: "Hidden", Dictionary: [payload]}
if (char) message.Target = char.cid
window.ServerSend("ChatRoomChat", message)
},
disappear: function() {
let item = window.InventoryGet(window.Player, "ItemButt")
if (!item || !item.Asset || !item.Asset.Name) throw "butt seems empty"
if (item.Asset.Name !== "AnalHook") throw "butt seems occupied by something other than the anal hook"
if (!item.Property.Type || item.Property.Type !== "Hair") throw "anal hook seems not tied to hair"
item.Property = {Type: "Hair", Hide: this.HIDE_ALL}
window.CharacterRefresh(window.Player, true, true)
},
title: function(title) { // WIP
if (this.empty(title)) throw "empty title"
title = this.normalise_message(title, {trim: true, up: true, low: true})
if (title.length > 16) throw "title too long"
if (!title.match(this.RE_TITLE)) throw "invalid title"
window.TitleSet(title)
//window.TitleList.push({Name: title, Requirement: () => true}) // check for existing first
},
copy_fbc_trigger: function(trigger) {
let result = {
Type: "Action",
Event: trigger.Event,
Matchers: trigger.Matchers.map(m => ({Tester: new RegExp(this.FBC_TESTER_PATCHES.reduce((ax,[f,r]) => ax.replaceAll(f,r), m.Tester.source), "u")}))
}
return(result)
},
patch_fbc: function() {
this.remove_fbc_hook()
delete this.remove_fbc_hook
window.bce_ActivityTriggers.push(...window.bce_ActivityTriggers.filter(t => "Emote" === t.Type).map(t => this.copy_fbc_trigger(t)))
/* (["anim", "pose"]).forEach(tag => {let cmd = window.Commands.find(c => tag === c.Tag); if (cmd) cmd.AutoComplete = this[`complete_fbc_${tag}`]}) */ // this line explodes, don't ask me why
let cmd = window.Commands.find(c => "anim" === c.Tag)
if (cmd) cmd.AutoComplete = this.complete_fbc_anim
cmd = window.Commands.find(c => "pose" === c.Tag)
if (cmd) cmd.AutoComplete = this.complete_fbc_pose
},
gather_versions: function() { return(window.ChatRoomCharacter.filter(c => c.MBCHC).map(c => ({name: c.dn, cid: c.cid, version: c.MBCHC.VERSION}))) },
find_timezone: function(char) {
const timezones = this.settings("timezones")
if (timezones && "number" === typeof timezones[char.cid]) return(timezones[char.cid])
const match = (char.Description) ? char.Description.match(this.RE_TZ) : null
const int = match ? Number.parseInt(match[1] + match[2]) : 42
if (this.in(int, -12, 12)) return(int)
return(null)
},
player_enters_room: function() { // or if the mod is loaded while player is in the room
this.hello()
},
set_timezone: function(args) {
let tz = Number.parseInt(args[0])
if (isNaN(tz)) throw `invalid offset "${args[0]}"`
if (!this.in(tz, -12, 12)) throw "offset should be [-12,12]"
let char = this.target2char(args[1])
char.MBCHC_LOCAL.TZ = tz
this.save_settings(s => {if (!s.timezones) s.timezones = {}; s.timezones[char.cid] = tz})
},
update_char: function(char) {
char.cid = char.MemberNumber // Club ID (shorter)
char.dn = window.CharacterNickname(char) // DisplayName (shortcut)
if (!char.MBCHC_LOCAL) char.MBCHC_LOCAL = {}
if (!char.MBCHC_LOCAL.TZ) char.MBCHC_LOCAL.TZ = this.find_timezone(char)
},
command_mbchc: function(argline, cmdline, args) { const mbchc = window.MBCHC; try { // `this` is command object
if (args.length < 1) return(mbchc.inform(Object.entries(mbchc.SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `<div>/mbchc ${cmd} ${sub.args ? Object.keys(sub.args).join(" ") : ""}: ${sub.desc}</div>`).join("")))
let cmd = String(args.shift())
let sub = mbchc.ensure(`unknown subcommand "${cmd}"`, () => mbchc.SUBCOMMANDS_MBCHC[cmd])
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
} catch (x) { mbchc.report(x) } },
command_activity: function(argline, cmdline, args) { const mbchc = window.MBCHC; if (!mbchc.empty(argline)) { try { // `this` is command object
let message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, ''), {trim: true, dot: true, up: true})
mbchc.send_activity(message)
} catch (x) { mbchc.report(x) } } },
command_do: function(argline, cmdline, args) { const mbchc = window.MBCHC; try { // `this` is command object
if (args.length < 1) return(mbchc.inform("<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>" + Object.keys(mbchc.MAP_ACTIONS).join(", ") + "<div>Available zones:</div>" + Object.keys(mbchc.DO_DATA.zones).join(", ")))
let [verb, zone, target] = args
let zones = mbchc.ensure(`unknown verb "${verb}"`, () => mbchc.DO_DATA.verbs[verb])
if (1 === Object.keys(zones).length) {
if (!target) target = zone
zone = mbchc.MAP_ZONES[Object.keys(zones)[0]][0]
}
if (!zone) throw "zone missing"
let ag = mbchc.ensure(`unknown zone "${zone}"`, () => mbchc.DO_DATA.zones[zone])
let types = mbchc.ensure(`zone "${zone}" invalid for "${verb}"`, () => zones[ag])
let char = window.Player
if (target && ((types.self.length < 1) || (types.others.length > 0))) char = mbchc.target2char(target)
let type = char.IsPlayer() ? "self" : "others"
let available = window.ActivityAllowedForGroup(char, ag)
let toy = window.InventoryGet(window.Player, "ItemHands")
if (toy && toy.Asset.Name === "SpankingToys") available.push(window.AssetAllActivities(char.AssetFamily).find(a => a.Name === window.InventorySpankingToysGetActivity?.(window.Player)))
let actions = mbchc.ensure(`zone "${zone}" invalid for ("${verb}" "${type}")`, () => types[type])
let action = mbchc.ensure(`invalid action (${verb} ${zone} ${target})`, () => actions.find(name => available.find(a => a.Name === name || a.Activity?.Name === name)))
mbchc.run_activity(char, ag, action)
} catch (x) { mbchc.report(x) } },
bell: function() {
setTimeout(() => {document.getElementById("InputChat").style.outline = ""}, 100)
document.getElementById("InputChat").style.outline = "solid red"
},
complete: function(options, space = true) {
if (options.length < 1) return(this.bell())
if (options.length > 1) {
let width = Math.max(...options.map(o => o.length))
let pref = null
for (let i = width; i > 0; i -= 1) { let test = options[0].slice(0, i); if (options.every(o => o.startsWith(test))) {pref = test; break} }
if (pref) this.complete([pref], false)
this.complete_hint(options)
} else window.ElementValue("InputChat", document.getElementById("InputChat").value.replace(this.RE_LAST_WORD, `$1${options[0]}${space ? " " : ""}`))
},
complete_hint: function(options) {
this.COMP_HINT.innerHTML = options.sort().map(s => `<div>${s}</div>`).join("")
this.COMP_HINT.style.display = "flex"
window.ElementSetDataAttribute(this.COMP_HINT.id, "colortheme", (window.Player.ChatSettings.ColorTheme || "Light"))
let rescroll = window.ElementIsScrolledToEnd("TextAreaChatLog")
window.ChatRoomResize(false)
if (rescroll) window.ElementScrollToEnd("TextAreaChatLog")
},
comp_hint_visible: function() {return(this.COMP_HINT.parentElement && "flex" === this.COMP_HINT.style.display)},
complete_hint_hide: function(options) {if (!this.comp_hint_visible()) return; this.COMP_HINT.style.display = "none"; window.ChatRoomResize(false)},
complete_target: function(token, me2 = true, check_perms = false) {
let [locase, found] = [token.toLocaleLowerCase(), new Set()]
for (let c of window.ChatRoomCharacter) {
if ((c.IsPlayer() && !me2) || (check_perms && !window.ServerChatRoomGetAllowItem(window.Player, c))) continue
this.char2targets(c).forEach(s => {if (s.toLocaleLowerCase().startsWith(locase)) found.add(s)})
}
this.complete(Array.from(found))
},
complete_common: function() {
let input = document.getElementById("InputChat").value
return([this, input, this.tokenise(input)])
},
complete_mbchc: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
let subname = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return(mbchc.complete(Object.keys(mbchc.SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname)))) // complete subcommand name
let sub = mbchc.SUBCOMMANDS_MBCHC[subname]
if (sub && sub.args) {
let argname = Object.keys(sub.args)[tokens.length - 3]
if ("TARGET" === argname) return(mbchc.complete_target(tokens[tokens.length - 1], false))
if ("[TARGET]" === argname) return(mbchc.complete_target(tokens[tokens.length - 1]), true)
}
},
complete_do_target: function(actions, token) {
if (!actions) return
let me2 = (actions.self.length > 0)
if (me2 && actions.others.length < 1) return(this.complete([window.Player.cid.toString()])) // target is always the player
this.complete_target(token, me2, true)
},
complete_do: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
// now, we *could* run a filter to exclude impossible activities, but it isn't very useful, and also seems like a lot of CPU to iterate over every action on every zone of every char in the room
let low = tokens[1].toLocaleLowerCase()
if (tokens.length < 3) return(mbchc.complete(Object.keys(mbchc.DO_DATA.verbs).filter(c => c.startsWith(low)))) // complete verb
let ags = mbchc.DO_DATA.verbs[low]
if (!ags) return(mbchc.bell())
low = tokens[2].toLocaleLowerCase()
if (tokens.length < 4) { // complete zone or target
if (Object.keys(ags).length < 2) return(mbchc.complete_do_target(ags[Object.keys(ags)[0]], tokens[2])) // zone implied, complete target
let zones = Object.entries(mbchc.DO_DATA.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone,ag]) => zone)
return(mbchc.complete(zones))
}
if (tokens.length < 5) { // complete target where it belongs
if (Object.keys(ags).length < 2) return // zone implied, target already given
return(mbchc.complete_do_target(ags[mbchc.DO_DATA.zones[low]], tokens[3]))
}
mbchc.bell()
},
complete_fbc_anim: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
if (tokens.length > 2) return(mbchc.bell())
let anim = tokens[1].toLocaleLowerCase()
return(mbchc.complete(Object.keys(window.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim))))
},
complete_fbc_pose: function(args, locase, cmdline) { const [mbchc, input, tokens] = window.MBCHC.complete_common(); // `this` is command object
if (tokens.length < 1) return
if (tokens.length < 2) return(mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`]))
let pose = tokens[tokens.length - 1].toLocaleLowerCase()
return(mbchc.complete(window.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose))))
},
history: function(down) {
let [text, history] = [window.ElementValue("InputChat"), window.ChatRoomLastMessage]
if (!this.HISTORY_MODE) {history.push(text); this.HISTORY_MODE = true}
let ids = history.map((t,i) => [t,i]).filter(([t,i]) => t.startsWith(history[history.length - 1])).map(([t,i]) => i)
if (!down) ids.reverse()
let found = ids.find(id => (down) ? id > window.ChatRoomLastMessageIndex : id < window.ChatRoomLastMessageIndex)
if (!found) return(this.bell())
window.ElementValue("InputChat", history[found])
window.ChatRoomLastMessageIndex = found
},
focus_chat_whitelist(event) {
if (event.ctrlKey && "v" === event.key) return true // ctrl+V should paste
return false
},
focus_chat(event) { // TODO: this is not ideal, but it will have to do for now
if (event.repeat) return // only unique presses please
if ("inline" !== document.getElementById("InputChat")?.style.display) return // input chat missing
if (["InputChat","bce-message-input"].includes(document.activeElement.id)) return // focus already set
if ([event.altKey, event.ctrlKey, event.metaKey].some(i => i) && !this.focus_chat_whitelist(event)) return // alt, ctrl and meta should all be false
window.ElementFocus("InputChat")
},
loader() {
if (this.remove_load_hook) {
this.remove_load_hook()
delete this.remove_load_hook
}
if (this.LOADED) return
// Calculated values
const COMMANDS = [
{ Tag: "mbchc", Description: ": Utility functions (\"/mbchc\" for help)", Action: this.command_mbchc, AutoComplete: this.complete_mbchc },
{ Tag: "activity", Description: "[Message]: Send a custom activity (or \"@@Message\", or \"@Message\" as yourself)", Action: this.command_activity },
{ Tag: "do", Description: ": Do an activity, as if clicked on its button (\"/do\" for help)", Action: this.command_do, AutoComplete: this.complete_do },
]
this.HIDE_ALL = this.HIDE_SPECIAL.concat(this.HIDE_BODY).concat(this.HIDE_CLOTHES).concat(this.HIDE_ITEMS)
this.CommandsKey = CommandsKey /* eslint-disable-line no-undef */ // window.CommandsKey is undefined
this.RE_ACTIVITY = RegExp(`^${this.CommandsKey}activity `)
this.PREF_ACTIVITY = `${this.CommandsKey}activity `
this.COMP_HINT = document.createElement("div")
this.COMP_HINT.id = "mbchcCompHint"
let css = document.createElement("style")
css.type = "text/css"
css.textContent = `
#TextAreaChatLog .mbchc, #TextAreaChatLog .mbchc {
background-color: ${this.RGB_POLLY};
}
#TextAreaChatLog[data-colortheme="dark"] .mbchc, #TextAreaChatLog[data-colortheme="dark2"] .mbchc {
background-color: ${this.RGB_MUTE};
}
#${this.COMP_HINT.id} {
flex-flow: column wrap;
overflow: auto;
display: none;
background-color: ${this.RGB_POLLY};
color: black;
}
#${this.COMP_HINT.id}[data-colortheme="dark"], #${this.COMP_HINT.id}[data-colortheme="dark2"] {
background-color: ${this.RGB_MUTE};
color: white;
}
#${this.COMP_HINT.id} div {
margin: 0 0.5ex;
}
`
document.head.appendChild(css)
// Actions
this.calculate_maps()
window.Player.MBCHC = {VERSION: this.VERSION}
window.CommandCombine(COMMANDS)
// Functions
window.MBCHC.HIDE_ALL = window.MBCHC.HIDE_SPECIAL.concat(window.MBCHC.HIDE_BODY).concat(window.MBCHC.HIDE_CLOTHES).concat(window.MBCHC.HIDE_ITEMS)
window.MBCHC.make_my_anal_hook_hide_body = 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)
}
window.MBCHC.donate_data = function(id) {
if (id == window.Player.MemberNumber) throw "recipient must not be you"
const char = window.ChatRoomCharacter.find( c => c.MemberNumber == id )
if (!char) throw "recipient not found"
if (!char.IsRestrained()) throw "recipient must be bound"
const cost = (Math.random() * 10 + 15).toFixed(0)
if (window.Player.Money < cost) throw "not enough money"
window.CharacterChangeMoney(window.Player, -cost)
window.ServerSend("ChatRoomChat", {Content: "ReceiveSuitcaseMoney", Type: "Hidden", Target: id})
return({cost: cost, name: (char.Nickname || char.Name)})
}
window.MBCHC.run_activity = function(char, ag, action) {
try {
char.FocusGroup = window.AssetGroupGet(char.AssetFamily, ag)
if (!char.FocusGroup) throw "invalid AssetGroup"
let activity = window.ActivityAllowedForGroup(char, char.FocusGroup.Name).find( a => a.Name === action)
if (!activity) throw "invalid activity"
window.ActivityRun(char, activity)
} finally {
char.FocusGroup = null
}
}
window.MBCHC.send_activity = function(msg) {
window.ServerSend("ChatRoomChat", {Type: "Action", Content: "lef_wie234jf_owhwi", Dictionary: [{Tag: 'MISSING PLAYER DIALOG: lef_wie234jf_owhwi', Text: msg}]})
}
// Command actions
window.MBCHC.action_title = function(args) {
let title = args.shift()
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"
let first = title.at(0).toLocaleUpperCase()
let rest = title.slice(1).toLocaleLowerCase()
title = first + rest
window.TitleSet(title)
// TODO: this needs much more work. at least don't push a second title
// we need to patch the text cache
// we need to check for other players' custom titles
window.TitleList.push({Name: title, Requirement: () => {return true}})
}
window.MBCHC.action_donate = function(args) {
let id = Number.parseInt(args.shift())
if (isNaN(id)) throw "empty or invalid member number"
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"})`)
}
// 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))
})
// Hooks
window.ChatRoomMessageInvolvesPlayer = function(data) {
if (!data.MBCHC_ID) {
data.MBCHC_ID = window.MBCHC.NEXT_MESSAGE
window.MBCHC.NEXT_MESSAGE += 1
if (window.MBCHC.LOG_MESSAGES) console.debug(data)
if (("ReceiveSuitcaseMoney" === data.Content) && ("Hidden" === data.Type)) { window.MBCHC.LAST_HACKED = data.Sender }
}
return(window.MBCHC.orig_ChatRoomMessageInvolvesPlayer(data))
}
window.ChatRoomReceiveSuitcaseMoney = function() {
let result = window.MBCHC.orig_ChatRoomReceiveSuitcaseMoney()
if (window.MBCHC.AUTOHACK_ENABLED && window.MBCHC.LAST_HACKED) {
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())
}
// 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 }
})
// Actions
window.CommandCombine(window.MBCHC.COMMANDS)
if (window.bcModSdk) window.bcModSdk.registerMod("MBCHC", window.MBCHC.VERSION)
window.MBCHC.LOADED = true
console.info(window.MBCHC.log("loaded version " + window.MBCHC.VERSION))
}
return(window.MBCHC.orig_AsylumGGTSSAddItems())
} // Loader
// 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 */
})()

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

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

592
mbchc.mjs Normal file
View File

@ -0,0 +1,592 @@
// 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 Normal file

File diff suppressed because it is too large Load Diff

94
package.json Normal file
View File

@ -0,0 +1,94 @@
{
"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"
}
}
]
}
}

31
server.js Normal file
View File

@ -0,0 +1,31 @@
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}`))