MBCHC/mbchc.mjs
Mute d8a4d250d7 110.13.0
* changed assert to only check for undefined by default
* fixes a bug with 0 time offset
* updated the club to R110
2024-12-04 00:50:36 +00:00

593 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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