From 7e6cea3605c20b44142b2aab22acf08df75ea52b Mon Sep 17 00:00:00 2001 From: Lucas <33839844+Lucas-Meijer@users.noreply.github.com> Date: Sun, 10 Aug 2025 19:52:03 +0200 Subject: [PATCH] Mafia: Add Search Capability (#10682) --- server/chat-plugins/mafia.ts | 260 ++++++++++++++++++++++++++++++++--- 1 file changed, 243 insertions(+), 17 deletions(-) diff --git a/server/chat-plugins/mafia.ts b/server/chat-plugins/mafia.ts index 1b35bfc137..de4f6afc8b 100644 --- a/server/chat-plugins/mafia.ts +++ b/server/chat-plugins/mafia.ts @@ -146,6 +146,78 @@ function writeFile(path: string, data: AnyObject) { )); } +function mafiaSearch( + entries: [string, MafiaDataAlignment | MafiaDataRole | MafiaDataTheme | MafiaDataIDEA | MafiaDataTerm][], + searchTarget: string, searchType: keyof MafiaData +) { + if (typeof (entries) === 'undefined' || searchType === `aliases` || searchTarget.length === 0) return entries; + + // Handle negation + const negation = searchTarget.startsWith('!'); + if (negation) searchTarget = searchTarget.substring(1).trim(); + + const entriesCopy = entries.slice(); + + // Check if the search term is an alias of something + const alias = toID((toID(searchTarget) in MafiaData[`aliases`]) ? + MafiaData[`aliases`][toID(searchTarget)] : searchTarget); + + if (searchType === `themes` && searchTarget.includes(`players`) && searchTarget.includes(`pl`)) { + // Search themes by playercount + const inequalities = ['<=', '>=', '=', '<', '>']; + const inequality = inequalities.find(x => searchTarget.includes(x)); + if (!inequality) return entries; + + const players = Number(searchTarget.split(inequality)[1].trim()); + if (!!players && !isNaN(players)) { + if (inequality === '=') { + // Filter based on themes with the exact player count + entries = entries.filter(([key]) => players in MafiaData[`themes`][key]); + } else if (inequality === '<' || inequality === '<=') { + // Filter based on themes less than / at most a certain amount of players + // Creates an array of the potential playercounts, and then looks if any in the theme matches that + entries = entries.filter(([key]) => ([...Array(players + (inequality === '<=' ? 1 : 0)).keys()]) + .some(playerCount => playerCount in (MafiaData[`themes`][key]))); + } else if (inequality === '>' || inequality === '>=') { + // Filter based on themes greater than / at least a certain amount of players + // Creates an array of the potential playercounts, and then looks if any in the theme matches that + entries = entries.filter(([key]) => ([...Array(50 - Number(players)).keys()] + .map(num => num + players + (inequality === '>=' ? 0 : 1))) + .some(playerCount => playerCount in (MafiaData[`themes`][key]))); + } + } else { + return entries; + } + } else if (searchType === `themes` && alias in MafiaData[`roles`]) { + // Search themes that contain a role + // Creates a list of all potential playercounts, then verifies if any of them contains the role that we seek + entries = entries.filter(([key, data]) => ([...Array(50).keys()]) + .some(playerCount => playerCount in (MafiaData[`themes`][key]) && + (MafiaData[`themes`][key])[playerCount].toString().toLowerCase().includes(alias))); + } else if (searchType === `IDEAs` && alias in MafiaData[`roles`]) { + // Search IDEAs that contain a role + entries = entries.filter(([key, data]) => MafiaData[`IDEAs`][key].roles.map(role => + toID((toID(role) in MafiaData[`aliases`]) ? MafiaData[`aliases`][toID(role)] : role)).includes(alias)); + } else if (searchType === `roles` && alias in MafiaData[`themes`]) { + // Search roles that appear in a theme + // Filters entries based on whether the list of roles in the given theme contains it (or an alias of it) + entries = entries.filter(([key, data]) => Object.keys(MafiaData[`themes`][alias]) + .filter((newKey: any) => toID((MafiaData[`themes`][alias])[newKey].toString()).includes(key)).length > 0); + } else if (searchType === `roles` && alias in MafiaData[`IDEAs`]) { + // Search roles that appear in an IDEA + // Filters entries based on whether the list of roles in the given IDEA contains it (or an alias of it) + entries = entries.filter(([key, data]) => MafiaData[`IDEAs`][alias].roles.map(role => + toID((toID(role) in MafiaData[`aliases`]) ? MafiaData[`aliases`][toID(role)] : role)).includes(toID(key))); + } else { + // Any other search type matches just on whether it is included in the text + // Filters entries based on whether it contains the given string anywhere + entries = entries.filter(([key]) => Object.entries(MafiaData[searchType][key]) + .some(([newKey, value]) => value.toString().toLowerCase().includes(searchTarget))); + } + // Inverses the found results for negation + return negation ? entriesCopy.filter(element => !entries.includes(element)) : entries; +} + // data assumptions - // the alignments "town" and "solo" always exist (defaults) // .alignment is always a valid key in data.alignments @@ -156,12 +228,16 @@ function writeFile(path: string, data: AnyObject) { MafiaData = readFile(DATA_FILE) || { alignments: {}, roles: {}, themes: {}, IDEAs: {}, terms: {}, aliases: {} }; if (!MafiaData.alignments.town) { MafiaData.alignments.town = { - name: 'town', plural: 'town', memo: [`This alignment is required for the script to function properly.`], + name: 'Town', + plural: 'Town', + memo: [`This alignment is required for the script to function properly.`], }; } if (!MafiaData.alignments.solo) { MafiaData.alignments.solo = { - name: 'solo', plural: 'solo', memo: [`This alignment is required for the script to function properly.`], + name: 'Solo', + plural: 'Solo', + memo: [`This alignment is required for the script to function properly.`], }; } @@ -328,6 +404,7 @@ class Mafia extends Rooms.RoomGame { dlAt: number; IDEA: MafiaIDEAModule; + constructor(room: ChatRoom, host: User) { super(room); @@ -340,6 +417,7 @@ class Mafia extends Rooms.RoomGame { this.hostid = host.id; this.host = Utils.escapeHTML(host.name); + this.cohostids = []; this.cohosts = []; @@ -2207,7 +2285,6 @@ export const commands: Chat.ChatCommands = { } return this.parse('/help mafia'); }, - forcehost: 'host', nexthost: 'host', host(target, room, user, connection, cmd) { @@ -4137,23 +4214,156 @@ export const commands: Chat.ChatCommands = { this.sendReply(`The entry ${entry} was deleted from the ${source} database.`); }, deletedatahelp: [`/mafia deletedata source,entry - Removes an entry from the database. Requires % @ # ~`], - listdata(target, room, user) { - if (!(target in MafiaData)) { - throw new Chat.ErrorMessage(`Invalid source. Valid sources are ${Object.keys(MafiaData).join(', ')}`); + + randtheme: 'listdata', + randrole: 'listdata', + randalignment: 'listdata', + randidea: 'listdata', + randterm: 'listdata', + randroles: 'listdata', + randalignments: 'listdata', + randideas: 'listdata', + randterms: 'listdata', + randdata: 'listdata', + randomtheme: 'listdata', + randomrole: 'listdata', + randomalignment: 'listdata', + randomidea: 'listdata', + randomterm: 'listdata', + randomdata: 'listdata', + randomthemes: 'listdata', + randomroles: 'listdata', + randomalignments: 'listdata', + randomideas: 'listdata', + randomterms: 'listdata', + randthemes: 'listdata', + listthemes: 'listdata', + listroles: 'listdata', + listalignments: 'listdata', + listideas: 'listdata', + listterms: 'listdata', + themes: 'listdata', + roles: 'listdata', + alignments: 'listdata', + ideas: 'listdata', + terms: 'listdata', + ds: 'listdata', + search: 'listdata', + random: 'listdata', + list: 'listdata', + listdata(target, room, user, connection, cmd, message) { + if (!this.runBroadcast()) return false; + + // Determine non-search targets first, afterward searching is done with the remainder + const targets = target.split(',').map(x => x.trim().toLowerCase()); + + // Determine search type + let searchType: keyof MafiaData = 'aliases'; + let foundSearchType = false; + const searchTypes: (keyof MafiaData)[] = ['themes', 'roles', 'alignments', 'IDEAs', 'terms', 'aliases']; + for (const type of searchTypes) { + const typeID = toID(type.substring(0, type.length - 1)); + if (cmd.includes(typeID) || targets.includes(typeID)) { + searchType = type; + foundSearchType = true; + if (targets.includes(type)) targets.splice(targets.indexOf(type), 1); + } } - const dataSource = MafiaData[target as keyof MafiaData]; - if (dataSource === MafiaData.aliases) { - const aliases = Object.entries(MafiaData.aliases) - .map(([from, to]) => `${from}: ${to}`) - .join('
'); - return this.sendReplyBox(`Mafia aliases:
${aliases}`); - } else { - const entries = Object.entries(dataSource) - .map(([key, data]) => ``) - .join(''); - return this.sendReplyBox(`Mafia ${target}:
${entries}`); + if (cmd === 'random' || cmd === 'randomdata' || cmd === 'randdata') { + searchType = + ([`themes`, `roles`, `alignments`, `IDEAs`, `terms`] as (keyof MafiaData)[])[Math.floor(Math.random() * 5)]; + foundSearchType = true; + } + + if (!foundSearchType) { + return this.errorReply(`Invalid source. Valid sources are ${Object.keys(MafiaData).filter(key => key !== `aliases`).join(', ')}.`); + } + + const dataSource = MafiaData[searchType]; + + // determine whether the command should return a random subset of results + const random = (cmd.includes('rand') || targets.includes(`random`)); + if (targets.includes(`random`)) targets.splice(targets.indexOf(`random`), 1); + + // TODO: hide certain roles from appearing (unless the command includes the 'hidden' parameter) + + // Number of results + let number = random ? 1 : 0; + for (let i = 0; i < targets.length; i++) { + if ((!!targets[i] && + !isNaN(Number(targets[i].toString())))) { + number = Number(targets[i]); + targets.splice(i, 1); + break; + } + + // Convert to rows + const themeRow = function (theme: MafiaDataTheme, players = 0) { + return ` ${players > 0 ? theme[players] : theme.desc} `; + }; + const ideaRow = function (idea: MafiaDataIDEA) { + return ` `; + }; + const row = function (role: MafiaDataRole | MafiaDataTerm | MafiaDataAlignment) { + return ` ${role.memo.join(' ')} `; + }; + + if (searchType === `aliases`) { + // Handle aliases separately for differing functionality + room = this.requireRoom(); + this.checkCan('mute', null, room); + const aliases = Object.entries(MafiaData.aliases) + .map(([from, to]) => `${from}: ${to}`) + .join('
'); + return this.sendReplyBox(`Mafia aliases:
${aliases}`); + } else { + // Create a table for a pleasant viewing experience + let table = `
`; + let entries: [string, MafiaDataAlignment | MafiaDataRole | MafiaDataTheme | MafiaDataIDEA | MafiaDataTerm][] = + Object.entries(dataSource).sort(); + + for (const targetString of targets) { + entries = targetString.split('|').map(x => x.trim()) + .map(searchTerm => mafiaSearch(entries.slice(), searchTerm, searchType)) + .reduce((aggregate, result) => [...new Set([...aggregate, ...result])]); + } + + if (typeof (entries) === 'undefined') return; + + if (random) entries = Utils.shuffle(entries); + if (number > 0) entries = entries.slice(0, number); + + if (entries.length === 0) { + return this.errorReply(`No ${searchType} found.`); + } + + if (entries.length === 1) { + this.target = entries[0][0]; + return this.run((Chat.commands.mafia as Chat.ChatCommands).data as Chat.AnnotatedChatHandler); + } + + table += entries + .map(([key, data]) => searchType === `themes` ? + themeRow(MafiaData[searchType][key]) : searchType === `IDEAs` ? + ideaRow(MafiaData[searchType][key]) : row(MafiaData[searchType][key])) + .join(''); + table += `
`; + return this.sendReplyBox(table); + } } }, + listdatahelp: [ + `/mafia roles [parameter, paramater, ...] - Views all Mafia roles. Parameters: theme that must include role, text included in role data.`, + `/mafia themes [parameter, paramater, ...] - Views all Mafia themes. Parameters: roles in theme, players(< | <= | = | => | >)[x] for playercounts, text included in theme data.`, + `/mafia alignments [parameter, paramater, ...] - Views all Mafia alignments. Parameters: text included in alignment data.`, + `/mafia ideas [parameter, paramater, ...] - Views all Mafia IDEAs. Parameters: roles in IDEA, text included in IDEA data.`, + `/mafia terms [parameter, paramater, ...] - Views all Mafia terms. Parameters: text included in term data.`, + `/mafia randomrole [parameter, paramater, ...] - View a random Mafia role. Parameters: number of roles to be randomly generated, theme that must include role, text included in role data.`, + `/mafia randomtheme [parameter, paramater, ...] - View a random Mafia theme. Parameters: number of themes to be randomly generated, roles in theme, players(< | <= | = | => | >)[x] for playercounts, text included in theme data.`, + `/mafia randomalignment [parameter, paramater, ...] - View a random Mafia alignment. Parameters: number of alignments to be randomly generated, text included in alignment data.`, + `/mafia randomidea [parameter, paramater, ...] - View a random Mafia IDEA. Parameters: number of IDEAs to be randomly generated, roles in IDEA, text included in IDEA data.`, + `/mafia randomterm [parameter, paramater, ...] - View a random Mafia term. Parameters: number of terms to be randomly generated, text included in term data.`, + ], disable(target, room, user) { room = this.requireRoom(); @@ -4286,6 +4496,22 @@ export const commands: Chat.ChatCommands = { `/mafia (un)gameban [user], [duration] - Ban a user from playing games for [duration] days. Requires % @ # ~`, ].join('
'); buf += ``; + buf += `
Mafia Dexsearch Commands`; + buf += [ + `
Commands to search Mafia data:
`, + `/mafia dt [data] - Views Mafia data.`, + `/mafia roles [parameter, paramater, ...] - Views all Mafia roles. Parameters: theme that must include role, text included in role data.`, + `/mafia themes [parameter, paramater, ...] - Views all Mafia themes. Parameters: roles in theme, players(< | <= | = | => | >)[x] for playercounts, text included in theme data.`, + `/mafia alignments [parameter, paramater, ...] - Views all Mafia alignments. Parameters: text included in alignment data.`, + `/mafia ideas [parameter, paramater, ...] - Views all Mafia IDEAs. Parameters: roles in IDEA, text included in IDEA data.`, + `/mafia terms [parameter, paramater, ...] - Views all Mafia terms. Parameters: text included in term data.`, + `/mafia randomrole [parameter, paramater, ...] - View a random Mafia role. Parameters: number of roles to be randomly generated, theme that must include role, text included in role data.`, + `/mafia randomtheme [parameter, paramater, ...] - View a random Mafia theme. Parameters: number of themes to be randomly generated, roles in theme, players(< | <= | = | => | >)[x] for playercounts, text included in theme data.`, + `/mafia randomalignment [parameter, paramater, ...] - View a random Mafia alignment. Parameters: number of alignments to be randomly generated, text included in alignment data.`, + `/mafia randomidea [parameter, paramater, ...] - View a random Mafia IDEA. Parameters: number of IDEAs to be randomly generated, roles in IDEA, text included in IDEA data.`, + `/mafia randomterm [parameter, paramater, ...] - View a random Mafia term. Parameters: number of terms to be randomly generated, text included in term data.`, + ].join('
'); + buf += `
`; return this.sendReplyBox(buf); },