1 Commits

Author SHA1 Message Date
2f4d2f9f50 massive cleanup
look at me, being all clever
2022-11-07 04:52:25 +00:00
12 changed files with 1118 additions and 8103 deletions

1
.gitignore vendored
View File

@@ -1 +0,0 @@
node_modules

View File

@@ -2,9 +2,97 @@
This document is updated with stable releases, for additional documentation please consult [[the wiki|https://code.fleshless.org/mute/MBCHC/wiki/Home]].
* Unstable doc: https://code.fleshless.org/mute/MBCHC/wiki/unstable
* Trunk doc: https://code.fleshless.org/mute/MBCHC/wiki/trunk
## PSA
The only supported version is unstable, don't use any other.
## Stable vs. unstable versions
Stable version is updated less often and after some testing. Hopefully it contains less bugs, but also is behind on new features. By the same token, unstable version is updated more often and with minimal testing by the authors. Think of it as beta versions of the club. Trunk version is the least tested and the most updated.
## Feedback
We are available at the club in a private room named `MBCHC`. If Mute isn't there, leave a message.
## Loader scripts
These download the corresponding main script, making sure it's always fresh. Most Tampermonkey users should probably use the loader instead of adding the main script directly. Tampermonkey has an update check, but loaders are just more convenient if you want to always have the current version.
On the other hand, if you want to control the script versions yourself, don't use the loader.
## Installation
### BCE integration
None at the moment. Please ask BCE devs to add MBCHC loading if you want to have it (seems like a good idea to us).
### Tampermonkey
1. Install Tampermonkey.
2. Select a script you want to add below, then click its URL.
3. Tampermonkey should recognise the script and prompt you for installation.
4. Refresh the club page and enjoy.
### Bookmarklet
(Doesn't work just yet, will be supported in the next release.)
## Scripts
### `mbchc-loader.user.js`
The loader script for the stable version (**if unsure, use this**).
URL: https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-loader.user.js
### `mbchc.user.js`
The main script, stable version.
URL: https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc.user.js
### `mbchc-loader-dev.user.js`
The loader script for the unstable version (**use this if you want to help testing**).
URL: https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-loader-dev.user.js
### `mbchc-dev.user.js`
The main script, unstable version.
URL: https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-dev.user.js
### `mbchc-local.user.js`
The main script, trunk version. (**use this if you really want the latest**).
URL: https://code.fleshless.org/mute/MBCHC/raw/branch/master/mbchc-local.user.js
## Features
These are new commands this mod adds, use `/help` for syntax.
### /disappear
This command will make you invisible, if you are wearing an anal hook tied to hair.
You might also want to consider hiding your arousal meter.
### /autohack
Toggles an autohack mode. Currently this mode starts hacking again as soon as the data gets stolen.
Be informed that autohacking is much less lucrative due to game mechanics. No plans to "fix" this.
### /donate
Buy data on the black market and send it to someone. Serves as money transfer. Doesn't require anything from the other party except from being restrained. Data will cost you more than it will make, this is by design.
### /nod
Send a nod activity, as if clicked on the button.
### /shake
Send a headshake activity, as if clicked on the button.
### /shrug
Send a shrug activity.
### /myself (or @)
Send a custom activity as yourself. Example: "@shivers" will send "(YourName shivers.)"
### /activity (or @@)
Send a custom activity. Example: "@@it starts to rain" will send "(It starts to rain.)"
### /title
Set a custom title. Currenty only works with the same titles you can select. **Work in progress.**
## Geek corner
Yes, we are aware of all the nifty git features (branches etc.), we just don't use them.
This mod is low effort, just enough to work without errors and deliver features we find useful.
We also have to keep in mind not everyone in the club works in software development, so it needs to be
as user-friendly as possible.
## Contributing
Sure, welcome onboard. Please send pull requests, patches, ideas, feedback, feature requests and bug reports.
## Contacts
Beyond social functions this site provides, you can always find us in the club or drop us an email. We don't use discord.

468
ambient.d.ts vendored
View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

592
mbchc.mjs
View File

@@ -1,592 +0,0 @@
// Take a look at the .d.ts for comments.
export const version = '110.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) => {if (v === $ || !(c?.(v) ?? true)) throw new Error(x); return v}
const/**@type {FP.asa}*/asa = (p, v) => v instanceof p ? v : $
const/**@type {FP.rsc}*/rsc = (f, r) => {try {f($)} catch (x) {r(x)} return true}
const/**@type {FP.Interval}*/range = new (class { proxy = new Proxy($O, this); min = 0; max = 0; mini = false; maxi = false
/**@type {FP.Interval['has']}*/has = (_, x) => val(int(x), x => (this.mini ? cge : cgt)(x, this.min) && (this.maxi ? cle : clt)(x, this.max)) ?? false
})()
const/**@type {FP.rng}*/rng = (min, max, mini = true, maxi = true) => mut(range.proxy, Object.assign(range, {min, max, mini, maxi}))
const/**@type {FP.m_t}*/m_t = v => { if (typeof v === 'string') return v.length === 0
if (v === $ || typeof v === 'object') return v === $ || v === null
|| ['length', 'size'].some(n => n in v && cur(/**@type {OBJ}*/(v)[n], x => typeof x === 'number' && x === 0))
|| (Object.getPrototypeOf(v) === Object.prototype && Reflect.ownKeys(v).length === 0)
return typeof v === 'boolean' && !v
}
//const/**@type {FP.loo}*/loo = (m, c, a) => {let r; for (let n = 0; c($) && n < m; n++) r = a($); return r}
/**@template T*/const Pipe = /**@implements {FP.Pipeline}*/class PipeClass {
/**@type {FP.Pipeline<T>['proxy']}*/proxy = new Proxy(/**@type {this & Record<number, T | undefined>}*/(this), {get(t, n, r) {return cur(typeof n === 'string' && int(n), i => {
if (typeof i !== 'number' || i < 0) return Reflect.get(t, n, r)
let c = 0; for (const v of t) if (++c > i) return v; return $
})}})
constructor(/**@type {Iterable<T>}*/iterable) {this.iterable = iterable}
/**@type {FP.Pipeline<T>['me']}*/me(iterable) {return (new PipeClass(iterable)).proxy}
[Symbol.iterator]() {return this.iterable[Symbol.iterator]()}
/**@type {FP.Pipeline<T>['rdc']}*/rdc(i, f) {let ax = i; for (const v of this) ax = f(ax, v); return ax}
/**@type {FP.Pipeline<T>['any']}*/any(f) {for (const v of this) if (f(v)) return true; return false}
/**@type {FP.Pipeline<T>['all']}*/all(f) {for (const v of this) if (!f(v)) return false; return true}
/**@type {FP.Pipeline<T>['map']}*/map(f) {return this.me((function*(i, f) {for (const v of i) yield f(v)})(this, f))}
/**@type {FP.Pipeline<T>['sel']}*/sel(f) {return this.me((function*(i, f) {for (const v of i) if (f(v)) yield v})(this, f))}
}
const/**@type {FP.P}*/P = I => (new Pipe(I)).proxy
const/**@type {Cons.Wrap}*/CW = new (class {
/**@type {Cons.MS}*/ms = {w: 'warn', i: 'info', d: 'debug', l: 'log'}; w = this.gen('w'); i = this.gen('i'); d = this.gen('d'); l = this.gen('l')
/**@type {Cons.E}*/e = x => yes(void console.error(x))
/**@type {Cons.Wrap['gen']}*/gen(m) {return msg => yes(void console[this.ms[m]](`MBCHC: ${msg}`))}
})()
const/**@type {Settings.Methods}*/Settings = { // FIXME separate a proper V1 type from an unknown object in the ExtensionSettings
/**I hate change*/migrate_0_1(v0) { if (v0.MBCHC === $) return true
val(v0.MBCHC.timezones, tz => this.save(v1 => v1.TZ = {...tz, ...v1.TZ}))
W.ServerAccountUpdate.QueueData({OnlineSettings: del(v0, 'MBCHC')})
return CW.w('MBCHC: settings migration done (v0 -> v1). This should never happen again on the same account.')
},
save(f = $) {W.Player.ExtensionSettings['MBCHC'] ||= {}; f?.(this.v1); return yes(void W.ServerPlayerExtensionSettingsSync('MBCHC'))},
replace: v1 => yes(void (W.Player.ExtensionSettings['MBCHC'] = v1), void Settings.save()),
'purge!': () => Settings.replace(/** @type {Settings.V1} */({})),
get v0() {return Reflect.get(W.Player, 'OnlineSettings')},
get v1() {return mut(/**@type {Settings.V1}*/(W.Player.ExtensionSettings['MBCHC']) ?? {}, v1 => { // we need to check and repair the whole object every time we access it
v1.TZ ||= {}
})},
}
const/**@type {TZ_Cache}*/TZ = { map: new Map(), RE: /(?:gmt|utc)\s*([+-])\s*(\d\d?)/i,
parse: desc => val(desc, v => val(TZ.RE.exec(v), m => val(int(`${m[1] ?? $S}${m[2] ?? $S}`), n => n in rng(-12, 12) ? n : $))),
memo: (cid, desc = $) => val(Settings.v1.TZ[cid] ?? TZ.parse(desc), n => mut(n, TZ.map.set(cid, n))),
lookup: c => val(U.cid(c), cid => TZ.map.get(cid) ?? TZ.memo(cid, c.Description)),
}
const/**@type {Utils}*/U = { remove_loader_hook: $, RGB: {Polly: '#81b1e7', Mute: '#6c2132'}, ACT: `${CommandsKey}activity `,
RE: { SPACES: /\s+/gu, REL: {L: /^<+$/, R: /^>+$/}, '@': [/^@/, /^@@/] },
get crc() {return W.ChatRoomCharacter},
get ic() {return asa(HTMLTextAreaElement, D.querySelector('#InputChat'))},
cid: c => c.MemberNumber,
dn: c => W.CharacterNickname(c),
current: () => `${W.CurrentModule}/${W.CurrentScreen}`,
style: (q, f) => cur(D.querySelector(q), e => e instanceof HTMLElement ? f(e.style) : $),
inform: html => yes(void W.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`)),
report: x => U.inform(String(x)) && CW.e(x),
split: text => text.split(U.RE.SPACES),
abs2char: pos => ass(`invalid position ${pos}`, U.crc[pos]),
rel2char: t => cur(ass('can\'t find my position', U.crc.findIndex(char => char.IsPlayer()), n => n >= 0), me =>
cur(ass(`failed to parse target "${t}"`, U.RE.REL.L.test(t) ? sub : U.RE.REL.R.test(t) ? add : $)(me, t.length), pos =>
cur(pos % U.crc.length, p => U.abs2char(p < 0 ? p + U.crc.length : p)))),
cid2char: id => id === U.cid(W.Player) ? W.Player : ass(`character ${id} not found in the room`, U.crc.find(c => id === U.cid(c))),
target2char(target) { let t = target.trim(); const /**@type {Set<Character>}*/f = new Set() // FIXME Target should be low case (take a look at this later)
if (m_t(t)) return W.Player
if (t.at(0) === '=') return U.cid2char(ass(`invalid member number "${target}"`, int(t.slice(1))))
if ('<>'.includes(t.at(0) ?? '-')) return U.rel2char(t)
const n = int(t)
if (n !== $ && n.toString() === t) { // We got a number
if (n in rng(0, 9)) return U.abs2char(n)
if (n in rng(11, 15)) return U.abs2char(n - 11)
if (n in rng(21, 25)) return U.abs2char(n - 16)
U.crc.filter(c => U.cid(c)?.toString().includes(t)).forEach(c => f.add(c)) // or union with `new Set(array)`
}
if (t.at(0) === '@') t = t.slice(1)
U.crc.filter(c => c.Name.toLocaleLowerCase().includes(t)).forEach(c => f.add(c))
U.crc.filter(c => val(c.Nickname, nn => nn.toLocaleLowerCase().includes(t))).forEach(c => f.add(c))
const found = [...f.keys()]
ass(`target "${target}": multiple matches (${found.map(c => `${U.cid(c)}|${c.Name}|${c.Nickname ?? c.Name}`).join(',')})`, found.length, n => n < 2) // make the list better
return ass(`target "${target}": no match`, found[0])
},
mkdiv: html => mut(D.createElement('div'), e => html === $ || (e.innerHTML = html)),
bell: () => yes(U.style('#InputChat', s => s.outline = 'solid red'), void setTimeout(() => {U.style('#InputChat', s => s.outline = $S)}, 100)),
targets: (reject_player = false, check_perms = false) => mut(new Set(), r => {
const wrap_text = (/**@type {string}*/text) => int(text) === $ ? text : `@${text}`
const wrap_int = (/**@type {number}*/i) => i in rng(0, 9) || i in rng(11, 15) || i in rng(21, 25) ? `=${i}` : i.toString()
U.crc.filter(c => !(reject_player && c.IsPlayer()) && !(check_perms && !W.ServerChatRoomGetAllowItem(W.Player, c))).forEach(c => {
U.split(c.Name).forEach(t => r.add(wrap_text(t)))
val(c.Nickname, n => U.split(n))?.forEach(t => r.add(wrap_text(t)))
val(U.cid(c), cid => r.add(wrap_int(cid)))
})
r.delete($S)
}),
complete_mbchc() {C.icomplete(ws =>
ws.length < 2 ? new Set([`${CommandsKey}${this.Tag}`])
: ws.length < 3 ? new Set(Object.keys(SUBCOMMANDS_MBCHC)) // FIXME no idea why enu() thinks there's a number in there
: val(ws[1], w => val(SUBCOMMANDS_MBCHC[w], sub => val(sub.args, as => val(enu(as)[ws.length - 3], a =>
a === 'TARGET' ? U.targets(true) : a === '[TARGET]' ? U.targets() : $)))) ?? $Ss
)},
complete_do_target(actions) {
if (m_t(actions)) return $Ss
if (m_t(actions.others)) return val(U.cid(W.Player), cid => new Set([cid.toString()])) ?? $Ss // Target is always the player
return U.targets(m_t(actions.self), true)
},
complete_do() {C.icomplete(ws => {
if (ws.length < 2) return new Set([`${CommandsKey}${this.Tag}`])
// Now, we *could* run a filter to exclude impossible activities, but it isn't very useful, and also seems like a lot of CPU to iterate over every action on every zone of every char in the room
let low = (ws[1] ?? $S).toLocaleLowerCase()
const DD = W.MBCHC.DO_DATA
if (ws.length < 3) return new Set(enu(DD.verbs).map(String)) // Complete verb
const ags = DD.verbs[low]
if (ags === $) return $Ss
low = (ws[2] ?? $S).toLocaleLowerCase()
if (ws.length < 4) { // Complete zone or target
if (enu(ags).length < 2) return val(ags[enu(ags)[0] ?? $S], t => U.complete_do_target(t)) ?? $Ss // Zone implied, complete target
return new Set(Object.entries(DD.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone, _ag]) => zone))
}
if (ws.length < 5) { // Complete target where it belongs
if (enu(ags).length < 2) return $Ss // Zone implied, target already given
return val(ags[DD.zones[low] ?? $S], t => U.complete_do_target(t)) ?? $Ss
}
return $Ss
})},
replace_me: (_, __, whole) => cur(whole.slice(1), t => `${U.ACT}<${U.cid(W.Player)}:>SourceCharacter${t.startsWith('\'') || t.startsWith(' ') ? $S : ' '}`),
scroll: () => yes(void W.ElementScrollToEnd('TextAreaChatLog')),
get scrolled() {return W.ElementIsScrolledToEnd('TextAreaChatLog')},
rescroll: f => cur(U.scrolled, s => yes(f, s && U.scroll())),
}
const/**@type {SUBCOMMANDS}*/SUBCOMMANDS_MBCHC = {
autohack: {desc: 'toggle the autohack feature', cb: mbchc => void U.inform(`Autohack is now ${((mbchc.AUTOHACK_ENABLED = !mbchc.AUTOHACK_ENABLED)) ? 'enabled' : 'disabled'}`)},
donate: {desc: 'buy data and send it to recipient', args: {TARGET: $O}, cb: (mbchc, args) => void mbchc.donate_data(args[0] ?? $S)},
tz: {desc: 'set target\'s UTC offset', args: {OFFSET: $O, '[TARGET]': $O}, cb: (mbchc, args) => mbchc.set_timezone(args)},
'purge!': {desc: 'delete MBCHC online saved data', cb: () => Settings['purge!']()},
}
const/**@type {Complete}*/C = { S_OPTS: {behavior: 'instant'},
e: mut(Object.assign(U.mkdiv(), {id: 'mbchcCompHint'}), div => void div.append(U.mkdiv())),
get div() {return C.e.firstElementChild ?? $},
lcp: cs => m_t(cs) ? $S : mut({p: ''}, r => void cur(P(cs), P => {for (let on = true, i = Math.max(...P.map(o => o.length)); on && i > 0; i -= 1) {
cur((P[0] ?? $S).slice(0, i), p => P.all(o => o.startsWith(p)) && yes(r.p = p) && (on = false))
}})).p,
complete_word: (i, cs, ci = false) => cur(void cs.forEach(c => (ci ? c.toLocaleLowerCase() : c).startsWith(ci ? i.toLocaleLowerCase() : i) || cs.delete(c)), _ => cs.size < 2 ? P(cs)[0] ?? i : C.lcp(cs)),
hint: cs => m_t(cs) || yes(_ => {
yes(C.e.style.display = 'block') && C.div?.replaceChildren(...[...cs].sort().reverse().map(U.mkdiv))
W.ElementSetDataAttribute(C.e.id, 'colortheme', W.Player.ChatSettings?.ColorTheme ?? 'Light')
U.rescroll(_ => void W.ChatRoomResize(false))
C.div?.lastElementChild?.scrollIntoView(C.S_OPTS)
}),
get hidden() {return C.e.parentElement === null || C.e.style.display === 'none'},
hide: () => C.hidden || yes(C.e.style.display = 'none', void W.ChatRoomResize(false)),
complete(f, ci = false) {
const e = ass('Somehow #InputChat is broken', U.ic)
if (e.selectionStart !== e.selectionEnd) return U.bell()
const input = e.value
const before_cursor = input.slice(0, e.selectionStart)
const words = U.split(before_cursor)
const cs = f(words)
const word = words.at(-1) ?? ''
const cword = C.complete_word(word, cs, ci)
const additions = cword.slice(word.length)
if (!m_t(additions)) e.setRangeText(additions, e.selectionStart, e.selectionEnd, 'end')
if (cs.size > 1) C.hint(cs)
if (cs.size === 1) e.setRangeText(' ', e.selectionStart, e.selectionEnd, 'end')
if (cs.size === 0) U.bell()
return true
},
icomplete: f => C.complete(f, true),
}
const/**@type {InputHistory}*/H = { input: undefined, ids: undefined, bottom: undefined, // FIXME ids don't need to be a set, but I'm too tired right now
key: {
Escape: (_, ic) => yes(val(H.input, i => ic.value = i)),
ArrowLeft: (_, ic) => yes(void ic.setSelectionRange(0, 0)),
},
icro: (ic, ro) => yes(ic.readOnly = ro, val(ic.parentElement?.parentElement?.dataset, d => ro ? d['mbchcMode'] = 'h' : del(d, 'mbchcMode'))),
enter: (ic, i, b, is) => yes(H.input = i, H.bottom = b, H.ids = is, H.icro(ic, true), b && U.scroll()),
exit: (ic, e) => yes(H.icro(ic, false), H.key[e.key]?.(e, ic), val(H.bottom, b => b && U.scroll()), W.ChatRoomLastMessageIndex = W.ChatRoomLastMessage.length)
}
ass('MBCHC found, aborting loading', W.MBCHC === $, Boolean)
ass('AsylumGGTSSAddItems() not found, aborting MBCHC loading', W.AsylumGGTSSAddItems)
const sdk = ass('SDK not found, please load with (or after) FUSAM or any other mod that uses SDK', W.bcModSdk)
const mod = sdk.registerMod({name: 'MBCHC', fullName: 'Mute\'s Bondage Club Hacks Collection', version, repository: 'https://code.fleshless.org/mute/MBCHC/'})
const/**@type {SDK.Hook}*/prior = (name, f) => mod.hookFunction(name, 0, (na, n) => rsc(_ => f(...na), CW.e) && n(na)) // eslint-disable-line @typescript-eslint/no-unsafe-return
const/**@type {SDK.Hook}*/after = (name, f) => mod.hookFunction(name, 0, (na, n) => mut(n(na), rsc(_ => f(...na), CW.e))) // eslint-disable-line @typescript-eslint/no-unsafe-return
// ^ type-safe, new and much improved, but still work in progress
// =================================================================================
// v legacy mess, also type-safe now?
/**@type {Window['MBCHC']}*/W.MBCHC = {
version, /** Just in case someone used it for anything @deprecated*/VERSION: version,
Settings, TZ,
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
LOADED: false,
AUTOHACK_ENABLED: false,
/**@type {number | undefined}*/LAST_HACKED: $,
RE_PREF_ACTIVITY_ME: /^@/,
RE_PREF_ACTIVITY: /^@@/,
RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/,
RE_LAST_WORD: /(^|\s)(\S*)$/,
RE_LAST_LETTER: /\w$/,
RE_ACTIVITY: new RegExp(`^${CommandsKey}activity `),
UTC_OFFSET: new Date().getTimezoneOffset() * 60 * 1000,
MAP_ACTIONS: { // ActivityFemale3DCG
// Action
'nod|yes': {Head: {self: 'Nod'}},
no: {Head: {self: 'Wiggle'}},
moan: {Mouth: {self: 'MoanGag'}},
mumble: {Mouth: {self: 'MoanGagTalk'}},
whimper: {Mouth: {self: 'MoanGagWhimper'}},
groan: {Mouth: {self: 'MoanGagGroan'}},
scream: {Mouth: {self: 'MoanGagAngry'}},
giggle: {Mouth: {self: 'MoanGagGiggle'}},
struggle: {Arms: {self: 'StruggleArms'}},
thrash: {Legs: {self: 'StruggleLegs'}},
// Action zone
'wiggle|shake': {'Arms,Breast,Boots,Butt,Ears,Feet,Hands,Nose,Pelvis,Torso': {self: 'Wiggle'}},
// Action target
whisper: {Ears: {others: 'Whisper'}},
choke: {Neck: {all: 'Choke'}},
brush: {Head: {all: 'TakeCare'}},
french: {Mouth: {others: 'FrenchKiss'}},
sit: {Legs: {others: 'Sit'}},
rim: {Butt: {others: 'MasturbateTongue'}},
press: {Butt: {others: 'Step'}},
rest: {Torso: {others: 'Step'}},
pet: {Head: {all: 'Pet'}},
boop: {Nose: {all: 'Pet'}},
cuddle: {Arms: {others: 'Cuddle'}},
nuzzle: {Nose: {others: 'Cuddle'}},
grab: {Arms: {others: 'Grope'}},
clean: {Mouth: {all: 'Caress'}},
lap: {Legs: {others: 'RestHead'}},
lean: {Breast: {others: 'RestHead'}},
peck: {Mouth: {others: 'PoliteKiss'}},
// Action zone target
item: {
'Breast,Butt,Feet,Legs': {all: 'SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem|Inject'},
'Nipples,Pelvis': {all: 'SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem'},
Arms: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem|Inject'},
Boots: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem'},
'Ears,Mouth': {all: 'TickleItem|RubItem|RollItem'},
'Hood,Nose': {all: 'TickleItem|RubItem'},
Neck: {all: 'TickleItem|RubItem|RollItem|Inject'},
Torso: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem'},
Vulva: {all: 'SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem'},
VulvaPiercings: {all: 'SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem|Inject'},
},
kiss: {
Mouth: {others: 'GagKiss|Kiss|GaggedKiss'},
'Boots,Hands': {self: 'PoliteKiss', others: 'PoliteKiss|GaggedKiss'},
'Arms,Breast,Nipples': {self: 'Kiss', others: 'Kiss|GaggedKiss'},
'Butt,Ears,Feet,Head,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {others: 'Kiss|GaggedKiss'},
},
smooch: {'Hands,Boots': {all: 'Kiss'}},
'nibble|chew': {'Arms,Hands,Boots,Mouth,Nipples': {all: 'Nibble'}, 'Ears,Feet,Legs,Neck,Nose,Pelvis,Torse,Vulva,VulvaPiercings': {others: 'Nibble'}},
'slap|spank': {'Head,Breast,Vulva,VulvaPiercings': {all: 'Slap'}, 'Arms,Boots,Butt,Feet,Hands,Legs,Pelvis,Torso': {all: 'Spank'}},
tickle: {'Arms,Boots,Breast,Feet,Legs,Neck,Pelvis,Torso': {all: 'Tickle'}},
massage: {'Arms,Boots,Feet,Legs,Neck,Pelvis,Torso': {all: 'MassageHands'}},
lick: {'Arms,Boots,Breast,Hands,Mouth,Nipples': {all: 'Lick'}, 'Ears,Feet,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {others: 'Lick'}},
suck: {'Nipples,Hands,Boots': {all: 'Suck'}},
bite: {'Arms,Boots,Feet,Hands,Legs,Mouth': {all: 'Bite'}, 'Breast,Butt,Ears,Head,Neck,Nipples,Nose,Torso': {others: 'Bite'}},
pinch: {'Arms,Ears,Nipples,Nose,Pelvis': {all: 'Pinch'}},
clamp: {Mouth: {all: 'HandGag'}, Nose: {all: 'Choke'}},
step: {'Breast,Neck,Pelvis': {others: 'Step'}},
pull: {'Head,Nose,Nipples': {all: 'Pull'}},
grope: {'Butt,Breast': {all: 'Grope'}, 'Feet,Legs,Pelvis': {others: 'Grope'}},
rub: {'Head,Torso': {others: 'Rub'}, Nose: {all: 'Rub'}, Legs: {self: 'Wiggle'}, Hands: {self: 'Caress'}},
caress: {Hands: {others: 'Caress'}, 'Arms,Breast,Butt,Ears,Feet,Head,Legs,Neck,Nipples,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {all: 'Caress'}},
polish: {'Hands,Boots': {all: 'TakeCare'}},
foot: {'Head,Nose': {others: 'Step'}, 'Torso,Boots': {others: 'MassageFeet'}, 'Vulva,VulvaPiercings': {others: 'MasturbateFoot'}},
fist: {'Vulva,Butt': {all: 'MasturbateFist'}},
fuck: {'Mouth,Vulva,Butt': {others: 'PenetrateSlow'}}, // Peg?
pound: {'Mouth,Vulva,Butt': {others: 'PenetrateFast'}},
tongue: {'Vulva,VulvaPiercings': {others: 'MasturbateTongue'}},
finger: {'Breast,Butt,Vulva,VulvaPiercings': {all: 'MasturbateHand'}},
},
MAP_ZONES: {
ItemBoots: ['foot', 'feet', 'boot', 'boots', 'shoe', 'shoes', 'toes', 'toenails', 'sole', 'soles', 'heel', 'heels'],
ItemFeet: ['leg', 'legs', 'ankle', 'ankles'],
ItemLegs: ['hips', 'hip', 'thighs', 'thigh'],
ItemVulva: ['vulva', 'pussy'],
ItemVulvaPiercings: ['clit', 'clitoris'],
ItemButt: ['butt', 'ass'],
ItemPelvis: ['tummy', 'pelvis'],
ItemTorso: ['body', 'torso', 'back', 'ribs'],
ItemBreast: ['breast', 'breasts', 'boob', 'boobs', 'booby', 'boobie', 'boobies', 'tit', 'tits', 'titty', 'tittie', 'titties'],
ItemNipples: ['nip', 'nips', 'nipple', 'nipples'],
ItemHands: ['hand', 'hands', 'fingers', 'fingernails', 'nails'],
ItemArms: ['arm', 'arms', 'elbow', 'elbows'],
ItemNeck: ['neck'],
ItemMouth: ['mouth', 'lip', 'lips', 'teeth', 'tongue', 'gag', 'cheek', 'cheeks'],
ItemNose: ['nose', 'nostrils'],
ItemEars: ['ear', 'ears', 'earlobe', 'earlobes'],
ItemHead: ['head', 'face', 'hair', 'eyes', 'forehead'],
},
DO_DATA: {verbs: {}, zones: {}},
calculate_maps() {
for (const [verbs, data] of Object.entries(this.MAP_ACTIONS)) {
const/**@type {{[k: string]: {self: string[]; others: string[]}}}*/unwound = {}
for (const [zones, actions] of Object.entries(data)) {
const all = val(actions.all, a => a.split('|')) ?? []
const processed = {self: val(actions.self, s => [...s.split('|'), ...all]) ?? all, others: val(actions.others, o => [...o.split('|'), ...all]) ?? all}
for (const zone of zones.split(',')) unwound[`Item${zone}`] = processed
}
for (const verb of verbs.split('|')) this.DO_DATA.verbs[verb] = unwound
}
for (const [ag, zones] of Object.entries(this.MAP_ZONES)) for (const zone of zones) this.DO_DATA.zones[zone] = ag
},
normalise_message(/**@type {string}*/text, /**@type {OBJ<boolean>}*/options = $O) {
let result = text
if (options['trim'] ?? false) result = result.trim()
if (options['low'] ?? false) result = result.toLocaleLowerCase()
if (options['up'] ?? false) {
const first = result.at(0)?.toLocaleUpperCase() ?? $S
const rest = result.slice(1)
result = first + rest
}
if ((options['dot'] ?? false) && this.RE_LAST_LETTER.test(result)) result = `${result}.`
return result
},
donate_data(/**@type {string}*/target) {
const char = U.target2char(target)
ass('target must not be you', !char.IsPlayer(), Boolean)
ass('target must be bound', char.IsRestrained(), Boolean)
const cost = Math.round(((Math.random() * 10) + 15))
ass('not enough money', W.Player.Money >= cost, Boolean)
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(), Boolean)
ass('no permissions', W.ServerChatRoomGetAllowItem(W.Player, char), Boolean)
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), Boolean)
//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 in [-12,12]', tz in rng(-12, 12), Boolean)
const char = U.target2char(args[1] ?? $S)
return val(U.cid(char), cid => Settings.save(v1 => v1.TZ[cid] = tz) && TZ.memo(cid))
},
command_mbchc(argline, cmdline, args) {
const mbchc = W.MBCHC
try { // `this` is command object
if (m_t(args)) return void U.inform(Object.entries(SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `<div>/mbchc ${cmd} ${val(sub.args, a => enu(a).join(' ')) ?? $S}: ${sub.desc}</div>`).join($S))
const cmd = String(args.shift())
const sub = ass(`unknown subcommand "${cmd}"`, SUBCOMMANDS_MBCHC[cmd])
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
} catch (x) {
U.report(x)
}
},
command_activity(argline, cmdline, _) {
const mbchc = W.MBCHC
if (!m_t(argline.trim())) {
try { // `this` is command object
const message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, $S), {trim: true, dot: true, up: true})
mbchc.send_activity(message)
} catch (x) {
U.report(x)
}
}
},
command_do(_argline, _cmdline, args) {
const mbchc = W.MBCHC
try { // `this` is command object
if (m_t(args)) return void U.inform('<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>' + enu(mbchc.MAP_ACTIONS).join(', ') + '<div>Available zones:</div>' + enu(mbchc.DO_DATA.zones).join(', '))
let [verb, zone, target] = args
const zones = ass(`unknown verb "${verb}"`, mbchc.DO_DATA.verbs[verb ?? $S])
if (enu(zones).length === 1) {
if (target === $) target = zone
zone = mbchc.MAP_ZONES[enu(zones)[0] ?? $S]?.[0]
}
zone = ass('zone missing', zone)
const ag = ass(`unknown zone "${zone}"`, mbchc.DO_DATA.zones[zone])
const types = ass(`zone "${zone}" invalid for "${verb}"`, zones[ag])
let/**@type {Character}*/char = W.Player
if (target !== $ && (m_t(types.self) || !m_t(types.others))) char = U.target2char(target)
const type = char.IsPlayer() ? 'self' : 'others'
const available = W.ActivityAllowedForGroup(char, /**@type {AssetGroupItemName}*/(ag))
//const toy = w.InventoryGet(w.Player, 'ItemHands')
//if (toy && toy.Asset.Name === 'SpankingToys') available.push(w.AssetAllActivities(char.AssetFamily).find(a => a.Name === w.InventorySpankingToysGetActivity?.(w.Player)))
const actions = ass(`zone "${zone}" invalid for ("${verb}" "${type}")`, types[type])
const action = ass(`invalid action (${verb} ${zone} ${target})`, actions.find(name => available.find(a => a.Activity?.Name === name)))
mbchc.run_activity(char, /**@type {AssetGroupItemName}*/(ag), /**@type {ActivityName}*/(action))
} catch (x) { U.report(x) }
},
loader() {
U.remove_loader_hook === $ || yes(void U.remove_loader_hook(), U.remove_loader_hook = $)
if (this.LOADED) return
val(Settings.v0, v0 => Settings.migrate_0_1(v0))
// Calculated values
const/**@type {ICommand[]}*/COMMANDS = [
{Tag: 'mbchc', Description: ': Utility functions ("/mbchc" for help)', Action: this.command_mbchc, AutoComplete: U.complete_mbchc},
{Tag: 'activity', Description: '[Message]: Send a custom activity (or "@@Message", or "@Message" as yourself)', Action: this.command_activity},
{Tag: 'do', Description: ': Do an activity, as if clicked on its button ("/do" for help)', Action: this.command_do, AutoComplete: U.complete_do},
]
const/**@type (e: Event) => void*/css_hook = e => void cur(/**@type {HTMLStyleElement}*/(e.target), css => U.scroll() && void css.removeEventListener('load', css_hook))
mut(D.createElement('style'), c => void D.head.append(c), c => void c.addEventListener('load', css_hook), c => c.textContent = `
#TextAreaChatLog .mbchc { background-color: ${U.RGB.Polly}; margin-left: -0.4em; padding-left: 0.4em; }
#TextAreaChatLog[data-colortheme^="dark"] .mbchc { background-color: ${U.RGB.Mute}; }
#${C.e.id} { display: none; text-align: right; }
#${C.e.id} > div { overflow: auto; position: absolute; bottom: 0; right: 0; max-height: 100%; padding: 0 0.5ex; background-color: ${U.RGB.Polly}; color: black; }
#${C.e.id}[data-colortheme^="dark"] > div { background-color: ${U.RGB.Mute}; color: white; }
#${C.e.id} > div div { margin: 0.25ex 0; }
#chat-room-div #TextAreaChatLog::before { content: ''; display: block; height: 100%; background: repeating-linear-gradient(135deg, transparent 0 20px, #333 2px 22px); }
#chat-room-div[data-mbchc-mode="h"] #TextAreaChatLog::after {
content: '⟨𝘗𝘨𝘜𝘱/𝘋𝘯/↕⟩ 𝗌𝖼𝗋𝗈𝗅𝗅 ⇅ ⟨𝘌𝘯𝘵𝘦𝘳⟩ 𝗌𝖾𝗇𝖽 ↵ ⟨𝘛𝘢𝘣/↔/⌫⟩ 𝖾𝖽𝗂𝗍 ⌨ ⟨𝘌𝘴𝘤⟩ 𝖺𝖻𝗈𝗋𝗍\\A' attr(data-mbchc-h-h); whitespace: pre;
display: block; position: sticky; z-index: 1; bottom: 0; background: black; color: orange; padding: 0 0.2ex; animation: 0.2s cubic-bezier(0.19, 1, 0.22, 1) mbchc_hh_show;
}
#InputChat:read-only { background: black; color: orange; }
@keyframes mbchc_hh_show { from {transform: translateX(-100%);} to {transform: translateX(0);} }
`) // will always scroll the chat on CSS load, I can't be fucked to make it conditional
// Actions
this.calculate_maps()
W.CommandCombine(COMMANDS)
// Hooks
after('ChatRoomReceiveSuitcaseMoney', () => {
if (this.AUTOHACK_ENABLED && this.LAST_HACKED !== $) {
W.CurrentCharacter = U.cid2char(this.LAST_HACKED)
this.LAST_HACKED = $
W.ChatRoomTryToTakeSuitcase()
}
})
prior('ChatRoomSendChat', () => val(U.ic, ic => !ic.value.startsWith('@@@') && ic.value.startsWith('@') && (ic.value = ic.value.replace(U.RE['@'][1], U.ACT).replace(U.RE['@'][0], U.replace_me))))
after('ChatRoomSendChat', () => { // FIXME actually make history a ring buffer of a given size. clear the array and push every string into it, compacting sequential equal strings into one.
const history = W.ChatRoomLastMessage
if ((history.length > 1) && (history.at(-1) === history.at(-2))) {
history.pop()
W.ChatRoomLastMessageIndex -= 1
}
})
prior('ChatRoomCharacterViewDrawOverlay', (C, CX, CY, Z) => {
// if (w.ChatRoomHideIconState < 1 && C.MBCHC) {
// w.DrawRect(CharX + (175 * Zoom), CharY, 50 * Zoom, 50 * Zoom, C.MBCHC.VERSION === w.Player.MBCHC.VERSION ? U.RGB.Polly : U.RGB.Mute)
// }
val(TZ.lookup(C), ctz => W.ChatRoomHideIconState < 1 && void cur(new Date(W.CommonTime() + this.UTC_OFFSET + (ctz * 60 * 60 * 1000)).getHours(), hr =>
void W.DrawTextFit(`${hr < 10 ? '0' : ''}${hr}`, CX + (Z * 200), CY + (Z * 25), Z * 46, 'white', 'black')))
})
const/**@type {(this: HTMLTextAreaElement, e: KeyboardEvent) => void}*/ickd = function(e) {
C.hide()
const ic = this // eslint-disable-line unicorn/no-this-assignment,@typescript-eslint/no-this-alias
if (ic.readOnly && !e.repeat) switch (e.key) { // FIXME maybe deal with modifiers
case 'ArrowUp': case 'ArrowDown': W.ChatRoomScrollHistory(e.key === 'ArrowUp'); break
case 'Escape': case 'Tab': case 'ArrowRight': case 'ArrowLeft':
e.stopImmediatePropagation()
e.preventDefault() // falls through
case 'Enter': case 'Backspace': H.exit(ic, e) // no default
}
}
after('ChatRoomCreateElement', () => { // This thing runs on every frame actually.
C.e.parentElement ?? D.body.append(C.e)
val(U.ic, ic => ic.dataset['mbchc'] ?? void ic.addEventListener('keydown', ickd) ?? (ic.dataset['mbchc'] = 'yes'))
})
prior('ChatRoomClearAllElements', () => C.hide() && void C.e.remove())
D.addEventListener('click', _ => C.hide()) // downstream handlers can capture clicks, but I can't be fucked to be honest
after('ChatRoomResize', () => {
if (W.CharacterGetCurrent() === null && W.CurrentScreen === 'ChatRoom' && U.ic !== $ && D.querySelector('#TextAreaChatLog') !== null && !C.hidden) { // Upstream
W.ElementPositionFix(C.e.id, ChatRoomFontSize, 800, 65, 200, 835)
}
})
mod.hookFunction('ChatRoomScrollHistory', 0, ([up]) => void val(U.ic, ic => { const input = ic.value, history = W.ChatRoomLastMessage
// FIXME we'll make it better when the history is a proper ring buffer
if (!ic.readOnly) {
const/**@type {Map<string,number>}*/map = new Map()
const/**@type {(l: string, i: number, I: string) => boolean}*/ cond = m_t(input) ? (_, i, __) => i > 0 : (l, _, i) => l !== i && l.startsWith(i)
if (m_t(history.reduce((ax, l, i) => cond(l, i, input) ? ax.set(l, i) : ax, map))) return U.bell()
val(asa(HTMLDivElement, D.querySelector('#TextAreaChatLog')), t => t.dataset['mbchcHH'] = `𝗵𝗶𝘀𝘁𝗼𝗿𝘆: ${m_t(input) ? 'Everything' : `Prefix: ${input}`}`)
H.enter(ic, input, U.scrolled, new Set(map.values()))
}
if (ic.readOnly) { // this can't be an else, because we mutate state above. To be honest, this will always be true, but I want to make sure.
if (H.ids === $) return U.bell() // shouldn't happen?
let found = -1
let first = -1
let last = -1
for (const i of H.ids) { // these aren't necessarily in order
if (up) {
if (i < W.ChatRoomLastMessageIndex && i > found) found = i // the largest i that is less than index
if (i > last) last = i
} else {
if (first < 0 || i < first) first = i
if (i > W.ChatRoomLastMessageIndex && (i < found || found < 0)) found = i // the smallest i that is greater than index
}
}
if (found < 0) found = up ? last : first
const line = history[found] ?? $S
if (line === input) return U.bell()
ic.value = line
W.ChatRoomLastMessageIndex = found
}
return true
}))
// Chat room handlers
W.ChatRoomRegisterMessageHandler({Priority: -220, Description: 'MBCHC preprocessor', Callback: (data, sender, message, metadata) => {
data.MBCHC_ID = this.NEXT_MESSAGE
this.NEXT_MESSAGE += 1
if (this.LOG_MESSAGES) console.debug({data, sender, msg: message, metadata})
return false
}})
W.ChatRoomRegisterMessageHandler({Priority: -219, Description: 'MBCHC autohack lookup',
Callback: (data, _sender, _message, _metadata) => {
if ((data.Type === 'Hidden') && (data.Content === 'ReceiveSuitcaseMoney')) this.LAST_HACKED = data.Sender
return false
},
})
// Footer
this.LOADED = true
CW.i(`loaded version ${version}`)
},
}
U.current() === 'Character/Login' ? U.remove_loader_hook = prior('AsylumGGTSSAddItems', () => void W.MBCHC.loader()) : W.MBCHC.loader()
const/**@type {BCE.Patcher}*/BP = { timer: undefined, patches: [[/^\^('s)?( )?/g, '^SourceCharacter$1\\s+'], [/([^\\])\$/g, '$1\\.?$$']],
cfs: { anim: () => enu(W.bce_EventExpressions ?? $O), pose: () => W.PoseFemale3DCG.map(p => p.Name) },
gen(f) {return function() {C.icomplete(ws => ws.length < 2 ? new Set([`${CommandsKey}${this.Tag}`]) : ws.length < 3 ? new Set(f()) : $Ss)}},
copy: t => ({Type: 'Action', Event: t.Event, Matchers: t.Matchers.map(m => ({Tester: new RegExp(BP.patches.reduce((ax, [f, r]) => ax.replaceAll(f, r), m.Tester.source), $S)}))}),
patch: () => val(W.bce_ActivityTriggers, ts => val(W.FBC_VERSION, _ =>
yes(void clearInterval(BP.timer), void enu(BP.cfs).forEach(t => val(W.GetCommands().find(c => t === c.Tag), cmd => cmd.AutoComplete = BP.gen(BP.cfs[t]))), void ts.forEach(t => t.Type === 'Emote' && ts.push(BP.copy(t))))
)),
}
BP.timer = W.setInterval(BP.patch, 100)

5818
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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