diff --git a/config/CUSTOM-RULES.md b/config/CUSTOM-RULES.md index 4614c63caf..7731db2728 100644 --- a/config/CUSTOM-RULES.md +++ b/config/CUSTOM-RULES.md @@ -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 diff --git a/config/formats.ts b/config/formats.ts index 989c7a5c46..d5442ae530 100644 --- a/config/formats.ts +++ b/config/formats.ts @@ -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", diff --git a/data/rulesets.ts b/data/rulesets.ts index 0f6c54dfe5..133e634e77 100644 --- a/data/rulesets.ts +++ b/data/rulesets.ts @@ -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); diff --git a/sim/dex-formats.ts b/sim/dex-formats.ts index 678dee2a92..f6aa197086 100644 --- a/sim/dex-formats.ts +++ b/sim/dex-formats.ts @@ -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): 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; diff --git a/test/assert.js b/test/assert.js index 1eadcf7fc6..fa1f5eab30 100644 --- a/test/assert.js +++ b/test/assert.js @@ -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"), diff --git a/test/common.js b/test/common.js index 551139c9bb..4d411ac97b 100644 --- a/test/common.js +++ b/test/common.js @@ -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); diff --git a/test/random-battles/all-gens.js b/test/random-battles/all-gens.js index 3cbd6a8da0..f592c2a702 100644 --- a/test/random-battles/all-gens.js +++ b/test/random-battles/all-gens.js @@ -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); } }); - }); - } + } + }); } }); diff --git a/test/sim/team-validator/custom-rules.js b/test/sim/team-validator/custom-rules.js index 01b83c08ac..b09bc19017 100644 --- a/test/sim/team-validator/custom-rules.js +++ b/test/sim/team-validator/custom-rules.js @@ -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', () => { diff --git a/test/sim/team-validator/misc.js b/test/sim/team-validator/misc.js index 3497b2f655..bdae6918c0 100644 --- a/test/sim/team-validator/misc.js +++ b/test/sim/team-validator/misc.js @@ -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.', () => {