Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions gen7notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Adds gen7ou: replay parser, observation space, tokenizer, and action mapping for mega/Z-moves (sharing the +9 slots like tera).

The main issue is Z-moves: the log shows the Z-move name, not the base move, so the base move is resolved by crystal type in forward fill when already revealed, otherwise enforced in team prediction and a final fallback in from_ReplayAction.

- All gen7 replays sampled by me (100 from high ELO and 100 recent replays) passed.
- No regressions on gen1–4/9
- Tokenizer is append-only on v1 (existing ids unchanged)
- gen7uu and gen7nu also enabled, but not all gen7 formats are solid yet: gen7ubers is left out for now (a few parser bugs around Marshadow's Z-move name, Ultra Burst + Z-move, and a team-prediction crash).

TODO: update README, and online play is wired but still needs to be verified.
61 changes: 61 additions & 0 deletions metamon/backend/replay_parser/backward.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,69 @@
from metamon.backend.replay_parser.exceptions import *
from metamon.backend.replay_parser.replay_state import (
Action,
Move,
Pokemon,
Turn,
Winner,
BackwardMarkers,
Replacement,
ParsedReplay,
)
from metamon.backend.replay_parser.str_parsing import move_name
from metamon.backend.showdown_dex.dex import Dex
from metamon.backend.team_prediction.predictor import TeamPredictor
from metamon.backend.team_prediction.team import TeamSet, PokemonSet


def _enforce_zmove_consistency(
pokemon: Pokemon,
revealed_moves: set[str],
item_was_revealed: bool,
usage_stats,
) -> None:
"""
A damaging Z-move reveals that this pokemon carries a base move of the
Z-crystal's type and holds the crystal itself. Team prediction doesn't know
that, so fix up its guesses: if the moveset has no move of the required
type, swap the least common predicted move for the most common move of that
type, and if the item was never revealed, replace it with the crystal.
"""
dex = Dex.from_gen(pokemon.gen)

def base_move_type(name: str) -> Optional[str]:
entry = dex.moves.get(move_name(name), {})
if entry.get("isZ") or entry.get("category") == "Status":
return None
return entry.get("type", "").upper()

if not item_was_revealed and pokemon.zmove_crystal:
# dex ids like "kommoniumz" --> "Kommonium Z"
pokemon.had_item = pokemon.zmove_crystal[:-1].capitalize() + " Z"

if any(base_move_type(m) == pokemon.zmove_used_type for m in pokemon.had_moves):
return
try:
move_usage = usage_stats[pokemon.name].get("moves", {})
except KeyError:
return
candidates = [
(weight, name)
for name, weight in move_usage.items()
if base_move_type(name) == pokemon.zmove_used_type
]
if not candidates:
return
replacement = max(candidates)[1]
predicted = [m for m in pokemon.had_moves if m not in revealed_moves]
if len(pokemon.had_moves) >= 4:
if not predicted:
return
least_common = min(predicted, key=lambda m: move_usage.get(m, 0.0))
del pokemon.had_moves[least_common]
new_move = Move(name=replacement, gen=pokemon.gen)
pokemon.had_moves[new_move.name] = new_move


def fill_missing_team_info(
battle_format: str,
date_played: datetime.date,
Expand Down Expand Up @@ -81,7 +133,16 @@ def fill_missing_team_info(
break
else:
raise BackwardException(f"Could not find match for {p.name}")
revealed_moves = set(p.had_moves.keys())
item_was_revealed = p.had_item is not None
p.fill_from_PokemonSet(match)
if p.zmove_used_type is not None:
usage_stats = team_predictor.get_usage_stats(
battle_format, date_played, rating=rating, gameid=gameid
)
_enforce_zmove_consistency(
p, revealed_moves, item_was_revealed, usage_stats
)

if (
p.had_item == BackwardMarkers.FORCE_UNKNOWN
Expand Down
47 changes: 39 additions & 8 deletions metamon/backend/replay_parser/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,18 +229,23 @@ def check_action_alignment(replay):
# standard switch
if action.target in switches and action.is_switch:
continue
elif action.is_zmove:
# Z-move name is intentionally absent from moves (one-turn transform)
continue
elif action.name in active_pokemon.moves.keys() and not action.is_switch:
# standard move
continue
elif action.name is None and action.is_tera:
# revealed only the choice to tera (at the start of the turn),
# but never found out what the move was...
elif action.name is None and (action.is_tera or action.is_mega or action.is_zmove):
# revealed the gimmick (tera/mega/z) but the pokemon couldn't act (flinch, sleep, etc.)
continue
elif action.is_revival:
pokemon = turn.get_pokemon(replay.from_p1_pov)
if action.target in pokemon and action.target not in switches:
# our revival choice is on our team but had fainted (can't be switched to)
continue
elif replay.replay.has_warning(WarningFlags.ZOROARK):
# Illusion makes action/pokemon alignment ambiguous; already flagged
continue
raise ActionMisaligned(active_pokemon, action)


Expand All @@ -262,8 +267,8 @@ def check_action_idxs(
continue
if action_idx > 13 or action_idx < -1:
raise ActionIndexError(f"Action index {action_idx} is out of bounds")
# check tera by action idx
if action_idx >= 9:
# Z-move and mega also use action_idx >= 9 but are not tera
if action_idx >= 9 and action.is_tera:
tera += 1
if tera and gen != 9:
raise ActionIndexError(f"Found Tera action in gen {gen}")
Expand All @@ -279,10 +284,13 @@ def check_action_idxs(
raise ActionIndexError(
f"Forced switch {state.forced_switch} != action {action.is_switch}"
)
if action_idx > 9 and (not state.can_tera or not action.is_tera):
# check tera action index is valid
if action_idx > 9 and not (
(state.can_tera and action.is_tera)
or (state.can_z and action.is_zmove)
or (state.can_mega and action.is_mega)
):
raise ActionIndexError(
f"Found Tera action index {action_idx} but can_tera is False"
f"Found gimmick action index {action_idx} but no valid gimmick flag is set"
)
if action.is_switch and (action_idx <= 3 or action_idx >= 9):
# check move actions become move action indices
Expand Down Expand Up @@ -319,6 +327,29 @@ def check_tera_consistency(replay):
raise ForwardVerify("Found no Tera available in gen 9")


def check_gimmick_consistency(replay):
gen = replay.gen
can_z_1 = can_z_2 = True
can_mega_1 = can_mega_2 = True
for turn in replay:
if gen not in (6, 7) and (turn.can_mega_1 or turn.can_mega_2):
raise ForwardVerify("Found mega flag in gen without mega evolution")
if gen != 7 and (turn.can_z_1 or turn.can_z_2):
raise ForwardVerify("Found Z-move flag in gen != 7")
if not can_z_1 and turn.can_z_1:
raise MultipleZMove("p1")
if not can_z_2 and turn.can_z_2:
raise MultipleZMove("p2")
if not can_mega_1 and turn.can_mega_1:
raise MultipleMega("p1")
if not can_mega_2 and turn.can_mega_2:
raise MultipleMega("p2")
can_z_1 &= turn.can_z_1
can_z_2 &= turn.can_z_2
can_mega_1 &= turn.can_mega_1
can_mega_2 &= turn.can_mega_2


def check_forced_switching(replay):
for turn in replay.turnlist[:-1]:
# was there a turn where we 1) had to switch, 2) could switch, but 3) didn't record it?
Expand Down
14 changes: 14 additions & 0 deletions metamon/backend/replay_parser/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,17 @@ def __init__(self, player: str):
super().__init__(
f"Detected multiple Tera moves for player {player} in a single battle."
)


class MultipleZMove(BackwardException):
def __init__(self, player: str):
super().__init__(
f"Detected multiple Z-moves for player {player} in a single battle."
)


class MultipleMega(BackwardException):
def __init__(self, player: str):
super().__init__(
f"Detected multiple Mega evolutions/Ultra Bursts for player {player} in a single battle."
)
73 changes: 64 additions & 9 deletions metamon/backend/replay_parser/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,16 @@ def _parse_gen(self, args: List[str]):
|gen|GENNUM
"""
self.replay.gen = int(args[0])
if not (self.replay.gen <= 4 or self.replay.gen == 9):
if not (self.replay.gen <= 4 or self.replay.gen in (7, 9)):
raise SoftLockedGen(self.replay.gen)
if self.replay.gen == 9:
self.curr_turn.can_tera_1 = True
self.curr_turn.can_tera_2 = True
if self.replay.gen == 7:
self.curr_turn.can_z_1 = True
self.curr_turn.can_z_2 = True
self.curr_turn.can_mega_1 = True
self.curr_turn.can_mega_2 = True

def _parse_player(self, args: List[str]):
"""
Expand Down Expand Up @@ -308,7 +313,11 @@ def _parse_choice(self, args: List[str]):
for poke_idx, poke_choice in enumerate(player_choice.split(",")):
msg = poke_choice.split(" ")
command = msg[0]
choice_args = re.sub(r"\d+", "", " ".join(msg[1:])).strip()
# strip protocol gimmick flags (mega, zmove, etc.) — they are not part of the move name
_GIMMICK_FLAGS = {"mega", "zmove", "ultra", "dynamax", "terastallize", "primal", "gigantamax"}
choice_args = re.sub(r"\d+", "", " ".join(
w for w in msg[1:] if w.lower() not in _GIMMICK_FLAGS
)).strip()
if (
command == "move"
and choice_args
Expand Down Expand Up @@ -587,6 +596,25 @@ def _parse_move(self, args: List[str]):
pokemon.use_move(move, pp_used=pp_used)
if pokemon.transformed_into is not None:
pokemon.transformed_into.reveal_move(copy.deepcopy(move))
team, slot = self.curr_turn.player_id_to_action_idx(poke_str)
curr_action = (self.curr_turn.moves_1 if team == 1 else self.curr_turn.moves_2)[slot]
action_move_name = move.name
if curr_action is not None and curr_action.is_zmove and move.entry.get("isZ"):
# the log records the Z-move name, but the action should point at the
# base move, which shares the Z-crystal's type. status Z-moves keep
# their own name in the log and skip this entirely.
z_type = move.entry.get("type", "").upper()
pokemon.zmove_used_type = z_type
pokemon.zmove_crystal = move.entry.get("isZ")
for bm_name, bm in pokemon.had_moves.items():
bm_entry = getattr(bm, "entry", {})
if (bm_name != move.name
and not bm_entry.get("isZ")
and bm_entry.get("type", "").upper() == z_type):
action_move_name = bm_name
break
pokemon.had_moves.pop(move.name, None)
pokemon.moves.pop(move.name, None)
# create edge between pokemon to help track down special cases
pokemon.last_target = Targeting(
pokemon=target_pokemon,
Expand All @@ -600,7 +628,7 @@ def _parse_move(self, args: List[str]):
# create Action
self.curr_turn.set_move_attribute(
s=poke_str,
move_name=move.name,
move_name=action_move_name,
is_noop=False,
is_switch=False,
user=pokemon,
Expand Down Expand Up @@ -999,11 +1027,29 @@ def _parse_terastallize(self, args: List[str]):
else:
self.curr_turn.can_tera_2 = False

def _parse_zpower_mega(self, args: List[str]):
def _parse_zpower(self, args: List[str]):
"""
|-zpower|... or |-mega|...
|-zpower|POKEMON
"""
raise SoftLockedGen(self.replay.gen)
poke_str = args[0][:3]
self.curr_turn.set_move_attribute(s=poke_str, is_zmove=True)
team, _ = self.curr_turn.player_id_to_action_idx(poke_str)
if team == 1:
self.curr_turn.can_z_1 = False
else:
self.curr_turn.can_z_2 = False

def _parse_mega(self, args: List[str]):
"""
|-mega|POKEMON|MEGASTONE
"""
poke_str = args[0][:3]
self.curr_turn.set_move_attribute(s=poke_str, is_mega=True)
team, _ = self.curr_turn.player_id_to_action_idx(poke_str)
if team == 1:
self.curr_turn.can_mega_1 = False
else:
self.curr_turn.can_mega_2 = False

def _parse_transform(self, args: List[str]):
"""
Expand Down Expand Up @@ -1259,7 +1305,13 @@ def _parse_burst(self, args: List[str]):
"""
|-burst|POKEMON|SPECIES|ITEM
"""
raise UnimplementedMessage(["-burst"] + args)
poke_str = args[0][:3]
self.curr_turn.set_move_attribute(s=poke_str, is_mega=True)
team, _ = self.curr_turn.player_id_to_action_idx(poke_str)
if team == 1:
self.curr_turn.can_mega_1 = False
else:
self.curr_turn.can_mega_2 = False

def _parse_fail(self, args: List[str]):
"""
Expand Down Expand Up @@ -1390,8 +1442,10 @@ def interpret_message(self, message: List[str]):
self._parse_item_enditem(data, name)
elif name == "-terastallize":
self._parse_terastallize(data)
elif name == "-zpower" or name == "-mega":
self._parse_zpower_mega(data)
elif name == "-zpower":
self._parse_zpower(data)
elif name == "-mega":
self._parse_mega(data)
elif name == "-transform":
self._parse_transform(data)
elif name == "-fieldstart" or name == "-fieldend":
Expand Down Expand Up @@ -1598,4 +1652,5 @@ def forward_fill(
checks.check_forward_consistency(replay)
checks.check_name_permanence(replay)
checks.check_tera_consistency(replay)
checks.check_gimmick_consistency(replay)
return replay
4 changes: 4 additions & 0 deletions metamon/backend/replay_parser/parse_replays.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ def povreplay_to_state_action(self, replay: backward.POVReplay):
player_conditions = turn.conditions_1 if p1 else turn.conditions_2
opponent_conditions = turn.conditions_2 if p1 else turn.conditions_1
can_tera = turn.can_tera_1 if p1 else turn.can_tera_2
can_z = turn.can_z_1 if p1 else turn.can_z_2
can_mega = turn.can_mega_1 if p1 else turn.can_mega_2
opponent_teampreview = turn.teampreview_2 if p1 else turn.teampreview_1

# fill a ReplayState
Expand All @@ -103,6 +105,8 @@ def povreplay_to_state_action(self, replay: backward.POVReplay):
battle_won=False,
battle_lost=False,
can_tera=can_tera,
can_z=can_z,
can_mega=can_mega,
opponent_teampreview=opponent_teampreview,
)
)
Expand Down
Loading