Make "All Pokemon" rules more convenient (#10932)
Some checks are pending
Node.js CI / build (18.x) (push) Waiting to run

* Make "All Pokemon" rules more convenient

Previously, "+All Pokemon" did nothing except override "-All Pokemon",
which switched from a default-allow to default-deny system.

They still do that, but they now also override all previous pokemon
bans/unbans. This makes it easier to replace a banlist/whitelist
from an inherited ruleset without needing to reverse every previous
ban/unban.

This also adds an error if you use `+All Pokemon` in a ruleset where
it doesn't do anything.

Fixes #10772
This commit is contained in:
Guangcong Luo 2025-03-02 14:47:30 -08:00 committed by GitHub
parent 7b32d0f8c7
commit 0cb51158aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 162 additions and 53 deletions

View File

@ -83,10 +83,20 @@ Syntax is identical to bans, just replace `-` with `+`, like:
More specific always trumps less specific:
`- all Pokemon, + Uber, - Giratina, + Giratina-Altered` - allow only Ubers other than Giratina-Origin
`- all pokemon, + Uber, - Giratina, + Giratina-Altered` - allow only Ubers other than Giratina-Origin
`- all pokemon, + Giratina-Altered, - Giratina, + Uber` - allow only Ubers other than Giratina-Origin
`- Nonexistent, + Necturna` - don't allow anything from outside the game, except the CAP Necturna
Except `all pokemon`, which removes all bans/unbans of pokemon before it:
`- all pokemon, + Pikachu, + Raichu` - allow Pikachu and Raichu
`+ Pikachu, - all pokemon, + Raichu` - allow only Raichu
(Note that `all pokemon` does not affect obtainability rules. `+ all pokemon` will not allow CAPs or anything like that.)
For equally specific rules, the last rule wins:
`- Pikachu, - Pikachu, + Pikachu` - allow Pikachu
@ -128,7 +138,7 @@ Whitelisting
Instead of a banlist, you can have a list of allowed things:
`- all Pokemon, + Charmander, + Squirtle, + Bulbasaur` - allow only Kanto starters
`- all pokemon, + Charmander, + Squirtle, + Bulbasaur` - allow only Kanto starters
`- all moves, + move: Metronome` - allow only the move Metronome

View File

@ -2714,7 +2714,6 @@ export const Formats: import('../sim/dex-formats').FormatList = [
team: 'randomHC',
ruleset: ['HP Percentage Mod', 'Cancel Mod'],
banlist: ['CAP', 'LGPE', 'MissingNo.', 'Pikachu-Cosplay', 'Pichu-Spiky-eared', 'Pokestar Smeargle', 'Pokestar UFO', 'Pokestar UFO-2', 'Pokestar Brycen-Man', 'Pokestar MT', 'Pokestar MT2', 'Pokestar Transport', 'Pokestar Giant', 'Pokestar Humanoid', 'Pokestar Monster', 'Pokestar F-00', 'Pokestar F-002', 'Pokestar Spirit', 'Pokestar Black Door', 'Pokestar White Door', 'Pokestar Black Belt', 'Pokestar UFO-PropU2', 'Xerneas-Neutral'],
unbanlist: ['All Pokemon'],
},
{
name: "[Gen 9] Doubles Hackmons Cup",

View File

@ -2613,7 +2613,6 @@ export const Rulesets: import('../sim/dex-formats').FormatDataTable = {
effectType: 'ValidatorRule',
name: "Hackmons Forme Legality",
desc: `Enforces proper forme legality for hackmons-based metagames.`,
unbanlist: ['All Pokemon'],
banlist: ['CAP', 'LGPE', 'Future'],
onChangeSet(set, format, setHas, teamHas) {
let species = this.dex.species.get(set.species);

View File

@ -631,19 +631,33 @@ export class DexFormats {
if (format.effectType !== 'Format') throw new Error(`Unrecognized format "${formatName}"`);
if (!customRulesString) return format.id;
const ruleTable = this.getRuleTable(format);
let hasCustomRules = false;
let hasPokemonRule = false;
const customRules = customRulesString.split(',').map(rule => {
rule = rule.replace(/[\r\n|]*/g, '').trim();
const ruleSpec = this.validateRule(rule);
if (typeof ruleSpec === 'string' && ruleTable.has(ruleSpec)) return null;
if (typeof ruleSpec === 'string') {
if (ruleSpec === '-pokemontag:allpokemon' || ruleSpec === '+pokemontag:allpokemon') {
if (hasPokemonRule) throw new Error(`You can't ban/unban pokemon before banning/unbanning all Pokemon.`);
}
if (this.isPokemonRule(ruleSpec)) hasPokemonRule = true;
}
if (typeof ruleSpec !== 'string' || !ruleTable.has(ruleSpec)) hasCustomRules = true;
return rule;
}).filter(Boolean);
if (!customRules.length) throw new Error(`The format already has your custom rules`);
});
if (!hasCustomRules) throw new Error(`None of your custom rules change anything`);
const validatedFormatid = format.id + '@@@' + customRules.join(',');
const moddedFormat = this.get(validatedFormatid, true);
this.getRuleTable(moddedFormat);
return validatedFormatid;
}
/**
* The default mode is `isTrusted = false`, which is a bit of a
* footgun. PS will never do anything unsafe, but `isTrusted = true`
* will throw if the format string is invalid, while
* `isTrusted = false` will silently fall back to the original format.
*/
get(name?: string | Format, isTrusted = false): Format {
if (name && typeof name !== 'string') return name;
@ -694,6 +708,12 @@ export class DexFormats {
return this.formatsListCache!;
}
isPokemonRule(ruleSpec: string) {
return (
ruleSpec.slice(1).startsWith('pokemontag:') || ruleSpec.slice(1).startsWith('pokemon:') ||
ruleSpec.slice(1).startsWith('basepokemon:')
);
}
getRuleTable(format: Format, depth = 1, repeals?: Map<string, number>): RuleTable {
if (format.ruleTable && !repeals) return format.ruleTable;
if (format.name.length > 50) {
@ -726,17 +746,25 @@ export class DexFormats {
// apply rule repeals before other rules
// repeals is a ruleid:depth map (positive: unused, negative: used)
for (const rule of ruleset) {
if (rule.startsWith('!') && !rule.startsWith('!!')) {
const ruleSpec = this.validateRule(rule, format) as string;
if (!repeals) repeals = new Map();
const ruleSpecs = ruleset.map(rule => this.validateRule(rule, format));
for (let ruleSpec of ruleSpecs) {
if (typeof ruleSpec !== 'string') continue;
if (ruleSpec.startsWith('^')) ruleSpec = ruleSpec.slice(1);
if (ruleSpec.startsWith('!') && !ruleSpec.startsWith('!!')) {
repeals ||= new Map();
repeals.set(ruleSpec.slice(1), depth);
}
}
for (const rule of ruleset) {
const ruleSpec = this.validateRule(rule, format);
let skipPokemonBans = ruleSpecs.filter(r => r === '+pokemontag:allpokemon').length;
let hasPokemonBans = false;
const warnForNoPokemonBans = !!skipPokemonBans && !format.customRules;
skipPokemonBans += ruleSpecs.filter(r => r === '-pokemontag:allpokemon').length;
// if (format.customRules) console.log(`${format.id}: ${format.customRules.join(', ')}`);
for (let ruleSpec of ruleSpecs) {
// complex ban/unban
if (typeof ruleSpec !== 'string') {
if (ruleSpec[0] === 'complexTeamBan') {
const complexTeamBan: ComplexTeamBan = ruleSpec.slice(1) as ComplexTeamBan;
@ -750,24 +778,42 @@ export class DexFormats {
continue;
}
if (rule.startsWith('!') && !rule.startsWith('!!')) {
// ^ is undocumented because I really don't want it used outside of tests
const noWarn = ruleSpec.startsWith('^');
if (noWarn) ruleSpec = ruleSpec.slice(1);
// repeal rule
if (ruleSpec.startsWith('!') && !ruleSpec.startsWith('!!')) {
const repealDepth = repeals!.get(ruleSpec.slice(1));
if (repealDepth === undefined) throw new Error(`Multiple "${rule}" rules in ${format.name}`);
if (repealDepth === depth) {
throw new Error(`Rule "${rule}" did nothing because "${rule.slice(1)}" is not in effect`);
if (repealDepth === undefined) throw new Error(`Multiple "${ruleSpec}" rules in ${format.name}`);
if (repealDepth === depth && !noWarn) {
throw new Error(`Rule "${ruleSpec}" did nothing because "${ruleSpec.slice(1)}" is not in effect`);
}
if (repealDepth === -depth) repeals!.delete(ruleSpec.slice(1));
continue;
}
// individual ban/unban
if ('+*-'.includes(ruleSpec.charAt(0))) {
if (ruleTable.has(ruleSpec)) {
throw new Error(`Rule "${rule}" in "${format.name}" already exists in "${ruleTable.get(ruleSpec) || format.name}"`);
throw new Error(`Rule "${ruleSpec}" in "${format.name}" already exists in "${ruleTable.get(ruleSpec) || format.name}"`);
}
if (skipPokemonBans) {
if (ruleSpec === '-pokemontag:allpokemon' || ruleSpec === '+pokemontag:allpokemon') {
skipPokemonBans--;
} else if (this.isPokemonRule(ruleSpec)) {
if (!format.customRules) {
throw new Error(`Rule "${ruleSpec}" must go after any "All Pokemon" rule in ${format.name} ("+All Pokemon" should go in ruleset, not unbanlist)`);
}
continue;
}
}
for (const prefix of '+*-') ruleTable.delete(prefix + ruleSpec.slice(1));
ruleTable.set(ruleSpec, '');
continue;
}
// rule
let [formatid, value] = ruleSpec.split('=');
const subformat = this.get(formatid);
const repealAndReplace = ruleSpec.startsWith('!!');
@ -797,7 +843,9 @@ export class DexFormats {
const oldValue = ruleTable.valueRules.get(subformat.id);
if (oldValue === value) {
throw new Error(`Rule "${ruleSpec}" is redundant with existing rule "${subformat.id}=${value}"${ruleTable.blame(subformat.id)}.`);
if (!noWarn) {
throw new Error(`Rule "${ruleSpec}" is redundant with existing rule "${subformat.id}=${value}"${ruleTable.blame(subformat.id)}.`);
}
} else if (repealAndReplace) {
if (oldValue === undefined) {
if (subformat.mutuallyExclusiveWith && ruleTable.valueRules.has(subformat.mutuallyExclusiveWith)) {
@ -823,8 +871,8 @@ export class DexFormats {
} else {
if (value !== undefined) throw new Error(`Rule "${ruleSpec}" should not have a value (no equals sign)`);
if (repealAndReplace) throw new Error(`"!!" is not supported for this rule`);
if (ruleTable.has(subformat.id) && !repealAndReplace) {
throw new Error(`Rule "${rule}" in "${format.name}" already exists in "${ruleTable.get(subformat.id) || format.name}"`);
if (ruleTable.has(subformat.id) && !repealAndReplace && !noWarn) {
throw new Error(`Rule "${ruleSpec}" in "${format.name}" already exists in "${ruleTable.get(subformat.id) || format.name}"`);
}
}
ruleTable.set(subformat.id, '');
@ -834,26 +882,33 @@ export class DexFormats {
const subRuleTable = this.getRuleTable(subformat, depth + 1, repeals);
for (const [ruleid, sourceFormat] of subRuleTable) {
// don't check for "already exists" here; multiple inheritance is allowed
if (!repeals?.has(ruleid)) {
const newValue = subRuleTable.valueRules.get(ruleid);
const oldValue = ruleTable.valueRules.get(ruleid);
if (newValue !== undefined) {
// set a value
const subSubFormat = this.get(ruleid);
if (subSubFormat.mutuallyExclusiveWith && ruleTable.valueRules.has(subSubFormat.mutuallyExclusiveWith)) {
// mutually exclusive conflict!
throw new Error(`Rule "${ruleid}=${newValue}" from ${subformat.name}${subRuleTable.blame(ruleid)} conflicts with "${subSubFormat.mutuallyExclusiveWith}=${ruleTable.valueRules.get(subSubFormat.mutuallyExclusiveWith)}"${ruleTable.blame(subSubFormat.mutuallyExclusiveWith)} (Repeal one with ! before adding another)`);
}
if (newValue !== oldValue) {
if (oldValue !== undefined) {
// conflict!
throw new Error(`Rule "${ruleid}=${newValue}" from ${subformat.name}${subRuleTable.blame(ruleid)} conflicts with "${ruleid}=${oldValue}"${ruleTable.blame(ruleid)} (Repeal one with ! before adding another)`);
}
ruleTable.valueRules.set(ruleid, newValue);
}
if (repeals?.has(ruleid)) continue;
if (skipPokemonBans && '+*-'.includes(ruleid.charAt(0))) {
if (this.isPokemonRule(ruleid)) {
hasPokemonBans = true;
continue;
}
ruleTable.set(ruleid, sourceFormat || subformat.name);
}
const newValue = subRuleTable.valueRules.get(ruleid);
const oldValue = ruleTable.valueRules.get(ruleid);
if (newValue !== undefined) {
// set a value
const subSubFormat = this.get(ruleid);
if (subSubFormat.mutuallyExclusiveWith && ruleTable.valueRules.has(subSubFormat.mutuallyExclusiveWith)) {
// mutually exclusive conflict!
throw new Error(`Rule "${ruleid}=${newValue}" from ${subformat.name}${subRuleTable.blame(ruleid)} conflicts with "${subSubFormat.mutuallyExclusiveWith}=${ruleTable.valueRules.get(subSubFormat.mutuallyExclusiveWith)}"${ruleTable.blame(subSubFormat.mutuallyExclusiveWith)} (Repeal one with ! before adding another)`);
}
if (newValue !== oldValue) {
if (oldValue !== undefined) {
// conflict!
throw new Error(`Rule "${ruleid}=${newValue}" from ${subformat.name}${subRuleTable.blame(ruleid)} conflicts with "${ruleid}=${oldValue}"${ruleTable.blame(ruleid)} (Repeal one with ! before adding another)`);
}
ruleTable.valueRules.set(ruleid, newValue);
}
}
ruleTable.set(ruleid, sourceFormat || subformat.name);
}
for (const [subRule, source, limit, bans] of subRuleTable.complexBans) {
ruleTable.addComplexBan(subRule, source || subformat.name, limit, bans);
@ -871,6 +926,9 @@ export class DexFormats {
ruleTable.checkCanLearn = subRuleTable.checkCanLearn;
}
}
if (!hasPokemonBans && warnForNoPokemonBans) {
throw new Error(`"+All Pokemon" rule has no effect (no species are banned by default, and it does not override obtainability rules)`);
}
ruleTable.getTagRules();
ruleTable.resolveNumbers(format, this.dex);
@ -937,6 +995,8 @@ export class DexFormats {
throw new Error(`Unrecognized rule "${rule}"`);
}
if (typeof value === 'string') id = `${id}=${value.trim()}`;
if (rule.startsWith('^!')) return `^!${id}`;
if (rule.startsWith('^')) return `^${id}`;
if (rule.startsWith('!!')) return `!!${id}`;
if (rule.startsWith('!')) return `!${id}`;
return id;

View File

@ -47,8 +47,11 @@ assert.atMost = function (value, threshold, message) {
});
};
assert.legalTeam = function (team, format, message) {
const actual = require('../dist/sim/team-validator').TeamValidator.get(format).validateTeam(team);
assert.legalTeam = function (team, formatName, message) {
require('../dist/sim/dex').Dex.formats.validate(formatName);
const format = require('../dist/sim/team-validator').TeamValidator.get(formatName);
// console.log(`${formatName}: ${[...format.ruleTable.keys()].join(', ')}`);
const actual = format.validateTeam(team);
if (actual === null) return;
throw new AssertionError({
message: message || "Expected team to be valid, but it was rejected because:\n" + actual.join("\n"),

View File

@ -49,19 +49,19 @@ class TestTools {
}
const gameType = Dex.toID(options.gameType || 'singles');
let basicFormat = this.currentMod === 'base' && gameType === 'singles' ? 'Anything Goes' : 'Custom Game';
const customRules = [
options.pokemon && '-Nonexistent',
options.legality && 'Obtainable',
!options.preview && '!Team Preview',
!options.pokemon && '+Nonexistent',
options.legality ? '^Obtainable' : '^!Obtainable',
options.preview ? '^Team Preview' : '^!Team Preview',
options.sleepClause && 'Sleep Clause Mod',
!options.cancel && '!Cancel Mod',
options.endlessBattleClause && 'Endless Battle Clause',
options.endlessBattleClause ? '^Endless Battle Clause' : '^!Endless Battle Clause',
options.inverseMod && 'Inverse Mod',
options.overflowStatMod && 'Overflow Stat Mod',
].filter(Boolean);
const customRulesID = customRules.length ? `@@@${customRules.join(',')}` : ``;
let basicFormat = this.currentMod === 'base' && gameType === 'singles' ? 'Anything Goes' : 'Custom Game';
let modPrefix = this.modPrefix;
if (this.currentMod === 'gen1stadium') basicFormat = 'OU';
if (gameType === 'multi') {
@ -76,7 +76,7 @@ class TestTools {
let format = formatsCache.get(formatName);
if (format) return format;
format = Dex.formats.get(formatName);
format = Dex.formats.get(formatName, true);
if (format.effectType !== 'Format') throw new Error(`Unidentified format: ${formatName}`);
formatsCache.set(formatName, format);

View File

@ -100,17 +100,18 @@ describe('value rule support (slow)', () => {
for (const format of Dex.formats.all()) {
if (!format.team) continue;
if (Dex.formats.getRuleTable(format).has('adjustleveldown') || Dex.formats.getRuleTable(format).has('adjustlevel')) continue; // already adjusts level
it(`${format.name} should support Adjust Level`, () => {
const ruleTable = Dex.formats.getRuleTable(format);
if (ruleTable.has('adjustleveldown') || ruleTable.has('adjustlevel')) return; // already adjusts level
for (const level of [1, 99999]) {
it(`${format.name} should support Adjust Level = ${level}`, () => {
for (const level of [1, 99999]) {
testTeam({ format: `${format.id}@@@Adjust Level = ${level}`, rounds: 50 }, team => {
for (const set of team) {
assert.equal(set.level, level);
}
});
});
}
}
});
}
});

View File

@ -1,6 +1,7 @@
'use strict';
const assert = require('../../assert');
const Dex = require('../../../dist/sim/dex').Dex;
describe("Custom Rules", () => {
it('should support legality tags', () => {
@ -57,10 +58,46 @@ describe("Custom Rules", () => {
];
assert.false.legalTeam(team, 'gen7ubers@@@-allpokemon,+giratinaaltered');
// -allpokemon should override +past
team = [
{ species: 'tyrantrum', ability: 'strongjaw', moves: ['protect'], evs: { hp: 1 } },
];
assert.false.legalTeam(team, 'gen8nationaldex@@@-allpokemon');
// +pikachu should not override -past
team = [
{ species: 'pikachu-belle', ability: 'lightningrod', moves: ['thunderbolt'], evs: { hp: 1 } },
];
assert.false.legalTeam(team, 'gen7ou@@@-allpokemon,+pikachu');
});
it('should allow Pokemon to be force-whitelisted', () => {
// -allpokemon should override +cloyster before it, but not after it
let team = [
{ species: 'cloyster', ability: 'skilllink', moves: ['iciclespear'], evs: { hp: 1 } },
];
assert.legalTeam(team, 'gen5monotype');
assert.false.legalTeam(team, 'gen5monotype@@@-allpokemon');
assert.throws(() => Dex.formats.validate('gen5monotype@@@+cloyster,-allpokemon'));
assert.legalTeam(team, 'gen5monotype@@@-allpokemon,+cloyster');
team = [
{ species: 'pikachu', ability: 'lightningrod', moves: ['thunderbolt'], evs: { hp: 1 } },
];
assert.legalTeam(team, 'gen9ou@@@-allpokemon,+pikachu');
assert.throws(() => Dex.formats.validate('gen9ou@@@+pikachu,-allpokemon'));
});
it('should warn when rules do nothing', () => {
assert.throws(() => Dex.formats.validate('gen9anythinggoes@@@obtainable'));
Dex.formats.validate('gen9anythinggoes@@@!obtainable');
assert.throws(() => Dex.formats.validate('gen9customgame@@@!obtainable'));
Dex.formats.validate('gen9customgame@@@obtainable');
assert.throws(() => Dex.formats.validate('gen9customgame@@@+cloyster,-allpokemon'));
Dex.formats.validate('gen9customgame@@@-allpokemon,+cloyster');
});
it('should support banning/unbanning tag combinations', () => {

View File

@ -49,7 +49,7 @@ describe('Team Validator', () => {
team = [
{ species: 'raichualola', ability: 'surgesurfer', moves: ['fakeout'], evs: { hp: 1 } },
];
assert.legalTeam(team, 'gen9anythinggoes@@@minsourcegen=9');
assert.legalTeam(team, 'gen9anythinggoes');
});
it('should prevent Pokemon that don\'t evolve via level-up and evolve from a Pokemon that does evolve via level-up from being underleveled.', () => {