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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions area_reader/dialects/ack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion area_reader/dialects/smaug.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ def read_smaug_programs(self):
self.read_and_verify_letter("|")
return programs

def load_sections(self):
def inline_mob_programs(self, mob):
return [(program.trigger, program.argument, program.commands) for program in mob.programs]

def read_sections(self):
readers = {
"area": self.read_area_metadata,
"author": self.load_author,
Expand Down
9 changes: 7 additions & 2 deletions area_reader/dialects/swr.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,16 @@ 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())

def load_sections(self):
def read_sections(self):
self.skip_whitespace()
if self.data.startswith("#FUSSAREA", self.index):
self.read_section_name()
Expand All @@ -49,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):
Expand Down
246 changes: 246 additions & 0 deletions area_reader/mobprogs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"""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("_", "*")
# 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"})
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)
# 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):
"""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:
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)
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 group, mprog in enumerate(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,
"group": group,
}
)
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 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 ":
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}
)
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)
for phrase in phrases:
entries.append(
{
"trigger": trigger,
"phrase": phrase,
"lines": list(lines),
"source": "inline",
"vnum": None,
"group": group,
}
)
return entries, diagnostics
27 changes: 27 additions & 0 deletions area_reader/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from attr import fields

import area_reader.mobprogs
import area_reader.model
import area_reader.serialization
import area_reader.values
Expand Down Expand Up @@ -279,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()
Expand Down Expand Up @@ -403,8 +409,29 @@ 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)
for vnum, entries in self.mob_programs()[0].items():
result["mobs"][vnum]["mob_programs"] = entries
result["diagnostics"] = [dict(diagnostic) for diagnostic in self.diagnostics]
return result

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading