From 7c6591b48cad4b8cfceb7a10c961ad91978f7aa3 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 5 Oct 2026 19:14:51 -0600 Subject: [PATCH 1/4] Serialize mob programs in one ROM 2.4-shaped list per mob Each mob in the JSON output gets mob_programs: ROM trigger records joined with their #MOBPROGS bodies, and inline SMAUG/ACK/FUSS programs translated to ROM 2.4 triggers and mob commands. A missing body, an unknown trigger and a line or phrase with no confident ROM form are reported as diagnostics. --- area_reader/dialects/ack.py | 3 + area_reader/dialects/smaug.py | 3 + area_reader/dialects/swr.py | 5 + area_reader/mobprogs.py | 208 +++++++++++++++++ area_reader/parser.py | 25 +- test_mob_programs.py | 413 ++++++++++++++++++++++++++++++++++ 6 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 area_reader/mobprogs.py create mode 100644 test_mob_programs.py diff --git a/area_reader/dialects/ack.py b/area_reader/dialects/ack.py index 600c67f..b571f04 100644 --- a/area_reader/dialects/ack.py +++ b/area_reader/dialects/ack.py @@ -344,6 +344,9 @@ def write(self, path): with open(path, mode="wt", encoding="latin-1", newline="\n") as area_file: area_file.write(self.dumps()) + def inline_mob_programs(self, mob): + return [(program.trigger, program.arguments, program.commands) for program in mob.mobprogs] + def section_readers(self): readers = super().section_readers() readers["objfuns"] = self.load_objfuns diff --git a/area_reader/dialects/smaug.py b/area_reader/dialects/smaug.py index 46073fb..dbbd979 100644 --- a/area_reader/dialects/smaug.py +++ b/area_reader/dialects/smaug.py @@ -68,6 +68,9 @@ def read_smaug_programs(self): self.read_and_verify_letter("|") return programs + def inline_mob_programs(self, mob): + return [(program.trigger, program.argument, program.commands) for program in mob.programs] + def load_sections(self): readers = { "area": self.read_area_metadata, diff --git a/area_reader/dialects/swr.py b/area_reader/dialects/swr.py index b342f55..7622856 100644 --- a/area_reader/dialects/swr.py +++ b/area_reader/dialects/swr.py @@ -30,6 +30,11 @@ def __init__(self, filename): def dumps(self): return render_document(self.area, self.area.NATIVE_SECTIONS, self.skipped_sections) + def inline_mob_programs(self, mob): + if isinstance(mob, SwrMobile): + return [(program.progtype, program.argument, program.commands) for program in mob.programs] + return super().inline_mob_programs(mob) + def write(self, path): with open(path, mode="wt", encoding="latin-1", newline="\n") as area_file: area_file.write(self.dumps()) diff --git a/area_reader/mobprogs.py b/area_reader/mobprogs.py new file mode 100644 index 0000000..f1c3a81 --- /dev/null +++ b/area_reader/mobprogs.py @@ -0,0 +1,208 @@ +"""Normalization of mob programs to one ROM 2.4-shaped list per mob.""" + +import re + +# Inline MOBprogram trigger names and the ROM 2.4 trigger words that fire on the same event. +INLINE_TRIGGERS = { + "act_prog": "ACT", + "all_greet_prog": "GRALL", + "bribe_prog": "BRIBE", + "death_prog": "DEATH", + "entry_prog": "ENTRY", + "fight_prog": "FIGHT", + "give_prog": "GIVE", + "greet_prog": "GREET", + "hitprcnt_prog": "HPCNT", + "rand_prog": "RANDOM", + "speech_prog": "SPEECH", +} +# ROM matches these triggers' phrase as one substring; the inline dialect marks that form with a leading "p ". +PHRASE_TRIGGERS = frozenset({"ACT", "SPEECH"}) + +# Inline command spellings, abbreviations included, and the ROM 2.4 "mob" subcommand taking the same arguments. +MOB_COMMANDS = { + "mea": "echoat", + "mer": "echoaround", + "mpasound": "asound", + "mpat": "at", + "mpdamage": "damage", + "mpe": "echo", + "mpecho": "echo", + "mpechoar": "echoaround", + "mpechoaround": "echoaround", + "mpechoat": "echoat", + "mpforce": "force", + "mpgot": "goto", + "mpgoto": "goto", + "mpjunk": "junk", + "mpkill": "kill", + "mpmload": "mload", + "mpoload": "oload", + "mppurge": "purge", + "mptrans": "transfer", + "mptransfer": "transfer", +} +# Commands whose arguments end in another command, which may itself be a mob command. +NESTING_COMMANDS = frozenset({"at", "force"}) +# Echo commands and the number of arguments before their text, where SMAUG reads an optional colour token. +ECHO_TARGETS = {"echo": 0, "echoat": 1, "echoaround": 1} +# SMAUG's get_color(): a token starting "_" (or "*", blinking) found in these lists is a colour, not text. +SMAUG_COLOURS = "_bla_red_dgr_bro_dbl_pur_cya_cha_dch_ora_gre_yel_blu_pin_lbl_whi" +SMAUG_BLINK_COLOURS = SMAUG_COLOURS.replace("_", "*") + +# ROM 2.4 if-check grammar: "check value", "check $actor", "check $actor value", "check $actor operator number". +VALUE_CHECKS = frozenset({"rand"}) +ACTOR_CHECKS = frozenset({"isevil", "isgood", "isimmort", "isnpc", "ispc"}) +ACTOR_VALUE_CHECKS = frozenset({"clan", "class", "name", "race"}) +ACTOR_NUMBER_CHECKS = {"inroom": "room", "level": "level", "sex": "sex"} +ROM_OPERATORS = frozenset({"==", "!=", ">", "<", ">=", "<="}) + +COMMAND_LINE = re.compile(r"(\s*)(\S+)(.*)", re.DOTALL) +CONDITION_WORDS = frozenset({"if", "or", "and"}) +CONDITION_LINE = re.compile(r"(\s*)(\w+)\s+(\w+)\s*\(\s*([^()]*?)\s*\)\s*(.*?)\s*") +COMPARISON = re.compile(r"(\S+)\s+(.+)") +ACTOR = re.compile(r"\$[inrt]") +NUMBER = re.compile(r"-?\d+") +DAMAGE_ARGUMENTS = re.compile(r"\s+(\S+)\s+(\d+)\s*") +NESTED_COMMAND = re.compile(r"(\s+\S+\s+)(\S.*)", re.DOTALL) + + +def program_lines(code): + """Split a program body into lines, without trailing blank lines.""" + lines = code.splitlines() + while lines and not lines[-1].strip(): + lines.pop() + return lines + + +def is_smaug_colour(token): + token = token.lower() + return (token.startswith("_") and token in SMAUG_COLOURS) or ( + token.startswith("*") and token in SMAUG_BLINK_COLOURS + ) + + +def quoted_value(value): + """Return ``value`` as one ROM argument, or ``None`` when no quoting can hold it.""" + if not re.search(r"\s", value): + return value + if "'" not in value: + return f"'{value}'" + if '"' not in value: + return f'"{value}"' + return None + + +def translate_condition(line): + match = CONDITION_LINE.fullmatch(line) + if match is None: + return None + indent, keyword, check, argument, tail = match.groups() + keyword = keyword.lower() + check = check.lower() + if check in VALUE_CHECKS: + if tail or not argument.isdigit(): + return None + return f"{indent}{keyword} {check} {argument}" + if not ACTOR.fullmatch(argument): + return None + if check in ACTOR_CHECKS: + if tail: + return None + return f"{indent}{keyword} {check} {argument}" + comparison = COMPARISON.fullmatch(tail) + if comparison is None: + return None + operator, value = comparison.groups() + if check in ACTOR_VALUE_CHECKS: + value = quoted_value(value) + if operator != "==" or value is None: + return None + return f"{indent}{keyword} {check} {argument} {value}" + if check in ACTOR_NUMBER_CHECKS: + if operator not in ROM_OPERATORS or not NUMBER.fullmatch(value): + return None + return f"{indent}{keyword} {ACTOR_NUMBER_CHECKS[check]} {argument} {operator} {value}" + return None + + +def translate_line(line): + """Return an inline program line in ROM 2.4 syntax, or ``None`` when it has no confident ROM form.""" + match = COMMAND_LINE.fullmatch(line) + if match is None: + return line + indent, word, rest = match.groups() + word = word.lower() + if word in CONDITION_WORDS: + return translate_condition(line) + if word not in MOB_COMMANDS: + if word.startswith("mp"): + return None + return line + command = MOB_COMMANDS[word] + if command in ECHO_TARGETS: + arguments = rest.split() + if len(arguments) > ECHO_TARGETS[command] and is_smaug_colour(arguments[ECHO_TARGETS[command]]): + return None + if command == "damage": + # SMAUG's "mpdamage victim amount" can kill; ROM's form is "mob damage victim min max kill". + damage = DAMAGE_ARGUMENTS.fullmatch(rest) + if damage is None: + return None + victim, amount = damage.groups() + return f"{indent}mob damage {victim} {amount} {amount} kill" + if command in NESTING_COMMANDS: + nested = NESTED_COMMAND.fullmatch(rest) + if nested is not None: + nested_line = translate_line(nested.group(2)) + if nested_line is None: + return None + rest = nested.group(1) + nested_line + return f"{indent}mob {command}{rest}" + + +def rom_programs(mob_vnum, mprogs, mobprogs): + """Join a mob's ROM ``M`` trigger records with their ``#MOBPROGS`` bodies.""" + entries = [] + diagnostics = [] + for mprog in mprogs: + trigger = mprog.trig_type.upper() + lines = [] + if mprog.vnum in mobprogs: + lines = program_lines(mobprogs[mprog.vnum]) + else: + diagnostics.append({"kind": "missing_mobprog", "mob": mob_vnum, "trigger": trigger, "vnum": mprog.vnum}) + entries.append( + {"trigger": trigger, "phrase": mprog.trig_phrase, "lines": lines, "source": "rom", "vnum": mprog.vnum} + ) + return entries, diagnostics + + +def inline_programs(mob_vnum, programs): + """Translate a mob's inline ``(trigger, argument, commands)`` programs to ROM 2.4 triggers and syntax.""" + entries = [] + diagnostics = [] + for name, phrase, commands in programs: + trigger = INLINE_TRIGGERS.get(name.lower()) + if trigger is None: + trigger = name.upper() + diagnostics.append({"kind": "unknown_mobprog_trigger", "mob": mob_vnum, "trigger": trigger}) + if trigger in PHRASE_TRIGGERS: + if phrase[:2].lower() == "p ": + phrase = phrase[2:] + elif len(phrase.split()) > 1: + # A list of words, any of which fires the program: one ROM phrase cannot say that. + diagnostics.append( + {"kind": "untranslated_mobprog_phrase", "mob": mob_vnum, "trigger": trigger, "phrase": phrase} + ) + lines = [] + for line in program_lines(commands): + translated = translate_line(line) + if translated is None: + diagnostics.append( + {"kind": "untranslated_mobprog_line", "mob": mob_vnum, "trigger": trigger, "line": line} + ) + translated = line + lines.append(translated) + entries.append({"trigger": trigger, "phrase": phrase, "lines": lines, "source": "inline", "vnum": None}) + return entries, diagnostics diff --git a/area_reader/parser.py b/area_reader/parser.py index 49ede61..782a9a5 100644 --- a/area_reader/parser.py +++ b/area_reader/parser.py @@ -8,6 +8,7 @@ from attr import fields +import area_reader.mobprogs import area_reader.model import area_reader.serialization import area_reader.values @@ -403,9 +404,31 @@ def parse_fail(self, message): def surrounding_text(self, window=50): return self.data[self.index - window : self.index + window] + def inline_mob_programs(self, mob): + """Return ``(trigger, argument, commands)`` for each program written inline on ``mob``.""" + del mob + return [] + + def mob_programs(self): + """Return each mob's programs in the normalized ROM 2.4 shape, by vnum, and their diagnostics.""" + programs = {} + diagnostics = [] + for vnum, mob in self.area.mobs.items(): + joined, missing = area_reader.mobprogs.rom_programs( + vnum, getattr(mob, "mprogs", None) or [], self.area.mobprogs + ) + translated, untranslated = area_reader.mobprogs.inline_programs(vnum, self.inline_mob_programs(mob)) + programs[vnum] = joined + translated + diagnostics.extend(missing) + diagnostics.extend(untranslated) + return programs, diagnostics + def as_dict(self): result = area_reader.serialization.EnumNameConverter().unstructure(self.area) - result["diagnostics"] = [dict(diagnostic) for diagnostic in self.diagnostics] + programs, diagnostics = self.mob_programs() + for vnum, entries in programs.items(): + result["mobs"][vnum]["mob_programs"] = entries + result["diagnostics"] = [dict(diagnostic) for diagnostic in self.diagnostics] + diagnostics return result def as_json(self, indent=None): diff --git a/test_mob_programs.py b/test_mob_programs.py new file mode 100644 index 0000000..90f0eba --- /dev/null +++ b/test_mob_programs.py @@ -0,0 +1,413 @@ +"""Mob programs are serialized in one ROM 2.4-shaped list per mob, whatever the source dialect.""" + +import json +from pathlib import Path + +import pytest + +import area_reader.dialects.ack +import area_reader.dialects.rom +import area_reader.dialects.smaug +import area_reader.dialects.swr +import area_reader.mobprogs + +ROM_MOBILE = """imp~ +an imp~ +An imp grins broadly. +~ +An imp. +~ +elf~ +AGU J 0 -500 0 +30 60 9d61+416 1d1+999 5d6+15 claw +-9 -9 -9 -9 +0 0 B Z +stand stand male 1500 +AHMV ABCDEFGHIJK small unknown +""" +ROM_HEADER = "#AREADATA\nName Programs~\nBuilders None~\nVNUMs 100 199\nCredits Programs~\nSecurity 9\nEnd\n" +ROM_JOINED = ( + ROM_HEADER + + "#MOBILES\n#100\n" + + ROM_MOBILE + + "M GRALL 100 100~\nM random 101 15~\n#101\n" + + ROM_MOBILE + + "#0\n#MOBPROGS\n#100\nmob echo The imp cackles.\nmob cast fireball $n\n~\n" + + "#101\nif rand 50\n grin\nendif\n\n\n~\n#0\n#$\n" +) +ROM_MISSING = ( + ROM_HEADER + + "#MOBILES\n#100\n" + + ROM_MOBILE + + "M GREET 100 100~\nM SPEECH 150 hello~\n#101\n" + + ROM_MOBILE + + "M DEATH 151 100~\n#0\n#MOBPROGS\n#100\nsay hello\n~\n#0\n#$\n" +) +SMAUG_MOBILE = "mob~\na mob~\nA mob is here.~\nA mob.~\n1 2 3 S\n4 5 6 1d2+3 2d3+4\n7 8\n9 10 1\n" + + +def load(reader_class, tmp_path: Path, text: str): + path = tmp_path / "area.are" + path.write_text(text, encoding="latin-1") + area_file = reader_class(path) + area_file.load_sections() + return area_file + + +def load_rom(tmp_path: Path, text: str) -> area_reader.dialects.rom.RomAreaFile: + return load(area_reader.dialects.rom.RomAreaFile, tmp_path, text) + + +def load_smaug_programs(tmp_path: Path, programs: str) -> area_reader.dialects.smaug.SmaugAreaFile: + text = "#AREA Inline~\n#MOBILES\n#7\n" + SMAUG_MOBILE + programs + "|\n#0\n#$\n" + return load(area_reader.dialects.smaug.SmaugAreaFile, tmp_path, text) + + +def test_rom_triggers_are_joined_with_their_program_bodies(tmp_path: Path) -> None: + payload = load_rom(tmp_path, ROM_JOINED).as_dict() + + assert payload["mobs"][100]["mob_programs"] == [ + { + "trigger": "GRALL", + "phrase": "100", + "lines": ["mob echo The imp cackles.", "mob cast fireball $n"], + "source": "rom", + "vnum": 100, + }, + {"trigger": "RANDOM", "phrase": "15", "lines": ["if rand 50", " grin", "endif"], "source": "rom", "vnum": 101}, + ] + assert payload["mobs"][101]["mob_programs"] == [] + assert payload["diagnostics"] == [] + + +def test_normalized_programs_leave_the_existing_keys_and_the_native_text_alone(tmp_path: Path) -> None: + area_file = load_rom(tmp_path, ROM_JOINED) + rendered = area_file.dumps() + + payload = json.loads(area_file.as_json()) + + assert payload["mobs"]["100"]["mprogs"] == [ + {"trig_type": "GRALL", "vnum": 100, "trig_phrase": "100"}, + {"trig_type": "random", "vnum": 101, "trig_phrase": "15"}, + ] + assert payload["mobprogs"] == { + "100": "mob echo The imp cackles.\nmob cast fireball $n\n", + "101": "if rand 50\n grin\nendif\n\n\n", + } + assert area_file.dumps() == rendered + assert "mob_programs" not in rendered + assert load_rom(tmp_path, rendered).area == area_file.area + + +def test_a_trigger_without_a_program_body_is_reported(tmp_path: Path) -> None: + area_file = load_rom(tmp_path, ROM_MISSING) + + payload = area_file.as_dict() + + assert payload["mobs"][100]["mob_programs"] == [ + {"trigger": "GREET", "phrase": "100", "lines": ["say hello"], "source": "rom", "vnum": 100}, + {"trigger": "SPEECH", "phrase": "hello", "lines": [], "source": "rom", "vnum": 150}, + ] + assert payload["mobs"][101]["mob_programs"] == [ + {"trigger": "DEATH", "phrase": "100", "lines": [], "source": "rom", "vnum": 151}, + ] + assert payload["diagnostics"] == [ + {"kind": "missing_mobprog", "mob": 100, "trigger": "SPEECH", "vnum": 150}, + {"kind": "missing_mobprog", "mob": 101, "trigger": "DEATH", "vnum": 151}, + ] + assert area_file.as_dict()["diagnostics"] == payload["diagnostics"] + + +def test_inline_programs_are_translated_to_rom_syntax(tmp_path: Path) -> None: + area_file = load_smaug_programs( + tmp_path, + "> all_greet_prog 100~\n" + "if name($n) == loki\n" + "scream\n" + "mpforce loki snarl\n" + "else\n" + " if ispc($n)\n" + " mpe $I frowns.\n" + " endif\n" + "endif\n" + "~\n" + "> fight_prog 40~\n" + "if rand(10)\n" + "mpasound A thunderous cry can be heard. \n" + "mpmload 12012\n" + "mpdamage $n 125\n" + "endif\n" + "~\n", + ) + rendered = area_file.dumps() + + payload = area_file.as_dict() + + assert payload["mobs"][7]["mob_programs"] == [ + { + "trigger": "GRALL", + "phrase": "100", + "lines": [ + "if name $n loki", + "scream", + "mob force loki snarl", + "else", + " if ispc $n", + " mob echo $I frowns.", + " endif", + "endif", + ], + "source": "inline", + "vnum": None, + }, + { + "trigger": "FIGHT", + "phrase": "40", + "lines": [ + "if rand 10", + "mob asound A thunderous cry can be heard. ", + "mob mload 12012", + "mob damage $n 125 125 kill", + "endif", + ], + "source": "inline", + "vnum": None, + }, + ] + assert payload["diagnostics"] == [] + assert payload["mobs"][7]["mprogs"] == [] + assert [program["trigger"] for program in payload["mobs"][7]["programs"]] == ["all_greet_prog", "fight_prog"] + assert payload["mobs"][7]["programs"][1]["commands"].startswith("if rand(10)\nmpasound A thunderous") + assert area_file.dumps() == rendered + + +@pytest.mark.parametrize( + ("name", "trigger"), + [ + ("act_prog", "ACT"), + ("bribe_prog", "BRIBE"), + ("death_prog", "DEATH"), + ("entry_prog", "ENTRY"), + ("fight_prog", "FIGHT"), + ("give_prog", "GIVE"), + ("greet_prog", "GREET"), + ("all_greet_prog", "GRALL"), + ("hitprcnt_prog", "HPCNT"), + ("rand_prog", "RANDOM"), + ("speech_prog", "SPEECH"), + ("GREET_PROG", "GREET"), + ], +) +def test_inline_trigger_names_map_to_rom_trigger_words(name: str, trigger: str) -> None: + entries, diagnostics = area_reader.mobprogs.inline_programs(7, [(name, "100", "say hi\n")]) + + assert entries == [{"trigger": trigger, "phrase": "100", "lines": ["say hi"], "source": "inline", "vnum": None}] + assert diagnostics == [] + + +@pytest.mark.parametrize( + ("line", "translated"), + [ + ("mpe The room shakes.", "mob echo The room shakes."), + ("mpecho $I cackles.", "mob echo $I cackles."), + ("MPECHO changed", "mob echo changed"), + ("mea $n You are struck!", "mob echoat $n You are struck!"), + ("mpechoat $r $I smashes you!", "mob echoat $r $I smashes you!"), + ("mer $n $I slams into $n!", "mob echoaround $n $I slams into $n!"), + ("mpechoar $n $n bursts into flames!", "mob echoaround $n $n bursts into flames!"), + ("mpechoaround $n $I shrieks.", "mob echoaround $n $I shrieks."), + ("mpasound A horn sounds.", "mob asound A horn sounds."), + ("mpforce $n drop all", "mob force $n drop all"), + ("mpat 3 wield ebony", "mob at 3 wield ebony"), + ("mpat 3 mpe $n vanishes.", "mob at 3 mob echo $n vanishes."), + ("mpat thor mpforce thor up", "mob at thor mob force thor up"), + ("mpat $n mpat $n mea $n Ouch.", "mob at $n mob at $n mob echoat $n Ouch."), + ("mpoload 12039 50", "mob oload 12039 50"), + ("mpmload 12012", "mob mload 12012"), + (" mpmload 25011", " mob mload 25011"), + ("mpgoto 12097", "mob goto 12097"), + ("mpgot 12797", "mob goto 12797"), + ("mptrans $n 12000", "mob transfer $n 12000"), + ("mptransfer 0.$n", "mob transfer 0.$n"), + ("mpjunk all", "mob junk all"), + ("mppurge", "mob purge"), + ("mppurge skirnir", "mob purge skirnir"), + ("mpkill $n", "mob kill $n"), + ("mpdamage $r 400", "mob damage $r 400 400 kill"), + ("if rand(25)", "if rand 25"), + ("if ispc($r)", "if ispc $r"), + ("if isnpc($n)", "if isnpc $n"), + ("if isimmort($n)", "if isimmort $n"), + ("if isgood($n)", "if isgood $n"), + (" if isevil($n)", " if isevil $n"), + ("or ispc($n)", "or ispc $n"), + ("and rand(10)", "and rand 10"), + ("IF ISPC($n)", "if ispc $n"), + ("if name($n) == loki", "if name $n loki"), + ("if name($n) == fire giant", "if name $n 'fire giant'"), + ("if name($n) == witch tus'schepteba", 'if name $n "witch tus\'schepteba"'), + ("if clan($n) == Dragonslayer", "if clan $n Dragonslayer"), + ("if race($n) == elf", "if race $n elf"), + ("if class($n) == mage", "if class $n mage"), + ("if sex($n) == 2", "if sex $n == 2"), + ("if level($n) < 30", "if level $n < 30"), + ("if inroom($i) == 12099", "if room $i == 12099"), + ("say mpe is not a command here", "say mpe is not a command here"), + ("c 'fireball' $n", "c 'fireball' $n"), + (", brings Gjaller to his lips.", ", brings Gjaller to his lips."), + ("else", "else"), + ("endif", "endif"), + ("", ""), + ], +) +def test_inline_lines_are_rewritten_as_rom_mob_commands(line: str, translated: str) -> None: + assert area_reader.mobprogs.translate_line(line) == translated + + +@pytest.mark.parametrize( + "line", + [ + "mprestore self 50", + "mpslay $n", + "mpopenpassage 12180 12181 2", + "mpclosepassage 12180 2", + "mpea Just as your blows begin to tell on $I, his wounds start to close!", + "mpe _red A burly berserker warrior charges to aid Heimdall!", + "mea $n _yel You fall into the pit!", + "mer $n *gre With a cry, $n falls into the pit!", + "mpat 3 mprestore self 50", + "mpforce thor mpslay $n", + "mpdamage $n", + "mpdamage $n lots", + "if goldamt($n) > 20000000", + "if con($n) < 15", + "if mobinarea(25011) >= 1", + "if name($n) != loki", + "if name($n) == both ' and \"", + "if ispc(loki)", + "if ispc($n) == 1", + "if rand(ten)", + "if level($n) / 3", + "if level($n) > high", + "if ispc $n", + ], +) +def test_lines_without_a_confident_rom_form_are_not_translated(line: str) -> None: + assert area_reader.mobprogs.translate_line(line) is None + + +def test_untranslated_lines_are_kept_and_reported(tmp_path: Path) -> None: + area_file = load_smaug_programs( + tmp_path, + "> hitprcnt_prog 50~\n" + "mpe $I staggers.\n" + "mprestore self 500\n" + "if con($n) < 15\n" + "mpe _red Blood sprays.\n" + "endif\n" + "~\n", + ) + + payload = area_file.as_dict() + + assert payload["mobs"][7]["mob_programs"] == [ + { + "trigger": "HPCNT", + "phrase": "50", + "lines": [ + "mob echo $I staggers.", + "mprestore self 500", + "if con($n) < 15", + "mpe _red Blood sprays.", + "endif", + ], + "source": "inline", + "vnum": None, + }, + ] + assert payload["diagnostics"] == [ + {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "HPCNT", "line": "mprestore self 500"}, + {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "HPCNT", "line": "if con($n) < 15"}, + {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "HPCNT", "line": "mpe _red Blood sprays."}, + ] + + +def test_an_inline_trigger_without_a_rom_equivalent_keeps_its_name(tmp_path: Path) -> None: + area_file = load_smaug_programs(tmp_path, "> time_prog 12~\nmpe The bell tolls.\n~\n") + + payload = area_file.as_dict() + + assert payload["mobs"][7]["mob_programs"] == [ + { + "trigger": "TIME_PROG", + "phrase": "12", + "lines": ["mob echo The bell tolls."], + "source": "inline", + "vnum": None, + }, + ] + assert payload["diagnostics"] == [{"kind": "unknown_mobprog_trigger", "mob": 7, "trigger": "TIME_PROG"}] + + +def test_inline_phrases_drop_the_exact_phrase_marker_and_report_word_lists(tmp_path: Path) -> None: + area_file = load_smaug_programs( + tmp_path, + "> act_prog p pokes you in the ribs.~\ngrin\n~\n" + "> speech_prog p i wish to see the baron~\nnod\n~\n" + "> act_prog flees~\ncackle\n~\n" + "> speech_prog necromancer witch~\nspit\n~\n" + "> give_prog rotting heart necromancer~\nsmile\n~\n", + ) + + payload = area_file.as_dict() + + assert [(entry["trigger"], entry["phrase"]) for entry in payload["mobs"][7]["mob_programs"]] == [ + ("ACT", "pokes you in the ribs."), + ("SPEECH", "i wish to see the baron"), + ("ACT", "flees"), + ("SPEECH", "necromancer witch"), + ("GIVE", "rotting heart necromancer"), + ] + assert payload["diagnostics"] == [ + {"kind": "untranslated_mobprog_phrase", "mob": 7, "trigger": "SPEECH", "phrase": "necromancer witch"}, + ] + + +def test_ack_inline_programs_are_normalized() -> None: + area_file = area_reader.dialects.ack.AckAreaFile(Path("test/ack/ack_sample.are")) + area_file.load_sections() + + payload = area_file.as_dict() + + programs = payload["mobs"][9623]["mob_programs"] + assert [(entry["trigger"], entry["source"], entry["vnum"]) for entry in programs] == [ + ("ACT", "inline", None), + ("DEATH", "inline", None), + ] + assert programs[0]["phrase"] == "opens the trapdoor." + assert [program.trigger for program in area_file.area.mobs[9623].mobprogs] == ["act_prog", "death_prog"] + assert payload["mobs"][16003]["mob_programs"] == [] + assert programs[1]["lines"] == [ + "mob echo Ymmas crashes to the ground, and errupts into a mass of spiders!", + " mob mload 9624", + ] + assert payload["diagnostics"] == [] + + +def test_fuss_inline_programs_are_normalized(tmp_path: Path) -> None: + area_file = load( + area_reader.dialects.swr.SwrAreaFile, + tmp_path, + "#FUSSAREA\n#AREADATA\nVersion 1\nName Programs~\n#ENDAREADATA\n\n" + "#MOBILE\nVnum 5\nKeywords test mob~\n" + "#MUDPROG\nProgtype greet_prog~\nArglist 100~\nComlist mpecho $I nods.\n~\n#ENDPROG\n\n" + "#ENDMOBILE\n\n#ENDAREA\n", + ) + + payload = area_file.as_dict() + + assert payload["mobs"][5]["mob_programs"] == [ + {"trigger": "GREET", "phrase": "100", "lines": ["mob echo $I nods."], "source": "inline", "vnum": None}, + ] + assert payload["mobs"][5]["programs"][0]["progtype"] == "greet_prog" + assert payload["diagnostics"] == [] From 9db2299eee06b598df72085927d426da4979f821 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 5 Oct 2026 19:15:49 -0600 Subject: [PATCH 2/4] Hold area_reader.mobprogs to the shared-foundations import contract --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f12e4b8..b50eddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ name = "Shared foundations do not depend on dialects" type = "forbidden" source_modules = [ "area_reader.constants", + "area_reader.mobprogs", "area_reader.model", "area_reader.native", "area_reader.parser", From 980e8044f9d4659c05ee18b7883ed7cfff2b66b5 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 5 Oct 2026 19:37:16 -0600 Subject: [PATCH 3/4] Translate SMAUG echo colours and split word-list phrases A SMAUG colour token on an echo becomes the ROM brace code, closed with {x. An ACT or SPEECH word list becomes one entry per word; every entry carries group, the index of its source program on the mob. --- area_reader/mobprogs.py | 58 +++++++++++++++++++++----- test_mob_programs.py | 92 ++++++++++++++++++++++++++++++++--------- 2 files changed, 121 insertions(+), 29 deletions(-) diff --git a/area_reader/mobprogs.py b/area_reader/mobprogs.py index f1c3a81..6a90dc9 100644 --- a/area_reader/mobprogs.py +++ b/area_reader/mobprogs.py @@ -49,6 +49,16 @@ # SMAUG's get_color(): a token starting "_" (or "*", blinking) found in these lists is a colour, not text. SMAUG_COLOURS = "_bla_red_dgr_bro_dbl_pur_cya_cha_dch_ora_gre_yel_blu_pin_lbl_whi" SMAUG_BLINK_COLOURS = SMAUG_COLOURS.replace("_", "*") +# SMAUG colour tokens and the ROM brace code for the same colour; the text is closed with "{x". +COLOUR_CODES = { + "_blu": "{B", + "_dch": "{D", + "_gre": "{G", + "_lbl": "{C", + "_red": "{r", + "_whi": "{W", + "_yel": "{Y", +} # ROM 2.4 if-check grammar: "check value", "check $actor", "check $actor value", "check $actor operator number". VALUE_CHECKS = frozenset({"rand"}) @@ -65,6 +75,11 @@ NUMBER = re.compile(r"-?\d+") DAMAGE_ARGUMENTS = re.compile(r"\s+(\S+)\s+(\d+)\s*") NESTED_COMMAND = re.compile(r"(\s+\S+\s+)(\S.*)", re.DOTALL) +# An echo command's arguments split into those before the text, the first word of the text, and the rest. +COLOURED_TEXT = { + command: re.compile(rf"(\s+(?:\S+\s+){{{targets}}})(\S+)\s*(.*)", re.DOTALL) + for command, targets in ECHO_TARGETS.items() +} def program_lines(code): @@ -141,9 +156,12 @@ def translate_line(line): return line command = MOB_COMMANDS[word] if command in ECHO_TARGETS: - arguments = rest.split() - if len(arguments) > ECHO_TARGETS[command] and is_smaug_colour(arguments[ECHO_TARGETS[command]]): - return None + coloured = COLOURED_TEXT[command].fullmatch(rest) + if coloured is not None and is_smaug_colour(coloured.group(2)): + code = COLOUR_CODES.get(coloured.group(2).lower()) + if code is None: + return None + rest = f"{coloured.group(1)}{code}{coloured.group(3)}{{x" if command == "damage": # SMAUG's "mpdamage victim amount" can kill; ROM's form is "mob damage victim min max kill". damage = DAMAGE_ARGUMENTS.fullmatch(rest) @@ -165,7 +183,7 @@ def rom_programs(mob_vnum, mprogs, mobprogs): """Join a mob's ROM ``M`` trigger records with their ``#MOBPROGS`` bodies.""" entries = [] diagnostics = [] - for mprog in mprogs: + for group, mprog in enumerate(mprogs): trigger = mprog.trig_type.upper() lines = [] if mprog.vnum in mobprogs: @@ -173,7 +191,14 @@ def rom_programs(mob_vnum, mprogs, mobprogs): else: diagnostics.append({"kind": "missing_mobprog", "mob": mob_vnum, "trigger": trigger, "vnum": mprog.vnum}) entries.append( - {"trigger": trigger, "phrase": mprog.trig_phrase, "lines": lines, "source": "rom", "vnum": mprog.vnum} + { + "trigger": trigger, + "phrase": mprog.trig_phrase, + "lines": lines, + "source": "rom", + "vnum": mprog.vnum, + "group": group, + } ) return entries, diagnostics @@ -182,16 +207,19 @@ def inline_programs(mob_vnum, programs): """Translate a mob's inline ``(trigger, argument, commands)`` programs to ROM 2.4 triggers and syntax.""" entries = [] diagnostics = [] - for name, phrase, commands in programs: + for group, (name, phrase, commands) in enumerate(programs): trigger = INLINE_TRIGGERS.get(name.lower()) if trigger is None: trigger = name.upper() diagnostics.append({"kind": "unknown_mobprog_trigger", "mob": mob_vnum, "trigger": trigger}) + phrases = [phrase] if trigger in PHRASE_TRIGGERS: if phrase[:2].lower() == "p ": - phrase = phrase[2:] - elif len(phrase.split()) > 1: - # A list of words, any of which fires the program: one ROM phrase cannot say that. + phrases = [phrase[2:]] + elif phrase.split(): + # A list of words, any of which fires the program: one ROM entry per word, in one group. + phrases = phrase.split() + else: diagnostics.append( {"kind": "untranslated_mobprog_phrase", "mob": mob_vnum, "trigger": trigger, "phrase": phrase} ) @@ -204,5 +232,15 @@ def inline_programs(mob_vnum, programs): ) translated = line lines.append(translated) - entries.append({"trigger": trigger, "phrase": phrase, "lines": lines, "source": "inline", "vnum": None}) + for phrase in phrases: + entries.append( + { + "trigger": trigger, + "phrase": phrase, + "lines": list(lines), + "source": "inline", + "vnum": None, + "group": group, + } + ) return entries, diagnostics diff --git a/test_mob_programs.py b/test_mob_programs.py index 90f0eba..8ba3883 100644 --- a/test_mob_programs.py +++ b/test_mob_programs.py @@ -73,8 +73,16 @@ def test_rom_triggers_are_joined_with_their_program_bodies(tmp_path: Path) -> No "lines": ["mob echo The imp cackles.", "mob cast fireball $n"], "source": "rom", "vnum": 100, + "group": 0, + }, + { + "trigger": "RANDOM", + "phrase": "15", + "lines": ["if rand 50", " grin", "endif"], + "source": "rom", + "vnum": 101, + "group": 1, }, - {"trigger": "RANDOM", "phrase": "15", "lines": ["if rand 50", " grin", "endif"], "source": "rom", "vnum": 101}, ] assert payload["mobs"][101]["mob_programs"] == [] assert payload["diagnostics"] == [] @@ -105,11 +113,11 @@ def test_a_trigger_without_a_program_body_is_reported(tmp_path: Path) -> None: payload = area_file.as_dict() assert payload["mobs"][100]["mob_programs"] == [ - {"trigger": "GREET", "phrase": "100", "lines": ["say hello"], "source": "rom", "vnum": 100}, - {"trigger": "SPEECH", "phrase": "hello", "lines": [], "source": "rom", "vnum": 150}, + {"trigger": "GREET", "phrase": "100", "lines": ["say hello"], "source": "rom", "vnum": 100, "group": 0}, + {"trigger": "SPEECH", "phrase": "hello", "lines": [], "source": "rom", "vnum": 150, "group": 1}, ] assert payload["mobs"][101]["mob_programs"] == [ - {"trigger": "DEATH", "phrase": "100", "lines": [], "source": "rom", "vnum": 151}, + {"trigger": "DEATH", "phrase": "100", "lines": [], "source": "rom", "vnum": 151, "group": 0}, ] assert payload["diagnostics"] == [ {"kind": "missing_mobprog", "mob": 100, "trigger": "SPEECH", "vnum": 150}, @@ -159,6 +167,7 @@ def test_inline_programs_are_translated_to_rom_syntax(tmp_path: Path) -> None: ], "source": "inline", "vnum": None, + "group": 0, }, { "trigger": "FIGHT", @@ -172,6 +181,7 @@ def test_inline_programs_are_translated_to_rom_syntax(tmp_path: Path) -> None: ], "source": "inline", "vnum": None, + "group": 1, }, ] assert payload["diagnostics"] == [] @@ -201,7 +211,9 @@ def test_inline_programs_are_translated_to_rom_syntax(tmp_path: Path) -> None: def test_inline_trigger_names_map_to_rom_trigger_words(name: str, trigger: str) -> None: entries, diagnostics = area_reader.mobprogs.inline_programs(7, [(name, "100", "say hi\n")]) - assert entries == [{"trigger": trigger, "phrase": "100", "lines": ["say hi"], "source": "inline", "vnum": None}] + assert entries == [ + {"trigger": trigger, "phrase": "100", "lines": ["say hi"], "source": "inline", "vnum": None, "group": 0}, + ] assert diagnostics == [] @@ -252,6 +264,19 @@ def test_inline_trigger_names_map_to_rom_trigger_words(name: str, trigger: str) ("if sex($n) == 2", "if sex $n == 2"), ("if level($n) < 30", "if level $n < 30"), ("if inroom($i) == 12099", "if room $i == 12099"), + ("mpe _red A burly berserker charges!", "mob echo {rA burly berserker charges!{x"), + ("mpe _gre Vines writhe.", "mob echo {GVines writhe.{x"), + ("mpe _yel Sparks fly.", "mob echo {YSparks fly.{x"), + ("mpe _lbl Frost spreads.", "mob echo {CFrost spreads.{x"), + ("mpe _whi A thunderous cry.", "mob echo {WA thunderous cry.{x"), + ("mpe _blu Lightning surges.", "mob echo {BLightning surges.{x"), + ("mpe _dch Shadows gather.", "mob echo {DShadows gather.{x"), + ("MPE _RED Blood sprays.", "mob echo {rBlood sprays.{x"), + ("mea $n _yel You fall into the pit!", "mob echoat $n {YYou fall into the pit!{x"), + ("mer $n _red $I slams into $n!", "mob echoaround $n {r$I slams into $n!{x"), + ("mpat 3 mpe _whi A horn sounds.", "mob at 3 mob echo {WA horn sounds.{x"), + ("mpe _not a colour", "mob echo _not a colour"), + ("mea $n *sigh* You hear a sigh.", "mob echoat $n *sigh* You hear a sigh."), ("say mpe is not a command here", "say mpe is not a command here"), ("c 'fireball' $n", "c 'fireball' $n"), (", brings Gjaller to his lips.", ", brings Gjaller to his lips."), @@ -272,8 +297,8 @@ def test_inline_lines_are_rewritten_as_rom_mob_commands(line: str, translated: s "mpopenpassage 12180 12181 2", "mpclosepassage 12180 2", "mpea Just as your blows begin to tell on $I, his wounds start to close!", - "mpe _red A burly berserker warrior charges to aid Heimdall!", - "mea $n _yel You fall into the pit!", + "mpe _pur A violet haze descends.", + "mea $n _ora You are burned!", "mer $n *gre With a cry, $n falls into the pit!", "mpat 3 mprestore self 50", "mpforce thor mpslay $n", @@ -304,6 +329,7 @@ def test_untranslated_lines_are_kept_and_reported(tmp_path: Path) -> None: "mprestore self 500\n" "if con($n) < 15\n" "mpe _red Blood sprays.\n" + "mpe _pur A violet haze descends.\n" "endif\n" "~\n", ) @@ -318,17 +344,19 @@ def test_untranslated_lines_are_kept_and_reported(tmp_path: Path) -> None: "mob echo $I staggers.", "mprestore self 500", "if con($n) < 15", - "mpe _red Blood sprays.", + "mob echo {rBlood sprays.{x", + "mpe _pur A violet haze descends.", "endif", ], "source": "inline", "vnum": None, + "group": 0, }, ] assert payload["diagnostics"] == [ {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "HPCNT", "line": "mprestore self 500"}, {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "HPCNT", "line": "if con($n) < 15"}, - {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "HPCNT", "line": "mpe _red Blood sprays."}, + {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "HPCNT", "line": "mpe _pur A violet haze descends."}, ] @@ -344,32 +372,51 @@ def test_an_inline_trigger_without_a_rom_equivalent_keeps_its_name(tmp_path: Pat "lines": ["mob echo The bell tolls."], "source": "inline", "vnum": None, + "group": 0, }, ] assert payload["diagnostics"] == [{"kind": "unknown_mobprog_trigger", "mob": 7, "trigger": "TIME_PROG"}] -def test_inline_phrases_drop_the_exact_phrase_marker_and_report_word_lists(tmp_path: Path) -> None: +def test_inline_phrases_drop_the_exact_phrase_marker_and_split_word_lists(tmp_path: Path) -> None: area_file = load_smaug_programs( tmp_path, "> act_prog p pokes you in the ribs.~\ngrin\n~\n" "> speech_prog p i wish to see the baron~\nnod\n~\n" "> act_prog flees~\ncackle\n~\n" - "> speech_prog necromancer witch~\nspit\n~\n" + "> speech_prog witch necromancer witch ~\nspit\nmpslay $n\n~\n" "> give_prog rotting heart necromancer~\nsmile\n~\n", ) payload = area_file.as_dict() - assert [(entry["trigger"], entry["phrase"]) for entry in payload["mobs"][7]["mob_programs"]] == [ - ("ACT", "pokes you in the ribs."), - ("SPEECH", "i wish to see the baron"), - ("ACT", "flees"), - ("SPEECH", "necromancer witch"), - ("GIVE", "rotting heart necromancer"), + programs = payload["mobs"][7]["mob_programs"] + assert [(entry["trigger"], entry["phrase"], entry["group"]) for entry in programs] == [ + ("ACT", "pokes you in the ribs.", 0), + ("SPEECH", "i wish to see the baron", 1), + ("ACT", "flees", 2), + ("SPEECH", "witch", 3), + ("SPEECH", "necromancer", 3), + ("SPEECH", "witch", 3), + ("GIVE", "rotting heart necromancer", 4), + ] + assert [entry["lines"] for entry in programs[3:6]] == [["spit", "mpslay $n"]] * 3 + assert payload["diagnostics"] == [ + {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "SPEECH", "line": "mpslay $n"}, + ] + assert payload["mobs"][7]["programs"][3]["argument"] == "witch necromancer witch " + + +def test_an_inline_phrase_trigger_without_a_phrase_is_kept_and_reported(tmp_path: Path) -> None: + area_file = load_smaug_programs(tmp_path, "> speech_prog ~\nnod\n~\n") + + payload = area_file.as_dict() + + assert payload["mobs"][7]["mob_programs"] == [ + {"trigger": "SPEECH", "phrase": "", "lines": ["nod"], "source": "inline", "vnum": None, "group": 0}, ] assert payload["diagnostics"] == [ - {"kind": "untranslated_mobprog_phrase", "mob": 7, "trigger": "SPEECH", "phrase": "necromancer witch"}, + {"kind": "untranslated_mobprog_phrase", "mob": 7, "trigger": "SPEECH", "phrase": ""}, ] @@ -407,7 +454,14 @@ def test_fuss_inline_programs_are_normalized(tmp_path: Path) -> None: payload = area_file.as_dict() assert payload["mobs"][5]["mob_programs"] == [ - {"trigger": "GREET", "phrase": "100", "lines": ["mob echo $I nods."], "source": "inline", "vnum": None}, + { + "trigger": "GREET", + "phrase": "100", + "lines": ["mob echo $I nods."], + "source": "inline", + "vnum": None, + "group": 0, + }, ] assert payload["mobs"][5]["programs"][0]["progtype"] == "greet_prog" assert payload["diagnostics"] == [] From c0ffd332026450ce9b1b3b1a391e1d84dad6bfaa Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Mon, 5 Oct 2026 19:43:13 -0600 Subject: [PATCH 4/4] Collect mob program diagnostics with the parse diagnostics load_sections reads the sections, then appends the mob program diagnostics to reader.diagnostics, the one list as_dict serializes. The per-dialect section loops are now read_sections. --- area_reader/dialects/smaug.py | 2 +- area_reader/dialects/swr.py | 4 ++-- area_reader/parser.py | 10 +++++++--- test_mob_programs.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/area_reader/dialects/smaug.py b/area_reader/dialects/smaug.py index dbbd979..0909895 100644 --- a/area_reader/dialects/smaug.py +++ b/area_reader/dialects/smaug.py @@ -71,7 +71,7 @@ def read_smaug_programs(self): def inline_mob_programs(self, mob): return [(program.trigger, program.argument, program.commands) for program in mob.programs] - def load_sections(self): + def read_sections(self): readers = { "area": self.read_area_metadata, "author": self.load_author, diff --git a/area_reader/dialects/swr.py b/area_reader/dialects/swr.py index 7622856..407e983 100644 --- a/area_reader/dialects/swr.py +++ b/area_reader/dialects/swr.py @@ -39,7 +39,7 @@ def write(self, path): with open(path, mode="wt", encoding="latin-1", newline="\n") as area_file: area_file.write(self.dumps()) - def load_sections(self): + def read_sections(self): self.skip_whitespace() if self.data.startswith("#FUSSAREA", self.index): self.read_section_name() @@ -54,7 +54,7 @@ def load_sections(self): for mob in self.load_smaug_vnum_section(area_reader.dialects.smaug.SmaugMob): self.store_vnum("mobs", mob.vnum, mob) return - super().load_sections() + super().read_sections() def load_mobiles(self): for mob in self.load_swr_vnum_section(area_reader.dialects.smaug.SmaugMob): diff --git a/area_reader/parser.py b/area_reader/parser.py index 782a9a5..087e2ce 100644 --- a/area_reader/parser.py +++ b/area_reader/parser.py @@ -280,6 +280,11 @@ def section_readers(self): } def load_sections(self): + self.read_sections() + # Program bodies and mobiles arrive in separate sections, so their diagnostics wait for the whole file. + self.diagnostics.extend(self.mob_programs()[1]) + + def read_sections(self): readers = self.section_readers() while True: section_name = self.read_section_name() @@ -425,10 +430,9 @@ def mob_programs(self): def as_dict(self): result = area_reader.serialization.EnumNameConverter().unstructure(self.area) - programs, diagnostics = self.mob_programs() - for vnum, entries in programs.items(): + for vnum, entries in self.mob_programs()[0].items(): result["mobs"][vnum]["mob_programs"] = entries - result["diagnostics"] = [dict(diagnostic) for diagnostic in self.diagnostics] + diagnostics + result["diagnostics"] = [dict(diagnostic) for diagnostic in self.diagnostics] return result def as_json(self, indent=None): diff --git a/test_mob_programs.py b/test_mob_programs.py index 8ba3883..54f1a53 100644 --- a/test_mob_programs.py +++ b/test_mob_programs.py @@ -124,6 +124,35 @@ def test_a_trigger_without_a_program_body_is_reported(tmp_path: Path) -> None: {"kind": "missing_mobprog", "mob": 101, "trigger": "DEATH", "vnum": 151}, ] assert area_file.as_dict()["diagnostics"] == payload["diagnostics"] + assert area_file.diagnostics == payload["diagnostics"] + + +def test_mob_program_diagnostics_are_collected_with_the_parse_diagnostics(tmp_path: Path) -> None: + rom = load_rom(tmp_path, ROM_MISSING.replace("#$\n", "#SOCIALS\nsmile~\n#$\n")) + smaug = load_smaug_programs(tmp_path, "> time_prog 12~\nmpslay $n\n~\n") + fuss = load( + area_reader.dialects.swr.SwrAreaFile, + tmp_path, + "#FUSSAREA\n#AREADATA\nVersion 1\nName Programs~\n#ENDAREADATA\n\n" + "#MOBILE\nVnum 5\nKeywords test mob~\n" + "#MUDPROG\nProgtype greet_prog~\nArglist 100~\nComlist mprestore self 50\n~\n#ENDPROG\n\n" + "#ENDMOBILE\n\n#ENDAREA\n", + ) + + assert rom.diagnostics == [ + {"kind": "skipped_section", "section": "socials"}, + {"kind": "missing_mobprog", "mob": 100, "trigger": "SPEECH", "vnum": 150}, + {"kind": "missing_mobprog", "mob": 101, "trigger": "DEATH", "vnum": 151}, + ] + assert smaug.diagnostics == [ + {"kind": "unknown_mobprog_trigger", "mob": 7, "trigger": "TIME_PROG"}, + {"kind": "untranslated_mobprog_line", "mob": 7, "trigger": "TIME_PROG", "line": "mpslay $n"}, + ] + assert fuss.diagnostics == [ + {"kind": "untranslated_mobprog_line", "mob": 5, "trigger": "GREET", "line": "mprestore self 50"}, + ] + for area_file in (rom, smaug, fuss): + assert area_file.as_dict()["diagnostics"] == area_file.diagnostics def test_inline_programs_are_translated_to_rom_syntax(tmp_path: Path) -> None: