From 6034adae3c5eb9cd9a9bea3ed7ae9725c2db11f9 Mon Sep 17 00:00:00 2001 From: Mute Date: Sun, 18 Aug 2024 01:12:10 +0000 Subject: [PATCH] 107.13.0 another absolutely massive diff * Disabled keyboard handler for focus management, the club got better at this * Input history redone entirely, the code contains comments * Completion now takes cursor position into account * Dropped autoremoval of service messages in favour of future manual deletion * Tons of typechecking cleanup and style improvements * The code now has zero errors on the strictest TS settings I know --- ambient.d.ts | 481 ++++++++++++++++++-- jsconfig.json | 20 +- mbchc.mjs | 1068 ++++++++++++++++++++++----------------------- package-lock.json | 15 +- package.json | 41 +- server.js | 23 +- 6 files changed, 1007 insertions(+), 641 deletions(-) diff --git a/ambient.d.ts b/ambient.d.ts index b20949f..f354574 100644 --- a/ambient.d.ts +++ b/ambient.d.ts @@ -2,61 +2,458 @@ interface ServerChatRoomMessage { MBCHC_ID?: number } -declare namespace MBCHC { - type WGT = Window & typeof globalThis - interface Root extends WGT { - MBCHC?: any - bcModSdk?: import('bondage-club-mod-sdk').ModSDKGlobalAPI - bce_ActivityTriggers?: any - bce_EventExpressions?: any - } - - namespace Settings { - type V0 = PlayerOnlineSettings & {MBCHC: {timezones?: Record}} // V0 is the whole onlinesettings - interface V1 { // V1 is specifically MBCHC inside extensionsettings - TZ: Record - } - interface Methods { - migrate_0_1(v0: V0): true - save(cb?: (v1: V1) => unknown): true - replace(new_v1: V1): true - 'purge!'(): true - get v1(): V1 +namespace BCE { + interface Matcher { + Tester: RegExp + Criteria?: { + TargetIsPlayer?: boolean + SenderIsPlayer?: boolean + DictionaryMatchers?: Array> } } + 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} + gen: (comp_func: () => Iterable) => (this: Optional) => 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 + verbs: { + [verb: string]: { + [zone: string]: { + self: string[] + others: string[] + } + } + } + } + MAP_ACTIONS: { + [verb: string]: { + [zones: string]: { + all?: string + self?: string + others?: string + } + } + } + MAP_ZONES: {[zone: string]: string[]} + LOADED: boolean + NEXT_MESSAGE: number + LOG_MESSAGES: boolean + LAST_HACKED: number | undefined + version: string + VERSION: string + Settings: Settings.Methods + TZ: TZ_Cache + SUBCOMMANDS_MBCHC: SUBCOMMANDS + H: InputHistory + U: Utils + AUTOHACK_ENABLED: boolean + RE_PREF_ACTIVITY_ME: RegExp + RE_PREF_ACTIVITY: RegExp + RE_ACT_CIDS: RegExp + RE_LAST_WORD: RegExp + RE_LAST_LETTER: RegExp + RE_ACTIVITY: RegExp + UTC_OFFSET: number + calculate_maps(): void + normalise_message(text: string, options?: OBJ): 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, args: string, msg: string, parsed: string[]): undefined + command_activity(this: Optional, args: string, msg: string, parsed: string[]): undefined + command_do(this: Optional, 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 = Record; +type FUN = (...args: never[]) => unknown; + +/** + * Stands for "Value or Function". Not remotely ideal, but the best I can come up with. + */ +type VOF = undefined | boolean | number | bigint | string | symbol | OBJ | Set | Map | 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 - upd(min: number, max: number, min_inclusive?: boolean, max_inclusive?: boolean): undefined has(_: unknown, x: string): boolean } - interface Utils { - interval: Interval - cid(character: Character): number | undefined - dn(character: Character): string - current(): string - with(value: V, cb: (value: V) => R): R // A silly helper to kinda curry values - true unknown>(cb: F): true // Useful for type-safe chaining - mutate(value: T, cb: (v: T) => unknown): T // A silly helper for chaining - rm(object: T, property: keyof T): T - mrg>(target: T, ...source: T[]): T // Shorter, also less confusing with types - style(query: string, cb: (s: CSSStyleDeclaration) => T): T | undefined - range(min: number, max: number, min_inclusive?: boolean, max_inclusive?: boolean): Proxy - assert(error: string, value: T, cb?: (value: T) => boolean): T | never - each>(object: T, cb: (key: string, value: unknown) => unknown): true + interface Pipeline { + proxy: PipelineProxy + me(iterable: Iterable): PipelineProxy + [Symbol.iterator](): Iterator + rdc(initial: R, func: (accumulator: R, value: T) => R): R + any(func: (v: T) => boolean): boolean + all(func: (v: T) => boolean): boolean + map(func: (value: T) => R): PipelineProxy + sel(func: (value: T) => boolean): PipelineProxy + } + interface PipelineProxy extends Pipeline { + [i: number]: T | undefined } - interface TZ_Cache { - map: Map - RE: RegExp - for(character: Character): number | undefined - memo(member_number: number, description?: string | undefined): number | undefined - parse(description: string | undefined): number | undefined - } + /** + * Creates an iteration pipeline. + */ + type P = (iterable: Iterable) => PipelineProxy; + + /** + * 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 = (value: T, func: (value: T) => R) => R; + + /** + * Convert `null` to `undefined`. + */ // eslint-disable-next-line @typescript-eslint/ban-types + type n2u = (value: T | undefined | null) => T | undefined; + + /** + * Enumerate keys of an object. Type-safe, also shorter. + */ + type enu = (object: O) => Array; + + /** + * 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 = (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 = (func: unknown) => func is F; + + /** + * Takes anything, ignores non-functions, calls functions with supplied parameters. + */ + type run = (functions_or_values: Array>, ...args: Parameters) => true; + + /** + * Takes anything, ignores non-functions, calls functions with `undefined` as a single parameter. + */ + type yes = (...args: Array unknown>>) => true; + + /** + * Takes anything, ignores non-functions, calls functions with a single provided parameter. + * Always returns the parameter itself. + */ + type mut = (value: T, ...args: Array unknown>>) => T; + + /** + * `delete` as a function + */ // eslint-disable-next-line @typescript-eslint/ban-types + type del = (object: T, property: keyof T) => T; + + /** + * Short for `assert`. Throws, if value is `undefined`, or if `condition(value)` is false. + */ + type ass = (error: string, value: T | undefined, condition?: (value: T) => boolean) => T | never; + + /** + * Casts value as a given prototype or returns undefined. + */ + type asa = (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 = (max: number, condition: (_: undefined) => boolean, action: (_: undefined) => T) => T | undefined; } + +namespace SDK { + type GDPT = import('./node_modules/bondage-club-mod-sdk/dist/bcmodsdk.d.ts').GetDotedPathType; + type Void = (...args: Parameters) => unknown; + type Hook = (name: F, hook: Void>) => () => 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}}; + + /** + * Specifically `MBCHC` inside `Player.ExtensionSettings`. + */ + interface V1 { + TZ: Record + } + + 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 + 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(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 + complete_mbchc(this: Optional): undefined + complete_do_target(actions: {self: unknown, others: unknown}): Set + complete_do(this: Optional): undefined + replace_me(_match: string, _offset: number, whole: string): string + pad_chat(chat: HTMLDivElement): undefined +} + +interface Complete { + S_OPTS: {behavior: 'instant'} + + /** + * The suggestions panel. + * Its structure is (outer div) -> (container div) -> (multiple suggestion divs) + */ + e: HTMLDivElement + + /** + * The container div. + */ + get div(): Element | undefined + + /** + * Longest common prefix, or an empty string for an empty set. + */ + lcp(candidates: Set): 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, ignore_case?: boolean | undefined): string + + /** + * Show the suggestions to the user. + */ + hint(candidates: Set): 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, ignore_case?: boolean | undefined): true + + /** + * Case-insensitive complete. + */ + icomplete(func: (words: string[]) => Set): 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 | undefined + + /** + * Enter the history mode. + */ + enter(textarea: HTMLTextAreaElement, input: string, bottom: boolean, ids: Set): true + + /** + * Exit the history mode and optionally restore original input. + */ + exit(textarea: HTMLTextAreaElement, restore_input: boolean): true +} + +// FIXME spread around readonlys where appropriate diff --git a/jsconfig.json b/jsconfig.json index c369f60..f37790a 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -7,13 +7,19 @@ "server.js" ], "compilerOptions": { - "lib": [ - "es2022", - "DOM" - ], + "target": "es2023", + "allowJs": true, "checkJs": true, - "strict": false, - "strictNullChecks": true, - "noImplicitOverride": true + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "strict": true, + "noEmit": true } } diff --git a/mbchc.mjs b/mbchc.mjs index 2d21437..8e1ce65 100644 --- a/mbchc.mjs +++ b/mbchc.mjs @@ -1,77 +1,245 @@ -/** @type {MBCHC.Root} */ const W = window, D = W.document -if (W.MBCHC !== undefined) throw new Error('MBCHC found, aborting loading') +// Take a look at the .d.ts for comments. +export const version = '107.13.0' +const W = window, D = W.document, /**fuck money*/$ = undefined, /**@type {''}*/$S = '', /**@type {{}}*/$O = {}, /**@type {Set}*/$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 -export const VERSION = '105.13.0' -const 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 - -/** @implements {MBCHC.Interval} */ class Interval { proxy = new Proxy({}, this); min = 0; max = 0; mini = false; maxi = false - /** @type {MBCHC.Interval['upd']} */ upd(min, max, mini = true, maxi = true) {this.min = min; this.max = max; this.mini = mini; this.maxi = maxi} - /** @type {MBCHC.Interval['has']} */ has(_, x) {return U.with(Number(x), x => (this.mini ? x >= this.min : x > this.min) && (this.maxi ? x <= this.max : x < this.max))} +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} -/** @type {MBCHC.Utils} */ const U = { interval: new Interval(), - cid: char => char.MemberNumber, - dn: char => W.CharacterNickname(char), - current: () => `${W.CurrentModule}/${W.CurrentScreen}`, - with: (v, cb) => cb(v), - true(cb) {cb(); return true}, - mutate(v, cb) {cb(v); return v}, - rm(o, p) {delete o[p]; return o}, // eslint-disable-line @typescript-eslint/no-dynamic-delete - mrg: (t, ...s) => Object.assign(t, ...s), // eslint-disable-line @typescript-eslint/no-unsafe-return - style: (q, cb) => U.with(D.querySelector(q), E => E instanceof HTMLElement ? cb(E.style) : undefined), - range: (...args) => U.true(() => void U.interval.upd(...args)) && U.interval.proxy, - assert(x, v, cb = Boolean) {if (!cb(v)) throw new Error(x); return v}, - each: (o, cb) => U.true(() => void Object.entries(o).forEach(kv => cb(...kv))), +/**@template T*/const Pipe = /**@implements {FP.Pipeline}*/class PipeClass { + /**@type {FP.Pipeline['proxy']}*/proxy = new Proxy(/**@type {this & Record}*/(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}*/iterable) {this.iterable = iterable} + /**@type {FP.Pipeline['me']}*/me(iterable) {return (new PipeClass(iterable)).proxy} + [Symbol.iterator]() {return this.iterable[Symbol.iterator]()} + /**@type {FP.Pipeline['rdc']}*/rdc(i, f) {let ax = i; for (const v of this) ax = f(ax, v); return ax} + /**@type {FP.Pipeline['any']}*/any(f) {for (const v of this) if (f(v)) return true; return false} + /**@type {FP.Pipeline['all']}*/all(f) {for (const v of this) if (!f(v)) return false; return true} + /**@type {FP.Pipeline['map']}*/map(f) {return this.me((function*(i, f) {for (const v of i) yield f(v)})(this, f))} + /**@type {FP.Pipeline['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 -/** @type {MBCHC.Settings.Methods} */ const Settings = { - migrate_0_1(v0) { // I hate change - U.with(v0.MBCHC.timezones, tz => tz !== undefined && this.save(v1 => void U.each(tz, (k, v) => v1.TZ[k] ||= v))) - W.ServerAccountUpdate.QueueData({OnlineSettings: U.rm(v0, 'MBCHC')}) - return U.true(() => void console.warn('MBCHC: settings migration done (v0 -> v1). This should never appear again.')) +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(cb = undefined) {W.Player.ExtensionSettings.MBCHC ||= {}; cb?.(this.v1); W.ServerPlayerExtensionSettingsSync('MBCHC'); return true}, - replace: v1 => U.true(() => W.Player.ExtensionSettings.MBCHC = v1) && Settings.save(), - 'purge!': () => Settings.replace(/** @type {MBCHC.Settings.V1} */ ({})), - get v1() {return U.mutate(/** @type {MBCHC.Settings.V1} */ (W.Player.ExtensionSettings.MBCHC) ?? {}, v1 => { // we need to check and repair the whole object every time we access it + 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 ||= {} })}, } -/** @type {MBCHC.TZ_Cache} */ const TZ = { map: new Map(), - RE: /(?:gmt|utc)\s*([+-])\s*(\d\d?)/i, - for: c => c.MemberNumber === undefined ? undefined : TZ.map.get(c.MemberNumber) ?? TZ.memo(c.MemberNumber, c.Description), - memo: (cid, desc = undefined) => U.with(Settings.v1.TZ[cid] ?? TZ.parse(desc), n => n === undefined ? undefined : U.true(() => TZ.map.set(cid, n)) && n), - parse: desc => desc === undefined ? undefined : U.with(TZ.RE.exec(desc), m => m === null ? undefined : U.with(Number.parseInt(m[1] + m[2], 10), n => n in U.range(-12, 12) ? n : undefined)), +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)), } -// ^ type-safe (still need strict later) -// ================================================================================= -// v legacy mess +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(`
${html}
`)), + 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}*/f = new Set() // FIXME Target should be lowcase (take a look at this later) + if (m_t(t)) return W.Player + if (t.at(0) === '=') return U.cid2char(ass(`invalid member number "${target}"`, int(t.slice(1)))) + if ('<>'.includes(t.at(0) ?? '-')) return U.rel2char(t) + const n = int(t) + if (n !== $ && n.toString() === t) { // We got a number + if (n in rng(0, 9)) return U.abs2char(n) + if (n in rng(11, 15)) return U.abs2char(n - 11) + if (n in rng(21, 25)) return U.abs2char(n - 16) + U.crc.filter(c => U.cid(c)?.toString().includes(t)).forEach(c => f.add(c)) // or union with `new Set(array)` + } + + if (t.at(0) === '@') t = t.slice(1) + U.crc.filter(c => c.Name.toLocaleLowerCase().includes(t)).forEach(c => f.add(c)) + U.crc.filter(c => val(c.Nickname, nn => nn.toLocaleLowerCase().includes(t))).forEach(c => f.add(c)) + + const found = [...f.keys()] + ass(`target "${target}": multiple matches (${found.map(c => `${U.cid(c)}|${c.Name}|${c.Nickname ?? c.Name}`).join(',')})`, found.length, n => n < 2) // make the list better + return ass(`target "${target}": no match`, found[0]) + }, + mkdiv: html => mut(D.createElement('div'), e => html === $ || (e.innerHTML = html)), + bell: () => yes(U.style('#InputChat', s => s.outline = 'solid red'), void setTimeout(() => {U.style('#InputChat', s => s.outline = $S)}, 100)), + targets: (reject_player = false, check_perms = false) => mut(new Set(), r => { + const wrap_text = (/**@type {string}*/text) => int(text) === $ ? text : `@${text}` + const wrap_int = (/**@type {number}*/i) => i in rng(0, 9) || i in rng(11, 15) || i in rng(21, 25) ? `=${i}` : i.toString() + U.crc.filter(c => !(reject_player && c.IsPlayer()) && !(check_perms && !W.ServerChatRoomGetAllowItem(W.Player, c))).forEach(c => { + U.split(c.Name).forEach(t => r.add(wrap_text(t))) + val(c.Nickname, n => U.split(n))?.forEach(t => r.add(wrap_text(t))) + val(U.cid(c), cid => r.add(wrap_int(cid))) + }) + r.delete($S) + }), + complete_mbchc() {C.icomplete(ws => + ws.length < 2 ? new Set([`${CommandsKey}${this.Tag}`]) + : ws.length < 3 ? new Set(Object.keys(SUBCOMMANDS_MBCHC)) // FIXME no idea why enu() thinks there's a number in there + : val(ws[1], w => val(SUBCOMMANDS_MBCHC[w], sub => val(sub.args, as => val(enu(as)[ws.length - 3], a => + a === 'TARGET' ? U.targets(true) : a === '[TARGET]' ? U.targets() : $)))) ?? $Ss + )}, + complete_do_target(actions) { + if (m_t(actions)) return $Ss + if (m_t(actions.others)) return val(U.cid(W.Player), cid => new Set([cid.toString()])) ?? $Ss // Target is always the player + return U.targets(m_t(actions.self), true) + }, + complete_do() {C.icomplete(ws => { + if (ws.length < 2) return new Set([`${CommandsKey}${this.Tag}`]) + // Now, we *could* run a filter to exclude impossible activities, but it isn't very useful, and also seems like a lot of CPU to iterate over every action on every zone of every char in the room + let low = (ws[1] ?? $S).toLocaleLowerCase() + const DD = W.MBCHC.DO_DATA + if (ws.length < 3) return new Set(enu(DD.verbs).map(String)) // Complete verb + const ags = DD.verbs[low] + if (ags === $) return $Ss + low = (ws[2] ?? $S).toLocaleLowerCase() + if (ws.length < 4) { // Complete zone or target + if (enu(ags).length < 2) return val(ags[enu(ags)[0] ?? $S], t => U.complete_do_target(t)) ?? $Ss // Zone implied, complete target + return new Set(Object.entries(DD.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone, _ag]) => zone)) + } + + if (ws.length < 5) { // Complete target where it belongs + if (enu(ags).length < 2) return $Ss // Zone implied, target already given + return val(ags[DD.zones[low] ?? $S], t => U.complete_do_target(t)) ?? $Ss + } + + return $Ss + })}, + replace_me: (_, __, whole) => cur(whole.slice(1), t => `${U.ACT}<${U.cid(W.Player)}:>SourceCharacter${t.startsWith('\'') || t.startsWith(' ') ? $S : ' '}`), + pad_chat: c => loo(100, _ => c.scrollHeight <= c.clientHeight, _ => yes(void c.prepend(U.mkdiv('\u061C')))) && void W.ElementScrollToEnd('TextAreaChatLog') +} + +const/**@type {SUBCOMMANDS}*/SUBCOMMANDS_MBCHC = { + autohack: {desc: 'toggle the autohack feature', cb: mbchc => void U.inform(`Autohack is now ${((mbchc.AUTOHACK_ENABLED = !mbchc.AUTOHACK_ENABLED)) ? 'enabled' : 'disabled'}`)}, + donate: {desc: 'buy data and send it to recipient', args: {TARGET: $O}, cb: (mbchc, args) => void mbchc.donate_data(args[0] ?? $S)}, + tz: {desc: 'set target\'s UTC offset', args: {OFFSET: $O, '[TARGET]': $O}, cb: (mbchc, args) => mbchc.set_timezone(args)}, + 'purge!': {desc: 'delete MBCHC online saved data', cb: () => Settings['purge!']()}, +} + +const/**@type {Complete}*/C = { S_OPTS: {behavior: 'instant'}, + e: mut(Object.assign(U.mkdiv(), {id: 'mbchcCompHint'}), div => void div.append(U.mkdiv())), + get div() {return C.e.firstElementChild ?? $}, + lcp: cs => m_t(cs) ? $S : mut({p: ''}, r => void cur(P(cs), P => {for (let on = true, i = Math.max(...P.map(o => o.length)); on && i > 0; i -= 1) { + cur((P[0] ?? $S).slice(0, i), p => P.all(o => o.startsWith(p)) && yes(r.p = p) && (on = false)) + }})).p, + complete_word: (i, cs, ci = false) => cur(void cs.forEach(c => (ci ? c.toLocaleLowerCase() : c).startsWith(ci ? i.toLocaleLowerCase() : i) || cs.delete(c)), _ => cs.size < 2 ? P(cs)[0] ?? i : C.lcp(cs)), + hint: cs => m_t(cs) || yes(_ => { + yes(C.e.style.display = 'block') && C.div?.replaceChildren(...[...cs].sort().reverse().map(U.mkdiv)) + W.ElementSetDataAttribute(C.e.id, 'colortheme', W.Player.ChatSettings?.ColorTheme ?? 'Light') + cur(W.ElementIsScrolledToEnd('TextAreaChatLog'), r => yes(void W.ChatRoomResize(false)) && r && void W.ElementScrollToEnd('TextAreaChatLog')) + C.div?.lastElementChild?.scrollIntoView(C.S_OPTS) + }), + get hidden() {return C.e.parentElement === null || C.e.style.display === 'none'}, + hide: () => C.hidden || yes(C.e.style.display = 'none', void W.ChatRoomResize(false)), + complete(f, ci = false) { + const e = ass('Somehow #InputChat is broken', U.ic) + if (e.selectionStart !== e.selectionEnd) return U.bell() + const input = e.value + const before_cursor = input.slice(0, e.selectionStart) + const words = U.split(before_cursor) + const cs = f(words) + const word = words.at(-1) ?? '' + const cword = C.complete_word(word, cs, ci) + const additions = cword.slice(word.length) + if (!m_t(additions)) e.setRangeText(additions, e.selectionStart, e.selectionEnd, 'end') + if (cs.size > 1) C.hint(cs) + if (cs.size === 1) e.setRangeText(' ', e.selectionStart, e.selectionEnd, 'end') + if (cs.size === 0) U.bell() + return true + }, + icomplete: f => C.complete(f, true), +} + +const/**@type {InputHistory}*/H = { input: undefined, ids: undefined, bottom: undefined, // FIXME ids don't need to be a set, but I'm too tired right now + enter: (ic, i, b, is) => yes(H.input = i, H.bottom = b, H.ids = is, ic.readOnly = true, val(ic.parentElement?.parentElement?.dataset, d => d['mbchcMode'] = 'h'), b && void W.ElementScrollToEnd('TextAreaChatLog')), + exit: (ic, r) => yes(ic.readOnly = false, val(ic.parentElement?.parentElement?.dataset, d => del(d, 'mbchcMode')), r && val(H.input, i => ic.value = i), val(H.bottom, b => b && void W.ElementScrollToEnd('TextAreaChatLog'))) +} + +ass('MBCHC found, aborting loading', W.MBCHC === $) +ass('AsylumGGTSSAddItems() not found, aborting MBCHC loading', W.AsylumGGTSSAddItems) +const sdk = ass('SDK not found, please load with (or after) FUSAM or any other mod that uses SDK', W.bcModSdk) +const mod = sdk.registerMod({name: 'MBCHC', fullName: 'Mute\'s Bondage Club Hacks Collection', version, repository: 'https://code.fleshless.org/mute/MBCHC/'}) +const/**@type {SDK.Hook}*/prior = (name, f) => mod.hookFunction(name, 0, (na, n) => rsc(_ => f(...na), CW.e) && n(na)) // eslint-disable-line @typescript-eslint/no-unsafe-return +const/**@type {SDK.Hook}*/after = (name, f) => mod.hookFunction(name, 0, (na, n) => mut(n(na), rsc(_ => f(...na), CW.e))) // eslint-disable-line @typescript-eslint/no-unsafe-return + +// ^ type-safe, new and much improved, but still work in progress +// ================================================================================= +// v legacy mess, also type-safe now? + +/**@type {Window['MBCHC']}*/W.MBCHC = { + version, /** Just in case someone used it for anything @deprecated*/VERSION: version, + Settings, TZ, + + SUBCOMMANDS_MBCHC, H, U, // debug, will go away -W.MBCHC = { - VERSION, - Settings, NEXT_MESSAGE: 1, LOG_MESSAGES: false, - RETHROW: false, LOADED: false, AUTOHACK_ENABLED: false, - LAST_HACKED: null, - HISTORY_MODE: false, + /**@type {number | undefined}*/LAST_HACKED: $, + //HISTORY_MODE: false, RE_PREF_ACTIVITY_ME: /^@/, RE_PREF_ACTIVITY: /^@@/, RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/, - RE_ALL_LEFT: /^<+$/, - RE_ALL_RIGHT: /^>+$/, - RE_SPACES: /\s{2,}/g, RE_LAST_WORD: /(^|\s)(\S*)$/, RE_LAST_LETTER: /\w$/, - RGB_MUTE: '#6c2132', - RGB_POLLY: '#81b1e7', + RE_ACTIVITY: new RegExp(`^${CommandsKey}activity `), + //PREF_ACTIVITY: `${CommandsKey}activity `, UTC_OFFSET: new Date().getTimezoneOffset() * 60 * 1000, MAP_ACTIONS: { // ActivityFemale3DCG - // action + // Action 'nod|yes': {Head: {self: 'Nod'}}, no: {Head: {self: 'Wiggle'}}, moan: {Mouth: {self: 'MoanGag'}}, @@ -166,558 +334,372 @@ W.MBCHC = { ItemEars: ['ear', 'ears', 'earlobe', 'earlobes'], ItemHead: ['head', 'face', 'hair', 'eyes', 'forehead'], }, - FBC_TESTER_PATCHES: [ - [/^\^('s)?( )?/g, '^SourceCharacter$1\\s+'], - [/([^\\])\$/g, '$1\\.?$$'], - ], - SUBCOMMANDS_MBCHC: { - autohack: {desc: 'toggle the autohack feature', cb: mbchc => mbchc.inform(`Autohack is now ${(mbchc.AUTOHACK_ENABLED = !mbchc.AUTOHACK_ENABLED) ? 'enabled' : 'disabled'}`)}, // eslint-disable-line no-cond-assign - donate: {desc: 'Buy data and send it to recipient', args: {TARGET: {}}, cb: (mbchc, args) => mbchc.donate_data(args[0])}, - tz: {desc: 'set target\'s UTC offset', args: {OFFSET: {}, '[TARGET]': {}}, cb: (mbchc, args) => mbchc.set_timezone(args)}, - 'purge!': {desc: 'delete MBCHC online saved data', cb: () => Settings['purge!']()}, - // if (W.Player.ExtensionSettings.MBCHC) { - // delete W.Player.ExtensionSettings.MBCHC - // mbchc.save_settings() // FIXME - // } - //}}, -}, + DO_DATA: {verbs: {}, zones: {}}, calculate_maps() { - this.DO_DATA = {verbs: {}, zones: {}} for (const [verbs, data] of Object.entries(this.MAP_ACTIONS)) { - const unwound = {} + const/**@type {{[k: string]: {self: string[]; others: string[]}}}*/unwound = {} for (const [zones, actions] of Object.entries(data)) { - const all = (actions.all) ? actions.all.split('|') : [] - const processed = {self: (actions.self) ? actions.self.split('|').concat(all) : all, others: (actions.others) ? actions.others.split('|').concat(all) : all} + 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 }, - log(level, message) { - console[level]('MBCHC: ' + String(message)) - }, - empty(text) { - if (!text) return (true) - if (String(text).trim().length === 0) return (true) - return (false) - }, - normalise_message(text, options = {}) { + normalise_message(/**@type {string}*/text, /**@type {OBJ}*/options = $O) { let result = text - if (options.trim) result = result.trim() - if (options.low) result = result.toLocaleLowerCase() - if (options.up) { - const first = result.at(0).toLocaleUpperCase() + 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 && this.RE_LAST_LETTER.test(result)) result = `${result}.` - return (result) - }, - tokenise(text) { - return text.replace(this.RE_SPACES, ' ').split(' ') - }, - inform(html, timeout = 60_000) { - W.ChatRoomSendLocal(`
${html}
`, timeout) - }, - report(x) { - this.inform(`${x.toString()}`) - if (this.RETHROW) throw x - }, - cid2char(id) { - id = Number.parseInt(id, 10) - if (id === U.cid(W.Player)) return (W.Player) - return U.assert(`character ${id} not found in the room`, W.ChatRoomCharacter.find(c => U.cid(c) === id)) - }, - pos2char(pos) { - if (!(pos in U.range(0, W.ChatRoomCharacter.length - 1))) throw new Error(`invalid position ${pos}`) - return (W.ChatRoomCharacter[pos]) - }, - rel2char(target) { - const me = U.assert('can\'t find my position', W.ChatRoomCharacter.findIndex(char => char.IsPlayer()) + 1) - 1 // 0 is falsey, but is valid index - let pos = null - if (this.RE_ALL_LEFT.test(target)) pos = me - target.length - if (this.RE_ALL_RIGHT.test(target)) pos = me + target.length - if (pos === null) throw new Error(`failed to parse target "${target}"`) - pos %= W.ChatRoomCharacter.length - if (pos < 0) pos += W.ChatRoomCharacter.length - return (this.pos2char(pos)) - }, - target2char(target) { // Target should be lowcase - const input = target - if (this.empty(target)) return (W.Player) - const int = Number.parseInt(target, 10) - target = String(target) - let found = [] - if (target.startsWith('=')) return (this.cid2char(target.slice(1))) - if (target.startsWith('<') || target.startsWith('>')) return (this.rel2char(target)) - if (!Number.isNaN(int) && int.toString() === target) { // We got a number - if (int in U.range(0, 9)) return (this.pos2char(int)) - if (int in U.range(11, 15)) return (this.pos2char(int - 11)) - if (int in U.range(21, 25)) return (this.pos2char(int - 16)) - found.push(...W.ChatRoomCharacter.filter(c => U.cid(c).toString().includes(target))) - } - - if (target.startsWith('@')) target = target.slice(1) - found.push(...W.ChatRoomCharacter.filter(c => c.Name.toLocaleLowerCase().includes(target))) - found.push(...W.ChatRoomCharacter.filter(c => c.Nickname && (c.Nickname.toLocaleLowerCase().includes(target)))) // eslint-disable-line unicorn/no-array-push-push - const map = {} - for (const c of found) { - if (!map[U.cid(c)]) map[U.cid(c)] = c - } - - found = Object.values(map) - if (found.length === 0) throw new Error(`target "${input}": no match`) - if (found.length > 1) throw new Error(`target "${input}": multiple matches (${found.map(c => `${U.cid(c)}|${c.Name}|${c.Nickname || c.Name}`).join(',')})`) - return (found[0]) - }, - char2targets(char) { - const [result, id] = [new Set(), U.cid(char).toString()] - result.add(id).add(`=${id}`) - for (const t of this.tokenise(char.Name)) { - result.add(t) - result.add(`@${t}`) - } - - if (char.Nickname) for (const t of this.tokenise(char.Nickname)) { - result.add(t) - result.add(`@${t}`) - } - + if ((options['dot'] ?? false) && this.RE_LAST_LETTER.test(result)) result = `${result}.` return result }, - //get settings() {return Settings.v1}, - donate_data(target) { - const char = this.target2char(target) - if (char.IsPlayer()) throw new Error('target must not be you') - if (!char.IsRestrained()) throw new Error('target must be bound') + 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)) - if (W.Player.Money < cost) throw new Error('not enough money') + 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: U.cid(W.Player), Type: 'Action', Content: `You've bought data for $${cost} and sent it to ${U.dn(char)}.`, Dictionary: [MISSING_PLAYER_DIALOG]}) + 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(char, ag, action) { + run_activity(/**@type {Character}*/char, /**@type {AssetGroupItemName}*/ag, /**@type {ActivityName}*/action) { try { - if (!W.ActivityAllowed()) throw new Error('activities disabled in this room') - if (!W.ServerChatRoomGetAllowItem(W.Player, char)) throw new Error('no permissions') - char.FocusGroup = U.assert('invalid AssetGroup', W.AssetGroupGet(char.AssetFamily, ag)) - const activity = U.assert('invalid activity', W.ActivityAllowedForGroup(char, char.FocusGroup.Name).find(a => a.Activity?.Name === action)) + 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 + char.FocusGroup = null // eslint-disable-line unicorn/no-null } }, - replace_me(match, offset, string) { - const text = string.slice(1) - let suffix = ' ' - if (text.startsWith('\'') || text.startsWith(' ')) suffix = '' - return `${W.MBCHC.PREF_ACTIVITY}<${U.cid(W.Player)}:>SourceCharacter${suffix}` + 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}) }, - cid2dict(type, cid) { - return ({Tag: `${type}Character`, MemberNumber: cid, Text: U.dn(this.cid2char(cid))}) - }, - send_activity(message) { - const dict = [MISSING_PLAYER_DIALOG] - const cids = message.match(this.RE_ACT_CIDS) - if (cids) { - message = message.replace(this.RE_ACT_CIDS, '') - if (cids[1]) dict.push(this.cid2dict('Source', cids[1])) - if (cids[2]) dict.push(this.cid2dict('Target', cids[2]), this.cid2dict('Destination', cids[2])) - } - W.ServerSend('ChatRoomChat', {Type: 'Action', Content: message, Dictionary: dict}) - }, - //receive(data) { - // const char = this.cid2char(data.Sender) - // if (char.IsPlayer()) return true // This is our own message, sent back to us - // const payload = this.ensure('Empty message', () => data.Dictionary[0]) - // switch (payload.type) { - // case 'greetings': case 'hello': { - // char.MBCHC = payload.value - // if (payload.type === 'greetings') this.hello(char) - // break - // } - // default: // If we don't know the type it may be from a newer version - // } - // return true - //}, - //hello(char = null) { - // const payload = {type: 'greetings', value: w.Player.MBCHC} - // if (char) payload.type = 'hello' - // const message = {Content: 'MBCHC', Type: /** @type {const} */ ('Hidden'), Dictionary: [payload]} - // if (char) message.Target = char.cid - // w.ServerSend('ChatRoomChat', message) - //}, - copy_fbc_trigger(trigger) { - const result = { - Type: 'Action', - Event: trigger.Event, - Matchers: trigger.Matchers.map(m => ({Tester: new RegExp(this.FBC_TESTER_PATCHES.reduce((ax, [f, r]) => ax.replaceAll(f, r), m.Tester.source), 'u')})), - } - return (result) - }, - patch_fbc() { - this.remove_fbc_hook() - delete this.remove_fbc_hook - W.bce_ActivityTriggers.push(...W.bce_ActivityTriggers.filter(t => t.Type === 'Emote').map(t => this.copy_fbc_trigger(t))) - /* (["anim", "pose"]).forEach(tag => {let cmd = w.Commands.find(c => tag === c.Tag); if (cmd) cmd.AutoComplete = this[`complete_fbc_${tag}`]}) */ // this line explodes, because it needs a semicolon in front if it - let cmd = W.Commands.find(c => c.Tag === 'anim') - if (cmd) cmd.AutoComplete = this.complete_fbc_anim - cmd = W.Commands.find(c => c.Tag === 'pose') - if (cmd) cmd.AutoComplete = this.complete_fbc_pose - }, - //player_enters_room() { // Or if the mod is loaded while player is in the room - // this.hello() - //}, - set_timezone(args) { - const tz = Number.parseInt(args[0], 10) - if (Number.isNaN(tz)) throw new Error(`invalid offset "${args[0]}"`) - if (!(tz in U.range(-12, 12))) throw new Error('offset should be [-12,12]') - const char = this.target2char(args[1]) - if (char.MemberNumber === undefined) return - Settings.save(v1 => v1.TZ[char.MemberNumber] = tz) - TZ.memo(char.MemberNumber) - //char.MBCHC_LOCAL.TZ = tz - //this.save_settings(s => { // FIXME - // if (!s.timezones) s.timezones = {} - // s.timezones[U.cid(char)] = tz - //}) + 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 (args.length === 0) return (mbchc.inform(Object.entries(mbchc.SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `
/mbchc ${cmd} ${sub.args ? Object.keys(sub.args).join(' ') : ''}: ${sub.desc}
`).join(''))) + if (m_t(args)) return void U.inform(Object.entries(SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `
/mbchc ${cmd} ${val(sub.args, a => enu(a).join(' ')) ?? $S}: ${sub.desc}
`).join($S)) const cmd = String(args.shift()) - const sub = U.assert(`unknown subcommand "${cmd}"`, mbchc.SUBCOMMANDS_MBCHC[cmd]) + const sub = ass(`unknown subcommand "${cmd}"`, SUBCOMMANDS_MBCHC[cmd]) sub.cb.call(mbchc, mbchc, args, argline, cmdline) - } catch (error) { - mbchc.report(error) + } catch (x) { + U.report(x) } }, command_activity(argline, cmdline, _) { const mbchc = W.MBCHC - if (!mbchc.empty(argline)) { + if (!m_t(argline.trim())) { try { // `this` is command object - const message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, ''), {trim: true, dot: true, up: true}) + const message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, $S), {trim: true, dot: true, up: true}) mbchc.send_activity(message) - } catch (error) { - mbchc.report(error) + } catch (x) { + U.report(x) } } }, - command_do(argline, cmdline, args) { + command_do(_argline, _cmdline, args) { const mbchc = W.MBCHC try { // `this` is command object - if (args.length === 0) return (mbchc.inform('
Usage: /do VERB [ZONE] [TARGET]
Available verbs:
' + Object.keys(mbchc.MAP_ACTIONS).join(', ') + '
Available zones:
' + Object.keys(mbchc.DO_DATA.zones).join(', '))) + if (m_t(args)) return void U.inform('
Usage: /do VERB [ZONE] [TARGET]
Available verbs:
' + enu(mbchc.MAP_ACTIONS).join(', ') + '
Available zones:
' + enu(mbchc.DO_DATA.zones).join(', ')) let [verb, zone, target] = args - const zones = U.assert(`unknown verb "${verb}"`, mbchc.DO_DATA.verbs[verb]) - if (Object.keys(zones).length === 1) { - if (!target) target = zone - zone = mbchc.MAP_ZONES[Object.keys(zones)[0]][0] + 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] } - if (!zone) throw new Error('zone missing') - const ag = U.assert(`unknown zone "${zone}"`, mbchc.DO_DATA.zones[zone]) - const types = U.assert(`zone "${zone}" invalid for "${verb}"`, zones[ag]) - let char = W.Player - if (target && ((types.self.length === 0) || (types.others.length > 0))) char = mbchc.target2char(target) + 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, ag) + 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 = U.assert(`zone "${zone}" invalid for ("${verb}" "${type}")`, types[type]) - const action = U.assert(`invalid action (${verb} ${zone} ${target})`, actions.find(name => available.find(a => a.Activity?.Name === name))) - mbchc.run_activity(char, ag, action) - } catch (error) { - mbchc.report(error) - } + 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) } }, - bell() { - setTimeout(() => { - U.style('#InputChat', s => s.outline = '') - }, 100) - U.style('#InputChat', s => s.outline = 'solid red') - }, - complete(options, space = true) { - if (options.length === 0) return (this.bell()) - if (options.length > 1) { - const width = Math.max(...options.map(o => o.length)) - let pref = null - for (let i = width; i > 0; i -= 1) { - const test = options[0].slice(0, i) - if (options.every(o => o.startsWith(test))) { - pref = test - break - } - } + //complete(options, space = true) { + // if (m_t(options)) return void U.bell() + // if (options.length > 1) { + // const width = Math.max(...options.map(o => o.length)) + // let pref = null + // for (let i = width; i > 0; i -= 1) { + // const test = options[0].slice(0, i) + // if (options.every(o => o.startsWith(test))) { + // pref = test + // break + // } + // } - if (pref) this.complete([pref], false) - this.comp_hint(options) - } else W.ElementValue('InputChat', W.ElementValue('InputChat').replace(this.RE_LAST_WORD, `$1${options[0]}${space ? ' ' : ''}`)) - }, + // if (pref) this.complete([pref], false) + // this.comp_hint(options) + // } else W.ElementValue('InputChat', W.ElementValue('InputChat').replace(this.RE_LAST_WORD, `$1${options[0]}${space ? ' ' : $S}`)) + //}, - /** - * Displays strings as completion hint - * @param {string[]} options List of words to display. The order will be modified without copy. - * @returns {undefined} - */ - comp_hint(options) { - if (options.length === 0) return - this.COMP_HINT.innerHTML = '
' + options.sort().reverse().map(s => `
${s}
`).join('') + '
' - this.COMP_HINT.style.display = 'block' - W.ElementSetDataAttribute(this.COMP_HINT.id, 'colortheme', (W.Player.ChatSettings?.ColorTheme || 'Light')) - const rescroll = W.ElementIsScrolledToEnd('TextAreaChatLog') - W.ChatRoomResize(false) - if (rescroll) W.ElementScrollToEnd('TextAreaChatLog') - this.COMP_HINT.firstChild?.lastChild?.scrollIntoView({behaviour: 'instant'}) - }, + ///** + // * Displays strings as completion hint + // * @param {string[]} options List of words to display. The order will be modified without copy. + // * @returns {undefined} + // */ + //comp_hint(options) { + // if (m_t(options)) return + // this.COMP_HINT.innerHTML = '
' + options.sort().reverse().map(s => `
${s}
`).join($S) + '
' + // this.COMP_HINT.style.display = 'block' + // W.ElementSetDataAttribute(this.COMP_HINT.id, 'colortheme', (W.Player.ChatSettings?.ColorTheme || 'Light')) + // const rescroll = W.ElementIsScrolledToEnd('TextAreaChatLog') + // W.ChatRoomResize(false) + // if (rescroll) W.ElementScrollToEnd('TextAreaChatLog') + // this.COMP_HINT.firstChild?.lastChild?.scrollIntoView({behaviour: 'instant'}) + //}, - /** - * Returns true if the completion box is attached to body and its display isn't none - * @returns {boolean} - */ - comp_hint_visible() { - return (this.COMP_HINT.parentElement && this.COMP_HINT.style.display !== 'none') - }, - comp_hint_hide() { - if (!this.comp_hint_visible()) return - this.COMP_HINT.style.display = 'none' - W.ChatRoomResize(false) - }, - complete_target(token, me2 = true, check_perms = false) { - const [locase, found] = [token.toLocaleLowerCase(), new Set()] - for (const c of W.ChatRoomCharacter) { - if ((c.IsPlayer() && !me2) || (check_perms && !W.ServerChatRoomGetAllowItem(W.Player, c))) continue - for (const s of this.char2targets(c)) { - if (s.toLocaleLowerCase().startsWith(locase)) found.add(s) - } - } + ///** + // * Returns true if the completion box is attached to body and its display isn't none + // * @returns {boolean} + // */ + //comp_hint_visible() { + // return this.COMP_HINT.parentElement && this.COMP_HINT.style.display !== 'none' + //}, + //comp_hint_hide() { + // if (!this.comp_hint_visible()) return + // this.COMP_HINT.style.display = 'none' + // W.ChatRoomResize(false) + //}, + //char2targets(/**@type {Character}*/char) { + // const/**@type {Set}*/result = new Set() + // val(U.cid(char)?.toString(), id => result.add(id).add(`=${id}`)) + // for (const t of U.split(char.Name)) { + // result.add(t) + // result.add(`@${t}`) + // } - this.complete(Array.from(found)) - }, - complete_common() { - // w.ElementValue('InputChat') will strip the trailing whitespace - const E = D.querySelector('#InputChat') - if (!(E && E instanceof HTMLTextAreaElement)) throw new Error('somehow InputChat is broken') - return ([this, E.value, this.tokenise(E.value)]) - }, - complete_mbchc(_args, _locase, _cmdline) { - const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object - if (tokens.length === 0) return - if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`])) - const subname = tokens[1].toLocaleLowerCase() - if (tokens.length < 3) return (mbchc.complete(Object.keys(mbchc.SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname)))) // Complete subcommand name - const sub = mbchc.SUBCOMMANDS_MBCHC[subname] - if (sub && sub.args) { - const argname = Object.keys(sub.args)[tokens.length - 3] - if (argname === 'TARGET') return (mbchc.complete_target(tokens.at(-1), false)) - if (argname === '[TARGET]') return (mbchc.complete_target(tokens.at(-1)), true) - } - }, - complete_do_target(actions, token) { - if (!actions) return - const me2 = (actions.self.length > 0) - if (me2 && actions.others.length === 0) return (this.complete([U.cid(W.Player).toString()])) // Target is always the player - this.complete_target(token, me2, true) - }, - complete_do(_args, _locase, _cmdline) { - const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object - if (tokens.length === 0) return - if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`])) - // Now, we *could* run a filter to exclude impossible activities, but it isn't very useful, and also seems like a lot of CPU to iterate over every action on every zone of every char in the room - let low = tokens[1].toLocaleLowerCase() - if (tokens.length < 3) return (mbchc.complete(Object.keys(mbchc.DO_DATA.verbs).filter(c => c.startsWith(low)))) // Complete verb - const ags = mbchc.DO_DATA.verbs[low] - if (!ags) return (mbchc.bell()) - low = tokens[2].toLocaleLowerCase() - if (tokens.length < 4) { // Complete zone or target - if (Object.keys(ags).length < 2) return (mbchc.complete_do_target(ags[Object.keys(ags)[0]], tokens[2])) // Zone implied, complete target - const zones = Object.entries(mbchc.DO_DATA.zones).filter(([zone, ag]) => zone.startsWith(low) && ags[ag]).map(([zone, _ag]) => zone) - return (mbchc.complete(zones)) - } + // if (char.Nickname !== $) for (const t of U.split(char.Nickname)) { + // result.add(t) + // result.add(`@${t}`) + // } - if (tokens.length < 5) { // Complete target where it belongs - if (Object.keys(ags).length < 2) return // Zone implied, target already given - return (mbchc.complete_do_target(ags[mbchc.DO_DATA.zones[low]], tokens[3])) - } + // return result + //}, + //complete_target(/**@type {string}*/token, me2 = true, check_perms = false) { + // const [locase, found] = [token.toLocaleLowerCase(), new Set()] + // for (const c of U.crc) { + // if ((c.IsPlayer() && !me2) || (check_perms && !W.ServerChatRoomGetAllowItem(W.Player, c))) continue + // for (const s of this.char2targets(c)) { + // if (s.toLocaleLowerCase().startsWith(locase)) found.add(s) + // } + // } - mbchc.bell() - }, - complete_fbc_anim(_args, _locase, _cmdline) { - const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object - if (tokens.length === 0) return - if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`])) - if (tokens.length > 2) return (mbchc.bell()) - const anim = tokens[1].toLocaleLowerCase() - return (mbchc.complete(Object.keys(W.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim)))) - }, - complete_fbc_pose(_args, _locase, _cmdline) { - const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object - if (tokens.length === 0) return - if (tokens.length < 2) return (mbchc.complete([`${mbchc.CommandsKey}${this.Tag}`])) - const pose = tokens.at(-1).toLocaleLowerCase() - return (mbchc.complete(W.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose)))) - }, - history(down) { - const [text, history] = [W.ElementValue('InputChat'), W.ChatRoomLastMessage] - if (!this.HISTORY_MODE) { - history.push(text) - this.HISTORY_MODE = true - } - - const ids = history.map((t, i) => ({t, i})).filter(r => r.t.startsWith(history.at(-1))).map(r => r.i) - if (!down) ids.reverse() - const found = ids.find(id => (down) ? id > W.ChatRoomLastMessageIndex : id < W.ChatRoomLastMessageIndex) - if (!found) return (this.bell()) - W.ElementValue('InputChat', history[found]) - W.ChatRoomLastMessageIndex = found - }, - focus_chat_checks() { // we only want to catch chatlog and canvas (no map though) keypresses - if (D.activeElement === D.body) return true - if (D.activeElement?.id !== 'MainCanvas') return false - return !W.ChatRoomMapViewIsActive() - }, - focus_chat_whitelist(event) { - if (event.ctrlKey && event.key === 'v') return true // Ctrl+V should paste - return false - }, - focus_chat(event) { - if (event.repeat) return // Only unique presses please - if (!this.focus_chat_checks()) return - if ([event.altKey, event.ctrlKey, event.metaKey].some(Boolean) && !this.focus_chat_whitelist(event)) return // Alt, ctrl and meta should all be false - if (U.style('#InputChat', s => s.display) !== 'inline') return // Input chat missing - W.ElementFocus('InputChat') - }, + // //this.complete(Array.from(found)) + //}, + //complete_common() { // w.ElementValue('InputChat') will strip the trailing whitespace + // const E = D.querySelector('#InputChat') + // ass('somehow #InputChat is broken', E instanceof HTMLTextAreaElement) + // return [this, E.value, U.split(E.value)] + //}, + //complete_mbchc(_args, _locase, _cmdline) { + // const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object + // if (m_t(tokens)) return + // if (tokens.length < 2) return //mbchc.complete([`${CommandsKey}${this.Tag}`]) + // const subname = tokens[1].toLocaleLowerCase() + // if (tokens.length < 3) return //mbchc.complete(Object.keys(SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname))) // Complete subcommand name + // const sub = SUBCOMMANDS_MBCHC[subname] + // if (sub && sub.args) { + // const argname = Object.keys(sub.args)[tokens.length - 3] + // if (argname === 'TARGET') return mbchc.complete_target(tokens.at(-1), false) + // if (argname === '[TARGET]') return mbchc.complete_target(tokens.at(-1)) + // } + //}, + //complete_fbc_anim(_args, _locase, _cmdline) { + // const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object + // if (m_t(tokens)) return + // if (tokens.length < 2) return mbchc.complete([`${CommandsKey}${this.Tag}`]) + // if (tokens.length > 2) return void U.bell() + // const anim = tokens[1].toLocaleLowerCase() + // return mbchc.complete(Object.keys(W.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim))) + //}, + //complete_fbc_pose(_args, _locase, _cmdline) { + // const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object + // if (m_t(tokens)) return + // if (tokens.length < 2) return mbchc.complete([`${CommandsKey}${this.Tag}`]) + // const pose = tokens.at(-1).toLocaleLowerCase() + // return mbchc.complete(W.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose))) + //}, + //focus_chat_checks() { // we only want to catch chatlog and canvas (no map though) keypresses + // if (D.activeElement === D.body) return true + // if (D.activeElement?.id !== 'MainCanvas') return false + // return !W.ChatRoomMapViewIsActive() + //}, + //focus_chat_whitelist(/**@type {KeyboardEvent}*/event) { + // if (event.ctrlKey && event.key === 'v') return true // Ctrl+V should paste + // return false + //}, + //focus_chat(/**@type {KeyboardEvent}*/event) { + // if (event.repeat) return // Only unique presses please + // if (!this.focus_chat_checks()) return + // if ([event.altKey, event.ctrlKey, event.metaKey].some(Boolean) && !this.focus_chat_whitelist(event)) return // Alt, ctrl and meta should all be false + // if (U.style('#InputChat', s => s.display) !== 'inline') return // Input chat missing + // W.ElementFocus('InputChat') + //}, loader() { - if (this.remove_load_hook) { - this.remove_load_hook() - delete this.remove_load_hook - } - + U.remove_loader_hook === $ || yes(void U.remove_loader_hook(), U.remove_loader_hook = $) if (this.LOADED) return - - U.with(/** @type {MBCHC.Settings.V0} */ (W.Player.OnlineSettings), os => os?.MBCHC && Settings.migrate_0_1(os)) + val(Settings.v0, v0 => Settings.migrate_0_1(v0)) // Calculated values - const COMMANDS = [ - {Tag: 'mbchc', Description: ': Utility functions ("/mbchc" for help)', Action: this.command_mbchc, AutoComplete: this.complete_mbchc}, + 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: this.complete_do}, + {Tag: 'do', Description: ': Do an activity, as if clicked on its button ("/do" for help)', Action: this.command_do, AutoComplete: U.complete_do}, ] - this.CommandsKey = CommandsKey - this.RE_ACTIVITY = new RegExp(`^${this.CommandsKey}activity `) - this.PREF_ACTIVITY = `${this.CommandsKey}activity ` - this.COMP_HINT = D.createElement('div') - this.COMP_HINT.id = 'mbchcCompHint' - const css = D.createElement('style') - css.textContent = ` - #TextAreaChatLog .mbchc, #TextAreaChatLog .mbchc { - background-color: ${this.RGB_POLLY}; - } - #TextAreaChatLog[data-colortheme="dark"] .mbchc, #TextAreaChatLog[data-colortheme="dark2"] .mbchc { - background-color: ${this.RGB_MUTE}; - } - #${this.COMP_HINT.id} { - display: none; - text-align: right; - } - #${this.COMP_HINT.id} > div { - overflow: auto; - position: absolute; - bottom: 0; - right: 0; - max-height: 100%; - padding: 0 0.5ex; - background-color: ${this.RGB_POLLY}; - color: black; - } - #${this.COMP_HINT.id}[data-colortheme="dark"] > div, #${this.COMP_HINT.id}[data-colortheme="dark2"] > div { - background-color: ${this.RGB_MUTE}; - color: white; - } - #${this.COMP_HINT.id} > div div { - margin: 0.25ex 0; - } - ` + mut(D.createElement('style'), c => void D.head.append(c), c => c.textContent = ` + #TextAreaChatLog .mbchc { background-color: ${U.RGB.Polly}; margin-left: -0.4em; padding-left: 0.4em; } + #TextAreaChatLog[data-colortheme^="dark"] .mbchc { background-color: ${U.RGB.Mute}; } + #${C.e.id} { display: none; text-align: right; } + #${C.e.id} > div { overflow: auto; position: absolute; bottom: 0; right: 0; max-height: 100%; padding: 0 0.5ex; background-color: ${U.RGB.Polly}; color: black; } + #${C.e.id}[data-colortheme^="dark"] > div { background-color: ${U.RGB.Mute}; color: white; } + #${C.e.id} > div div { margin: 0.25ex 0; } + #chat-room-div[data-mbchc-mode="h"] #TextAreaChatLog::after { content: 'π—΅π—Άπ˜€π˜π—Όπ—Ώπ˜† βŸ¨π˜—π˜¨π˜œπ˜±/π˜‹π˜―βŸ© π—Œπ–Όπ—‹π—ˆπ—…π—… β‡… ⟨𝘌𝘯𝘡𝘦𝘳⟩ π—Œπ–Ύπ—‡π–½ ↡ βŸ¨π˜›π˜’π˜£βŸ© 𝖾𝖽𝗂𝗍 ⌨ ⟨𝘌𝘴𝘀⟩ π–Ίπ–»π—ˆπ—‹π— ⟲'; display: block; position: sticky; bottom: 0; background: black; color: orange; padding: 0 0.2ex; } + #InputChat:read-only { background: black; color: orange; } + `) - D.head.append(css) // Actions this.calculate_maps() - //w.Player.MBCHC = {VERSION: this.VERSION} W.CommandCombine(COMMANDS) // Hooks - this.remove_fbc_hook = this.before('MainRun', () => W.bce_ActivityTriggers && this.patch_fbc()) - // this.after('CharacterOnlineRefresh', char => this.update_char(char)) - this.after('ChatRoomReceiveSuitcaseMoney', () => { - if (this.AUTOHACK_ENABLED && this.LAST_HACKED) { - W.CurrentCharacter = this.cid2char(this.LAST_HACKED) - this.LAST_HACKED = null + after('ChatRoomReceiveSuitcaseMoney', () => { + if (this.AUTOHACK_ENABLED && this.LAST_HACKED !== $) { + W.CurrentCharacter = U.cid2char(this.LAST_HACKED) + this.LAST_HACKED = $ W.ChatRoomTryToTakeSuitcase() } }) - this.before('ChatRoomSendChat', () => { - let input = W.ElementValue('InputChat') - if (!input.startsWith('@@@') && input.startsWith('@')) { - input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY) - input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me) - W.ElementValue('InputChat', input) - } - }) - this.after('ChatRoomSendChat', () => { + prior('ChatRoomSendChat', () => val(U.ic, ic => !ic.value.startsWith('@@@') && ic.value.startsWith('@') && (ic.value = ic.value.replace(U.RE['@'][1], U.ACT).replace(U.RE['@'][0], U.replace_me)))) + //{ + // let input = W.ElementValue('InputChat') + // if (!input.startsWith('@@@') && input.startsWith('@')) { + // input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY) + // input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me) + // W.ElementValue('InputChat', input) + // } + //}) + after('ChatRoomSendChat', () => { // FIXME actually make history a ring buffer of a given size. clear the array and push every string into it, compacting sequential equal strings into one. const history = W.ChatRoomLastMessage if ((history.length > 1) && (history.at(-1) === history.at(-2))) { history.pop() W.ChatRoomLastMessageIndex -= 1 } }) - this.before('ChatRoomCharacterViewDrawOverlay', (C, CharX, CharY, Zoom, _Pos) => { + 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 ? this.RGB_POLLY : this.RGB_MUTE) + // w.DrawRect(CharX + (175 * Zoom), CharY, 50 * Zoom, 50 * Zoom, C.MBCHC.VERSION === w.Player.MBCHC.VERSION ? U.RGB.Polly : U.RGB.Mute) // } - const ctz = TZ.for(C) - if (W.ChatRoomHideIconState < 1 && ctz !== undefined) { - const hours = new Date(W.CommonTime() + this.UTC_OFFSET + (ctz * 60 * 60 * 1000)).getHours() - W.DrawTextFit(hours < 10 ? '0' + hours.toString() : hours.toString(), CharX + (200 * Zoom), CharY + (25 * Zoom), 46 * Zoom, 'white', 'black') - } + 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'))) }) - this.after('ChatRoomCreateElement', () => this.COMP_HINT.parentElement || D.body.append(this.COMP_HINT)) - this.before('ChatRoomClearAllElements', () => { - this.comp_hint_hide() - this.COMP_HINT.remove() - }) - this.before('ChatRoomClick', () => { - this.comp_hint_hide() - }) - this.after('ChatRoomResize', () => { - if (W.CharacterGetCurrent() === null && W.CurrentScreen === 'ChatRoom' && D.querySelector('#InputChat') && D.querySelector('#TextAreaChatLog') && this.comp_hint_visible()) { // Upstream - const fontsize = ChatRoomFontSize - //w.ElementPositionFix('TextAreaChatLog', fontsize, 1005, 66, 988, 630) - //w.ElementPositionFix(this.COMP_HINT.id, fontsize, 1005, 701, 988, 200) - W.ElementPositionFix(this.COMP_HINT.id, fontsize, 800, 65, 200, 835) - //this.COMP_HINT.style.display = 'flex' - } - }) - D.addEventListener('keydown', event => this.focus_chat(event)) - this.SDK.hookFunction('ChatRoomKeyDown', 0, (nextargs, next) => { // This fires on chat input events - const [event] = nextargs - W.MBCHC.comp_hint_hide() - if ((W.KeyPress === 33) || (W.KeyPress === 34)) { // Better history - event.preventDefault() - return (W.MBCHC.history(W.KeyPress - 33)) - } - if (W.MBCHC.HISTORY_MODE) { - W.ChatRoomLastMessage.pop() - W.MBCHC.HISTORY_MODE = false + const/**@type {(this: HTMLTextAreaElement, e: KeyboardEvent) => void}*/ickd = function(e) { + C.hide() + const ic = this // eslint-disable-line unicorn/no-this-assignment,@typescript-eslint/no-this-alias + if (ic.readOnly) switch (e.key) { + case 'Tab': case 'Escape': + e.stopImmediatePropagation() + e.preventDefault() // falls through + case 'Enter': + H.exit(ic, e.key === 'Escape') // no default } - - return (next(nextargs)) + } + after('ChatRoomCreateElement', () => { // This thing runs on every frame actually. + C.e.parentElement === null && void D.body.append(C.e) + val(U.ic, ic => ic.dataset['mbchc'] ?? void ic.addEventListener('keydown', ickd) ?? (ic.dataset['mbchc'] = 'yes')) + val(asa(HTMLDivElement, D.querySelector('#TextAreaChatLog')), c => c.scrollHeight > c.clientHeight || void U.pad_chat(c)) }) + prior('ChatRoomClearAllElements', () => C.hide() && void C.e.remove()) + D.addEventListener('click', _ => C.hide()) // downstream handlers can capture clicks, but I can't be fucked to be honest + //after('ChatRoomResize', () => { + // if (W.CharacterGetCurrent() === null && W.CurrentScreen === 'ChatRoom' && D.querySelector('#InputChat') && D.querySelector('#TextAreaChatLog') && this.comp_hint_visible()) { // Upstream + // const fontsize = ChatRoomFontSize + // //w.ElementPositionFix('TextAreaChatLog', fontsize, 1005, 66, 988, 630) + // //w.ElementPositionFix(this.COMP_HINT.id, fontsize, 1005, 701, 988, 200) + // W.ElementPositionFix(this.COMP_HINT.id, fontsize, 800, 65, 200, 835) + // //this.COMP_HINT.style.display = 'flex' + // } + //}) + after('ChatRoomResize', () => { + if (W.CharacterGetCurrent() === null && W.CurrentScreen === 'ChatRoom' && U.ic !== $ && D.querySelector('#TextAreaChatLog') !== null && !C.hidden) { // Upstream + W.ElementPositionFix(C.e.id, ChatRoomFontSize, 800, 65, 200, 835) + } + }) + + //D.addEventListener('keydown', event => void this.focus_chat(event)) // Looks like the club got better at this + + mod.hookFunction('ChatRoomScrollHistory', 0, ([up]) => void val(U.ic, ic => { const input = ic.value, history = W.ChatRoomLastMessage + // FIXME later duplicates should override earlier ones + // FIXME we'll make it better when the history is a proper ring buffer + if (!ic.readOnly) { + const bottom = W.ElementIsScrolledToEnd('TextAreaChatLog') + if (m_t(input)) { + if (history.length < 2) return U.bell() + H.enter(ic, input, bottom, mut(new Set(history.keys()), s => s.delete(0))) // J'accuse! ChatRoomLastMessage is a total mess and a half. + } else { + const/**@type {Set}*/lines = new Set() // these only exist to filter duplicates + const/**@type {Set}*/ids = new Set() + history.forEach((l, i) => l !== input && l.startsWith(input) && cur(lines.size, s => lines.add(l) !== $ && s < lines.size && ids.add(i))) + if (m_t(ids)) return U.bell() + H.enter(ic, input, bottom, ids) + } + } + 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) { + if (up) { + if (i < W.ChatRoomLastMessageIndex) found = i + last = i + } else { + if (first < 0) first = i + if (i > W.ChatRoomLastMessageIndex) { + found = i + break + } + } + } + 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) => { @@ -726,18 +708,6 @@ W.MBCHC = { if (this.LOG_MESSAGES) console.debug({data, sender, msg: message, metadata}) return false }}) - //w.ChatRoomRegisterMessageHandler({Priority: -219, Description: 'MBCHC room enter hook', - // Callback: (data, _sender, _message, _metadata) => { - // if ((data.Type === 'Action') && (data.Content === 'ServerEnter') && (data.Sender === U.cid(w.Player))) this.player_enters_room() - // return false - // }, - //}) - //w.ChatRoomRegisterMessageHandler({Priority: -219, Description: 'MBCHC specific consumer', - // Callback: (data, _sender, _message, _metadata) => { - // if ((data.Type === 'Hidden') && (data.Content === 'MBCHC')) return this.receive(data) - // 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 @@ -747,36 +717,18 @@ W.MBCHC = { // Footer this.LOADED = true - this.log('info', `loaded version ${this.VERSION}`) - //if ((W.CurrentModule === 'Online') && (W.CurrentScreen === 'ChatRoom')) { - // for (const c of W.ChatRoomCharacter) this.update_char(c) - // //this.player_enters_room() - //} - }, - preloader() { - if (!W.AsylumGGTSSAddItems) throw new Error('AsylumGGTSSAddItems() not found, aborting MBCHC loading') - if (!W.bcModSdk) throw new Error('SDK not found, please load with (or after) FUSAM') - this.SDK = W.bcModSdk.registerMod({name: 'MBCHC', fullName: 'Mute\'s Bondage Club Hacks Collection', version: this.VERSION, repository: 'https://code.fleshless.org/mute/MBCHC/'}) - this.before = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs, next) => { - try { - cb?.(...nextargs) - } catch (error) { - console.error(error) - } - return next(nextargs) - }) - this.after = (name, cb) => this.SDK.hookFunction(name, 0, (nextargs, next) => { - const result = next(nextargs); - try { - cb?.(...nextargs) - } catch (error) { - console.error(error) - } - return result - }) - - U.current() === 'Character/Login' ? this.remove_load_hook = this.before('AsylumGGTSSAddItems', () => this.loader()) :this.loader() + CW.i(`loaded version ${version}`) }, } -W.MBCHC.preloader() +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) diff --git a/package-lock.json b/package-lock.json index c4b4162..18f6753 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "mbchc", - "version": "105.13.0", + "version": "107.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mbchc", - "version": "105.13.0", - "license": "SEE LICENSE IN LICENSE.", + "version": "107.13.0", + "license": "SEE LICENSE IN LICENSE", "devDependencies": { - "bc-stubs": "^105.0.0", + "bc-stubs": "^106.0.0", "bondage-club-mod-sdk": "^1.2.0", + "typescript": "^5.5.2", "xo": "^0.56.0" } }, @@ -1073,9 +1074,9 @@ "dev": true }, "node_modules/bc-stubs": { - "version": "105.0.3", - "resolved": "https://registry.npmjs.org/bc-stubs/-/bc-stubs-105.0.3.tgz", - "integrity": "sha512-haKRphxOdPQT/9W6s5L0x5DFDttOrk0xBddFfoLnMQlz7AiXnU3TGdlJ+biWpmQtyHc7WrJARbYd4Wf7+a4j8g==", + "version": "106.0.0", + "resolved": "https://registry.npmjs.org/bc-stubs/-/bc-stubs-106.0.0.tgz", + "integrity": "sha512-qUbBXFdTZNq+JUGJLpWCiqfMwSDu/i36fcoTtwfbHMV32lSkXsaLgqK7itOpDZIUTWcZhneN4DO2m6Jd7juk7w==", "dev": true, "dependencies": { "socket.io-client": "4.6.1" diff --git a/package.json b/package.json index 520baf3..f0b593c 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,38 @@ { "name": "mbchc", - "version": "105.13.0", + "version": "107.13.0", "description": "Mute's Bondage Club Hacks Collection", + "author": "Mute", "type": "module", "devDependencies": { - "xo": "^0.56.0", - "bc-stubs": "^105.0.0", - "bondage-club-mod-sdk": "^1.2.0" + "bc-stubs": "^106.0.0", + "bondage-club-mod-sdk": "^1.2.0", + "typescript": "^5.5.2", + "xo": "^0.56.0" }, - "license": "SEE LICENSE IN LICENSE.", + "license": "SEE LICENSE IN LICENSE", "eslintConfig": { "root": true, - "extends": [ - "xo", - "xo-typescript" - ], + "extends": ["xo", "xo-typescript", "plugin:unicorn/recommended"], "parser": "@typescript-eslint/parser", "parserOptions": { "project": ["./jsconfig.json"] }, - "plugins": ["@typescript-eslint"], - "globals": {"GM": true, "GM_info": true}, + "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", @@ -44,9 +47,12 @@ "camelcase": "off", "capitalized-comments": "off", "curly": "off", + "generator-star-spacing": "off", + "max-nested-callbacks": "off", "max-params": "off", "max-statements-per-line": "off", "new-cap": "off", + "no-mixed-spaces-and-tabs": ["error", "smart-tabs"], "no-return-assign": "off", "no-unused-expressions": "off", "no-unused-vars": ["error", { @@ -61,16 +67,23 @@ "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/prefer-module": "off", - "unicorn/prefer-top-level-await": "off", - "fake/fuck-commas": "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" } } diff --git a/server.js b/server.js index b22cefd..803dffb 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,8 @@ import {readFileSync} from 'node:fs' import {createServer} from 'node:http' +import {argv} from 'node:process' -const config = {host: '127.0.0.1', port: 9696} +const config = {host: '127.0.0.1', port: 9696, filename: argv[2] ?? 'mbchc.mjs'} const h_cors = { 'Access-Control-Max-Age': '86400', @@ -11,23 +12,19 @@ const h_cors = { 'Access-Control-Allow-Headers': '*', // 'Access-Control-Allow-Credentials': 'false', // omit this header to disallow } -const h_all = Object.assign({ +const h_all = { 'Content-Type': 'text/javascript', 'Cache-Control': 'no-cache', -}, h_cors) + ...h_cors +} -/** - * @typedef {import('node:http').ServerResponse} ServerResponse - * @type {Record} - */ -const resp = { - GET(rx) { rx.writeHead(200, h_all); rx.write(readFileSync('./mbchc.mjs')) }, +/** @type {Record 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) => { - resp[rq.method] && (new URL(`http://${config.host}:${config.port}${rq.url}`)).pathname === '/' ? resp[rq.method](rx) : rx.writeHead(400) - rx.end() - console.log('%s %d %s %s', (new Date()).toISOString(), rx.statusCode, rq.method, rq.url) + rq.method !== undefined && resp[rq.method] !== undefined && (new URL(`http://${config.host}:${config.port}${rq.url}`)).pathname === '/' ? resp[rq.method](rx) : rx.writeHead(400) + rx.end(() => void console.log('%s %d %s %s', (new Date()).toISOString(), rx.statusCode, rq.method, rq.url)) }) -server.listen(config.port, config.host, () => console.log(`Server started at http://${config.host}:${config.port}`)) +server.listen(config.port, config.host, () => void console.log(`Server started at http://${config.host}:${config.port} for ${config.filename}`))