Mute
6034adae3c
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
735 lines
40 KiB
JavaScript
735 lines
40 KiB
JavaScript
// Take a look at the .d.ts for comments.
|
||
export const version = '107.13.0'
|
||
const W = window, D = W.document, /**fuck money*/$ = undefined, /**@type {''}*/$S = '', /**@type {{}}*/$O = {}, /**@type {Set<string>}*/$Ss = new Set() // /**@type {readonly []}*/$A = [],
|
||
const/**@type {TextDictionaryEntry}*/MISSING_PLAYER_DIALOG = {Tag: 'MISSING TEXT IN "Interface.csv": ', Text: '\u200C'} // Zero-width non-joiner, used to break up ligatures, does nothing here, but an empty string is a falsey value
|
||
|
||
const/**@type {FP.cur}*/cur = (v, f) => f(v)
|
||
const/**@type {FP.n2u}*/n2u = v => v === null ? $ : v
|
||
const/**@type {FP.enu}*/enu = o => Object.keys(o)
|
||
const/**@type {FP.val}*/val = (v, f) => v === $ || v === null ? $ : f(v)
|
||
const/**@type {FP.add}*/add = (x, y) => x + y
|
||
const/**@type {FP.sub}*/sub = (x, y) => x - y
|
||
const/**@type {FP.cgt}*/cgt = (x, y) => x > y
|
||
const/**@type {FP.cge}*/cge = (x, y) => x >= y
|
||
const/**@type {FP.clt}*/clt = (x, y) => x < y
|
||
const/**@type {FP.cle}*/cle = (x, y) => x <= y
|
||
const/**@type {FP.int}*/int = s => cur(Number.parseInt(s, 10), n => Number.isNaN(n) ? $ : n)
|
||
/**@type {FP.fun}*/const fun = f => typeof f === 'function'
|
||
const/**@type {FP.run}*/run = (fs, ...args) => {fs.forEach(f => fun(f) && void f(...args)); return true}
|
||
const/**@type {FP.yes}*/yes = (...args) => run(args, $)
|
||
const/**@type {FP.mut}*/mut = (v, ...args) => run(args, v) && v
|
||
const/**@type {FP.del}*/del = (o, p) => mut(o, Reflect.deleteProperty(o, p))
|
||
const/**@type {FP.ass}*/ass = (x, v, c = Boolean) => {if (v === $ || !c(v)) throw new Error(x); return v}
|
||
const/**@type {FP.asa}*/asa = (p, v) => v instanceof p ? v : $
|
||
const/**@type {FP.rsc}*/rsc = (f, r) => {try {f($)} catch (x) {r(x)} return true}
|
||
const/**@type {FP.Interval}*/range = new (class { proxy = new Proxy($O, this); min = 0; max = 0; mini = false; maxi = false
|
||
/**@type {FP.Interval['has']}*/has = (_, x) => val(int(x), x => (this.mini ? cge : cgt)(x, this.min) && (this.maxi ? cle : clt)(x, this.max)) ?? false
|
||
})()
|
||
const/**@type {FP.rng}*/rng = (min, max, mini = true, maxi = true) => mut(range.proxy, Object.assign(range, {min, max, mini, maxi}))
|
||
const/**@type {FP.m_t}*/m_t = v => { if (typeof v === 'string') return v.length === 0
|
||
if (v === $ || typeof v === 'object') return v === $ || v === null
|
||
|| ['length', 'size'].some(n => n in v && cur(/**@type {OBJ}*/(v)[n], x => typeof x === 'number' && x === 0))
|
||
|| (Object.getPrototypeOf(v) === Object.prototype && Reflect.ownKeys(v).length === 0)
|
||
return typeof v === 'boolean' && !v
|
||
}
|
||
const/**@type {FP.loo}*/loo = (m, c, a) => {let r; for (let n = 0; c($) && n < m; n++) r = a($); return r}
|
||
|
||
/**@template T*/const Pipe = /**@implements {FP.Pipeline}*/class PipeClass {
|
||
/**@type {FP.Pipeline<T>['proxy']}*/proxy = new Proxy(/**@type {this & Record<number, T | undefined>}*/(this), {get(t, n, r) {return cur(typeof n === 'string' && int(n), i => {
|
||
if (typeof i !== 'number' || i < 0) return Reflect.get(t, n, r)
|
||
let c = 0; for (const v of t) if (++c > i) return v; return $
|
||
})}})
|
||
constructor(/**@type {Iterable<T>}*/iterable) {this.iterable = iterable}
|
||
/**@type {FP.Pipeline<T>['me']}*/me(iterable) {return (new PipeClass(iterable)).proxy}
|
||
[Symbol.iterator]() {return this.iterable[Symbol.iterator]()}
|
||
/**@type {FP.Pipeline<T>['rdc']}*/rdc(i, f) {let ax = i; for (const v of this) ax = f(ax, v); return ax}
|
||
/**@type {FP.Pipeline<T>['any']}*/any(f) {for (const v of this) if (f(v)) return true; return false}
|
||
/**@type {FP.Pipeline<T>['all']}*/all(f) {for (const v of this) if (!f(v)) return false; return true}
|
||
/**@type {FP.Pipeline<T>['map']}*/map(f) {return this.me((function*(i, f) {for (const v of i) yield f(v)})(this, f))}
|
||
/**@type {FP.Pipeline<T>['sel']}*/sel(f) {return this.me((function*(i, f) {for (const v of i) if (f(v)) yield v})(this, f))}
|
||
}
|
||
const/**@type {FP.P}*/P = I => (new Pipe(I)).proxy
|
||
|
||
const/**@type {Cons.Wrap}*/CW = new (class {
|
||
/**@type {Cons.MS}*/ms = {w: 'warn', i: 'info', d: 'debug', l: 'log'}; w = this.gen('w'); i = this.gen('i'); d = this.gen('d'); l = this.gen('l')
|
||
/**@type {Cons.E}*/e = x => yes(void console.error(x))
|
||
/**@type {Cons.Wrap['gen']}*/gen(m) {return msg => yes(void console[this.ms[m]](`MBCHC: ${msg}`))}
|
||
})()
|
||
|
||
const/**@type {Settings.Methods}*/Settings = { // FIXME separate a proper V1 type from an unknown object in the extensionsettings
|
||
/**I hate change*/migrate_0_1(v0) { if (v0.MBCHC === $) return true
|
||
val(v0.MBCHC.timezones, tz => this.save(v1 => v1.TZ = {...tz, ...v1.TZ}))
|
||
W.ServerAccountUpdate.QueueData({OnlineSettings: del(v0, 'MBCHC')})
|
||
return CW.w('MBCHC: settings migration done (v0 -> v1). This should never happen again on the same account.')
|
||
},
|
||
save(f = $) {W.Player.ExtensionSettings['MBCHC'] ||= {}; f?.(this.v1); return yes(void W.ServerPlayerExtensionSettingsSync('MBCHC'))},
|
||
replace: v1 => yes(void (W.Player.ExtensionSettings['MBCHC'] = v1), void Settings.save()),
|
||
'purge!': () => Settings.replace(/** @type {Settings.V1} */({})),
|
||
get v0() {return Reflect.get(W.Player, 'OnlineSettings')},
|
||
get v1() {return mut(/**@type {Settings.V1}*/(W.Player.ExtensionSettings['MBCHC']) ?? {}, v1 => { // we need to check and repair the whole object every time we access it
|
||
v1.TZ ||= {}
|
||
})},
|
||
}
|
||
|
||
const/**@type {TZ_Cache}*/TZ = { map: new Map(), RE: /(?:gmt|utc)\s*([+-])\s*(\d\d?)/i,
|
||
parse: desc => val(desc, v => val(TZ.RE.exec(v), m => val(int(`${m[1] ?? $S}${m[2] ?? $S}`), n => n in rng(-12, 12) ? n : $))),
|
||
memo: (cid, desc = $) => val(Settings.v1.TZ[cid] ?? TZ.parse(desc), n => mut(n, TZ.map.set(cid, n))),
|
||
lookup: c => val(U.cid(c), cid => TZ.map.get(cid) ?? TZ.memo(cid, c.Description)),
|
||
}
|
||
|
||
const/**@type {Utils}*/U = { remove_loader_hook: $, RGB: {Polly: '#81b1e7', Mute: '#6c2132'}, ACT: `${CommandsKey}activity `,
|
||
RE: { SPACES: /\s+/gu, REL: {L: /^<+$/, R: /^>+$/}, '@': [/^@/, /^@@/] },
|
||
get crc() {return W.ChatRoomCharacter},
|
||
get ic() {return asa(HTMLTextAreaElement, D.querySelector('#InputChat'))},
|
||
cid: c => c.MemberNumber,
|
||
dn: c => W.CharacterNickname(c),
|
||
current: () => `${W.CurrentModule}/${W.CurrentScreen}`,
|
||
style: (q, f) => cur(D.querySelector(q), e => e instanceof HTMLElement ? f(e.style) : $),
|
||
inform: html => yes(void W.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`)),
|
||
report: x => U.inform(String(x)) && CW.e(x),
|
||
split: text => text.split(U.RE.SPACES),
|
||
abs2char: pos => ass(`invalid position ${pos}`, U.crc[pos]),
|
||
rel2char: t => cur(ass('can\'t find my position', U.crc.findIndex(char => char.IsPlayer()), n => n >= 0), me =>
|
||
cur(ass(`failed to parse target "${t}"`, U.RE.REL.L.test(t) ? sub : U.RE.REL.R.test(t) ? add : $)(me, t.length), pos =>
|
||
cur(pos % U.crc.length, p => U.abs2char(p < 0 ? p + U.crc.length : p)))),
|
||
cid2char: id => id === U.cid(W.Player) ? W.Player : ass(`character ${id} not found in the room`, U.crc.find(c => id === U.cid(c))),
|
||
target2char(target) { let t = target.trim(); const /**@type {Set<Character>}*/f = new Set() // FIXME Target should be 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
|
||
|
||
NEXT_MESSAGE: 1,
|
||
LOG_MESSAGES: false,
|
||
LOADED: false,
|
||
AUTOHACK_ENABLED: false,
|
||
/**@type {number | undefined}*/LAST_HACKED: $,
|
||
//HISTORY_MODE: false,
|
||
RE_PREF_ACTIVITY_ME: /^@/,
|
||
RE_PREF_ACTIVITY: /^@@/,
|
||
RE_ACT_CIDS: /^<(\d+)?:(\d+)?>/,
|
||
RE_LAST_WORD: /(^|\s)(\S*)$/,
|
||
RE_LAST_LETTER: /\w$/,
|
||
RE_ACTIVITY: new RegExp(`^${CommandsKey}activity `),
|
||
//PREF_ACTIVITY: `${CommandsKey}activity `,
|
||
UTC_OFFSET: new Date().getTimezoneOffset() * 60 * 1000,
|
||
MAP_ACTIONS: { // ActivityFemale3DCG
|
||
// Action
|
||
'nod|yes': {Head: {self: 'Nod'}},
|
||
no: {Head: {self: 'Wiggle'}},
|
||
moan: {Mouth: {self: 'MoanGag'}},
|
||
mumble: {Mouth: {self: 'MoanGagTalk'}},
|
||
whimper: {Mouth: {self: 'MoanGagWhimper'}},
|
||
groan: {Mouth: {self: 'MoanGagGroan'}},
|
||
scream: {Mouth: {self: 'MoanGagAngry'}},
|
||
giggle: {Mouth: {self: 'MoanGagGiggle'}},
|
||
struggle: {Arms: {self: 'StruggleArms'}},
|
||
thrash: {Legs: {self: 'StruggleLegs'}},
|
||
|
||
// Action zone
|
||
'wiggle|shake': {'Arms,Breast,Boots,Butt,Ears,Feet,Hands,Nose,Pelvis,Torso': {self: 'Wiggle'}},
|
||
|
||
// Action target
|
||
whisper: {Ears: {others: 'Whisper'}},
|
||
choke: {Neck: {all: 'Choke'}},
|
||
brush: {Head: {all: 'TakeCare'}},
|
||
french: {Mouth: {others: 'FrenchKiss'}},
|
||
sit: {Legs: {others: 'Sit'}},
|
||
rim: {Butt: {others: 'MasturbateTongue'}},
|
||
press: {Butt: {others: 'Step'}},
|
||
rest: {Torso: {others: 'Step'}},
|
||
pet: {Head: {all: 'Pet'}},
|
||
boop: {Nose: {all: 'Pet'}},
|
||
cuddle: {Arms: {others: 'Cuddle'}},
|
||
nuzzle: {Nose: {others: 'Cuddle'}},
|
||
grab: {Arms: {others: 'Grope'}},
|
||
clean: {Mouth: {all: 'Caress'}},
|
||
lap: {Legs: {others: 'RestHead'}},
|
||
lean: {Breast: {others: 'RestHead'}},
|
||
peck: {Mouth: {others: 'PoliteKiss'}},
|
||
|
||
// Action zone target
|
||
item: {
|
||
'Breast,Butt,Feet,Legs': {all: 'SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem|Inject'},
|
||
'Nipples,Pelvis': {all: 'SpankItem|TickleItem|RubItem|RollItem|MasturbateItem|PourItem|ShockItem'},
|
||
Arms: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem|Inject'},
|
||
Boots: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem'},
|
||
'Ears,Mouth': {all: 'TickleItem|RubItem|RollItem'},
|
||
'Hood,Nose': {all: 'TickleItem|RubItem'},
|
||
Neck: {all: 'TickleItem|RubItem|RollItem|Inject'},
|
||
Torso: {all: 'SpankItem|TickleItem|RubItem|RollItem|PourItem|ShockItem'},
|
||
Vulva: {all: 'SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem'},
|
||
VulvaPiercings: {all: 'SpankItem|TickleItem|RubItem|MasturbateItem|ShockItem|Inject'},
|
||
},
|
||
kiss: {
|
||
Mouth: {others: 'GagKiss|Kiss|GaggedKiss'},
|
||
'Boots,Hands': {self: 'PoliteKiss', others: 'PoliteKiss|GaggedKiss'},
|
||
'Arms,Breast,Nipples': {self: 'Kiss', others: 'Kiss|GaggedKiss'},
|
||
'Butt,Ears,Feet,Head,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {others: 'Kiss|GaggedKiss'},
|
||
},
|
||
smooch: {'Hands,Boots': {all: 'Kiss'}},
|
||
'nibble|chew': {'Arms,Hands,Boots,Mouth,Nipples': {all: 'Nibble'}, 'Ears,Feet,Legs,Neck,Nose,Pelvis,Torse,Vulva,VulvaPiercings': {others: 'Nibble'}},
|
||
'slap|spank': {'Head,Breast,Vulva,VulvaPiercings': {all: 'Slap'}, 'Arms,Boots,Butt,Feet,Hands,Legs,Pelvis,Torso': {all: 'Spank'}},
|
||
tickle: {'Arms,Boots,Breast,Feet,Legs,Neck,Pelvis,Torso': {all: 'Tickle'}},
|
||
massage: {'Arms,Boots,Feet,Legs,Neck,Pelvis,Torso': {all: 'MassageHands'}},
|
||
lick: {'Arms,Boots,Breast,Hands,Mouth,Nipples': {all: 'Lick'}, 'Ears,Feet,Legs,Neck,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {others: 'Lick'}},
|
||
suck: {'Nipples,Hands,Boots': {all: 'Suck'}},
|
||
bite: {'Arms,Boots,Feet,Hands,Legs,Mouth': {all: 'Bite'}, 'Breast,Butt,Ears,Head,Neck,Nipples,Nose,Torso': {others: 'Bite'}},
|
||
pinch: {'Arms,Ears,Nipples,Nose,Pelvis': {all: 'Pinch'}},
|
||
clamp: {Mouth: {all: 'HandGag'}, Nose: {all: 'Choke'}},
|
||
step: {'Breast,Neck,Pelvis': {others: 'Step'}},
|
||
pull: {'Head,Nose,Nipples': {all: 'Pull'}},
|
||
grope: {'Butt,Breast': {all: 'Grope'}, 'Feet,Legs,Pelvis': {others: 'Grope'}},
|
||
rub: {'Head,Torso': {others: 'Rub'}, Nose: {all: 'Rub'}, Legs: {self: 'Wiggle'}, Hands: {self: 'Caress'}},
|
||
caress: {Hands: {others: 'Caress'}, 'Arms,Breast,Butt,Ears,Feet,Head,Legs,Neck,Nipples,Nose,Pelvis,Torso,Vulva,VulvaPiercings': {all: 'Caress'}},
|
||
polish: {'Hands,Boots': {all: 'TakeCare'}},
|
||
foot: {'Head,Nose': {others: 'Step'}, 'Torso,Boots': {others: 'MassageFeet'}, 'Vulva,VulvaPiercings': {others: 'MasturbateFoot'}},
|
||
fist: {'Vulva,Butt': {all: 'MasturbateFist'}},
|
||
fuck: {'Mouth,Vulva,Butt': {others: 'PenetrateSlow'}}, // Peg?
|
||
pound: {'Mouth,Vulva,Butt': {others: 'PenetrateFast'}},
|
||
tongue: {'Vulva,VulvaPiercings': {others: 'MasturbateTongue'}},
|
||
finger: {'Breast,Butt,Vulva,VulvaPiercings': {all: 'MasturbateHand'}},
|
||
},
|
||
MAP_ZONES: {
|
||
ItemBoots: ['foot', 'feet', 'boot', 'boots', 'shoe', 'shoes', 'toes', 'toenails', 'sole', 'soles', 'heel', 'heels'],
|
||
ItemFeet: ['leg', 'legs', 'ankle', 'ankles'],
|
||
ItemLegs: ['hips', 'hip', 'thighs', 'thigh'],
|
||
ItemVulva: ['vulva', 'pussy'],
|
||
ItemVulvaPiercings: ['clit', 'clitoris'],
|
||
ItemButt: ['butt', 'ass'],
|
||
ItemPelvis: ['tummy', 'pelvis'],
|
||
ItemTorso: ['body', 'torso', 'back', 'ribs'],
|
||
ItemBreast: ['breast', 'breasts', 'boob', 'boobs', 'booby', 'boobie', 'boobies', 'tit', 'tits', 'titty', 'tittie', 'titties'],
|
||
ItemNipples: ['nip', 'nips', 'nipple', 'nipples'],
|
||
ItemHands: ['hand', 'hands', 'fingers', 'fingernails', 'nails'],
|
||
ItemArms: ['arm', 'arms', 'elbow', 'elbows'],
|
||
ItemNeck: ['neck'],
|
||
ItemMouth: ['mouth', 'lip', 'lips', 'teeth', 'tongue', 'gag', 'cheek', 'cheeks'],
|
||
ItemNose: ['nose', 'nostrils'],
|
||
ItemEars: ['ear', 'ears', 'earlobe', 'earlobes'],
|
||
ItemHead: ['head', 'face', 'hair', 'eyes', 'forehead'],
|
||
},
|
||
DO_DATA: {verbs: {}, zones: {}},
|
||
calculate_maps() {
|
||
for (const [verbs, data] of Object.entries(this.MAP_ACTIONS)) {
|
||
const/**@type {{[k: string]: {self: string[]; others: string[]}}}*/unwound = {}
|
||
for (const [zones, actions] of Object.entries(data)) {
|
||
const all = val(actions.all, a => a.split('|')) ?? []
|
||
const processed = {self: val(actions.self, s => [...s.split('|'), ...all]) ?? all, others: val(actions.others, o => [...o.split('|'), ...all]) ?? all}
|
||
for (const zone of zones.split(',')) unwound[`Item${zone}`] = processed
|
||
}
|
||
for (const verb of verbs.split('|')) this.DO_DATA.verbs[verb] = unwound
|
||
}
|
||
for (const [ag, zones] of Object.entries(this.MAP_ZONES)) for (const zone of zones) this.DO_DATA.zones[zone] = ag
|
||
},
|
||
normalise_message(/**@type {string}*/text, /**@type {OBJ<boolean>}*/options = $O) {
|
||
let result = text
|
||
if (options['trim'] ?? false) result = result.trim()
|
||
if (options['low'] ?? false) result = result.toLocaleLowerCase()
|
||
if (options['up'] ?? false) {
|
||
const first = result.at(0)?.toLocaleUpperCase() ?? $S
|
||
const rest = result.slice(1)
|
||
result = first + rest
|
||
}
|
||
if ((options['dot'] ?? false) && this.RE_LAST_LETTER.test(result)) result = `${result}.`
|
||
return result
|
||
},
|
||
donate_data(/**@type {string}*/target) {
|
||
const char = U.target2char(target)
|
||
ass('target must not be you', !char.IsPlayer())
|
||
ass('target must be bound', char.IsRestrained())
|
||
const cost = Math.round(((Math.random() * 10) + 15))
|
||
ass('not enough money', W.Player.Money >= cost)
|
||
W.CharacterChangeMoney(W.Player, -cost)
|
||
W.ServerSend('ChatRoomChat', {Content: 'ReceiveSuitcaseMoney', Type: 'Hidden', Target: U.cid(char)})
|
||
W.ChatRoomMessage({Sender: ass('...', U.cid(W.Player)), Type: 'Action', Content: `You've bought data for $${cost} and sent it to ${U.dn(char)}.`, Dictionary: [MISSING_PLAYER_DIALOG]})
|
||
},
|
||
run_activity(/**@type {Character}*/char, /**@type {AssetGroupItemName}*/ag, /**@type {ActivityName}*/action) {
|
||
try {
|
||
ass('activities disabled in this room', W.ActivityAllowed())
|
||
ass('no permissions', W.ServerChatRoomGetAllowItem(W.Player, char))
|
||
char.FocusGroup = ass('invalid AssetGroup', n2u(W.AssetGroupGet(char.AssetFamily, ag)))
|
||
const activity = ass('invalid activity', W.ActivityAllowedForGroup(char, char.FocusGroup.Name).find(a => a.Activity?.Name === action))
|
||
//if ((activity.Name || activity.Activity.Name).endsWith('Item')) {
|
||
// const item = this.ensure('no toy found', () => w.Player.Inventory.find(i => i.Asset?.Name === 'SpankingToys' && i.Asset.Group?.Name === char.FocusGroup.Name && w.AssetSpankingToys.DynamicActivity(char) === (activity.Name || activity.Activity.Name)))
|
||
// w.DialogPublishAction(char, item)
|
||
//} else w.ActivityRun(w.Player, char, char.FocusGroup, activity)
|
||
W.ActivityRun(W.Player, char, char.FocusGroup, activity)
|
||
} finally {
|
||
char.FocusGroup = null // eslint-disable-line unicorn/no-null
|
||
}
|
||
},
|
||
send_activity(/**@type {string}*/message) { let Content = message; const /**@type {ChatMessageDictionary}*/Dictionary = [MISSING_PLAYER_DIALOG]
|
||
val(this.RE_ACT_CIDS.exec(Content), cids => {
|
||
Content = Content.replace(this.RE_ACT_CIDS, $S)
|
||
val(cids[1], cid => val(int(cid), n => Dictionary.push({SourceCharacter: n})))
|
||
val(cids[2], cid => val(int(cid), n => Dictionary.push({TargetCharacter: n})))
|
||
})
|
||
W.ServerSend('ChatRoomChat', {Type: 'Action', Content, Dictionary})
|
||
},
|
||
set_timezone(/**@type {string[]}*/args) {
|
||
const tz = ass(`invalid offset "${args[0]}"`, int(args[0] ?? $S))
|
||
ass('offset should be [-12,12]', tz in rng(-12, 12))
|
||
const char = U.target2char(args[1] ?? $S)
|
||
return val(U.cid(char), cid => Settings.save(v1 => v1.TZ[cid] = tz) && TZ.memo(cid))
|
||
},
|
||
command_mbchc(argline, cmdline, args) {
|
||
const mbchc = W.MBCHC
|
||
try { // `this` is command object
|
||
if (m_t(args)) return void U.inform(Object.entries(SUBCOMMANDS_MBCHC).map(([cmd, sub]) => `<div>/mbchc ${cmd} ${val(sub.args, a => enu(a).join(' ')) ?? $S}: ${sub.desc}</div>`).join($S))
|
||
const cmd = String(args.shift())
|
||
const sub = ass(`unknown subcommand "${cmd}"`, SUBCOMMANDS_MBCHC[cmd])
|
||
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
|
||
} catch (x) {
|
||
U.report(x)
|
||
}
|
||
},
|
||
command_activity(argline, cmdline, _) {
|
||
const mbchc = W.MBCHC
|
||
if (!m_t(argline.trim())) {
|
||
try { // `this` is command object
|
||
const message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, $S), {trim: true, dot: true, up: true})
|
||
mbchc.send_activity(message)
|
||
} catch (x) {
|
||
U.report(x)
|
||
}
|
||
}
|
||
},
|
||
command_do(_argline, _cmdline, args) {
|
||
const mbchc = W.MBCHC
|
||
try { // `this` is command object
|
||
if (m_t(args)) return void U.inform('<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>' + enu(mbchc.MAP_ACTIONS).join(', ') + '<div>Available zones:</div>' + enu(mbchc.DO_DATA.zones).join(', '))
|
||
let [verb, zone, target] = args
|
||
const zones = ass(`unknown verb "${verb}"`, mbchc.DO_DATA.verbs[verb ?? $S])
|
||
if (enu(zones).length === 1) {
|
||
if (target === $) target = zone
|
||
zone = mbchc.MAP_ZONES[enu(zones)[0] ?? $S]?.[0]
|
||
}
|
||
|
||
zone = ass('zone missing', zone)
|
||
const ag = ass(`unknown zone "${zone}"`, mbchc.DO_DATA.zones[zone])
|
||
const types = ass(`zone "${zone}" invalid for "${verb}"`, zones[ag])
|
||
let/**@type {Character}*/char = W.Player
|
||
if (target !== $ && (m_t(types.self) || !m_t(types.others))) char = U.target2char(target)
|
||
const type = char.IsPlayer() ? 'self' : 'others'
|
||
const available = W.ActivityAllowedForGroup(char, /**@type {AssetGroupItemName}*/(ag))
|
||
//const toy = w.InventoryGet(w.Player, 'ItemHands')
|
||
//if (toy && toy.Asset.Name === 'SpankingToys') available.push(w.AssetAllActivities(char.AssetFamily).find(a => a.Name === w.InventorySpankingToysGetActivity?.(w.Player)))
|
||
const actions = ass(`zone "${zone}" invalid for ("${verb}" "${type}")`, types[type])
|
||
const action = ass(`invalid action (${verb} ${zone} ${target})`, actions.find(name => available.find(a => a.Activity?.Name === name)))
|
||
mbchc.run_activity(char, /**@type {AssetGroupItemName}*/(ag), /**@type {ActivityName}*/(action))
|
||
} catch (x) { U.report(x) }
|
||
},
|
||
//complete(options, space = true) {
|
||
// if (m_t(options)) return void U.bell()
|
||
// if (options.length > 1) {
|
||
// const width = Math.max(...options.map(o => o.length))
|
||
// let pref = null
|
||
// for (let i = width; i > 0; i -= 1) {
|
||
// const test = options[0].slice(0, i)
|
||
// if (options.every(o => o.startsWith(test))) {
|
||
// pref = test
|
||
// break
|
||
// }
|
||
// }
|
||
|
||
// if (pref) this.complete([pref], false)
|
||
// this.comp_hint(options)
|
||
// } else W.ElementValue('InputChat', W.ElementValue('InputChat').replace(this.RE_LAST_WORD, `$1${options[0]}${space ? ' ' : $S}`))
|
||
//},
|
||
|
||
///**
|
||
// * Displays strings as completion hint
|
||
// * @param {string[]} options List of words to display. The order will be modified without copy.
|
||
// * @returns {undefined}
|
||
// */
|
||
//comp_hint(options) {
|
||
// if (m_t(options)) return
|
||
// this.COMP_HINT.innerHTML = '<div>' + options.sort().reverse().map(s => `<div>${s}</div>`).join($S) + '</div>'
|
||
// this.COMP_HINT.style.display = 'block'
|
||
// W.ElementSetDataAttribute(this.COMP_HINT.id, 'colortheme', (W.Player.ChatSettings?.ColorTheme || 'Light'))
|
||
// const rescroll = W.ElementIsScrolledToEnd('TextAreaChatLog')
|
||
// W.ChatRoomResize(false)
|
||
// if (rescroll) W.ElementScrollToEnd('TextAreaChatLog')
|
||
// this.COMP_HINT.firstChild?.lastChild?.scrollIntoView({behaviour: 'instant'})
|
||
//},
|
||
|
||
///**
|
||
// * Returns true if the completion box is attached to body and its display isn't none
|
||
// * @returns {boolean}
|
||
// */
|
||
//comp_hint_visible() {
|
||
// return this.COMP_HINT.parentElement && this.COMP_HINT.style.display !== 'none'
|
||
//},
|
||
//comp_hint_hide() {
|
||
// if (!this.comp_hint_visible()) return
|
||
// this.COMP_HINT.style.display = 'none'
|
||
// W.ChatRoomResize(false)
|
||
//},
|
||
//char2targets(/**@type {Character}*/char) {
|
||
// const/**@type {Set<string>}*/result = new Set()
|
||
// val(U.cid(char)?.toString(), id => result.add(id).add(`=${id}`))
|
||
// for (const t of U.split(char.Name)) {
|
||
// result.add(t)
|
||
// result.add(`@${t}`)
|
||
// }
|
||
|
||
// if (char.Nickname !== $) for (const t of U.split(char.Nickname)) {
|
||
// result.add(t)
|
||
// result.add(`@${t}`)
|
||
// }
|
||
|
||
// return result
|
||
//},
|
||
//complete_target(/**@type {string}*/token, me2 = true, check_perms = false) {
|
||
// const [locase, found] = [token.toLocaleLowerCase(), new Set()]
|
||
// for (const c of U.crc) {
|
||
// if ((c.IsPlayer() && !me2) || (check_perms && !W.ServerChatRoomGetAllowItem(W.Player, c))) continue
|
||
// for (const s of this.char2targets(c)) {
|
||
// if (s.toLocaleLowerCase().startsWith(locase)) found.add(s)
|
||
// }
|
||
// }
|
||
|
||
// //this.complete(Array.from(found))
|
||
//},
|
||
//complete_common() { // w.ElementValue('InputChat') will strip the trailing whitespace
|
||
// const E = D.querySelector('#InputChat')
|
||
// ass('somehow #InputChat is broken', E instanceof HTMLTextAreaElement)
|
||
// return [this, E.value, U.split(E.value)]
|
||
//},
|
||
//complete_mbchc(_args, _locase, _cmdline) {
|
||
// const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object
|
||
// if (m_t(tokens)) return
|
||
// if (tokens.length < 2) return //mbchc.complete([`${CommandsKey}${this.Tag}`])
|
||
// const subname = tokens[1].toLocaleLowerCase()
|
||
// if (tokens.length < 3) return //mbchc.complete(Object.keys(SUBCOMMANDS_MBCHC).filter(c => c.startsWith(subname))) // Complete subcommand name
|
||
// const sub = SUBCOMMANDS_MBCHC[subname]
|
||
// if (sub && sub.args) {
|
||
// const argname = Object.keys(sub.args)[tokens.length - 3]
|
||
// if (argname === 'TARGET') return mbchc.complete_target(tokens.at(-1), false)
|
||
// if (argname === '[TARGET]') return mbchc.complete_target(tokens.at(-1))
|
||
// }
|
||
//},
|
||
//complete_fbc_anim(_args, _locase, _cmdline) {
|
||
// const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object
|
||
// if (m_t(tokens)) return
|
||
// if (tokens.length < 2) return mbchc.complete([`${CommandsKey}${this.Tag}`])
|
||
// if (tokens.length > 2) return void U.bell()
|
||
// const anim = tokens[1].toLocaleLowerCase()
|
||
// return mbchc.complete(Object.keys(W.bce_EventExpressions).filter(a => a.toLocaleLowerCase().startsWith(anim)))
|
||
//},
|
||
//complete_fbc_pose(_args, _locase, _cmdline) {
|
||
// const [mbchc, _input, tokens] = W.MBCHC.complete_common(); // `this` is command object
|
||
// if (m_t(tokens)) return
|
||
// if (tokens.length < 2) return mbchc.complete([`${CommandsKey}${this.Tag}`])
|
||
// const pose = tokens.at(-1).toLocaleLowerCase()
|
||
// return mbchc.complete(W.PoseFemale3DCG.map(p => p.Name).filter(p => p.toLocaleLowerCase().startsWith(pose)))
|
||
//},
|
||
//focus_chat_checks() { // we only want to catch 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() {
|
||
U.remove_loader_hook === $ || yes(void U.remove_loader_hook(), U.remove_loader_hook = $)
|
||
if (this.LOADED) return
|
||
val(Settings.v0, v0 => Settings.migrate_0_1(v0))
|
||
|
||
// Calculated values
|
||
const/**@type {ICommand[]}*/COMMANDS = [
|
||
{Tag: 'mbchc', Description: ': Utility functions ("/mbchc" for help)', Action: this.command_mbchc, AutoComplete: U.complete_mbchc},
|
||
{Tag: 'activity', Description: '[Message]: Send a custom activity (or "@@Message", or "@Message" as yourself)', Action: this.command_activity},
|
||
{Tag: 'do', Description: ': Do an activity, as if clicked on its button ("/do" for help)', Action: this.command_do, AutoComplete: U.complete_do},
|
||
]
|
||
mut(D.createElement('style'), c => void D.head.append(c), c => c.textContent = `
|
||
#TextAreaChatLog .mbchc { background-color: ${U.RGB.Polly}; margin-left: -0.4em; padding-left: 0.4em; }
|
||
#TextAreaChatLog[data-colortheme^="dark"] .mbchc { background-color: ${U.RGB.Mute}; }
|
||
#${C.e.id} { display: none; text-align: right; }
|
||
#${C.e.id} > div { overflow: auto; position: absolute; bottom: 0; right: 0; max-height: 100%; padding: 0 0.5ex; background-color: ${U.RGB.Polly}; color: black; }
|
||
#${C.e.id}[data-colortheme^="dark"] > div { background-color: ${U.RGB.Mute}; color: white; }
|
||
#${C.e.id} > div div { margin: 0.25ex 0; }
|
||
#chat-room-div[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; }
|
||
`)
|
||
|
||
// Actions
|
||
this.calculate_maps()
|
||
W.CommandCombine(COMMANDS)
|
||
|
||
// Hooks
|
||
after('ChatRoomReceiveSuitcaseMoney', () => {
|
||
if (this.AUTOHACK_ENABLED && this.LAST_HACKED !== $) {
|
||
W.CurrentCharacter = U.cid2char(this.LAST_HACKED)
|
||
this.LAST_HACKED = $
|
||
W.ChatRoomTryToTakeSuitcase()
|
||
}
|
||
})
|
||
prior('ChatRoomSendChat', () => val(U.ic, ic => !ic.value.startsWith('@@@') && ic.value.startsWith('@') && (ic.value = ic.value.replace(U.RE['@'][1], U.ACT).replace(U.RE['@'][0], U.replace_me))))
|
||
//{
|
||
// let input = W.ElementValue('InputChat')
|
||
// if (!input.startsWith('@@@') && input.startsWith('@')) {
|
||
// input = input.replace(this.RE_PREF_ACTIVITY, this.PREF_ACTIVITY)
|
||
// input = input.replace(this.RE_PREF_ACTIVITY_ME, this.replace_me)
|
||
// W.ElementValue('InputChat', input)
|
||
// }
|
||
//})
|
||
after('ChatRoomSendChat', () => { // FIXME actually make history a ring buffer of a given size. clear the array and push every string into it, compacting sequential equal strings into one.
|
||
const history = W.ChatRoomLastMessage
|
||
if ((history.length > 1) && (history.at(-1) === history.at(-2))) {
|
||
history.pop()
|
||
W.ChatRoomLastMessageIndex -= 1
|
||
}
|
||
})
|
||
prior('ChatRoomCharacterViewDrawOverlay', (C, CX, CY, Z) => {
|
||
// if (w.ChatRoomHideIconState < 1 && C.MBCHC) {
|
||
// w.DrawRect(CharX + (175 * Zoom), CharY, 50 * Zoom, 50 * Zoom, C.MBCHC.VERSION === w.Player.MBCHC.VERSION ? U.RGB.Polly : U.RGB.Mute)
|
||
// }
|
||
val(TZ.lookup(C), ctz => W.ChatRoomHideIconState < 1 && void cur(new Date(W.CommonTime() + this.UTC_OFFSET + (ctz * 60 * 60 * 1000)).getHours(), hr =>
|
||
void W.DrawTextFit(`${hr < 10 ? '0' : ''}${hr}`, CX + (Z * 200), CY + (Z * 25), Z * 46, 'white', 'black')))
|
||
})
|
||
|
||
const/**@type {(this: HTMLTextAreaElement, e: KeyboardEvent) => void}*/ickd = function(e) {
|
||
C.hide()
|
||
const ic = this // eslint-disable-line unicorn/no-this-assignment,@typescript-eslint/no-this-alias
|
||
if (ic.readOnly) switch (e.key) {
|
||
case 'Tab': case 'Escape':
|
||
e.stopImmediatePropagation()
|
||
e.preventDefault() // falls through
|
||
case 'Enter':
|
||
H.exit(ic, e.key === 'Escape') // no default
|
||
}
|
||
}
|
||
after('ChatRoomCreateElement', () => { // This thing runs on every frame actually.
|
||
C.e.parentElement === 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<string>}*/lines = new Set() // these only exist to filter duplicates
|
||
const/**@type {Set<number>}*/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) => {
|
||
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)
|