105.13.0 semver; settings migration

[lots of internal change]
I'm determined to clean this mess up after all.
Instead of a total rewrite (which didn't work), I'll migrate the
code to JSDoc piece by piece. This is the first piece, setting up
some basic framework and testing out this and that.
Until I figure out how test this thing, I'll just ask peeps to import
the module from testing branch I guess.
Probably not gonna deal with anything new until I can turn "strict" on.
This commit is contained in:
Mute 2024-07-13 21:55:45 +00:00
parent 90231cb2ae
commit 16308eccf1
6 changed files with 6106 additions and 6030 deletions

62
ambient.d.ts vendored Normal file
View File

@ -0,0 +1,62 @@
interface ServerChatRoomMessage {
MBCHC_ID?: number
}
declare namespace MBCHC {
type WGT = Window & typeof globalThis
interface Root extends WGT {
MBCHC?: any
bcModSdk?: import('bondage-club-mod-sdk').ModSDKGlobalAPI
bce_ActivityTriggers?: any
bce_EventExpressions?: any
}
namespace Settings {
type V0 = PlayerOnlineSettings & {MBCHC: {timezones?: Record<number, number>}} // V0 is the whole onlinesettings
interface V1 { // V1 is specifically MBCHC inside extensionsettings
TZ: Record<number, number>
}
interface Methods {
migrate_0_1(v0: V0): true
save(cb?: (v1: V1) => unknown): true
replace(new_v1: V1): true
'purge!'(): true
get v1(): V1
}
}
interface Interval {
proxy: object // eslint-disable-line @typescript-eslint/ban-types
min: number
max: number
mini: boolean
maxi: boolean
upd(min: number, max: number, min_inclusive?: boolean, max_inclusive?: boolean): undefined
has(_: unknown, x: string): boolean
}
interface Utils {
interval: Interval
cid(character: Character): number | undefined
dn(character: Character): string
current(): string
with<V, R>(value: V, cb: (value: V) => R): R // A silly helper to kinda curry values
true<F extends (...args: unknown[]) => unknown>(cb: F): true // Useful for type-safe chaining
mutate<T>(value: T, cb: (v: T) => unknown): T // A silly helper for chaining
rm<T>(object: T, property: keyof T): T
mrg<T extends Record<string, unknown>>(target: T, ...source: T[]): T // Shorter, also less confusing with types
style<T>(query: string, cb: (s: CSSStyleDeclaration) => T): T | undefined
range(min: number, max: number, min_inclusive?: boolean, max_inclusive?: boolean): Proxy
assert<T>(error: string, value: T, cb?: (value: T) => boolean): T | never
each<T extends Record<string, unknown>>(object: T, cb: (key: string, value: unknown) => unknown): true
}
interface TZ_Cache {
map: Map<number, number>
RE: RegExp
for(character: Character): number | undefined
memo(member_number: number, description?: string | undefined): number | undefined
parse(description: string | undefined): number | undefined
}
}

View File

@ -2,7 +2,7 @@
"include": [
"node_modules/bc-stubs/bc/**/*.d.ts",
"node_modules/bondage-club-mod-sdk/dist/**/*.d.ts",
"typedef.d.ts",
"ambient.d.ts",
"mbchc.mjs",
"server.js"
],
@ -13,6 +13,7 @@
],
"checkJs": true,
"strict": false,
"strictNullChecks": true,
"noImplicitOverride": true
}
}

378
mbchc.mjs
View File

@ -1,45 +1,57 @@
export {}
/** @type {MBCHC.Root} */ const W = window, D = W.document
if (W.MBCHC !== undefined) throw new Error('MBCHC found, aborting loading')
/** @typedef {import('bondage-club-mod-sdk').ModSDKGlobalAPI} ModSDKGlobalAPI */
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
/** @type {Window & typeof globalThis & {MBCHC?: any, bcModSdk?: ModSDKGlobalAPI, bce_ActivityTriggers?: any, bce_EventExpressions?: any}} */
const w = window
/** @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))}
}
/**
* 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)
/** @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))),
}
/**
* 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)
/** @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 ||= {}
})},
}
/**
* @returns {string} Current view
*/
const current = () => `${w.CurrentModule}/${w.CurrentScreen}`
/** @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)),
}
/**
* @param {Character} char
* @returns {number}
*/
const cid = char => char.MemberNumber
// ^ type-safe (still need strict later)
// =================================================================================
// v legacy mess
// 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',
W.MBCHC = {
VERSION,
Settings,
NEXT_MESSAGE: 1,
LOG_MESSAGES: false,
RETHROW: false,
@ -50,7 +62,6 @@ w.MBCHC = {
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,
@ -163,18 +174,13 @@ w.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)
},
'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)) {
@ -188,17 +194,6 @@ w.MBCHC = {
}
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))
},
@ -223,64 +218,61 @@ w.MBCHC = {
return text.replace(this.RE_SPACES, ' ').split(' ')
},
inform(html, timeout = 60_000) {
w.ChatRoomSendLocal(`<div class="mbchc">${html}</div>`, timeout)
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)))
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 (!this.in(pos, 0, w.ChatRoomCharacter.length - 1)) throw new Error(`invalid position ${pos}`)
return (w.ChatRoomCharacter[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 = this.ensure('can\'t find my position', () => w.ChatRoomCharacter.findIndex(char => char.IsPlayer()) + 1) - 1 // 0 is falsey, but is valid index
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
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)
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 (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
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
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 => `${cid(c)}|${c.Name}|${c.Nickname || c.Name}`).join(',')})`)
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(), cid(char).toString()]
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)
@ -294,27 +286,28 @@ w.MBCHC = {
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: 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]})
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 = 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 (!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)
W.ActivityRun(W.Player, char, char.FocusGroup, activity)
} finally {
char.FocusGroup = null
}
@ -323,10 +316,10 @@ w.MBCHC = {
const text = string.slice(1)
let suffix = ' '
if (text.startsWith('\'') || text.startsWith(' ')) suffix = ''
return `${w.MBCHC.PREF_ACTIVITY}<${cid(w.Player)}:>SourceCharacter${suffix}`
return `${W.MBCHC.PREF_ACTIVITY}<${U.cid(W.Player)}:>SourceCharacter${suffix}`
},
cid2dict(type, cid) {
return ({Tag: `${type}Character`, MemberNumber: cid, Text: this.cid2char(cid).dn})
return ({Tag: `${type}Character`, MemberNumber: cid, Text: U.dn(this.cid2char(cid))})
},
send_activity(message) {
const dict = [MISSING_PLAYER_DIALOG]
@ -336,7 +329,7 @@ w.MBCHC = {
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})
W.ServerSend('ChatRoomChat', {Type: 'Action', Content: message, Dictionary: dict})
},
//receive(data) {
// const char = this.cid2char(data.Sender)
@ -370,54 +363,43 @@ w.MBCHC = {
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)))
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')
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')
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]')
if (!(tz in U.range(-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)
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
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])
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
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})
@ -428,27 +410,27 @@ w.MBCHC = {
}
},
command_do(argline, cmdline, args) {
const mbchc = w.MBCHC
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])
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 = 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
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 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)))
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)
@ -456,9 +438,9 @@ w.MBCHC = {
},
bell() {
setTimeout(() => {
style('#InputChat', s => s.outline = '')
U.style('#InputChat', s => s.outline = '')
}, 100)
style('#InputChat', s => s.outline = 'solid red')
U.style('#InputChat', s => s.outline = 'solid red')
},
complete(options, space = true) {
if (options.length === 0) return (this.bell())
@ -475,22 +457,22 @@ w.MBCHC = {
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 ? ' ' : ''}`))
} 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}
* @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')
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'})
},
@ -504,12 +486,12 @@ w.MBCHC = {
comp_hint_hide() {
if (!this.comp_hint_visible()) return
this.COMP_HINT.style.display = 'none'
w.ChatRoomResize(false)
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 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)
}
@ -519,12 +501,12 @@ w.MBCHC = {
},
complete_common() {
// w.ElementValue('InputChat') will strip the trailing whitespace
const E = document.querySelector('#InputChat')
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
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()
@ -539,11 +521,11 @@ w.MBCHC = {
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
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
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
@ -566,22 +548,22 @@ w.MBCHC = {
mbchc.bell()
},
complete_fbc_anim(_args, _locase, _cmdline) {
const [mbchc, _input, tokens] = w.MBCHC.complete_common(); // `this` is command object
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))))
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
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))))
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]
const [text, history] = [W.ElementValue('InputChat'), W.ChatRoomLastMessage]
if (!this.HISTORY_MODE) {
history.push(text)
this.HISTORY_MODE = true
@ -589,15 +571,15 @@ w.MBCHC = {
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)
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
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()
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
@ -607,8 +589,8 @@ w.MBCHC = {
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')
if (U.style('#InputChat', s => s.display) !== 'inline') return // Input chat missing
W.ElementFocus('InputChat')
},
loader() {
if (this.remove_load_hook) {
@ -617,18 +599,21 @@ w.MBCHC = {
}
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 /* eslint-disable-line no-undef */ // window.CommandsKey is undefined
this.CommandsKey = CommandsKey
this.RE_ACTIVITY = new RegExp(`^${this.CommandsKey}activity `)
this.PREF_ACTIVITY = `${this.CommandsKey}activity `
this.COMP_HINT = document.createElement('div')
this.COMP_HINT = D.createElement('div')
this.COMP_HINT.id = 'mbchcCompHint'
const css = document.createElement('style')
const css = D.createElement('style')
css.textContent = `
#TextAreaChatLog .mbchc, #TextAreaChatLog .mbchc {
background-color: ${this.RGB_POLLY};
@ -659,47 +644,48 @@ w.MBCHC = {
}
`
document.head.append(css)
D.head.append(css)
// Actions
this.calculate_maps()
//w.Player.MBCHC = {VERSION: this.VERSION}
w.CommandCombine(COMMANDS)
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.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)
W.CurrentCharacter = this.cid2char(this.LAST_HACKED)
this.LAST_HACKED = null
w.ChatRoomTryToTakeSuitcase()
W.ChatRoomTryToTakeSuitcase()
}
})
this.before('ChatRoomSendChat', () => {
let input = w.ElementValue('InputChat')
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)
W.ElementValue('InputChat', input)
}
})
this.after('ChatRoomSendChat', () => {
const history = w.ChatRoomLastMessage
const history = W.ChatRoomLastMessage
if ((history.length > 1) && (history.at(-1) === history.at(-2))) {
history.pop()
w.ChatRoomLastMessageIndex -= 1
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')
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 || document.body.append(this.COMP_HINT))
this.after('ChatRoomCreateElement', () => this.COMP_HINT.parentElement || D.body.append(this.COMP_HINT))
this.before('ChatRoomClearAllElements', () => {
this.comp_hint_hide()
this.COMP_HINT.remove()
@ -708,33 +694,33 @@ w.MBCHC = {
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
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)
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))
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
W.MBCHC.comp_hint_hide()
if ((W.KeyPress === 33) || (W.KeyPress === 34)) { // Better history
event.preventDefault()
return (w.MBCHC.history(w.KeyPress - 33))
return (W.MBCHC.history(W.KeyPress - 33))
}
if (w.MBCHC.HISTORY_MODE) {
w.ChatRoomLastMessage.pop()
w.MBCHC.HISTORY_MODE = false
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) => {
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})
@ -742,7 +728,7 @@ w.MBCHC = {
}})
//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()
// if ((data.Type === 'Action') && (data.Content === 'ServerEnter') && (data.Sender === U.cid(w.Player))) this.player_enters_room()
// return false
// },
//})
@ -752,7 +738,7 @@ w.MBCHC = {
// return false
// },
//})
w.ChatRoomRegisterMessageHandler({Priority: -219, Description: 'MBCHC autohack lookup',
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
@ -762,15 +748,15 @@ w.MBCHC = {
// Footer
this.LOADED = true
this.log('info', `loaded version ${this.VERSION}`)
if ((w.CurrentModule === 'Online') && (w.CurrentScreen === 'ChatRoom')) {
for (const c of w.ChatRoomCharacter) this.update_char(c)
//this.player_enters_room()
}
//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/'})
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)
@ -788,13 +774,9 @@ w.MBCHC = {
}
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()
U.current() === 'Character/Login' ? this.remove_load_hook = this.before('AsylumGGTSSAddItems', () => this.loader()) :this.loader()
},
}
w.MBCHC.preloader()
W.MBCHC.preloader()

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "mbchc",
"version": "0.0.12",
"version": "105.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mbchc",
"version": "0.0.12",
"version": "105.13.0",
"license": "SEE LICENSE IN LICENSE.",
"devDependencies": {
"bc-stubs": "^105.0.0",

View File

@ -1,6 +1,6 @@
{
"name": "mbchc",
"version": "0.0.12",
"version": "105.13.0",
"description": "Mute's Bondage Club Hacks Collection",
"type": "module",
"devDependencies": {
@ -9,12 +9,37 @@
"bondage-club-mod-sdk": "^1.2.0"
},
"license": "SEE LICENSE IN LICENSE.",
"xo": {
"env": [
"browser",
"node"
"eslintConfig": {
"root": true,
"extends": [
"xo",
"xo-typescript"
],
"parser": "@typescript-eslint/parser",
"parserOptions": { "project": ["./jsconfig.json"] },
"plugins": ["@typescript-eslint"],
"globals": {"GM": true, "GM_info": true},
"rules": {
"@typescript-eslint/brace-style": "off",
"@typescript-eslint/comma-dangle": ["error", "only-multiline"],
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/consistent-type-imports": "off",
"@typescript-eslint/member-delimiter-style": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-confusing-void-expression": ["error", {
"ignoreVoidOperator": true
}],
"@typescript-eslint/no-meaningless-void-operator": "off",
"@typescript-eslint/object-curly-spacing": "off",
"@typescript-eslint/padding-line-between-statements": "off",
"@typescript-eslint/semi": "off",
"@typescript-eslint/space-before-function-paren": "off",
"@typescript-eslint/strict-boolean-expressions": ["error", {
"allowString": false,
"allowNumber": false,
"allowNullableObject": false
}],
"array-element-newline": "off",
"brace-style": "off",
"camelcase": "off",
"capitalized-comments": "off",
@ -28,11 +53,27 @@
"argsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
}],
"no-void": "off",
"padding-line-between-statements": "off",
"object-curly-newline": "off",
"one-var": "off",
"one-var-declaration-per-line": "off",
"semi": "off",
"space-before-function-paren": "off",
"spaced-comment": "off",
"unicorn/no-array-for-each": "off",
"unicorn/no-array-reduce": "off",
"unicorn/prefer-module": "off",
"unicorn/prefer-top-level-await": "off",
"fake/fuck-commas": "off"
},
"overrides": [
{
"files": ["*.d.ts"],
"rules": {
"no-unused-vars": "off"
}
}
]
}
}

10
typedef.d.ts vendored
View File

@ -1,10 +0,0 @@
/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/consistent-type-definitions */
interface PlayerOnlineSettings {
MBCHC?: any;
}
interface ServerChatRoomMessage {
MBCHC_ID?: number;
}