From 6566dde8d0aa256b77497fa9e2925d74260b6de7 Mon Sep 17 00:00:00 2001 From: jtoyoda <31416603+jtoyoda@users.noreply.github.com> Date: Sun, 28 Nov 2021 14:32:08 -0700 Subject: [PATCH] Initial FF1R implementation (#123) FF1R --- FF1Client.py | 255 ++++++++ .../assets/gameInfo/en_Final Fantasy.md | 24 + .../assets/tutorial/ff1/multiworld_en.md | 114 ++++ .../static/assets/tutorial/tutorials.json | 19 + data/lua/FF1/core.dll | Bin 0 -> 29184 bytes data/lua/FF1/ff1_connector.lua | 542 ++++++++++++++++++ data/lua/FF1/json.lua | 380 ++++++++++++ data/lua/FF1/socket.lua | 132 +++++ playerSettings.yaml | 4 +- setup.py | 2 + worlds/ff1/Items.py | 71 +++ worlds/ff1/Locations.py | 75 +++ worlds/ff1/Options.py | 22 + worlds/ff1/__init__.py | 97 ++++ worlds/ff1/data/items.json | 194 +++++++ worlds/ff1/data/locations.json | 257 +++++++++ 16 files changed, 2186 insertions(+), 2 deletions(-) create mode 100644 FF1Client.py create mode 100644 WebHostLib/static/assets/gameInfo/en_Final Fantasy.md create mode 100644 WebHostLib/static/assets/tutorial/ff1/multiworld_en.md create mode 100644 data/lua/FF1/core.dll create mode 100644 data/lua/FF1/ff1_connector.lua create mode 100644 data/lua/FF1/json.lua create mode 100644 data/lua/FF1/socket.lua create mode 100644 worlds/ff1/Items.py create mode 100644 worlds/ff1/Locations.py create mode 100644 worlds/ff1/Options.py create mode 100644 worlds/ff1/__init__.py create mode 100644 worlds/ff1/data/items.json create mode 100644 worlds/ff1/data/locations.json 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 0000000000000000000000000000000000000000..3e9569571ab0947dcb7bcd789dc9c06c009d072d GIT binary patch literal 29184 zcmeIbe|(hHwKw`CnS=ocGU9-vjyU3gQ9_)_OhPh~UqFIU1D#}&84x6dWWprmS0~RP zC}MDkB@R(;ORcB17OQVBr}eb7$D^@wG>{ZfX-m=4P(bM^S2qoMQsro@=-lsG&+|+Y z#GZ5S`~Gp?`#A&q_u6}}wf0(Tuf3mVCQI+$DWyn~q(g|uC8-Z7eM&g~`;kENv>Sdo zO?rOvuW#&2s`&Md)uEMUq~U?nI4>P-QjpMuas8l%ssI z!XK(2KJjNSViac0O`>Le07$TjR4E>fNKz$gEp3w2K+QWP>A57zT=Lm1NiC_8bfyll zmo$wpo@u#cZPrNAzRQiLcFK~28)f9h9f$}&qBTJT^7vRmZC1FUPR86f&P2r;1T(@i zgmIq|Or52GNlyY-sSAO|YD5_KDUqc9tZ-+z9(7DBXl5ogj{`!sgvJX8TiOA*m&V(T zkcI#n$A3yBY0>!df9L<#aljvwZTdiLv&|Ur3umz;g>-8rqhHKMwi*BYVmeh`tfR`Q z$O0^l+CKM-4~rxTJuUdhpfv6mF@`WlM^huSRrHssARAPWwF(F@xFuv}sxwjJ7o}Wp z=p(gS9jmZeokzqp&>S7K4bbGX(C+OmwHZSu^zz1MY+EB4ql2d23Y)GHOvCK|QApqh zZ^*#oo>8Ju9)MZbsKNVFmvFd^Ns+m=cf2|MU5lU1q-5xo+Zo&i)D09|3y$ zShdaI>}SsQEV1+~G50Jp_V`UbOY}WU4B~G$Nz&P+hFt>?yZg*B|Xq8mL5tz9tqjLBKW|ogX{{o)3WuiVEm z_OzK5EENoat2qhAAkbSq&06IZ?=AQ;c4<9r7IgERmcJSO?2CxW`I@J~W~hZeoJqbX zWnd+#3nNMy#xF32RUG{5!35!qufPnFN1DsHZDHJ&m}700d7^if-6|J8qPwABikw|A zRh~n*_HH-8o6gsVr+URg;EFozJi3_i5yK7Jr$nWs=%FOf=i%3eb%<|C@Oc80(Fe9L zeOrJT%;=-nSHsrc!!XJcY(1X=1hg$@o6Uxjuf`vH-d2EIAhu6Q#S^(ePC!x2-cSsR zNEYm;RJRPPGbFxEas=_O3V{9+@hG*F+FV|&UWKnxtU;N~rW+~yPU+qM4Egegw$o5( zsOJV_SKh6W+a4)NDrkvJ$8Z@{Vl&hb3^_Ld@PazjLaDvt;O9#1KcP0%p^ouJ4y&00 zk%j>S;y9`T;;7Of_FfOH;NC!ytd-)MC_#~Iq(}j=iy?o3IJU!93eurZwX5g-iZ{+A z47PQU7&QQbGAaqPS=#0ZPN9})t3~P;|E!kY zV_0h+78!1UU);uJokHt(l>%smRO^8F-s|89i<8+zo8maDcyqfH!ch_UdBro_(KzPy zFn>b(AhLe(Q*>9(98YmBWaeIe(TZNQ8Y{SWCgGs1`PTD#>|bO0szZsi)rd4CA%a}# zy$84149H6&=73w$|G;g2@dl;{_4B;BD%wmc#GS@~dw3mY+d4?Yla%Y=fKLS=+G%Wv zR>G725Y;Lm&MOuT8Q2C;Z;JsRnf5uGz7jfYtnwbh20}-1k*;8Km0uJRHg7RDdr*n3 zksJ#l>@W&`@oiK=eP&|PD)AfCrN2ntf)2(Qo2Ig<{+VE)jNm^ZMzn|(OqeCXEHLf= z5q=R&1#SVq?_>0kVEpvs@GDpF`wt`u{IV{O-{Q;Q_Y&%G1RRf_<{uRV6iL{H(fs08 z>O30K*F#LO+f<7v&6on#+=mRjsA2$wbkr?Oa(CT|0NQdXp1sYxiXQFCK^;Hs;8zweJ%Sj zkSINtnNHWu$rxoWMiHOPCkPYqF?!wv&k{Z;%RPk7LPBS$LeWCIkEPOiGGt^vuCitH ztb)r4HwwXpV2jTv;G--~XT$}=FR=2`4AguNfTq-g+^hQc_6D{$}EaLO=krAtHU z-o?Pxy!CIKUjY0pTR-LeJfM={`AEKc{~3MCA&L%AwBIXk$bvtWR=b+591mTq&7tWw zrC1pv$4KG;Q+FM)cof8#y#cJK1#?S6k3E2x-Y?HgXj!waN*v7~wit$|F)spQgv?lz3iyx}3k0Ol@mavr=2n})D!>IF%r?>?dC6~cp zL|_t}@%+M_=pf(d?2mQrqOS3yI0^LXJ|{B3)o35JOOZd-CVZ`l_dg^mrNoj_x^60k z4T7ip9ZVdC4nJ~@``XC0+fK^UA6b#)zB?&0ExJEBdLVh*$;esfYs0D6 zy%s%zBd$CJ_af1Q`tD)^ve+(ISH z@B(Dn;(?kK17j}X<5W+38rP4P4-BZ>Pd4j9Wct10KDNFF)g>EiPiju{ihCwBVxX~h zz1rVB0c5P*sJ37G7425F{cmhtzdS!}k>d1(M$8{QZ=m%w5!*tFy@fk0o0?Po;vtm1 z)X>QdrqE7i($y$YZ84?rq8;3do8=_g7?J@(jjOrRK<&378zS}clU+26Kfv-4NSso% zmocan_QDvdR=eLY*4>IM4pP(TATrpHM}L#9epDid4$V%^xp$JiXI6OR|QdE=*DLH7&IDL(OQ#d@ks;}h?p zjHU63A#N~*GJ=xPCpM!*wa-!-_lcd{ifcizRtE@b8{|4eug>O${sL%)QsEUxKjt$i zL!ae^@QKHj#?TPTK4C$L#weu}UG3bejQ_maqe1E6hle8WoC7$5q=n~QLkyz|+>DJR zcFWq;auzFR6%F}@((VH8$(9^14cJAGdo~daJ~{xZlGXU+)ta zF$}MZHMOYvNV}rGIhFL~0YzU{CTuF{qtc#6?e|=!9f}t6 zs^_}_re@sv4*C`dxv&)e=x^iV2=Jw&<^@Wj3WPuQGwc`3sCp%OHz(2N&`UW0T*Kp$ zJ360=IusMS0SYwLpi2flE*bQ&(v(l5(QfC#^|3>>328oZBoHbfWSntPqT&)#ak)Ez z%MH}~w^YrzB&oOr7{C%6rRKlD2LS>YJi#h9lZo?-KQ`i{Lq`c84L(=E=~{|zZpJKa zCI?6-VfUJfkE6N}BybNu3MYOgWk%<@g- zu#Pt?=+Ld{1BUQ3${BJz0^0m}KIOpuauRWT7JB=|^N6uX{QQAD!7h{uJ-=`vM=LcK zDT{~PaYEo4G4J%a!*nG9VLiH$;)wnv5;RxCUuWiJE7jDB2MjoUR)C@ksOo9ln=*tE zO^|aO#|N#-d@ms7`67?g$U!dZYsX#jZ$0ly)*g;1_qKF-8Bk<|wy|HM4=~Mi#!;fX zgr0r2B~jD`U4oQ>2k`jk>ki0NG3qm-d#)j~oZHwbu+iVSKjHO=u}JdI@D#SV(m z&q!V&7#44H63T)P2sr-%*0e=;;)7uw7-3PL;G8!C9Wv}L5M3#TC-Kd39`?~Gl2qgC z-Dg_^^j-!P62^KGWA|=M!v0Z;?W}i)Erdoo;)kjNqIL!$hDY%amPK(kmmVyFx>*!o zM0nwtV=duC_AX9Zxpk2EG!QB!Ybacaw!Re1qqTwr6DhNoo^zE3D#bF=L>v{HrJCMd z%(g(4$fruuH}Ny%eBvJw<7yTY@h+qdrbr#1FG6eRWU#7{roR83cw8xaxx7A6F6Ht{ zD(4}q!k;{&l@<6m5@iMcWt5|X<#hbmN_UR%$Cxa1A@I0lTjO zQefzrR7)E$xXrun#QC41#R0!XH^`&IDC{hl|5x1O0QCuosQj%pn6`B1!WeU?<+b3QB^Lv<>e9Z1jb^01AlPfp&A8S3D3K5!Ye_=!>;V z?3@W`m~VfmV;SVDt6VbnZqbE0ZpTDDY2xXTcwBqRYJpMG_dZ3lf^|Morh|D6L=T$% z;!YMgD{fk#iPGv70|C(vdi`hkeX%J>S=vh~bJxOa_8<(j=D8 z!$`1Gc%l?R4$RM@V?mbY_t?Q|JKTRex&LVhNwHgJGx zTgs>yaOp2ci5U7Mv zp^bD5_jkuy$0HB?C(7R}VD1&=SC8A*#E+G}s_-%V*jhXQR3HNoJw<7hRp~(&F&&6u zd519=aoP-YXZeMUrhsU~<6_A5>1)O>J>DH+A@+;!oQlV3V^!4f78Uu+iT@Gyy=!o| zP}t)W!{`g+wjC%nC;PBJ<)M3QB>k0X*chYd*2<3PIiq|H?q?z^Ufl=l{t~#18=8-P z6uG%MX}I9kL8{YJ9d7O-S5*z)@+u9M0F$o{btr?29>$-Tj@#dX=Hwc&6e-rwp91pW%OBoN7{iI}Ta@;|m~oxhz8dYRBv2S+Emr!=78~}04y4U^ zW8rrKIzTU;z#!Nf-pWSriXW2P><;!XV5PJ8{kORR#eM+BAcZ z4X-qySME ztFcQpa-8^06Iuw>)usoh4}%o=Ehh9miUG;9`WdRyUqN?kRIHV6i3+29byOtF(?fkK zM7~1*@YSGIhnY>FcX$dHaHKtbjV4y`;T1?lC9r%6r}=-5e#}WUXl#Hf_lh4wqQU$5k*oE}=OaCSgzw45Fi7MyQRwdqeSF`oEf=ywE~dY=cN1E*Fi z9pziZC>*RyVRR?<4=11KKQ&7keem>&(?h4zRu7y$G0`FTpXXnKdCSL%k*e*gy$!8_ zIE8ksBe&#xzbF5W${V>HqjC$fYWo#`(8@~tXU9=qHP$|yC@bwJQC9rBjE5Be?rZGz zs{S45Ba>-TOeUe=6MdKhJ_9cK@yyKEyG4&fDK4>K!;hF?tzkpo1=pcD_5kdFy|Svt zFRBe>Rq=4Y`(wG>C%zBBC@ze@_#$#`-;)^C>l1M}8<2|H-X%Id(HaKuq#70kaA^IL z+NycMBo!oCP)a=f*VIxum-xgS9-p^2bpuoH-=gO;#-3l;=tLvi@@O|uxN#cX;O9;J z#u(@#J0yte#I`p($9RKhD_c$|!BAk2$Mk{4-`T)?lMk>^lA#~Xy@2&=%aYA^Qe zlA1<{cxn2Jzr&N5Qb66=TdNnl{!Gh&qXur;o&`V10Nl+a8vt#rq{l^gK|~aRAwIg7 zyj5(4*rLtm!-Eg-x(W2!hc#L_g@4a1n zZe+u4_yz{dfYbG%QDMDbG%A1zUx6kC=v^u(R>6W*!2QC+Rq%@+pd)}w$VT%kqHGt$ zbhRT1d7sG&{vJe)wk+FhMpP+hO&fBwI3(ZjuOaHEQ~QvINWj*R^DrbTFbURRHm$*I zZ4GSEL1XWFy1FvKrkJp8;mN9m(NHrzocbzdYeezIc$}WH*}RIN`ozEDjHr1Z1%C1I zm5P{xZ6=Lv%eju&Hk=(0N76Mel_>SR7Y);+1Aa(McNrGupK>mU?XX!fx55sgN6Xeh zVu5Cm`!Y}vFQ9&mAH)TPAGB4E39J3B%kTqol8Kd*gbVn5nJ-{U4S0|Nu4I5K8OYBS z3vdX=)~|3#;|}XL^f!}E!>5fOdSX?VL=i!DV7xO_BTv%+cco1v> z<%9`fvY^(LB8a;57mq^f>HdWzhEGlRv78xtZ%-MX?-$z?sUtz1#2g=<41ac-#^jUC zZap&@taO1NDl7jL9q|qoDl}HI4b}V|MS|Mi9DiP;?{5HGWm8mp!QN39K0YXY+~}jY zsfz#)j!t|f%lcnCuPI}FaEzQyzwj~{rU^4ECSSvM2TS}iz=MsSum{A4X{}uu?ZH#?vhJ#VD_z^v>C)XOK#=tT!C z@}6JkrGk$L6NOtszO(0>`j38u8k0O3XVf8jeb&S`DzcLv2b@DEF!owf;cFo)niliB z&M=u~vY6)d7x$C)4(FfvKr!>wb(zvt(S9iXWxC>dclwJ5sB14SzvvO34G)PIqwg;AT!- zuMg12E6h+KI<6?!toJG1R9V1aW9L_8Pr3u0wDaqF0Ap)_+4~pdz_b72`L&Fw9Xr3` zg+e+6_F_|J+kCn6>%S!$RchF2)XoFRUa=oiIsQCQ(QL(ggoI$6Swv@n&ws;b0ZXOm zOw+CrVH&MC{=Z5su@0d__}~ck*>%RurHrj@q+PQE&p(es?mic-%Gn*~YlbDVs9AQ^3WxKX9oX3=Iv z(z}n7j}N5D(_%(?sRJ)jn0s-ffKlxj)hqr0X2RV)fo+oA*ZXWI`o%9mIrPr>#|E46 z)YN`xuK0mcW$MM9i)I=a9~AG|@8t|v)O!NKm_Yp6~{?CAhRHmR>JcfLs6Ax^OD0xz$3)x6LL<(4)x`xm#y)O;Y z1K7&uG->adM(XZ8JdLMiXUg=Mj7*vnBpQk zl^*`kfGWmcdVh((6O>6WYGjh_qHjhazqX6^qtCkUSVucA=C{iJ_&N9$_GJ@|y z*l>M=6mD0*=M}%gwIUeOQzk|7#dlF)qCs#O5uAY$jFt41@@MeJKp)s+ip>o8s>F^& zNBIyI4DarcG-9^jf}$wB=}7CtHuey>04kaBlN81eC{NA^aZ9sAh%CZ*U_uV4v-N(D zI*2P!#ppzCuAzhYyaWzc$EJA$col3aciQrMl9wLH4c>1b+ZO-s{6LsbPZnaQnefO6 z_;A+`MR^70kT4N3WR&?Gm>{!?aQHvjA0s|oADafeF^R3lTPb?D)0Q_bi;1$Je;1!G z@uNt=!a@9y^F$n1TPiL!v>rDk@VHUqg@VDGgaP{va(+*Y~rcjx~*{P-oN20s?a zi^Qe`79NEoz~tM1(8nq?!1IGfJ5eA@m{++n;LC8laMkqtL z3!xGrfN&2&Jwg*gJ3<8EeuS+E-$3X^_!h!HAp8K~e<1t>;pYf1BK#}D0K#hszeV^x z!byZbA-sq1KEg)`pCZH&Qty(CS0H2{NORRsQnH#!nXA+4=FUw`o1~vSEenS1@LxpQx@&b@IAbV6?SO*XYWN4@W1D<;o{9oJW~)bcCtY_6Vw z2N1*eJ${JvP#R2me9RV+e5(5oy`rmAB)$?7kF5ZvZWZ;V_d(OJ53S>YFojN;f)s;Z z#Fs=4S`r(CHXh#sD+4;?m3Yo;5>u7o_+`@AG;E;a8r0wxV3K$N1K}%NX*e3{tHvnc z;g3_>p;~o@zccop{YlQa#cUq|=Z}Y5(abi6D}b(^8P=QZ+=?l+6@h*;ca=e4% z?Hq68xQpY>9B^T*2}49G7!k%5e$D1svyb z?BdwYaVEzS$2yMn92+<`a%|$*%&~=IE5|mDGZ1^G()aQs$sY}zyJc+Gi5BkahLp(j z>BW-s`smqFoR~gL&(zso8_?RWjGot>D6lPWjGlS0F*^F7{3Ziiqi07>^q;`-7neQ8 zckp%(w28D8_oZ^d+vmEEZJKq$Lf-P$ze4Z3w(f@%Js3U8N*6sQ4QHyYO8t;h|3_(< zd|A^c2M9XScLKg1h1V24qHitg<{iGS|C6IP9gYpok?_#hb2vODwtvO0nAD@Q4a}!& zmu_kJoIlisY2!0WArP?GTk4qSnm&TAti|gZKu#qv3+kQLJWY5YeGjY9~O^$d1#B74jqueg&#R{RFiSktjvm zik=*T&2O<;Rz&o8NqL4IIOWfO0+sTt5G7&_dmSs}^tf!Z_$i9dpoVxV5qj(YQi^;icq7 zg%_^y1Sn$Vi9{?gzEgb90fW?R3_Z~bGFyR6?9)QICa z{GgJ~v0ZA_JJ2jJyI~XHZ0gXQgQHqI9V2A{~?kj!$?Q z?5ubm5Cex&iC9#}>GnyV6iF3tq8@DKZzMqf4C2@5wgPN}uH#F#kkV(CN5P<})fCV0 zQha>{#X02^7nUM!+%cnrlc^1yTwlUTY5^y6@;I6ALh|HesUak>GU=IBoa&xggS59y z|4a*&yU$?%Je0ZzXePnUeVQ7dS%uP}R4?MmI;sCqYBjg2*E2ocL+J8!0qT0XPazwh zVa57Cvl|u7N#YyCnPv!u1(l&8EMt6z8QEwd0Z0)%f)@1z6QZ%>GfZg1TmD8QuMIRC zLj5E<*;@sSsk-FHb4N+bjL-+(Wpug-{oP`0v6H18hId z&VC={t*7ilPuZyN$>p)bIg(_Ryx)KF?s>nHZ>apfvp@ZZ)OwHTsX5=XbcC;kP*sCI zqboltyNKy^@-(p;v_dAxmeIY#%GU$X+qLu30bEQ9q(t0goRCoT{?NSD@3G-m){^-m3a_{o-FnJS#Up3q$5}Pwku&k*(Loa{>qMbEC2jyZ1DNasq$>tU3`G?vpf`>_ z8Cp?3;1Bd|2imVvSz$cXc`#;qI8S0ttH+8?8@%J2tvAHO$Ika9Tv#fA8$Uiyn)PjepY z{uu76-n?NJPTZzgxxw>kcyesHKIMexv#=q0ASv3P;W-|D7f1Qkm%d>BU4NqeYB`@E~2KbiIQ8w!Q6&O0w@C-He1O=Eyxe$U;6o4)8#6WxvUhSMubzY@~ z7#8I(FnMAnw*BIUPmV^@yg$N{}6TiPDOVUAvvk0RI2GnODEJ3&%VFSWWgxv_s z08gLoNS~s<2(KU9)>6#T?Ah)V86nrK!Q%zP347FIzhR8Eml1?be&?A{(1pn(D$?_3qkQxxG^k zH?`3heb6twt!N^*N5i+mF>ghSlcIUdH^Sl84-^P*oz5QC!M_`1&a`t=j) zKwurwPowc9$j^ynY5(W6p^R%F@Tncd<36aVHP{}JrAXWQw)T75tif=&9m@{>McP{0 z>zLxs^$KpC!4@J#iZpaca(xH*8AJ&(5NT`-qEm2VeXs*H8aJtpK3(;ak$x3oBhob% z$w=QLlky~Ehgu(^6!i^AJM4f#>UB%Txi?E-$@0g$wp8mctMc9PwPn;+TUxfL=5|RUh7s10w$4aLM|&8HYHjb3 zo7&r~Yj~}(>YYKEz)+oFwbT}AT^kHrZ&vwrv$e4)*wQd27;D?xTY`0M)|=bgzSPzZ zrfV{Cb5k2+qp_|&c(b*s!&=wS5Dt>SDk5hc-+ajoG=|z0^d!b>jkJIj80PZhjcfkW zc!@TQ2l;BG#cpT{%fw7g4qBiHw6<99`EB@}%a*!LQaBjt4APW4vcyA8v^0LQIHF#YW2#qJufX&CoxwJiIaQaWmZnZQh!JT`+9;N4K&_g*S6^aE#DaoZwQ7T;QIQOreK?_ z5Q>N**g|>X_dUZts}i8YG$Q43T!C4l6RDit2cY>w)k5miBhS zVM3iO!$M*FFch7DkZ)*jlfy6wyq;JK8Jm>47LDQdR;&Zrfcr@inE|i`37%0t(x!4g z*pO%+UlR^CLU`@GbPobzvFk5YC%mmpY*O?cr9?A82YL z8`q|rqz~28dpam2o7Omz-O`Yqqn4**1ATB zzE!cJ2_68($99zvBw7{986yU?_b z*rmygX1o_FM%=1V2#Ek*lBCr)*0naZY+}ZYZ8}5`e1rD-_7?0i1R!}QmM3-qO<#lf zKoNjH@i;M*#$yuUOImPj(#D+FMOswXFgCwxg%hy%hTGfLu}VPGwAGfRrI4?>b+iGu zTU!7@d@hChG_@&Msr+CLHHFE$NXSKd0(HioWuKjgF`CA44~$u-Y>N|UzSlZY*Q$b@ z?U6792daUsye>>!{wn^ZQ3Kwk;#4aysaAZv=}6}yX!Urt zG6DCk8p-%G1nRG~9~sw9^(WP{g4T{_Mv4A+EXQ+81OxUn?NfjaamO0$HRI|Qk%v)- zI`(1|$PTIl+`e&j-$Y#j>Mr;B@AK#XzUR+?FT2*6P1`bjB?(eri=6^)Dy%P;IwGB+ zdU7fwVOaQkxGr3GFKs8%ZCt9yP9g_o-b(z5K8k-YNmo-%MJ0G_E}0zM>yD_9&^`l50qQrvq;OiL|Ec(Arh2sP~lO59SmnPDNU)N z-gIomFX1!qfTcFaLLLJSVUT>65SK{mloeabocq{&K|DWcOM9o4QF zSEupeRvtsyuI}VK1TY~?KCU4eG>m{@PL@N_g4dI756V9FKda#tF6>-_O_XwyKgGu< zo4d-YmX%d#TH{ac?7;Pj+$i0h+SyrO*VYKn!RKFGm1pMx!%4M491KIC@ZGhuGuAea(mpn~$*U~CoIhL;tO4ldV z*0px7s|{`h!=giO^f{wWw+iOX_JCQ}NwmR5|k5c%eq<+2h4}gZ*Xm8_5A4;wTLF=WAlv=gs ze$c}gB(a3~z$-L`R&uw))^=oYIK9?+{+qP4(-! z0{5@u@Kgr61HR(oakVP;RIPo~zj2X_^&=le3!}KZy@(*8{-_PU2U5rmpGH{RAj;(W z)hbnBIoa+1{`~)$1H0DZ4iP`zx&vtm?iDp!pQrMT@S&0Jw&M9J(*AMnUfeetZIZE+ z?j4b8{pcR@Xx3QSj;~LiaY;rS(%txLDi%t!C8G%`F71s5wOxnQ3OM?8o;7HvpA0(V zkc^{L&XtVkk#-?HgVc-qx2TNt2vTE?WTbC3TT!NOJdfaB^m(esID3%pMg5c1j`4RQ zrFpj@-Hq`!Qi}c>{%|pj9dj`!QvBMJu^OotX$8^|q#7LEzaB+@t^7nI=D|JfPNc^C zv3jE4xM+-iqL<1zg=NhC$kG&9c<0ZqAB89G_xaTcLdACQQuOI!0 zzAnHMeP@soeI*#5=-Z33hF<~t?VO7-k?uvR(Pu#!zc^;pBRz`x3&0PT-o`UXt>f;K z>7Mxv#?{87Ctg3l=2iNQ8~$kZ|DFEt$pIasoy33k2d^1*X;f(S$*8bjrXDXhNOZlM#60xRTF6_NYpd6WBrS~3rkDySa$pJ zQRZ0VcsBd@*{8Bi&N63(^O*BPXO3&L>*btK?zeKU&M(b>EdR~?EuKd`w->A{c(~yE z1&0d0D5x&nQFx&+v*@3Sjuic&=s$`jD_;e`=v;8*W}nTT>-@<1DZXm7%2nrTaec$} zP1jFd@3_vn;x1E8H0P0=(VXiSlq^`j;K2pw7Oc(d%ll)VKHr@0&R>=Pc>b^RZ+8dX zAG&|U4D_shH^dB^k4=KUq_)4Ut= zm*zj1|F?XDd#2mwzSW)U_PbZQ8{O;O8{GH1A9lywkGsF;{*n79?w8$fyIr39Jo8|8&7S1E!FjK9kIR?Sl=EoLrrhUqXS;84XSrQ&x4Xo>#9iU8axZtUb~m`2-5tPT zv-=_UcK4(1UGDF?cLR^-+`n-5xnFS)x?gu6bsu-1a{rrKxIb`TaR1FMdD1)v&vef% zp4&V*9*?KQQ|c-ARCv6eYR_`dD$g2EgD2!^@pO1(&qmK?PnTz#XS-*IXQyYE=Lye~ zp535rkLNkhUeEKMKF>Z+zh}^M*mJ~l)bo~S$n(>}cM9Jv94`E8;U|Tk7p4?VE;1FF zi!4RfB3n^LQD#wB(ZfYMioRL&MA1`4&ldf-Xm8OAMf-{l6df-5P0?FLe=Isv^ls7l zq7REki@qq*6;CNP7SAlU6wfZsD9$Q&6}yW|ikB2u6jv25FJ4{TP~2SHQM{pebMZsP z+lwD9-c|hF;@!nRDt@l`7sY+WuM`g!zg~Q__;~TD;(sd^#UB)3DE=GCl^4(E!F$P` zW;fWU+s*b_cANcHyWO5^FR)kH+w4w90d(X}$7)BjqubHzc---{;|0g7jz2j5-J#2# zoSmM1YxZr~p6olbz1e~6j_eKDJ=u?De-E#;l z3^}`;Z#pGcIkfB{*LK&Vu3fJ0x}J9JasABog6m&hgRVDRZ@Nyp{_Og9*Qo1rS4z&* zoS8W{=FHE@&RLXmd(P4vl7x<&O*xsluG~erOLKj>>vA{eek1n>x&M-TAotVUFLJM6 zFnhtl1#c`kz2LJ2>3K8qw&ZQgdn9jX-uLr!96yT!;T}4oz7j(C!9|@cRQbT?r}cn-0OVa+2`En z>~{`24?B-IhYC&=SPHF$dkgyt_Z7ZXI8-=VNNX*1;T;ByA=!0yJveN(TkKYFIK!T4 z&jW``?4@?Ez1qIU-T>Kev3J;I`$qd_dzXD1G+~E*C$!-S`;+$FuvmNS&)N6dpNDqr zv-jHv?T770>__cy*@x_>>}Tv}?ZSTEK4QNBJ8;o1Idl%A!{jhKEDo!~=E!hlI`XiR z<&Fx6*HI0d*Wd^_S{xk?*|E{F+0o_L2FtO-vD2~3@r2_^$8N{7jy;a&9D5zl!=CJO z^uwkQAC5ZSatt|6!M>b#j5sbhMjaO&QnoHzpKZuCW}C9j*_P~#?96N;`TuL5{~I$@ B{xARl literal 0 HcmV?d00001 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 +}