MBCHC/mbchc.mjs

783 lines
35 KiB
JavaScript
Raw Normal View History

/** @type {MBCHC.Root} */ const W = window, D = W.document
if (W.MBCHC !== undefined) throw new Error('MBCHC found, aborting loading')
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))}
}
/** @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))),
}
/** @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.'))
},
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
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)),
}
// ^ type-safe (still need strict later)
// =================================================================================
// v legacy mess
W.MBCHC = {
VERSION,
Settings,
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
RETHROW: false,
LOADED: false,
AUTOHACK_ENABLED: false,
LAST_HACKED: null,
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',
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'],
},
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
// }
//}},
},
calculate_maps() {
this.DO_DATA = {verbs: {}, zones: {}}
for (const [verbs, data] of Object.entries(this.MAP_ACTIONS)) {
const 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}
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 = {}) {
let result = text
if (options.trim) result = result.trim()
if (options.low) result = result.toLocaleLowerCase()
if (options.up) {
const first = result.at(0).toLocaleUpperCase()
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(`<div class="mbchc">${html}</div>`, 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}`)
}
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')
const cost = Math.round(((Math.random() * 10) + 15))
if (W.Player.Money < cost) throw new Error('not enough money')
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]})
},
run_activity(char, ag, 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))
//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
}
},
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}`
},
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
//})
},
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]) => `<div>/mbchc ${cmd} ${sub.args ? Object.keys(sub.args).join(' ') : ''}: ${sub.desc}</div>`).join('')))
const cmd = String(args.shift())
const sub = U.assert(`unknown subcommand "${cmd}"`, mbchc.SUBCOMMANDS_MBCHC[cmd])
sub.cb.call(mbchc, mbchc, args, argline, cmdline)
} catch (error) {
mbchc.report(error)
}
},
command_activity(argline, cmdline, _) {
const mbchc = W.MBCHC
if (!mbchc.empty(argline)) {
try { // `this` is command object
const message = mbchc.normalise_message(cmdline.replace(mbchc.RE_ACTIVITY, ''), {trim: true, dot: true, up: true})
mbchc.send_activity(message)
} catch (error) {
mbchc.report(error)
}
}
},
command_do(argline, cmdline, args) {
const mbchc = W.MBCHC
try { // `this` is command object
if (args.length === 0) return (mbchc.inform('<div>Usage: /do VERB [ZONE] [TARGET]</div><div>Available verbs:</div>' + Object.keys(mbchc.MAP_ACTIONS).join(', ') + '<div>Available zones:</div>' + Object.keys(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]
}
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)
const type = char.IsPlayer() ? 'self' : 'others'
const available = W.ActivityAllowedForGroup(char, 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)
}
},
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
}
}
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 ? ' ' : ''}`))
},
/**
* 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 = '<div>' + options.sort().reverse().map(s => `<div>${s}</div>`).join('') + '</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)
},
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)
}
}
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 (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]))
}
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')
},
loader() {
if (this.remove_load_hook) {
this.remove_load_hook()
delete this.remove_load_hook
}
if (this.LOADED) return
U.with(/** @type {MBCHC.Settings.V0} */ (W.Player.OnlineSettings), os => os?.MBCHC && Settings.migrate_0_1(os))
// Calculated values
const COMMANDS = [
{Tag: 'mbchc', Description: ': Utility functions ("/mbchc" for help)', Action: this.command_mbchc, AutoComplete: this.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},
]
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;
}
`
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
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', () => {
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) => {
// 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)
// }
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')
}
})
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
}
return (next(nextargs))
})
// 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 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
return false
},
})
// 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()
},
}
W.MBCHC.preloader()