Files
dockipelago/worlds/gstla/GameData.py
Jonathan Tinney 7971961166
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

566 lines
23 KiB
Python

import json
import os
from collections import defaultdict
from enum import Enum, IntFlag, auto, IntEnum
from typing import NamedTuple, List, Dict, Set
SCRIPT_DIR = os.path.join(os.path.dirname(__file__))
num_words = {
1: 'One',
2: 'Two',
3: 'Three',
4: 'Four',
5: 'Five',
6: 'Six',
7: 'Seven',
8: 'Eight',
9: 'Nine',
10: 'Ten'
}
mimic_names = [
"Milquetoast Mimic",
"Clumsy Mimic",
"Mimic",
"Journeyman Mimic",
"Advanced Mimic",
"Sacred Mimic",
"Royal Mimic",
"Imperial Mimic",
"Divine Mimic"
]
class LocationDatum(NamedTuple):
rando_flag: int
flag: int
mapId: int
locked: bool
is_summon: bool
is_key_item: bool
is_major_item: bool
is_hidden: bool
addr: List[int]
event_type: int
location_id: int
id: int
vanilla_contents: int
vanilla_name: str
map_name: str
restrictions: int
class EnemyDatum(NamedTuple):
name: str
id: int
ap_id: int
flag: int
class EventDatum(NamedTuple):
event_id: int
flag: int
location_name: str
item_name: str
class ItemType(int, Enum):
Consumable = 0 # is this the right name?
Weapon = 1
Armor = 2
Shield = 3
Helm = 4
Boots = 5
PsyenergyItem = 6
Trident = 7
Ring = 8
Shirt = 9
Class = 10
KeyItem = 11
Psyenergy = 12
Djinn = 13
Event = 14
Character = 15
Mimic = 16
Summon = 17
class ItemFlags(IntFlag):
NONE = 0
Cursed = auto()# 1 "Curses on pickup"
Sticky = auto()# 2 "Unremovable"
Rare = auto()# 4 "If dropped, can be bought back from shops"
Important = auto()# 8 "Cannot be dropped"
Stackable = auto()# 16 "Carry up to 30"
NoTransfer = auto()# 32 "Cannot be transfered from GS1 to GS2"
UnusedOne = auto()
UnusedTwo = auto()
class ItemDatum(NamedTuple):
# TODO: add more?
id: int
name: str
addr: int
item_type: ItemType
flags: ItemFlags
use_effect: int = 0
# TODO: event type is a property of locations, not of items
# event_type: int
is_mimic: bool = False
class ElementType(IntEnum):
Earth = 0
Water = 1
Fire = 2
Air = 3
class DjinnDatum(NamedTuple):
ap_id: int
id: int
element: ElementType
name: str
addr: int
stats_addr: int
stats: List[int]
vanilla_flag: int
item_type: ItemType = ItemType.Djinn
class SummonDatum(NamedTuple):
# ap_id: int
id: int
name: str
addr: int
item_type: ItemType = ItemType.Summon
class PsyDatum(NamedTuple):
id: int
name: str
addr: int
item_type: ItemType = ItemType.Psyenergy
class CharacterDatum(NamedTuple):
id: int
name: str
flag: int
addr: int
item_type: ItemType = ItemType.Character
class LocationName(NamedTuple):
id: int
flag: int
py_name: str
str_name: str
@classmethod
def from_loc_data(cls, loc: LocationDatum, value: str = None, suffix: str = ''):
key = loc.map_name
val = loc.vanilla_name + suffix
py_name = key.replace(' ', '_').replace("'", '')
str_name = val.replace(' ', '_').replace("'", '').replace('???', 'Empty')
if value is None:
return LocationName(loc.id, loc.flag, py_name + '_' + str_name, py_name + ' - ' + str_name)
else:
return LocationName(loc.id, loc.flag, py_name + '_' + str_name, value)
class ItemName(NamedTuple):
id: int
py_name: str
str_name: str
@classmethod
def from_item_data(cls, item: ItemDatum, suffix = ''):
ret = ItemName(item.id,
ItemName.setup_py_name(item.name, suffix),
ItemName.setup_str_name(item.name,suffix))
return ret
@classmethod
def from_enemy_data(cls, enemy: EnemyDatum, event: bool = True):
ret = ItemName(enemy.ap_id,
ItemName.setup_py_name(enemy.name, '_'+str(enemy.id) + (" Event" if event else "")),
ItemName.setup_str_name(enemy.name, ' '+str(enemy.id) + (" Event" if event else "")))
return ret
@staticmethod
def setup_py_name(name: str, suffix=''):
return (name + suffix).replace(' ', '_').replace("'", '').replace('???', 'Empty')
@staticmethod
def setup_str_name(name: str, suffix = ''):
return (name + suffix).replace('???', 'Empty')
SPECIAL_NAMES = {
419: ItemName(419, 'Rusty_Sword_CorsairsEdge',"Rusty Sword - Corsair's Edge"),
417: ItemName(417, 'Rusty_Sword_RobbersBlade',"Rusty Sword - Robber's Blade"),
420: ItemName(420, 'Rusty_Sword_PiratesSabre',"Rusty Sword - Pirate's Sabre"),
418: ItemName(418, 'Rusty_Sword_SoulBrand',"Rusty Sword - Soul Brand"),
421: ItemName(421, 'Rusty_Axe_CaptainsAxe',"Rusty Axe - Captain's Axe"),
422: ItemName(422, 'Rusty_Axe_VikingAxe',"Rusty Axe - Viking Axe"),
424: ItemName(424, 'Rusty_Mace_HagboneMace',"Rusty Mace - Hagbone Mace"),
423: ItemName(423, 'Rusty_Mace_DemonMace',"Rusty Mace - Demon Mace"),
425: ItemName(425, 'Rusty_Staff_Dracomace',"Rusty Staff - Dracomace"),
426: ItemName(426, 'Rusty_Staff_GlowerStaff',"Rusty Staff - Glower Staff"),
427: ItemName(427, 'Rusty_Staff_GoblinsRod',"Rusty Staff - Goblin's Rod"),
123: ItemName(123, 'Dragon_Shield_GS', "Dragon Shield GS"),
132: ItemName(132, 'Spirit_Gloves_GS', 'Spirit Gloves GS'),
222: ItemName(222, 'Mars_Star', 'Mars Star'),
223: ItemName(223, 'Mythril_Bag_Jupiter', 'Mythril Bag (Jupiter)'),
224: ItemName(224, 'Mythril_Bag_Empty', 'Mythril Bag (Empty)'),
245: ItemName(245, 'Mythril_Bag_Mars_Jupiter', 'Mythril Bag (Mars & Jupiter)'),
}
class GameData:
def __init__(self):
self.raw_location_data: List[LocationDatum] = []
self.raw_item_data: List[ItemDatum] = []
self.raw_djinn_data: List[DjinnDatum] = []
self.raw_summon_data: List[SummonDatum] = []
self.raw_psy_data: List[PsyDatum] = []
self.raw_character_data: List[CharacterDatum] = []
self.raw_enemy_data: List[EnemyDatum] = []
self.location_names: Dict[int, LocationName] = dict()
self.item_names: Dict[int, ItemName] = dict()
self.events: Dict[int, EventDatum] = dict()
self.vanilla_item_ids: Set[int] = set()
self.vanilla_shop_contents: Set[int] = set()
self.forgeable_ids: Set[int] = set()
self.lucky_medal_ids: Set[int] = {
# 23, # Assassin Blade
# 37, # Burning Axe
# 48, # Grevious Mace
# 81, # Spirit Armor
# 97, # Kimono
# 108, # Cocktail Dress
# 124, # Earth Shield
# 133, # Battle Gloves
# 142, # Guardian Armlet
# 152, # Adept's Helm
# 160, # Ninja Hood
# 171, # Glittering Tiara
183, # Potion
186, # Psy Crystal
189, # Water of Life
280, # Hestia Blade
302, # Mighty Axe
320, # Fireman's Pole
327, # Leda's Bracelet
335, # Erebus Armor
341, # Wild Coat
342, # Floral Dress
359, # Aegis Shield
364, # Crafted Gloves
380, # Minerva Helm
387, # Crown of Glory
395, # Brilliant Circlet
}
self.lucky_wheels_ids: Set[int] = {
181, # Nut
182, # Vial
183, # Potion
188, # Elixer
186, # Psy Crystal
189, # Water of Life
258, # Fur Boots
257, # Quick Boots
256, # Hyper Boots
252, # Running Shirt
251, # Silk Shirt
250, # Mythril Shirt
264, #Sleep Ring
263, # War Ring
262, # Adept Ring
}
self._load_locations()
self._load_items()
self._load_shop_data()
self._load_djinn()
self._load_summons()
self._load_forgeables()
self._setup_events()
self._setup_location_names()
self._setup_item_names()
self._setup_psy_energies()
self._setup_characters()
def _load_locations(self):
with open(os.path.join(SCRIPT_DIR, 'data', 'item_locations.json'), 'r') as loc_file:
location_data = json.load(loc_file)
# A few locations use different flags than stated in the item_locations.json file
# E.g. character starting inventories
flag_overwrites = {
16384202: 0x4, # Shaman's Rod -> Felix
16384204: 0x6, # Mind Read -> Sheba
16384206: 0x6, # Whirlwind -> Sheba
16384208: 0x4, # Growth -> Felix
16384210: 0x3, # Carry Stone -> Mia
16384212: 0x2, # Lifting Gem -> Ivan
16384214: 0x1, # Orb of Force -> Garet
16384216: 0x0, # Catch Beads -> Isaac
16384218: 0x7, # Douse Drop -> Piers
16384220: 0x7, # Frost Jewel -> Piers
}
restriction_map = {'no-empty': 1, 'no-mimic': 2, 'no-summon': 4, 'no-money': 8}
restriction_dict: defaultdict[int, int] = defaultdict(lambda: 0)
for loc_logic_file in os.listdir(os.path.join(SCRIPT_DIR, 'data', 'location_logic')):
assert loc_logic_file.endswith('.json')
with open(os.path.join(SCRIPT_DIR, 'data', 'location_logic', loc_logic_file), 'r') as data_file:
data = json.load(data_file)
treasure_data = data['Treasure']
for datum in treasure_data:
if 'Restriction' not in datum:
continue
restrictions = datum['Restriction']
addr = int(datum['Addr'], 16)
assert addr not in restriction_dict
for restriction in restrictions:
restriction_dict[addr] += restriction_map[restriction]
for flag, locs in location_data.items():
# The extra locations are variations on the same map. We mostly don't care for the client,
# but the rom generator currently does care, since it will need to place the same item on all
# variations of the map
loc = locs[0]
addr = [x['addr'] for x in locs]
rando_flag = int(flag, 16)
mapped_flag = flag_overwrites.get(addr[0], rando_flag)
restriction_data = restriction_dict[loc['id']]
if 0x80 != loc['eventType'] and 0x84 != loc['eventType']:
restriction_data |= restriction_map['no-empty'] + restriction_map['no-mimic']
if addr[0] > 0xFA0000:
restriction_data |= restriction_map['no-money']
if loc['id'] < 0x10 or (loc['id'] | 0xF00) == 0x100:
restriction_data |= restriction_map['no-empty'] + restriction_map['no-mimic'] + restriction_map['no-money']
datum = LocationDatum(rando_flag, mapped_flag, loc['mapId'], loc['locked'], loc['isSummon'], loc['isKeyItem'],
loc['isMajorItem'], loc['isHidden'], addr, loc['eventType'],
loc['locationId'], loc['id'], loc['vanillaContents'], loc['vanillaName'],
loc['mapName'], restriction_data)
self.raw_location_data.append(datum)
if datum.vanilla_name == 'Mimic':
self.raw_item_data.append(
# Agreed upon rando id of 0xA00 + mimic id
ItemDatum(0xA01 + datum.vanilla_contents, mimic_names[datum.vanilla_contents], datum.addr[0], ItemType.Mimic, ItemFlags.NONE, 0, True)
)
else:
self.vanilla_item_ids.add(datum.vanilla_contents)
def _load_shop_data(self):
with open(os.path.join(SCRIPT_DIR, 'data', 'shops.json'), 'r') as shop_file:
shop_data = json.load(shop_file)
for shop in shop_data:
for id in shop['items']:
if id != 0:
self.vanilla_shop_contents.add(id)
for artifact in shop['artifacts']:
if artifact != 0:
self.vanilla_shop_contents.add(artifact)
def _load_forgeables(self):
with open(os.path.join(SCRIPT_DIR, 'data', 'forgeables.json'), 'r') as forge_file:
forge_data = json.load(forge_file)
for forge in forge_data.values():
for result in forge['results']:
if result != 0:
self.forgeable_ids.add(result)
def _load_items(self):
with open(os.path.join(SCRIPT_DIR, 'data', 'items.json'), 'r') as item_file:
item_data = json.load(item_file)
for item in item_data.values():
item_type = ItemType(item['itemType'])
self.raw_item_data.append(
ItemDatum(item['id'] if item_type != ItemType.PsyenergyItem else item['useEffect'] + 0xE00,
item['name'],
item['addr'],
item_type,
ItemFlags(item['flags']),
item['useEffect'])
)
coins = dict()
for loc in self.raw_location_data:
if loc.vanilla_contents > 0x8000:
# TODO: get the name in a better way?
coins[loc.vanilla_contents] = ItemDatum(loc.vanilla_contents, f"Coins {loc.vanilla_contents-0x8000}", loc.addr[0], ItemType.Consumable, ItemFlags(0))
for coin in coins.values():
self.raw_item_data.append(coin)
def _load_djinn(self):
with open(os.path.join(SCRIPT_DIR, 'data', 'djinn.json'), 'r') as djinn_file:
djinn_data = json.load(djinn_file)
for djinn in djinn_data:
# Largest vanilla item id is 460
self.raw_djinn_data.append(
DjinnDatum(
djinn['addr'],
djinn['vanillaId'],
ElementType(djinn['vanillaElement']),
djinn['vanillaName'],
djinn['addr'],
# From emo tracker pack
djinn['statAddr'],
djinn['stats'],
# 0x30 is the start of the djinn flags; the game has flag slots for 20 djinn
# per element, even though there's only 18 per element, so some flags are just unused
0x30 + (djinn['vanillaElement'] * 20) + djinn['vanillaId'],
)
)
for djinn in self.raw_djinn_data:
self.item_names[djinn.ap_id] = (ItemName(djinn.ap_id, djinn.name, djinn.name))
def _load_summons(self):
with open(os.path.join(SCRIPT_DIR, 'data', 'summons.json'), 'r') as summon_file:
summon_data = json.load(summon_file)
for summon in summon_data:
self.raw_summon_data.append(
SummonDatum(summon['id'] + 0xF00, summon['name'], summon['addr'])
)
def _load_enemies(self):
# Function is unused, but leaving here in case we actually want to use it for some reason
enemy_offset = 6000
with open(os.path.join(SCRIPT_DIR, 'data', 'enemies.json'), 'r') as enemy_file:
enemy_data = json.load(enemy_file)
for name, list in enemy_data.items():
if name == "???":
continue
for enemy in list:
self.raw_enemy_data.append(
# Enemy kill flags start at 0x600; the flag is off by 7 sorta; -1 for the json
# starting at 1 instead of 0, and + 8 for player characters
EnemyDatum(enemy['name'], enemy['id'] - 1, enemy['id']+enemy_offset, enemy['id'] - 1 + 8 + 0x600)
)
for enemy in self.raw_enemy_data:
name = ItemName.from_enemy_data(enemy)
self.item_names[enemy.ap_id] = name
self.location_names[enemy.ap_id] = LocationName(enemy.ap_id, enemy.flag, name.py_name, name.str_name)
def _recurse_tracker_data(self,
tracker_name_data: Dict[int, str],
tracker_names: Dict[str, int],
node: Dict[str, any]) -> None:
if 'sections' not in node:
if 'children' in node:
for child in node['children']:
self._recurse_tracker_data(tracker_name_data, tracker_names, child)
return
sections = node['sections']
area_name = node['name']
for i in range(len(sections)):
section = sections[i]
flags = node['children'][i]['name']
for flag_str in flags.split(","):
flag = int(flag_str, 16)
if flag in tracker_name_data:
continue
tracker_name = f"{area_name} - {section['name']}"
# count = names[tracker_name] + 1
tracker_names[tracker_name] += 1
count = tracker_names[tracker_name]
suffix = ''
if count > 1:
suffix = ' ' + num_words[count]
tracker_name_data[flag] = tracker_name + suffix
def _setup_location_names(self):
names: defaultdict[str, int] = defaultdict(lambda: 0)
tracker_name_data: Dict[int, str] = dict()
tracker_names: defaultdict[str, int] = defaultdict(lambda: 0)
with open(os.path.join(SCRIPT_DIR, 'data', 'locations.json')) as infile:
tracker_data = json.load(infile)[0]
self._recurse_tracker_data(tracker_name_data, tracker_names, tracker_data)
for loc in self.raw_location_data:
tracker_name = tracker_name_data.get(loc.rando_flag, None)
# tracker_name = None
if tracker_name is not None:
loc_name = LocationName.from_loc_data(loc, tracker_name)
else:
loc_name = LocationName.from_loc_data(loc)
count = names[loc_name.py_name] + 1
names[loc_name.py_name] = count
if count > 1:
if tracker_name is not None:
loc_name = LocationName.from_loc_data(loc, tracker_name, ' ' + num_words[count])
else:
loc_name = LocationName.from_loc_data(loc, None, ' ' + num_words[count])
# loc_name = LocationName.from_loc_data(loc, None, ' ' + num_words[count])
assert loc_name.id not in self.location_names, "Id: %s, Name: %s" % (hex(loc_name.id), loc_name.str_name)
self.location_names[loc_name.id] = loc_name
def _setup_item_names(self):
for item in self.raw_item_data:
item_id = item.id
if item_id in SPECIAL_NAMES:
item_name = SPECIAL_NAMES[item_id]
else:
item_name = ItemName.from_item_data(item)
assert item_name.id not in self.item_names, (item_name.id, item_name.str_name)
self.item_names[item_name.id] = item_name
def _setup_psy_energies(self):
self.raw_psy_data += [
PsyDatum(3596, 'Growth', 3596),
PsyDatum(3662, 'Whirlwind', 3662),
PsyDatum(3722, 'Parch', 3722),
PsyDatum(3723, 'Sand', 3723),
PsyDatum(3725, 'Mind Read', 3725),
PsyDatum(3728, 'Reveal', 3728),
PsyDatum(3738, 'Blaze', 3738),
]
for d in self.raw_psy_data:
self.item_names[d.id] = ItemName(d.id, d.name.replace(' ', '_'), d.name)
def _setup_characters(self):
self.raw_character_data += [
CharacterDatum(3328, "Isaac", 3328, 16384384),
CharacterDatum(3329, "Garet", 3329, 16384386),
CharacterDatum(3330, "Ivan", 3330, 16384388),
CharacterDatum(3331, "Mia", 3331, 16384390),
# Felix is 3332, but we don't do nutin with him
CharacterDatum(3333, "Jenna", 3333, 16384392),
CharacterDatum(3334, "Sheba", 3334, 16384394),
CharacterDatum(3335, "Piers", 3335, 16384396),
]
for c in self.raw_character_data:
self.item_names[c.id] = ItemName(c.id, c.name, c.name)
def _setup_events(self):
# Just some offset to avoid colliding with anything else; needs to avoid any location or item ids in AP
event_offset = 5000
events = [
EventDatum(event_offset + 1, 0x778, "Mars Lighthouse - Doom Dragon", "Doom Dragon Defeated" ),
EventDatum(event_offset + 2, 0x8AB, "Alhafra Briggs", "Briggs defeated" ),
EventDatum(event_offset + 3, 0x97F, "Alhafra Prison Briggs", "Briggs escaped" ),
EventDatum(event_offset + 4, 0x8FF, "Gabomba Statue Ritual", "Gabomba Statue Completed" ),
EventDatum(event_offset + 5, 0x9EE, "Gaia Rock - Serpent", "Serpent defeated" ),
EventDatum(event_offset + 6, 0x665, "Sea of Time - Poseidon", "Poseidon defeated"),
EventDatum(event_offset + 7, 0x93F, "Lemurian Ship - Aqua Hydra", "Aqua Hydra defeated"),
EventDatum(event_offset + 8, 0x94D, "Shaman Village - Moapa", "Moapa defeated" ),
EventDatum(event_offset + 9, 0xA21, "Jupiter_Lighthouse Aeri - Agatio and Karst", "Jupiter Beacon Lit"),
EventDatum(event_offset + 10, 0xA4B, "Mars Lighthouse - Flame Dragons", "Flame Dragons - defeated"),
EventDatum(event_offset + 11, 0x8DE, "Lemurian Ship - Engine Room", "Ship"),
EventDatum(event_offset + 12, 0x8DF, "Contigo - Wings of Anemos", "Wings of Anemos"),
EventDatum(event_offset + 13, 0x64a, "Kandorean Temple - Chestbeaters", "Chestbeaters defeated"),
EventDatum(event_offset + 14, 0x64d, "Yampi Desert - King Scorpion", "King Scorpion defeated"),
EventDatum(event_offset + 15, 0x662, "Champa - Avimander", "Avimander defeated"),
EventDatum(event_offset + 16, 0x6a4, "Treasure Isle - Star Magician", "Star Magician defeated"),
EventDatum(event_offset + 17, 0x6dd, "Islet Cave - Sentinel", "Sentinel defeated"),
EventDatum(event_offset + 18, 0x6d1, "Yampi Desert Cave - Valukar", "Valukar defeated"),
EventDatum(event_offset + 19, 0x6da, "Anemos Inner Sanctum - Dullahan", "Dullahan defeated"),
# Flag here is really the Jupiter Lighthouse flag. Base rando with PC shuffle seems to do something weird
# with the reunion flag
EventDatum(event_offset + 20, 0xA21, "Contigo - Reunion", "Reunion"),
EventDatum(event_offset + 21, 0x1, "Victory Event", "Victory"),
EventDatum(event_offset + 22, 0xA5F, "Loho - Ship Cannon", "Ship Cannon"),
EventDatum(event_offset + 23, 0xA30, "Mars Lighthouse - Heated", "Mars Lighthouse Heated"),
# EventDatum(event_offset + 15,, "Jupiter Lighthouse - Karst", "Karst defeated"),
# EventDatum(event_offset + 15,, "Jupiter Lighthouse - Agatio", "Agatio defeated"),
]
self.events = {e.event_id: e for e in events}
for event in self.events.values():
self.location_names[event.event_id] = LocationName(event.event_id, event.flag, event.location_name.replace(' ', '_').replace('-', '').replace('__', '_'), event.location_name)
for event in self.events.values():
self.item_names[event.event_id] = ItemName(event.event_id, event.item_name.replace('-', '').replace(' ', '_').replace('__', '_'), event.item_name)