Compare commits
	
		
			75 Commits
		
	
	
		
			v.1
			...
			c3d47fec77
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c3d47fec77 | |||
| 7e207b7e93 | |||
| 1eaffc46be | |||
| 966328327e | |||
| fe4248eb8b | |||
| 6034adae3c | |||
| 16308eccf1 | |||
| d87bfde6f9 | |||
| 90231cb2ae | |||
| b0961f4fb8 | |||
| d30945201d | |||
| 378d0af8d3 | |||
| 526b51e158 | |||
| ff377fb709 | |||
| e4f7ce0560 | |||
| 2bf6695af0 | |||
| 0f1195d92b | |||
| d3852d6e63 | |||
| 2f87b283ed | |||
| 89eafdfab3 | |||
| 0d1f074c43 | |||
| 247134e04b | |||
| 46fb29bcec | |||
| 45f6f80493 | |||
| eaf4e3f26f | |||
| 337eb83d47 | |||
| 3a97857fc1 | |||
| a9c6e5fd35 | |||
| 247c908f46 | |||
| 1cc9ac18d7 | |||
| e690960782 | |||
| 634eddbd2a | |||
| d43e3cdeed | |||
| 5c1d2f6397 | |||
| 5c534b1537 | |||
| 41b2029efd | |||
| f911c05073 | |||
| 8d1516f1a7 | |||
| 3e3f891016 | |||
| ef41dd1dce | |||
| 4930e5111b | |||
| c76ad63d96 | |||
| 18095495bb | |||
| faf4e95499 | |||
| 839ad8f0b3 | |||
| 62def6bb3e | |||
| 3f4e43adbc | |||
| 29e9d84ceb | |||
| b99e28502b | |||
| 621dea9959 | |||
| 0145147333 | |||
| 6fbe13b17a | |||
| c73bffc4bb | |||
| 537c538211 | |||
| faf2714810 | |||
| 87cf188c64 | |||
| c872444b14 | |||
| 69193e0861 | |||
| 18702fa607 | |||
| f7e571b26a | |||
| 561bedc606 | |||
| a0e482c536 | |||
| 071ca9a8e1 | |||
| 1fa1c8c951 | |||
| d30d0c1408 | |||
| 8d6669795b | |||
| a140c3a301 | |||
| 7611721459 | |||
| 1d8e3b100b | |||
| e6a35024c8 | |||
| 6663c2eede | |||
| 1f45cb04e0 | |||
| c29d264520 | |||
| ee52aa45a8 | |||
| a06791c039 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					node_modules
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										11
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										11
									
								
								README.md
									
									
									
									
									
								
							@@ -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.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										459
									
								
								ambient.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										459
									
								
								ambient.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,459 @@
 | 
				
			|||||||
 | 
					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
 | 
				
			||||||
 | 
							SUBCOMMANDS_MBCHC: SUBCOMMANDS
 | 
				
			||||||
 | 
							H: InputHistory
 | 
				
			||||||
 | 
							U: Utils
 | 
				
			||||||
 | 
							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
 | 
				
			||||||
 | 
						//pad_chat(chat: HTMLDivElement): undefined
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Enter the history mode.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						enter(textarea: HTMLTextAreaElement, input: string, bottom: boolean, ids: Set<number>): true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Exit the history mode and optionally restore original input.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						exit(textarea: HTMLTextAreaElement, restore_input: boolean): true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// FIXME spread around readonly where appropriate
 | 
				
			||||||
							
								
								
									
										4
									
								
								bondage-club-mod-sdk-1.0.2.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								bondage-club-mod-sdk-1.0.2.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										4
									
								
								bondage-club-mod-sdk-1.1.0.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								bondage-club-mod-sdk-1.1.0.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										25
									
								
								jsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								jsconfig.json
									
									
									
									
									
										Normal 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
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@@ -1,32 +1,50 @@
 | 
				
			|||||||
// ==UserScript==
 | 
					// ==UserScript==
 | 
				
			||||||
// @name         MBCHC
 | 
					// @name         MBCHC
 | 
				
			||||||
// @version      dev.1
 | 
					// @version      dev.8
 | 
				
			||||||
// @description  Mute's Bondage Club Hacks Collection
 | 
					// @description  Mute's Bondage Club Hacks Collection
 | 
				
			||||||
// @author       codename.mute@proton.me
 | 
					// @author       codename.mute@proton.me
 | 
				
			||||||
// @homepage     https://code.fleshless.org/mute/MBCHC
 | 
					 | 
				
			||||||
// @namespace    https://code.fleshless.org/mute/
 | 
					// @namespace    https://code.fleshless.org/mute/
 | 
				
			||||||
 | 
					// @homepage     https://code.fleshless.org/mute/MBCHC
 | 
				
			||||||
 | 
					// @updateURL    https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js
 | 
				
			||||||
 | 
					// @downloadURL  https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js
 | 
				
			||||||
// @match        https://bondageprojects.elementfx.com/R*
 | 
					// @match        https://bondageprojects.elementfx.com/R*
 | 
				
			||||||
// @match        https://www.bondageprojects.elementfx.com/R*
 | 
					// @match        https://www.bondageprojects.elementfx.com/R*
 | 
				
			||||||
// @match        https://bondage-europe.com/R*
 | 
					// @match        https://bondage-europe.com/R*
 | 
				
			||||||
// @match        https://www.bondage-europe.com/R*
 | 
					// @match        https://www.bondage-europe.com/R*
 | 
				
			||||||
 | 
					// @match        http://localhost:*/*
 | 
				
			||||||
 | 
					// @match        http://127.0.0.1:*/*
 | 
				
			||||||
// @grant        none
 | 
					// @grant        none
 | 
				
			||||||
// ==/UserScript==
 | 
					// ==/UserScript==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
(function() {
 | 
					(function() {
 | 
				
			||||||
    'use strict';
 | 
					"use strict";
 | 
				
			||||||
    if (!window.AsylumGGTSSAddItems) throw "AsylumGGTSSAddItems() not found, aborting"
 | 
					if (!window.AsylumGGTSSAddItems) throw "AsylumGGTSSAddItems() not found, aborting MBCHC loading"
 | 
				
			||||||
 | 
					if (window.MBCHC) throw "MBCHC found, aborting loading"
 | 
				
			||||||
    // Static data
 | 
					window.MBCHC = {
 | 
				
			||||||
    window.MBCHC = {
 | 
						VERSION: "dev.8",
 | 
				
			||||||
 | 
						TARGET_VERSION: "R93",
 | 
				
			||||||
	NEXT_MESSAGE: 1,
 | 
						NEXT_MESSAGE: 1,
 | 
				
			||||||
	LOG_MESSAGES: false,
 | 
						LOG_MESSAGES: false,
 | 
				
			||||||
 | 
						RETHROW: false,
 | 
				
			||||||
	LOADED: false,
 | 
						LOADED: false,
 | 
				
			||||||
        VERSION: 'dev',
 | 
					 | 
				
			||||||
	AUTOHACK_ENABLED: false,
 | 
						AUTOHACK_ENABLED: false,
 | 
				
			||||||
	LAST_HACKED: null,
 | 
						LAST_HACKED: null,
 | 
				
			||||||
 | 
						HISTORY_MODE: false,
 | 
				
			||||||
	RE_TITLE: /^[a-zA-Z]+$/,
 | 
						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_SPECIAL: ["Activity","Emoticon"],
 | 
				
			||||||
        HIDE_BODY: ["Blush","BodyLower","BodyUpper","Eyebrows","Eyes","Eyes2","Face","Fluids","HairBack","HairFront","Hands","Head","Mouth","Nipples","Pussy"],
 | 
						HIDE_BODY: ["Blush","BodyLower","BodyUpper","Eyebrows","Eyes","Eyes2","Face","Fluids","HairBack","HairFront","Hands","Head","LeftHand","Mouth","Nipples","Pussy","RightHand"],
 | 
				
			||||||
	HIDE_CLOTHES: [
 | 
						HIDE_CLOTHES: [
 | 
				
			||||||
		"Cloth","ClothAccessory","Necklace","Suit","ClothLower","SuitLower","Bra","Corset","Panties",
 | 
							"Cloth","ClothAccessory","Necklace","Suit","ClothLower","SuitLower","Bra","Corset","Panties",
 | 
				
			||||||
		"Socks","RightAnklet","LeftAnklet","Garters","Shoes","Hat","HairAccessory3","HairAccessory1","HairAccessory2",
 | 
							"Socks","RightAnklet","LeftAnklet","Garters","Shoes","Hat","HairAccessory3","HairAccessory1","HairAccessory2",
 | 
				
			||||||
@@ -34,215 +52,616 @@
 | 
				
			|||||||
	],
 | 
						],
 | 
				
			||||||
	HIDE_ITEMS: [
 | 
						HIDE_ITEMS: [
 | 
				
			||||||
		"ItemMisc","ItemEars","ItemHead","ItemNose","ItemHood","ItemAddon","ItemMouth","ItemMouth2","ItemMouth3",
 | 
							"ItemMisc","ItemEars","ItemHead","ItemNose","ItemHood","ItemAddon","ItemMouth","ItemMouth2","ItemMouth3",
 | 
				
			||||||
            "ItemArms","ItemNeckAccessories","ItemNeck","ItemNeckRestraints","ItemNipples","ItemNipplesPiercings","ItemBreast","ItemTorso",
 | 
							"ItemArms","ItemNeckAccessories","ItemNeck","ItemNeckRestraints","ItemNipples","ItemNipplesPiercings","ItemBreast","ItemTorso","ItemTorso2",
 | 
				
			||||||
		"ItemHands","ItemPelvis","ItemVulva","ItemVulvaPiercings",
 | 
							"ItemHands","ItemPelvis","ItemVulva","ItemVulvaPiercings",
 | 
				
			||||||
		"ItemDevices","ItemLegs","ItemFeet","ItemBoots"
 | 
							"ItemDevices","ItemLegs","ItemFeet","ItemBoots"
 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
        COMMANDS: [
 | 
						MAP_ACTIONS: { //ActivityFemale3DCG
 | 
				
			||||||
            { Tag: "disappear",
 | 
							// action
 | 
				
			||||||
              Description: ": Become invisible; requires anal hook (hair)",
 | 
							"nod|yes":  {Head:   {self: "Nod"}},
 | 
				
			||||||
              Action: (argline, cmdline, args) => {
 | 
							"no":       {Head:   {self: "Wiggle"}},
 | 
				
			||||||
                try {
 | 
							"moan":     {Mouth:  {self: "MoanGag"}},
 | 
				
			||||||
                  window.MBCHC.make_my_anal_hook_hide_body()
 | 
							"mumble":   {Mouth:  {self: "MoanGagTalk"}},
 | 
				
			||||||
                } catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
 | 
							"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"}
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
            { Tag: "title",
 | 
							"smooch":   {"Hands,Boots": {all: "Kiss"}},
 | 
				
			||||||
              Description: "[Title]: (WIP) Set a custom title (one short word, letters only)",
 | 
							"nibble|chew": {"Arms,Hands,Boots,Mouth,Nipples": {all: "Nibble"}, "Ears,Feet,Legs,Neck,Nose,Pelvis,Torse,Vulva,VulvaPiercings": {others: "Nibble"}},
 | 
				
			||||||
              Action: (argline, cmdline, args) => {
 | 
							"slap|spank": {"Head,Breast,Vulva,VulvaPiercings": {all: "Slap"}, "Arms,Boots,Butt,Feet,Hands,Legs,Pelvis,Torso": {all: "Spank"}},
 | 
				
			||||||
                try {
 | 
							"tickle":   {"Arms,Boots,Breast,Feet,Legs,Neck,Pelvis,Torso": {all: "Tickle"}},
 | 
				
			||||||
                  window.MBCHC.action_title(args)
 | 
							"massage":  {"Arms,Boots,Feet,Legs,Neck,Pelvis,Torso": {all: "MassageHands"}},
 | 
				
			||||||
                } catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
 | 
							"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"}},
 | 
				
			||||||
            { Tag: "donate",
 | 
							"pinch":    {"Arms,Ears,Nipples,Nose,Pelvis": {all: "Pinch"}},
 | 
				
			||||||
              Description: "[MemberNumber]: Buy data and send it to recipient",
 | 
							"clamp":    {Mouth: {all: "HandGag"}, Nose: {all: "Choke"}},
 | 
				
			||||||
              Action: (argline, cmdline, args) => {
 | 
							"step":     {"Breast,Neck,Pelvis": {others: "Step"}},
 | 
				
			||||||
                try {
 | 
							"pull":     {"Head,Nose,Nipples": {all: "Pull"}},
 | 
				
			||||||
                  window.MBCHC.action_donate(args)
 | 
							"grope":    {"Butt,Breast": {all: "Grope"}, "Feet,Legs,Pelvis": {others: "Grope"}},
 | 
				
			||||||
                } catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
 | 
							"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"}},
 | 
				
			||||||
            { Tag: "autohack",
 | 
							"foot":     {"Head,Nose": {others: "Step"}, "Torso,Boots": {others: "MassageFeet"}, "Vulva,VulvaPiercings": {others: "MasturbateFoot"}},
 | 
				
			||||||
              Description: ": Toggle autohack mode",
 | 
							"fist":     {"Vulva,Butt": {all: "MasturbateFist"}},
 | 
				
			||||||
              Action: (argline, cmdline, args) => {
 | 
							"fuck":     {"Mouth,Vulva,Butt": {others: "PenetrateSlow"}}, //peg?
 | 
				
			||||||
                try {
 | 
							"pound":    {"Mouth,Vulva,Butt": {others: "PenetrateFast"}},
 | 
				
			||||||
                  window.MBCHC.action_autohack()
 | 
							"tongue":   {"Vulva,VulvaPiercings": {others: "MasturbateTongue"}},
 | 
				
			||||||
                } catch (x) { window.ChatRoomSendLocal(`(Error: ${x.toString()})`) }
 | 
							"finger":   {"Breast,Butt,Vulva,VulvaPiercings": {all: "MasturbateHand"}},
 | 
				
			||||||
              },
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            { 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()})`) }
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
 | 
						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\\.?$$"],
 | 
				
			||||||
	],
 | 
						],
 | 
				
			||||||
        log: function(msg) {return("MBCHC: " + msg.toString())},
 | 
						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) {
 | 
						empty: function(text) {
 | 
				
			||||||
		if (!text) return(true)
 | 
							if (!text) return(true)
 | 
				
			||||||
		if (String(text).trim().length < 1) return(true)
 | 
							if (String(text).trim().length < 1) return(true)
 | 
				
			||||||
		return(false)
 | 
							return(false)
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
        add_full_stop: function(text) {
 | 
						normalise_message: function(text, options = {}) {
 | 
				
			||||||
            if (text.endsWith('.')) return(text)
 | 
							let result = text
 | 
				
			||||||
            return(`${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)
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
        capitalise: function(text, lower = false) {
 | 
						tokenise: function(text) { return text.replace(this.RE_SPACES, " ").split(" ") },
 | 
				
			||||||
            let first = text.at(0).toLocaleUpperCase()
 | 
						inform: function(html, timeout = 60000) { window.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`, timeout) },
 | 
				
			||||||
            let rest = text.slice(1)
 | 
						report: function(x) {
 | 
				
			||||||
            if (lower) rest = rest.toLocaleLowerCase()
 | 
							this.inform(`Error: ${x.toString()}`)
 | 
				
			||||||
            return(first + rest)
 | 
							if (this.RETHROW) throw x
 | 
				
			||||||
	},
 | 
						},
 | 
				
			||||||
        // we need this one here, this is our main loading hook
 | 
						in: function(x, floor, ceiling) { return((x >= floor) && (x <= ceiling)) },
 | 
				
			||||||
        orig_AsylumGGTSSAddItems: window.AsylumGGTSSAddItems,
 | 
						cid2char: function(cid) {
 | 
				
			||||||
    } // MBCHC
 | 
							cid = Number.parseInt(cid)
 | 
				
			||||||
 | 
							if (cid === window.Player.cid) return(window.Player)
 | 
				
			||||||
    // Loader
 | 
							return(this.ensure(`character ${cid} not found in the room`, () => window.ChatRoomCharacter.find(c => c.cid === cid)))
 | 
				
			||||||
    window.AsylumGGTSSAddItems = function() {
 | 
						},
 | 
				
			||||||
        if (!window.MBCHC.LOADED) {
 | 
						pos2char: function(pos) {
 | 
				
			||||||
 | 
							if (!this.in(pos, 0, window.ChatRoomCharacter.length - 1)) throw `invalid position ${pos}`
 | 
				
			||||||
            // Save originals hopefully after patching
 | 
							return(window.ChatRoomCharacter[pos])
 | 
				
			||||||
            window.MBCHC.orig_ChatRoomMessageInvolvesPlayer = window.ChatRoomMessageInvolvesPlayer
 | 
						},
 | 
				
			||||||
            window.MBCHC.orig_ChatRoomReceiveSuitcaseMoney = window.ChatRoomReceiveSuitcaseMoney
 | 
						rel2char: function(target) {
 | 
				
			||||||
            window.MBCHC.orig_ChatRoomSendChat = window.ChatRoomSendChat
 | 
							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
 | 
				
			||||||
            // Functions
 | 
							if (target.match(this.RE_ALL_LEFT)) pos = me - target.length
 | 
				
			||||||
            window.MBCHC.HIDE_ALL = window.MBCHC.HIDE_SPECIAL.concat(window.MBCHC.HIDE_BODY).concat(window.MBCHC.HIDE_CLOTHES).concat(window.MBCHC.HIDE_ITEMS)
 | 
							if (target.match(this.RE_ALL_RIGHT)) pos = me + target.length
 | 
				
			||||||
            window.MBCHC.make_my_anal_hook_hide_body = function() {
 | 
							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")
 | 
							let item = window.InventoryGet(window.Player, "ItemButt")
 | 
				
			||||||
		if (!item || !item.Asset || !item.Asset.Name) throw "butt seems empty"
 | 
							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.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"
 | 
							if (!item.Property.Type || item.Property.Type !== "Hair") throw "anal hook seems not tied to hair"
 | 
				
			||||||
		item.Property = {Type: "Hair", Hide: this.HIDE_ALL}
 | 
							item.Property = {Type: "Hair", Hide: this.HIDE_ALL}
 | 
				
			||||||
		window.CharacterRefresh(window.Player, true, true)
 | 
							window.CharacterRefresh(window.Player, true, true)
 | 
				
			||||||
            }
 | 
						},
 | 
				
			||||||
            window.MBCHC.donate_data = function(id) {
 | 
						title: function(title) { // WIP
 | 
				
			||||||
                if (id == window.Player.MemberNumber) throw "recipient must not be you"
 | 
							if (this.empty(title)) throw "empty title"
 | 
				
			||||||
                const char = window.ChatRoomCharacter.find( c => c.MemberNumber == id )
 | 
							title = this.normalise_message(title, {trim: true, up: true, low: true})
 | 
				
			||||||
                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.length > 16) throw "title too long"
 | 
				
			||||||
                if (!title.match(window.MBCHC.RE_TITLE)) throw "invalid title"
 | 
							if (!title.match(this.RE_TITLE)) throw "invalid title"
 | 
				
			||||||
                let first = title.at(0).toLocaleUpperCase()
 | 
					 | 
				
			||||||
                let rest = title.slice(1).toLocaleLowerCase()
 | 
					 | 
				
			||||||
                title = first + rest
 | 
					 | 
				
			||||||
		window.TitleSet(title)
 | 
							window.TitleSet(title)
 | 
				
			||||||
                // TODO: this needs much more work. at least don't push a second title
 | 
							//window.TitleList.push({Name: title, Requirement: () => true}) // check for existing first
 | 
				
			||||||
                // we need to patch the text cache
 | 
						},
 | 
				
			||||||
                // we need to check for other players' custom titles
 | 
						copy_fbc_trigger: function(trigger) {
 | 
				
			||||||
                window.TitleList.push({Name: title, Requirement: () => {return true}})
 | 
							let result = {
 | 
				
			||||||
            }
 | 
								Type: "Action",
 | 
				
			||||||
            window.MBCHC.action_donate = function(args) {
 | 
								Event: trigger.Event,
 | 
				
			||||||
                let id = Number.parseInt(args.shift())
 | 
								Matchers: trigger.Matchers.map(m => ({Tester: new RegExp(this.FBC_TESTER_PATCHES.reduce((ax,[f,r]) => ax.replaceAll(f,r), m.Tester.source), "u")}))
 | 
				
			||||||
                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
 | 
					 | 
				
			||||||
            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)
 | 
							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]
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
            window.ChatRoomSendChat = function() {
 | 
							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")
 | 
								let input = window.ElementValue("InputChat")
 | 
				
			||||||
                if (!input.startsWith("@@@")) {
 | 
								if (!input.startsWith("@@@") && input.startsWith("@")) {
 | 
				
			||||||
                    input = input.replace(/^@@/, "/activity ")
 | 
									input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY)
 | 
				
			||||||
                    input = input.replace(/^@/, "/myself ")
 | 
									input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me)
 | 
				
			||||||
				window.ElementValue("InputChat", input)
 | 
									window.ElementValue("InputChat", input)
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
                return(window.MBCHC.orig_ChatRoomSendChat())
 | 
							})
 | 
				
			||||||
 | 
							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))
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            // Actions
 | 
							// Chat room handlers
 | 
				
			||||||
            window.CommandCombine(window.MBCHC.COMMANDS)
 | 
							window.ChatRoomRegisterMessageHandler({ Priority: -220, Description: "MBCHC preprocessor", Callback: (data, sender, msg, metadata) => {
 | 
				
			||||||
            if (window.bcModSdk) window.bcModSdk.registerMod("MBCHC", window.MBCHC.VERSION)
 | 
								data.MBCHC_ID = this.NEXT_MESSAGE
 | 
				
			||||||
            window.MBCHC.LOADED = true
 | 
								this.NEXT_MESSAGE += 1
 | 
				
			||||||
            console.info(window.MBCHC.log("loaded version " + window.MBCHC.VERSION))
 | 
								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()
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
        return(window.MBCHC.orig_AsylumGGTSSAddItems())
 | 
						},
 | 
				
			||||||
    } // Loader
 | 
						preloader() {
 | 
				
			||||||
 | 
							this.SDK = window.bcModSdk.registerMod({name:"MBCHC",fullName:"Mute's Bondage Club Hacks Collection",version:this.VERSION,repository:"https://code.fleshless.org/mute/MBCHC/"})
 | 
				
			||||||
 | 
							this.before = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs,next) => {try {cb?.(...nextargs)} catch (x) {console.error(x)} finally {return next(nextargs)}})
 | 
				
			||||||
 | 
							this.after = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs,next) => {const result = next(nextargs); try {cb?.(...nextargs)} catch (x) {console.error(x)} finally {return result}})
 | 
				
			||||||
 | 
							if (window.CurrentModule && window.CurrentScreen && !("Character" === window.CurrentModule && "Login" === window.CurrentScreen)) return this.loader()
 | 
				
			||||||
 | 
							this.remove_load_hook = this.before("AsylumGGTSSAddItems", () => this.loader())
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					} // MBCHC
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					fetch("https://code.fleshless.org/mute/MBCHC/raw/branch/master/bondage-club-mod-sdk-1.1.0.js").then(r=>r.text()).then(r=>eval(r)).then(_=>window.MBCHC.preloader()).catch(x=>console.error(x)) /* eslint-disable-line no-eval */
 | 
				
			||||||
})()
 | 
					})()
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										667
									
								
								mbchc-local.user.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										667
									
								
								mbchc-local.user.js
									
									
									
									
									
										Normal 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 */
 | 
				
			||||||
 | 
					})()
 | 
				
			||||||
							
								
								
									
										725
									
								
								mbchc.mjs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										725
									
								
								mbchc.mjs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,725 @@
 | 
				
			|||||||
 | 
					// Take a look at the .d.ts for comments.
 | 
				
			||||||
 | 
					export const version = '107.13.0'
 | 
				
			||||||
 | 
					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 : ' '}`),
 | 
				
			||||||
 | 
						//pad_chat: c => loo(100, _ => c.scrollHeight <= c.clientHeight, _ => yes(void c.prepend(U.mkdiv('\u061C')))) && void W.ElementScrollToEnd('TextAreaChatLog')
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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')
 | 
				
			||||||
 | 
							cur(W.ElementIsScrolledToEnd('TextAreaChatLog'), r => yes(void W.ChatRoomResize(false)) && r && void W.ElementScrollToEnd('TextAreaChatLog'))
 | 
				
			||||||
 | 
							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
 | 
				
			||||||
 | 
						enter: (ic, i, b, is) => yes(H.input = i, H.bottom = b, H.ids = is, ic.readOnly = true, val(ic.parentElement?.parentElement?.dataset, d => d['mbchcMode'] = 'h'), b && void W.ElementScrollToEnd('TextAreaChatLog')),
 | 
				
			||||||
 | 
						exit: (ic, r) => yes(ic.readOnly = false, val(ic.parentElement?.parentElement?.dataset, d => del(d, 'mbchcMode')), r && val(H.input, i => ic.value = i), val(H.bottom, b => b && void W.ElementScrollToEnd('TextAreaChatLog')), 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,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						SUBCOMMANDS_MBCHC, H, U, // debug, will go away
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						NEXT_MESSAGE: 1,
 | 
				
			||||||
 | 
						LOG_MESSAGES: false,
 | 
				
			||||||
 | 
						LOADED: false,
 | 
				
			||||||
 | 
						AUTOHACK_ENABLED: false,
 | 
				
			||||||
 | 
						/**@type {number | undefined}*/LAST_HACKED: $,
 | 
				
			||||||
 | 
						//HISTORY_MODE: false,
 | 
				
			||||||
 | 
						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 `),
 | 
				
			||||||
 | 
						//PREF_ACTIVITY: `${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) }
 | 
				
			||||||
 | 
						},
 | 
				
			||||||
 | 
						//complete(options, space = true) {
 | 
				
			||||||
 | 
						//	if (m_t(options)) return void U.bell()
 | 
				
			||||||
 | 
						//	if (options.length > 1) {
 | 
				
			||||||
 | 
						//		const width = Math.max(...options.map(o => o.length))
 | 
				
			||||||
 | 
						//		let pref = null
 | 
				
			||||||
 | 
						//		for (let i = width; i > 0; i -= 1) {
 | 
				
			||||||
 | 
						//			const test = options[0].slice(0, i)
 | 
				
			||||||
 | 
						//			if (options.every(o => o.startsWith(test))) {
 | 
				
			||||||
 | 
						//				pref = test
 | 
				
			||||||
 | 
						//				break
 | 
				
			||||||
 | 
						//			}
 | 
				
			||||||
 | 
						//		}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						//		if (pref) this.complete([pref], false)
 | 
				
			||||||
 | 
						//		this.comp_hint(options)
 | 
				
			||||||
 | 
						//	} else W.ElementValue('InputChat', W.ElementValue('InputChat').replace(this.RE_LAST_WORD, `$1${options[0]}${space ? ' ' : $S}`))
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						///**
 | 
				
			||||||
 | 
						// * Displays strings as completion hint
 | 
				
			||||||
 | 
						// * @param {string[]} options List of words to display. The order will be modified without copy.
 | 
				
			||||||
 | 
						// * @returns {undefined}
 | 
				
			||||||
 | 
						// */
 | 
				
			||||||
 | 
						//comp_hint(options) {
 | 
				
			||||||
 | 
						//	if (m_t(options)) return
 | 
				
			||||||
 | 
						//	this.COMP_HINT.innerHTML = '<div>' + options.sort().reverse().map(s => `<div>${s}</div>`).join($S) + '</div>'
 | 
				
			||||||
 | 
						//	this.COMP_HINT.style.display = 'block'
 | 
				
			||||||
 | 
						//	W.ElementSetDataAttribute(this.COMP_HINT.id, 'colortheme', (W.Player.ChatSettings?.ColorTheme || 'Light'))
 | 
				
			||||||
 | 
						//	const rescroll = W.ElementIsScrolledToEnd('TextAreaChatLog')
 | 
				
			||||||
 | 
						//	W.ChatRoomResize(false)
 | 
				
			||||||
 | 
						//	if (rescroll) W.ElementScrollToEnd('TextAreaChatLog')
 | 
				
			||||||
 | 
						//	this.COMP_HINT.firstChild?.lastChild?.scrollIntoView({behaviour: 'instant'})
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						///**
 | 
				
			||||||
 | 
						// * Returns true if the completion box is attached to body and its display isn't none
 | 
				
			||||||
 | 
						// * @returns {boolean}
 | 
				
			||||||
 | 
						// */
 | 
				
			||||||
 | 
						//comp_hint_visible() {
 | 
				
			||||||
 | 
						//	return this.COMP_HINT.parentElement && this.COMP_HINT.style.display !== 'none'
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//comp_hint_hide() {
 | 
				
			||||||
 | 
						//	if (!this.comp_hint_visible()) return
 | 
				
			||||||
 | 
						//	this.COMP_HINT.style.display = 'none'
 | 
				
			||||||
 | 
						//	W.ChatRoomResize(false)
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//char2targets(/**@type {Character}*/char) {
 | 
				
			||||||
 | 
						//	const/**@type {Set<string>}*/result = new Set()
 | 
				
			||||||
 | 
						//	val(U.cid(char)?.toString(), id => result.add(id).add(`=${id}`))
 | 
				
			||||||
 | 
						//	for (const t of U.split(char.Name)) {
 | 
				
			||||||
 | 
						//		result.add(t)
 | 
				
			||||||
 | 
						//		result.add(`@${t}`)
 | 
				
			||||||
 | 
						//	}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						//	if (char.Nickname !== $) for (const t of U.split(char.Nickname)) {
 | 
				
			||||||
 | 
						//		result.add(t)
 | 
				
			||||||
 | 
						//		result.add(`@${t}`)
 | 
				
			||||||
 | 
						//	}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						//	return result
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//complete_target(/**@type {string}*/token, me2 = true, check_perms = false) {
 | 
				
			||||||
 | 
						//	const [locase, found] = [token.toLocaleLowerCase(), new Set()]
 | 
				
			||||||
 | 
						//	for (const c of U.crc) {
 | 
				
			||||||
 | 
						//		if ((c.IsPlayer() && !me2) || (check_perms && !W.ServerChatRoomGetAllowItem(W.Player, c))) continue
 | 
				
			||||||
 | 
						//		for (const s of this.char2targets(c)) {
 | 
				
			||||||
 | 
						//			if (s.toLocaleLowerCase().startsWith(locase)) found.add(s)
 | 
				
			||||||
 | 
						//		}
 | 
				
			||||||
 | 
						//	}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						//	//this.complete(Array.from(found))
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//complete_common() { // w.ElementValue('InputChat') will strip the trailing whitespace
 | 
				
			||||||
 | 
						//	const E = D.querySelector('#InputChat')
 | 
				
			||||||
 | 
						//	ass('somehow #InputChat is broken', E instanceof HTMLTextAreaElement)
 | 
				
			||||||
 | 
						//	return [this, E.value, U.split(E.value)]
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//complete_mbchc(_args, _locase, _cmdline) {
 | 
				
			||||||
 | 
						//	const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object
 | 
				
			||||||
 | 
						//	if (m_t(tokens)) return
 | 
				
			||||||
 | 
						//	if (tokens.length < 2) return //mbchc.complete([`${CommandsKey}${this.Tag}`])
 | 
				
			||||||
 | 
						//	const subname = tokens[1].toLocaleLowerCase()
 | 
				
			||||||
 | 
						//	if (tokens.length < 3) return //mbchc.complete(Object.keys(SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname))) // Complete subcommand name
 | 
				
			||||||
 | 
						//	const sub = SUBCOMMANDS_MBCHC[subname]
 | 
				
			||||||
 | 
						//	if (sub && sub.args) {
 | 
				
			||||||
 | 
						//		const argname = Object.keys(sub.args)[tokens.length - 3]
 | 
				
			||||||
 | 
						//		if (argname === 'TARGET') return mbchc.complete_target(tokens.at(-1), false)
 | 
				
			||||||
 | 
						//		if (argname === '[TARGET]') return mbchc.complete_target(tokens.at(-1))
 | 
				
			||||||
 | 
						//	}
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//complete_fbc_anim(_args, _locase, _cmdline) {
 | 
				
			||||||
 | 
						//	const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object
 | 
				
			||||||
 | 
						//	if (m_t(tokens)) return
 | 
				
			||||||
 | 
						//	if (tokens.length < 2) return mbchc.complete([`${CommandsKey}${this.Tag}`])
 | 
				
			||||||
 | 
						//	if (tokens.length > 2) return void U.bell()
 | 
				
			||||||
 | 
						//	const anim = tokens[1].toLocaleLowerCase()
 | 
				
			||||||
 | 
						//	return mbchc.complete(Object.keys(W.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim)))
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//complete_fbc_pose(_args, _locase, _cmdline) {
 | 
				
			||||||
 | 
						//	const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object
 | 
				
			||||||
 | 
						//	if (m_t(tokens)) return
 | 
				
			||||||
 | 
						//	if (tokens.length < 2) return mbchc.complete([`${CommandsKey}${this.Tag}`])
 | 
				
			||||||
 | 
						//	const pose = tokens.at(-1).toLocaleLowerCase()
 | 
				
			||||||
 | 
						//	return mbchc.complete(W.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose)))
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//focus_chat_checks() { // we only want to catch chat log and canvas (no map though) keypresses
 | 
				
			||||||
 | 
						//	if (D.activeElement === D.body) return true
 | 
				
			||||||
 | 
						//	if (D.activeElement?.id !== 'MainCanvas') return false
 | 
				
			||||||
 | 
						//	return !W.ChatRoomMapViewIsActive()
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//focus_chat_whitelist(/**@type {KeyboardEvent}*/event) {
 | 
				
			||||||
 | 
						//	if (event.ctrlKey && event.key === 'v') return true // Ctrl+V should paste
 | 
				
			||||||
 | 
						//	return false
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						//focus_chat(/**@type {KeyboardEvent}*/event) {
 | 
				
			||||||
 | 
						//	if (event.repeat) return // Only unique presses please
 | 
				
			||||||
 | 
						//	if (!this.focus_chat_checks()) return
 | 
				
			||||||
 | 
						//	if ([event.altKey, event.ctrlKey, event.metaKey].some(Boolean) && !this.focus_chat_whitelist(event)) return // Alt, ctrl and meta should all be false
 | 
				
			||||||
 | 
						//	if (U.style('#InputChat', s => s.display) !== 'inline') return // Input chat missing
 | 
				
			||||||
 | 
						//	W.ElementFocus('InputChat')
 | 
				
			||||||
 | 
						//},
 | 
				
			||||||
 | 
						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},
 | 
				
			||||||
 | 
							]
 | 
				
			||||||
 | 
							mut(D.createElement('style'), c => void D.head.append(c), 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%; }
 | 
				
			||||||
 | 
								#chat-room-div[data-mbchc-mode="h"] #TextAreaChatLog::after { content: '𝗵𝗶𝘀𝘁𝗼𝗿𝘆 ⟨𝘗𝘨𝘜𝘱/𝘋𝘯⟩ 𝗌𝖼𝗋𝗈𝗅𝗅 ⇅ ⟨𝘌𝘯𝘵𝘦𝘳⟩ 𝗌𝖾𝗇𝖽 ↵ ⟨𝘛𝘢𝘣⟩ 𝖾𝖽𝗂𝗍 ⌨ ⟨𝘌𝘴𝘤⟩ 𝖺𝖻𝗈𝗋𝗍 ⟲'; display: block; position: sticky; bottom: 0; background: black; color: orange; padding: 0 0.2ex; animation: 0.2s cubic-bezier(0.19, 1, 0.22, 1) mbchc_hist_hint_show; }
 | 
				
			||||||
 | 
								#InputChat:read-only { background: black; color: orange; }
 | 
				
			||||||
 | 
								@keyframes mbchc_hist_hint_show { from {transform: translateX(-100%);} to {transform: translateX(0);} }
 | 
				
			||||||
 | 
							`)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							// 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))))
 | 
				
			||||||
 | 
							//{
 | 
				
			||||||
 | 
							//	let input = W.ElementValue('InputChat')
 | 
				
			||||||
 | 
							//	if (!input.startsWith('@@@') && input.startsWith('@')) {
 | 
				
			||||||
 | 
							//		input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY)
 | 
				
			||||||
 | 
							//		input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me)
 | 
				
			||||||
 | 
							//		W.ElementValue('InputChat', input)
 | 
				
			||||||
 | 
							//	}
 | 
				
			||||||
 | 
							//})
 | 
				
			||||||
 | 
							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) switch (e.key) {
 | 
				
			||||||
 | 
									case 'Tab': case 'Escape':
 | 
				
			||||||
 | 
										e.stopImmediatePropagation()
 | 
				
			||||||
 | 
										e.preventDefault() // falls through
 | 
				
			||||||
 | 
									case 'Enter':
 | 
				
			||||||
 | 
										H.exit(ic, e.key === 'Escape') // 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'))
 | 
				
			||||||
 | 
								//val(asa(HTMLDivElement, D.querySelector('#TextAreaChatLog')), c => c.scrollHeight > c.clientHeight || void U.pad_chat(c))
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
							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' && D.querySelector('#InputChat') && D.querySelector('#TextAreaChatLog') && this.comp_hint_visible()) { // Upstream
 | 
				
			||||||
 | 
							//		const fontsize = ChatRoomFontSize
 | 
				
			||||||
 | 
							//		//w.ElementPositionFix('TextAreaChatLog', fontsize, 1005, 66, 988, 630)
 | 
				
			||||||
 | 
							//		//w.ElementPositionFix(this.COMP_HINT.id, fontsize, 1005, 701, 988, 200)
 | 
				
			||||||
 | 
							//		W.ElementPositionFix(this.COMP_HINT.id, fontsize, 800, 65, 200, 835)
 | 
				
			||||||
 | 
							//		//this.COMP_HINT.style.display = 'flex'
 | 
				
			||||||
 | 
							//	}
 | 
				
			||||||
 | 
							//})
 | 
				
			||||||
 | 
							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)
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							//D.addEventListener('keydown', event => void this.focus_chat(event)) // Looks like the club got better at this
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							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()
 | 
				
			||||||
 | 
									H.enter(ic, input, W.ElementIsScrolledToEnd('TextAreaChatLog'), 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
									
								
							
							
						
						
									
										5818
									
								
								package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										92
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								package.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
						"name": "mbchc",
 | 
				
			||||||
 | 
						"version": "107.13.0",
 | 
				
			||||||
 | 
						"description": "Mute's Bondage Club Hacks Collection",
 | 
				
			||||||
 | 
						"author": "Mute",
 | 
				
			||||||
 | 
						"type": "module",
 | 
				
			||||||
 | 
						"devDependencies": {
 | 
				
			||||||
 | 
							"bc-stubs": "^107.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",
 | 
				
			||||||
 | 
								"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"
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							]
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										30
									
								
								server.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								server.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					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) => {
 | 
				
			||||||
 | 
						rq.method !== undefined && resp[rq.method] !== undefined && (new URL(`http://${config.host}:${config.port}${rq.url}`)).pathname === '/' ? resp[rq.method](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}`))
 | 
				
			||||||
		Reference in New Issue
	
	Block a user