diff --git a/args/commands.py b/args/commands.py index 49535e7a..00a48093 100644 --- a/args/commands.py +++ b/args/commands.py @@ -1,28 +1,72 @@ -from constants.commands import COMMAND_OPTIONS, RANDOM_COMMAND, RANDOM_UNIQUE_COMMAND, NONE_COMMAND, RANDOM_EXCLUDE_COMMANDS, id_name, name_id +from constants.commands import (COMMAND_OPTIONS, COMMON_COMMANDS, RETIRED_COM_MODES, PROBABILITY_COMMAND_IDS, + RANDOM_COMMAND, RANDOM_UNIQUE_COMMAND, NONE_COMMAND, RANDOM_EXCLUDE_COMMANDS, + id_name, name_id) def name(): return "Commands" +# The three command flag families compose: +# -com traditional: one 2-digit id per character slot (99 random, 98 random +# unique, 97 none). Alone it behaves exactly as it always has; combined +# with the probability flags its explicit picks claim slots first, its +# 99/98 values mark slots for random/unique backfill, and its 97 values +# hold slots empty. +# -comfr F.M.I percent chances for Fight/Magic/Item (-comfru: unique backfill +# default). A special case of -compr limited to the common commands. +# -compr dot-separated command ids and matching percent chances +# (-compru: unique backfill default). Rolled slots are grouped by +# likelihood and capped by the character's free slots; anything left +# unfilled is backfilled from the non--rec-excluded pool. + def parse(parser): commands = parser.add_argument_group("Commands") - commands.add_argument("-com", "--commands", type = str, help = "Character commands") + commands.add_argument("-com", "--commands", type = str, nargs = "*", + help = "Character commands: one 2 digit command id per slot " + f"({len(COMMAND_OPTIONS)} ids; 99 random, 98 random unique, 97 none). " + "Composable with -comfr/-compr: explicit picks claim slots first, " + "99/98 mark random/unique backfill slots, 97 holds a slot empty") + commands.add_argument("-comfr", "--commands-fr", type = str, default = None, metavar = "F.M.I", + help = "Give every character the common commands by chance: " + "'FIGHT.MAGIC.ITEM' percent chances (e.g. -comfr 10.50.90); " + "unfilled slots are backfilled randomly, respecting -rec") + commands.add_argument("-comfru", "--commands-fru", type = str, default = None, metavar = "F.M.I", + help = "Like -comfr, but unfilled slots draft unique commands") + commands.add_argument("-compr", "--commands-pr", type = str, nargs = 2, default = None, + metavar = ("IDS", "PERCENTS"), + help = "Give commands by chance: dot-separated command ids and matching " + "percent chances (e.g. -compr 0.1.2.28 50.50.50.100; 97 = a chance " + "at an empty slot); unfilled slots are backfilled randomly, " + "respecting -rec") + commands.add_argument("-compru", "--commands-pru", type = str, nargs = 2, default = None, + metavar = ("IDS", "PERCENTS"), + help = "Like -compr, but unfilled slots draft unique commands") commands.add_argument("-scc", "--shuffle-commands", action = "store_true", help = "Shuffle selected/randomized commands") - commands.add_argument("-rec1", "--random-exclude-command1", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities") - commands.add_argument("-rec2", "--random-exclude-command2", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities") - commands.add_argument("-rec3", "--random-exclude-command3", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities") - commands.add_argument("-rec4", "--random-exclude-command4", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities") - commands.add_argument("-rec5", "--random-exclude-command5", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities") - commands.add_argument("-rec6", "--random-exclude-command6", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities") + commands.add_argument("-rec", "--random-exclude-command-ids", type = str, default = None, metavar = "VALUE", + help = "Exclude commands from random possibilities, as dot-separated command ids " + "with an arbitrary number of entries (e.g. '-rec 05.07.10')") + # legacy single-command forms, kept for backward compatibility: each value is folded into the + # same exclusion list as -rec (and the canonical flag string re-emits them as -rec) + commands.add_argument("-rec1", "--random-exclude-command1", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities (legacy form of -rec)") + commands.add_argument("-rec2", "--random-exclude-command2", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities (legacy form of -rec)") + commands.add_argument("-rec3", "--random-exclude-command3", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities (legacy form of -rec)") + commands.add_argument("-rec4", "--random-exclude-command4", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities (legacy form of -rec)") + commands.add_argument("-rec5", "--random-exclude-command5", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities (legacy form of -rec)") + commands.add_argument("-rec6", "--random-exclude-command6", type = int, choices = RANDOM_EXCLUDE_COMMANDS, metavar = "VALUE", default = NONE_COMMAND, help = "Exclude selected command from random possibilities (legacy form of -rec)") -def process(args): - if not args.commands: - args.blitz_command_possible = True - return +def _process_character_commands(args, tokens): + # one explicitly selected (or random/none) command id per character slot + if len(tokens) != 1: + args.parser.error(f"commands: expected a single value of command ids, got '{' '.join(tokens)}'") digits = 2 # number of digits each command id substring is - args.character_commands = [int(args.commands[index : index + digits]) for index in range(0, len(args.commands), digits)] + value = tokens[0] + expected_length = digits * len(COMMAND_OPTIONS) + if not value.isdigit() or len(value) != expected_length: + args.parser.error(f"commands: '{value}' must be {expected_length} digits " + f"({len(COMMAND_OPTIONS)} {digits} digit command ids)") + + args.character_commands = [int(value[index : index + digits]) for index in range(0, len(value), digits)] - args.command_strings = [] for index, command in enumerate(args.character_commands): if command == RANDOM_COMMAND: args.command_strings.append("Random") @@ -30,71 +74,212 @@ def process(args): args.command_strings.append("Random Unique") elif command == NONE_COMMAND: args.command_strings.append("None") - else: + elif command in id_name: args.command_strings.append(id_name[command]) + else: + args.parser.error(f"commands: '{command:02}' is not a valid command id for {COMMAND_OPTIONS[index]}") + + args.commands = value + +def _parse_percent(args, flag, value): + try: + percent = int(value) + except ValueError: + args.parser.error(f"{flag}: '{value}' is not a valid percent chance") + if percent < 0 or percent > 100: + args.parser.error(f"{flag}: percent chance '{percent}' must be between 0 and 100") + return percent + +def _process_probability_random(args, flag, values): + # dot-separated command ids and matching percent chances, + # e.g. -compr 0.1.2.28 50.50.50.100. 97 declares a chance at an empty slot. + ids = [] + for part in values[0].split("."): + try: + command = int(part) + except ValueError: + args.parser.error(f"{flag}: '{part}' is not a valid command id") + if command != NONE_COMMAND and command not in PROBABILITY_COMMAND_IDS: + args.parser.error(f"{flag}: '{command:02}' is not a valid probability command id " + f"(one of the {len(PROBABILITY_COMMAND_IDS)} real commands, or 97 for None)") + if command in ids: + args.parser.error(f"{flag}: duplicate probability command id '{command:02}'") + if command in args.random_exclude_commands: + args.parser.error(f"{flag}: '{id_name[command]}' ({command:02}) is both given a probability " + "and excluded by -rec") + ids.append(command) + + percents = values[1].split(".") + if len(percents) != len(ids): + args.parser.error(f"{flag}: {len(ids)} command ids but {len(percents)} percent chances") + + return [(command, _parse_percent(args, flag, percent)) for command, percent in zip(ids, percents)] + +def _process_fight_magic_item(args, flag, value): + # 'FIGHT.MAGIC.ITEM' percent chances for the three common commands + percents = value.split(".") + if len(percents) != len(COMMON_COMMANDS): + args.parser.error(f"{flag}: '{value}' must be {len(COMMON_COMMANDS)} percent chances separated by " + f"'.', one each for {', '.join(COMMON_COMMANDS)}") + return [(name_id[command_name], _parse_percent(args, flag, percent)) + for command_name, percent in zip(COMMON_COMMANDS, percents)] + +def _process_excluded_commands(args): + # merge -rec (dot-separated, arbitrary length) with the legacy -recN single + # values into one exclusion list. -rec entries come first, then the legacy + # flags in order; duplicates are preserved (they were legal before and are + # harmless to the consumers). + from constants.commands import RANDOM_POSSIBLE_COMMANDS + excluded = [] + if args.random_exclude_command_ids is not None: + for part in args.random_exclude_command_ids.split("."): + try: + command = int(part) + except ValueError: + args.parser.error(f"random-exclude-command-ids: '{part}' is not a valid command id") + if command not in RANDOM_EXCLUDE_COMMANDS: + args.parser.error(f"random-exclude-command-ids: '{command:02}' is not an excludable command id") + if command != NONE_COMMAND: + excluded.append(command) + + for legacy in (args.random_exclude_command1, args.random_exclude_command2, + args.random_exclude_command3, args.random_exclude_command4, + args.random_exclude_command5, args.random_exclude_command6): + if legacy != NONE_COMMAND: + excluded.append(legacy) + + possible = [name_id[name] for name in RANDOM_POSSIBLE_COMMANDS] + if excluded and not [command for command in possible if command not in excluded]: + args.parser.error("random-exclude-command-ids: cannot exclude every " + "randomly-selectable command") + + args.random_exclude_commands = excluded + +def process(args): + _process_excluded_commands(args) + + args.character_commands = [] + args.command_strings = [] + args.command_probabilities = [] + + # mutually exclusive variants + if args.commands_fr is not None and args.commands_fru is not None: + args.parser.error("commands: -comfr and -comfru are incompatible; pick one") + if args.commands_pr is not None and args.commands_pru is not None: + args.parser.error("commands: -compr and -compru are incompatible; pick one") + + fr_value = args.commands_fr if args.commands_fr is not None else args.commands_fru + pr_value = args.commands_pr if args.commands_pr is not None else args.commands_pru + + # the unique/non-unique variants set the leftover-backfill style, so mixing + # them across the two families would be ambiguous + fr_unique = args.commands_fru is not None + pr_unique = args.commands_pru is not None + if fr_value is not None and pr_value is not None and fr_unique != pr_unique: + args.parser.error("commands: cannot mix unique and non-unique variants " + "(-comfru pairs with -compru, -comfr with -compr)") + args.commands_unique_backfill = fr_unique or pr_unique + + # traditional -com (composable with the probability flags) + tokens = [] + for value in args.commands or []: + tokens.extend(value.split()) + if tokens and tokens[0].lower() in RETIRED_COM_MODES: + mode = tokens[0].lower() + args.parser.error(f"commands: '-com {mode}' has been split into its own flag; " + f"use -com{mode} instead (e.g. -com{mode} {' '.join(tokens[1:])})".rstrip()) + if tokens: + _process_character_commands(args, tokens) + else: + args.commands = None + + # probability declarations: -compr first, then -comfr folded in as the + # special case for the common commands (an id in both is a conflict) + if pr_value is not None: + pr_flag = "-compru" if pr_unique else "-compr" + args.command_probabilities = _process_probability_random(args, pr_flag, pr_value) + args.commands_pr_value = (".".join(f"{command:02}" for command, _ in args.command_probabilities) + + " " + ".".join(str(percent) for _, percent in args.command_probabilities)) + else: + args.commands_pr_value = None + + if fr_value is not None: + fr_flag = "-comfru" if fr_unique else "-comfr" + fr_probabilities = _process_fight_magic_item(args, fr_flag, fr_value) + declared = [command for command, _ in args.command_probabilities] + for command, percent in fr_probabilities: + if command in declared: + args.parser.error(f"commands: '{id_name[command]}' is declared by both {fr_flag} and " + f"{'-compru' if pr_unique else '-compr'}") + args.command_probabilities.append((command, percent)) + args.commands_fr_value = ".".join(str(percent) for _, percent in fr_probabilities) + else: + args.commands_fr_value = None - args.random_exclude_commands = [] - if args.random_exclude_command1 != NONE_COMMAND: - args.random_exclude_commands.append(args.random_exclude_command1) - if args.random_exclude_command2 != NONE_COMMAND: - args.random_exclude_commands.append(args.random_exclude_command2) - if args.random_exclude_command3 != NONE_COMMAND: - args.random_exclude_commands.append(args.random_exclude_command3) - if args.random_exclude_command4 != NONE_COMMAND: - args.random_exclude_commands.append(args.random_exclude_command4) - if args.random_exclude_command5 != NONE_COMMAND: - args.random_exclude_commands.append(args.random_exclude_command5) - if args.random_exclude_command6 != NONE_COMMAND: - args.random_exclude_commands.append(args.random_exclude_command6) - - random_exists = "Random" in args.command_strings or "Random Unique" in args.command_strings - blitz_excluded = name_id["Blitz"] in args.random_exclude_commands - args.blitz_command_possible = ("Blitz" in args.command_strings) or (random_exists and not blitz_excluded) + args.commands_probability_mode = fr_value is not None or pr_value is not None + + # can a blitz command exist in this configuration? (needed before objectives + # roll a possible Suplex A Train condition) + blitz_id = name_id["Blitz"] + blitz_excluded = blitz_id in args.random_exclude_commands + blitz_explicit = "Blitz" in args.command_strings + if args.commands_probability_mode: + blitz_declared = any(command == blitz_id and percent > 0 + for command, percent in args.command_probabilities) + args.blitz_command_possible = blitz_explicit or blitz_declared or not blitz_excluded + elif args.commands: + random_exists = "Random" in args.command_strings or "Random Unique" in args.command_strings + args.blitz_command_possible = blitz_explicit or (random_exists and not blitz_excluded) + else: + args.blitz_command_possible = True def flags(args): flags = "" if args.commands: flags += " -com " + args.commands + if args.commands_fr_value is not None: + flags += (" -comfru " if args.commands_fru is not None else " -comfr ") + args.commands_fr_value + if args.commands_pr_value is not None: + flags += (" -compru " if args.commands_pru is not None else " -compr ") + args.commands_pr_value if args.shuffle_commands: flags += " -scc" - if args.random_exclude_command1 != NONE_COMMAND: - flags += f" -rec1 {args.random_exclude_command1}" - if args.random_exclude_command2 != NONE_COMMAND: - flags += f" -rec2 {args.random_exclude_command2}" - if args.random_exclude_command3 != NONE_COMMAND: - flags += f" -rec3 {args.random_exclude_command3}" - if args.random_exclude_command4 != NONE_COMMAND: - flags += f" -rec4 {args.random_exclude_command4}" - if args.random_exclude_command5 != NONE_COMMAND: - flags += f" -rec5 {args.random_exclude_command5}" - if args.random_exclude_command6 != NONE_COMMAND: - flags += f" -rec6 {args.random_exclude_command6}" + # canonical form: every exclusion (from -rec or the legacy -recN wrappers) + # is re-emitted as one dot-separated -rec value + if args.random_exclude_commands: + flags += " -rec " + ".".join(f"{command:02}" for command in args.random_exclude_commands) return flags def options(args): result = [] - if args.commands is not None: - for index, command_string in enumerate(args.command_strings): - result.append((COMMAND_OPTIONS[index], command_string, COMMAND_OPTIONS[index])) - else: + if args.commands is None and not args.commands_probability_mode: for option in COMMAND_OPTIONS: result.append((option, option, option)) + else: + if args.commands is not None: + for index, command_string in enumerate(args.command_strings): + result.append((COMMAND_OPTIONS[index], command_string, COMMAND_OPTIONS[index])) + if args.commands_probability_mode: + mode_name = "Custom Unique" if args.commands_unique_backfill else "Custom" + result.append(("Random Mode", mode_name, "commands_probability_mode")) + for command, percent in args.command_probabilities: + command_name = "None" if command == NONE_COMMAND else id_name[command] + result.append((f"{command_name} Chance", f"{percent}%", f"command_probability_{command}")) result.append(("", "", "")) result.append(("Shuffle Commands", args.shuffle_commands, "shuffle_commands")) - add_exclude_command = lambda command, i : result.append(("Random Exclude", "None" if command == NONE_COMMAND else id_name[command], f"random_exclude_command{i}")) - - add_exclude_command(args.random_exclude_command1, 1) - add_exclude_command(args.random_exclude_command2, 2) - add_exclude_command(args.random_exclude_command3, 3) - add_exclude_command(args.random_exclude_command4, 4) - add_exclude_command(args.random_exclude_command5, 5) - add_exclude_command(args.random_exclude_command6, 6) + # one row per exclusion, padded with "None" rows to the fixed six the menu + # has always shown (extra exclusions beyond six simply add rows) + exclude_rows = list(args.random_exclude_commands) + while len(exclude_rows) < 6: + exclude_rows.append(NONE_COMMAND) + for i, command in enumerate(exclude_rows, start = 1): + result.append(("Random Exclude", "None" if command == NONE_COMMAND else id_name[command], + f"random_exclude_command{i}")) return result diff --git a/constants/commands.py b/constants/commands.py index 2cf42f04..75c18f51 100644 --- a/constants/commands.py +++ b/constants/commands.py @@ -40,6 +40,24 @@ RANDOM_UNIQUE_COMMAND = 98 NONE_COMMAND = 97 # none command id is 3 digits in base 10 (255), use a custom 2 digit value for args +# retired -com meta-mode prefixes ('-com fr 10.50.90'), recognized only to +# print a migration error pointing at the -comfr/-comfru/-compr/-compru flags +RETIRED_COM_MODES = ["fr", "fru", "pr", "pru"] + +# command ids a probability can be declared for (-compr/-compru): every real +# menu command the randomizer hands out (not Revert/Leap/Mimic/Row/Def/Summon +# or the broken Empty slots), plus None (declared as 97, same as -com strings) +PROBABILITY_EXCLUDED_NAMES = ["Revert", "Leap", "Mimic", "Row", "Def", "Summon", "Empty", "Empty?", "None"] +PROBABILITY_COMMAND_IDS = [command_id for command_id, command_name in id_name.items() + if command_name not in PROBABILITY_EXCLUDED_NAMES] + +# every character has four command slots in their initialization data ($ed7ca2 - $ed7ca5) +COMMAND_SLOT_COUNT = 4 + +# commands with a fixed position in the battle menu (fight -> skills -> magic -> item), +# in the order their percent chances are given to the full random modes +COMMON_COMMANDS = ["Fight", "Magic", "Item"] + COMMAND_OPTIONS = ["Morph", "Steal", "SwdTech", "Throw", "Tools", "Blitz", "Runic", "Lore", "Sketch", "Slot", "Dance", "Rage", "Leap"] EXCLUDE_COMMANDS = ["Item", "Magic", "Revert", "Leap", "Mimic", "Row", "Def", "Summon", "Empty", "Empty?", "None"] diff --git a/data/commands.py b/data/commands.py index 5676bb30..25cbc908 100644 --- a/data/commands.py +++ b/data/commands.py @@ -7,6 +7,27 @@ class Commands: def __init__(self, characters): self.characters = characters + def mod_moogle_commands(self, command_list): + from data.characters import Characters + + # Give the Moogles for Moogle Defense randomized commands + # Copy the list minus any exclusions + possible_moogle_commands = command_list.copy() + # randomize commands for Moogles during Moogle Defense from the non-excluded set + # Remove Morph to ensure only 1 character gets Morph + # Remove Rage to avoid any issues with Randomized Atma weapon + # Remove X-Magic as they won't have any Magic + # Remove Blitz, SwdTech, Dance, and Lore because they won't have abilities within unless a party member does + moogle_exclusions = [name_id["Morph"], name_id["Rage"], name_id["X Magic"], name_id["Blitz"], name_id["SwdTech"], name_id["Lore"], name_id["Dance"]] + for exclude in moogle_exclusions: + try: + possible_moogle_commands.remove(exclude) + except ValueError: + pass + if len(possible_moogle_commands) > 0: + for index in range(Characters.FIRST_MOOGLE, Characters.LAST_MOOGLE + 1): + self.characters[index].commands[1] = random.choice(possible_moogle_commands) + def mod_commands(self): command_set = set(name_id[name] for name in RANDOM_POSSIBLE_COMMANDS) command_list = list(command_set) @@ -27,23 +48,7 @@ def mod_commands(self): pass from data.characters import Characters - # Give the Moogles for Moogle Defense randomized commands - # Copy the list minus any exclusions - possible_moogle_commands = command_list.copy() - # randomize commands for Moogles during Moogle Defense from the non-excluded set - # Remove Morph to ensure only 1 character gets Morph - # Remove Rage to avoid any issues with Randomized Atma weapon - # Remove X-Magic as they won't have any Magic - # Remove Blitz, SwdTech, Dance, and Lore because they won't have abilities within unless a party member does - moogle_exclusions = [morph_id, name_id["Rage"], name_id["X Magic"], name_id["Blitz"], name_id["SwdTech"], name_id["Lore"], name_id["Dance"]] - for exclude in moogle_exclusions: - try: - possible_moogle_commands.remove(exclude) - except ValueError: - pass - if len(possible_moogle_commands) > 0: - for index in range(Characters.FIRST_MOOGLE, Characters.LAST_MOOGLE + 1): - self.characters[index].commands[1] = random.choice(possible_moogle_commands) + self.mod_moogle_commands(command_list) # if suplex a train condition exists, guarantee blitz import objectives @@ -87,6 +92,258 @@ def mod_commands(self): self.characters[Characters.GAU].commands[0] = args.character_commands[-2] # rage self.characters[Characters.GAU].commands[1] = args.character_commands[-1] # leap + def full_random_characters(self): + # the characters the -com flag controls: terra through mog, plus gau + from data.characters import Characters + return list(range(Characters.GAU + 1)) + + def random_command_list(self): + # commands available to random selection, minus the -rec exclusions + command_list = [name_id[name] for name in RANDOM_POSSIBLE_COMMANDS] + for exclude_command in args.random_exclude_commands: + try: + command_list.remove(exclude_command) + except ValueError: + pass + return command_list + + def random_skills(self, characters, skill_counts, available, taken = None): + # fill each character's skill slots independently, commands may repeat between + # characters. `taken` maps a character to commands it already holds outside + # this fill (the -com pr rolled commands), which must not be dealt again + morph_id = name_id["Morph"] + taken = taken or {} + + skills = {character : [] for character in characters} + for character in characters: + for _ in range(skill_counts[character]): + # never give a character the same command twice + candidates = [command for command in available + if command not in skills[character] + and command not in taken.get(character, ())] + if not candidates: + break + + command = random.choice(candidates) + skills[character].append(command) + if command == morph_id: + available.remove(morph_id) # only one character gets morph + + return skills + + def draft_skills(self, characters, skill_counts, available, taken = None): + # snake draft the skill slots so commands are unique for as long as they last, + # refilling the available commands whenever they run out. `taken` as in + # random_skills: commands a character already holds and must not draft again + morph_id = name_id["Morph"] + taken = taken or {} + + draft_order = list(characters) + random.shuffle(draft_order) + + skills = {character : [] for character in characters} + pool = list(available) + for round_index in range(max(skill_counts.values(), default = 0)): + round_order = [character for character in draft_order if skill_counts[character] > round_index] + if round_index % 2: + round_order.reverse() # snake back the other way every other round + + for character in round_order: + # never give a character the same command twice + held = set(skills[character]) | set(taken.get(character, ())) + candidates = [command for command in pool if command not in held] + if not candidates: + # the pool ran out, or all that is left of it is already on this character. + # refill around whatever is left instead of replacing it, so a command which + # could not be handed out this turn is still waiting to be drafted later + pool.extend(available) + candidates = [command for command in pool if command not in held] + if not candidates: + continue + + command = random.choice(candidates) + pool.remove(command) + skills[character].append(command) + if command == morph_id: + # only one character gets morph. dropping it from the available commands is + # enough to keep it out of the pool for good: a refill only happens once every + # pooled command is already on the drafting character, so morph can never be + # waiting in the pool at the moment a refill would add a second copy of it + available.remove(morph_id) + + return skills + + def guarantee_blitz(self, characters, skills): + # if suplex a train condition exists, guarantee blitz + import objectives + blitz_id = name_id["Blitz"] + + if not objectives.suplex_train_condition_exists: + return + if any(blitz_id in skills[character] for character in characters): + return + + # replace a random skill slot with blitz (even if blitz is in the excluded commands) + possible_characters = [character for character in characters if skills[character]] + if not possible_characters: + return + + character = random.choice(possible_characters) + skills[character][random.randrange(len(skills[character]))] = blitz_id + + def roll_probability_commands(self, character, capacity, held): + # roll one character's slots from the declared (command, percent) list + # (-compr/-compru, plus -comfr/-comfru folded in). more commands can be + # declared than the character has free slots, so: group the declarations + # by likelihood, and starting with the most likely group roll each + # command in a random order, stopping as soon as `capacity` slots are + # claimed. a rolled 97 (None) claims a slot and leaves it empty; a + # rolled command the character already holds explicitly claims nothing. + from data.characters import Characters + fight_id = name_id["Fight"] + + by_percent = {} + for command, percent in args.command_probabilities: + by_percent.setdefault(percent, []).append(command) + + rolled = [] + for percent in sorted(by_percent, reverse = True): + group = list(by_percent[percent]) + random.shuffle(group) + for command in group: + if len(rolled) == capacity: + return rolled + # gau has no fight command in vanilla, and that is part of what + # makes him unique -- a declared fight probability never applies + # to him (his slot backfills instead) + if command == fight_id and character == Characters.GAU: + continue + if random.randrange(100) < percent: + if command not in held: + rolled.append(command) + return rolled + + def mod_probability_random_commands(self): + # composed command assignment (-com / -comfr(u) / -compr(u)): + # 1. explicit -com picks claim their slots (97 holds a slot empty; + # 99/98 mark slots for random/unique backfill) + # 2. the declared probabilities roll into the remaining capacity + # 3. backfill: 98-marked slots draft unique, 99-marked slots fill + # randomly, and any still-unfilled slots use the family default + # (unique for -comfru/-compru, random otherwise) + from data.characters import Characters + fight_id = name_id["Fight"] + magic_id = name_id["Magic"] + item_id = name_id["Item"] + none_id = name_id["None"] + morph_id = name_id["Morph"] + + available = self.random_command_list() + self.mod_moogle_commands(available) + + characters = self.full_random_characters() + + # step 1: explicit traditional picks and slot marks + explicit = {character : [] for character in characters} + random_fill = {character : 0 for character in characters} + unique_fill = {character : 0 for character in characters} + empty_slots = {character : 0 for character in characters} + if args.character_commands: + # same explicit-command rules as mod_commands: the random pool plus + # fight anywhere, morph only in terra's slot, leap only in gau's + leap_id = name_id["Leap"] + allowed = set(name_id[name] for name in RANDOM_POSSIBLE_COMMANDS) | {fight_id} + character_picks = {character : [args.character_commands[character]] + for character in characters if character != Characters.GAU} + character_picks[Characters.GAU] = [args.character_commands[-2], args.character_commands[-1]] + for character, picks in character_picks.items(): + for command in picks: + if command == RANDOM_COMMAND: + random_fill[character] += 1 + elif command == RANDOM_UNIQUE_COMMAND: + unique_fill[character] += 1 + elif command == NONE_COMMAND: + empty_slots[character] += 1 + elif (command in allowed + or (command == morph_id and character == 0) + or (command == leap_id and character == Characters.GAU)): + explicit[character].append(command) + else: + raise ValueError(f"Invalid character command {command}") + + # a morph handed out explicitly or by its probability roll must stay out + # of the backfill pool: the "only one character gets morph" pool rule + # (and the draft refill's morph invariant) cannot deal a second copy + morph_placed = (any(morph_id in commands for commands in explicit.values()) + or any(command == morph_id for command, _ in args.command_probabilities)) + if morph_placed: + try: + available.remove(morph_id) + except ValueError: + pass + + # step 2: probability rolls into the remaining capacity + rolled = {} + capacity = {} + for character in characters: + capacity[character] = (COMMAND_SLOT_COUNT - len(explicit[character]) + - empty_slots[character] + - random_fill[character] - unique_fill[character]) + rolled[character] = self.roll_probability_commands( + character, capacity[character], set(explicit[character])) + + # step 3: backfill -- leftover capacity joins the family-default style + for character in characters: + leftover = capacity[character] - len(rolled[character]) + if args.commands_unique_backfill: + unique_fill[character] += leftover + else: + random_fill[character] += leftover + + taken = {character : explicit[character] + + [command for command in rolled[character] if command != NONE_COMMAND] + for character in characters} + drafted = self.draft_skills(characters, unique_fill, available, taken) + taken = {character : taken[character] + drafted[character] for character in characters} + randomed = self.random_skills(characters, random_fill, available, taken) + + skills = {character : drafted[character] + randomed[character] for character in characters} + self.guarantee_blitz(characters, skills) + + # apply the commands in menu order: fight -> skills -> magic -> item, + # with explicit picks ahead of rolled skills ahead of backfilled ones + # and empty slots at the end + for character in characters: + common = [command for command in rolled[character] + if command in (fight_id, magic_id, item_id)] + explicit_skills = [command for command in explicit[character] if command != fight_id] + rolled_skills = [command for command in rolled[character] + if command not in (fight_id, magic_id, item_id, NONE_COMMAND)] + commands = [] + if fight_id in common or fight_id in explicit[character]: + commands.append(fight_id) + commands.extend(explicit_skills) + commands.extend(rolled_skills) + commands.extend(skills[character]) + if magic_id in common: + commands.append(magic_id) + if item_id in common: + commands.append(item_id) + commands.extend([none_id] * (COMMAND_SLOT_COUNT - len(commands))) + + self.characters[character].commands = commands + + def shuffle_full_random_commands(self): + # commands are already random, so shuffle whole command sets between characters + # instead of single slots to keep each character's menu order intact + characters = self.full_random_characters() + + command_sets = [self.characters[character].commands for character in characters] + random.shuffle(command_sets) + + for index, character in enumerate(characters): + self.characters[character].commands = command_sets[index] + def shuffle_commands(self): from data.characters import Characters @@ -105,18 +362,34 @@ def mod(self): import data.characters_asm as characters_asm from data.characters import Characters - if args.commands: + if args.commands_probability_mode: + self.mod_probability_random_commands() + elif args.commands: self.mod_commands() if args.shuffle_commands: - self.shuffle_commands() + if args.commands_probability_mode: + self.shuffle_full_random_commands() + else: + self.shuffle_commands() - if args.commands or args.shuffle_commands: + if args.commands or args.commands_probability_mode or args.shuffle_commands: characters_asm.update_morph_character(self.characters[ : Characters.CHARACTER_COUNT]) def log(self): from log import section, format_option from data.characters import Characters + if args.commands_probability_mode: + # every slot can be randomized, so log each character's full command menu + lcolumn = [] + for character in self.full_random_characters(): + commands = [id_name[command] for command in self.characters[character].commands + if command != name_id["None"]] + lcolumn.append(format_option(Characters.DEFAULT_NAME[character].capitalize(), ", ".join(commands))) + + section("Commands", lcolumn, []) + return + lcolumn = [] for index, option in enumerate(COMMAND_OPTIONS[ : -2]): lcolumn.append(format_option(option, id_name[self.characters[index].commands[1]])) diff --git a/data/natural_magic.py b/data/natural_magic.py index 17a2a48f..65d3def9 100644 --- a/data/natural_magic.py +++ b/data/natural_magic.py @@ -112,13 +112,28 @@ def call_check_spell_learn(space, learner, unique_label): asm.JSR(natural_magic_check, asm.ABS), ) - def mod_learners(self): + def get_magic_users(self, possible_learners): + # characters who can cast their spells in battle. x magic opens the magic menu too, + # so it counts even without the magic command itself + magic_users = set(self.characters.get_characters_with_command("Magic")) + magic_users |= set(self.characters.get_characters_with_command("X Magic")) + + return [learner for learner in possible_learners if learner in magic_users] + + def random_learner(self, possible_learners): import random + + # prefer a character who can cast in battle, but fall back to any of them if the + # commands flag left nobody with a magic command. a character without one can still + # cast their natural magic outside of battle, so they are not a wasted pick + return random.choice(self.get_magic_users(possible_learners) or possible_learners) + + def mod_learners(self): from data.characters import Characters possible_learners = list(range(Characters.CHARACTER_COUNT - 2)) # exclude gogo/umaro if self.args.natural_magic1 == "random": - self.learner1 = random.choice(possible_learners) + self.learner1 = self.random_learner(possible_learners) self.learner1_name = self.characters.get_name(self.learner1) elif self.args.natural_magic1: self.learner1 = self.characters.get_by_name(self.args.natural_magic1).id @@ -133,7 +148,7 @@ def mod_learners(self): pass if self.args.natural_magic2 == "random": - self.learner2 = random.choice(possible_learners) + self.learner2 = self.random_learner(possible_learners) self.learner2_name = self.characters.get_name(self.learner2) elif self.args.natural_magic2: self.learner2 = self.characters.get_by_name(self.args.natural_magic2).id diff --git a/menus/menus.py b/menus/menus.py index 26b0c006..f96472ff 100644 --- a/menus/menus.py +++ b/menus/menus.py @@ -9,6 +9,7 @@ import menus.sell as sell import menus.buy as buy import menus.magic as magic +import menus.skills as skills import menus.required_character_swap as required_character_swap class Menus: @@ -29,6 +30,7 @@ def __init__(self, characters, dances, rages, enemies): self.sell_menu = sell.SellMenu() self.buy_menu = buy.BuyMenu() self.magic_menu = magic.MagicMenu() + self.skills_menu = skills.SkillsMenu() self.required_character_swap = required_character_swap.RequiredCharacterSwap() self.scrollbar_bugfix() diff --git a/menus/skills.py b/menus/skills.py new file mode 100644 index 00000000..083c18bd --- /dev/null +++ b/menus/skills.py @@ -0,0 +1,71 @@ +from memory.space import Reserve +import instruction.asm as asm + +# The Skills submenu (main menu -> Skills -> character) enables its seven rows +# per character in a routine at C3/4D3D: every row starts greyed ($24 at +# $79-$7F), then each of the character's four command bytes is compared against +# a per-row command-id table at C3/4D78 (02 02 07 0A 0C 10 13) and a match +# enables the row ($20). The row byte is both the draw color and the selection +# gate (C3/208B: LDA $79,X : CMP #$20 : BNE deny). +# +# Vanilla keys the ESPERS row (index 0) to the Magic command id (02) -- the +# same test as the Magic row -- with one extra rule greying it for Gogo. With +# randomized commands a character can be esper-capable yet lack the Magic +# command, which wrongly locks them out of equipping espers (level-up stat +# bonuses, spell learning, out-of-battle casting). +# +# This rewrite keeps every other row keyed to its command, but enables the +# Espers row by character id alone: enabled for ids below Gogo (0x0C), greyed +# for Gogo/Umaro and any special record above them. Byte-for-byte the same 59 +# bytes, rewritten in place: the command scan indexes the table from its second +# entry and stores to $7A,X (rows 1-6), freeing the tail for the id check. + + +class SkillsMenu: + def __init__(self): + self.espers_row_mod() + + def espers_row_mod(self): + ROW_TABLE_MAGIC_ONWARD = 0xc34d79 # vanilla row table at C3/4D78, minus the espers entry + GOGO = 0x0c # gogo 0x0c, umaro 0x0d, moogles/specials above + + space = Reserve(0x34d3d, 0x34d77, "skills menu row enable: espers row by character id") + space.write( + asm.LDA(0x24, asm.IMM8), # a = greyed + asm.LDX(0x00, asm.DIR), # x = 0 ($00 holds zero here, as vanilla relies on) + "GREY_LOOP", + asm.STA(0x79, asm.DIR_X), + asm.INX(), + asm.CPX(0x0007, asm.IMM16), + asm.BNE("GREY_LOOP"), # grey all seven rows + + asm.JSR(0x4edd, asm.ABS), # y = character record base + asm.PHY(), + asm.LDX(0x0004, asm.IMM16), # four command slots + "COMMAND_LOOP", + asm.PHX(), + asm.LDX(0x00, asm.DIR), # x = 0: row table index (row 1 = Magic) + "ROW_LOOP", + asm.LDA(0x0016, asm.ABS_Y), # a = character's command byte + asm.CMP(ROW_TABLE_MAGIC_ONWARD, asm.LNG_X), + asm.BNE("NEXT_ROW"), + asm.LDA(0x20, asm.IMM8), + asm.STA(0x7a, asm.DIR_X), # enable matching row (magic..dance) + "NEXT_ROW", + asm.INX(), + asm.CPX(0x0006, asm.IMM16), + asm.BNE("ROW_LOOP"), + asm.INY(), # next command byte + asm.PLX(), + asm.DEX(), + asm.BNE("COMMAND_LOOP"), + + asm.PLY(), + asm.LDA(0x0000, asm.ABS_Y), # a = character id + asm.CMP(GOGO, asm.IMM8), + asm.BCS("RETURN"), # gogo/umaro/specials: espers stays greyed + asm.LDA(0x20, asm.IMM8), + asm.STA(0x79, asm.DIR), # everyone else: espers always enabled + "RETURN", + asm.RTS(), + ) diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 00000000..bc612638 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,352 @@ +import os +import subprocess +import sys +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +def parse_flags(*flags): + # args/arguments.py parses the given flags and prints the canonical flag string, + # which exercises the whole -com interface without needing a rom + return subprocess.run( + [sys.executable, os.path.join("args", "arguments.py"), "-i", "rom.smc", *flags], + cwd = REPO_ROOT, + capture_output = True, + text = True, + timeout = 60, + ) + +# drafts 24 skill slots out of a pool of 5 commands, which forces repeated refills and, +# often, a refill triggered by a leftover the drafting character already has +DRAFT_INVARIANTS = """ +import sys, types, collections +sys.argv = ["wc.py", "-i", "rom.smc", "-comfru", "0.0.0"] +import args +sys.modules["objectives"] = types.ModuleType("objectives") +sys.modules["objectives"].suplex_train_condition_exists = False + +from constants.commands import name_id +from data.commands import Commands + +MORPH = name_id["Morph"] +POOL = [MORPH] + list(range(100, 104)) +CHARACTERS, SLOTS = list(range(6)), 4 + +for trial in range(2000): + skills = Commands([]).draft_skills(CHARACTERS, {c : SLOTS for c in CHARACTERS}, list(POOL)) + for character in CHARACTERS: + drafted = skills[character] + assert len(drafted) == SLOTS, f"dropped a slot: {drafted}" + assert len(set(drafted)) == SLOTS, f"duplicate command: {drafted}" + assert all(command in POOL for command in drafted), drafted + assert sum(MORPH in skills[c] for c in CHARACTERS) <= 1, "morph drafted more than once" +print("ok") +""" + +# behavioral invariants for the -com pr/pru probability modes, run against fake +# characters (no rom needed). declared: fight/item/magic/possess at 100%, blitz +# at 0%; rage excluded via -rec. every character must therefore hold exactly +# fight/possess/magic/item in menu order, with nothing backfilled. +PR_INVARIANTS = """ +import sys, types +sys.argv = ["wc.py", "-i", "rom.smc", "-compr", "0.1.2.28.10", "100.100.100.100.0", "-rec", "16"] +import args +sys.modules["objectives"] = types.ModuleType("objectives") +sys.modules["objectives"].suplex_train_condition_exists = False + +from constants.commands import name_id +from data.commands import Commands + +class FakeChar: + def __init__(self): + self.commands = [0, 0, 0, 0] + +GAU = 11 + +for trial in range(300): + chars = [FakeChar() for _ in range(0x20)] + c = Commands(chars) + c.mod_probability_random_commands() + for i in c.full_random_characters(): + cmds = chars[i].commands + if i == GAU: + # gau never gets fight from a probability roll, even at 100%; the + # freed slot backfills and the rest keep their menu order + assert 0 not in cmds and cmds[0] == 28 and cmds[2:] == [2, 1], \ + f"gau menu order broken: {cmds}" + else: + assert cmds == [0, 28, 2, 1], f"menu order broken: {cmds}" +print("ok") +""" + +# six declarations at equal likelihood: each character rolls exactly four of the +# six (the group cap), never a -rec excluded command, and never a duplicate. +PR_CAP_INVARIANTS = """ +import sys, types +sys.argv = ["wc.py", "-i", "rom.smc", "-compru", "0.1.2.27.28.29", "50.50.50.50.50.50", "-rec", "16"] +import args +sys.modules["objectives"] = types.ModuleType("objectives") +sys.modules["objectives"].suplex_train_condition_exists = False + +from constants.commands import name_id +from data.commands import Commands + +DECLARED = {0, 1, 2, 27, 28, 29} +NONE = name_id["None"] +RAGE = name_id["Rage"] + +class FakeChar: + def __init__(self): + self.commands = [0, 0, 0, 0] + +seen_over_four = False +for trial in range(300): + chars = [FakeChar() for _ in range(0x20)] + c = Commands(chars) + c.mod_probability_random_commands() + for i in c.full_random_characters(): + cmds = chars[i].commands + real = [x for x in cmds if x != NONE] + assert len(cmds) == 4, cmds + assert len(set(real)) == len(real), f"duplicate command: {cmds}" + assert RAGE not in real, f"excluded command dealt: {cmds}" + declared_held = [x for x in real if x in DECLARED] + assert len(declared_held) <= 4, f"cap exceeded: {cmds}" +print("ok") +""" + +# a declared None (97) claims a slot and stays empty: with none at 100% and +# three 100% commands, every character has exactly one empty slot, no backfill. +PR_NONE_INVARIANTS = """ +import sys, types +sys.argv = ["wc.py", "-i", "rom.smc", "-compr", "5.7.13.97", "100.100.100.100"] +import args +sys.modules["objectives"] = types.ModuleType("objectives") +sys.modules["objectives"].suplex_train_condition_exists = False + +from constants.commands import name_id +from data.commands import Commands + +NONE = name_id["None"] + +class FakeChar: + def __init__(self): + self.commands = [0, 0, 0, 0] + +for trial in range(300): + chars = [FakeChar() for _ in range(0x20)] + c = Commands(chars) + c.mod_probability_random_commands() + for i in c.full_random_characters(): + cmds = chars[i].commands + real = [x for x in cmds if x != NONE] + assert sorted(real) == [5, 7, 13], f"expected steal/swdtech/sketch + empty: {cmds}" +print("ok") +""" + +# a declared morph is dealt only by its roll: at 50% morph plus unique backfill, +# no character may ever hold two morphs and backfill must never add one. +PR_MORPH_INVARIANTS = """ +import sys, types +sys.argv = ["wc.py", "-i", "rom.smc", "-compru", "3", "50"] +import args +sys.modules["objectives"] = types.ModuleType("objectives") +sys.modules["objectives"].suplex_train_condition_exists = False + +from constants.commands import name_id +from data.commands import Commands + +MORPH = name_id["Morph"] +NONE = name_id["None"] + +class FakeChar: + def __init__(self): + self.commands = [0, 0, 0, 0] + +for trial in range(300): + chars = [FakeChar() for _ in range(0x20)] + c = Commands(chars) + c.mod_probability_random_commands() + for i in c.full_random_characters(): + cmds = chars[i].commands + real = [x for x in cmds if x != NONE] + assert len(real) == 4, f"pru backfill left a hole: {cmds}" + assert cmds.count(MORPH) <= 1, f"double morph: {cmds}" +print("ok") +""" + + +# composed mode: -com explicit picks + -compr rolls + backfill. terra gets an +# explicit steal, locke holds a slot empty (97), cyan marks a unique-backfill +# slot (98), everyone else is 99; possess is declared at 100%. +COMPOSED_INVARIANTS = """ +import sys, types +sys.argv = ["wc.py", "-i", "rom.smc", + "-com", "05979899999999999999999999", "-compr", "28", "100"] +import args +sys.modules["objectives"] = types.ModuleType("objectives") +sys.modules["objectives"].suplex_train_condition_exists = False + +from constants.commands import name_id +from data.commands import Commands + +STEAL = name_id["Steal"] +POSSESS = name_id["Possess"] +NONE = name_id["None"] + +class FakeChar: + def __init__(self): + self.commands = [0, 0, 0, 0] + +for trial in range(300): + chars = [FakeChar() for _ in range(0x20)] + c = Commands(chars) + c.mod_probability_random_commands() + for i in c.full_random_characters(): + cmds = chars[i].commands + real = [x for x in cmds if x != NONE] + assert len(set(real)) == len(real), f"duplicate: {cmds}" + assert POSSESS in real, f"100% possess missing: {cmds}" + terra, locke, cyan = chars[0].commands, chars[1].commands, chars[2].commands + assert STEAL in terra, f"explicit steal missing: {terra}" + assert len([x for x in terra if x != NONE]) == 4, f"terra not full: {terra}" + assert len([x for x in locke if x != NONE]) == 3, f"locke 97 slot not empty: {locke}" + assert len([x for x in cyan if x != NONE]) == 4, f"cyan not full: {cyan}" +print("ok") +""" + + +class TestCommandsFlag(unittest.TestCase): + def assert_accepted(self, *flags, expected = None): + result = parse_flags(*flags) + self.assertEqual(result.returncode, 0, msg = result.stderr) + if expected is not None: + self.assertIn(expected, result.stdout) + return result.stdout + + def assert_rejected(self, *flags, expected = None): + result = parse_flags(*flags) + self.assertNotEqual(result.returncode, 0, msg = result.stdout) + if expected is not None: + self.assertIn(expected, result.stderr) + + def test_character_command_ids(self): + self.assert_accepted("-com", "03050708091011121315191617", expected = "-com 03050708091011121315191617") + self.assert_accepted("-com", "99999999999999999999999999", expected = "-com 99999999999999999999999999") + + def test_full_random_modes(self): + self.assert_accepted("-comfr", "10.50.90", expected = "-comfr 10.50.90") + self.assert_accepted("-comfru", "0.0.0", expected = "-comfru 0.0.0") + + def test_retired_com_modes_rejected(self): + # the old '-com fr/pr ...' meta-mode syntax points at the new flags + self.assert_rejected("-com", "fr", "10.50.90", expected = "use -comfr") + self.assert_rejected("-com", "pru", "3", "50", expected = "use -compru") + + def test_no_commands_flag(self): + self.assertNotIn("-com", self.assert_accepted()) + self.assertNotIn("-com", self.assert_accepted("-com")) + + def test_family_composition(self): + # -com composes with the probability flags; each emits its own flag + out = self.assert_accepted("-com", "05999999999999999999999999", "-comfr", "50.50.50") + self.assertIn("-com 05999999999999999999999999", out) + self.assertIn("-comfr 50.50.50", out) + # -comfr folds into -compr as extra declarations; both still emitted + out = self.assert_accepted("-compr", "28", "100", "-comfr", "50.50.50") + self.assertIn("-compr 28 100", out) + self.assertIn("-comfr 50.50.50", out) + # unique variants pair up + self.assert_accepted("-compru", "28", "100", "-comfru", "50.50.50") + + def test_family_conflicts_rejected(self): + self.assert_rejected("-comfr", "10.50.90", "-comfru", "10.50.90", + expected = "-comfr and -comfru are incompatible") + self.assert_rejected("-compr", "28", "100", "-compru", "28", "100", + expected = "-compr and -compru are incompatible") + self.assert_rejected("-comfr", "10.50.90", "-compru", "28", "100", + expected = "cannot mix unique and non-unique") + # an id declared by both -comfr and -compr is a conflict + self.assert_rejected("-comfr", "10.50.90", "-compr", "0.28", "100.100", + expected = "declared by both") + + def test_unique_draft_refills(self): + result = subprocess.run( + [sys.executable, "-c", DRAFT_INVARIANTS], + cwd = REPO_ROOT, + capture_output = True, + text = True, + timeout = 120, + ) + self.assertEqual(result.returncode, 0, msg = result.stderr) + self.assertIn("ok", result.stdout) + + def test_invalid_values_rejected(self): + self.assert_rejected("-com", "0305070809", expected = "must be 26 digits") + self.assert_rejected("-com", "03050708091011121315191650", expected = "not a valid command id") + self.assert_rejected("-comfr", "10.50", expected = "3 percent chances") + self.assert_rejected("-comfr", "10.50.101", expected = "must be between 0 and 100") + self.assert_rejected("-comfru", "10.50.abc", expected = "not a valid percent chance") + + def test_probability_modes(self): + # ids are canonicalized to two digits; percents kept as given + self.assert_accepted("-compr", "0.1.2.28", "50.50.50.100", + expected = "-compr 00.01.02.28 50.50.50.100") + self.assert_accepted("-compru", "0.1.2.27.28.29", "50.50.50.50.50.50", + expected = "-compru 00.01.02.27.28.29 50.50.50.50.50.50") + # 97 declares a chance at an empty slot + self.assert_accepted("-compr", "97.10", "50.100", expected = "-compr 97.10 50.100") + + def test_probability_invalid_rejected(self): + self.assert_rejected("-compr", "0.1.2", expected = "expected 2 arguments") + self.assert_rejected("-compr", "0.1.2", "50.50", expected = "3 command ids but 2 percent chances") + self.assert_rejected("-compr", "0.25", "50.50", expected = "not a valid probability command id") # Summon + self.assert_rejected("-compr", "0.0", "50.50", expected = "duplicate probability command id") + self.assert_rejected("-compr", "0.abc", "50.50", expected = "not a valid command id") + self.assert_rejected("-compr", "0.1", "50.101", expected = "must be between 0 and 100") + self.assert_rejected("-comfr", "50.50", expected = "3 percent chances") + # a command cannot both have a probability and be excluded by -rec + self.assert_rejected("-compr", "0.16", "50.50", "-rec", "16", + expected = "both given a probability and excluded by -rec") + + def test_probability_invariants(self): + for name, script in (("pr", PR_INVARIANTS), ("cap", PR_CAP_INVARIANTS), + ("none", PR_NONE_INVARIANTS), ("morph", PR_MORPH_INVARIANTS), + ("composed", COMPOSED_INVARIANTS)): + with self.subTest(name): + result = subprocess.run( + [sys.executable, "-c", script], + cwd = REPO_ROOT, + capture_output = True, + text = True, + timeout = 120, + ) + self.assertEqual(result.returncode, 0, msg = result.stderr) + self.assertIn("ok", result.stdout) + + def test_random_exclude_dot_list(self): + # arbitrary-length dot-separated exclusions, re-emitted canonically + self.assert_accepted("-rec", "28.27", expected = "-rec 28.27") + self.assert_accepted("-rec", "05.07.10.16.13", expected = "-rec 05.07.10.16.13") + # single-digit input normalizes to two-digit canonical form + self.assert_accepted("-rec", "5.7", expected = "-rec 05.07") + # the none id (97) is dropped from the canonical string + self.assertNotIn("-rec", self.assert_accepted("-rec", "97")) + + def test_random_exclude_legacy_wrappers(self): + # legacy -recN flags still parse and fold into the canonical -rec form + self.assert_accepted("-rec1", "28", "-rec2", "27", expected = "-rec 28.27") + self.assert_accepted("-rec3", "10", expected = "-rec 10") + # mixed usage: -rec values first, then the legacy flags in order + self.assert_accepted("-rec", "05", "-rec1", "28", expected = "-rec 05.28") + + def test_random_exclude_invalid_rejected(self): + self.assert_rejected("-rec", "28.abc", expected = "not a valid command id") + self.assert_rejected("-rec", "01", expected = "not an excludable command id") # Item + # excluding the whole random pool is rejected (would empty every draw) + from constants.commands import RANDOM_POSSIBLE_COMMANDS, name_id + everything = ".".join(f"{name_id[name]:02}" for name in RANDOM_POSSIBLE_COMMANDS) + self.assert_rejected("-rec", everything, expected = "cannot exclude every") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_natural_magic.py b/tests/test_natural_magic.py new file mode 100644 index 00000000..0c299df8 --- /dev/null +++ b/tests/test_natural_magic.py @@ -0,0 +1,84 @@ +import os +import subprocess +import sys +import unittest + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# a random natural magic learner should be someone who can cast in battle whenever the +# commands leave anyone who can, and any character at all when they do not +LEARNER_CHOICE = """ +import sys +sys.argv = ["wc.py", "-i", "rom.smc", "-nm1", "random"] +import args + +from constants.commands import name_id +from data.characters import Characters +from data.natural_magic import NaturalMagic + +FIGHT, ITEM, MAGIC, X_MAGIC = name_id["Fight"], name_id["Item"], name_id["Magic"], name_id["X Magic"] +BLITZ, NONE = name_id["Blitz"], name_id["None"] +POSSIBLE = list(range(Characters.CHARACTER_COUNT - 2)) + +class FakeCharacter: + def __init__(self, id, commands): + self.id = id + self.commands = commands + +class FakeCharacters: + get_characters_with_command = Characters.get_characters_with_command + def __init__(self, characters): + self.characters = characters + +def learner_picker(magic_users, x_magic_users): + characters = [] + for id in range(Characters.CHARACTER_COUNT): + if id in magic_users: + commands = [FIGHT, BLITZ, MAGIC, ITEM] + elif id in x_magic_users: + commands = [FIGHT, X_MAGIC, BLITZ, ITEM] + else: + commands = [FIGHT, BLITZ, NONE, ITEM] + characters.append(FakeCharacter(id, commands)) + + natural_magic = object.__new__(NaturalMagic) + natural_magic.characters = FakeCharacters(characters) + return natural_magic + +# only celes and mog have a magic command +picker = learner_picker(magic_users = [Characters.CELES, Characters.MOG], x_magic_users = []) +picked = {picker.random_learner(POSSIBLE) for _ in range(400)} +assert picked == {Characters.CELES, Characters.MOG}, picked + +# x magic counts as being able to cast +picker = learner_picker(magic_users = [Characters.CELES], x_magic_users = [Characters.GAU]) +picked = {picker.random_learner(POSSIBLE) for _ in range(400)} +assert picked == {Characters.CELES, Characters.GAU}, picked + +# a magic user outside the possible learners must not be picked +picker = learner_picker(magic_users = [Characters.CELES, Characters.GOGO], x_magic_users = []) +picked = {picker.random_learner(POSSIBLE) for _ in range(400)} +assert picked == {Characters.CELES}, picked + +# nobody can cast: fall back to any possible learner rather than failing +picker = learner_picker(magic_users = [], x_magic_users = []) +picked = {picker.random_learner(POSSIBLE) for _ in range(2000)} +assert picked == set(POSSIBLE), sorted(picked) + +print("ok") +""" + +class TestNaturalMagicLearners(unittest.TestCase): + def test_random_learner_prefers_magic_users(self): + result = subprocess.run( + [sys.executable, "-c", LEARNER_CHOICE], + cwd = REPO_ROOT, + capture_output = True, + text = True, + timeout = 120, + ) + self.assertEqual(result.returncode, 0, msg = result.stderr) + self.assertIn("ok", result.stdout) + +if __name__ == "__main__": + unittest.main()