Mute
90231cb2ae
a massive diff due to infrastructure work the completion pane moved to the left of chat no other intended changes for users
801 lines
34 KiB
JavaScript
801 lines
34 KiB
JavaScript
export {}
|
|
|
|
/** @typedef {import('bondage-club-mod-sdk').ModSDKGlobalAPI} ModSDKGlobalAPI */
|
|
|
|
/** @type {Window & typeof globalThis & {MBCHC?: any, bcModSdk?: ModSDKGlobalAPI, bce_ActivityTriggers?: any, bce_EventExpressions?: any}} */
|
|
const w = window
|
|
|
|
/**
|
|
* A silly helper to memorise values in callbacks
|
|
* @template V, R
|
|
* @param {V} v Value to memorise
|
|
* @param {function(V): R} cb Callback
|
|
* @returns {R} Return value of the callback
|
|
*/
|
|
const take = (v, cb) => cb(v)
|
|
|
|
/**
|
|
* Takes a DOM query and passes the element's style into a callback, returning its result
|
|
* @template T
|
|
* @param {string} q Query
|
|
* @param {function(CSSStyleDeclaration): T} cb Callback
|
|
* @returns {T | void} Return value of the callback, if it was called
|
|
*/
|
|
const style = (q, cb) => take(document.querySelector(q), E => E && E instanceof HTMLElement && E.style ? cb(E.style) : undefined)
|
|
|
|
/**
|
|
* @returns {string} Current view
|
|
*/
|
|
const current = () => `${w.CurrentModule}/${w.CurrentScreen}`
|
|
|
|
/**
|
|
* @param {Character} char
|
|
* @returns {number}
|
|
*/
|
|
const cid = char => char.MemberNumber
|
|
|
|
// Zero-width non-joiner, used to break up ligatures, does nothing here, but an empty string is a falsey value
|
|
const MISSING_PLAYER_DIALOG = {Tag: 'MISSING TEXT IN "Interface.csv": ', Text: '\u200C'}
|
|
|
|
if (w.MBCHC) throw new Error('MBCHC found, aborting loading')
|
|
w.MBCHC = {
|
|
VERSION: 'dev.12',
|
|
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_TZ: /(?:gmt|utc)\s*([+-])\s*(\d\d?)/i,
|
|
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(mbchc) {
|
|
if (w.Player.OnlineSettings.MBCHC) {
|
|
delete w.Player.OnlineSettings.MBCHC
|
|
mbchc.save_settings()
|
|
}
|
|
}},
|
|
},
|
|
ensure(text, callback) {
|
|
const result = callback.call(this)
|
|
if (!result) throw new Error(text)
|
|
return (result)
|
|
},
|
|
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
|
|
},
|
|
settings(setting = null) {
|
|
const settings = w.Player.OnlineSettings.MBCHC || {}
|
|
return (setting ? settings[setting] : settings)
|
|
},
|
|
save_settings(cb = null) {
|
|
if (cb) {
|
|
if (!w.Player.OnlineSettings.MBCHC) w.Player.OnlineSettings.MBCHC = {}
|
|
cb.call(this, w.Player.OnlineSettings.MBCHC)
|
|
}
|
|
w.ServerAccountUpdate.QueueData({OnlineSettings: w.Player.OnlineSettings})
|
|
},
|
|
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
|
|
},
|
|
in(x, floor, ceiling) {
|
|
return ((x >= floor) && (x <= ceiling))
|
|
},
|
|
cid2char(id) {
|
|
id = Number.parseInt(id, 10)
|
|
if (id === cid(w.Player)) return (w.Player)
|
|
return (this.ensure(`character ${id} not found in the room`, () => w.ChatRoomCharacter.find(c => cid(c) === id)))
|
|
},
|
|
pos2char(pos) {
|
|
if (!this.in(pos, 0, w.ChatRoomCharacter.length - 1)) throw new Error(`invalid position ${pos}`)
|
|
return (w.ChatRoomCharacter[pos])
|
|
},
|
|
rel2char(target) {
|
|
const me = this.ensure('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 (this.in(int, 0, 9)) return (this.pos2char(int))
|
|
if (this.in(int, 11, 15)) return (this.pos2char(int - 11))
|
|
if (this.in(int, 21, 25)) return (this.pos2char(int - 16))
|
|
found.push(...w.ChatRoomCharacter.filter(c => 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[cid(c)]) map[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 => `${cid(c)}|${c.Name}|${c.Nickname || c.Name}`).join(',')})`)
|
|
return (found[0])
|
|
},
|
|
char2targets(char) {
|
|
const [result, id] = [new Set(), 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
|
|
},
|
|
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: cid(char)})
|
|
w.ChatRoomMessage({Sender: cid(w.Player), Type: 'Action', Content: `You've bought data for $${cost} and sent it to ${char.dn}.`, 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 = this.ensure('invalid AssetGroup', () => w.AssetGroupGet(char.AssetFamily, ag))
|
|
const activity = this.ensure('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}<${cid(w.Player)}:>SourceCharacter${suffix}`
|
|
},
|
|
cid2dict(type, cid) {
|
|
return ({Tag: `${type}Character`, MemberNumber: cid, Text: this.cid2char(cid).dn})
|
|
},
|
|
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
|
|
},
|
|
find_timezone(char) {
|
|
const timezones = this.settings('timezones')
|
|
if (timezones && typeof timezones[cid(char)] === 'number') return (timezones[cid(char)])
|
|
const match = (char.Description) ? char.Description.match(this.RE_TZ) : null
|
|
const int = match ? Number.parseInt(match[1] + match[2], 10) : 42
|
|
if (this.in(int, -12, 12)) return (int)
|
|
return (null)
|
|
},
|
|
//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 (!this.in(tz, -12, 12)) throw new Error('offset should be [-12,12]')
|
|
const char = this.target2char(args[1])
|
|
char.MBCHC_LOCAL.TZ = tz
|
|
this.save_settings(s => {
|
|
if (!s.timezones) s.timezones = {}
|
|
s.timezones[cid(char)] = tz
|
|
})
|
|
},
|
|
update_char(char) {
|
|
//char.cid = char.MemberNumber // Club ID (shorter)
|
|
char.dn = w.CharacterNickname(char) // DisplayName (shortcut)
|
|
if (!char.MBCHC_LOCAL) char.MBCHC_LOCAL = {}
|
|
if (!char.MBCHC_LOCAL.TZ) char.MBCHC_LOCAL.TZ = this.find_timezone(char)
|
|
},
|
|
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 = mbchc.ensure(`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 = mbchc.ensure(`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 = mbchc.ensure(`unknown zone "${zone}"`, () => mbchc.DO_DATA.zones[zone])
|
|
const types = mbchc.ensure(`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 = mbchc.ensure(`zone "${zone}" invalid for ("${verb}" "${type}")`, () => types[type])
|
|
const action = mbchc.ensure(`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(() => {
|
|
style('#InputChat', s => s.outline = '')
|
|
}, 100)
|
|
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 {void}
|
|
*/
|
|
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 = document.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([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 (document.activeElement === document.body) return true
|
|
if (document.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 (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
|
|
// 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 /* eslint-disable-line no-undef */ // window.CommandsKey is undefined
|
|
this.RE_ACTIVITY = new RegExp(`^${this.CommandsKey}activity `)
|
|
this.PREF_ACTIVITY = `${this.CommandsKey}activity `
|
|
this.COMP_HINT = document.createElement('div')
|
|
this.COMP_HINT.id = 'mbchcCompHint'
|
|
const css = document.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;
|
|
}
|
|
`
|
|
|
|
document.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)
|
|
// }
|
|
if (w.ChatRoomHideIconState < 1 && C.MBCHC_LOCAL && typeof C.MBCHC_LOCAL.TZ === 'number') {
|
|
const hours = new Date(w.CommonTime() + this.UTC_OFFSET + (C.MBCHC_LOCAL.TZ * 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 || document.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' && document.querySelector('#InputChat') && document.querySelector('#TextAreaChatLog') && this.comp_hint_visible()) { // Upstream
|
|
const fontsize = ChatRoomFontSize /* eslint-disable-line no-undef */ // window.ChatRoomFontSize is undefined
|
|
//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'
|
|
}
|
|
})
|
|
document.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 === 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
|
|
})
|
|
// for some reason many (not all) hooks don't work if the mod is loaded in the room
|
|
// to be honest I have no idea what's going on. the hooks get registered, they just don't get called by SDK.
|
|
if (current() === 'Online/ChatRoom') throw new Error('please do not load in a chat room')
|
|
if (current() === 'Character/Login') {
|
|
this.remove_load_hook = this.before('AsylumGGTSSAddItems', () => this.loader())
|
|
} else this.loader()
|
|
},
|
|
}
|
|
|
|
w.MBCHC.preloader()
|