Compare commits

..

35 Commits
0.0.2 ... 0.0.3

Author SHA1 Message Date
Fabian Dill
764e6e7926 Fix MultiTracker breaking after Hint is used 2021-04-12 00:06:27 +02:00
Fabian Dill
4292cdddd5 Factorio: add Funnel tech shape 2021-04-11 18:19:47 +02:00
Fabian Dill
9aef76767a Include example trigger for legacy weapons 2021-04-11 15:53:13 +02:00
Fabian Dill
3858a12f26 MultiServer: check for correct game 2021-04-10 21:08:01 +02:00
Fabian Dill
1943586221 Factorio: add medium_diamonds and pyramid tech tree layouts 2021-04-10 19:34:30 +02:00
Fabian Dill
6d15aef88a Factorio: align tech tree sections in growing ingredient requirements 2021-04-10 18:45:11 +02:00
Chris Wilson
50f06c3aac Add "swords" option back to playerSettings.yaml 2021-04-10 12:31:04 -04:00
Fabian Dill
7f3c46dd8a Factorio: Allow connecting and entering slot name in one command; /connect server_address slot_name 2021-04-10 15:29:56 +02:00
Fabian Dill
ea15f221ae various fixes to WebHost 2021-04-10 15:26:30 +02:00
Fabian Dill
d4b422840a Fix dynamic world attributes not updating 2021-04-10 06:36:06 +02:00
Fabian Dill
0586b24579 Factorio: add small_diamonds tech tree layout 2021-04-10 03:03:46 +02:00
Fabian Dill
e11016b0a2 fix _done having ingredient letters instead of starting name 2021-04-10 00:21:56 +02:00
Fabian Dill
74a368458e dynamically mark advancement technologies 2021-04-10 00:17:55 +02:00
Fabian Dill
1b70d485c0 shortcut logic for requirement-less technologies 2021-04-10 00:08:59 +02:00
Fabian Dill
2355f9c8d3 Only apply logic for allowed science pack 2021-04-09 22:16:55 +02:00
Fabian Dill
ceea55e3c6 traverse recipe tree for Factorio logic 2021-04-09 22:10:04 +02:00
Fabian Dill
c4d6ac50be turn weapons into boolean swordless 2021-04-09 20:40:45 +02:00
Fabian Dill
4461cb67f0 fix ap-sync and remove infinite techs from randomization 2021-04-09 00:33:32 +02:00
Fabian Dill
f0a6b5a8e4 Factorio:
add visibility option
fix tech_cost using the wrong variable name
fix yaml defaults not init'ing the Option class
LttP:
fix potential pathing confusion in maseya palette shuffler
Server:
Minimum version per team made no sense, removed
2021-04-08 19:53:24 +02:00
Fabian Dill
443fc03700 Send actual NetworkPlayer on Connected too 2021-04-07 02:49:36 +02:00
Fabian Dill
6567f14415 add log_network Server argument 2021-04-07 02:37:21 +02:00
Fabian Dill
32560eac92 send actual NetworkPlayers 2021-04-07 02:20:03 +02:00
Fabian Dill
4c71662719 factorio: award free samples to entire force 2021-04-07 01:55:53 +02:00
Fabian Dill
96a28ed41e implement Factorio option "free_samples" 2021-04-06 21:16:25 +02:00
Fabian Dill
bc1d0ed583 Update Factorio mod to give free samples
(for now always, probably an option later)
2021-04-06 02:20:13 +02:00
Fabian Dill
635897574f clean up technology handling a bit 2021-04-05 15:37:15 +02:00
Fabian Dill
0eca0b2209 add jinja 2 requirement 2021-04-04 11:27:37 +02:00
Fabian Dill
20b72369d8 allow basic WebHost functionality to work 2021-04-04 03:18:19 +02:00
Fabian Dill
d451145d53 Merge branch 'main' into Archipelago_Main 2021-04-04 03:17:46 +02:00
Fabian Dill
4ab59d522d Make bridge notification less spammy 2021-04-04 01:19:54 +02:00
Fabian Dill
250099f5fd Small adjustments 2021-04-03 20:02:15 +02:00
Fabian Dill
c14a150795 Output Factorio mod as zip 2021-04-03 15:06:32 +02:00
Fabian Dill
91bcd59940 implement Factorio options max_science_pack and tech_cost
also give warnings about deprecated LttP options
also fix FactorioClient.py getting stuck if send an unknown item id
also fix !missing having an extra newline after each entry
also default to no webui
2021-04-03 14:47:49 +02:00
Fabian Dill
b871a688a4 correctly add 4 bows to easy item pool
(found by el0)
2021-04-02 14:55:39 +02:00
Fabian Dill
d225eb9ca8 update readme links 2021-04-01 20:46:43 +02:00
37 changed files with 817 additions and 459 deletions

View File

@@ -24,6 +24,13 @@ class MultiWorld():
plando_connections: List[PlandoConnection]
er_seeds: Dict[int, str]
class AttributeProxy():
def __init__(self, rule):
self.rule = rule
def __getitem__(self, player) -> bool:
return self.rule(player)
def __init__(self, players: int):
# TODO: move per-player settings into new classes per game-type instead of clumping it all together here
@@ -56,15 +63,21 @@ class MultiWorld():
self.dynamic_regions = []
self.dynamic_locations = []
self.spoiler = Spoiler(self)
self.fix_trock_doors = self.AttributeProxy(lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.fix_palaceofdarkness_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.fix_trock_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
for player in range(1, players + 1):
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('tech_tree_layout_prerequisites', {})
set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
set_player_attr('mode', 'open')
set_player_attr('swords', 'random')
set_player_attr('swordless', False)
set_player_attr('difficulty', 'normal')
set_player_attr('item_functionality', 'normal')
set_player_attr('timer', False)
@@ -80,11 +93,6 @@ class MultiWorld():
set_player_attr('powder_patch_required', False)
set_player_attr('ganon_at_pyramid', True)
set_player_attr('ganonstower_vanilla', True)
set_player_attr('sewer_light_cone', self.mode[player] == 'standard')
set_player_attr('fix_trock_doors', self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
set_player_attr('fix_skullwoods_exit', self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
set_player_attr('fix_palaceofdarkness_exit', self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
set_player_attr('fix_trock_exit', self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
set_player_attr('can_access_trock_eyebridge', None)
set_player_attr('can_access_trock_front', None)
set_player_attr('can_access_trock_big_chest', None)
@@ -136,13 +144,9 @@ class MultiWorld():
for hk_option in Options.hollow_knight_options:
set_player_attr(hk_option, False)
self.worlds = []
#for i in range(players):
# self.worlds.append(worlds.alttp.ALTTPWorld({}, i))
@property
def NOTCURSED(self): # not here to stay
return {player: not cursed for player, cursed in self.CURSED.items()}
# self.worlds = []
# for i in range(players):
# self.worlds.append(worlds.alttp.ALTTPWorld({}, i))
def secure(self):
self.random = secrets.SystemRandom()
@@ -726,7 +730,7 @@ class CollectionState(object):
def can_retrieve_tablet(self, player:int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
(self.world.swords[player] == "swordless" and
(self.world.swordless[player] and
self.has("Hammer", player)))
def has_sword(self, player: int) -> bool:
@@ -747,7 +751,7 @@ class CollectionState(object):
def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and
(self.world.swords[player] == "swordless" or
(self.world.swordless[player] or
self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool:
@@ -1111,7 +1115,7 @@ class Location():
@property
def hint_text(self):
return getattr(self, "_hint_text", self.name)
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
class Item():
location: Optional[Location] = None
@@ -1313,7 +1317,7 @@ class Spoiler(object):
'dark_room_logic': self.world.dark_room_logic,
'mode': self.world.mode,
'retro': self.world.retro,
'weapons': self.world.swords,
'swordless': self.world.swordless,
'goal': self.world.goal,
'shuffle': self.world.shuffle,
'item_pool': self.world.difficulty,
@@ -1412,7 +1416,7 @@ class Spoiler(object):
outfile.write('Mode: %s\n' % self.metadata['mode'][player])
outfile.write('Retro: %s\n' %
('Yes' if self.metadata['retro'][player] else 'No'))
outfile.write('Swords: %s\n' % self.metadata['weapons'][player])
outfile.write('Swordless: %s\n' % ('Yes' if self.metadata['swordless'][player] else 'No'))
outfile.write('Goal: %s\n' % self.metadata['goal'][player])
if "triforce" in self.metadata["goal"][player]: # triforce hunt
outfile.write("Pieces available for Triforce: %s\n" %

View File

@@ -311,12 +311,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
args['players'].sort()
current_team = -1
logger.info('Players:')
for team, slot, name in args['players']:
if team != current_team:
logger.info(f' Team #{team + 1}')
current_team = team
logger.info(' %s (Player %d)' % (name, slot))
if args["datapackage_version"] > network_data_package["version"]:
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
@@ -356,7 +356,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": CLientStatus.CLIENT_GOAL}])
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
# Get the server side view of missing as of time of connecting.
# This list is used to only send to the server what is reported as ACTUALLY Missing.

View File

@@ -48,6 +48,11 @@ class FactorioCommandProcessor(ClientCommandProcessor):
return True
return False
def _cmd_connect(self, address: str = "", name="") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.auth = name
return super(FactorioCommandProcessor, self)._cmd_connect(address)
class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor
@@ -76,7 +81,8 @@ async def game_watcher(ctx: FactorioContext):
researches_done_file = os.path.join(script_folder, "research_done.json")
if os.path.exists(researches_done_file):
os.remove(researches_done_file)
from worlds.factorio.Technologies import lookup_id_to_name, tech_table
from worlds.factorio.Technologies import lookup_id_to_name
bridge_counter = 0
try:
while 1:
if os.path.exists(researches_done_file):
@@ -92,8 +98,11 @@ async def game_watcher(ctx: FactorioContext):
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
await asyncio.sleep(1)
else:
research_logger.info("Did not find Factorio Bridge file.")
await asyncio.sleep(5)
bridge_counter += 1
if bridge_counter >= 60:
research_logger.info("Did not find Factorio Bridge file, waiting for mod to run.")
bridge_counter = 1
await asyncio.sleep(1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
@@ -137,11 +146,12 @@ async def factorio_server_watcher(ctx: FactorioContext):
if ctx.rcon_client:
while ctx.send_index < len(ctx.items_received):
item_id = ctx.items_received[ctx.send_index].item
item_name = lookup_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis.")
response = ctx.rcon_client.send_command(f'/ap-get-technology {item_name}')
if response:
factorio_server_logger.info(response)
if item_id not in lookup_id_to_name:
logging.error(f"Cannot send unknown item ID: {item_id}")
else:
item_name = lookup_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis.")
ctx.rcon_client.send_command(f'/ap-get-technology {item_name}')
ctx.send_index += 1
await asyncio.sleep(1)

View File

@@ -982,8 +982,8 @@ async def main():
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--founditems', default=False, action='store_true',
help='Show items found by other players for themselves.')
parser.add_argument('--disable_web_ui', default=False, action='store_true',
help="Turn off emitting a webserver for the webbrowser based user interface.")
parser.add_argument('--web_ui', default=False, action='store_true',
help="Emit a webserver for the webbrowser based user interface.")
args = parser.parse_args()
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
if args.diff_file:
@@ -1002,7 +1002,7 @@ async def main():
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
port = None
if not args.disable_web_ui:
if args.web_ui:
# Find an available port on the host system to use for hosting the websocket server
while True:
port = randrange(49152, 65535)
@@ -1015,7 +1015,7 @@ async def main():
ctx = Context(args.snes, args.connect, args.password, args.founditems, port)
input_task = asyncio.create_task(console_loop(ctx), name="Input")
if not args.disable_web_ui:
if args.web_ui:
ui_socket = websockets.serve(functools.partial(websocket_server, ctx=ctx),
'localhost', port, ping_timeout=None, ping_interval=None)
await ui_socket

204
Main.py
View File

@@ -10,8 +10,7 @@ import pickle
from typing import Dict
from BaseClasses import MultiWorld, CollectionState, Region, Item
from worlds.alttp import ALttPLocation
from worlds.alttp.Items import ItemFactory, item_table, item_name_groups
from worlds.alttp.Items import ItemFactory, item_name_groups
from worlds.alttp.Regions import create_regions, mark_light_world_regions, \
lookup_vanilla_location_to_entrance
from worlds.alttp.InvertedRegions import create_inverted_regions, mark_dark_world_regions
@@ -23,7 +22,7 @@ from Fill import distribute_items_restrictive, flood_items, balance_multiworld_p
from worlds.alttp.Shops import create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes
from Utils import output_path, parse_player_names, get_options, __version__, _version_tuple
from worlds.hk import gen_hollow, set_rules as set_hk_rules
from worlds.hk import gen_hollow
from worlds.hk import create_regions as hk_create_regions
from worlds.factorio import gen_factorio, factorio_create_regions
from worlds.factorio.Mod import generate_mod
@@ -70,7 +69,7 @@ def main(args, seed=None):
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
world.mode = args.mode.copy()
world.swords = args.swords.copy()
world.swordless = args.swordless.copy()
world.difficulty = args.difficulty.copy()
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
@@ -135,6 +134,8 @@ def main(args, seed=None):
import Options
for hk_option in Options.hollow_knight_options:
setattr(world, hk_option, getattr(args, hk_option, {}))
for factorio_option in Options.factorio_options:
setattr(world, factorio_option, getattr(args, factorio_option, {}))
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.rom_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in range(1, world.players + 1)}
@@ -490,7 +491,12 @@ def main(args, seed=None):
for future in roms:
rom_name = future.result()
rom_names.append(rom_name)
minimum_versions = {"server": (0, 0, 2)}
client_versions = {}
minimum_versions = {"server": (0, 0, 3), "clients": client_versions}
games = {}
for slot in world.player_ids:
client_versions[slot] = (0, 0, 3)
games[slot] = world.game[slot]
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
slot, team, rom_name in rom_names}
@@ -498,24 +504,27 @@ def main(args, seed=None):
for player, name in enumerate(team, 1):
if player not in world.alttp_player_ids:
connect_names[name] = (i, player)
multidata = zlib.compress(pickle.dumps({"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player] or
world.game[player] != "A Link to the Past"},
"locations": {
(location.address, location.player):
(location.item.code, location.item.player)
for location in world.get_filled_locations() if
type(location.address) is int},
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"version": tuple(_version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
}), 9)
multidata = zlib.compress(pickle.dumps({
"games": games,
"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player] or
world.game[player] != "A Link to the Past"},
"locations": {
(location.address, location.player):
(location.item.code, location.item.player)
for location in world.get_filled_locations() if
type(location.address) is int},
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"version": tuple(_version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
}), 9)
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
f.write(bytes([1])) # version of format
@@ -542,155 +551,8 @@ def main(args, seed=None):
return world
def copy_world(world):
# ToDo: Not good yet
# delete now?
ret = MultiWorld(world.players, world.shuffle, world.logic, world.mode, world.swords, world.difficulty, world.item_functionality, world.timer, world.progressive, world.goal, world.algorithm, world.accessibility, world.shuffle_ganon, world.retro, world.custom, world.customitemarray, world.hints)
ret.teams = world.teams
ret.player_names = copy.deepcopy(world.player_names)
ret.remote_items = world.remote_items.copy()
ret.required_medallions = world.required_medallions.copy()
ret.swamp_patch_required = world.swamp_patch_required.copy()
ret.ganon_at_pyramid = world.ganon_at_pyramid.copy()
ret.powder_patch_required = world.powder_patch_required.copy()
ret.ganonstower_vanilla = world.ganonstower_vanilla.copy()
ret.treasure_hunt_count = world.treasure_hunt_count.copy()
ret.treasure_hunt_icon = world.treasure_hunt_icon.copy()
ret.sewer_light_cone = world.sewer_light_cone.copy()
ret.light_world_light_cone = world.light_world_light_cone
ret.dark_world_light_cone = world.dark_world_light_cone
ret.seed = world.seed
ret.can_access_trock_eyebridge = world.can_access_trock_eyebridge.copy()
ret.can_access_trock_front = world.can_access_trock_front.copy()
ret.can_access_trock_big_chest = world.can_access_trock_big_chest.copy()
ret.can_access_trock_middle = world.can_access_trock_middle.copy()
ret.can_take_damage = world.can_take_damage
ret.difficulty_requirements = world.difficulty_requirements.copy()
ret.fix_fake_world = world.fix_fake_world.copy()
ret.mapshuffle = world.mapshuffle.copy()
ret.compassshuffle = world.compassshuffle.copy()
ret.keyshuffle = world.keyshuffle.copy()
ret.bigkeyshuffle = world.bigkeyshuffle.copy()
ret.crystals_needed_for_ganon = world.crystals_needed_for_ganon.copy()
ret.crystals_needed_for_gt = world.crystals_needed_for_gt.copy()
ret.open_pyramid = world.open_pyramid.copy()
ret.boss_shuffle = world.boss_shuffle.copy()
ret.enemy_shuffle = world.enemy_shuffle.copy()
ret.enemy_health = world.enemy_health.copy()
ret.enemy_damage = world.enemy_damage.copy()
ret.beemizer = world.beemizer.copy()
ret.timer = world.timer.copy()
ret.shufflepots = world.shufflepots.copy()
ret.shuffle_prizes = world.shuffle_prizes.copy()
ret.shop_shuffle = world.shop_shuffle.copy()
ret.shop_shuffle_slots = world.shop_shuffle_slots.copy()
ret.dark_room_logic = world.dark_room_logic.copy()
ret.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.copy()
ret.game = world.game.copy()
ret.completion_condition = world.completion_condition.copy()
for player in world.alttp_player_ids:
if world.mode[player] != 'inverted':
create_regions(ret, player)
else:
create_inverted_regions(ret, player)
create_shops(ret, player)
create_dungeons(ret, player)
for player in world.hk_player_ids:
hk_create_regions(ret, player)
copy_dynamic_regions_and_locations(world, ret)
# copy bosses
for dungeon in world.dungeons:
for level, boss in dungeon.bosses.items():
ret.get_dungeon(dungeon.name, dungeon.player).bosses[level] = boss
for shop in world.shops:
copied_shop = ret.get_region(shop.region.name, shop.region.player).shop
copied_shop.inventory = copy.copy(shop.inventory)
# connect copied world
for region in world.regions:
copied_region = ret.get_region(region.name, region.player)
copied_region.is_light_world = region.is_light_world
copied_region.is_dark_world = region.is_dark_world
for exit in copied_region.exits:
old_connection = world.get_entrance(exit.name, exit.player).connected_region
exit.connect(ret.get_region(old_connection.name, old_connection.player))
# fill locations
for location in world.get_locations():
if location.item is not None:
item = Item(location.item.name, location.item.advancement, location.item.code, player = location.item.player)
ret.get_location(location.name, location.player).item = item
item.location = ret.get_location(location.name, location.player)
item.world = ret
item.type = location.item.type
item.game = location.item.game
if location.event:
ret.get_location(location.name, location.player).event = True
if location.locked:
ret.get_location(location.name, location.player).locked = True
# copy remaining itempool. No item in itempool should have an assigned location
for old_item in world.itempool:
item = Item(old_item.name, old_item.advancement, old_item.code, player = old_item.player)
item.type = old_item.type
ret.itempool.append(item)
for old_item in world.precollected_items:
item = Item(old_item.name, old_item.advancement, old_item.code, player = old_item.player)
item.type = old_item.type
ret.push_precollected(item)
# copy progress items in state
ret.state.prog_items = world.state.prog_items.copy()
ret.state.stale = {player: True for player in range(1, world.players + 1)}
for player in world.alttp_player_ids:
set_rules(ret, player)
for player in world.hk_player_ids:
set_hk_rules(ret, player)
return ret
def copy_dynamic_regions_and_locations(world, ret):
for region in world.dynamic_regions:
new_reg = Region(region.name, region.type, region.hint_text, region.player)
ret.regions.append(new_reg)
ret.initialize_regions([new_reg])
ret.dynamic_regions.append(new_reg)
# Note: ideally exits should be copied here, but the current use case (Take anys) do not require this
if region.shop:
new_reg.shop = region.shop.__class__(new_reg, region.shop.room_id, region.shop.shopkeeper_config,
region.shop.custom, region.shop.locked, region.shop.sram_offset)
ret.shops.append(new_reg.shop)
for location in world.dynamic_locations:
new_reg = ret.get_region(location.parent_region.name, location.parent_region.player)
new_loc = ALttPLocation(location.player, location.name, location.address, location.crystal, location.hint_text, new_reg)
# todo: this is potentially dangerous. later refactor so we
# can apply dynamic region rules on top of copied world like other rules
new_loc.access_rule = location.access_rule
new_loc.always_allow = location.always_allow
new_loc.item_rule = location.item_rule
new_reg.locations.append(new_loc)
ret.clear_location_cache()
def create_playthrough(world):
"""Destructive to the world it is run on."""
"""Destructive to the world while it is run, damage gets repaired afterwards."""
# get locations containing progress items
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
state_cache = [None]

View File

@@ -31,7 +31,7 @@ from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_
import Utils
from Utils import get_item_name_from_id, get_location_name_from_address, \
_version_tuple, restricted_loads, Version
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
colorama.init()
lttp_console_names = frozenset(set(Items.item_table) | set(Items.item_name_groups) | set(Regions.lookup_name_to_id))
@@ -110,7 +110,8 @@ class Context(Node):
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['AP']
self.minimum_client_versions: typing.Dict[typing.Tuple[int, int], Utils.Version] = {}
self.games = {}
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
@@ -119,21 +120,23 @@ class Context(Node):
self._load(self._decompress(data), use_embedded_server_options)
self.data_filename = multidatapath
def _decompress(self, data: bytes) -> dict:
@staticmethod
def _decompress(data: bytes) -> dict:
format_version = data[0]
if format_version != 1:
raise Exception("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils._version_tuple:
raise RuntimeError(f"Supplied Multidata requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils._version_tuple}")
clients_ver = decoded_obj["minimum_versions"].get("clients", [])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
for team, player, version in clients_ver:
self.minimum_client_versions[team, player] = Utils.Version(*version)
for player, version in clients_ver.items():
self.minimum_client_versions[player] = Utils.Version(*version)
for team, names in enumerate(decoded_obj['names']):
for player, name in enumerate(names, 1):
@@ -144,12 +147,13 @@ class Context(Node):
self.locations = decoded_obj['locations']
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
for player, loc_data in decoded_obj["er_hint_data"].items()}
self.games = decoded_obj["games"]
if use_embedded_server_options:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
def get_players_package(self):
return [(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
def _set_options(self, server_options: dict):
for key, value in server_options.items():
@@ -331,11 +335,14 @@ async def server(websocket, path, ctx: Context):
ctx.endpoints.append(client)
try:
logging.info("Incoming")
if ctx.log_network:
logging.info("Incoming connection")
await on_client_connected(ctx, client)
logging.info("Sent Room Info")
if ctx.log_network:
logging.info("Sent Room Info")
async for data in websocket:
logging.info(data)
if ctx.log_network:
logging.info(f"Incoming message: {data}")
for msg in decode(data):
await process_client_cmd(ctx, client, msg)
except Exception as e:
@@ -350,7 +357,7 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': ctx.password is not None,
'players': [(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name)) for client
'players': [NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name), client.name) for client
in ctx.endpoints if client.auth],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
@@ -525,10 +532,15 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
parts = []
NetUtils.add_json_text(parts, net_item.player, type=NetUtils.JSONTypes.player_id)
NetUtils.add_json_text(parts, " sent ")
NetUtils.add_json_text(parts, net_item.item, type=NetUtils.JSONTypes.item_id)
NetUtils.add_json_text(parts, " to ")
NetUtils.add_json_text(parts, receiving_player, type=NetUtils.JSONTypes.player_id)
if net_item.player == receiving_player:
NetUtils.add_json_text(parts, " found their ")
NetUtils.add_json_text(parts, net_item.item, type=NetUtils.JSONTypes.item_id)
else:
NetUtils.add_json_text(parts, " sent ")
NetUtils.add_json_text(parts, net_item.item, type=NetUtils.JSONTypes.item_id)
NetUtils.add_json_text(parts, " to ")
NetUtils.add_json_text(parts, receiving_player, type=NetUtils.JSONTypes.player_id)
NetUtils.add_json_text(parts, " (")
NetUtils.add_json_text(parts, net_item.location, type=NetUtils.JSONTypes.location_id)
NetUtils.add_json_text(parts, ")")
@@ -794,7 +806,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client)
if locations:
texts = [f'Missing: {get_item_name_from_id(location)}\n' for location in locations]
texts = [f'Missing: {get_item_name_from_id(location)}' for location in locations]
texts.append(f"Found {len(locations)} missing location checks")
self.ctx.notify_client_multiple(self.client, texts)
else:
@@ -967,6 +979,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
errors.add('InvalidSlot')
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
if args['game'] != game:
errors.add('InvalidSlot')
# this can only ever be 0 or 1 elements
clients = [c for c in ctx.endpoints if c.auth and c.slot == slot and c.team == team]
if clients:
@@ -982,13 +997,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.name = ctx.player_names[(team, slot)]
client.team = team
client.slot = slot
minver = Utils.Version(*(ctx.minimum_client_versions.get((team, slot), (0,0,0))))
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
if ctx.compatibility == 1 and "AP" not in args['tags']:
errors.add('IncompatibleVersion')
#only exact version match allowed
# only exact version match allowed
elif ctx.compatibility == 0 and args['version'] != _version_tuple:
errors.add('IncompatibleVersion')
if errors:
@@ -1004,7 +1019,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
"team": client.team, "slot": client.slot,
"players": ctx.get_players_package(),
"missing_locations": get_missing_checks(ctx, client),
"checked_locations": get_checked_checks(ctx, client)}]
"checked_locations": get_checked_checks(ctx, client),
}]
items = get_received_items(ctx, client.team, client.slot)
if items:
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": tuplize_received_items(items)})
@@ -1309,6 +1325,7 @@ def parse_args() -> argparse.Namespace:
#1 -> recommended for friendly racing, tries to block third party clients
#0 -> recommended for tournaments to force a level playing field, only allow an exact version match
""")
parser.add_argument('--log_network', default=defaults["log_network"], action="store_true")
args = parser.parse_args()
return args
@@ -1345,7 +1362,7 @@ async def main(args: argparse.Namespace):
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility)
ctx.log_network = args.log_network
data_filename = args.multidata
try:

View File

@@ -198,11 +198,15 @@ def main(args=None, callback=ERmain):
pre_rolled["original_seed_name"] = seedname
pre_rolled["pre_rolled"] = vars(settings).copy()
if "plando_items" in pre_rolled["pre_rolled"]:
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in pre_rolled["pre_rolled"]["plando_items"]]
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in
pre_rolled["pre_rolled"]["plando_items"]]
if "plando_connections" in pre_rolled["pre_rolled"]:
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in pre_rolled["pre_rolled"]["plando_connections"]]
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in
pre_rolled["pre_rolled"][
"plando_connections"]]
with open(os.path.join(args.outputpath if args.outputpath else ".", f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
with open(os.path.join(args.outputpath if args.outputpath else ".",
f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
yaml.dump(pre_rolled, f)
for k, v in vars(settings).items():
if v is not None:
@@ -294,7 +298,8 @@ def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name] += 1
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
NUMBER=(name_counter[name] if name_counter[name] > 1 else ''),
NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
return new_name.strip().replace(' ', '_')[:16]
@@ -315,17 +320,44 @@ available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if leve
boss_shuffle_options = {None: 'none',
'none': 'none',
'basic': 'basic',
'normal': 'normal',
'full': 'full',
'chaos': 'chaos',
'singularity': 'singularity'
}
goals = {
'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt',
}
# remove sometime before 1.0.0, warn before
legacy_boss_shuffle_options = {
# legacy, will go away:
'simple': 'basic',
'random': 'full',
'normal': 'full'
}
legacy_goals = {
'dungeons': 'bosses',
'fast_ganon': 'crystals',
}
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
new_options = set(new_weights) - set(weights)
@@ -337,6 +369,7 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
f'This is probably in error.')
return weights
def roll_linked_options(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]:
@@ -349,7 +382,8 @@ def roll_linked_options(weights: dict) -> dict:
weights = update_weights(weights, option_set["options"], "Linked", option_set["name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom", option_set["name"])
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom",
option_set["name"])
weights["rom"] = rom_weights
else:
logging.debug(f"linked option {option_set['name']} skipped.")
@@ -358,10 +392,11 @@ def roll_linked_options(weights: dict) -> dict:
f"Please fix your linked option.") from e
return weights
def roll_triggers(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
for option_set in weights["triggers"]:
for i, option_set in enumerate(weights["triggers"]):
try:
key = get_choice("option_name", option_set)
if key not in weights:
@@ -373,18 +408,25 @@ def roll_triggers(weights: dict) -> dict:
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
if "options" in option_set:
weights = update_weights(weights, option_set["options"], "Triggered", option_set["option_name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Triggered Rom", option_set["option_name"])
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Triggered Rom",
option_set["option_name"])
weights["rom"] = rom_weights
weights[key] = result
except Exception as e:
raise ValueError(f"A trigger is destroyed. "
raise ValueError(f"Your trigger number {i+1} is destroyed. "
f"Please fix your triggers.") from e
return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
if boss_shuffle in legacy_boss_shuffle_options:
new_boss_shuffle = legacy_boss_shuffle_options[boss_shuffle]
logging.warning(f"Boss shuffle {boss_shuffle} is deprecated, "
f"please use {new_boss_shuffle} instead")
return new_boss_shuffle
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
@@ -392,6 +434,10 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in legacy_boss_shuffle_options:
remainder_shuffle = legacy_boss_shuffle_options[boss_shuffle]
logging.warning(f"Boss shuffle {boss} is deprecated, "
f"please use {remainder_shuffle} instead")
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss:
@@ -419,7 +465,7 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses", ))):
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
if "pre_rolled" in weights:
pre_rolled = weights["pre_rolled"]
@@ -435,7 +481,8 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
if "plando_connections" in pre_rolled:
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
connection["exit"],
connection["direction"]) for connection in pre_rolled["plando_connections"]]
connection["direction"]) for connection in
pre_rolled["plando_connections"]]
if "connections" not in plando_options and pre_rolled["plando_connections"]:
raise Exception("Connection Plando is turned off. Reusing this pre-rolled setting not permitted.")
@@ -486,11 +533,22 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
for option_name, option in Options.hollow_knight_options.items():
setattr(ret, option_name, option.from_any(get_choice(option_name, weights, True)))
elif ret.game == "Factorio":
pass
for option_name, option in Options.factorio_options.items():
if option_name in weights:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
else:
setattr(ret, option_name, option.from_any(option.default))
elif ret.game == "Minecraft":
for option_name, option in Options.minecraft_options.items():
if option_name in weights:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
else:
setattr(ret, option_name, option.from_any(option.default))
else:
raise Exception(f"Unsupported game {ret.game}")
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
glitches_required = get_choice('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'minor_glitches']:
@@ -533,17 +591,11 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
goal = get_choice('goals', weights, 'ganon')
ret.goal = {'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt'
}[goal]
if goal in legacy_goals:
logging.warning(f"Goal {goal} is depcrecated, please use {legacy_goals[goal]} instead.")
goal = legacy_goals[goal]
ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole
@@ -587,11 +639,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.hints = get_choice('hints', weights)
ret.swords = {'randomized': 'random',
'assured': 'assured',
'vanilla': 'vanilla',
'swordless': 'swordless'
}[get_choice('weapons', weights, 'assured')]
ret.swordless = get_choice('swordless', weights, False)
ret.difficulty = get_choice('item_pool', weights)
@@ -602,6 +650,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.enemy_shuffle = bool(get_choice('enemy_shuffle', weights, False))
ret.killable_thieves = get_choice('killable_thieves', weights, False)
ret.tile_shuffle = get_choice('tile_shuffle', weights, False)
ret.bush_shuffle = get_choice('bush_shuffle', weights, False)
@@ -772,5 +821,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.quickswap = True
ret.sprite = "Link"
if __name__ == '__main__':
main()

View File

@@ -99,6 +99,7 @@ class Node:
def __init__(self):
self.endpoints = []
super(Node, self).__init__()
self.log_network = 0
def broadcast_all(self, msgs):
msgs = self.dumper(msgs)
@@ -114,6 +115,9 @@ class Node:
except websockets.ConnectionClosed:
logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str):
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
@@ -123,6 +127,9 @@ class Node:
except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
async def disconnect(self, endpoint):
if endpoint in self.endpoints:

View File

@@ -23,6 +23,7 @@ class AssembleOptions(type):
class Option(metaclass=AssembleOptions):
value: int
name_lookup: typing.Dict[int, str]
default = 0
def __repr__(self):
return f"{self.__class__.__name__}({self.get_option_name()})"
@@ -47,6 +48,7 @@ class Option(metaclass=AssembleOptions):
class Toggle(Option):
option_false = 0
option_true = 1
default = 0
def __init__(self, value: int):
self.value = value
@@ -86,6 +88,7 @@ class Toggle(Option):
def get_option_name(self):
return bool(self.value)
class Choice(Option):
def __init__(self, value: int):
self.value: int = value
@@ -101,7 +104,9 @@ class Choice(Option):
@classmethod
def from_any(cls, data: typing.Any):
return cls.from_text(data)
if type(data) == int and data in cls.options.values():
return cls(data)
return cls.from_text(str(data))
class Logic(Choice):
@@ -231,7 +236,61 @@ hollow_knight_skip_options: typing.Dict[str, type(Option)] = {
"SHADESKIPS": Toggle,
}
hollow_knight_options: typing.Dict[str, Option] = {**hollow_knight_randomize_options, **hollow_knight_skip_options}
hollow_knight_options: typing.Dict[str, type(Option)] = {**hollow_knight_randomize_options, **hollow_knight_skip_options}
class MaxSciencePack(Choice):
option_automation_science_pack = 0
option_logistic_science_pack = 1
option_military_science_pack = 2
option_chemical_science_pack = 3
option_production_science_pack = 4
option_utility_science_pack = 5
option_space_science_pack = 6
default = 6
def get_allowed_packs(self):
return {option.replace("_", "-") for option, value in self.options.items()
if value <= self.value}
class TechCost(Choice):
option_very_easy = 0
option_easy = 1
option_kind = 2
option_normal = 3
option_hard = 4
option_very_hard = 5
option_insane = 6
default = 3
class FreeSamples(Choice):
option_none = 0
option_single_craft = 1
option_half_stack = 2
option_stack = 3
default = 3
class TechTreeLayout(Choice):
option_single = 0
option_small_diamonds = 1
option_medium_diamonds = 2
option_pyramid = 3
option_funnel = 4
default = 0
class Visibility(Choice):
option_none = 0
option_sending = 1
default = 0
factorio_options: typing.Dict[str, type(Option)] = {"max_science_pack": MaxSciencePack,
"tech_tree_layout": TechTreeLayout,
"tech_cost": TechCost,
"free_samples": FreeSamples,
"visibility": Visibility}
minecraft_options: typing.Dict[str, type(Option)] = {}
if __name__ == "__main__":
import argparse

View File

@@ -1,12 +1,13 @@
# [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/Berserker66/MultiWorld-Utilities/releases)
# [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio (Alpha Status)
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial).
Downloads can be found at [Releases](https://github.com/Berserker66/MultiWorld-Utilities/releases), including compiled
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
windows binaries.
## History
@@ -25,7 +26,7 @@ We recognize that there is a strong community of incredibly smart people that ha
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
## Running Archipelago
For most people all you need to do is head over to the [releases](https://github.com/Berserker66/MultiWorld-Utilities/releases) page then download and run the appropriate installer. The installers function on Windows only.
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/Berserker66/MultiWorld-Utilities/wiki/Running-from-source).

View File

@@ -12,7 +12,7 @@ class Version(typing.NamedTuple):
minor: int
build: int
__version__ = "0.0.2"
__version__ = "0.0.3"
_version_tuple = tuplize_version(__version__)
import builtins
@@ -84,9 +84,13 @@ def local_path(*path):
# cx_Freeze
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
# we are running in a normal Python environment
import __main__
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
if hasattr(__main__, "__file__"):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
# pray
local_path.cached_path = os.path.abspath(".")
return os.path.join(local_path.cached_path, *path)
@@ -190,6 +194,7 @@ def get_default_options() -> dict:
"remaining_mode": "goal",
"auto_shutdown": 0,
"compatibility": 2,
"log_network": 0
},
"multi_mystery_options": {
"teams": 1,
@@ -385,7 +390,7 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus"}:
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
import NetUtils
return getattr(NetUtils, name)
# Forbid everything else.

View File

@@ -46,6 +46,7 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "simple"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
app.autoversion = True

View File

@@ -9,13 +9,13 @@ import socket
import threading
import time
import random
import zlib
import pickle
from .models import *
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, parse_yaml
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -27,7 +27,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
self.ctx.save()
self.output(f"Registered Twitch Stream https://www.twitch.tv/{user}")
return True
elif platform.lower().startswith("y"): # youtube
elif platform.lower().startswith("y"): # youtube
self.ctx.video[self.client.team, self.client.slot] = "Youtube", user
self.ctx.save()
self.output(f"Registered Youtube Stream for {user}")
@@ -81,16 +81,16 @@ class WebHostContext(Context):
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
existing_savegame = Room.get(id=self.room_id).multisave
if existing_savegame:
self.set_save(existing_savegame)
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving()
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
def _save(self, exit_save:bool = False) -> bool:
room = Room.get(id=self.room_id)
room.multisave = self.get_save()
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.utcnow()

View File

@@ -16,7 +16,7 @@ def download_patch(room_id, patch_id):
room = Room.get(id=room_id)
last_port = room.last_port
patch_data = update_patch_data(patch.data, server=f"{app.config['HOSTNAME']}:{last_port}")
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = io.BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}.apbp"

View File

@@ -9,7 +9,7 @@
{% macro list_patches_room(room) %}
{% if room.seed.patches %}
<ul>
{% for patch in patches|list|sort(attribute="player") %}
{% for patch in room.seed.patches|list|sort(attribute="player_id") %}
<li><a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}">
Patch for player {{ patch.player_id }} - {{ patch.player_name }}</a></li>
{% endfor %}

View File

@@ -8,6 +8,7 @@ from uuid import UUID
from worlds.alttp import Items, Regions
from WebHostLib import app, cache, Room
from NetUtils import Hint
from Utils import restricted_loads
def get_id(item_name):
@@ -253,7 +254,7 @@ for item_name, data in Items.item_table.items():
big_key_ids[area] = data[2]
ids_big_key[data[2]] = area
from MultiServer import get_item_name_from_id
from MultiServer import get_item_name_from_id, Context
def attribute_item(inventory, team, recipient, item):
@@ -295,9 +296,9 @@ def get_static_room_data(room: Room):
result = _multidata_cache.get(room.seed.id, None)
if result:
return result
multidata = room.seed.multidata
multidata = Context._decompress(room.seed.multidata)
# in > 100 players this can take a bit of time and is the main reason for the cache
locations = {tuple(k): tuple(v) for k, v in multidata['locations']}
locations = multidata['locations']
names = multidata["names"]
seed_checks_in_area = checks_in_area.copy()
@@ -308,30 +309,24 @@ def get_static_room_data(room: Room):
for area, checks in key_only_locations.items():
seed_checks_in_area[area] += len(checks)
seed_checks_in_area["Total"] = 249
if "checks_in_area" not in multidata:
player_checks_in_area = {playernumber: (seed_checks_in_area if use_door_tracker and
(0x140031, playernumber) in locations else checks_in_area)
for playernumber in range(1, len(names[0]) + 1)}
player_location_to_area = {playernumber: location_to_area
for playernumber in range(1, len(names[0]) + 1)}
else:
player_checks_in_area = {playernumber: {areaname: len(multidata["checks_in_area"][f'{playernumber}'][areaname])
if areaname != "Total" else multidata["checks_in_area"][f'{playernumber}']["Total"]
for areaname in ordered_areas}
for playernumber in range(1, len(names[0]) + 1)}
player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][f'{playernumber}'])
for playernumber in range(1, len(names[0]) + 1)}
player_checks_in_area = {playernumber: {areaname: len(multidata["checks_in_area"][playernumber][areaname])
if areaname != "Total" else multidata["checks_in_area"][playernumber]["Total"]
for areaname in ordered_areas}
for playernumber in range(1, len(names[0]) + 1)}
player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber])
for playernumber in range(1, len(names[0]) + 1)}
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
for _, (item_id, item_player) in multidata["locations"]:
for _, (item_id, item_player) in locations.items():
if item_id in ids_big_key:
player_big_key_locations[item_player].add(ids_big_key[item_id])
if item_id in ids_small_key:
player_small_key_locations[item_player].add(ids_small_key[item_id])
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
player_big_key_locations, player_small_key_locations, multidata["precollected_items"]
_multidata_cache[room.seed.id] = result
return result
@@ -348,7 +343,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
abort(404)
# Collect seed information and pare it down to a single player
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations = get_static_room_data(room)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations, precollected_items = get_static_room_data(room)
player_name = names[tracked_team][tracked_player - 1]
seed_checks_in_area = seed_checks_in_area[tracked_player]
location_to_area = player_location_to_area[tracked_player]
@@ -356,13 +351,18 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
checks_done = {loc_name: 0 for loc_name in default_locations}
# Add starting items to inventory
starting_items = room.seed.multidata.get("precollected_items", None)[tracked_player - 1]
starting_items = precollected_items[tracked_player - 1]
if starting_items:
for item_id in starting_items:
attribute_item_solo(inventory, item_id)
if room.multisave:
multisave = restricted_loads(room.multisave)
else:
multisave = {}
# Add items to player inventory
for (ms_team, ms_player), locations_checked in room.multisave.get("location_checks", {}):
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}):
# logging.info(f"{ms_team}, {ms_player}, {locations_checked}")
# Skip teams and players not matching the request
@@ -380,7 +380,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int):
checks_done["Total"] += 1
# Note the presence of the triforce item
for (ms_team, ms_player), game_state in room.multisave.get("client_game_state", []):
for (ms_team, ms_player), game_state in multisave.get("client_game_state", []):
# Skip teams and players not matching the request
if ms_team != tracked_team or ms_player != tracked_player:
continue
@@ -484,7 +484,8 @@ def getTracker(tracker: UUID):
room = Room.get(tracker=tracker)
if not room:
abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, player_small_key_locations = get_static_room_data(room)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, player_big_key_locations, \
player_small_key_locations, precollected_items = get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1)}
for teamnumber, team in enumerate(names)}
@@ -492,14 +493,18 @@ def getTracker(tracker: UUID):
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1)}
for teamnumber, team in enumerate(names)}
precollected_items = room.seed.multidata.get("precollected_items", None)
hints = {team: set() for team in range(len(names))}
if "hints" in room.multisave:
for key, hintdata in room.multisave["hints"]:
if room.multisave:
multisave = restricted_loads(room.multisave)
else:
multisave = {}
if "hints" in multisave:
for key, hintdata in multisave["hints"]:
for hint in hintdata:
hints[key[0]].add(Hint(*hint))
for (team, player), locations_checked in room.multisave.get("location_checks", {}):
for (team, player), locations_checked in multisave.get("location_checks", {}):
if precollected_items:
precollected = precollected_items[player - 1]
for item_id in precollected:
@@ -513,7 +518,7 @@ def getTracker(tracker: UUID):
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
for (team, player), game_state in room.multisave.get("client_game_state", []):
for (team, player), game_state in multisave.get("client_game_state", []):
if game_state:
inventory[team][player][106] = 1 # Triforce
@@ -525,7 +530,7 @@ def getTracker(tracker: UUID):
activity_timers = {}
now = datetime.datetime.utcnow()
for (team, player), timestamp in room.multisave.get("client_activity_timers", []):
for (team, player), timestamp in multisave.get("client_activity_timers", []):
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
player_names = {}
@@ -533,12 +538,12 @@ def getTracker(tracker: UUID):
for player, name in enumerate(names, 1):
player_names[(team, player)] = name
long_player_names = player_names.copy()
for (team, player), alias in room.multisave.get("name_aliases", []):
for (team, player), alias in multisave.get("name_aliases", []):
player_names[(team, player)] = alias
long_player_names[(team, player)] = f"{alias} ({long_player_names[(team, player)]})"
video = {}
for (team, player), data in room.multisave.get("video", []):
for (team, player), data in multisave.get("video", []):
video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,

View File

@@ -2,6 +2,7 @@ import json
import zlib
import zipfile
import logging
import MultiServer
from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, select
@@ -40,15 +41,18 @@ def uploads():
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
elif file.filename.endswith(".apbp"):
splitted = file.filename.split("/")[-1][3:].split("P", 1)
player = int(splitted[1].split(".")[0].split("_")[0])
patches.add(Patch(data=zfile.open(file, "r").read(), player=player))
player_id, player_name = splitted[1].split(".")[0].split("_")
patches.add(Patch(data=zfile.open(file, "r").read(), player_name=player_name, player_id=player_id))
elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
elif file.filename.endswith(".archipelago"):
try:
multidata = json.loads(zlib.decompress(zfile.open(file).read()).decode("utf-8-sig"))
multidata = zfile.open(file).read()
MultiServer.Context._decompress(multidata)
except:
flash("Could not load multidata. File may be corrupted or incompatible.")
else:
multidata = zfile.open(file).read()
if multidata:
commit() # commit patches
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=session["_id"])
@@ -61,9 +65,11 @@ def uploads():
flash("No multidata was found in the zip file, which is required.")
else:
try:
multidata = json.loads(zlib.decompress(file.read()).decode("utf-8-sig"))
multidata = file.read()
MultiServer.Context._decompress(multidata)
except:
flash("Could not load multidata. File may be corrupted or incompatible.")
raise
else:
seed = Seed(multidata=multidata, owner=session["_id"])
commit() # place into DB and generate ids

23
data/factorio/mod/lib.lua Normal file
View File

@@ -0,0 +1,23 @@
function filter_ingredients(ingredients)
local new_ingredient_list = {}
for _, ingredient_table in pairs(ingredients) do
if allowed_ingredients[ingredient_table[1]] then -- name of ingredient_table
table.insert(new_ingredient_list, ingredient_table)
end
end
return new_ingredient_list
end
function get_any_stack_size(name)
local item = game.item_prototypes[name]
if item ~= nil then
return item.stack_size
end
item = game.equipment_prototypes[name]
if item ~= nil then
return item.stack_size
end
-- failsafe
return 1
end

View File

@@ -1,19 +1,43 @@
require "lib"
-- for testing
script.on_event(defines.events.on_tick, function(event)
if event.tick%600 == 0 then
dumpTech()
dumpTech(game.forces["player"])
end
end)
-- hook into researches done
script.on_event(defines.events.on_research_finished, function(event)
game.print("Research done")
dumpTech()
local technology = event.research
dumpTech(technology.force)
{% if free_samples %}
local players = technology.force.players
if technology.effects then
for _, effect in pairs(technology.effects) do
if effect.type == "unlock-recipe" then
local recipe = game.recipe_prototypes[effect.recipe]
for _, result in pairs(recipe.products) do
if result.type == "item" and result.amount then
{% if free_samples == 1 %}
local new = {count=result.amount, name=result.name}
{% elif free_samples == 2 %}
local new = {count=get_any_stack_size(result.name) * 0.5, name=result.name}
{% else %}
local new = {count=get_any_stack_size(result.name), name=result.name}
{% endif %}
for _, player in pairs(players) do
player.insert(new)
end
end
end
end
end
end
{% endif %}
end)
function dumpTech()
local force = game.forces["player"]
function dumpTech(force)
local data_collection = {}
for tech_name, tech in pairs(force.technologies) do
if tech.researched and string.find(tech_name, "ap-") == 1 then
@@ -30,7 +54,7 @@ function dumpGameInfo()
local data_collection = {}
local force = game.forces["player"]
for tech_name, tech in pairs(force.technologies) do
if tech.enabled then
if tech.enabled and tech.research_unit_count_formula == nil then
local tech_data = {}
local unlocks = {}
tech_data["unlocks"] = unlocks
@@ -89,7 +113,8 @@ commands.add_command("ap-get-info-dump", "Dump Game Info, used by Archipelago.",
end)
commands.add_command("ap-sync", "Run manual Research Sync with Archipelago.", function(call)
dumpTech()
dumpTech(game.players[call.player_index].force)
game.print("Wrote bridge file.")
end)
commands.add_command("ap-get-technology", "Grant a technology, used by the Archipelago Client.", function(call)

View File

@@ -1,23 +1,40 @@
-- this file gets written automatically by the Archipelago Randomizer and is in its raw form a Jinja2 Template
require('lib')
local technologies = data.raw["technology"]
local original_tech
local new_tree_copy
allowed_ingredients = {}
{%- for ingredient in allowed_science_packs %}
allowed_ingredients["{{ingredient}}"]= 1
{% endfor %}
local template_tech = table.deepcopy(technologies["automation"])
{#- ensure the copy unlocks nothing #}
template_tech.unlocks = {}
template_tech.upgrade = false
template_tech.effects = {}
template_tech.prerequisites = {}
function prep_copy(new_copy, old_tech)
old_tech.enabled = false
new_copy.unit = table.deepcopy(old_tech.unit)
new_copy.unit.ingredients = filter_ingredients(new_copy.unit.ingredients)
end
{# each randomized tech gets set to be invisible, with new nodes added that trigger those #}
{%- for original_tech_name, item_name, receiving_player in locations %}
original_tech = technologies["{{original_tech_name}}"]
{#- the tech researched by the local player #}
new_tree_copy = table.deepcopy(template_tech)
new_tree_copy.name = "ap-{{ tech_table[original_tech_name] }}-"{# use AP ID #}
{#- hide and disable original tech; which will be shown, unlocked and enabled by AP Client #}
original_tech.enabled = false
{#- copy original tech costs #}
new_tree_copy.unit = table.deepcopy(original_tech.unit)
{% if item_name in tech_table %}
prep_copy(new_tree_copy, original_tech)
{% if tech_cost != 1 %}
if new_tree_copy.unit.count then
new_tree_copy.unit.count = math.max(1, math.floor(new_tree_copy.unit.count * {{ tech_cost_scale }}))
end
{% endif %}
{% if item_name in tech_table and visibility %}
{#- copy Factorio Technology Icon #}
new_tree_copy.icon = table.deepcopy(technologies["{{ item_name }}"].icon)
new_tree_copy.icons = table.deepcopy(technologies["{{ item_name }}"].icons)
@@ -28,7 +45,13 @@ new_tree_copy.icon = "__{{ mod_name }}__/graphics/icons/ap.png"
new_tree_copy.icons = nil
new_tree_copy.icon_size = 512
{% endif %}
{#- add new technology to game #}
{#- connect Technology #}
{%- if original_tech_name in tech_tree_layout_prerequisites %}
{%- for prerequesite in tech_tree_layout_prerequisites[original_tech_name] %}
table.insert(new_tree_copy.prerequisites, "ap-{{ tech_table[prerequesite] }}-")
{% endfor %}
{% endif -%}
{#- add new Technology to game #}
data:extend{new_tree_copy}
{% endfor %}

View File

@@ -1,8 +1,17 @@
[technology-name]
{% for original_tech_name, item_name, receiving_player in locations %}
{%- if visibility %}
ap-{{ tech_table[original_tech_name] }}-={{ player_names[receiving_player] }}'s {{ item_name }}
{%- else %}
ap-{{ tech_table[original_tech_name] }}-= An Archipelago Sendable
{%- endif %}
{% endfor %}
[technology-description]
{% for original_tech_name, item_name, receiving_player in locations %}
"ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends {{ item_name }} to {{ player_names[receiving_player] }}.
{%- if visibility %}
ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends {{ item_name }} to {{ player_names[receiving_player] }}.
{%- else %}
ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends something to someone.
{%- endif %}
{% endfor %}

File diff suppressed because one or more lines are too long

View File

@@ -40,6 +40,8 @@ server_options:
# 1 -> Recommended for friendly racing, only allow Berserker's Multiworld, to disallow old /getitem for example
# 0 -> Recommended for tournaments to force a level playing field, only allow an exact version match
compatibility: 2
# log all server traffic, mostly for dev use
log_network: 0
# Options for MultiMystery.py
multi_mystery_options:
# Teams

View File

@@ -27,6 +27,7 @@ game:
A Link to the Past: 1
Hollow Knight: 1
Factorio: 1
Minecraft: 1
# Shared Options supported by all games:
accessibility:
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
@@ -35,6 +36,34 @@ accessibility:
progression_balancing:
on: 50 # A system to reduce BK, as in times during which you can't do anything by moving your items into an earlier access sphere to make it likely you have stuff to do
off: 0 # Turn this off if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
# Factorio options:
tech_tree_layout:
single: 1
small_diamonds: 1
medium_diamonds: 1
pyramid: 1
funnel: 1
max_science_pack:
automation_science_pack: 0
logistic_science_pack: 0
military_science_pack: 0
chemical_science_pack: 0
production_science_pack: 0
utility_science_pack: 0
space_science_pack: 1
tech_cost:
very_easy : 0
easy : 0
kind : 0
normal : 1
hard : 0
very_hard : 0
insane : 0
free_samples:
none: 1
single_craft: 0
half_stack: 0
stack: 0
# A Link to the Past options:
### Logic Section ###
# Warning: overworld_glitches is not available and minor_glitches is only partially implemented on the door-rando version
@@ -172,11 +201,9 @@ retro:
hints:
'on': 50 # Hint tiles sometimes give item location hints
'off': 0 # Hint tiles provide gameplay tips
weapons: # Specifically, swords
randomized: 0 # Swords are placed randomly throughout the world
assured: 50 # Begin with a sword, the rest are placed randomly throughout the world
vanilla: 0 # Swords are placed in vanilla locations in your own game (Uncle, Pyramid Fairy, Smiths, Pedestal)
swordless: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change
swordless:
on: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change
off: 1
item_pool:
easy: 0 # Doubled upgrades, progressives, and etc
normal: 50 # Item availability remains unchanged from vanilla game
@@ -235,12 +262,14 @@ beemizer: # Remove items from the global item pool and replace them with single
2: 0 # 50% of rupees, bombs and arrows are replaced with bees, of which 70% are traps and 30% single bees
3: 0 # 75% of rupees, bombs and arrows are replaced with bees, of which 80% are traps and 20% single bees
4: 0 # 100% of rupees, bombs and arrows are replaced with bees, of which 90% are traps and 10% single bees
5: 0 # 100% of rupees, bombs and arrows are replaced with bees, of which 100% are traps and 0% single bees
### Shop Settings ###
shop_shuffle_slots: # Maximum amount of shop slots to be filled with regular item pool items (such as Moon Pearl)
0: 50
5: 0
15: 0
30: 0
random: 0 # 0 to 30 evenly distributed
shop_shuffle:
none: 50
g: 0 # Generate new default inventories for overworld/underworld shops, and unique shops
@@ -301,8 +330,8 @@ meta_ignore: # Nullify options specified in the meta.yaml file. Adding an option
- inverted # Never play inverted seeds
retro:
- on # Never play retro seeds
weapons:
- swordless # Never play a swordless seed
swordless:
- on # Never play a swordless seed
linked_options:
- name: crosskeys
options: # These overwrite earlier options if the percentage chance triggers
@@ -326,9 +355,9 @@ linked_options:
- name: enemizer
options:
boss_shuffle: # Subchances can be injected too, which then get rolled
simple: 1
basic: 1
full: 1
random: 1
chaos: 1
singularity: 1
enemy_damage:
shuffled: 1
@@ -339,12 +368,46 @@ linked_options:
expert: 1
percentage: 0 # Set this to the percentage chance you want enemizer
# triggers that replace options upon rolling certain options
legacy_weapons: # this is not an actual option, just a set of weights to trigger from
trigger_disabled: 50
randomized: 0 # Swords are placed randomly throughout the world
assured: 0 # Begin with a sword, the rest are placed randomly throughout the world
vanilla: 0 # Swords are placed in vanilla locations in your own game (Uncle, Pyramid Fairy, Smiths, Pedestal)
swordless: 0 # swordless mode
triggers:
# trigger block for legacy weapons mode, to enable these add weights to legacy_weapons
- option_name: legacy_weapons
option_result: randomized
options:
swordless: off
- option_name: legacy_weapons
option_result: assured
options:
swordless: off
startinventory:
Progressive Sword: 1
- option_name: legacy_weapons
option_result: vanilla
options:
swordless: off
plando_items:
- items:
Progressive Sword: 4
locations:
- Master Sword Pedestal
- Pyramid Fairy - Left
- Blacksmith
- Link's Uncle
- option_name: legacy_weapons
option_result: swordless
options:
swordless: on
# end of legacy weapons block
- option_name: enemy_damage # targets enemy_damage
option_result: shuffled # if it rolls shuffled
percentage: 0 # AND has a 0 percent chance (meaning this is default disabled, just to show how it works)
options: # then inserts these options
swords: assured
swordless: off
### door rando only options (not supported at all yet on this branch) ###
door_shuffle: # Only available if the host uses the doors branch, it is ignored otherwise
vanilla: 50 # Everything should be like in vanilla

View File

@@ -7,4 +7,5 @@ prompt_toolkit>=3.0.18
appdirs>=1.4.4
maseya-z3pr>=1.0.0rc1
xxtea>=2.0.0.post0
factorio-rcon-py>=1.2.1
factorio-rcon-py>=1.2.1
jinja2>=2.11.3

View File

@@ -57,7 +57,6 @@ def manifest_creation():
scripts = {"LttPClient.py": "ArchipelagoLttPClient",
"MultiMystery.py": "ArchipelagoMultiMystery",
"MultiServer.py": "ArchipelagoServer",
"gui.py": "ArchipelagoLttPCreator",
"Mystery.py": "ArchipelagoMystery",
"LttPAdjuster.py": "ArchipelagoLttPAdjuster",
"FactorioClient.py": "ArchipelagoFactorioClient"}

View File

@@ -8,25 +8,28 @@ __all__ = {"lookup_any_item_id_to_name",
from .alttp.Items import lookup_id_to_name as alttp
from .hk.Items import lookup_id_to_name as hk
from .factorio import Technologies
lookup_any_item_id_to_name = {**alttp, **hk, **Technologies.lookup_id_to_name}
lookup_any_item_name_to_id = {name: id for id, name in lookup_any_item_id_to_name.items()}
lookup_any_item_id_to_name = {**alttp, **hk, **Technologies.lookup_id_to_name}
assert len(alttp) + len(hk) + len(Technologies.lookup_id_to_name) == len(lookup_any_item_id_to_name)
lookup_any_item_name_to_id = {name: id for id, name in lookup_any_item_id_to_name.items()}
from .alttp import Regions
from .hk import Locations
lookup_any_location_id_to_name = {**Regions.lookup_id_to_name, **Locations.lookup_id_to_name,
**Technologies.lookup_id_to_name}
assert len(Regions.lookup_id_to_name) + len(Locations.lookup_id_to_name) + len(Technologies.lookup_id_to_name) == \
len(lookup_any_location_id_to_name)
lookup_any_location_name_to_id = {name: id for id, name in lookup_any_location_id_to_name.items()}
network_data_package = {"lookup_any_location_id_to_name": lookup_any_location_id_to_name,
"lookup_any_item_id_to_name": lookup_any_item_id_to_name,
"version": 2}
"version": 4}
@enum.unique
class Games(str, enum.Enum):
HK = "Hollow Knight"
LTTP = "A Link to the Past"
Factorio = "Factorio"
Minecraft = "Minecraft"

View File

@@ -80,7 +80,7 @@ def KholdstareDefeatRule(state, player: int):
state.has('Fire Rod', player) or
(
state.has('Bombos', player) and
(state.has_sword(player) or state.world.swords[player] == 'swordless')
(state.has_sword(player) or state.world.swordless[player])
)
) and
(
@@ -89,7 +89,7 @@ def KholdstareDefeatRule(state, player: int):
(
state.has('Fire Rod', player) and
state.has('Bombos', player) and
state.world.swords[player] == 'swordless' and
state.world.swordless[player] and
state.can_extend_magic(player, 16)
)
)
@@ -113,7 +113,7 @@ def AgahnimDefeatRule(state, player: int):
def GanonDefeatRule(state, player: int):
if state.world.swords[player] == "swordless":
if state.world.swordless[player]:
return state.has('Hammer', player) and \
state.has_fire_source(player) and \
state.has('Silver Bow', player) and \
@@ -241,7 +241,7 @@ def place_bosses(world, player: int):
if shuffle_mode == "none":
return # vanilla bosses come pre-placed
if shuffle_mode in ["basic", "normal"]:
if shuffle_mode in ["basic", "full"]:
if world.boss_shuffle[player] == "basic": # vanilla bosses shuffled
bosses = placeable_bosses + ['Armos Knights', 'Lanmolas', 'Moldorm']
else: # all bosses present, the three duplicates chosen at random

View File

@@ -45,11 +45,9 @@ def parse_arguments(argv, no_defaults=False):
Requires the moon pearl to be Link in the Light World
instead of a bunny.
''')
parser.add_argument('--swords', default=defval('random'), const='random', nargs='?', choices= ['random', 'assured', 'swordless', 'vanilla'],
parser.add_argument('--swordless', action='store_true',
help='''\
Select sword placement. (default: %(default)s)
Random: All swords placed randomly.
Assured: Start game with a sword already.
Toggles Swordless Mode
Swordless: No swords. Curtains in Skull Woods and Agahnim\'s
Tower are removed, Agahnim\'s Tower barrier can be
destroyed with hammer. Misery Mire and Turtle Rock
@@ -57,7 +55,6 @@ def parse_arguments(argv, no_defaults=False):
Ether and Bombos Tablet can be activated with Hammer
(and Book). Bombos pads have been added in Ice
Palace, to allow for an alternative to firerod.
Vanilla: Swords are in vanilla locations.
''')
parser.add_argument('--goal', default=defval('ganon'), const='ganon', nargs='?',
choices=['ganon', 'pedestal', 'bosses', 'triforcehunt', 'localtriforcehunt', 'ganontriforcehunt', 'localganontriforcehunt', 'crystals', 'ganonpedestal'],
@@ -401,7 +398,7 @@ def parse_arguments(argv, no_defaults=False):
for player in range(1, multiargs.multi + 1):
playerargs = parse_arguments(shlex.split(getattr(ret, f"p{player}")), True)
for name in ['logic', 'mode', 'swords', 'goal', 'difficulty', 'item_functionality',
for name in ['logic', 'mode', 'swordless', 'goal', 'difficulty', 'item_functionality',
'shuffle', 'crystals_ganon', 'crystals_gt', 'open_pyramid', 'timer',
'countdown_start_time', 'red_clock_time', 'blue_clock_time', 'green_clock_time',
'mapshuffle', 'compassshuffle', 'keyshuffle', 'bigkeyshuffle', 'startinventory',

View File

@@ -70,7 +70,7 @@ difficulties = {
basicmagic=['Magic Upgrade (1/2)', 'Magic Upgrade (1/4)'],
progressivesword=['Progressive Sword'] * 8,
basicsword=['Master Sword', 'Tempered Sword', 'Golden Sword', 'Fighter Sword'] * 2,
progressivebow=["Progressive Bow"] * 2,
progressivebow=["Progressive Bow"] * 4,
basicbow=['Bow', 'Silver Bow'] * 2,
timedohko=['Green Clock'] * 25,
timedother=['Green Clock'] * 20 + ['Blue Clock'] * 10 + ['Red Clock'] * 10,
@@ -279,7 +279,7 @@ def generate_itempool(world, player: int):
itempool.extend(itemdiff.bottles)
itempool.extend(itemdiff.basicbow)
itempool.extend(itemdiff.basicarmor)
if world.swords[player] != 'swordless':
if not world.swordless[player]:
itempool.extend(itemdiff.basicsword)
itempool.extend(itemdiff.basicmagic)
itempool.extend(itemdiff.basicglove)
@@ -335,7 +335,7 @@ def generate_itempool(world, player: int):
possible_weapons = []
for item in pool:
if item in ['Progressive Sword', 'Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword']:
if not found_sword and world.swords[player] != 'swordless':
if not found_sword:
found_sword = True
possible_weapons.append(item)
if item in ['Progressive Bow', 'Bow'] and not found_bow:
@@ -548,7 +548,7 @@ def get_pool_core(world, player: int):
timer = world.timer[player]
goal = world.goal[player]
mode = world.mode[player]
swords = world.swords[player]
swordless = world.swordless[player]
retro = world.retro[player]
logic = world.logic[player]
@@ -619,7 +619,7 @@ def get_pool_core(world, player: int):
if want_progressives():
pool.extend(diff.progressivebow)
elif (swords == 'swordless' or logic == 'noglitches') and goal != 'icerodhunt':
elif (swordless or logic == 'noglitches') and goal != 'icerodhunt':
swordless_bows = ['Bow', 'Silver Bow']
if difficulty == "easy":
swordless_bows *= 2
@@ -627,33 +627,11 @@ def get_pool_core(world, player: int):
else:
pool.extend(diff.basicbow)
if swords == 'swordless':
if swordless:
pool.extend(diff.swordless)
elif swords == 'vanilla':
swords_to_use = diff.progressivesword.copy() if want_progressives() else diff.basicsword.copy()
world.random.shuffle(swords_to_use)
place_item('Link\'s Uncle', swords_to_use.pop())
place_item('Blacksmith', swords_to_use.pop())
place_item('Pyramid Fairy - Left', swords_to_use.pop())
if goal != 'pedestal':
place_item('Master Sword Pedestal', swords_to_use.pop())
else:
swords_to_use.pop()
place_item('Master Sword Pedestal', 'Triforce')
if swords_to_use:
pool.extend(swords_to_use)
else:
progressive_swords = want_progressives()
pool.extend(diff.progressivesword if progressive_swords else diff.basicsword)
if swords == 'assured' and goal != 'icerodhunt':
if progressive_swords:
precollected_items.append('Progressive Sword')
pool.remove('Progressive Sword')
else:
precollected_items.append('Fighter Sword')
pool.remove('Fighter Sword')
pool.extend(['Rupees (50)'])
extraitems = total_items_to_place - len(pool) - len(placed_items)
@@ -684,7 +662,7 @@ def get_pool_core(world, player: int):
else:
break
if goal == 'pedestal' and swords != 'vanilla':
if goal == 'pedestal':
place_item('Master Sword Pedestal', 'Triforce')
pool.remove("Rupees (20)")

View File

@@ -879,7 +879,7 @@ def patch_rom(world, rom, player, team, enemized):
rom.write_bytes(0x6D323, [0x00, 0x00, 0xe4, 0xff, 0x08, 0x0E])
# set light cones
rom.write_byte(0x180038, 0x01 if world.sewer_light_cone[player] else 0x00)
rom.write_byte(0x180038, 0x01 if world.mode[player] == "standard" else 0x00)
rom.write_byte(0x180039, 0x01 if world.light_world_light_cone else 0x00)
rom.write_byte(0x18003A, 0x01 if world.dark_world_light_cone else 0x00)
@@ -961,7 +961,7 @@ def patch_rom(world, rom, player, team, enemized):
# Set overflow items for progressive equipment
rom.write_bytes(0x180090,
[difficulty.progressive_sword_limit if world.swords[player] != 'swordless' else 0,
[difficulty.progressive_sword_limit if not world.swordless[player] else 0,
overflow_replacement,
difficulty.progressive_shield_limit, overflow_replacement,
difficulty.progressive_armor_limit, overflow_replacement,
@@ -971,7 +971,7 @@ def patch_rom(world, rom, player, team, enemized):
rom.write_bytes(0x180098, [difficulty.progressive_bow_limit, overflow_replacement])
if difficulty.progressive_bow_limit < 2 and (
world.swords[player] == 'swordless' or world.logic[player] == 'noglitches'):
world.swordless[player] or world.logic[player] == 'noglitches'):
rom.write_bytes(0x180098, [2, overflow_replacement])
rom.write_byte(0x180181, 0x01) # Make silver arrows work only on ganon
rom.write_byte(0x180182, 0x00) # Don't auto equip silvers on pickup
@@ -1123,11 +1123,11 @@ def patch_rom(world, rom, player, team, enemized):
rom.write_byte(0x180029, 0x01) # Smithy quick item give
# set swordless mode settings
rom.write_byte(0x18003F, 0x01 if world.swords[player] == 'swordless' else 0x00) # hammer can harm ganon
rom.write_byte(0x180040, 0x01 if world.swords[player] == 'swordless' else 0x00) # open curtains
rom.write_byte(0x180041, 0x01 if world.swords[player] == 'swordless' else 0x00) # swordless medallions
rom.write_byte(0x180043, 0xFF if world.swords[player] == 'swordless' else 0x00) # starting sword for link
rom.write_byte(0x180044, 0x01 if world.swords[player] == 'swordless' else 0x00) # hammer activates tablets
rom.write_byte(0x18003F, 0x01 if world.swordless[player] else 0x00) # hammer can harm ganon
rom.write_byte(0x180040, 0x01 if world.swordless[player] else 0x00) # open curtains
rom.write_byte(0x180041, 0x01 if world.swordless[player] else 0x00) # swordless medallions
rom.write_byte(0x180043, 0xFF if world.swordless[player] else 0x00) # starting sword for link
rom.write_byte(0x180044, 0x01 if world.swordless[player] else 0x00) # hammer activates tablets
if world.item_functionality[player] == 'easy':
rom.write_byte(0x18003F, 0x01) # hammer can harm ganon
@@ -1651,7 +1651,7 @@ def write_custom_shops(rom, world, player):
if item is None:
break
if not item['item'] in item_table: # item not native to ALTTP
item_code = 0x21
item_code = 0x09 # Hammer
else:
item_code = ItemFactory(item['item'], player).code
if item['item'] == 'Single Arrow' and item['player'] == 0 and world.retro[player]:
@@ -1775,7 +1775,7 @@ def apply_rom_settings(rom, beep, color, quickswap, fastmenu, disable_music, spr
option_name: True
}
data_dir = local_path("../../data") if is_bundled() else None
data_dir = local_path("data") if is_bundled() else None
offsets_array = build_offset_collections(options, data_dir)
restore_maseya_colors(rom, offsets_array)
if mode == 'default':
@@ -2218,7 +2218,7 @@ def write_strings(rom, world, player, team):
prog_bow_locs = world.find_items('Progressive Bow', player)
distinguished_prog_bow_loc = next((location for location in prog_bow_locs if location.item.code == 0x65), None)
progressive_silvers = world.difficulty_requirements[player].progressive_bow_limit >= 2 or (
world.swords[player] == 'swordless' or world.logic[player] == 'noglitches')
world.swordless[player] or world.logic[player] == 'noglitches')
if distinguished_prog_bow_loc:
prog_bow_locs.remove(distinguished_prog_bow_loc)
silverarrow_hint = (' %s?' % hint_text(distinguished_prog_bow_loc).replace('Ganon\'s',

View File

@@ -505,7 +505,7 @@ def default_rules(world, player):
set_rule(world.get_entrance('Pyramid Hole', player), lambda state: state.has('Beat Agahnim 2', player) or world.open_pyramid[player])
if world.swords[player] == 'swordless':
if world.swordless[player]:
swordless_rules(world, player)
@@ -657,7 +657,7 @@ def inverted_rules(world, player):
set_rule(world.get_entrance('Inverted Pyramid Hole', player), lambda state: state.has('Beat Agahnim 2', player) or world.open_pyramid[player])
if world.swords[player] == 'swordless':
if world.swordless[player]:
swordless_rules(world, player)
def no_glitches_rules(world, player):
@@ -781,7 +781,7 @@ def add_conditional_lamps(world, player):
add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True)
add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True)
if not world.sewer_light_cone[player]:
if not world.mode[player] == "standard":
add_lamp_requirement(world, world.get_location('Sewers - Dark Cross', player), player)
add_lamp_requirement(world, world.get_entrance('Sewers Back Door', player), player)
add_lamp_requirement(world, world.get_entrance('Throne Room', player), player)

View File

@@ -1,6 +1,7 @@
"""Outputs a Factorio Mod to facilitate integration with Archipelago"""
import os
import zipfile
from typing import Optional
import threading
import json
@@ -8,6 +9,7 @@ import json
import jinja2
import Utils
import shutil
import Options
from BaseClasses import MultiWorld
from .Technologies import tech_table
@@ -29,26 +31,41 @@ def generate_mod(world: MultiWorld, player: int):
global template, locale_template
with template_load_lock:
if not template:
template = jinja2.Template(open(Utils.local_path("data", "factorio", "mod_template", "data-final-fixes.lua")).read())
locale_template = jinja2.Template(open(Utils.local_path("data", "factorio", "mod_template", "locale", "en", "locale.cfg")).read())
mod_template_folder = Utils.local_path("data", "factorio", "mod_template")
template = jinja2.Template(open(os.path.join(mod_template_folder, "data-final-fixes.lua")).read())
locale_template = jinja2.Template(open(os.path.join(mod_template_folder, "locale", "en", "locale.cfg")).read())
control_template = jinja2.Template(open(os.path.join(mod_template_folder, "control.lua")).read())
# get data for templates
player_names = {x: world.player_names[x][0] for x in world.player_ids}
locations = []
for location in world.get_filled_locations(player):
if not location.name.startswith("recipe-"): # introduce this a new location property?
if not location.name.startswith("recipe-"): # introduce this as a new location property?
locations.append((location.name, location.item.name, location.item.player))
mod_name = f"archipelago-client-{world.seed}-{player}"
tech_cost = {0: 0.1,
1: 0.25,
2: 0.5,
3: 1,
4: 2,
5: 5,
6: 10}[world.tech_cost[player].value]
template_data = {"locations": locations, "player_names" : player_names, "tech_table": tech_table,
"mod_name": mod_name}
"mod_name": mod_name, "allowed_science_packs": world.max_science_pack[player].get_allowed_packs(),
"tech_cost_scale": tech_cost,
"tech_tree_layout_prerequisites": world.tech_tree_layout_prerequisites[player]}
for factorio_option in Options.factorio_options:
template_data[factorio_option] = getattr(world, factorio_option)[player].value
control_code = control_template.render(**template_data)
data_final_fixes_code = template.render(**template_data)
mod_code = template.render(**template_data)
mod_dir = Utils.output_path(mod_name)
mod_dir = Utils.output_path(mod_name)+"_"+Utils.__version__
en_locale_dir = os.path.join(mod_dir, "locale", "en")
os.makedirs(en_locale_dir, exist_ok=True)
shutil.copytree(Utils.local_path("data", "factorio", "mod"), mod_dir, dirs_exist_ok=True)
with open(os.path.join(mod_dir, "data-final-fixes.lua"), "wt") as f:
f.write(mod_code)
f.write(data_final_fixes_code)
with open(os.path.join(mod_dir, "control.lua"), "wt") as f:
f.write(control_code)
locale_content = locale_template.render(**template_data)
with open(os.path.join(en_locale_dir, "locale.cfg"), "wt") as f:
f.write(locale_content)
@@ -56,3 +73,14 @@ def generate_mod(world: MultiWorld, player: int):
info["name"] = mod_name
with open(os.path.join(mod_dir, "info.json"), "wt") as f:
json.dump(info, f, indent=4)
# zip the result
zf_path = os.path.join(mod_dir+".zip")
with zipfile.ZipFile(zf_path, compression=zipfile.ZIP_DEFLATED, mode='w') as zf:
for root, dirs, files in os.walk(mod_dir):
for file in files:
zf.write(os.path.join(root, file),
os.path.relpath(os.path.join(root, file),
os.path.join(mod_dir, '..')))
shutil.rmtree(mod_dir)

97
worlds/factorio/Shapes.py Normal file
View File

@@ -0,0 +1,97 @@
from typing import Dict, List, Set
from BaseClasses import MultiWorld
from Options import TechTreeLayout
from worlds.factorio.Technologies import technology_table
def get_shapes(world: MultiWorld, player: int) -> Dict[str, List[str]]:
prerequisites: Dict[str, Set[str]] = {}
layout = world.tech_tree_layout[player].value
if layout == TechTreeLayout.option_small_diamonds:
slice_size = 4
tech_names: List[str] = list(set(technology_table) - world._static_nodes)
tech_names.sort()
world.random.shuffle(tech_names)
while len(tech_names) > slice_size:
slice = tech_names[:slice_size]
tech_names = tech_names[slice_size:]
slice.sort(key=lambda tech_name: len(technology_table[tech_name].ingredients))
diamond_0, diamond_1, diamond_2, diamond_3 = slice
# 0 |
# 1 2 |
# 3 V
prerequisites[diamond_3] = {diamond_1, diamond_2}
prerequisites[diamond_2] = prerequisites[diamond_1] = {diamond_0}
elif layout == TechTreeLayout.option_medium_diamonds:
slice_size = 9
tech_names: List[str] = list(set(technology_table) - world._static_nodes)
tech_names.sort()
world.random.shuffle(tech_names)
while len(tech_names) > slice_size:
slice = tech_names[:slice_size]
tech_names = tech_names[slice_size:]
slice.sort(key=lambda tech_name: len(technology_table[tech_name].ingredients))
# 0 |
# 1 2 |
# 3 4 5 |
# 6 7 |
# 8 V
prerequisites[slice[1]] = {slice[0]}
prerequisites[slice[2]] = {slice[0]}
prerequisites[slice[3]] = {slice[1]}
prerequisites[slice[4]] = {slice[1], slice[2]}
prerequisites[slice[5]] = {slice[2]}
prerequisites[slice[6]] = {slice[3], slice[4]}
prerequisites[slice[7]] = {slice[4], slice[5]}
prerequisites[slice[8]] = {slice[6], slice[7]}
elif layout == TechTreeLayout.option_pyramid:
slice_size = 1
tech_names: List[str] = list(set(technology_table) - world._static_nodes)
tech_names.sort()
world.random.shuffle(tech_names)
tech_names.sort(key=lambda tech_name: len(technology_table[tech_name].ingredients))
previous_slice = []
while len(tech_names) > slice_size:
slice = tech_names[:slice_size]
world.random.shuffle(slice)
tech_names = tech_names[slice_size:]
for i, tech_name in enumerate(previous_slice):
prerequisites.setdefault(slice[i], set()).add(tech_name)
prerequisites.setdefault(slice[i + 1], set()).add(tech_name)
previous_slice = slice
slice_size += 1
elif layout == TechTreeLayout.option_funnel:
tech_names: List[str] = list(set(technology_table) - world._static_nodes)
# find largest inverse pyramid
# https://www.wolframalpha.com/input/?i=x+=+1/2+(n++++1)+(2++++n)+solve+for+n
import math
slice_size = int(0.5*(math.sqrt(8*len(tech_names)+1)-3))
import logging
logging.info(slice_size)
tech_names.sort()
world.random.shuffle(tech_names)
tech_names.sort(key=lambda tech_name: len(technology_table[tech_name].ingredients))
previous_slice = []
while slice_size:
slice = tech_names[:slice_size]
world.random.shuffle(slice)
tech_names = tech_names[slice_size:]
if previous_slice:
for i, tech_name in enumerate(slice):
prerequisites.setdefault(tech_name, set()).update(previous_slice[i:i+2])
previous_slice = slice
slice_size -= 1
world.tech_tree_layout_prerequisites[player] = prerequisites
return prerequisites

View File

@@ -1,46 +1,136 @@
from __future__ import annotations
# Factorio technologies are imported from a .json document in /data
from typing import Dict
import os
from typing import Dict, Set, FrozenSet
import json
import Utils
import logging
factorio_id = 2**17
factorio_id = 2 ** 17
source_file = Utils.local_path("data", "factorio", "techs.json")
recipe_source_file = Utils.local_path("data", "factorio", "recipes.json")
with open(source_file) as f:
raw = json.load(f)
with open(recipe_source_file) as f:
raw_recipes = json.load(f)
tech_table = {}
technology_table:Dict[str, Technology] = {}
requirements = {}
ingredients = {}
all_ingredients = set()
always = lambda state: True
class Technology(): # maybe make subclass of Location?
def __init__(self, name, ingredients):
self.name = name
global factorio_id
self.factorio_id = factorio_id
factorio_id += 1
self.ingredients = ingredients
def build_rule(self, allowed_packs, player: int):
logging.debug(f"Building rules for {self.name}")
ingredient_rules = []
for ingredient in self.ingredients:
if ingredient in allowed_packs:
logging.debug(f"Building rules for ingredient {ingredient}")
technologies = required_technologies[ingredient] # technologies that unlock the recipes
if technologies:
logging.debug(f"Required Technologies: {technologies}")
ingredient_rules.append(
lambda state, technologies=technologies: all(state.has(technology.name, player)
for technology in technologies))
if ingredient_rules:
ingredient_rules = frozenset(ingredient_rules)
return lambda state: all(rule(state) for rule in ingredient_rules)
return always
def get_prior_technologies(self, allowed_packs) -> Set[Technology]:
"""Get Technologies that have to precede this one to resolve tree connections."""
technologies = set()
for ingredient in self.ingredients:
if ingredient in allowed_packs:
technologies |= required_technologies[ingredient] # technologies that unlock the recipes
return technologies
def __hash__(self):
return self.factorio_id
def __repr__(self):
return f"{self.__class__.__name__}({self.name})"
class Recipe():
def __init__(self, name, category, ingredients, products):
self.name = name
self.category = category
self.ingredients = ingredients
self.products = products
def __repr__(self):
return f"{self.__class__.__name__}({self.name})"
@property
def unlocking_technologies(self) -> Set[Technology]:
"""Unlocked by any of the returned technologies. Empty set indicates a starting recipe."""
return {technology_table[tech_name] for tech_name in recipe_sources.get(self.name, ())}
# TODO: export this dynamically, or filter it during export
starting_ingredient_recipes = {"automation-science-pack"}
# recipes and technologies can share names in Factorio
for technology in sorted(raw):
data = raw[technology]
tech_table[technology] = factorio_id
for technology_name in sorted(raw):
data = raw[technology_name]
factorio_id += 1
if data["requires"]:
requirements[technology] = set(data["requires"])
current_ingredients = set(data["ingredients"])-starting_ingredient_recipes
if current_ingredients:
current_ingredients = set(data["ingredients"])
technology = Technology(technology_name, current_ingredients)
tech_table[technology_name] = technology.factorio_id
technology_table[technology_name] = technology
all_ingredients |= current_ingredients
current_ingredients = {"recipe-"+ingredient for ingredient in current_ingredients}
ingredients[technology] = current_ingredients
recipe_sources = {}
recipe_sources: Dict[str, str] = {} # recipe_name -> technology source
for technology, data in raw.items():
recipe_source = all_ingredients & set(data["unlocks"])
for recipe in recipe_source:
recipe_sources["recipe-"+recipe] = technology
for recipe_name in data["unlocks"]:
recipe_sources.setdefault(recipe_name, set()).add(technology)
all_ingredients = {"recipe-"+ingredient for ingredient in all_ingredients}
del(raw)
lookup_id_to_name: Dict[int, str] = {item_id: item_name for item_name, item_id in tech_table.items()}
del (raw)
lookup_id_to_name: Dict[int, str] = {item_id: item_name for item_name, item_id in tech_table.items()}
all_product_sources: Dict[str, Recipe] = {}
for recipe_name, recipe_data in raw_recipes.items():
# example:
# "accumulator":{"ingredients":["iron-plate","battery"],"products":["accumulator"],"category":"crafting"}
recipe = Recipe(recipe_name, recipe_data["category"], set(recipe_data["ingredients"]), set(recipe_data["products"]))
if recipe.products != recipe.ingredients: # prevents loop recipes like uranium centrifuging
for product_name in recipe.products:
all_product_sources[product_name] = recipe
# build requirements graph for all technology ingredients
all_ingredient_names: Set[str] = set()
for technology in technology_table.values():
all_ingredient_names |= technology.ingredients
def recursively_get_unlocking_technologies(ingredient_name, _done=None) -> Set[Technology]:
if _done:
if ingredient_name in _done:
return set()
else:
_done.add(ingredient_name)
else:
_done = {ingredient_name}
recipe = all_product_sources.get(ingredient_name)
if not recipe:
return set()
current_technologies = recipe.unlocking_technologies.copy()
for ingredient_name in recipe.ingredients:
current_technologies |= recursively_get_unlocking_technologies(ingredient_name, _done)
return current_technologies
required_technologies: Dict[str, FrozenSet[Technology]] = {}
for ingredient_name in all_ingredient_names:
required_technologies[ingredient_name] = frozenset(recursively_get_unlocking_technologies(ingredient_name))
advancement_technologies: Set[str] = set()
for technologies in required_technologies.values():
advancement_technologies |= {technology.name for technology in technologies}

View File

@@ -1,20 +1,18 @@
import logging
from BaseClasses import Region, Entrance, Location, MultiWorld, Item
from .Technologies import tech_table, requirements, ingredients, all_ingredients, recipe_sources
static_nodes = {"automation", "logistics"}
from .Technologies import tech_table, recipe_sources, technology_table, advancement_technologies, required_technologies
from .Shapes import get_shapes
def gen_factorio(world: MultiWorld, player: int):
static_nodes = world._static_nodes = {"automation", "logistics"} # turn dynamic/option?
for tech_name, tech_id in tech_table.items():
tech_item = Item(tech_name, True, tech_id, player)
tech_item = Item(tech_name, tech_name in advancement_technologies, tech_id, player)
tech_item.game = "Factorio"
if tech_name in static_nodes:
loc = world.get_location(tech_name, player)
loc.item = tech_item
loc.locked = loc.event = True
loc.locked = True
loc.event = tech_item.advancement
else:
world.itempool.append(tech_item)
set_rules(world, player)
@@ -30,30 +28,25 @@ def factorio_create_regions(world: MultiWorld, player: int):
tech = Location(player, tech_name, tech_id, nauvis)
nauvis.locations.append(tech)
tech.game = "Factorio"
for ingredient in all_ingredients: # register science packs as events
ingredient_location = Location(player, ingredient, 0, nauvis)
ingredient_location.item = Item(ingredient, True, 0, player)
ingredient_location.event = ingredient_location.locked = True
menu.locations.append(ingredient_location)
crash.connect(nauvis)
world.regions += [menu, nauvis]
def set_rules(world: MultiWorld, player: int):
shapes = get_shapes(world, player)
if world.logic[player] != 'nologic':
from worlds.generic import Rules
for tech_name in tech_table:
# vanilla layout, to be implemented
# rules = requirements.get(tech_name, set()) | ingredients.get(tech_name, set())
allowed_packs = world.max_science_pack[player].get_allowed_packs()
for tech_name, technology in technology_table.items():
# loose nodes
rules = ingredients.get(tech_name, set())
if rules:
location = world.get_location(tech_name, player)
Rules.set_rule(location, lambda state, rules=rules: all(state.has(rule, player) for rule in rules))
location = world.get_location(tech_name, player)
Rules.set_rule(location, technology.build_rule(allowed_packs, player))
prequisites = shapes.get(tech_name)
if prequisites:
locations = {world.get_location(requisite, player) for requisite in prequisites}
Rules.add_rule(location, lambda state,
locations=locations: all(state.can_reach(loc) for loc in locations))
for recipe, technology in recipe_sources.items():
Rules.set_rule(world.get_location(recipe, player), lambda state, tech=technology: state.has(tech, player))
world.completion_condition[player] = lambda state: all(state.has(ingredient, player)
for ingredient in all_ingredients)
# get all technologies
world.completion_condition[player] = lambda state: all(state.has(technology, player)
for technology in advancement_technologies)