mirror of
https://github.com/Aurorastation/Aurora.3.git
synced 2025-12-13 03:32:07 +00:00
end of an era      ---------------------------------------------------------------------------------- ---------------------------------------------------------------------------------- ---------------------------------------------------------------------------------- ---------------------------------------------------------------------------------- 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 <>
580 lines
18 KiB
Python
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
|