diff --git a/FF1Client.py b/FF1Client.py new file mode 100644 index 00000000..3666d950 --- /dev/null +++ b/FF1Client.py @@ -0,0 +1,255 @@ +import asyncio +import json +import time +from asyncio import StreamReader, StreamWriter +from typing import List + + +import Utils +from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \ + get_base_parser + +SYSTEM_MESSAGE_ID = 0 + +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator then restart ff1_connector.lua" +CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator make sure ff1_connector.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator then restart ff1_connector.lua" +CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" +CONNECTION_CONNECTED_STATUS = "Connected" +CONNECTION_INITIAL_STATUS = "Connection has not been initiated" + + +class FF1CommandProcessor(ClientCommandProcessor): + def __init__(self, ctx: CommonContext): + super().__init__(ctx) + + def _cmd_nes(self): + """Check NES Connection State""" + if isinstance(self.ctx, FF1Context): + logger.info(f"NES Status: {self.ctx.nes_status}") + + +class FF1Context(CommonContext): + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.nes_streams: (StreamReader, StreamWriter) = None + self.nes_sync_task = None + self.messages = {} + self.locations_array = None + self.nes_status = CONNECTION_INITIAL_STATUS + self.game = 'Final Fantasy' + self.awaiting_rom = False + + command_processor = FF1CommandProcessor + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(FF1Context, self).server_auth(password_requested) + if not self.auth: + self.awaiting_rom = True + logger.info('Awaiting connection to NES to get Player information') + return + + await self.send_connect() + + def _set_message(self, msg: str, msg_id: int): + self.messages[(time.time(), msg_id)] = msg + + def on_package(self, cmd: str, args: dict): + if cmd == 'Connected': + self.game = self.games.get(self.slot, None) + asyncio.create_task(parse_locations(self.locations_array, self, True)) + elif cmd == 'Print': + msg = args['text'] + if ': !' not in msg: + self._set_message(msg, SYSTEM_MESSAGE_ID) + elif cmd == "ReceivedItems": + msg = f"Received {', '.join([self.item_name_getter(item.item) for item in args['items']])}" + self._set_message(msg, SYSTEM_MESSAGE_ID) + elif cmd == 'PrintJSON': + print_type = args['type'] + item = args['item'] + receiving_player_id = args['receiving'] + receiving_player_name = self.player_names[receiving_player_id] + sending_player_id = item.player + sending_player_name = self.player_names[item.player] + if print_type == 'Hint': + msg = f"Hint: Your {self.item_name_getter(item.item)} is at" \ + f" {self.player_names[item.player]}'s {self.location_name_getter(item.location)}" + self._set_message(msg, item.item) + elif print_type == 'ItemSend' and receiving_player_id != self.slot: + if sending_player_id == self.slot: + if receiving_player_id == self.slot: + msg = f"You found your own {self.item_name_getter(item.item)}" + else: + msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}" + else: + if receiving_player_id == sending_player_id: + msg = f"{sending_player_name} found their {self.item_name_getter(item.item)}" + else: + msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \ + f"{receiving_player_name}" + self._set_message(msg, item.item) + + +def get_payload(ctx: FF1Context): + current_time = time.time() + return json.dumps( + { + "items": [item.item for item in ctx.items_received], + "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() + if key[0] > current_time - 10} + } + ) + + +async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool): + if locations_array == ctx.locations_array and not force: + return + else: + # print("New values") + ctx.locations_array = locations_array + locations_checked = [] + if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game: + await ctx.send_msgs([ + {"cmd": "StatusUpdate", + "status": 30} + ]) + ctx.finished_game = True + for location in ctx.missing_locations: + # index will be - 0x100 or 0x200 + index = location + if location < 0x200: + # Location is a chest + index -= 0x100 + flag = 0x04 + else: + # Location is an NPC + index -= 0x200 + flag = 0x02 + + # print(f"Location: {ctx.location_name_getter(location)}") + # print(f"Index: {str(hex(index))}") + # print(f"value: {locations_array[index] & flag != 0}") + if locations_array[index] & flag != 0: + locations_checked.append(location) + if locations_checked: + # print([ctx.location_name_getter(location) for location in locations_checked]) + await ctx.send_msgs([ + {"cmd": "LocationChecks", + "locations": locations_checked} + ]) + + +async def nes_sync_task(ctx: FF1Context): + logger.info("Starting nes connector. Use /nes for status information") + while not ctx.exit_event.is_set(): + error_status = None + if ctx.nes_streams: + (reader, writer) = ctx.nes_streams + msg = get_payload(ctx).encode() + writer.write(msg) + writer.write(b'\n') + try: + await asyncio.wait_for(writer.drain(), timeout=1.5) + try: + # Data will return a dict with up to two fields: + # 1. A keepalive response of the Players Name (always) + # 2. An array representing the memory values of the locations area (if in game) + data = await asyncio.wait_for(reader.readline(), timeout=5) + data_decoded = json.loads(data.decode()) + # print(data_decoded) + if ctx.game is not None and 'locations' in data_decoded: + # Not just a keep alive ping, parse + asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False)) + if not ctx.auth: + ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) + if ctx.awaiting_rom: + await ctx.server_auth(False) + except asyncio.TimeoutError: + logger.debug("Read Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.nes_streams = None + except ConnectionResetError as e: + logger.debug("Read failed due to Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.nes_streams = None + except TimeoutError: + logger.debug("Connection Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.nes_streams = None + except ConnectionResetError: + logger.debug("Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.nes_streams = None + if ctx.nes_status == CONNECTION_TENTATIVE_STATUS: + if not error_status: + logger.info("Successfully Connected to NES") + ctx.nes_status = CONNECTION_CONNECTED_STATUS + else: + ctx.nes_status = f"Was tentatively connected but error occured: {error_status}" + elif error_status: + ctx.nes_status = error_status + logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates") + else: + try: + logger.debug("Attempting to connect to NES") + ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10) + ctx.nes_status = CONNECTION_TENTATIVE_STATUS + except TimeoutError: + logger.debug("Connection Timed Out, Trying Again") + ctx.nes_status = CONNECTION_TIMING_OUT_STATUS + continue + except ConnectionRefusedError: + logger.debug("Connection Refused, Trying Again") + ctx.nes_status = CONNECTION_REFUSED_STATUS + continue + + +if __name__ == '__main__': + # Text Mode to use !hint and such with games that have no text entry + Utils.init_logging("FF1Client") + + async def main(args): + ctx = FF1Context(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + if gui_enabled: + input_task = None + from kvui import TextManager + ctx.ui = TextManager(ctx) + ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI") + else: + input_task = asyncio.create_task(console_loop(ctx), name="Input") + ui_task = None + + ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync") + + await ctx.exit_event.wait() + ctx.server_address = None + + await ctx.shutdown() + + if ctx.nes_sync_task: + await ctx.nes_sync_task + + if ui_task: + await ui_task + + if input_task: + input_task.cancel() + + + import colorama + + parser = get_base_parser() + args, rest = parser.parse_known_args() + colorama.init() + + loop = asyncio.get_event_loop() + loop.run_until_complete(main(args)) + loop.close() + colorama.deinit() diff --git a/WebHostLib/static/assets/gameInfo/en_Final Fantasy.md b/WebHostLib/static/assets/gameInfo/en_Final Fantasy.md new file mode 100644 index 00000000..40628ce1 --- /dev/null +++ b/WebHostLib/static/assets/gameInfo/en_Final Fantasy.md @@ -0,0 +1,24 @@ +# Final Fantasy 1 (NES) + +## Where is the settings page? +Unlike most games on Archipelago.gg, Final Fantasy 1's settings are controlled entirely by the original randomzier. +You can find an exhaustive list of documented settings [here](https://finalfantasyrandomizer.com/) + +## What does randomization do to this game? +A better questions is what isn't randomized at this point. Enemies stats and spell, character spells, shop inventory +and boss stats and spells are all commonly randomized. Unlike most other randomizers it is also most standard to shuffle +progression items and non-progression items into separate pools and then redistribute them to their respective +locations. So ,for example, Princess Sarah may have the CANOE instead of the LUTE; however, she will never have a Heal +Pot or some armor. There are plenty of other things that can be randomized on our +[main randomizer site](https://finalfantasyrandomizer.com/) + +Some features are not currently supported by AP. A non-exhaustive list includes: +- Shard Hunt +- Deep Dungeon + +## What Final Fantasy items can appear in other players' worlds? +Currently, only progression items can appear in other players' worlds. Armor, Weapons and Consumable Items can not. + +## What does another world's item look like in Final Fantasy +All local and remote items appear the same. It will say that you received an item and then BOTH the client log and +the emulator will display what was found external to the in-game text box. diff --git a/WebHostLib/static/assets/tutorial/ff1/multiworld_en.md b/WebHostLib/static/assets/tutorial/ff1/multiworld_en.md new file mode 100644 index 00000000..543b33df --- /dev/null +++ b/WebHostLib/static/assets/tutorial/ff1/multiworld_en.md @@ -0,0 +1,114 @@ +# Final Fantasy 1 (NES) Multiworld Setup Guide + +## Required Software +- The FF1Client which is bundled with [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) +- The [BizHawk](http://tasvideos.org/BizHawk.html) emulator. Versions 2.3.1 and higher are supported. + Version 2.7 is recommended +- Your Final Fantasy (USA Edition) ROM file, probably named `Final Fantasy (USA).nes`. Neither Archipelago.gg nor the + Final Fantasy Randomizer Community can supply you with this. + +## Installation Procedures +1. Download and install the latest version of [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) + 1. On Windows, download Setup.Archipelago..exe and run it +2. Assign Bizhawk version 2.3.1 or higher as your default program for launching `.nes` files. + 1. Extract your Bizhawk folder to your Desktop, or somewhere you will remember. Below are optional additional + steps for loading ROMs more conveniently + 1. Right-click on a ROM file and select **Open with...** + 2. Check the box next to **Always use this app to open .nes files** + 3. Scroll to the bottom of the list and click the grey text **Look for another App on this PC** + 4. Browse for `EmuHawk.exe` located inside your Bizhawk folder (from step 1) and click **Open**. + +## Playing a Multiworld +Playing a multiworld on Archipelago.gg has 3 key components: +1. The Server which is hosting a game for all players. +2. The Client Program. For Final Fantasy 1, it is a standalone program but other randomizers may build it in. +3. The Game itself, in this case running on Bizhawk, which then connects to the Client running on your computer. + +To set this up the following steps are required: +1. (Each Player) Generate your own yaml file and randomized ROM +2. (Host Only) Generate a randomized game with you and 0 or more players using Archipelago +3. (Host Only) Run the Archipelago Server +4. (Each Player) Run your client program and connect it to the Server +5. (Each Player) Run your game and connect it to your client program +6. (Each Player) Play the game and have fun! + +### Obtaining your Archipelago yaml file and randomized ROM +Unlike most other Archipelago.gg games Final Fantasy 1 is randomized by the +[main randomizer](https://finalfantasyrandomizer.com/). Generate a game by going to the site and performing the +following steps: +1. Select the randomization options (also known as `Flags` in the community) of your choice. If you do not know what +you prefer, or it is your first time playing select the "Archipelago" preset on the main page. +2. Go to the `Beta` tab and ensure `Archipelago` is enabled. Set your player name to any name that represents you. +3. Upload you `Final Fantasy(USA).nes` (and click `Remember ROM` for the future!) +4. Press the `NEW` button beside `Seed` a few times +5. Click `GENERATE ROM` + +It should download two files. One is the `*.nes` file which your emulator will run and the other is the yaml file +required by Archipelago.gg + +### Generating the Multiworld and Starting the Server +The game can be generated locally or by Archipelago.gg. + +#### Generating on Archipelago.gg (Recommended) +1. Gather all yaml files +2. Create a zip file containing all of the yaml files. Make sure it is a `*.zip` not a `*.7z` or a `*.rar` +3. Navigate to the [Generate Page](https://archipelago.gg/generate) and click `Upload File` + 1. For your first game keep `Forfeit Permission` as `Automatic on goal completion`. Forfeiting actually means + giving out all of the items remaining in your game in this case so you do not block anyone else. + 2. For your first game keep `Hint Cost` at 10% +4. Select your zip file + +#### Generating Locally +1. Navigate to your Archipelago install directory +2. Empty the `Players` directory then fill it with one yaml per player including your own which you got from the + finalfantasyrandomizer website above +3. Run `ArchipelagoGenerate.exe` (double-click it in File Explorer) +4. You will find your generated game in the `output` directory + +#### Starting the server +If you generated on Archipelago.gg click `Create New Room` on the results page to start your server +If you generated locally simply navigate to the [Host Game Page](https://archipelago.gg/uploads) and upload the file +in the `output` directory + +### Running the Client Program and Connecting to the Server +1. Navigate to your Archipelago install folder and run `ArchipelagoFF1Client.exe` +2. Notice the `/connect command` on the server hosting page (It should look like `/connect archipelago.gg:*****` where + ***** are numbers) +3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should + already say `archipelago.gg`) and click `connect` + +#### Running Your Game and Connecting to the Client Program +1. Open Bizhawk 2.3.1 or higher and load your ROM OR + click your ROM file if it is already associated with the extension `*.nes` +2. Click on the Tools menu and click on **Lua Console** +3. Click the folder button to open a new Lua script. (CTL-O or **Script** -> **Open Script**) +4. Navigate to the location you installed Archipelago to. Open data/lua/FF1/ff1_connector.lua + 1. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception + close your emulator entirely, restart it and re-run these steps + 2. If it says `Must use a version of bizhawk 2.3.1 or higher`, double-check your Bizhawk version by clicking + **Help** -> **About** + +### Play the game +When the client shows both NES and server are connected you are good to go. You can check the connection status of the +NES at any time by running `/nes` + +### Helpful Commands +Commands are broken into two types: `/` and `!` commands. The difference is that `/commands` are local to your machine +and game whereas `!` commands ask the server. Most of the time you can use local commands. + +#### Local Commands +- `/connect
` connect to the multiworld server +- `/disconnect` if you accidentally connected to the wrong port run this to disconnect and then reconnect using +- `/nes` Shows the current status of the NES connection +- `/received` Displays all the items you have found or been sent +- `/missing` Displays all the locations along with their current status (checked/missing) +- Just typing anything will broadcast a message to all players + +#### Remote Commands +- `!hint ` Tells you at which location in whose game your Item is. Note you need to have checked some locations +to earn a hint. You can check how many you have by just running `!hint` +- `!forfeit` If you didn't turn on auto-forfeit or you allowed forfeiting prior to goal completion. Remember that +"forfeiting" actually means sending out your remaining items in your world + +#### Host only (on Archipelago.gg) +`/forfeit ` Forfeits someone regardless of settings and game completion status diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json index 2762aa7a..67f33e16 100644 --- a/WebHostLib/static/assets/tutorial/tutorials.json +++ b/WebHostLib/static/assets/tutorial/tutorials.json @@ -323,5 +323,24 @@ ] } ] + }, + { + "gameTitle": "Final Fantasy", + "tutorials": [ + { + "name": "Multiworld Setup Guide", + "description": "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.", + "files": [ + { + "language": "English", + "filename": "ff1/multiworld_en.md", + "link": "ff1/multiworld/en", + "authors": [ + "jat2980" + ] + } + ] + } + ] } ] diff --git a/data/lua/FF1/core.dll b/data/lua/FF1/core.dll new file mode 100644 index 00000000..3e956957 Binary files /dev/null and b/data/lua/FF1/core.dll differ diff --git a/data/lua/FF1/ff1_connector.lua b/data/lua/FF1/ff1_connector.lua new file mode 100644 index 00000000..9de61dfb --- /dev/null +++ b/data/lua/FF1/ff1_connector.lua @@ -0,0 +1,542 @@ +local socket = require("socket") +local json = require('json') +local math = require('math') + +local STATE_OK = "Ok" +local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected" +local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made" +local STATE_UNINITIALIZED = "Uninitialized" + +local ITEM_INDEX = 0x03 +local WEAPON_INDEX = 0x07 +local ARMOR_INDEX = 0x0B + +local goldLookup = { + [0x16C] = 10, + [0x16D] = 20, + [0x16E] = 25, + [0x16F] = 30, + [0x170] = 55, + [0x171] = 70, + [0x172] = 85, + [0x173] = 110, + [0x174] = 135, + [0x175] = 155, + [0x176] = 160, + [0x177] = 180, + [0x178] = 240, + [0x179] = 255, + [0x17A] = 260, + [0x17B] = 295, + [0x17C] = 300, + [0x17D] = 315, + [0x17E] = 330, + [0x17F] = 350, + [0x180] = 385, + [0x181] = 400, + [0x182] = 450, + [0x183] = 500, + [0x184] = 530, + [0x185] = 575, + [0x186] = 620, + [0x187] = 680, + [0x188] = 750, + [0x189] = 795, + [0x18A] = 880, + [0x18B] = 1020, + [0x18C] = 1250, + [0x18D] = 1455, + [0x18E] = 1520, + [0x18F] = 1760, + [0x190] = 1975, + [0x191] = 2000, + [0x192] = 2750, + [0x193] = 3400, + [0x194] = 4150, + [0x195] = 5000, + [0x196] = 5450, + [0x197] = 6400, + [0x198] = 6720, + [0x199] = 7340, + [0x19A] = 7690, + [0x19B] = 7900, + [0x19C] = 8135, + [0x19D] = 9000, + [0x19E] = 9300, + [0x19F] = 9500, + [0x1A0] = 9900, + [0x1A1] = 10000, + [0x1A2] = 12350, + [0x1A3] = 13000, + [0x1A4] = 13450, + [0x1A5] = 14050, + [0x1A6] = 14720, + [0x1A7] = 15000, + [0x1A8] = 17490, + [0x1A9] = 18010, + [0x1AA] = 19990, + [0x1AB] = 20000, + [0x1AC] = 20010, + [0x1AD] = 26000, + [0x1AE] = 45000, + [0x1AF] = 65000 +} + +local extensionConsumableLookup = { + [432] = 0x3C, + [436] = 0x3C, + [440] = 0x3C, + [433] = 0x3D, + [437] = 0x3D, + [441] = 0x3D, + [434] = 0x3E, + [438] = 0x3E, + [442] = 0x3E, + [435] = 0x3F, + [439] = 0x3F, + [443] = 0x3F +} + +local itemMessages = {} +local consumableStacks = nil +local prevstate = "" +local curstate = STATE_UNINITIALIZED +local ff1Socket = nil +local frame = 0 + +local u8 = nil +local wU8 = nil +local isNesHawk = false + + +--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded +local function defineMemoryFunctions() + local memDomain = {} + local domains = memory.getmemorydomainlist() + if domains[1] == "System Bus" then + --NesHawk + isNesHawk = true + memDomain["systembus"] = function() memory.usememorydomain("System Bus") end + memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end + memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end + elseif domains[1] == "WRAM" then + --QuickNES + memDomain["systembus"] = function() memory.usememorydomain("System Bus") end + memDomain["saveram"] = function() memory.usememorydomain("WRAM") end + memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end + end + return memDomain +end + +local memDomain = defineMemoryFunctions() +u8 = memory.read_u8 +wU8 = memory.write_u8 +uRange = memory.readbyterange + +local function StateOKForMainLoop() + memDomain.saveram() + local A = u8(0x102) -- Party Made + local B = u8(0x0FC) + local C = u8(0x0A3) + return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2) +end + +function table.empty (self) + for _, _ in pairs(self) do + return false + end + return true +end + +function slice (tbl, s, e) + local pos, new = 1, {} + for i = s + 1, e do + new[pos] = tbl[i] + pos = pos + 1 + end + return new +end + +local bizhawk_version = client.getversion() +local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5") +local is26To27 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") + +local function getMaxMessageLength() + if is23Or24Or25 then + return client.screenwidth()/11 + elseif is26To27 then + return client.screenwidth()/12 + end +end + +local function drawText(x, y, message, color) + if is23Or24Or25 then + gui.addmessage(message) + elseif is26To27 then + gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", nil, nil, nil, "client") + end +end + +local function clearScreen() + if is23Or24Or25 then + return + elseif is26To27 then + drawText(0, 0, "", "black") + end +end + +local function drawMessages() + if table.empty(itemMessages) then + clearScreen() + return + end + local y = 10 + found = false + maxMessageLength = getMaxMessageLength() + for k, v in pairs(itemMessages) do + if v["TTL"] > 0 then + message = v["message"] + while true do + drawText(5, y, message:sub(1, maxMessageLength), v["color"]) + y = y + 16 + + message = message:sub(maxMessageLength + 1, message:len()) + if message:len() == 0 then + break + end + end + newTTL = 0 + if is26To27 then + newTTL = itemMessages[k]["TTL"] - 1 + end + itemMessages[k]["TTL"] = newTTL + found = true + end + end + if found == false then + clearScreen() + end +end + +function generateLocationChecked() + memDomain.saveram() + data = uRange(0x01FF, 0x101) + data[0] = nil + return data +end + +function setConsumableStacks() + memDomain.rom() + consumableStacks = {} + -- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4 + consumableStacks[0x35] = 1 + consumableStacks[0x36] = u8(0x47400) + 1 + consumableStacks[0x37] = u8(0x47401) + 1 + consumableStacks[0x38] = u8(0x47402) + 1 + consumableStacks[0x39] = u8(0x47403) + 1 + consumableStacks[0x3A] = u8(0x47404) + 1 + consumableStacks[0x3B] = u8(0x47405) + 1 + consumableStacks[0x3C] = u8(0x47406) + 1 + consumableStacks[0x3D] = u8(0x47407) + 1 + consumableStacks[0x3E] = u8(0x47408) + 1 + consumableStacks[0x3F] = u8(0x47409) + 1 +end + +function getEmptyWeaponSlots() + memDomain.saveram() + ret = {} + count = 1 + slot1 = uRange(0x118, 0x4) + slot2 = uRange(0x158, 0x4) + slot3 = uRange(0x198, 0x4) + slot4 = uRange(0x1D8, 0x4) + for i,v in pairs(slot1) do + if v == 0 then + ret[count] = 0x118 + i + count = count + 1 + end + end + for i,v in pairs(slot2) do + if v == 0 then + ret[count] = 0x158 + i + count = count + 1 + end + end + for i,v in pairs(slot3) do + if v == 0 then + ret[count] = 0x198 + i + count = count + 1 + end + end + for i,v in pairs(slot4) do + if v == 0 then + ret[count] = 0x1D8 + i + count = count + 1 + end + end + return ret +end + +function getEmptyArmorSlots() + memDomain.saveram() + ret = {} + count = 1 + slot1 = uRange(0x11C, 0x4) + slot2 = uRange(0x15C, 0x4) + slot3 = uRange(0x19C, 0x4) + slot4 = uRange(0x1DC, 0x4) + for i,v in pairs(slot1) do + if v == 0 then + ret[count] = 0x11C + i + count = count + 1 + end + end + for i,v in pairs(slot2) do + if v == 0 then + ret[count] = 0x15C + i + count = count + 1 + end + end + for i,v in pairs(slot3) do + if v == 0 then + ret[count] = 0x19C + i + count = count + 1 + end + end + for i,v in pairs(slot4) do + if v == 0 then + ret[count] = 0x1DC + i + count = count + 1 + end + end + return ret +end + +function processBlock(block) + local msgBlock = block['messages'] + if msgBlock ~= nil then + for i, v in pairs(msgBlock) do + if itemMessages[i] == nil then + local msg = {TTL=450, message=v, color=0xFFFF0000} + itemMessages[i] = msg + end + end + end + local itemsBlock = block["items"] + memDomain.saveram() + isInGame = u8(0x102) + if itemsBlock ~= nil and isInGame ~= 0x00 then + if consumableStacks == nil then + setConsumableStacks() + end + memDomain.saveram() +-- print('ITEMBLOCK: ') +-- print(itemsBlock) + itemIndex = u8(ITEM_INDEX) +-- print('ITEMINDEX: '..itemIndex) + for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do + -- Minus the offset and add to the correct domain + local memoryLocation = v + if v >= 0x100 and v <= 0x114 then + -- This is a key item + memoryLocation = memoryLocation - 0x0E0 + wU8(memoryLocation, 0x01) + elseif v >= 0x1E0 then + -- This is a movement item + -- Minus Offset (0x100) - movement offset (0xE0) + memoryLocation = memoryLocation - 0x1E0 + -- Canal is a flipped bit + if memoryLocation == 0x0C then + wU8(memoryLocation, 0x00) + else + wU8(memoryLocation, 0x01) + end + + elseif v >= 0x16C and v <= 0x1AF then + -- This is a gold item + amountToAdd = goldLookup[v] + biggest = u8(0x01E) + medium = u8(0x01D) + smallest = u8(0x01C) + currentValue = 0x10000 * biggest + 0x100 * medium + smallest + newValue = currentValue + amountToAdd + newBiggest = math.floor(newValue / 0x10000) + newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100) + newSmallest = math.floor(math.fmod(newValue, 0x100)) + wU8(0x01E, newBiggest) + wU8(0x01D, newMedium) + wU8(0x01C, newSmallest) + elseif v >= 0x115 and v <= 0x11B then + -- This is a regular consumable OR a shard + -- Minus Offset (0x100) + item offset (0x20) + memoryLocation = memoryLocation - 0x0E0 + currentValue = u8(memoryLocation) + amountToAdd = consumableStacks[memoryLocation] + if currentValue < 99 then + wU8(memoryLocation, currentValue + amountToAdd) + end + elseif v >= 0x1B0 and v <= 0x1BB then + -- This is an extension consumable + memoryLocation = extensionConsumableLookup[v] + currentValue = u8(memoryLocation) + amountToAdd = consumableStacks[memoryLocation] + if currentValue < 99 then + value = currentValue + amountToAdd + if value > 99 then + value = 99 + end + wU8(memoryLocation, value) + end + end + end + if #itemsBlock ~= itemIndex then + wU8(ITEM_INDEX, #itemsBlock) + end + + memDomain.saveram() + weaponIndex = u8(WEAPON_INDEX) + emptyWeaponSlots = getEmptyWeaponSlots() + lastUsedWeaponIndex = weaponIndex +-- print('WEAPON_INDEX: '.. weaponIndex) + memDomain.saveram() + for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do + if v >= 0x11C and v <= 0x143 then + -- Minus the offset and add to the correct domain + local itemValue = v - 0x11B + if #emptyWeaponSlots > 0 then + slot = table.remove(emptyWeaponSlots, 1) + wU8(slot, itemValue) + lastUsedWeaponIndex = weaponIndex + i + else + break + end + end + end + if lastUsedWeaponIndex ~= weaponIndex then + wU8(WEAPON_INDEX, lastUsedWeaponIndex) + end + memDomain.saveram() + armorIndex = u8(ARMOR_INDEX) + emptyArmorSlots = getEmptyArmorSlots() + lastUsedArmorIndex = armorIndex +-- print('ARMOR_INDEX: '.. armorIndex) + memDomain.saveram() + for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do + if v >= 0x144 and v <= 0x16B then + -- Minus the offset and add to the correct domain + local itemValue = v - 0x143 + if #emptyArmorSlots > 0 then + slot = table.remove(emptyArmorSlots, 1) + wU8(slot, itemValue) + lastUsedArmorIndex = armorIndex + i + else + break + end + end + end + if lastUsedArmorIndex ~= armorIndex then + wU8(ARMOR_INDEX, lastUsedArmorIndex) + end + end +end + +function difference(a, b) + local aa = {} + for k,v in pairs(a) do aa[v]=true end + for k,v in pairs(b) do aa[v]=nil end + local ret = {} + local n = 0 + for k,v in pairs(a) do + if aa[v] then n=n+1 ret[n]=v end + end + return ret +end + +function receive() + l, e = ff1Socket:receive() + if e == 'closed' then + if curstate == STATE_OK then + print("Connection closed") + end + curstate = STATE_UNINITIALIZED + return + elseif e == 'timeout' then + print("timeout") + return + elseif e ~= nil then + print(e) + curstate = STATE_UNINITIALIZED + return + end + processBlock(json.decode(l)) + + -- Determine Message to send back + memDomain.rom() + local playerName = uRange(0x7BCBF, 0x41) + playerName[0] = nil + local retTable = {} + retTable["playerName"] = playerName + if StateOKForMainLoop() then + retTable["locations"] = generateLocationChecked() + end + msg = json.encode(retTable).."\n" + local ret, error = ff1Socket:send(msg) + if ret == nil then + print(error) + elseif curstate == STATE_INITIAL_CONNECTION_MADE then + curstate = STATE_TENTATIVELY_CONNECTED + elseif curstate == STATE_TENTATIVELY_CONNECTED then + print("Connected!") + itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"} + curstate = STATE_OK + end +end + +function main() + if (is23Or24Or25 or is26To27) == false then + print("Must use a version of bizhawk 2.3.1 or higher") + return + end + server, error = socket.bind('localhost', 52980) + + while true do + gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow") + frame = frame + 1 + drawMessages() + if not (curstate == prevstate) then + -- console.log("Current state: "..curstate) + prevstate = curstate + end + if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then + if (frame % 60 == 0) then + gui.drawEllipse(248, 9, 6, 6, "Black", "Blue") + receive() + else + gui.drawEllipse(248, 9, 6, 6, "Black", "Green") + end + elseif (curstate == STATE_UNINITIALIZED) then + gui.drawEllipse(248, 9, 6, 6, "Black", "White") + if (frame % 60 == 0) then + gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow") + + drawText(5, 8, "Waiting for client", 0xFFFF0000) + drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000) + + -- Advance so the messages are drawn + emu.frameadvance() + server:settimeout(2) + print("Attempting to connect") + local client, timeout = server:accept() + if timeout == nil then + -- print('Initial Connection Made') + curstate = STATE_INITIAL_CONNECTION_MADE + ff1Socket = client + ff1Socket:settimeout(0) + end + end + end + emu.frameadvance() + end +end + +main() diff --git a/data/lua/FF1/json.lua b/data/lua/FF1/json.lua new file mode 100644 index 00000000..0833bf6f --- /dev/null +++ b/data/lua/FF1/json.lua @@ -0,0 +1,380 @@ +-- +-- json.lua +-- +-- Copyright (c) 2015 rxi +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. +-- + +local json = { _version = "0.1.0" } + +------------------------------------------------------------------------------- +-- Encode +------------------------------------------------------------------------------- + +local encode + +local escape_char_map = { + [ "\\" ] = "\\\\", + [ "\"" ] = "\\\"", + [ "\b" ] = "\\b", + [ "\f" ] = "\\f", + [ "\n" ] = "\\n", + [ "\r" ] = "\\r", + [ "\t" ] = "\\t", +} + +local escape_char_map_inv = { [ "\\/" ] = "/" } +for k, v in pairs(escape_char_map) do + escape_char_map_inv[v] = k +end + + +local function escape_char(c) + return escape_char_map[c] or string.format("\\u%04x", c:byte()) +end + + +local function encode_nil(val) + return "null" +end + + +local function encode_table(val, stack) + local res = {} + stack = stack or {} + + -- Circular reference? + if stack[val] then error("circular reference") end + + stack[val] = true + + if val[1] ~= nil or next(val) == nil then + -- Treat as array -- check keys are valid and it is not sparse + local n = 0 + for k in pairs(val) do + if type(k) ~= "number" then + error("invalid table: mixed or invalid key types") + end + n = n + 1 + end + if n ~= #val then + error("invalid table: sparse array") + end + -- Encode + for i, v in ipairs(val) do + table.insert(res, encode(v, stack)) + end + stack[val] = nil + return "[" .. table.concat(res, ",") .. "]" + + else + -- Treat as an object + for k, v in pairs(val) do + if type(k) ~= "string" then + error("invalid table: mixed or invalid key types") + end + table.insert(res, encode(k, stack) .. ":" .. encode(v, stack)) + end + stack[val] = nil + return "{" .. table.concat(res, ",") .. "}" + end +end + + +local function encode_string(val) + return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"' +end + + +local function encode_number(val) + -- Check for NaN, -inf and inf + if val ~= val or val <= -math.huge or val >= math.huge then + error("unexpected number value '" .. tostring(val) .. "'") + end + return string.format("%.14g", val) +end + + +local type_func_map = { + [ "nil" ] = encode_nil, + [ "table" ] = encode_table, + [ "string" ] = encode_string, + [ "number" ] = encode_number, + [ "boolean" ] = tostring, +} + + +encode = function(val, stack) + local t = type(val) + local f = type_func_map[t] + if f then + return f(val, stack) + end + error("unexpected type '" .. t .. "'") +end + + +function json.encode(val) + return ( encode(val) ) +end + + +------------------------------------------------------------------------------- +-- Decode +------------------------------------------------------------------------------- + +local parse + +local function create_set(...) + local res = {} + for i = 1, select("#", ...) do + res[ select(i, ...) ] = true + end + return res +end + +local space_chars = create_set(" ", "\t", "\r", "\n") +local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",") +local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u") +local literals = create_set("true", "false", "null") + +local literal_map = { + [ "true" ] = true, + [ "false" ] = false, + [ "null" ] = nil, +} + + +local function next_char(str, idx, set, negate) + for i = idx, #str do + if set[str:sub(i, i)] ~= negate then + return i + end + end + return #str + 1 +end + + +local function decode_error(str, idx, msg) + --local line_count = 1 + --local col_count = 1 + --for i = 1, idx - 1 do + -- col_count = col_count + 1 + -- if str:sub(i, i) == "\n" then + -- line_count = line_count + 1 + -- col_count = 1 + -- end + -- end + -- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) ) +end + + +local function codepoint_to_utf8(n) + -- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa + local f = math.floor + if n <= 0x7f then + return string.char(n) + elseif n <= 0x7ff then + return string.char(f(n / 64) + 192, n % 64 + 128) + elseif n <= 0xffff then + return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128) + elseif n <= 0x10ffff then + return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128, + f(n % 4096 / 64) + 128, n % 64 + 128) + end + error( string.format("invalid unicode codepoint '%x'", n) ) +end + + +local function parse_unicode_escape(s) + local n1 = tonumber( s:sub(3, 6), 16 ) + local n2 = tonumber( s:sub(9, 12), 16 ) + -- Surrogate pair? + if n2 then + return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000) + else + return codepoint_to_utf8(n1) + end +end + + +local function parse_string(str, i) + local has_unicode_escape = false + local has_surrogate_escape = false + local has_escape = false + local last + for j = i + 1, #str do + local x = str:byte(j) + + if x < 32 then + decode_error(str, j, "control character in string") + end + + if last == 92 then -- "\\" (escape char) + if x == 117 then -- "u" (unicode escape sequence) + local hex = str:sub(j + 1, j + 5) + if not hex:find("%x%x%x%x") then + decode_error(str, j, "invalid unicode escape in string") + end + if hex:find("^[dD][89aAbB]") then + has_surrogate_escape = true + else + has_unicode_escape = true + end + else + local c = string.char(x) + if not escape_chars[c] then + decode_error(str, j, "invalid escape char '" .. c .. "' in string") + end + has_escape = true + end + last = nil + + elseif x == 34 then -- '"' (end of string) + local s = str:sub(i + 1, j - 1) + if has_surrogate_escape then + s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape) + end + if has_unicode_escape then + s = s:gsub("\\u....", parse_unicode_escape) + end + if has_escape then + s = s:gsub("\\.", escape_char_map_inv) + end + return s, j + 1 + + else + last = x + end + end + decode_error(str, i, "expected closing quote for string") +end + + +local function parse_number(str, i) + local x = next_char(str, i, delim_chars) + local s = str:sub(i, x - 1) + local n = tonumber(s) + if not n then + decode_error(str, i, "invalid number '" .. s .. "'") + end + return n, x +end + + +local function parse_literal(str, i) + local x = next_char(str, i, delim_chars) + local word = str:sub(i, x - 1) + if not literals[word] then + decode_error(str, i, "invalid literal '" .. word .. "'") + end + return literal_map[word], x +end + + +local function parse_array(str, i) + local res = {} + local n = 1 + i = i + 1 + while 1 do + local x + i = next_char(str, i, space_chars, true) + -- Empty / end of array? + if str:sub(i, i) == "]" then + i = i + 1 + break + end + -- Read token + x, i = parse(str, i) + res[n] = x + n = n + 1 + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "]" then break end + if chr ~= "," then decode_error(str, i, "expected ']' or ','") end + end + return res, i +end + + +local function parse_object(str, i) + local res = {} + i = i + 1 + while 1 do + local key, val + i = next_char(str, i, space_chars, true) + -- Empty / end of object? + if str:sub(i, i) == "}" then + i = i + 1 + break + end + -- Read key + if str:sub(i, i) ~= '"' then + decode_error(str, i, "expected string for key") + end + key, i = parse(str, i) + -- Read ':' delimiter + i = next_char(str, i, space_chars, true) + if str:sub(i, i) ~= ":" then + decode_error(str, i, "expected ':' after key") + end + i = next_char(str, i + 1, space_chars, true) + -- Read value + val, i = parse(str, i) + -- Set + res[key] = val + -- Next token + i = next_char(str, i, space_chars, true) + local chr = str:sub(i, i) + i = i + 1 + if chr == "}" then break end + if chr ~= "," then decode_error(str, i, "expected '}' or ','") end + end + return res, i +end + + +local char_func_map = { + [ '"' ] = parse_string, + [ "0" ] = parse_number, + [ "1" ] = parse_number, + [ "2" ] = parse_number, + [ "3" ] = parse_number, + [ "4" ] = parse_number, + [ "5" ] = parse_number, + [ "6" ] = parse_number, + [ "7" ] = parse_number, + [ "8" ] = parse_number, + [ "9" ] = parse_number, + [ "-" ] = parse_number, + [ "t" ] = parse_literal, + [ "f" ] = parse_literal, + [ "n" ] = parse_literal, + [ "[" ] = parse_array, + [ "{" ] = parse_object, +} + + +parse = function(str, idx) + local chr = str:sub(idx, idx) + local f = char_func_map[chr] + if f then + return f(str, idx) + end + decode_error(str, idx, "unexpected character '" .. chr .. "'") +end + + +function json.decode(str) + if type(str) ~= "string" then + error("expected argument of type string, got " .. type(str)) + end + return ( parse(str, next_char(str, 1, space_chars, true)) ) +end + + +return json \ No newline at end of file diff --git a/data/lua/FF1/socket.lua b/data/lua/FF1/socket.lua new file mode 100644 index 00000000..a98e9521 --- /dev/null +++ b/data/lua/FF1/socket.lua @@ -0,0 +1,132 @@ +----------------------------------------------------------------------------- +-- LuaSocket helper module +-- Author: Diego Nehab +-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $ +----------------------------------------------------------------------------- + +----------------------------------------------------------------------------- +-- Declare module and import dependencies +----------------------------------------------------------------------------- +local base = _G +local string = require("string") +local math = require("math") +local socket = require("socket.core") +module("socket") + +----------------------------------------------------------------------------- +-- Exported auxiliar functions +----------------------------------------------------------------------------- +function connect(address, port, laddress, lport) + local sock, err = socket.tcp() + if not sock then return nil, err end + if laddress then + local res, err = sock:bind(laddress, lport, -1) + if not res then return nil, err end + end + local res, err = sock:connect(address, port) + if not res then return nil, err end + return sock +end + +function bind(host, port, backlog) + local sock, err = socket.tcp() + if not sock then return nil, err end + sock:setoption("reuseaddr", true) + local res, err = sock:bind(host, port) + if not res then return nil, err end + res, err = sock:listen(backlog) + if not res then return nil, err end + return sock +end + +try = newtry() + +function choose(table) + return function(name, opt1, opt2) + if base.type(name) ~= "string" then + name, opt1, opt2 = "default", name, opt1 + end + local f = table[name or "nil"] + if not f then base.error("unknown key (".. base.tostring(name) ..")", 3) + else return f(opt1, opt2) end + end +end + +----------------------------------------------------------------------------- +-- Socket sources and sinks, conforming to LTN12 +----------------------------------------------------------------------------- +-- create namespaces inside LuaSocket namespace +sourcet = {} +sinkt = {} + +BLOCKSIZE = 2048 + +sinkt["close-when-done"] = function(sock) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function(self, chunk, err) + if not chunk then + sock:close() + return 1 + else return sock:send(chunk) end + end + }) +end + +sinkt["keep-open"] = function(sock) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function(self, chunk, err) + if chunk then return sock:send(chunk) + else return 1 end + end + }) +end + +sinkt["default"] = sinkt["keep-open"] + +sink = choose(sinkt) + +sourcet["by-length"] = function(sock, length) + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function() + if length <= 0 then return nil end + local size = math.min(socket.BLOCKSIZE, length) + local chunk, err = sock:receive(size) + if err then return nil, err end + length = length - string.len(chunk) + return chunk + end + }) +end + +sourcet["until-closed"] = function(sock) + local done + return base.setmetatable({ + getfd = function() return sock:getfd() end, + dirty = function() return sock:dirty() end + }, { + __call = function() + if done then return nil end + local chunk, err, partial = sock:receive(socket.BLOCKSIZE) + if not err then return chunk + elseif err == "closed" then + sock:close() + done = 1 + return partial + else return nil, err end + end + }) +end + + +sourcet["default"] = sourcet["until-closed"] + +source = choose(sourcet) diff --git a/playerSettings.yaml b/playerSettings.yaml index e41acaf0..d1bf71fe 100644 --- a/playerSettings.yaml +++ b/playerSettings.yaml @@ -50,7 +50,7 @@ progression_balancing: # - "Big Keys" # non_local_items: # Force certain items to appear outside your world only, unless in single-player. Recognizes some group names, like "Swords" # - "Progressive Weapons" -# exclude_locations: # Force certain locations to never contain progression items, and always be filled with junk. +# exclude_locations: # Force certain locations to never contain progression items, and always be filled with junk. # - "Master Sword Pedestal" A Link to the Past: @@ -261,7 +261,7 @@ A Link to the Past: 25: 0 # 25% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees 50: 0 # 50% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees 75: 0 # 75% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees - 100: 0 # All junk fill items (rupees, bombs and arrows) are replaced with bees + 100: 0 # All junk fill items (rupees, bombs and arrows) are replaced with bees beemizer_trap_chance: 60: 50 # 60% chance for each beemizer replacement to be a trap, 40% chance to be a single bee 70: 0 # 70% chance for each beemizer replacement to be a trap, 30% chance to be a single bee diff --git a/setup.py b/setup.py index 3a7c313c..0911ce5e 100644 --- a/setup.py +++ b/setup.py @@ -82,6 +82,8 @@ scripts = { "MinecraftClient.py": ("ArchipelagoMinecraftClient", False, mcicon), # Ocarina of Time "OoTAdjuster.py": ("ArchipelagoOoTAdjuster", True, icon), + # FF1 + "FF1Client.py": ("ArchipelagoFF1Client", True, icon), } exes = [] diff --git a/worlds/ff1/Items.py b/worlds/ff1/Items.py new file mode 100644 index 00000000..cab5c26b --- /dev/null +++ b/worlds/ff1/Items.py @@ -0,0 +1,71 @@ +import json +from pathlib import Path +from typing import Dict, Set, NamedTuple, List + +from BaseClasses import Item + + +class ItemData(NamedTuple): + name: str + code: int + item_type: str + progression: bool + + +FF1_BRIDGE = 'Bridge' + + +FF1_STARTER_ITEMS = [ + "Ship" +] + +FF1_PROGRESSION_LIST = [ + "Rod", "Cube", "Lute", "Key", "Chime", "Oxyale", + "Ship", "Canoe", "Floater", "Canal", + "Crown", "Crystal", "Herb", "Tnt", "Adamant", "Slab", "Ruby", "Bottle", + "Shard", + "EarthOrb", "FireOrb", "WaterOrb", "AirOrb" +] + + +class FF1Items: + _item_table: List[ItemData] = [] + _item_table_lookup: Dict[str, ItemData] = {} + + def _populate_item_table_from_data(self): + base_path = Path(__file__).parent + file_path = (base_path / "data/items.json").resolve() + with open(file_path) as file: + items = json.load(file) + # Hardcode progression and categories for now + self._item_table = [ItemData(name, code, "FF1Item", name in FF1_PROGRESSION_LIST) + for name, code in items.items()] + self._item_table_lookup = {item.name: item for item in self._item_table} + + def _get_item_table(self) -> List[ItemData]: + if not self._item_table or not self._item_table_lookup: + self._populate_item_table_from_data() + return self._item_table + + def _get_item_table_lookup(self) -> Dict[str, ItemData]: + if not self._item_table or not self._item_table_lookup: + self._populate_item_table_from_data() + return self._item_table_lookup + + def get_item_names_per_category(self) -> Dict[str, Set[str]]: + categories: Dict[str, Set[str]] = {} + + for item in self._get_item_table(): + categories.setdefault(item.item_type, set()).add(item.name) + + return categories + + def generate_item(self, name: str, player: int) -> Item: + item = self._get_item_table_lookup().get(name) + return Item(name, item.progression, item.code, player) + + def get_item_name_to_code_dict(self) -> Dict[str, int]: + return {name: item.code for name, item in self._get_item_table_lookup().items()} + + def get_item(self, name: str) -> ItemData: + return self._get_item_table_lookup()[name] diff --git a/worlds/ff1/Locations.py b/worlds/ff1/Locations.py new file mode 100644 index 00000000..6b733c70 --- /dev/null +++ b/worlds/ff1/Locations.py @@ -0,0 +1,75 @@ +import json +from pathlib import Path +from typing import Dict, NamedTuple, List, Optional + +from BaseClasses import Region, RegionType, Location + +EventId: Optional[int] = None +CHAOS_TERMINATED_EVENT = 'Terminated Chaos' + + +class LocationData(NamedTuple): + name: str + address: int + + +class FF1Locations: + _location_table: List[LocationData] = [] + _location_table_lookup: Dict[str, LocationData] = {} + + def _populate_item_table_from_data(self): + base_path = Path(__file__).parent + file_path = (base_path / "data/locations.json").resolve() + with open(file_path) as file: + locations = json.load(file) + # Hardcode progression and categories for now + self._location_table = [LocationData(name, code) for name, code in locations.items()] + self._location_table_lookup = {item.name: item for item in self._location_table} + + def _get_location_table(self) -> List[LocationData]: + if not self._location_table or not self._location_table_lookup: + self._populate_item_table_from_data() + return self._location_table + + def _get_location_table_lookup(self) -> Dict[str, LocationData]: + if not self._location_table or not self._location_table_lookup: + self._populate_item_table_from_data() + return self._location_table_lookup + + def get_location_name_to_address_dict(self) -> Dict[str, int]: + data = {name: location.address for name, location in self._get_location_table_lookup().items()} + data[CHAOS_TERMINATED_EVENT] = EventId + return data + + @staticmethod + def create_menu_region(player: int, locations: Dict[str, int], + rules: Dict[str, List[List[str]]]) -> Region: + menu_region = Region("Menu", RegionType.Generic, "Menu", player) + for name, address in locations.items(): + location = Location(player, name, address, menu_region) + ## TODO REMOVE WHEN LOGIC FOR TOFR IS CORRECT + if "ToFR" in name: + rules_list = [["Rod", "Cube", "Lute", "Key", "Chime", "Oxyale", + "Ship", "Canoe", "Floater", "Canal", + "Crown", "Crystal", "Herb", "Tnt", "Adamant", "Slab", "Ruby", "Bottle"]] + location.access_rule = generate_rule(rules_list, player) + elif name in rules: + rules_list = rules[name] + location.access_rule = generate_rule(rules_list, player) + menu_region.locations.append(location) + + return menu_region + + +def generate_rule(rules_list, player): + def x(state): + for rule in rules_list: + current_state = True + for item in rule: + if not state.has(item, player): + current_state = False + break + if current_state: + return True + return False + return x diff --git a/worlds/ff1/Options.py b/worlds/ff1/Options.py new file mode 100644 index 00000000..ac1b0952 --- /dev/null +++ b/worlds/ff1/Options.py @@ -0,0 +1,22 @@ +from typing import Dict + +from Options import OptionDict + + +class Locations(OptionDict): + displayname = "locations" + + +class Items(OptionDict): + displayname = "items" + + +class Rules(OptionDict): + displayname = "rules" + + +ff1_options: Dict[str, OptionDict] = { + "locations": Locations, + "items": Items, + "rules": Rules +} diff --git a/worlds/ff1/__init__.py b/worlds/ff1/__init__.py new file mode 100644 index 00000000..01b6e8db --- /dev/null +++ b/worlds/ff1/__init__.py @@ -0,0 +1,97 @@ +from typing import Dict +from BaseClasses import Item, Location, MultiWorld +from .Items import ItemData, FF1Items, FF1_STARTER_ITEMS, FF1_PROGRESSION_LIST, FF1_BRIDGE +from .Locations import EventId, FF1Locations, generate_rule, CHAOS_TERMINATED_EVENT +from .Options import ff1_options +from ..AutoWorld import World + + +class FF1World(World): + """ + Final Fantasy 1, originally released on the NES on 1987, is the game that started the beloved, long running series. + The randomizer takes the original 8-bit Final Fantasy game for NES (USA edition) and allows you to + shuffle important aspects like the location of key items, the difficulty of monsters and fiends, + and even the location of towns and dungeons. + Part puzzle and part speed-run, it breathes new life into one of the most influential games ever made. + """ + + options = ff1_options + game = "Final Fantasy" + topology_present = False + remote_items = True + data_version = 0 + remote_start_inventory = True + + ff1_items = FF1Items() + ff1_locations = FF1Locations() + item_name_groups = ff1_items.get_item_names_per_category() + item_name_to_id = ff1_items.get_item_name_to_code_dict() + location_name_to_id = ff1_locations.get_location_name_to_address_dict() + + def __init__(self, world: MultiWorld, player: int): + super().__init__(world, player) + self.locked_items = [] + self.locked_locations = [] + + def generate_early(self): + return + + def create_regions(self): + locations = get_options(self.world, 'locations', self.player) + rules = get_options(self.world, 'rules', self.player) + menu_region = self.ff1_locations.create_menu_region(self.player, locations, rules) + terminated_event = Location(self.player, CHAOS_TERMINATED_EVENT, EventId, menu_region) + terminated_item = Item(CHAOS_TERMINATED_EVENT, True, EventId, self.player) + terminated_event.place_locked_item(terminated_item) + + items = get_options(self.world, 'items', self.player) + goal_rule = generate_rule([[name for name in items.keys() if name in FF1_PROGRESSION_LIST and name != "Shard"]], + self.player) + if "Shard" in items.keys(): + def goal_rule_and_shards(state): + return goal_rule(state) and state.has("Shard", self.player, 32) + terminated_event.access_rule = goal_rule_and_shards + + menu_region.locations.append(terminated_event) + self.world.regions += [menu_region] + + def create_item(self, name: str) -> Item: + return self.ff1_items.generate_item(name, self.player) + + def set_rules(self): + self.world.completion_condition[self.player] = lambda state: state.has(CHAOS_TERMINATED_EVENT, self.player) + + def generate_basic(self): + items = get_options(self.world, 'items', self.player) + if FF1_BRIDGE in items.keys(): + self._place_locked_item_in_sphere0(FF1_BRIDGE) + if items: + possible_early_items = [name for name in FF1_STARTER_ITEMS if name in items.keys()] + if possible_early_items: + progression_item = self.world.random.choice(possible_early_items) + self._place_locked_item_in_sphere0(progression_item) + items = [self.create_item(name) for name, data in items.items() for x in range(data['count']) if name not in + self.locked_items] + + self.world.itempool += items + + def _place_locked_item_in_sphere0(self, progression_item: str): + if progression_item: + rules = get_options(self.world, 'rules', self.player) + sphere_0_locations = [name for name, rules in rules.items() + if rules and len(rules[0]) == 0 and name not in self.locked_locations] + if sphere_0_locations: + initial_location = self.world.random.choice(sphere_0_locations) + locked_location = self.world.get_location(initial_location, self.player) + locked_location.place_locked_item(self.create_item(progression_item)) + self.locked_items.append(progression_item) + self.locked_locations.append(locked_location.name) + + def fill_slot_data(self) -> Dict[str, object]: + slot_data: Dict[str, object] = {} + + return slot_data + + +def get_options(world: MultiWorld, name: str, player: int): + return getattr(world, name, None)[player].value diff --git a/worlds/ff1/data/items.json b/worlds/ff1/data/items.json new file mode 100644 index 00000000..99611837 --- /dev/null +++ b/worlds/ff1/data/items.json @@ -0,0 +1,194 @@ +{ + "None": 256, + "Lute": 257, + "Crown": 258, + "Crystal": 259, + "Herb": 260, + "Key": 261, + "Tnt": 262, + "Adamant": 263, + "Slab": 264, + "Ruby": 265, + "Rod": 266, + "Floater": 267, + "Chime": 268, + "Tail": 269, + "Cube": 270, + "Bottle": 271, + "Oxyale": 272, + "EarthOrb": 273, + "FireOrb": 274, + "WaterOrb": 275, + "AirOrb": 276, + "Shard": 277, + "Tent": 278, + "Cabin": 279, + "House": 280, + "Heal": 281, + "Pure": 282, + "Soft": 283, + "WoodenNunchucks": 284, + "SmallKnife": 285, + "WoodenRod": 286, + "Rapier": 287, + "IronHammer": 288, + "ShortSword": 289, + "HandAxe": 290, + "Scimitar": 291, + "IronNunchucks": 292, + "LargeKnife": 293, + "IronStaff": 294, + "Sabre": 295, + "LongSword": 296, + "GreatAxe": 297, + "Falchon": 298, + "SilverKnife": 299, + "SilverSword": 300, + "SilverHammer": 301, + "SilverAxe": 302, + "FlameSword": 303, + "IceSword": 304, + "DragonSword": 305, + "GiantSword": 306, + "SunSword": 307, + "CoralSword": 308, + "WereSword": 309, + "RuneSword": 310, + "PowerRod": 311, + "LightAxe": 312, + "HealRod": 313, + "MageRod": 314, + "Defense": 315, + "WizardRod": 316, + "Vorpal": 317, + "CatClaw": 318, + "ThorHammer": 319, + "BaneSword": 320, + "Katana": 321, + "Xcalber": 322, + "Masamune": 323, + "Cloth": 324, + "WoodenArmor": 325, + "ChainArmor": 326, + "IronArmor": 327, + "SteelArmor": 328, + "SilverArmor": 329, + "FlameArmor": 330, + "IceArmor": 331, + "OpalArmor": 332, + "DragonArmor": 333, + "Copper": 334, + "Silver": 335, + "Gold": 336, + "Opal": 337, + "WhiteShirt": 338, + "BlackShirt": 339, + "WoodenShield": 340, + "IronShield": 341, + "SilverShield": 342, + "FlameShield": 343, + "IceShield": 344, + "OpalShield": 345, + "AegisShield": 346, + "Buckler": 347, + "ProCape": 348, + "Cap": 349, + "WoodenHelm": 350, + "IronHelm": 351, + "SilverHelm": 352, + "OpalHelm": 353, + "HealHelm": 354, + "Ribbon": 355, + "Gloves": 356, + "CopperGauntlets": 357, + "IronGauntlets": 358, + "SilverGauntlets": 359, + "ZeusGauntlets": 360, + "PowerGauntlets": 361, + "OpalGauntlets": 362, + "ProRing": 363, + "Gold10": 364, + "Gold20": 365, + "Gold25": 366, + "Gold30": 367, + "Gold55": 368, + "Gold70": 369, + "Gold85": 370, + "Gold110": 371, + "Gold135": 372, + "Gold155": 373, + "Gold160": 374, + "Gold180": 375, + "Gold240": 376, + "Gold255": 377, + "Gold260": 378, + "Gold295": 379, + "Gold300": 380, + "Gold315": 381, + "Gold330": 382, + "Gold350": 383, + "Gold385": 384, + "Gold400": 385, + "Gold450": 386, + "Gold500": 387, + "Gold530": 388, + "Gold575": 389, + "Gold620": 390, + "Gold680": 391, + "Gold750": 392, + "Gold795": 393, + "Gold880": 394, + "Gold1020": 395, + "Gold1250": 396, + "Gold1455": 397, + "Gold1520": 398, + "Gold1760": 399, + "Gold1975": 400, + "Gold2000": 401, + "Gold2750": 402, + "Gold3400": 403, + "Gold4150": 404, + "Gold5000": 405, + "Gold5450": 406, + "Gold6400": 407, + "Gold6720": 408, + "Gold7340": 409, + "Gold7690": 410, + "Gold7900": 411, + "Gold8135": 412, + "Gold9000": 413, + "Gold9300": 414, + "Gold9500": 415, + "Gold9900": 416, + "Gold10000": 417, + "Gold12350": 418, + "Gold13000": 419, + "Gold13450": 420, + "Gold14050": 421, + "Gold14720": 422, + "Gold15000": 423, + "Gold17490": 424, + "Gold18010": 425, + "Gold19990": 426, + "Gold20000": 427, + "Gold20010": 428, + "Gold26000": 429, + "Gold45000": 430, + "Gold65000": 431, + "Smoke": 435, + "FullCure": 432, + "Blast": 434, + "Phoenix": 433, + "Flare": 437, + "Black": 438, + "Refresh": 436, + "Guard": 439, + "Wizard": 442, + "HighPotion": 441, + "Cloak": 443, + "Quick": 440, + "Ship": 480, + "Bridge": 488, + "Canal": 492, + "Canoe": 498 +} diff --git a/worlds/ff1/data/locations.json b/worlds/ff1/data/locations.json new file mode 100644 index 00000000..9771d51d --- /dev/null +++ b/worlds/ff1/data/locations.json @@ -0,0 +1,257 @@ +{ + "Coneria1": 257, + "Coneria2": 258, + "ConeriaMajor": 259, + "Coneria4": 260, + "Coneria5": 261, + "Coneria6": 262, + "MatoyasCave1": 299, + "MatoyasCave3": 301, + "MatoyasCave2": 300, + "NorthwestCastle1": 273, + "NorthwestCastle3": 275, + "NorthwestCastle2": 274, + "ToFTopLeft1": 263, + "ToFBottomLeft": 265, + "ToFTopLeft2": 264, + "ToFRevisited6": 509, + "ToFRevisited4": 507, + "ToFRMasmune": 504, + "ToFRevisited5": 508, + "ToFRevisited3": 506, + "ToFRevisited2": 505, + "ToFRevisited7": 510, + "ToFTopRight1": 267, + "ToFTopRight2": 268, + "ToFBottomRight": 266, + "IceCave15": 377, + "IceCave16": 378, + "IceCave9": 371, + "IceCave11": 373, + "IceCave10": 372, + "IceCave12": 374, + "IceCave13": 375, + "IceCave14": 376, + "IceCave1": 363, + "IceCave2": 364, + "IceCave3": 365, + "IceCave4": 366, + "IceCave5": 367, + "IceCaveMajor": 370, + "IceCave7": 369, + "IceCave6": 368, + "Elfland1": 269, + "Elfland2": 270, + "Elfland3": 271, + "Elfland4": 272, + "Ordeals5": 383, + "Ordeals6": 384, + "Ordeals7": 385, + "Ordeals1": 379, + "Ordeals2": 380, + "Ordeals3": 381, + "Ordeals4": 382, + "OrdealsMajor": 387, + "Ordeals8": 386, + "SeaShrine7": 411, + "SeaShrine8": 412, + "SeaShrine9": 413, + "SeaShrine10": 414, + "SeaShrine1": 405, + "SeaShrine2": 406, + "SeaShrine3": 407, + "SeaShrine4": 408, + "SeaShrine5": 409, + "SeaShrine6": 410, + "SeaShrine13": 417, + "SeaShrine14": 418, + "SeaShrine11": 415, + "SeaShrine15": 419, + "SeaShrine16": 420, + "SeaShrineLocked": 421, + "SeaShrine18": 422, + "SeaShrine19": 423, + "SeaShrine20": 424, + "SeaShrine23": 427, + "SeaShrine21": 425, + "SeaShrine22": 426, + "SeaShrine24": 428, + "SeaShrine26": 430, + "SeaShrine28": 432, + "SeaShrine25": 429, + "SeaShrine30": 434, + "SeaShrine31": 435, + "SeaShrine27": 431, + "SeaShrine29": 433, + "SeaShrineMajor": 436, + "SeaShrine12": 416, + "DwarfCave3": 291, + "DwarfCave4": 292, + "DwarfCave6": 294, + "DwarfCave7": 295, + "DwarfCave5": 293, + "DwarfCave8": 296, + "DwarfCave9": 297, + "DwarfCave10": 298, + "DwarfCave1": 289, + "DwarfCave2": 290, + "Waterfall1": 437, + "Waterfall2": 438, + "Waterfall3": 439, + "Waterfall4": 440, + "Waterfall5": 441, + "Waterfall6": 442, + "MirageTower5": 456, + "MirageTower16": 467, + "MirageTower17": 468, + "MirageTower15": 466, + "MirageTower18": 469, + "MirageTower14": 465, + "SkyPalace1": 470, + "SkyPalace2": 471, + "SkyPalace3": 472, + "SkyPalace4": 473, + "SkyPalace18": 487, + "SkyPalace19": 488, + "SkyPalace16": 485, + "SkyPalaceMajor": 489, + "SkyPalace17": 486, + "SkyPalace22": 491, + "SkyPalace21": 490, + "SkyPalace23": 492, + "SkyPalace24": 493, + "SkyPalace31": 500, + "SkyPalace32": 501, + "SkyPalace33": 502, + "SkyPalace34": 503, + "SkyPalace29": 498, + "SkyPalace26": 495, + "SkyPalace25": 494, + "SkyPalace28": 497, + "SkyPalace27": 496, + "SkyPalace30": 499, + "SkyPalace14": 483, + "SkyPalace11": 480, + "SkyPalace12": 481, + "SkyPalace13": 482, + "SkyPalace15": 484, + "SkyPalace10": 479, + "SkyPalace5": 474, + "SkyPalace6": 475, + "SkyPalace7": 476, + "SkyPalace8": 477, + "SkyPalace9": 478, + "MirageTower9": 460, + "MirageTower13": 464, + "MirageTower10": 461, + "MirageTower12": 463, + "MirageTower11": 462, + "MirageTower1": 452, + "MirageTower2": 453, + "MirageTower4": 455, + "MirageTower3": 454, + "MirageTower8": 459, + "MirageTower7": 458, + "MirageTower6": 457, + "Volcano30": 359, + "Volcano32": 361, + "Volcano31": 360, + "Volcano28": 357, + "Volcano29": 358, + "Volcano21": 350, + "Volcano20": 349, + "Volcano24": 353, + "Volcano19": 348, + "Volcano25": 354, + "VolcanoMajor": 362, + "Volcano26": 355, + "Volcano27": 356, + "Volcano22": 351, + "Volcano23": 352, + "Volcano1": 330, + "Volcano9": 338, + "Volcano2": 331, + "Volcano10": 339, + "Volcano3": 332, + "Volcano8": 337, + "Volcano4": 333, + "Volcano13": 342, + "Volcano11": 340, + "Volcano7": 336, + "Volcano6": 335, + "Volcano5": 334, + "Volcano14": 343, + "Volcano12": 341, + "Volcano15": 344, + "Volcano18": 347, + "Volcano17": 346, + "Volcano16": 345, + "MarshCave6": 281, + "MarshCave5": 280, + "MarshCave7": 282, + "MarshCave8": 283, + "MarshCave10": 285, + "MarshCave2": 277, + "MarshCave11": 286, + "MarshCave3": 278, + "MarshCaveMajor": 284, + "MarshCave12": 287, + "MarshCave4": 279, + "MarshCave1": 276, + "MarshCave13": 288, + "TitansTunnel1": 326, + "TitansTunnel2": 327, + "TitansTunnel3": 328, + "TitansTunnel4": 329, + "EarthCave1": 302, + "EarthCave2": 303, + "EarthCave5": 306, + "EarthCave3": 304, + "EarthCave4": 305, + "EarthCave9": 310, + "EarthCave10": 311, + "EarthCave11": 312, + "EarthCave6": 307, + "EarthCave7": 308, + "EarthCave12": 313, + "EarthCaveMajor": 317, + "EarthCave19": 320, + "EarthCave17": 318, + "EarthCave18": 319, + "EarthCave20": 321, + "EarthCave24": 325, + "EarthCave21": 322, + "EarthCave22": 323, + "EarthCave23": 324, + "EarthCave13": 314, + "EarthCave15": 316, + "EarthCave14": 315, + "EarthCave8": 309, + "Cardia11": 398, + "Cardia9": 396, + "Cardia10": 397, + "Cardia6": 393, + "Cardia8": 395, + "Cardia7": 394, + "Cardia13": 400, + "Cardia12": 399, + "Cardia4": 391, + "Cardia5": 392, + "Cardia3": 390, + "Cardia1": 388, + "Cardia2": 389, + "CaravanShop": 767, + "King": 513, + "Princess2": 530, + "Matoya": 522, + "Astos": 519, + "Bikke": 516, + "CanoeSage": 533, + "ElfPrince": 518, + "Nerrick": 520, + "Smith": 521, + "CubeBot": 529, + "Sarda": 525, + "Fairy": 531, + "Lefein": 527 +}