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