Files
Aurora.3/tools/mapmerge2/dmm.py
DreamySkrell db8d0918e6 Remove the Crew Armory and the Leviathan (#19728)
end of an era


![image](https://github.com/user-attachments/assets/3a8c35b1-5dd6-4456-8f87-b807e61b8535)

![image](https://github.com/user-attachments/assets/723dd548-852d-48f8-9255-881aa7e6f71b)

![image](https://github.com/user-attachments/assets/157491eb-a1b8-465b-868a-d8df1c56aa41)

![image](https://github.com/user-attachments/assets/50c1ebbb-0d4f-40f0-bc4f-2675f126b8be)

![image](https://github.com/user-attachments/assets/e3e8c83f-4d77-4c81-aa55-263e90eb0c90)


----------------------------------------------------------------------------------

----------------------------------------------------------------------------------

----------------------------------------------------------------------------------

----------------------------------------------------------------------------------

changes:
  - rscadd: "Removes the Crew Armory."
  - rscadd: "Removes the Leviathan."
  - rscadd: "Updates mapmerge2 tool with most current tg version."



MATT'S EDIT: For whoever is reading, the Leviathan and the Crew Armoury
are being removed to try and lower the Horizon's passive militarization
a bit. We're going to try playing without the crew armoury, but in case
security stomps are unbearable and Ops can't properly fulfill the role
of "giving people guns", then ERTs will likely end up being readded.

There will be an IC explanation given for the Leviathan's removal. The
crew armoury one will probably be a straight retcon.

---------

Co-authored-by: DreamySkrell <>
2024-08-11 01:14:32 +00:00

580 lines
18 KiB
Python

# Tools for working with DreamMaker maps
import io
import bidict
import random
from collections import namedtuple
TGM_HEADER = "//MAP CONVERTED BY dmm2tgm.py THIS HEADER COMMENT PREVENTS RECONVERSION, DO NOT REMOVE"
ENCODING = 'utf-8'
Coordinate = namedtuple('Coordinate', ['x', 'y', 'z'])
class DMM:
__slots__ = ['key_length', 'size', 'dictionary', 'grid', 'header']
def __init__(self, key_length, size):
self.key_length = key_length
self.size = size
self.dictionary = bidict.bidict()
self.grid = {}
self.header = None
@staticmethod
def from_file(fname):
with open(fname, 'r', encoding=ENCODING) as f:
return _parse(f.read())
@staticmethod
def from_bytes(bytes):
return _parse(bytes.decode(ENCODING))
def to_file(self, fname, *, tgm = True):
self._presave_checks()
with open(fname, 'w', newline='\n', encoding=ENCODING) as f:
(save_tgm if tgm else save_dmm)(self, f)
def to_bytes(self, *, tgm = True):
self._presave_checks()
bio = io.BytesIO()
with io.TextIOWrapper(bio, newline='\n', encoding=ENCODING) as f:
(save_tgm if tgm else save_dmm)(self, f)
f.flush()
return bio.getvalue()
def get_or_generate_key(self, tile):
try:
return self.dictionary.inv[tile]
except KeyError:
key = self.generate_new_key()
self.dictionary[key] = tile
return key
def get_tile(self, coord):
return self.dictionary[self.grid[coord]]
def set_tile(self, coord, tile):
tile = tuple(tile)
self.grid[coord] = self.get_or_generate_key(tile)
def generate_new_key(self):
self._ensure_free_keys(1)
max_key = max_key_for(self.key_length)
# choose one of the free keys at random
key = random.randint(0, max_key - 1)
while key in self.dictionary:
key = random.randint(0, max_key - 1)
return key
def overwrite_key(self, key, fixed, bad_keys):
try:
self.dictionary[key] = fixed
return None
except bidict.DuplicationError:
old_key = self.dictionary.inv[fixed]
bad_keys[key] = old_key
print(f"Merging '{num_to_key(key, self.key_length)}' into '{num_to_key(old_key, self.key_length)}'")
return old_key
def reassign_bad_keys(self, bad_keys):
if not bad_keys:
return
for k, v in self.grid.items():
# reassign the grid entries which used the old key
self.grid[k] = bad_keys.get(v, v)
def remove_unused_keys(self, modified_keys = None):
unused_keys = list(set(modified_keys)) if modified_keys is not None else self.dictionary.keys()
for key in self.grid.values():
if key in unused_keys:
unused_keys.remove(key)
for key in unused_keys:
del self.dictionary[key]
def _presave_checks(self):
# last-second handling of bogus keys to help prevent and fix broken maps
self._ensure_free_keys(0)
max_key = max_key_for(self.key_length)
bad_keys = {key: 0 for key in self.dictionary.keys() if key >= max_key}
if bad_keys:
print(f"Warning: fixing {len(bad_keys)} overflowing keys")
for k in bad_keys:
# create a new non-bogus key and transfer that value to it
new_key = bad_keys[k] = self.generate_new_key()
self.dictionary.forceput(new_key, self.dictionary[k])
print(f" {num_to_key(k, self.key_length, True)} -> {num_to_key(new_key, self.key_length)}")
# handle entries in the dictionary which have atoms in the wrong order
keys = list(self.dictionary.keys())
for key in keys:
value = self.dictionary[key]
if is_bad_atom_ordering(num_to_key(key, self.key_length, True), value):
fixed = tuple(fix_atom_ordering(value))
self.overwrite_key(key, fixed, bad_keys)
self.reassign_bad_keys(bad_keys)
def _ensure_free_keys(self, desired):
# ensure that free keys exist by increasing the key length if necessary
free_keys = max_key_for(self.key_length) - len(self.dictionary)
while free_keys < desired:
if self.key_length >= MAX_KEY_LENGTH:
raise KeyTooLarge(f"can't expand beyond key length {MAX_KEY_LENGTH} ({len(self.dictionary)} keys)")
self.key_length += 1
free_keys = max_key_for(self.key_length) - len(self.dictionary)
return free_keys
@property
def coords_zyx(self):
for z in range(1, self.size.z + 1):
for y in range(1, self.size.y + 1):
for x in range(1, self.size.x + 1):
yield (z, y, x)
@property
def coords_z(self):
return range(1, self.size.z + 1)
@property
def coords_yx(self):
for y in range(1, self.size.y + 1):
for x in range(1, self.size.x + 1):
yield (y, x)
def __repr__(self):
return f"DMM(size={self.size}, key_length={self.key_length}, dictionary_size={len(self.dictionary)})"
# ----------
# key handling
# Base 52 a-z A-Z dictionary for fast conversion
MAX_KEY_LENGTH = 3 # things will get ugly fast if you exceed this
BASE = 52
base52 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
base52_r = {x: i for i, x in enumerate(base52)}
assert len(base52) == BASE and len(base52_r) == BASE
def key_to_num(key):
num = 0
for ch in key:
num = BASE * num + base52_r[ch]
return num
def num_to_key(num, key_length, allow_overflow=False):
if num >= (BASE ** key_length if allow_overflow else max_key_for(key_length)):
raise KeyTooLarge(f"num={num} does not fit in key_length={key_length}")
result = ''
while num:
result = base52[num % BASE] + result
num //= BASE
assert len(result) <= key_length
return base52[0] * (key_length - len(result)) + result
def max_key_for(key_length):
# keys only go up to "ymo" = 65534, under-estimated just in case
# https://secure.byond.com/forum/?post=2340796#comment23770802
return min(65530, BASE ** key_length)
class KeyTooLarge(Exception):
pass
# ----------
# An actual atom parser
def parse_map_atom(atom):
try:
i = atom.index('{')
except ValueError:
return atom, {}
path, rest = atom[:i], atom[i+1:]
vars = {}
in_string = False
in_name = False
escaping = False
current_name = ''
current = ''
for ch in rest:
if escaping:
escaping = False
current += ch
elif ch == '\\':
escaping = True
elif ch == '"':
in_string = not in_string
current += ch
elif in_string:
current += ch
elif ch == ';':
vars[current_name.strip()] = current.strip()
current_name = current = ''
elif ch == '=':
current_name = current
current = ''
elif ch == '}':
vars[current_name.strip()] = current.strip()
break
elif ch not in ' ':
current += ch
return path, vars
def is_bad_atom_ordering(key, atoms):
seen_turfs = 0
seen_areas = 0
can_fix = False
for each in atoms:
if each.startswith('/turf'):
if seen_turfs == 1:
print(f"Warning: key '{key}' has multiple turfs!")
if seen_areas:
print(f"Warning: key '{key}' has area before turf (autofixing...)")
can_fix = True
seen_turfs += 1
elif each.startswith('/area'):
if seen_areas == 1:
print(f"Warning: key '{key}' has multiple areas!!!")
seen_areas += 1
else:
if (seen_turfs or seen_areas) and not can_fix:
print(f"Warning: key '{key}' has movable after turf or area (autofixing...)")
can_fix = True
if not seen_areas or not seen_turfs:
print(f"Warning: key '{key}' is missing either a turf or area")
return can_fix
def split_atom_groups(atoms):
movables, turfs, areas = [], [], []
for each in atoms:
if each.startswith('/turf'):
turfs.append(each)
elif each.startswith('/area'):
areas.append(each)
else:
movables.append(each)
return movables, turfs, areas
def fix_atom_ordering(atoms):
movables, turfs, areas = split_atom_groups(atoms)
movables.extend(turfs)
movables.extend(areas)
return movables
# ----------
# TGM writer
def save_tgm(dmm, output):
output.write(f"{TGM_HEADER}\n")
if dmm.header:
output.write(f"{dmm.header}\n")
# write dictionary in tgm format
for key, value in sorted(dmm.dictionary.items()):
output.write(f'"{num_to_key(key, dmm.key_length)}" = (\n')
for idx, thing in enumerate(value):
in_quote_block = False
in_varedit_block = False
for char in thing:
if in_quote_block:
if char == '"':
in_quote_block = False
output.write(char)
elif char == '"':
in_quote_block = True
output.write(char)
elif not in_varedit_block:
if char == "{":
in_varedit_block = True
output.write("{\n\t")
else:
output.write(char)
elif char == ";":
output.write(";\n\t")
elif char == "}":
output.write("\n\t}")
in_varedit_block = False
else:
output.write(char)
if idx < len(value) - 1:
output.write(",\n")
output.write(")\n")
# thanks to YotaXP for finding out about this one
max_x, max_y, max_z = dmm.size
for z in range(1, max_z + 1):
output.write("\n")
for x in range(1, max_x + 1):
output.write(f"({x},{1},{z}) = {{\"\n")
for y in range(max_y, 0, -1):
output.write(f"{num_to_key(dmm.grid[x, y, z], dmm.key_length)}\n")
output.write("\"}\n")
# ----------
# DMM writer
def save_dmm(dmm, output):
if dmm.header:
output.write(f"{dmm.header}\n")
# writes a tile dictionary the same way Dreammaker does
for key, value in sorted(dmm.dictionary.items()):
output.write(f'"{num_to_key(key, dmm.key_length)}" = ({",".join(value)})\n')
output.write("\n")
# writes a map grid the same way Dreammaker does
max_x, max_y, max_z = dmm.size
for z in range(1, max_z + 1):
output.write(f"(1,1,{z}) = {{\"\n")
for y in range(max_y, 0, -1):
for x in range(1, max_x + 1):
try:
output.write(num_to_key(dmm.grid[x, y, z], dmm.key_length))
except KeyError:
print(f"Key error: ({x}, {y}, {z})")
output.write("\n")
output.write("\"}\n")
# ----------
# Parser
def _parse(map_raw_text):
in_comment_line = False
comment_trigger = False
in_quote_block = False
in_key_block = False
in_data_block = False
in_varedit_block = False
after_data_block = False
escaping = False
skip_whitespace = False
dictionary = bidict.bidict()
duplicate_keys = {}
curr_key_len = 0
curr_key = 0
curr_datum = ""
curr_data = list()
in_map_block = False
in_coord_block = False
in_map_string = False
base_x = 0
adjust_y = True
curr_num = ""
reading_coord = "x"
key_length = 0
maxx = 0
maxy = 0
maxz = 0
curr_x = 0
curr_y = 0
curr_z = 0
grid = dict()
it = iter(map_raw_text)
# map block
for char in it:
if char == "\n":
in_comment_line = False
comment_trigger = False
continue
elif in_comment_line:
continue
elif char in "\r\t":
continue
if char == "/" and not in_quote_block:
if comment_trigger:
in_comment_line = True
continue
else:
comment_trigger = True
else:
comment_trigger = False
if in_data_block:
if in_varedit_block:
if in_quote_block:
if char == "\\":
curr_datum = curr_datum + char
escaping = True
elif escaping:
curr_datum = curr_datum + char
escaping = False
elif char == "\"":
curr_datum = curr_datum + char
in_quote_block = False
else:
curr_datum = curr_datum + char
else:
if skip_whitespace and char == " ":
skip_whitespace = False
continue
skip_whitespace = False
if char == "\"":
curr_datum = curr_datum + char
in_quote_block = True
elif char == ";":
skip_whitespace = True
curr_datum = curr_datum + char
elif char == "}":
curr_datum = curr_datum + char
in_varedit_block = False
else:
curr_datum = curr_datum + char
elif char == "{":
curr_datum = curr_datum + char
in_varedit_block = True
elif char == ",":
curr_data.append(curr_datum)
curr_datum = ""
elif char == ")":
curr_data.append(curr_datum)
curr_data = tuple(curr_data)
try:
dictionary[curr_key] = curr_data
except bidict.ValueDuplicationError:
# if the map has duplicate values, eliminate them now
duplicate_keys[curr_key] = dictionary.inv[curr_data]
curr_data = list()
curr_datum = ""
curr_key = 0
curr_key_len = 0
in_data_block = False
after_data_block = True
else:
curr_datum = curr_datum + char
elif in_key_block:
if char == "\"":
in_key_block = False
if key_length == 0:
key_length = curr_key_len
else:
assert key_length == curr_key_len
else:
curr_key = BASE * curr_key + base52_r[char]
curr_key_len += 1
# else we're looking for a key block, a data block or the map block
elif char == "\"":
in_key_block = True
after_data_block = False
elif char == "(":
if after_data_block:
in_coord_block = True
after_data_block = False
curr_key = 0
curr_key_len = 0
break
else:
in_data_block = True
after_data_block = False
# grid block
for char in it:
if char == "\r":
continue
if in_coord_block:
if char == ",":
if reading_coord == "x":
curr_x = int(curr_num)
if curr_x > maxx:
maxx = curr_x
base_x = curr_x
curr_num = ""
reading_coord = "y"
elif reading_coord == "y":
curr_y = int(curr_num)
if curr_y > maxy:
maxy = curr_y
curr_num = ""
reading_coord = "z"
else:
raise ValueError("too many dimensions")
elif char == ")":
curr_z = int(curr_num)
if curr_z > maxz:
maxz = curr_z
in_coord_block = False
reading_coord = "x"
curr_num = ""
else:
curr_num = curr_num + char
elif in_map_string:
if char == "\"":
in_map_string = False
adjust_y = True
curr_y -= 1
elif char == "\n":
if adjust_y:
adjust_y = False
else:
curr_y += 1
curr_x = base_x
else:
curr_key = BASE * curr_key + base52_r[char]
curr_key_len += 1
if curr_key_len == key_length:
grid[curr_x, curr_y, curr_z] = duplicate_keys.get(curr_key, curr_key)
if curr_x > maxx:
maxx = curr_x
curr_x += 1
curr_key = 0
curr_key_len = 0
# else look for coordinate block or a map string
elif char == "(":
in_coord_block = True
elif char == "\"":
in_map_string = True
if curr_y > maxy:
maxy = curr_y
if not grid:
# Usually caused by unbalanced quotes.
max_key = num_to_key(max(dictionary.keys()), key_length, True)
raise ValueError(f"dmm failed to parse, check for a syntax error near or after key {max_key!r}")
# Convert from raw .dmm coordinates to DM/BYOND coordinates by flipping Y
grid2 = dict()
for (x, y, z), tile in grid.items():
grid2[x, maxy + 1 - y, z] = tile
data = DMM(key_length, Coordinate(maxx, maxy, maxz))
data.dictionary = dictionary
data.grid = grid2
return data