Skip to content

Commit bdeb19f

Browse files
WifiLatencyKrisXV
andauthored
/ds: Add rule functionality (#10973)
* /ds metagame rules with moves compability. * mon attributes compatibility * pokedex rule compatibility. * clean up. support convergencelegality * updated dexsearchhelp. better error feedback. * Fix negations for abilities and moves. * Scrapped convergence, cleanup. * Updated dshelp for the mapped rule param values. * Fixed duplicate formes and tests reciving species. * Support /ds stacking multiple rules * Apply suggestions from code review * tests for /ds rule functionality --------- Co-authored-by: Kris Johnson <[email protected]>
1 parent c06500f commit bdeb19f

File tree

2 files changed

+180
-57
lines changed

2 files changed

+180
-57
lines changed

server/chat-plugins/datasearch.ts

+148-57
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import { ProcessManager, Utils } from '../../lib';
12+
import type { FormatData } from '../../sim/dex-formats';
1213
import { TeamValidator } from '../../sim/team-validator';
1314
import { Chat } from '../chat';
1415

@@ -52,7 +53,17 @@ type Direction = 'less' | 'greater' | 'equal';
5253
const MAX_PROCESSES = 1;
5354
const RESULTS_MAX_LENGTH = 10;
5455
const MAX_RANDOM_RESULTS = 30;
55-
const dexesHelp = Object.keys((global.Dex?.dexes || {})).filter(x => x !== 'sourceMaps').join('</code>, <code>');
56+
const dexesHelpMods = Object.keys((global.Dex?.dexes || {})).filter(x => x !== 'sourceMaps').join('</code>, <code>');
57+
const supportedDexsearchRules: { [k: string]: string[] } = Object.assign(Object.create(null), {
58+
movevalidation: ['stabmonsmovelegality', 'alphabetcupmovelegality'],
59+
statmodification: ['350cupmod', 'flippedmod', 'scalemonsmod', 'badnboostedmod', 'reevolutionmod'],
60+
banlist: [
61+
'hoennpokedex', 'sinnohpokedex', 'oldunovapokedex', 'newunovapokedex', 'kalospokedex', 'oldalolapokedex',
62+
'newalolapokedex', 'galarpokedex', 'isleofarmorpokedex', 'crowntundrapokedex', 'galarexpansionpokedex',
63+
'paldeapokedex', 'kitakamipokedex', 'blueberrypokedex',
64+
],
65+
});
66+
const dexsearchHelpRules = Object.values((supportedDexsearchRules)).flat().filter(x => x).join('</code>, <code>');
5667

5768
function toListString(arr: string[]) {
5869
if (!arr.length) return '';
@@ -138,7 +149,8 @@ export const commands: Chat.ChatCommands = {
138149
`<code>Alola</code>, <code>Galar</code>, <code>Therian</code>, <code>Totem</code>, or <code>Primal</code> can be used as parameters to search for those formes.<br/>` +
139150
`Parameters separated with <code>|</code> will be searched as alternatives for each other; e.g., <code>trick | switcheroo</code> searches for all Pok\u00e9mon that learn either Trick or Switcheroo.<br/>` +
140151
`You can search for info in a specific generation by appending the generation to ds or by using the <code>maxgen</code> keyword; e.g. <code>/ds1 normal</code> or <code>/ds normal, maxgen1</code> searches for all Pok\u00e9mon that were Normal type in Generation I.<br/>` +
141-
`You can search for info in a specific mod by using <code>mod=[mod name]</code>; e.g. <code>/nds mod=ssb, protean</code>. All valid mod names are: <code>${dexesHelp}</code><br />` +
152+
`You can search for info in a specific mod by using <code>mod=[mod name]</code>; e.g. <code>/nds mod=ssb, protean</code>. All valid mod names are: <code>${dexesHelpMods}</code><br/>` +
153+
`You can search for info in a specific rule defined metagame by using <code>rule=[rule name]</code>; e.g. <code>/nds rule=alphabetcupmovelegality, v-create</code>. All supported rule names are: <code>${dexsearchHelpRules}</code><br/>` +
142154
`By default, <code>/dexsearch</code> will search only Pok\u00e9mon obtainable in the current generation. Add the parameter <code>unreleased</code> to include unreleased Pok\u00e9mon. Add the parameter <code>natdex</code> (or use the command <code>/nds</code>) to include all past Pok\u00e9mon.<br/>` +
143155
`Searching for a Pok\u00e9mon with both egg group and type parameters can be differentiated by adding the suffix <code>group</code> onto the egg group parameter; e.g., seaching for <code>grass, grass group</code> will show all Grass types in the Grass egg group.<br/>` +
144156
`The parameter <code>monotype</code> will only show Pok\u00e9mon that are single-typed.<br/>` +
@@ -363,7 +375,7 @@ export const commands: Chat.ChatCommands = {
363375
`- Parameters separated with <code>|</code> will be searched as alternatives for each other; e.g. <code>fire | water</code> searches for all moves that are either Fire type or Water type.<br/>` +
364376
`- If a Pok\u00e9mon is included as a parameter, only moves from its movepool will be included in the search.<br/>` +
365377
`- You can search for info in a specific generation by appending the generation to ms; e.g. <code>/ms1 normal</code> searches for all moves that were Normal type in Generation I.<br/>` +
366-
`- You can search for info in a specific mod by using <code>mod=[mod name]</code>; e.g. <code>/nms mod=ssb, dark, bp=100</code>. All valid mod names are: <code>${dexesHelp}</code><br />` +
378+
`- You can search for info in a specific mod by using <code>mod=[mod name]</code>; e.g. <code>/nms mod=ssb, dark, bp=100</code>. All valid mod names are: <code>${dexesHelpMods}</code><br/>` +
367379
`- <code>/ms</code> will search all non-dexited moves (clickable in that game); you can include dexited moves by using <code>/nms</code> or by adding <code>natdex</code> as a parameter.<br/>` +
368380
`- The order of the parameters does not matter.` +
369381
`</details>`
@@ -618,14 +630,61 @@ function getMod(target: string) {
618630
return { splitTarget: arr, usedMod: modTerm ? toID(modTerm.split(/ ?= ?/)[1]) : undefined, count };
619631
}
620632

633+
function getRule(target: string) {
634+
const arr = target.split(',').map(x => x.trim());
635+
const ruleTerms: string[] = [];
636+
for (const term of arr) {
637+
const sanitizedStr = term.toLowerCase().replace(/[^a-z0-9=]+/g, '');
638+
if (sanitizedStr.startsWith('rule=') && Dex.data.Rulesets[toID(sanitizedStr.split('=')[1])]) {
639+
ruleTerms.push(term);
640+
}
641+
}
642+
const count = arr.filter(x => {
643+
const sanitizedStr = x.toLowerCase().replace(/[^a-z0-9=]+/g, '');
644+
return sanitizedStr.startsWith('rule=');
645+
}).length;
646+
if (ruleTerms.length > 0) {
647+
for (const rule of ruleTerms) {
648+
arr.splice(arr.indexOf(rule), 1);
649+
}
650+
}
651+
return { splitTarget: arr, usedRules: ruleTerms.map(
652+
x => x.toLowerCase().replace(/[^a-z0-9=]+/g, '').split('rule=')[1]), count };
653+
}
654+
655+
function prepareDexsearchValidator(usedMod: string | undefined, rules: FormatData[], nationalSearch: boolean | null) {
656+
const format = Object.entries(Dex.data.Rulesets).find(([a, f]) => f.mod === usedMod)?.[1].name || 'gen9ou';
657+
const ruleTable = Dex.formats.getRuleTable(Dex.formats.get(format));
658+
const additionalRules = [];
659+
for (const rule of rules) {
660+
if (!ruleTable.has(toID(rule.name))) additionalRules.push(toID(rule.name));
661+
}
662+
if (nationalSearch && !ruleTable.has('natdexmod')) additionalRules.push('natdexmod');
663+
if (nationalSearch && ruleTable.valueRules.has('minsourcegen')) additionalRules.push('!!minsourcegen=3');
664+
return TeamValidator.get(`${format}${additionalRules.length ? `@@@${additionalRules.join(',')}` : ''}`);
665+
}
666+
621667
function runDexsearch(target: string, cmd: string, canAll: boolean, message: string, isTest: boolean) {
622668
const searches: DexOrGroup[] = [];
623-
const { splitTarget, usedMod, count: c } = getMod(target);
624-
if (c > 1) {
669+
const { splitTarget: remainingTargets, usedMod, count: modCount } = getMod(target);
670+
const { splitTarget, usedRules } = getRule(remainingTargets.join(','));
671+
if (modCount > 1) {
625672
return { error: `You can't run searches for multiple mods.` };
626673
}
627-
674+
for (const str of splitTarget) {
675+
const sanitizedStr = str.toLowerCase().replace(/[^a-z0-9=]+/g, '');
676+
if (sanitizedStr.startsWith('mod=') || sanitizedStr.startsWith('rule=')) {
677+
return { error: `${sanitizedStr.split('=')[1]} is an invalid mod or rule, see /dexsearchhelp.` };
678+
}
679+
}
628680
const mod = Dex.mod(usedMod || 'base');
681+
const rules: FormatData[] = [];
682+
for (const rule of usedRules) {
683+
if (!dexsearchHelpRules.includes(rule))
684+
return { error: `${rule} is an unsupported rule, see /dexsearchhelp` };
685+
rules.push(Dex.data.Rulesets[rule]);
686+
}
687+
629688
const allTiers: { [k: string]: TierTypes.Singles | TierTypes.Other } = Object.assign(Object.create(null), {
630689
anythinggoes: 'AG', ag: 'AG',
631690
uber: 'Uber', ubers: 'Uber', ou: 'OU',
@@ -1146,13 +1205,38 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
11461205
};
11471206
}
11481207

1208+
// Prepare move validator and pokemonSource outside the hot loop
1209+
// but don't prepare them at all if there are no moves to check...
1210+
// These only ever get accessed if there are moves or banlists to filter by.
1211+
let validator;
1212+
let pokemonSource;
1213+
if (Object.values(searches).some(search => !!Object.keys(search.moves).length)) {
1214+
validator = prepareDexsearchValidator(usedMod, rules, nationalSearch);
1215+
}
1216+
11491217
const dex: { [k: string]: Species } = {};
11501218
for (const species of mod.species.all()) {
11511219
const megaSearchResult = megaSearch === null || megaSearch === !!species.isMega;
11521220
const gmaxSearchResult = gmaxSearch === null || gmaxSearch === species.name.endsWith('-Gmax');
11531221
const fullyEvolvedSearchResult = fullyEvolvedSearch === null || fullyEvolvedSearch !== species.nfe;
11541222
const restrictedSearchResult = restrictedSearch === null ||
11551223
restrictedSearch === species.tags.includes('Restricted Legendary');
1224+
1225+
/**
1226+
* Not every ruleset with an onValidateSet function is specifically to exclude mons.
1227+
* In the current list of supported rules only the Pokedex rules do such which is
1228+
* why this step is ignored for other rules. Rules can be added for this functionality
1229+
* in the supportedDexSearchTypes mapping at the top of the function.
1230+
*/
1231+
let ruleResult = true;
1232+
for (const rule of rules) {
1233+
if (!ruleResult) break;
1234+
if (!supportedDexsearchRules['banlist'].includes(toID(rule.name))) continue;
1235+
if (!validator) validator = prepareDexsearchValidator(usedMod, rules, nationalSearch);
1236+
ruleResult = !rule.onValidateSet?.call(validator,
1237+
{ name: species.name, species: species.id } as PokemonSet, validator.format, {}, {});
1238+
}
1239+
11561240
if (
11571241
species.gen <= mod.gen &&
11581242
(
@@ -1163,9 +1247,15 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
11631247
megaSearchResult &&
11641248
gmaxSearchResult &&
11651249
fullyEvolvedSearchResult &&
1166-
restrictedSearchResult
1250+
restrictedSearchResult &&
1251+
ruleResult
11671252
) {
1168-
dex[species.id] = species;
1253+
let newSpecies = species;
1254+
for (const rule of rules) {
1255+
newSpecies = rule?.onModifySpecies?.call({ dex: mod, clampIntRange: Utils.clampIntRange, toID } as Battle,
1256+
newSpecies) || newSpecies;
1257+
}
1258+
dex[newSpecies.id] = newSpecies;
11691259
}
11701260
}
11711261

@@ -1176,19 +1266,6 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
11761266
Object.values(search).reduce(accumulateKeyCount, 0)
11771267
));
11781268

1179-
// Prepare move validator and pokemonSource outside the hot loop
1180-
// but don't prepare them at all if there are no moves to check...
1181-
// These only ever get accessed if there are moves to filter by.
1182-
let validator;
1183-
let pokemonSource;
1184-
if (Object.values(searches).some(search => Object.keys(search.moves).length !== 0)) {
1185-
const format = Object.entries(Dex.data.Rulesets).find(([a, f]) => f.mod === usedMod)?.[1].name || 'gen9ou';
1186-
const ruleTable = Dex.formats.getRuleTable(Dex.formats.get(format));
1187-
const additionalRules = [];
1188-
if (nationalSearch && !ruleTable.has('natdexmod')) additionalRules.push('natdexmod');
1189-
if (nationalSearch && ruleTable.valueRules.has('minsourcegen')) additionalRules.push('!!minsourcegen=3');
1190-
validator = TeamValidator.get(`${format}${additionalRules.length ? `@@@${additionalRules.join(',')}` : ''}`);
1191-
}
11921269
for (const alts of searches) {
11931270
if (alts.skip) continue;
11941271
const altsMoves = Object.keys(alts.moves).map(x => mod.moves.get(x)).filter(move => move.gen <= mod.gen);
@@ -1352,13 +1429,29 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
13521429
}
13531430
if (matched) continue;
13541431

1355-
for (const move of altsMoves) {
1356-
pokemonSource = validator?.allSources();
1357-
if (validator && !validator.checkCanLearn(move, dex[mon], pokemonSource) === alts.moves[move.id]) {
1358-
matched = true;
1359-
break;
1432+
if (validator) {
1433+
for (const move of altsMoves) {
1434+
pokemonSource = validator.allSources();
1435+
const isNotSearch = !alts.moves[move.id];
1436+
1437+
let matchRule = false;
1438+
let numMoveValidationRules = 0;
1439+
for (const rule of rules) {
1440+
if (!supportedDexsearchRules['movevalidation'].includes(toID(rule.name))) continue;
1441+
else numMoveValidationRules++;
1442+
matchRule = !rule.checkCanLearn?.call(
1443+
validator, move, dex[mon], pokemonSource, {} as PokemonSet) === !isNotSearch;
1444+
if (matchRule === !isNotSearch) break;
1445+
}
1446+
const matchNormally = !validator.checkCanLearn(move, dex[mon], pokemonSource) === !isNotSearch;
1447+
1448+
if ((!isNotSearch && (matchNormally || (numMoveValidationRules > 0 && matchRule))) ||
1449+
(isNotSearch && matchNormally && (numMoveValidationRules === 0 || matchRule))) {
1450+
matched = true;
1451+
break;
1452+
}
1453+
if (pokemonSource && !pokemonSource.size()) break;
13601454
}
1361-
if (pokemonSource && !pokemonSource.size()) break;
13621455
}
13631456
if (matched) continue;
13641457

@@ -1368,51 +1461,49 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
13681461

13691462
const stat = sort?.slice(0, -1);
13701463

1371-
function getSortValue(name: string) {
1372-
if (!stat) return 0;
1373-
const mon = mod.species.get(name);
1374-
if (stat === 'bst') {
1375-
return mon.bst;
1464+
function getSortValue(species: Species) {
1465+
if (!stat) {
1466+
return 0;
1467+
} else if (stat === 'bst') {
1468+
return species.bst;
13761469
} else if (stat === 'weight') {
1377-
return mon.weighthg;
1470+
return species.weighthg;
13781471
} else if (stat === 'height') {
1379-
return mon.heightm;
1472+
return species.heightm;
13801473
} else if (stat === 'gen') {
1381-
return mon.gen;
1474+
return species.gen;
13821475
} else {
1383-
return mon.baseStats[stat as StatID];
1476+
return species.baseStats[stat as StatID];
13841477
}
13851478
}
13861479

1387-
let results: string[] = [];
1388-
for (const mon of Object.keys(dex).sort()) {
1389-
if (singleTypeSearch !== null && (dex[mon].types.length === 1) !== singleTypeSearch) continue;
1390-
const isRegionalForm = (["Alola", "Galar", "Hisui"].includes(dex[mon].forme) || dex[mon].forme.startsWith("Paldea")) &&
1391-
dex[mon].baseSpecies !== "Pikachu";
1392-
const maskForm = dex[mon].baseSpecies === "Ogerpon" && !dex[mon].forme.endsWith("Tera");
1480+
let results: Species[] = [];
1481+
for (const mon of Object.values(dex).sort()) {
1482+
if (singleTypeSearch !== null && (mon.types.length === 1) !== singleTypeSearch) continue;
1483+
const isRegionalForm = (["Alola", "Galar", "Hisui"].includes(mon.forme) || mon.forme.startsWith("Paldea")) &&
1484+
mon.baseSpecies !== "Pikachu";
1485+
const maskForm = mon.baseSpecies === "Ogerpon" && !mon.forme.endsWith("Tera");
13931486
const allowGmax = (gmaxSearch || tierSearch);
1394-
if (!isRegionalForm && !maskForm && dex[mon].baseSpecies && results.includes(dex[mon].baseSpecies) &&
1395-
getSortValue(mon) === getSortValue(dex[mon].baseSpecies)) continue;
1396-
const teraFormeChangesFrom = dex[mon].forme.endsWith("Tera") ? !Array.isArray(dex[mon].battleOnly) ?
1397-
dex[mon].battleOnly! : null : null;
1398-
if (teraFormeChangesFrom && results.includes(teraFormeChangesFrom) &&
1399-
getSortValue(mon) === getSortValue(teraFormeChangesFrom)) continue;
1400-
if (dex[mon].isNonstandard === 'Gigantamax' && !allowGmax) continue;
1401-
results.push(dex[mon].name);
1487+
if (!isRegionalForm && !maskForm && mon.baseSpecies && results.includes(mod.species.get(mon.baseSpecies)) &&
1488+
getSortValue(mon) === getSortValue(mod.species.get(mon.baseSpecies))) continue;
1489+
const teraFormeChangesFrom = mon.forme.endsWith("Tera") ? !Array.isArray(mon.battleOnly) ?
1490+
mon.battleOnly! : null : null;
1491+
if (teraFormeChangesFrom && results.includes(mod.species.get(teraFormeChangesFrom)) &&
1492+
getSortValue(mon) === getSortValue(mod.species.get(teraFormeChangesFrom))) continue;
1493+
if (mon.isNonstandard === 'Gigantamax' && !allowGmax) continue;
1494+
results.push(mon);
14021495
}
14031496

14041497
if (usedMod === 'gen7letsgo') {
1405-
results = results.filter(name => {
1406-
const species = mod.species.get(name);
1498+
results = results.filter(species => {
14071499
return (species.num <= 151 || ['Meltan', 'Melmetal'].includes(species.name)) &&
14081500
(!species.forme || (['Alola', 'Mega', 'Mega-X', 'Mega-Y', 'Starter'].includes(species.forme) &&
14091501
species.name !== 'Pikachu-Alola'));
14101502
});
14111503
}
14121504

14131505
if (usedMod === 'gen8bdsp') {
1414-
results = results.filter(name => {
1415-
const species = mod.species.get(name);
1506+
results = results.filter(species => {
14161507
if (species.id === 'pichuspikyeared') return false;
14171508
if (capSearch) return species.gen <= 4;
14181509
return species.gen <= 4 && species.num >= 1;
@@ -1428,15 +1519,15 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
14281519
results.sort();
14291520
if (sort) {
14301521
const direction = sort.slice(-1);
1431-
Utils.sortBy(results, name => getSortValue(name) * (direction === '+' ? 1 : -1));
1522+
Utils.sortBy(results, species => getSortValue(species) * (direction === '+' ? 1 : -1));
14321523
}
14331524
let notShown = 0;
14341525
if (!showAll && results.length > MAX_RANDOM_RESULTS) {
14351526
notShown = results.length - RESULTS_MAX_LENGTH;
14361527
results = results.slice(0, RESULTS_MAX_LENGTH);
14371528
}
14381529
resultsStr += results.map(
1439-
result => `<a href="//${Config.routes.dex}/pokemon/${toID(result)}" target="_blank" class="subtle" style="white-space:nowrap"><psicon pokemon="${result}" style="vertical-align:-7px;margin:-2px" />${result}</a>`
1530+
result => `<a href="//${Config.routes.dex}/pokemon/${toID(result.name)}" target="_blank" class="subtle" style="white-space:nowrap"><psicon pokemon="${result.name}" style="vertical-align:-7px;margin:-2px" />${result.name}</a>`
14401531
).join(", ");
14411532
if (notShown) {
14421533
resultsStr += `, and ${notShown} more. <span style="color:#999999;">Redo the search with ', all' at the end to show all results.</span>`;
@@ -1446,7 +1537,7 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
14461537
} else {
14471538
resultsStr += "No Pok&eacute;mon found.";
14481539
}
1449-
if (isTest) return { results, reply: resultsStr };
1540+
if (isTest) return { results: results.map(species => species.name), reply: resultsStr };
14501541
return { reply: resultsStr };
14511542
}
14521543

test/server/chat-plugins/datasearch.js

+32
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,36 @@ describe("Datasearch Plugin", () => {
111111
search = datasearch.testables.runDexsearch(target, cmd, true, `/${cmd} ${target}`);
112112
assert.false(search.reply.includes('Eiscue-Noice'));
113113
});
114+
115+
it('should include Pokemon capable of learning normally unobtainable moves under the provided rulsets', () => {
116+
const cmd = 'ds';
117+
const target = 'mod=gen8, rule=stabmonsmovelegality, rule=alphabetcupmovelegality, calm mind, boomburst, steel';
118+
const search = datasearch.testables.runDexsearch(target, cmd, true, `/${cmd} ${target}`);
119+
assert(search.reply.includes('Metagross'));
120+
});
121+
122+
it('should exclude Pokemon capable of learning moves under normal circumstances or the provided rulesets', () => {
123+
const cmd = 'ds';
124+
const target = 'mod=gen9, rule=stabmonsmovelegality, !wish';
125+
const search = datasearch.testables.runDexsearch(target, cmd, true, `/${cmd} ${target}`);
126+
assert.false(search.reply.includes('Alomomola'));
127+
assert.false(search.reply.includes('Altaria'));
128+
});
129+
130+
it('should include only Pokemon allowed by the provided rulesets', () => {
131+
const cmd = 'ds';
132+
const target = 'mod=gen8, rule=kalospokedex, rule=hoennpokedex, water, ground';
133+
const search = datasearch.testables.runDexsearch(target, cmd, true, `/${cmd} ${target}`);
134+
assert(search.reply.includes('Whiscash'));
135+
assert.false(search.reply.includes('Swampert'));
136+
assert.false(search.reply.includes('Quagsire'));
137+
assert.false(search.reply.includes('Gastrodon'));
138+
});
139+
140+
it('should sort Pokemon with modified stats based on rulesets', () => {
141+
const cmd = 'ds';
142+
const target = 'mod=gen8, rule=reevolutionmod, water, bst desc';
143+
const search = datasearch.testables.runDexsearch(target, cmd, true, `/${cmd} ${target}`);
144+
assert(search.reply.includes('Milotic'));
145+
});
114146
});

0 commit comments

Comments
 (0)