Compare commits
495 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89984a0d09 | ||
|
|
2e2ca1665b | ||
|
|
1b27fc495f | ||
|
|
51c38fc628 | ||
|
|
74c30ce09a | ||
|
|
859316353e | ||
|
|
63c9bea724 | ||
|
|
c73b994305 | ||
|
|
88451d4239 | ||
|
|
f74db254f6 | ||
|
|
3cb0a22e17 | ||
|
|
ca3e01b15e | ||
|
|
e9d1dcc46c | ||
|
|
7fd0f1a5bf | ||
|
|
2d65fbf798 | ||
|
|
ac915d00fc | ||
|
|
fbb8d6b132 | ||
|
|
fb0f70b3e3 | ||
|
|
17929415ee | ||
|
|
631b6788c6 | ||
|
|
7972aa6320 | ||
|
|
138c884684 | ||
|
|
f5ef98287a | ||
|
|
5188b41ab0 | ||
|
|
f83ba6e615 | ||
|
|
cc2a72eb82 | ||
|
|
4fcce66505 | ||
|
|
66627d8a66 | ||
|
|
adfd68f83c | ||
|
|
ddc619f2e7 | ||
|
|
ff2e57705e | ||
|
|
a6a859b272 | ||
|
|
88c5ebdd2f | ||
|
|
3d578bcc98 | ||
|
|
c3290af2bd | ||
|
|
01f1545b3e | ||
|
|
fc8e849db5 | ||
|
|
9115e59f15 | ||
|
|
2f4b248a45 | ||
|
|
2f28afb46e | ||
|
|
e960d7b58c | ||
|
|
321569c542 | ||
|
|
df037c54ff | ||
|
|
d859cecffb | ||
|
|
fd6e009c4b | ||
|
|
4520051ec9 | ||
|
|
b90b73859a | ||
|
|
6c357b61cc | ||
|
|
12957db90f | ||
|
|
3c74f561d5 | ||
|
|
cc70a6fa26 | ||
|
|
1c42564d90 | ||
|
|
e76c870c09 | ||
|
|
5daadcb2d5 | ||
|
|
a124a7a82a | ||
|
|
a65bf60cea | ||
|
|
3fa28a3fdb | ||
|
|
baa7992a7a | ||
|
|
7ba4bfc0d5 | ||
|
|
11fedef2f5 | ||
|
|
944347a2b3 | ||
|
|
8c72b0a6c4 | ||
|
|
5d62d4e063 | ||
|
|
9b05537a0e | ||
|
|
fd0a87626e | ||
|
|
9402d82405 | ||
|
|
da6674760c | ||
|
|
ee03371dd0 | ||
|
|
a975c8fd00 | ||
|
|
60840da740 | ||
|
|
de567cc701 | ||
|
|
de4775b0c8 | ||
|
|
104cc0ea83 | ||
|
|
5bb8de500a | ||
|
|
21255b3b46 | ||
|
|
e8da9924c6 | ||
|
|
96b38aba04 | ||
|
|
b8b51965d2 | ||
|
|
be46d128bc | ||
|
|
c05f1ed24f | ||
|
|
99775ec1bd | ||
|
|
f4f043ac87 | ||
|
|
acbca78e2d | ||
|
|
30ac7baa2c | ||
|
|
21a5170337 | ||
|
|
3a5a6a096b | ||
|
|
578ae70150 | ||
|
|
57282e76a4 | ||
|
|
7aaa652ef5 | ||
|
|
81da0d2ba4 | ||
|
|
ce6cdcaf92 | ||
|
|
4730a928b5 | ||
|
|
4c0f0a16c9 | ||
|
|
b07fc80f3f | ||
|
|
6a3d1fcaf4 | ||
|
|
4aeb3cd3dc | ||
|
|
6dc2000638 | ||
|
|
72610d8c2f | ||
|
|
0f55fa4f45 | ||
|
|
aec39c919c | ||
|
|
a0849f9416 | ||
|
|
0668f94461 | ||
|
|
953ccc55d9 | ||
|
|
fbaa8226c4 | ||
|
|
8abfd14569 | ||
|
|
f2f4d6a133 | ||
|
|
3ed7092af5 | ||
|
|
9d6fa855d8 | ||
|
|
8c7404edf9 | ||
|
|
3f6a9e5dc7 | ||
|
|
9e1748bf67 | ||
|
|
527a9b49e2 | ||
|
|
b187223162 | ||
|
|
2c5e99efed | ||
|
|
fa8531022d | ||
|
|
8d4be10fd7 | ||
|
|
285b9e12eb | ||
|
|
53fcb86174 | ||
|
|
a532ceeb0a | ||
|
|
9ec0680ce5 | ||
|
|
299036ecca | ||
|
|
4bfeb77a3a | ||
|
|
ab7a5b07eb | ||
|
|
50ad661796 | ||
|
|
d3e71ecb9b | ||
|
|
ba3bb201cd | ||
|
|
01d88c362a | ||
|
|
95350a1fa9 | ||
|
|
cc458ca5b1 | ||
|
|
f19878fcb8 | ||
|
|
eb8e8691e9 | ||
|
|
0423c22d7f | ||
|
|
3441c390bd | ||
|
|
a0fb9bc4ab | ||
|
|
a7bb6f6a95 | ||
|
|
f1bef73757 | ||
|
|
4598dd1a0f | ||
|
|
0241d6f443 | ||
|
|
72acb5509a | ||
|
|
b43e99fa20 | ||
|
|
b5083ddb1b | ||
|
|
f62e8b7be9 | ||
|
|
f655dc0dbc | ||
|
|
95e0fa2672 | ||
|
|
4b7c8f757e | ||
|
|
381e9c744a | ||
|
|
9aa4bb3f4b | ||
|
|
63617edfef | ||
|
|
72de0450e0 | ||
|
|
306bdd322f | ||
|
|
231613cb3b | ||
|
|
2af5739592 | ||
|
|
b38f7c8f2a | ||
|
|
e3a81c1bed | ||
|
|
cd8452d839 | ||
|
|
4b38cb4c2e | ||
|
|
eda8c6f263 | ||
|
|
a8cf67c94d | ||
|
|
928b341fb3 | ||
|
|
6e51b1d50c | ||
|
|
78aaa65b45 | ||
|
|
3627d8f1ae | ||
|
|
1e64b817f6 | ||
|
|
37e999652d | ||
|
|
9408557f03 | ||
|
|
16701249b4 | ||
|
|
3c1ac134f2 | ||
|
|
230d9d993e | ||
|
|
d1c83ffc09 | ||
|
|
a52f991543 | ||
|
|
dfc56a3272 | ||
|
|
41037ce599 | ||
|
|
a3924ed40a | ||
|
|
361bd4e5f6 | ||
|
|
8cc245ac11 | ||
|
|
2d8a6e84c1 | ||
|
|
d2add54cd6 | ||
|
|
40044ac5a6 | ||
|
|
bb15d0636e | ||
|
|
2cc7d8395b | ||
|
|
2f2e039356 | ||
|
|
0cd388ca66 | ||
|
|
7ef1fe81f6 | ||
|
|
774610de7b | ||
|
|
f6c85e17d5 | ||
|
|
8142306562 | ||
|
|
2d84245103 | ||
|
|
1d954b192c | ||
|
|
db0604f585 | ||
|
|
08beb5fbe6 | ||
|
|
7df06b87a5 | ||
|
|
abf4e82737 | ||
|
|
7f8617d639 | ||
|
|
f5c62a82ac | ||
|
|
66514ec607 | ||
|
|
096e682b18 | ||
|
|
e098b3c504 | ||
|
|
4dde466364 | ||
|
|
6d6fc52481 | ||
|
|
eaae4af832 | ||
|
|
7f5afddb38 | ||
|
|
36a981aaa2 | ||
|
|
fdcf093be0 | ||
|
|
1bd55b4572 | ||
|
|
eb0e5b7438 | ||
|
|
884dece54c | ||
|
|
3759f4c644 | ||
|
|
f232f74246 | ||
|
|
a9ecab35d8 | ||
|
|
e1e25d0eae | ||
|
|
f45c042351 | ||
|
|
f15bb9dbd7 | ||
|
|
610871c61b | ||
|
|
35b9e4768a | ||
|
|
1b4762715c | ||
|
|
5c21538553 | ||
|
|
85481d7321 | ||
|
|
153fa16bcf | ||
|
|
71642f494f | ||
|
|
8ba408385b | ||
|
|
d2c420a1fd | ||
|
|
855ff480a5 | ||
|
|
eb586aab55 | ||
|
|
b097f30f4d | ||
|
|
78f565c706 | ||
|
|
af30d8b7cd | ||
|
|
e79a918c03 | ||
|
|
83dc92c6a5 | ||
|
|
64c80c32f0 | ||
|
|
12eba33dbf | ||
|
|
0eee1f2d01 | ||
|
|
39a5921522 | ||
|
|
c99a689504 | ||
|
|
997a3e18a3 | ||
|
|
15747f48e9 | ||
|
|
d62b46f6cd | ||
|
|
d406e4c3d9 | ||
|
|
fc7d37def4 | ||
|
|
f6b3dfe5ba | ||
|
|
fe9094dedc | ||
|
|
34ff5d9662 | ||
|
|
df9bad75ea | ||
|
|
21af3bf563 | ||
|
|
b2f5f095fc | ||
|
|
8a1ac566c8 | ||
|
|
75bf595f86 | ||
|
|
312f13e254 | ||
|
|
2fc4006dfa | ||
|
|
47f7ec16c0 | ||
|
|
e105616b96 | ||
|
|
a503134533 | ||
|
|
bceb8540a1 | ||
|
|
10c6a70696 | ||
|
|
b809d76b79 | ||
|
|
bfad85223b | ||
|
|
b53c5593a8 | ||
|
|
3bfb98a1c6 | ||
|
|
573fde4bbc | ||
|
|
5c8a076790 | ||
|
|
20b173453d | ||
|
|
3460c9f714 | ||
|
|
69a5bf0159 | ||
|
|
01f0f309d1 | ||
|
|
3d67e1dbdb | ||
|
|
719e21ac8c | ||
|
|
14ed3b82a0 | ||
|
|
9e5e43fcd5 | ||
|
|
7493b7f35e | ||
|
|
54b3a57f46 | ||
|
|
4f998a6880 | ||
|
|
62a6cdc9f7 | ||
|
|
bc83dfa9e2 | ||
|
|
5adbab1d2b | ||
|
|
b0c1a7acce | ||
|
|
14cadbf80d | ||
|
|
741ab3e45c | ||
|
|
f456dba993 | ||
|
|
50a21fbd74 | ||
|
|
768ae584d3 | ||
|
|
ae32315bf7 | ||
|
|
9821e05386 | ||
|
|
38bc3d47ad | ||
|
|
4feb3bf411 | ||
|
|
b53d6c370b | ||
|
|
31c550d410 | ||
|
|
babd809fa6 | ||
|
|
54177c7064 | ||
|
|
4884184e4a | ||
|
|
4c7ef593be | ||
|
|
2600e9a805 | ||
|
|
6ac74f5686 | ||
|
|
172c1789a8 | ||
|
|
ffc00b7800 | ||
|
|
f44f015cb9 | ||
|
|
a4dcda16c1 | ||
|
|
9db506ef42 | ||
|
|
007f2caecf | ||
|
|
80a5845695 | ||
|
|
1b5525a8c5 | ||
|
|
22d45b9571 | ||
|
|
773602169d | ||
|
|
b650d3d9e6 | ||
|
|
9b2171088e | ||
|
|
e58ae58e24 | ||
|
|
a11e840d36 | ||
|
|
7d5b20ccfc | ||
|
|
2530d28c9d | ||
|
|
c669bc3e7f | ||
|
|
5943c8975a | ||
|
|
d9f97f6aad | ||
|
|
576521229c | ||
|
|
ac919f72a8 | ||
|
|
85ce2aff47 | ||
|
|
8030db03ad | ||
|
|
1e90470862 | ||
|
|
e37ca97bde | ||
|
|
97f45f5d96 | ||
|
|
0a64caf4c5 | ||
|
|
eee6fc0f10 | ||
|
|
60972e026b | ||
|
|
fd9123610b | ||
|
|
6458653812 | ||
|
|
328d448ab2 | ||
|
|
10aca70879 | ||
|
|
92edc68890 | ||
|
|
4d4af9d74e | ||
|
|
92c21de61d | ||
|
|
f918d34098 | ||
|
|
95e0f551e8 | ||
|
|
43e17f82b0 | ||
|
|
c7417623e6 | ||
|
|
50ed657b0e | ||
|
|
8b5d7028f7 | ||
|
|
aa28b3887f | ||
|
|
739b563bc2 | ||
|
|
a3a68de341 | ||
|
|
57c761aa7d | ||
|
|
75891b2d38 | ||
|
|
44943f6bf8 | ||
|
|
5fdcd2d7c7 | ||
|
|
43e3c84635 | ||
|
|
7f8bb10fc5 | ||
|
|
cc85edafc4 | ||
|
|
878ab33039 | ||
|
|
4b495557cd | ||
|
|
d1fd1cd788 | ||
|
|
f870bb3fad | ||
|
|
719f9d7d48 | ||
|
|
fd811bfd1b | ||
|
|
6837cd2917 | ||
|
|
f778a263a7 | ||
|
|
007f66d86e | ||
|
|
0e32393acb | ||
|
|
20729242f9 | ||
|
|
91655a855d | ||
|
|
9f2f343f76 | ||
|
|
6c1d164330 | ||
|
|
937fee9019 | ||
|
|
023a798ac1 | ||
|
|
07d61f6d47 | ||
|
|
304f63aedf | ||
|
|
30190f373a | ||
|
|
b51b094cc1 | ||
|
|
f4a2f344a7 | ||
|
|
1e7214a86b | ||
|
|
f8fd8b3585 | ||
|
|
644d62c915 | ||
|
|
741ec36ee1 | ||
|
|
a08d7bb1b2 | ||
|
|
16ae77ca1c | ||
|
|
a5bf3a8407 | ||
|
|
cd0306d513 | ||
|
|
b29d0b8276 | ||
|
|
3ee88fd8fe | ||
|
|
bc9c93b180 | ||
|
|
e49d10ab22 | ||
|
|
059946d59e | ||
|
|
6211760922 | ||
|
|
167958c002 | ||
|
|
8b16ffb629 | ||
|
|
b5193162bf | ||
|
|
bc34c237b6 | ||
|
|
d9824d26d2 | ||
|
|
8d08b55e69 | ||
|
|
503c844971 | ||
|
|
deff356910 | ||
|
|
883ebbf267 | ||
|
|
cd45116dce | ||
|
|
d80362c4b8 | ||
|
|
384e06d6fe | ||
|
|
e6f44a70d0 | ||
|
|
0ca90ee7e8 | ||
|
|
59a56c803a | ||
|
|
1e0b44bdc5 | ||
|
|
2f3296bada | ||
|
|
434d8e0977 | ||
|
|
0a89eaaf62 | ||
|
|
cea2f81b86 | ||
|
|
86b612f3b5 | ||
|
|
d425e5eb6a | ||
|
|
183fd33f3f | ||
|
|
8c82d3e747 | ||
|
|
7b495f3d81 | ||
|
|
3ea7f1cb03 | ||
|
|
2a13fe05c6 | ||
|
|
2c4c899179 | ||
|
|
760fb32016 | ||
|
|
278f40471b | ||
|
|
20ca09c730 | ||
|
|
568a71cdbe | ||
|
|
753a5f7cb2 | ||
|
|
96e13786cd | ||
|
|
5d6592f296 | ||
|
|
534dd331b9 | ||
|
|
b3b56fcafd | ||
|
|
671fd50cfb | ||
|
|
eaf19643a9 | ||
|
|
a582a3781b | ||
|
|
e0d90e0b21 | ||
|
|
a73189338c | ||
|
|
1e414dd370 | ||
|
|
5ea03c71c0 | ||
|
|
d7a46f089e | ||
|
|
6e33181f05 | ||
|
|
622f8f8158 | ||
|
|
821b0f0f92 | ||
|
|
471b217e99 | ||
|
|
adda0eff4a | ||
|
|
2001ca6566 | ||
|
|
b9a783d7d7 | ||
|
|
eb9ee9f41e | ||
|
|
fae14ad283 | ||
|
|
4b5ac3f926 | ||
|
|
72e5acfb86 | ||
|
|
16c6e17a49 | ||
|
|
ac31671914 | ||
|
|
4b283242fe | ||
|
|
353ea0fbbe | ||
|
|
fc941f55ef | ||
|
|
12600a8cbd | ||
|
|
33fa9542e0 | ||
|
|
d872ea32af | ||
|
|
46bb2d1367 | ||
|
|
403ddd603f | ||
|
|
7907838c24 | ||
|
|
15bd79186a | ||
|
|
4555b77204 | ||
|
|
dd3c612dec | ||
|
|
09b6698de8 | ||
|
|
27ee156706 | ||
|
|
48c3d1fa4a | ||
|
|
286254c5cd | ||
|
|
82cd51f5f4 | ||
|
|
08bf993146 | ||
|
|
a55bcae3ec | ||
|
|
607a14e921 | ||
|
|
c71387ad00 | ||
|
|
c095c28618 | ||
|
|
cae1188ff8 | ||
|
|
7e599c51f8 | ||
|
|
6ccb9d2dc2 | ||
|
|
1d00ed463e | ||
|
|
c99054e479 | ||
|
|
85a9e0d0bc | ||
|
|
8b4ea3c80c | ||
|
|
30dec34b72 | ||
|
|
a3d2df7c45 | ||
|
|
034f338f45 | ||
|
|
1d84346705 | ||
|
|
6e916ebd45 | ||
|
|
a993bed8dc | ||
|
|
aa6f65ee1f | ||
|
|
573931930c | ||
|
|
252bb69808 | ||
|
|
0175c8ab8a | ||
|
|
f78bb2078d | ||
|
|
bc028a63cd | ||
|
|
4b04f2b918 | ||
|
|
887a3b0922 | ||
|
|
3df78fa387 | ||
|
|
c36ac5baba | ||
|
|
d8e33fe596 | ||
|
|
80b7e2e188 | ||
|
|
14b430a168 | ||
|
|
22aa4cbb9f | ||
|
|
71bb5b850e | ||
|
|
066c830a43 | ||
|
|
760107becf | ||
|
|
8dad49e385 | ||
|
|
518e5db55b | ||
|
|
31a3c1cf33 | ||
|
|
e1b4975a11 | ||
|
|
f8a5e8bfc7 | ||
|
|
a656ad5cd2 | ||
|
|
109eb5b9dc |
2
.github/workflows/unittests.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||
python ModuleUpdate.py --yes --force
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
|
||||
10
.gitignore
vendored
@@ -4,9 +4,13 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -37,6 +41,8 @@ success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -142,4 +148,6 @@ dmypy.json
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
cython_debug/
|
||||
|
||||
Archipelago.zip
|
||||
|
||||
695
BaseClasses.py
@@ -3,24 +3,20 @@ import logging
|
||||
import typing
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
import sys
|
||||
|
||||
import prompt_toolkit
|
||||
import websockets
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
|
||||
import Utils
|
||||
from MultiServer import CommandProcessor
|
||||
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
|
||||
from Utils import Version
|
||||
|
||||
# logging note:
|
||||
# logging.* gets send to only the text console, logger.* gets send to the WebUI as well, if it's initialized.
|
||||
from worlds import network_data_package
|
||||
from worlds.alttp import Items, Regions
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
@@ -47,19 +43,16 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
"""List all received items"""
|
||||
logger.info('Received items:')
|
||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
logging.info('%s from %s (%s) (%d/%d in list)' % (
|
||||
color(self.ctx.item_name_getter(item.item), 'red', 'bold'),
|
||||
color(self.ctx.player_names[item.player], 'yellow'),
|
||||
self.ctx.location_name_getter(item.location), index, len(self.ctx.items_received)))
|
||||
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks, from your local game state"""
|
||||
count = 0
|
||||
checked_count = 0
|
||||
for location, location_id in Regions.lookup_name_to_id.items():
|
||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
|
||||
if location_id < 0:
|
||||
continue
|
||||
if location_id not in self.ctx.locations_checked:
|
||||
@@ -78,8 +71,6 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output("No missing location checks found.")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
def _cmd_ready(self):
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
@@ -93,11 +84,15 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
def default(self, raw: str):
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]))
|
||||
|
||||
|
||||
class CommonContext():
|
||||
starting_reconnect_delay = 5
|
||||
current_reconnect_delay = starting_reconnect_delay
|
||||
command_processor = ClientCommandProcessor
|
||||
def __init__(self, server_address, password, found_items: bool):
|
||||
game: None
|
||||
ui: None
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.password = password
|
||||
@@ -108,7 +103,6 @@ class CommonContext():
|
||||
# own state
|
||||
self.finished_game = False
|
||||
self.ready = False
|
||||
self.found_items = found_items
|
||||
self.team = None
|
||||
self.slot = None
|
||||
self.auth = None
|
||||
@@ -149,16 +143,17 @@ class CommonContext():
|
||||
if local_package and local_package["version"] > network_data_package["version"]:
|
||||
data_package: dict = local_package
|
||||
elif network: # check if data from server is newer
|
||||
for key, value in data_package.items():
|
||||
if type(value) == dict: # convert to int keys
|
||||
data_package[key] = \
|
||||
{int(subkey) if subkey.isdigit() else subkey: subvalue for subkey, subvalue in value.items()}
|
||||
|
||||
if data_package["version"] > network_data_package["version"]:
|
||||
Utils.persistent_store("datapackage", "latest", network_data_package)
|
||||
|
||||
item_lookup: dict = data_package["lookup_any_item_id_to_name"]
|
||||
locations_lookup: dict = data_package["lookup_any_location_id_to_name"]
|
||||
item_lookup: dict = {}
|
||||
locations_lookup: dict = {}
|
||||
for game, gamedata in data_package["games"].items():
|
||||
for item_name, item_id in gamedata["item_name_to_id"].items():
|
||||
item_lookup[item_id] = item_name
|
||||
for location_name, location_id in gamedata["location_name_to_id"].items():
|
||||
locations_lookup[location_id] = location_name
|
||||
|
||||
def get_item_name_from_id(code: int):
|
||||
return item_lookup.get(code, f'Unknown item (ID:{code})')
|
||||
@@ -205,7 +200,7 @@ class CommonContext():
|
||||
self.input_requests += 1
|
||||
return await self.input_queue.get()
|
||||
|
||||
async def connect(self, address= None):
|
||||
async def connect(self, address=None):
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address))
|
||||
|
||||
@@ -213,9 +208,15 @@ class CommonContext():
|
||||
logger.info(args["text"])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]:
|
||||
pass # don't want info on other player's local pickups.
|
||||
logger.info(self.jsontotextparser(args["data"]))
|
||||
if self.ui:
|
||||
self.ui.print_json(args["data"])
|
||||
else:
|
||||
text = self.jsontotextparser(args["data"])
|
||||
logger.info(text)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
"""For custom package handling in subclasses."""
|
||||
pass
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
@@ -295,8 +296,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.info('Password required')
|
||||
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
|
||||
logger.info(f"Remaining setting: {args['remaining_mode']}")
|
||||
logger.info(f"A !hint costs {args['hint_cost']}% of checks points and you get {args['location_check_points']}"
|
||||
f" for each location checked. Use !hint for more information.")
|
||||
logger.info(
|
||||
f"A !hint costs {args['hint_cost']}% of your total location count as points"
|
||||
f" and you get {args['location_check_points']}"
|
||||
f" for each location checked. Use !hint for more information.")
|
||||
ctx.hint_cost = int(args['hint_cost'])
|
||||
ctx.check_points = int(args['location_check_points'])
|
||||
ctx.forfeit_mode = args['forfeit_mode']
|
||||
@@ -334,9 +337,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.error('Invalid password')
|
||||
ctx.password = None
|
||||
await ctx.server_auth(True)
|
||||
else:
|
||||
elif errors:
|
||||
raise Exception("Unknown connection errors: " + str(errors))
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
else:
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
|
||||
elif cmd == 'Connected':
|
||||
ctx.team = args["team"]
|
||||
@@ -395,12 +399,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
elif cmd == 'PrintJSON':
|
||||
ctx.on_print_json(args)
|
||||
|
||||
elif cmd == 'InvalidArguments':
|
||||
logger.warning(f"Invalid Arguments: {args['text']}")
|
||||
elif cmd == 'InvalidPacket':
|
||||
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||
|
||||
elif cmd == "Bounced":
|
||||
pass
|
||||
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
||||
ctx.on_package(cmd, args)
|
||||
|
||||
|
||||
async def console_loop(ctx: CommonContext):
|
||||
import sys
|
||||
@@ -420,4 +429,4 @@ async def console_loop(ctx: CommonContext):
|
||||
if input_text:
|
||||
commandprocessor(input_text)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logger.exception(e)
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
Hint description:
|
||||
|
||||
Hints will appear in the following ratios across the 15 telepathic tiles that have hints and the five storyteller locations:
|
||||
|
||||
4 hints for inconvenient entrances.
|
||||
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
|
||||
3 hints for inconvenient item locations.
|
||||
5 hints for valuable items.
|
||||
4 junk hints.
|
||||
|
||||
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following ratios will be used instead:
|
||||
|
||||
5 hints for inconvenient item locations.
|
||||
8 hints for valuable items.
|
||||
7 junk hints.
|
||||
|
||||
In the simple, restricted, and restricted legacy shuffles, these are the ratios:
|
||||
|
||||
2 hints for inconvenient entrances.
|
||||
1 hint for an inconvenient dungeon entrance.
|
||||
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
|
||||
3 hints for inconvenient item locations.
|
||||
5 hints for valuable items.
|
||||
5 junk hints.
|
||||
|
||||
These hints will use the following format:
|
||||
|
||||
Entrance hints go "[Entrance on overworld] leads to [interior]".
|
||||
|
||||
Inconvenient item locations are a little more custom but amount to "[Location] has [item name]". The item name is literal and will specify which dungeon the dungeon specific items hail from (small key/big key/map/compass).
|
||||
|
||||
The valuable items are of the format "[item name] can be found [location]". The item name is again literal, and the location text is taken from Ganon's silver arrow hints. Note that the way it works is that every unique valuable item that exists is considered independently, and you won't get multiple hints for the EXACT same item (so you can only get one hint for Progressive Sword no matter how many swords exist in the seed, but if swords are not progressive, you could get hints for both Master Sword and Tempered Sword). More copies of an item existing does not increase the probability of getting a hint for that particular item (you are equally likely to get a hint for a Progressive Sword as for the Hammer). Unlike the IR, item names are never obfuscated by "something unique", and there is no special bias for hints for GT Big Key or Pegasus Boots.
|
||||
|
||||
Hint Locations:
|
||||
|
||||
Eastern Palace room before Big Chest
|
||||
Desert Palace bonk torch room
|
||||
Tower of Hera entrance room
|
||||
Tower of Hera Big Chest room
|
||||
Castle Tower after dark rooms
|
||||
Palace of Darkness before Bow section
|
||||
Swamp Palace entryway
|
||||
Thieves' Town upstairs
|
||||
Ice Palace entrance
|
||||
Ice Palace after first drop
|
||||
Ice Palace tall ice floor room
|
||||
Misery Mire cutscene room
|
||||
Turtle Rock entrance
|
||||
Spectacle Rock cave
|
||||
Spiky Hint cave
|
||||
PoD Bdlg NPC
|
||||
Near PoD Storyteller (bug near bomb wall)
|
||||
Dark Sanctuary Storyteller (long room with tables)
|
||||
Near Mire Storyteller (feather duster in winding cave)
|
||||
SE DW Storyteller (owl in winding cave)
|
||||
|
||||
Inconvenient entrance list:
|
||||
|
||||
Skull Woods Final
|
||||
Ice Palace
|
||||
Misery Mire
|
||||
Turtle Rock
|
||||
Ganon's Tower
|
||||
Mimic Ledge
|
||||
SW DM Foothills Cave (mirror from upper Bumper ledge)
|
||||
Hammer Pegs (near purple chest)
|
||||
Super Bomb cracked wall
|
||||
|
||||
Inconvenient location list:
|
||||
|
||||
Swamp left (two chests)
|
||||
Mire left (two chests)
|
||||
Hera basement
|
||||
Eastern Palace Big Key chest (protected by anti-fairies)
|
||||
Thieves' Town Big Chest
|
||||
Ice Palace Big Chest
|
||||
Ganon's Tower Big Chest
|
||||
Purple Chest
|
||||
Spike Cave
|
||||
Magic Bat
|
||||
Sahasrahla (Green Pendant)
|
||||
|
||||
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following two locations are added to the inconvenient locations list:
|
||||
|
||||
Graveyard Cave
|
||||
Mimic Cave
|
||||
|
||||
Valuable Items are simply all items that are shown on the pause subscreen (Y, B, or A sections) minus Silver Arrows and plus Triforce Pieces, Magic Upgrades (1/2 or 1/4), and the Single Arrow. If key shuffle is being used, you can additionally get hints for Small Keys or Big Keys but not hints for Maps or Compasses.
|
||||
|
||||
While the exact verbage of location names and item names can be found in the source code, here's a copy for reference:
|
||||
|
||||
Overworld Entrance naming:
|
||||
|
||||
Turtle Rock: Turtle Rock Main
|
||||
Misery Mire: Misery Mire
|
||||
Ice Palace: Ice Palace
|
||||
Skull Woods Final Section: The back of Skull Woods
|
||||
Death Mountain Return Cave (West): The SW DM Foothills Cave
|
||||
Mimic Cave: Mimic Ledge
|
||||
Dark World Hammer Peg Cave: The rows of pegs
|
||||
Pyramid Fairy: The crack on the pyramid
|
||||
Eastern Palace: Eastern Palace
|
||||
Elder House (East): Elder House
|
||||
Elder House (West): Elder House
|
||||
Two Brothers House (East): Eastern Quarreling Brothers' house
|
||||
Old Man Cave (West): The lower DM entrance
|
||||
Hyrule Castle Entrance (South): The ground level castle door
|
||||
Thieves Town: Thieves' Town
|
||||
Bumper Cave (Bottom): The lower Bumper Cave
|
||||
Swamp Palace: Swamp Palace
|
||||
Dark Death Mountain Ledge (West): The East dark DM connector ledge
|
||||
Dark Death Mountain Ledge (East): The East dark DM connector ledge
|
||||
Superbunny Cave (Top): The summit of dark DM cave
|
||||
Superbunny Cave (Bottom): The base of east dark DM
|
||||
Hookshot Cave: The rock on dark DM
|
||||
Desert Palace Entrance (South): The book sealed passage
|
||||
Tower of Hera: The Tower of Hera
|
||||
Two Brothers House (West): The door near the race game
|
||||
Old Man Cave (East): The SW-most cave on west DM
|
||||
Old Man House (Bottom): A cave with a door on west DM
|
||||
Old Man House (Top): The eastmost cave on west DM
|
||||
Death Mountain Return Cave (East): The westmost cave on west DM
|
||||
Spectacle Rock Cave Peak: The highest cave on west DM
|
||||
Spectacle Rock Cave: The right ledge on west DM
|
||||
Spectacle Rock Cave (Bottom): The left ledge on west DM
|
||||
Paradox Cave (Bottom): The right paired cave on east DM
|
||||
Paradox Cave (Middle): The southmost cave on east DM
|
||||
Paradox Cave (Top): The east DM summit cave
|
||||
Fairy Ascension Cave (Bottom): The east DM cave behind rocks
|
||||
Fairy Ascension Cave (Top): The central ledge on east DM
|
||||
Spiral Cave: The left ledge on east DM
|
||||
Spiral Cave (Bottom): The SWmost cave on east DM
|
||||
Palace of Darkness: Palace of Darkness
|
||||
Hyrule Castle Entrance (West): The left castle door
|
||||
Hyrule Castle Entrance (East): The right castle door
|
||||
Agahnims Tower: The sealed castle door
|
||||
Desert Palace Entrance (West): The westmost building in the desert
|
||||
Desert Palace Entrance (North): The northmost cave in the desert
|
||||
Blinds Hideout: Blind's old house
|
||||
Lake Hylia Fairy: A cave NE of Lake Hylia
|
||||
Light Hype Fairy: The cave south of your house
|
||||
Desert Fairy: The cave near the desert
|
||||
Chicken House: The chicken lady's house
|
||||
Aginahs Cave: The open desert cave
|
||||
Sahasrahlas Hut: The house near armos
|
||||
Cave Shop (Lake Hylia): The cave NW Lake Hylia
|
||||
Blacksmiths Hut: The old smithery
|
||||
Sick Kids House: The central house in Kakariko
|
||||
Lost Woods Gamble: A tree trunk door
|
||||
Fortune Teller (Light): A building NE of Kakariko
|
||||
Snitch Lady (East): A house guarded by a snitch
|
||||
Snitch Lady (West): A house guarded by a snitch
|
||||
Bush Covered House: A house with an uncut lawn
|
||||
Tavern (Front): A building with a backdoor
|
||||
Light World Bomb Hut: A Kakariko building with no door
|
||||
Kakariko Shop: The old Kakariko shop
|
||||
Mini Moldorm Cave: The cave south of Lake Hylia
|
||||
Long Fairy Cave: The eastmost portal cave
|
||||
Good Bee Cave: The open cave SE Lake Hylia
|
||||
20 Rupee Cave: The rock SE Lake Hylia
|
||||
50 Rupee Cave: The rock near the desert
|
||||
Ice Rod Cave: The sealed cave SE Lake Hylia
|
||||
Library: The old library
|
||||
Potion Shop: The witch's building
|
||||
Dam: The old dam
|
||||
Lumberjack House: The lumberjack house
|
||||
Lake Hylia Fortune Teller: The building NW Lake Hylia
|
||||
Kakariko Gamble Game: The old Kakariko gambling den
|
||||
Waterfall of Wishing: Going behind the waterfall
|
||||
Capacity Upgrade: The cave on the island
|
||||
Bonk Rock Cave: The rock pile near Sanctuary
|
||||
Graveyard Cave: The graveyard ledge
|
||||
Checkerboard Cave: The NE desert ledge
|
||||
Cave 45: The ledge south of haunted grove
|
||||
Kings Grave: The northeastmost grave
|
||||
Bonk Fairy (Light): The rock pile near your home
|
||||
Hookshot Fairy: A cave on east DM
|
||||
Bonk Fairy (Dark): The rock pile near the old bomb shop
|
||||
Dark Sanctuary Hint: The dark sanctuary cave
|
||||
Dark Lake Hylia Fairy: The cave NE dark Lake Hylia
|
||||
C-Shaped House: The NE house in Village of Outcasts
|
||||
Big Bomb Shop: The old bomb shop
|
||||
Dark Death Mountain Fairy: The SW cave on dark DM
|
||||
Dark Lake Hylia Shop: The building NW dark Lake Hylia
|
||||
Dark World Shop: The hammer sealed building
|
||||
Red Shield Shop: The fenced in building
|
||||
Mire Shed: The western hut in the mire
|
||||
East Dark World Hint: The dark cave near the eastmost portal
|
||||
Dark Desert Hint: The cave east of the mire
|
||||
Spike Cave: The ledge cave on west dark DM
|
||||
Palace of Darkness Hint: The building south of Kiki
|
||||
Dark Lake Hylia Ledge Spike Cave: The rock SE dark Lake Hylia
|
||||
Cave Shop (Dark Death Mountain): The base of east dark DM
|
||||
Dark World Potion Shop: The building near the catfish
|
||||
Archery Game: The old archery game
|
||||
Dark World Lumberjack Shop: The northmost Dark World building
|
||||
Hype Cave: The cave south of the old bomb shop
|
||||
Brewery: The Village of Outcasts building with no door
|
||||
Dark Lake Hylia Ledge Hint: The open cave SE dark Lake Hylia
|
||||
Chest Game: The westmost building in the Village of Outcasts
|
||||
Dark Desert Fairy: The eastern hut in the mire
|
||||
Dark Lake Hylia Ledge Fairy: The sealed cave SE dark Lake Hylia
|
||||
Fortune Teller (Dark): The building NE the Village of Outcasts
|
||||
Sanctuary: Sanctuary
|
||||
Lumberjack Tree Cave: The cave Behind Lumberjacks
|
||||
Lost Woods Hideout Stump: The stump in Lost Woods
|
||||
North Fairy Cave: The cave East of Graveyard
|
||||
Bat Cave Cave: The cave in eastern Kakariko
|
||||
Kakariko Well Cave: The cave in northern Kakariko
|
||||
Hyrule Castle Secret Entrance Stairs: The tunnel near the castle
|
||||
Skull Woods First Section Door: The southeastmost skull
|
||||
Skull Woods Second Section Door (East): The central open skull
|
||||
Skull Woods Second Section Door (West): The westmost open skull
|
||||
Desert Palace Entrance (East): The eastern building in the desert
|
||||
Turtle Rock Isolated Ledge Entrance: The isolated ledge on east dark DM
|
||||
Bumper Cave (Top): The upper Bumper Cave
|
||||
Hookshot Cave Back Entrance: The stairs on the floating island
|
||||
|
||||
Destination Entrance Naming:
|
||||
|
||||
Hyrule Castle: Hyrule Castle (all three entrances)
|
||||
Eastern Palace: Eastern Palace
|
||||
Desert Palace: Desert Palace (all four entrances, including final)
|
||||
Tower of Hera: Tower of Hera
|
||||
Palace of Darkness: Palace of Darkness
|
||||
Swamp Palace: Swamp Palace
|
||||
Skull Woods: Skull Woods (any entrance including final)
|
||||
Thieves' Town: Thieves' Town
|
||||
Ice Palace: Ice Palace
|
||||
Misery Mire: Misery Mire
|
||||
Turtle Rock: Turtle Rock (all four entrances)
|
||||
Ganon's Tower: Ganon's Tower
|
||||
Castle Tower: Agahnim's Tower
|
||||
A connector: Paradox Cave, Spectacle Rock Cave, Hookshot Cave, Superbunny Cave, Spiral Cave, Old Man Fetch Cave, Old Man House, Elder House, Quarreling Brothers' House, Bumper Cave, DM Fairy Ascent Cave, DM Exit Cave
|
||||
A bounty of five items: Mini-moldorm cave, Hype Cave, Blind's Hideout
|
||||
Sahasrahla: Sahasrahla
|
||||
A cave with two items: Mire hut, Waterfall Fairy, Pyramid Fairy
|
||||
A fairy fountain: Any healer fairy cave, either bonk cave with four fairies, the "long fairy" cave
|
||||
A common shop: Any shop that sells bombs by default
|
||||
The rare shop: The shop that sells the Red Shield by default
|
||||
The potion shop: Potion Shop
|
||||
The bomb shop: Bomb Shop
|
||||
A fortune teller: Any of the three fortune tellers
|
||||
A house with a chest: Chicken Lady's house, C-House, Brewery
|
||||
A cave with an item: Checkerboard cave, Hammer Pegs cave, Cave 45, Graveyard Ledge cave
|
||||
A cave with a chest: Sanc Bonk Rock Cave, Cape Grave Cave, Ice Rod Cave, Aginah's Cave
|
||||
The dam: Watergate
|
||||
The sick kid: Sick Kid
|
||||
The library: Library
|
||||
Mimic Cave: Mimic Cave
|
||||
Spike Cave: Spike Cave
|
||||
A game of 16 chests: VoO chest game (for the item)
|
||||
A storyteller: The four DW NPCs who charge 20 rupees for a hint as well as the PoD Bdlg guy who gives a free hint
|
||||
A cave with some cash: 20 rupee cave, 50 rupee cave (both have thieves and some pots)
|
||||
A game of chance: Gambling game (just for cash, no items)
|
||||
A game of skill: Archery minigame
|
||||
The queen of fairies: Capacity Upgrade Fairy
|
||||
A drop's exit: Sanctuary, LW Thieves' Hideout, Kakariko Well, Magic Bat, Useless Fairy, Uncle Tunnel, Ganon drop exit
|
||||
A restock room: The Kakariko bomb/arrow restock room
|
||||
The tavern: The Kakariko tavern
|
||||
The grass man: The Kakariko man with many beds
|
||||
A cold bee: The "wrong side" of Ice Rod cave where you can get a Good Bee
|
||||
Fairies deep in a cave: Hookshot Fairy
|
||||
|
||||
Location naming reference:
|
||||
|
||||
Mushroom: in the woods
|
||||
Master Sword Pedestal: at the pedestal
|
||||
Bottle Merchant: with a merchant
|
||||
Stumpy: with tree boy
|
||||
Flute Spot: underground
|
||||
Digging Game: underground
|
||||
Lake Hylia Island: on an island
|
||||
Floating Island: on an island
|
||||
Bumper Cave Ledge: on a ledge
|
||||
Spectacle Rock: atop a rock
|
||||
Maze Race: at the race
|
||||
Desert Ledge: in the desert
|
||||
Pyramid: on the pyramid
|
||||
Catfish: with a catfish
|
||||
Ether Tablet: at a monument
|
||||
Bombos Tablet: at a monument
|
||||
Hobo: with the hobo
|
||||
Zora's Ledge: near Zora
|
||||
King Zora: at a high price
|
||||
Sunken Treasure: underwater
|
||||
Floodgate Chest: in the dam
|
||||
Blacksmith: with the smith
|
||||
Purple Chest: from a box
|
||||
Old Man: with the old man
|
||||
Link's Uncle: with your uncle
|
||||
Secret Passage: near your uncle
|
||||
Kakariko Well (5 items): in a well
|
||||
Lost Woods Hideout: near a thief
|
||||
Lumberjack Tree: in a hole
|
||||
Magic Bat: with the bat
|
||||
Paradox Cave (7 items): in a cave with seven chests
|
||||
Blind's Hideout (5 items): in a basement
|
||||
Mini Moldorm Cave (5 items): near Moldorms
|
||||
Hype Cave (4 back chests): near a bat-like man
|
||||
Hype Cave - Generous Guy: with a bat-like man
|
||||
Hookshot Cave (4 items): across pits
|
||||
Sahasrahla's Hut (chests in back): near the elder
|
||||
Sahasrahla: with the elder
|
||||
Waterfall Fairy (2 items): near a fairy
|
||||
Pyramid Fairy (2 items): near a fairy
|
||||
Mire Shed (2 items): near sparks
|
||||
Superbunny Cave (2 items): in a connection
|
||||
Spiral Cave: in spiral cave
|
||||
Kakariko Tavern: in the bar
|
||||
Link's House: in your home
|
||||
Sick Kid: with the sick
|
||||
Library: near books
|
||||
Potion Shop: near potions
|
||||
Spike Cave: beyond spikes
|
||||
Mimic Cave: in a cave of mimicry
|
||||
Chest Game: as a prize
|
||||
Chicken House: near poultry
|
||||
Aginah's Cave: with Aginah
|
||||
Ice Rod Cave: in a frozen cave
|
||||
Brewery: alone in a home
|
||||
C-Shaped House: alone in a home
|
||||
Spectacle Rock Cave: alone in a cave
|
||||
King's Tomb: alone in a cave
|
||||
Cave 45: alone in a cave
|
||||
Graveyard Cave: alone in a cave
|
||||
Checkerboard Cave: alone in a cave
|
||||
Bonk Rock Cave: alone in a cave
|
||||
Peg Cave: alone in a cave
|
||||
Sanctuary: in Sanctuary
|
||||
Hyrule Castle - Boomerang Chest: in Hyrule Castle
|
||||
Hyrule Castle - Map Chest: in Hyrule Castle
|
||||
Hyrule Castle - Zelda's Chest: in Hyrule Castle
|
||||
Sewers - Dark Cross: in the sewers
|
||||
Sewers - Secret Room (3 items): in the sewers
|
||||
Eastern Palace - Boss: with the Armos
|
||||
Eastern Palace (otherwise, 5 items): in Eastern Palace
|
||||
Desert Palace - Boss: with Lanmolas
|
||||
Desert Palace (otherwise, 5 items): in Desert Palace
|
||||
Tower of Hera - Boss: with Moldorm
|
||||
Tower of Hera (otherwise, 5 items): in Tower of Hera
|
||||
Castle Tower (2 items): in Castle Tower
|
||||
Palace of Darkness - Boss: with Helmasaur King
|
||||
Palace of Darkness (otherwise, 13 items): in Palace of Darkness
|
||||
Swamp Palace - Boss: with Arrghus
|
||||
Swamp Palace (otherwise, 9 items): in Swamp Palace
|
||||
Skull Woods - Bridge Room: near Mothula
|
||||
Skull Woods - Boss: with Mothula
|
||||
Skull Woods (otherwise, 6 items): in Skull Woods
|
||||
Thieves' Town - Boss: with Blind
|
||||
Thieves' Town (otherwise, 7 items): in Thieves' Town
|
||||
Ice Palace - Boss: with Kholdstare
|
||||
Ice Palace (otherwise, 7 items): in Ice Palace
|
||||
Misery Mire - Boss: with Vitreous
|
||||
Misery Mire (otherwise, 7 items): in Misery Mire
|
||||
Turtle Rock - Boss: with Trinexx
|
||||
Turtle Rock (otherwise, 11 items): in Turtle Rock
|
||||
Ganons Tower (after climb, 4 items): atop Ganon's Tower
|
||||
Ganon's Tower (otherwise, 23 items): in Ganon's Tower
|
||||
@@ -1,78 +1,84 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import subprocess
|
||||
import factorio_rcon
|
||||
|
||||
import colorama
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled
|
||||
from MultiServer import mark_raw
|
||||
|
||||
import Utils
|
||||
import random
|
||||
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
from worlds.factorio import Factorio
|
||||
|
||||
rcon_port = 24242
|
||||
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
|
||||
save_name = "Archipelago"
|
||||
log_folder = Utils.local_path("logs")
|
||||
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
|
||||
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
bin_dir = os.path.dirname(executable)
|
||||
if not os.path.isdir(bin_dir):
|
||||
raise FileNotFoundError(bin_dir)
|
||||
if not os.path.exists(executable):
|
||||
if os.path.exists(executable + ".exe"):
|
||||
executable = executable + ".exe"
|
||||
else:
|
||||
raise FileNotFoundError(executable)
|
||||
|
||||
import sys
|
||||
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:])
|
||||
|
||||
threadpool = ThreadPoolExecutor(10)
|
||||
if gui_enabled:
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
|
||||
filename=os.path.join(log_folder, "FactorioClient.txt"), filemode="w", force=True)
|
||||
else:
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
|
||||
logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, "FactorioClient.txt"), "w"))
|
||||
|
||||
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
ctx: FactorioContext
|
||||
|
||||
@mark_raw
|
||||
def _cmd_factorio(self, text: str) -> bool:
|
||||
"""Send the following command to the bound Factorio Server."""
|
||||
if self.ctx.rcon_client:
|
||||
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
|
||||
self.ctx.print_to_game(f"/factorio {text}")
|
||||
result = self.ctx.rcon_client.send_command(text)
|
||||
if result:
|
||||
self.output(result)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
if not self.ctx.auth:
|
||||
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
|
||||
return super(FactorioCommandProcessor, self)._cmd_connect(address)
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.ctx.awaiting_bridge = True
|
||||
|
||||
|
||||
class FactorioContext(CommonContext):
|
||||
command_processor = FactorioCommandProcessor
|
||||
game = "Factorio"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FactorioContext, self).__init__(*args, **kwargs)
|
||||
# updated by spinup server
|
||||
mod_version: Utils.Version = Utils.Version(0, 0, 0)
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super(FactorioContext, self).__init__(server_address, password)
|
||||
self.send_index = 0
|
||||
self.rcon_client = None
|
||||
self.raw_json_text_parser = RawJSONtoTextParser(self)
|
||||
self.awaiting_bridge = False
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
if password_requested and not self.password:
|
||||
await super(FactorioContext, self).server_auth(password_requested)
|
||||
|
||||
if not self.auth:
|
||||
if self.rcon_client:
|
||||
get_info(self, self.rcon_client) # retrieve current auth code
|
||||
else:
|
||||
raise Exception("Cannot connect to a server with unknown own identity, "
|
||||
"bridge to Factorio first.")
|
||||
|
||||
await self.send_msgs([{"cmd": 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils._version_tuple,
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': ['AP'],
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
|
||||
}])
|
||||
@@ -80,62 +86,75 @@ class FactorioContext(CommonContext):
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
if self.rcon_client:
|
||||
cleaned_text = args['text'].replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")")
|
||||
self.print_to_game(args['text'])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]:
|
||||
pass # don't want info on other player's local pickups.
|
||||
copy_data = copy.deepcopy(args["data"]) # jsontotextparser is destructive currently
|
||||
logger.info(self.jsontotextparser(args["data"]))
|
||||
if self.rcon_client:
|
||||
cleaned_text = self.raw_json_text_parser(copy_data).replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")")
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
self.print_to_game(text)
|
||||
super(FactorioContext, self).on_print_json(args)
|
||||
|
||||
async def game_watcher(ctx: FactorioContext, bridge_file: str):
|
||||
@property
|
||||
def savegame_name(self) -> str:
|
||||
return f"AP_{self.seed_name}_{self.auth}.zip"
|
||||
|
||||
def print_to_game(self, text):
|
||||
# TODO: remove around version 0.2
|
||||
if self.mod_version < Utils.Version(0, 1, 6):
|
||||
text = text.replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}\")")
|
||||
else:
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
# catch up sync anything that is already cleared.
|
||||
if args["checked_locations"]:
|
||||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
bridge_counter = 0
|
||||
try:
|
||||
while 1:
|
||||
if os.path.exists(bridge_file):
|
||||
bridge_logger.info("Found Factorio Bridge file.")
|
||||
while 1:
|
||||
with open(bridge_file) as f:
|
||||
data = json.load(f)
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
ctx.auth = data["slot_name"]
|
||||
ctx.seed_name = data["seed_name"]
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.awaiting_bridge and ctx.rcon_client:
|
||||
ctx.awaiting_bridge = False
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
if data["slot_name"] != ctx.auth:
|
||||
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||
elif data["seed_name"] != ctx.seed_name:
|
||||
bridge_logger.warning(
|
||||
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||
else:
|
||||
data = data["info"]
|
||||
research_data = data["research_done"]
|
||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
||||
victory = data["victory"]
|
||||
|
||||
if not ctx.finished_game and victory:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if ctx.locations_checked != research_data:
|
||||
bridge_logger.info(f"New researches done: "
|
||||
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
||||
bridge_logger.info(
|
||||
f"New researches done: "
|
||||
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
|
||||
ctx.locations_checked = research_data
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
bridge_counter += 1
|
||||
if bridge_counter >= 60:
|
||||
bridge_logger.info(
|
||||
"Did not find Factorio Bridge file, "
|
||||
"waiting for mod to run, which requires the server to run, "
|
||||
"which requires a player to be connected.")
|
||||
bridge_counter = 0
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
|
||||
|
||||
def stream_factorio_output(pipe, queue):
|
||||
def stream_factorio_output(pipe, queue, process):
|
||||
def queuer():
|
||||
while 1:
|
||||
while process.poll() is None:
|
||||
text = pipe.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
@@ -144,91 +163,217 @@ def stream_factorio_output(pipe, queue):
|
||||
|
||||
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
async def factorio_server_watcher(ctx: FactorioContext):
|
||||
import subprocess
|
||||
import factorio_rcon
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
factorio_process = subprocess.Popen((executable, "--start-server", *(str(elem) for elem in server_args)),
|
||||
savegame_name = os.path.abspath(ctx.savegame_name)
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||
))
|
||||
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
|
||||
*(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
encoding="utf-8")
|
||||
factorio_server_logger.info("Started Factorio Server")
|
||||
factorio_queue = Queue()
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue)
|
||||
script_folder = None
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
try:
|
||||
while 1:
|
||||
while not ctx.exit_event.is_set():
|
||||
if factorio_process.poll():
|
||||
factorio_server_logger.info("Factorio server has exited.")
|
||||
ctx.exit_event.set()
|
||||
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
if not ctx.rcon_client and "Hosting game at IP ADDR:" in msg:
|
||||
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
# trigger lua interface confirmation
|
||||
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
|
||||
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
|
||||
ctx.rcon_client.send_command("/ap-sync")
|
||||
if not script_folder and "Write data path:" in msg:
|
||||
script_folder = msg.split("Write data path: ", 1)[1].split("[", 1)[0].strip()
|
||||
bridge_file = os.path.join(script_folder, "script-output", "ap_bridge.json")
|
||||
if os.path.exists(bridge_file):
|
||||
os.remove(bridge_file)
|
||||
logging.info(f"Bridge File Path: {bridge_file}")
|
||||
asyncio.create_task(game_watcher(ctx, bridge_file), name="FactorioProgressionWatcher")
|
||||
# TODO: remove around version 0.2
|
||||
if ctx.mod_version < Utils.Version(0, 1, 6):
|
||||
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
|
||||
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
|
||||
if not ctx.server:
|
||||
logger.info("Established bridge to Factorio Server. "
|
||||
"Ready to connect to Archipelago via /connect")
|
||||
|
||||
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
|
||||
ctx.awaiting_bridge = True
|
||||
|
||||
if ctx.rcon_client:
|
||||
commands = {}
|
||||
while ctx.send_index < len(ctx.items_received):
|
||||
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
|
||||
item_id = transfer_item.item
|
||||
player_name = ctx.player_names[transfer_item.player]
|
||||
if item_id not in lookup_id_to_name:
|
||||
logging.error(f"Cannot send unknown item ID: {item_id}")
|
||||
if item_id not in Factorio.item_id_to_name:
|
||||
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
|
||||
else:
|
||||
item_name = lookup_id_to_name[item_id]
|
||||
item_name = Factorio.item_id_to_name[item_id]
|
||||
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
|
||||
ctx.rcon_client.send_command(f'/ap-get-technology {item_name} {player_name}')
|
||||
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
|
||||
ctx.send_index += 1
|
||||
await asyncio.sleep(1)
|
||||
if commands:
|
||||
ctx.rcon_client.send_commands(commands)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
ctx.rcon_client = None
|
||||
ctx.exit_event.set()
|
||||
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
|
||||
|
||||
async def main():
|
||||
ctx = FactorioContext(None, None, True)
|
||||
# testing shortcuts
|
||||
# ctx.server_address = "localhost"
|
||||
# ctx.auth = "Nauvis"
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
await asyncio.sleep(3)
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
def get_info(ctx, rcon_client):
|
||||
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
|
||||
ctx.auth = info["slot_name"]
|
||||
ctx.seed_name = info["seed_name"]
|
||||
|
||||
|
||||
async def factorio_spinup_server(ctx: FactorioContext):
|
||||
savegame_name = os.path.abspath("Archipelago.zip")
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
subprocess.run((
|
||||
executable, "--create", savegame_name
|
||||
))
|
||||
factorio_process = subprocess.Popen(
|
||||
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
encoding="utf-8")
|
||||
factorio_server_logger.info("Started Information Exchange Factorio Server")
|
||||
factorio_queue = Queue()
|
||||
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
|
||||
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
|
||||
rcon_client = None
|
||||
try:
|
||||
while not ctx.auth:
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
|
||||
parts = msg.split()
|
||||
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
|
||||
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
get_info(ctx, rcon_client)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
if ctx.mod_version == ctx.__class__.mod_version:
|
||||
raise Exception("No Archipelago mod was loaded. Aborting.")
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
logger.info(f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
from kvui import FactorioManager
|
||||
ctx.ui = FactorioManager(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
|
||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||
await factorio_server_task
|
||||
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="FactorioProgressionWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
ctx.snes_reconnect_address = None
|
||||
|
||||
await asyncio.gather(input_task, factorio_server_task)
|
||||
|
||||
if ctx.server is not None and not ctx.server.socket.closed:
|
||||
await ctx.server.socket.close()
|
||||
if ctx.server_task is not None:
|
||||
await ctx.server_task
|
||||
await progression_watcher
|
||||
await factorio_server_task
|
||||
|
||||
if ctx.server and not ctx.server.socket.closed:
|
||||
await ctx.server.socket.close()
|
||||
if ctx.server_task:
|
||||
await ctx.server_task
|
||||
|
||||
while ctx.input_requests > 0:
|
||||
ctx.input_queue.put_nowait(None)
|
||||
ctx.input_requests -= 1
|
||||
|
||||
await input_task
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
colors = node["color"].split(";")
|
||||
for color in colors:
|
||||
if color in {"red", "green", "blue", "orange", "yellow", "pink", "purple", "white", "black", "gray",
|
||||
"brown", "cyan", "acid"}:
|
||||
node["text"] = f"[color={color}]{node['text']}[/color]"
|
||||
return self._handle_text(node)
|
||||
elif color == "magenta":
|
||||
node["text"] = f"[color=pink]{node['text']}[/color]"
|
||||
return self._handle_text(node)
|
||||
return self._handle_text(node)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Optional arguments to FactorioClient follow. "
|
||||
"Remaining arguments get passed into bound Factorio instance."
|
||||
"Refer to Factorio --help for those.")
|
||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
|
||||
if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
|
||||
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
rcon_port = args.rcon_port
|
||||
rcon_password = args.rcon_password if args.rcon_password else ''.join(random.choice(string.ascii_letters) for x in range(32))
|
||||
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
options = Utils.get_options()
|
||||
executable = options["factorio_options"]["executable"]
|
||||
|
||||
if not os.path.exists(os.path.dirname(executable)):
|
||||
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")
|
||||
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
|
||||
executable = os.path.join(executable, "factorio")
|
||||
if not os.path.isfile(executable):
|
||||
if os.path.isfile(executable + ".exe"):
|
||||
executable = executable + ".exe"
|
||||
else:
|
||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
|
||||
222
Fill.py
@@ -3,16 +3,16 @@ import typing
|
||||
import collections
|
||||
import itertools
|
||||
|
||||
from BaseClasses import CollectionState, PlandoItem, Location
|
||||
from worlds.alttp.Items import ItemFactory
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
from BaseClasses import CollectionState, Location, MultiWorld
|
||||
from worlds.generic import PlandoItem
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def fill_restrictive(world, base_state: CollectionState, locations, itempool, single_player_placement=False,
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool, single_player_placement=False,
|
||||
lock=False):
|
||||
def sweep_from_pool():
|
||||
new_state = base_state.copy()
|
||||
@@ -68,7 +68,7 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
|
||||
def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None):
|
||||
def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
|
||||
# If not passed in, then get a shuffled list of locations to fill in
|
||||
if not fill_locations:
|
||||
fill_locations = world.get_unfilled_locations()
|
||||
@@ -77,65 +77,32 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
|
||||
# get items to distribute
|
||||
world.random.shuffle(world.itempool)
|
||||
progitempool = []
|
||||
nonexcludeditempool = []
|
||||
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
||||
nonlocalrestitempool = []
|
||||
restitempool = []
|
||||
|
||||
for item in world.itempool:
|
||||
if item.advancement:
|
||||
progitempool.append(item)
|
||||
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
|
||||
nonexcludeditempool.append(item)
|
||||
elif item.name in world.local_items[item.player]:
|
||||
localrestitempool[item.player].append(item)
|
||||
elif item.name in world.non_local_items[item.player]:
|
||||
nonlocalrestitempool.append(item)
|
||||
else:
|
||||
restitempool.append(item)
|
||||
|
||||
standard_keyshuffle_players = set()
|
||||
|
||||
# fill in gtower locations with trash first
|
||||
for player in world.alttp_player_ids:
|
||||
if not gftower_trash or not world.ganonstower_vanilla[player] or \
|
||||
world.logic[player] in {'owglitches', "nologic"}:
|
||||
gtower_trash_count = 0
|
||||
elif 'triforcehunt' in world.goal[player] and ('local' in world.goal[player] or world.players == 1):
|
||||
gtower_trash_count = world.random.randint(world.crystals_needed_for_gt[player] * 2,
|
||||
world.crystals_needed_for_gt[player] * 4)
|
||||
else:
|
||||
gtower_trash_count = world.random.randint(0, world.crystals_needed_for_gt[player] * 2)
|
||||
|
||||
if gtower_trash_count:
|
||||
gtower_locations = [location for location in fill_locations if
|
||||
'Ganons Tower' in location.name and location.player == player]
|
||||
world.random.shuffle(gtower_locations)
|
||||
trashcnt = 0
|
||||
localrest = localrestitempool[player]
|
||||
if localrest:
|
||||
gt_item_pool = restitempool + localrest
|
||||
world.random.shuffle(gt_item_pool)
|
||||
else:
|
||||
gt_item_pool = restitempool.copy()
|
||||
|
||||
while gtower_locations and gt_item_pool and trashcnt < gtower_trash_count:
|
||||
spot_to_fill = gtower_locations.pop()
|
||||
item_to_place = gt_item_pool.pop()
|
||||
if item_to_place in localrest:
|
||||
localrest.remove(item_to_place)
|
||||
else:
|
||||
restitempool.remove(item_to_place)
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
fill_locations.remove(spot_to_fill)
|
||||
trashcnt += 1
|
||||
if world.mode[player] == 'standard' and world.keyshuffle[player] is True:
|
||||
standard_keyshuffle_players.add(player)
|
||||
|
||||
|
||||
# Make sure the escape small key is placed first in standard with key shuffle to prevent running out of spots
|
||||
if standard_keyshuffle_players:
|
||||
progitempool.sort(
|
||||
key=lambda item: 1 if item.name == 'Small Key (Hyrule Castle)' and
|
||||
item.player in standard_keyshuffle_players else 0)
|
||||
|
||||
world.random.shuffle(fill_locations)
|
||||
call_all(world, "fill_hook", progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
|
||||
|
||||
fill_restrictive(world, world.state, fill_locations, progitempool)
|
||||
|
||||
if nonexcludeditempool:
|
||||
world.random.shuffle(fill_locations)
|
||||
fill_restrictive(world, world.state, fill_locations, nonexcludeditempool) # needs logical fill to not conflict with local items
|
||||
|
||||
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
|
||||
local_locations = {player: [] for player in world.player_ids}
|
||||
for location in fill_locations:
|
||||
@@ -154,27 +121,32 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
|
||||
world.push_item(spot_to_fill, item_to_place, False)
|
||||
fill_locations.remove(spot_to_fill)
|
||||
|
||||
for item_to_place in nonlocalrestitempool:
|
||||
for i, location in enumerate(fill_locations):
|
||||
if location.player != item_to_place.player:
|
||||
world.push_item(fill_locations.pop(i), item_to_place, False)
|
||||
break
|
||||
else:
|
||||
logging.warning(f"Could not place non_local_item {item_to_place} among {fill_locations}, tossing.")
|
||||
|
||||
world.random.shuffle(fill_locations)
|
||||
|
||||
restitempool, fill_locations = fast_fill(world, restitempool, fill_locations)
|
||||
unplaced = [item for item in progitempool + restitempool]
|
||||
unplaced = progitempool + restitempool
|
||||
unfilled = [location.name for location in fill_locations]
|
||||
|
||||
for location in fill_locations:
|
||||
world.push_item(location, ItemFactory('Nothing', location.player), False)
|
||||
|
||||
if unplaced or unfilled:
|
||||
logging.warning(f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
|
||||
|
||||
|
||||
def fast_fill(world, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
|
||||
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
|
||||
placing = min(len(item_pool), len(fill_locations))
|
||||
for item, location in zip(item_pool, fill_locations):
|
||||
world.push_item(location, item, False)
|
||||
return item_pool[placing:], fill_locations[placing:]
|
||||
|
||||
|
||||
def flood_items(world):
|
||||
def flood_items(world: MultiWorld):
|
||||
# get items to distribute
|
||||
world.random.shuffle(world.itempool)
|
||||
itempool = world.itempool
|
||||
@@ -224,7 +196,7 @@ def flood_items(world):
|
||||
location_list = world.get_reachable_locations()
|
||||
world.random.shuffle(location_list)
|
||||
for location in location_list:
|
||||
if location.item is not None and not location.item.advancement and not location.item.smallkey and not location.item.bigkey:
|
||||
if location.item is not None and not location.item.advancement:
|
||||
# safe to replace
|
||||
replace_item = location.item
|
||||
replace_item.location = None
|
||||
@@ -234,7 +206,7 @@ def flood_items(world):
|
||||
break
|
||||
|
||||
|
||||
def balance_multiworld_progression(world):
|
||||
def balance_multiworld_progression(world: MultiWorld):
|
||||
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
|
||||
if not balanceable_players:
|
||||
logging.info('Skipping multiworld progression balancing.')
|
||||
@@ -347,7 +319,8 @@ def balance_multiworld_progression(world):
|
||||
if world.has_beaten_game(state):
|
||||
break
|
||||
elif not sphere_locations:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
logging.warning("Progression Balancing ran out of paths.")
|
||||
break
|
||||
|
||||
|
||||
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
|
||||
@@ -363,73 +336,78 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
|
||||
location_1.event, location_2.event = location_2.event, location_1.event
|
||||
|
||||
|
||||
def distribute_planned(world):
|
||||
def distribute_planned(world: MultiWorld):
|
||||
# TODO: remove. Preferably by implementing key drop
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
world_name_lookup = world.world_name_lookup
|
||||
|
||||
for player in world.player_ids:
|
||||
placement: PlandoItem
|
||||
for placement in world.plando_items[player]:
|
||||
if placement.location in key_drop_data:
|
||||
placement.warn(
|
||||
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
|
||||
continue
|
||||
item = ItemFactory(placement.item, player)
|
||||
target_world: int = placement.world
|
||||
if target_world is False or world.players == 1:
|
||||
target_world = player # in own world
|
||||
elif target_world is True: # in any other world
|
||||
unfilled = list(location for location in world.get_unfilled_locations_for_players(
|
||||
placement.location,
|
||||
set(world.player_ids) - {player}) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
try:
|
||||
placement: PlandoItem
|
||||
for placement in world.plando_items[player]:
|
||||
if placement.location in key_drop_data:
|
||||
placement.warn(
|
||||
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
|
||||
continue
|
||||
item = world.worlds[player].create_item(placement.item)
|
||||
target_world: int = placement.world
|
||||
if target_world is False or world.players == 1:
|
||||
target_world = player # in own world
|
||||
elif target_world is True: # in any other world
|
||||
unfilled = list(location for location in world.get_unfilled_locations_for_players(
|
||||
placement.location,
|
||||
set(world.player_ids) - {player}) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
||||
elif target_world is None: # any random world
|
||||
unfilled = list(location for location in world.get_unfilled_locations_for_players(
|
||||
placement.location,
|
||||
set(world.player_ids)) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
||||
elif type(target_world) == int: # target world by player id
|
||||
if target_world not in range(1, world.players + 1):
|
||||
placement.failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||
ValueError)
|
||||
continue
|
||||
else: # find world by name
|
||||
if target_world not in world_name_lookup:
|
||||
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
ValueError)
|
||||
continue
|
||||
target_world = world_name_lookup[target_world]
|
||||
|
||||
location = world.get_location(placement.location, target_world)
|
||||
if location.item:
|
||||
placement.failed(f"Cannot place item into already filled location {location}.")
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
||||
elif target_world is None: # any random world
|
||||
unfilled = list(location for location in world.get_unfilled_locations_for_players(
|
||||
placement.location,
|
||||
set(world.player_ids)) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
if location.can_fill(world.state, item, False):
|
||||
world.push_item(location, item, collect=False)
|
||||
location.event = True # flag location to be checked during fill
|
||||
location.locked = True
|
||||
logging.debug(f"Plando placed {item} at {location}")
|
||||
else:
|
||||
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
||||
elif type(target_world) == int: # target world by player id
|
||||
if target_world not in range(1, world.players + 1):
|
||||
placement.failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||
ValueError)
|
||||
continue
|
||||
else: # find world by name
|
||||
if target_world not in world_name_lookup:
|
||||
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
ValueError)
|
||||
continue
|
||||
target_world = world_name_lookup[target_world]
|
||||
|
||||
location = world.get_location(placement.location, target_world)
|
||||
if location.item:
|
||||
placement.failed(f"Cannot place item into already filled location {location}.")
|
||||
continue
|
||||
|
||||
if location.can_fill(world.state, item, False):
|
||||
world.push_item(location, item, collect=False)
|
||||
location.event = True # flag location to be checked during fill
|
||||
location.locked = True
|
||||
logging.debug(f"Plando placed {item} at {location}")
|
||||
else:
|
||||
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
|
||||
continue
|
||||
|
||||
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
|
||||
try:
|
||||
world.itempool.remove(item)
|
||||
except ValueError:
|
||||
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
|
||||
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
|
||||
try:
|
||||
world.itempool.remove(item)
|
||||
except ValueError:
|
||||
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
|
||||
except Exception as e:
|
||||
raise Exception(f"Error running plando for player {player} ({world.player_name[player]})") from e
|
||||
|
||||
738
Generate.py
Normal file
@@ -0,0 +1,738 @@
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import typing
|
||||
import os
|
||||
from collections import Counter
|
||||
import string
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoItem, PlandoConnection
|
||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from Main import get_seed, seeddigits
|
||||
import Options
|
||||
from worlds.alttp.Items import item_table
|
||||
from worlds.alttp import Bosses
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.alttp.Regions import location_table, key_drop_data
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
|
||||
def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
|
||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||
action='store_true')
|
||||
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
|
||||
help="Input directory for player files.")
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: min(max(int(value), 1), 255))
|
||||
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
|
||||
parser.add_argument('--rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
|
||||
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
|
||||
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
|
||||
parser.add_argument('--race', action='store_true', default=defaults["race"])
|
||||
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
|
||||
parser.add_argument('--log_output_path', help='Path to store output log')
|
||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default=defaults["plando_options"],
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
args = parser.parse_args()
|
||||
if not os.path.isabs(args.weights_file_path):
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
return args, options
|
||||
|
||||
|
||||
def get_seed_name(random):
|
||||
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
if not args:
|
||||
args, options = mystery_argparse()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
random.seed(seed)
|
||||
seed_name = get_seed_name(random)
|
||||
|
||||
if args.race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache = {}
|
||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
|
||||
|
||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||
try:
|
||||
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||
meta_weights = weights_cache[args.meta_file_path]
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
else:
|
||||
meta_weights = None
|
||||
player_id = 1
|
||||
player_files = {}
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yaml(path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
print(f"P{player_id} Weights: {fname} >> "
|
||||
f"{get_choice('description', weights_cache[fname], 'No description specified')}")
|
||||
player_files[player_id] = fname
|
||||
player_id += 1
|
||||
|
||||
args.multi = max(player_id-1, args.multi)
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed}")
|
||||
|
||||
if not weights_cache:
|
||||
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
|
||||
f"A mix is also permitted.")
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.create_spoiler = args.spoiler > 0
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.race = args.race
|
||||
erargs.skip_playthrough = args.spoiler < 2
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
|
||||
# set up logger
|
||||
if args.log_level:
|
||||
erargs.loglevel = args.log_level
|
||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||
erargs.loglevel]
|
||||
|
||||
if args.log_output_path:
|
||||
os.makedirs(args.log_output_path, exist_ok=True)
|
||||
logging.basicConfig(format='%(message)s', level=loglevel, force=True,
|
||||
filename=os.path.join(args.log_output_path, f"{seed}.log"))
|
||||
else:
|
||||
logging.basicConfig(format='%(message)s', level=loglevel, force=True)
|
||||
|
||||
erargs.rom = args.rom
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||
for k, v in weights_cache.items()}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
|
||||
if meta_weights:
|
||||
for player, path in player_path_cache.items():
|
||||
weights_cache[path].setdefault("meta_ignore", [])
|
||||
for key in meta_weights:
|
||||
option = get_choice(key, meta_weights)
|
||||
if option is not None:
|
||||
for player, path in player_path_cache.items():
|
||||
players_meta = weights_cache[path].get("meta_ignore", [])
|
||||
if key not in players_meta:
|
||||
weights_cache[path][key] = option
|
||||
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]:
|
||||
weights_cache[path][key] = option
|
||||
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings = settings_cache[path] if settings_cache[path] else \
|
||||
roll_settings(weights_cache[path], args.plando)
|
||||
for k, v in vars(settings).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {erargs.name}")
|
||||
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
elif len(player_settings.values()) > 0:
|
||||
important[option] = player_settings[1]
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yaml(path):
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme:
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
|
||||
else:
|
||||
with open(path, 'rb') as f:
|
||||
yaml = str(f.read(), "utf-8")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
return parse_yaml(yaml)
|
||||
|
||||
|
||||
def interpret_on_off(value):
|
||||
return {"on": True, "off": False}.get(value, value)
|
||||
|
||||
|
||||
def convert_to_on_off(value):
|
||||
return {True: "on", False: "off"}.get(value, value)
|
||||
|
||||
|
||||
def get_choice_legacy(option, root, value=None) -> typing.Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
return interpret_on_off(random.choices(root[option])[0])
|
||||
if type(root[option]) is not dict:
|
||||
return interpret_on_off(root[option])
|
||||
if not root[option]:
|
||||
return value
|
||||
if any(root[option].values()):
|
||||
return interpret_on_off(
|
||||
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
def get_choice(option, root, value=None) -> typing.Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
return random.choices(root[option])[0]
|
||||
if type(root[option]) is not dict:
|
||||
return root[option]
|
||||
if not root[option]:
|
||||
return value
|
||||
if any(root[option].values()):
|
||||
return random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0]
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key):
|
||||
return '{' + key + '}'
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name_counter[name] += 1
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||
NUMBER=(name_counter[name] if name_counter[
|
||||
name] > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip()[:16]
|
||||
if new_name == "Archipelago":
|
||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> typing.Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
'none': 'none',
|
||||
'basic': 'basic',
|
||||
'full': 'full',
|
||||
'chaos': 'chaos',
|
||||
'singularity': 'singularity'
|
||||
}
|
||||
|
||||
goals = {
|
||||
'ganon': 'ganon',
|
||||
'crystals': 'crystals',
|
||||
'bosses': 'bosses',
|
||||
'pedestal': 'pedestal',
|
||||
'ganon_pedestal': 'ganonpedestal',
|
||||
'triforce_hunt': 'triforcehunt',
|
||||
'local_triforce_hunt': 'localtriforcehunt',
|
||||
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||
'ice_rod_hunt': 'icerodhunt',
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
|
||||
logging.debug(f'Applying {new_weights}')
|
||||
new_options = set(new_weights) - set(weights)
|
||||
weights.update(new_weights)
|
||||
if new_options:
|
||||
for new_option in new_options:
|
||||
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
|
||||
f'overwrite a root option. '
|
||||
f'This is probably in error.')
|
||||
return weights
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
|
||||
for option_set in weights["linked_options"]:
|
||||
if "name" not in option_set:
|
||||
raise ValueError("One of your linked options does not have a name.")
|
||||
try:
|
||||
if roll_percentage(option_set["percentage"]):
|
||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||
new_options = option_set["options"]
|
||||
for category_name, category_options in new_options.items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Linked", option_set["name"])
|
||||
else:
|
||||
logging.debug(f"linked option {option_set['name']} skipped.")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
|
||||
f"Please fix your linked option.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def roll_triggers(weights: dict) -> dict:
|
||||
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
|
||||
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
|
||||
for i, option_set in enumerate(weights["triggers"]):
|
||||
try:
|
||||
currently_targeted_weights = weights
|
||||
category = option_set.get("option_category", None)
|
||||
if category:
|
||||
currently_targeted_weights = currently_targeted_weights[category]
|
||||
key = get_choice("option_name", option_set)
|
||||
if key not in currently_targeted_weights:
|
||||
logging.warning(f'Specified option name {option_set["option_name"]} did not '
|
||||
f'match with a root option. '
|
||||
f'This is probably in error.')
|
||||
trigger_result = get_choice("option_result", option_set)
|
||||
result = get_choice(key, currently_targeted_weights)
|
||||
currently_targeted_weights[key] = result
|
||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
for category_name, category_options in option_set["options"].items():
|
||||
currently_targeted_weights = weights
|
||||
if category_name:
|
||||
currently_targeted_weights = currently_targeted_weights[category_name]
|
||||
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
|
||||
f"Please fix your triggers.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif "bosses" in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
for boss in options:
|
||||
if boss in boss_shuffle_options:
|
||||
remainder_shuffle = boss_shuffle_options[boss]
|
||||
elif "-" in boss:
|
||||
loc, boss_name = boss.split("-")
|
||||
if boss_name not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||
if loc not in available_boss_locations:
|
||||
raise ValueError(f"Unknown Boss Location {loc}")
|
||||
level = ''
|
||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||
# split off level
|
||||
loc = loc.split(" ")
|
||||
level = f" {loc[-1]}"
|
||||
loc = " ".join(loc[:-1])
|
||||
loc = loc.title().replace("Of", "of")
|
||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||
bosses.append(boss)
|
||||
elif boss not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||
else:
|
||||
bosses.append(boss)
|
||||
return ";".join(bosses + [remainder_shuffle])
|
||||
else:
|
||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights)
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
version = requirements.get("version", __version__)
|
||||
if tuplize_version(version) > version_tuple:
|
||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||
f"however generator is of version {__version__}")
|
||||
required_plando_options = requirements.get("plando", "")
|
||||
if required_plando_options:
|
||||
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
|
||||
required_plando_options -= plando_options
|
||||
if required_plando_options:
|
||||
if len(required_plando_options) == 1:
|
||||
raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
|
||||
f"which is not enabled.")
|
||||
else:
|
||||
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
|
||||
f"which are not enabled.")
|
||||
|
||||
ret = argparse.Namespace()
|
||||
ret.name = get_choice('name', weights)
|
||||
ret.accessibility = get_choice('accessibility', weights)
|
||||
ret.progression_balancing = get_choice('progression_balancing', weights, True)
|
||||
ret.game = get_choice("game", weights)
|
||||
if ret.game not in weights:
|
||||
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
|
||||
world_type = AutoWorldRegister.world_types[ret.game]
|
||||
game_weights = weights[ret.game]
|
||||
ret.local_items = set()
|
||||
for item_name in game_weights.get('local_items', []):
|
||||
items = world_type.item_name_groups.get(item_name, {item_name})
|
||||
for item in items:
|
||||
if item in world_type.item_names:
|
||||
ret.local_items.add(item)
|
||||
else:
|
||||
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
|
||||
|
||||
ret.non_local_items = set()
|
||||
for item_name in game_weights.get('non_local_items', []):
|
||||
items = world_type.item_name_groups.get(item_name, {item_name})
|
||||
for item in items:
|
||||
if item in world_type.item_names:
|
||||
ret.non_local_items.add(item)
|
||||
else:
|
||||
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
|
||||
|
||||
inventoryweights = game_weights.get('start_inventory', {})
|
||||
startitems = []
|
||||
for item in inventoryweights.keys():
|
||||
itemvalue = get_choice_legacy(item, inventoryweights)
|
||||
if isinstance(itemvalue, int):
|
||||
for i in range(int(itemvalue)):
|
||||
startitems.append(item)
|
||||
elif itemvalue:
|
||||
startitems.append(item)
|
||||
ret.startinventory = startitems
|
||||
ret.start_hints = set(game_weights.get('start_hints', []))
|
||||
|
||||
ret.excluded_locations = set()
|
||||
for location in game_weights.get('exclude_locations', []):
|
||||
if location in world_type.location_names:
|
||||
ret.excluded_locations.add(location)
|
||||
else:
|
||||
raise Exception(f"Could not exclude location {location}, as it was not recognized.")
|
||||
|
||||
if ret.game in AutoWorldRegister.world_types:
|
||||
for option_name, option in AutoWorldRegister.world_types[ret.game].options.items():
|
||||
if option_name in game_weights:
|
||||
try:
|
||||
if issubclass(option, Options.OptionDict) or issubclass(option, Options.OptionList):
|
||||
setattr(ret, option_name, option.from_any(game_weights[option_name]))
|
||||
else:
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_name} in {ret.game}") from e
|
||||
else:
|
||||
setattr(ret, option_name, option(option.default))
|
||||
if ret.game == "Minecraft":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement, "both")
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
return ret
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
|
||||
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
|
||||
glitches_required = get_choice_legacy('glitches_required', weights)
|
||||
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
|
||||
logging.warning("Only NMG, OWG, HMG and No Logic supported")
|
||||
glitches_required = 'none'
|
||||
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
|
||||
glitches_required]
|
||||
|
||||
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
|
||||
if not ret.dark_room_logic: # None/False
|
||||
ret.dark_room_logic = "none"
|
||||
if ret.dark_room_logic == "sconces":
|
||||
ret.dark_room_logic = "torches"
|
||||
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
|
||||
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
|
||||
|
||||
ret.restrict_dungeon_item_on_boss = get_choice_legacy('restrict_dungeon_item_on_boss', weights, False)
|
||||
|
||||
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
|
||||
if entrance_shuffle.startswith('none-'):
|
||||
ret.shuffle = 'vanilla'
|
||||
else:
|
||||
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
|
||||
|
||||
goal = get_choice_legacy('goals', weights, 'ganon')
|
||||
|
||||
ret.goal = goals[goal]
|
||||
|
||||
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
|
||||
# fast ganon + ganon at hole
|
||||
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
|
||||
|
||||
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
|
||||
|
||||
# sum a percentage to required
|
||||
if extra_pieces == 'percentage':
|
||||
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
|
||||
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
|
||||
# vanilla mode (specify how many pieces are)
|
||||
elif extra_pieces == 'available':
|
||||
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
|
||||
get_choice_legacy('triforce_pieces_available', weights, 30))
|
||||
# required pieces + fixed extra
|
||||
elif extra_pieces == 'extra':
|
||||
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
|
||||
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
|
||||
|
||||
# change minimum to required pieces to avoid problems
|
||||
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
|
||||
|
||||
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
|
||||
if not ret.shop_shuffle:
|
||||
ret.shop_shuffle = ''
|
||||
|
||||
ret.mode = get_choice_legacy("mode", weights)
|
||||
ret.retro = get_choice_legacy("retro", weights)
|
||||
|
||||
ret.hints = get_choice_legacy('hints', weights)
|
||||
|
||||
ret.swordless = get_choice_legacy('swordless', weights, False)
|
||||
|
||||
ret.difficulty = get_choice_legacy('item_pool', weights)
|
||||
|
||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||
|
||||
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
|
||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||
|
||||
ret.enemy_shuffle = bool(get_choice_legacy('enemy_shuffle', weights, False))
|
||||
|
||||
ret.killable_thieves = get_choice_legacy('killable_thieves', weights, False)
|
||||
ret.tile_shuffle = get_choice_legacy('tile_shuffle', weights, False)
|
||||
ret.bush_shuffle = get_choice_legacy('bush_shuffle', weights, False)
|
||||
|
||||
ret.enemy_damage = {None: 'default',
|
||||
'default': 'default',
|
||||
'shuffled': 'shuffled',
|
||||
'random': 'chaos', # to be removed
|
||||
'chaos': 'chaos',
|
||||
}[get_choice_legacy('enemy_damage', weights)]
|
||||
|
||||
ret.enemy_health = get_choice_legacy('enemy_health', weights)
|
||||
|
||||
ret.shufflepots = get_choice_legacy('pot_shuffle', weights)
|
||||
|
||||
ret.beemizer = int(get_choice_legacy('beemizer', weights, 0))
|
||||
|
||||
ret.timer = {'none': False,
|
||||
None: False,
|
||||
False: False,
|
||||
'timed': 'timed',
|
||||
'timed_ohko': 'timed-ohko',
|
||||
'ohko': 'ohko',
|
||||
'timed_countdown': 'timed-countdown',
|
||||
'display': 'display'}[get_choice_legacy('timer', weights, False)]
|
||||
|
||||
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
|
||||
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
|
||||
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
|
||||
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
|
||||
|
||||
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
|
||||
|
||||
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
|
||||
|
||||
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
|
||||
get_choice_legacy("turtle_rock_medallion", weights, "random")]
|
||||
|
||||
for index, medallion in enumerate(ret.required_medallions):
|
||||
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||
.get(medallion.lower(), None)
|
||||
if not ret.required_medallions[index]:
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_items = []
|
||||
if "items" in plando_options:
|
||||
|
||||
def add_plando_item(item: str, location: str):
|
||||
if item not in item_table:
|
||||
raise Exception(f"Could not plando item {item} as the item was not recognized")
|
||||
if location not in location_table and location not in key_drop_data:
|
||||
raise Exception(
|
||||
f"Could not plando item {item} at location {location} as the location was not recognized")
|
||||
ret.plando_items.append(PlandoItem(item, location, location_world, from_pool, force))
|
||||
|
||||
options = weights.get("plando_items", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
from_pool = get_choice_legacy("from_pool", placement, PlandoItem._field_defaults["from_pool"])
|
||||
location_world = get_choice_legacy("world", placement, PlandoItem._field_defaults["world"])
|
||||
force = str(get_choice_legacy("force", placement, PlandoItem._field_defaults["force"])).lower()
|
||||
if "items" in placement and "locations" in placement:
|
||||
items = placement["items"]
|
||||
locations = placement["locations"]
|
||||
if isinstance(items, dict):
|
||||
item_list = []
|
||||
for key, value in items.items():
|
||||
item_list += [key] * value
|
||||
items = item_list
|
||||
if not items or not locations:
|
||||
raise Exception("You must specify at least one item and one location to place items.")
|
||||
random.shuffle(items)
|
||||
random.shuffle(locations)
|
||||
for item, location in zip(items, locations):
|
||||
add_plando_item(item, location)
|
||||
else:
|
||||
item = get_choice_legacy("item", placement, get_choice_legacy("items", placement))
|
||||
location = get_choice_legacy("location", placement)
|
||||
add_plando_item(item, location)
|
||||
|
||||
ret.plando_texts = {}
|
||||
if "texts" in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
at = str(get_choice_legacy("at", placement))
|
||||
if at not in tt:
|
||||
raise Exception(f"No text target \"{at}\" found.")
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice_legacy("entrance", placement),
|
||||
get_choice_legacy("exit", placement),
|
||||
get_choice_legacy("direction", placement, "both")
|
||||
))
|
||||
|
||||
ret.sprite_pool = weights.get('sprite_pool', [])
|
||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||
if 'random_sprite_on_event' in weights:
|
||||
randomoneventweights = weights['random_sprite_on_event']
|
||||
if get_choice_legacy('enabled', randomoneventweights, False):
|
||||
ret.sprite = 'randomon'
|
||||
ret.sprite += '-hit' if get_choice_legacy('on_hit', randomoneventweights, True) else ''
|
||||
ret.sprite += '-enter' if get_choice_legacy('on_enter', randomoneventweights, False) else ''
|
||||
ret.sprite += '-exit' if get_choice_legacy('on_exit', randomoneventweights, False) else ''
|
||||
ret.sprite += '-slash' if get_choice_legacy('on_slash', randomoneventweights, False) else ''
|
||||
ret.sprite += '-item' if get_choice_legacy('on_item', randomoneventweights, False) else ''
|
||||
ret.sprite += '-bonk' if get_choice_legacy('on_bonk', randomoneventweights, False) else ''
|
||||
ret.sprite = 'randomonall' if get_choice_legacy('on_everything', randomoneventweights, False) else ret.sprite
|
||||
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
|
||||
|
||||
if (not ret.sprite_pool or get_choice_legacy('use_weighted_sprite_pool', randomoneventweights, False)) \
|
||||
and 'sprite' in weights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
|
||||
for key, value in weights['sprite'].items():
|
||||
if key.startswith('random'):
|
||||
ret.sprite_pool += ['random'] * int(value)
|
||||
else:
|
||||
ret.sprite_pool += [key] * int(value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import atexit
|
||||
confirmation = atexit.register(input, "Press enter to close.")
|
||||
main()
|
||||
# in case of error-free exit should not need confirmation
|
||||
atexit.unregister(confirmation)
|
||||
194
GuiUtils.py
@@ -1,194 +0,0 @@
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
|
||||
from Utils import local_path
|
||||
|
||||
def set_icon(window):
|
||||
er16 = tk.PhotoImage(file=local_path('data', 'ER16.gif'))
|
||||
er32 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
|
||||
er48 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
|
||||
window.tk.call('wm', 'iconphoto', window._w, er16, er32, er48) # pylint: disable=protected-access
|
||||
|
||||
# Although tkinter is intended to be thread safe, there are many reports of issues
|
||||
# some which may be platform specific, or depend on if the TCL library was compiled without
|
||||
# multithreading support. Therefore I will assume it is not thread safe to avoid any possible problems
|
||||
class BackgroundTask(object):
|
||||
def __init__(self, window, code_to_run, *args):
|
||||
self.window = window
|
||||
self.queue = queue.Queue()
|
||||
self.running = True
|
||||
self.process_queue()
|
||||
self.task = threading.Thread(target=code_to_run, args=(self, *args))
|
||||
self.task.start()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
# safe to call from worker
|
||||
def queue_event(self, event):
|
||||
self.queue.put(event)
|
||||
|
||||
def process_queue(self):
|
||||
try:
|
||||
while True:
|
||||
if not self.running:
|
||||
return
|
||||
event = self.queue.get_nowait()
|
||||
event()
|
||||
if self.running:
|
||||
#if self is no longer running self.window may no longer be valid
|
||||
self.window.update_idletasks()
|
||||
except queue.Empty:
|
||||
pass
|
||||
if self.running:
|
||||
self.window.after(100, self.process_queue)
|
||||
|
||||
class BackgroundTaskProgress(BackgroundTask):
|
||||
def __init__(self, parent, code_to_run, title, *args):
|
||||
self.parent = parent
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window['padx'] = 5
|
||||
self.window['pady'] = 5
|
||||
|
||||
try:
|
||||
self.window.attributes("-toolwindow", 1)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
self.window.wm_title(title)
|
||||
self.label_var = tk.StringVar()
|
||||
self.label_var.set("")
|
||||
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
|
||||
self.label.pack()
|
||||
self.window.resizable(width=False, height=False)
|
||||
|
||||
set_icon(self.window)
|
||||
self.window.focus()
|
||||
super().__init__(self.window, code_to_run, *args)
|
||||
|
||||
#safe to call from worker thread
|
||||
def update_status(self, text):
|
||||
self.queue_event(lambda: self.label_var.set(text))
|
||||
|
||||
# only call this in an event callback
|
||||
def close_window(self):
|
||||
self.stop()
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
|
||||
class ToolTips(object):
|
||||
# This class derived from wckToolTips which is available under the following license:
|
||||
|
||||
# Copyright (c) 1998-2007 by Secret Labs AB
|
||||
# Copyright (c) 1998-2007 by Fredrik Lundh
|
||||
#
|
||||
# By obtaining, using, and/or copying this software and/or its
|
||||
# associated documentation, you agree that you have read, understood,
|
||||
# and will comply with the following terms and conditions:
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and its
|
||||
# associated documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appears in all
|
||||
# copies, and that both that copyright notice and this permission notice
|
||||
# appear in supporting documentation, and that the name of Secret Labs
|
||||
# AB or the author not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
label = None
|
||||
window = None
|
||||
active = 0
|
||||
tag = None
|
||||
after_id = None
|
||||
|
||||
@classmethod
|
||||
def getcontroller(cls, widget):
|
||||
if cls.tag is None:
|
||||
|
||||
cls.tag = "ui_tooltip_%d" % id(cls)
|
||||
widget.bind_class(cls.tag, "<Enter>", cls.enter)
|
||||
widget.bind_class(cls.tag, "<Leave>", cls.leave)
|
||||
widget.bind_class(cls.tag, "<Motion>", cls.motion)
|
||||
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
|
||||
|
||||
# pick suitable colors for tooltips
|
||||
try:
|
||||
cls.bg = "systeminfobackground"
|
||||
cls.fg = "systeminfotext"
|
||||
widget.winfo_rgb(cls.fg) # make sure system colors exist
|
||||
widget.winfo_rgb(cls.bg)
|
||||
except Exception:
|
||||
cls.bg = "#ffffe0"
|
||||
cls.fg = "black"
|
||||
|
||||
return cls.tag
|
||||
|
||||
@classmethod
|
||||
def register(cls, widget, text):
|
||||
widget.ui_tooltip_text = text
|
||||
tags = list(widget.bindtags())
|
||||
tags.append(cls.getcontroller(widget))
|
||||
widget.bindtags(tuple(tags))
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, widget):
|
||||
tags = list(widget.bindtags())
|
||||
tags.remove(cls.getcontroller(widget))
|
||||
widget.bindtags(tuple(tags))
|
||||
|
||||
# event handlers
|
||||
@classmethod
|
||||
def enter(cls, event):
|
||||
widget = event.widget
|
||||
if not cls.label:
|
||||
# create and hide balloon help window
|
||||
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
|
||||
cls.popup.overrideredirect(1)
|
||||
cls.popup.withdraw()
|
||||
cls.label = tk.Label(
|
||||
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
|
||||
)
|
||||
cls.label.pack()
|
||||
cls.active = 0
|
||||
cls.xy = event.x_root + 16, event.y_root + 10
|
||||
cls.event_xy = event.x, event.y
|
||||
cls.after_id = widget.after(200, cls.display, widget)
|
||||
|
||||
@classmethod
|
||||
def motion(cls, event):
|
||||
cls.xy = event.x_root + 16, event.y_root + 10
|
||||
cls.event_xy = event.x, event.y
|
||||
|
||||
@classmethod
|
||||
def display(cls, widget):
|
||||
if not cls.active:
|
||||
# display balloon help window
|
||||
text = widget.ui_tooltip_text
|
||||
if callable(text):
|
||||
text = text(widget, cls.event_xy)
|
||||
cls.label.config(text=text)
|
||||
cls.popup.deiconify()
|
||||
cls.popup.lift()
|
||||
cls.popup.geometry("+%d+%d" % cls.xy)
|
||||
cls.active = 1
|
||||
cls.after_id = None
|
||||
|
||||
@classmethod
|
||||
def leave(cls, event):
|
||||
widget = event.widget
|
||||
if cls.active:
|
||||
cls.popup.withdraw()
|
||||
cls.active = 0
|
||||
if cls.after_id:
|
||||
widget.after_cancel(cls.after_id)
|
||||
cls.after_id = None
|
||||
895
LttPAdjuster.py
@@ -1,24 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import queue
|
||||
import random
|
||||
import shutil
|
||||
import textwrap
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from tkinter import Tk
|
||||
import tkinter as tk
|
||||
from argparse import Namespace
|
||||
from concurrent.futures import as_completed, ThreadPoolExecutor
|
||||
from glob import glob
|
||||
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, LEFT, X, TOP, LabelFrame, \
|
||||
IntVar, Checkbutton, E, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
from Gui import update_sprites
|
||||
from GuiUtils import BackgroundTaskProgress
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings
|
||||
from Utils import output_path
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, open_file
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.rom_seeds = {1: random}
|
||||
self.slot_seeds = {1: random}
|
||||
|
||||
|
||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
@@ -35,7 +44,7 @@ def main():
|
||||
help='Path to an ALttP JAP(1.0) rom to use as a base.')
|
||||
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
|
||||
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
|
||||
parser.add_argument('--fastmenu', default='normal', const='normal', nargs='?',
|
||||
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
|
||||
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
|
||||
help='''\
|
||||
Select the rate at which the menu opens and closes.
|
||||
@@ -91,6 +100,7 @@ def main():
|
||||
parser.add_argument('--names', default='', type=str)
|
||||
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
|
||||
args = parser.parse_args()
|
||||
args.music = not args.disablemusic
|
||||
if args.update_sprites:
|
||||
run_sprite_update()
|
||||
sys.exit()
|
||||
@@ -141,7 +151,7 @@ def adjust(args):
|
||||
if hasattr(args, "world"):
|
||||
world = getattr(args, "world")
|
||||
|
||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.fastmenu, args.disablemusic,
|
||||
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
|
||||
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world)
|
||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||
rom.write_to_file(path)
|
||||
@@ -153,10 +163,8 @@ def adjust(args):
|
||||
|
||||
|
||||
def adjustGUI():
|
||||
from tkinter import Checkbutton, OptionMenu, Toplevel, LabelFrame, PhotoImage, Tk, LEFT, RIGHT, BOTTOM, TOP, \
|
||||
StringVar, IntVar, Frame, Label, W, E, X, BOTH, Entry, Spinbox, Button, filedialog, messagebox, ttk
|
||||
from Gui import get_rom_options_frame, get_rom_frame
|
||||
from GuiUtils import set_icon
|
||||
from tkinter import Tk, LEFT, BOTTOM, TOP, \
|
||||
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
|
||||
from argparse import Namespace
|
||||
from Main import __version__ as MWVersion
|
||||
adjustWindow = Tk()
|
||||
@@ -188,14 +196,14 @@ def adjustGUI():
|
||||
guiargs = Namespace()
|
||||
guiargs.heartbeep = rom_vars.heartbeepVar.get()
|
||||
guiargs.heartcolor = rom_vars.heartcolorVar.get()
|
||||
guiargs.fastmenu = rom_vars.fastMenuVar.get()
|
||||
guiargs.menuspeed = rom_vars.menuspeedVar.get()
|
||||
guiargs.ow_palettes = rom_vars.owPalettesVar.get()
|
||||
guiargs.uw_palettes = rom_vars.uwPalettesVar.get()
|
||||
guiargs.hud_palettes = rom_vars.hudPalettesVar.get()
|
||||
guiargs.sword_palettes = rom_vars.swordPalettesVar.get()
|
||||
guiargs.shield_palettes = rom_vars.shieldPalettesVar.get()
|
||||
guiargs.quickswap = bool(rom_vars.quickSwapVar.get())
|
||||
guiargs.disablemusic = bool(rom_vars.disableMusicVar.get())
|
||||
guiargs.music = bool(rom_vars.MusicVar.get())
|
||||
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
|
||||
guiargs.rom = romVar2.get()
|
||||
guiargs.baserom = romVar.get()
|
||||
@@ -214,7 +222,6 @@ def adjustGUI():
|
||||
else:
|
||||
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
|
||||
from Utils import persistent_store
|
||||
from worlds.alttp.Rom import Sprite
|
||||
if isinstance(guiargs.sprite, Sprite):
|
||||
guiargs.sprite = guiargs.sprite.name
|
||||
persistent_store("adjuster", "last_settings_3", guiargs)
|
||||
@@ -239,5 +246,859 @@ def run_sprite_update():
|
||||
print("Done updating sprites")
|
||||
|
||||
|
||||
def update_sprites(task, on_finish=None):
|
||||
resultmessage = ""
|
||||
successful = True
|
||||
sprite_dir = local_path("data", "sprites", "alttpr")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
|
||||
def finished():
|
||||
task.close_window()
|
||||
if on_finish:
|
||||
on_finish(successful, resultmessage)
|
||||
|
||||
try:
|
||||
task.update_status("Downloading alttpr sprites list")
|
||||
with urlopen('https://alttpr.com/sprites') as response:
|
||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||
successful = False
|
||||
task.queue_event(finished)
|
||||
return
|
||||
|
||||
try:
|
||||
task.update_status("Determining needed sprites")
|
||||
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if filename not in current_sprites]
|
||||
|
||||
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
||||
except Exception as e:
|
||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||
successful = False
|
||||
task.queue_event(finished)
|
||||
return
|
||||
|
||||
|
||||
def dl(sprite_url, filename):
|
||||
target = os.path.join(sprite_dir, filename)
|
||||
with urlopen(sprite_url) as response, open(target, 'wb') as out:
|
||||
shutil.copyfileobj(response, out)
|
||||
|
||||
def rem(sprite):
|
||||
os.remove(os.path.join(sprite_dir, sprite))
|
||||
|
||||
|
||||
with ThreadPoolExecutor() as pool:
|
||||
dl_tasks = []
|
||||
rem_tasks = []
|
||||
|
||||
for (sprite_url, filename) in needed_sprites:
|
||||
dl_tasks.append(pool.submit(dl, sprite_url, filename))
|
||||
|
||||
for sprite in obsolete_sprites:
|
||||
rem_tasks.append(pool.submit(rem, sprite))
|
||||
|
||||
deleted = 0
|
||||
updated = 0
|
||||
|
||||
for dl_task in as_completed(dl_tasks):
|
||||
updated += 1
|
||||
task.update_status("Downloading needed sprite %g/%g" % (updated, len(needed_sprites)))
|
||||
try:
|
||||
dl_task.result()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
|
||||
type(e).__name__, e)
|
||||
successful = False
|
||||
|
||||
for rem_task in as_completed(rem_tasks):
|
||||
deleted += 1
|
||||
task.update_status("Removing obsolete sprite %g/%g" % (deleted, len(obsolete_sprites)))
|
||||
try:
|
||||
rem_task.result()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
|
||||
type(e).__name__, e)
|
||||
successful = False
|
||||
|
||||
if successful:
|
||||
resultmessage = "alttpr sprites updated successfully"
|
||||
|
||||
task.queue_event(finished)
|
||||
|
||||
|
||||
def set_icon(window):
|
||||
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
|
||||
window.tk.call('wm', 'iconphoto', window._w, logo)
|
||||
|
||||
|
||||
class BackgroundTask(object):
|
||||
def __init__(self, window, code_to_run, *args):
|
||||
self.window = window
|
||||
self.queue = queue.Queue()
|
||||
self.running = True
|
||||
self.process_queue()
|
||||
self.task = threading.Thread(target=code_to_run, args=(self, *args))
|
||||
self.task.start()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
# safe to call from worker
|
||||
def queue_event(self, event):
|
||||
self.queue.put(event)
|
||||
|
||||
def process_queue(self):
|
||||
try:
|
||||
while True:
|
||||
if not self.running:
|
||||
return
|
||||
event = self.queue.get_nowait()
|
||||
event()
|
||||
if self.running:
|
||||
#if self is no longer running self.window may no longer be valid
|
||||
self.window.update_idletasks()
|
||||
except queue.Empty:
|
||||
pass
|
||||
if self.running:
|
||||
self.window.after(100, self.process_queue)
|
||||
|
||||
|
||||
class BackgroundTaskProgress(BackgroundTask):
|
||||
def __init__(self, parent, code_to_run, title, *args):
|
||||
self.parent = parent
|
||||
self.window = tk.Toplevel(parent)
|
||||
self.window['padx'] = 5
|
||||
self.window['pady'] = 5
|
||||
|
||||
try:
|
||||
self.window.attributes("-toolwindow", 1)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
self.window.wm_title(title)
|
||||
self.label_var = tk.StringVar()
|
||||
self.label_var.set("")
|
||||
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
|
||||
self.label.pack()
|
||||
self.window.resizable(width=False, height=False)
|
||||
|
||||
set_icon(self.window)
|
||||
self.window.focus()
|
||||
super().__init__(self.window, code_to_run, *args)
|
||||
|
||||
# safe to call from worker thread
|
||||
def update_status(self, text):
|
||||
self.queue_event(lambda: self.label_var.set(text))
|
||||
|
||||
# only call this in an event callback
|
||||
def close_window(self):
|
||||
self.stop()
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
def get_rom_frame(parent=None):
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
romVar = StringVar(value="Zelda no Densetsu - Kamigami no Triforce (Japan).sfc")
|
||||
romEntry = Entry(romFrame, textvariable=romVar)
|
||||
|
||||
def RomSelect():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc")), ("All Files", "*")])
|
||||
try:
|
||||
get_base_rom_bytes(rom) # throws error on checksum fail
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
messagebox.showerror(title="Error while reading ROM", message=str(e))
|
||||
else:
|
||||
romVar.set(rom)
|
||||
romSelectButton['state'] = "disabled"
|
||||
romSelectButton["text"] = "ROM verified"
|
||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||
|
||||
baseRomLabel.pack(side=LEFT)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton.pack(side=LEFT)
|
||||
romFrame.pack(side=TOP, expand=True, fill=X)
|
||||
|
||||
return romFrame, romVar
|
||||
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
romOptionsFrame.columnconfigure(1, weight=1)
|
||||
for i in range(5):
|
||||
romOptionsFrame.rowconfigure(i, weight=1)
|
||||
vars = Namespace()
|
||||
|
||||
vars.MusicVar = IntVar()
|
||||
vars.MusicVar.set(1)
|
||||
MusicCheckbutton = Checkbutton(romOptionsFrame, text="Music", variable=vars.MusicVar)
|
||||
MusicCheckbutton.grid(row=0, column=0, sticky=E)
|
||||
|
||||
vars.disableFlashingVar = IntVar(value=1)
|
||||
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)", variable=vars.disableFlashingVar)
|
||||
disableFlashingCheckbutton.grid(row=6, column=0, sticky=E)
|
||||
|
||||
spriteDialogFrame = Frame(romOptionsFrame)
|
||||
spriteDialogFrame.grid(row=0, column=1)
|
||||
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
|
||||
|
||||
|
||||
|
||||
vars.spriteNameVar = StringVar()
|
||||
vars.sprite = None
|
||||
def set_sprite(sprite_param):
|
||||
nonlocal vars
|
||||
if isinstance(sprite_param, str):
|
||||
vars.sprite = sprite_param
|
||||
vars.spriteNameVar.set(sprite_param)
|
||||
elif sprite_param is None or not sprite_param.valid:
|
||||
vars.sprite = None
|
||||
vars.spriteNameVar.set('(unchanged)')
|
||||
else:
|
||||
vars.sprite = sprite_param
|
||||
vars.spriteNameVar.set(vars.sprite.name)
|
||||
|
||||
set_sprite(None)
|
||||
vars.spriteNameVar.set('(unchanged)')
|
||||
spriteEntry = Label(spriteDialogFrame, textvariable=vars.spriteNameVar)
|
||||
|
||||
def SpriteSelect():
|
||||
nonlocal vars
|
||||
SpriteSelector(parent, set_sprite, spritePool=vars.sprite_pool)
|
||||
|
||||
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
|
||||
|
||||
baseSpriteLabel.pack(side=LEFT)
|
||||
spriteEntry.pack(side=LEFT)
|
||||
spriteSelectButton.pack(side=LEFT)
|
||||
|
||||
vars.quickSwapVar = IntVar(value=1)
|
||||
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||
|
||||
menuspeedFrame = Frame(romOptionsFrame)
|
||||
menuspeedFrame.grid(row=1, column=1, sticky=E)
|
||||
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
|
||||
menuspeedLabel.pack(side=LEFT)
|
||||
vars.menuspeedVar = StringVar()
|
||||
vars.menuspeedVar.set('normal')
|
||||
menuspeedOptionMenu = OptionMenu(menuspeedFrame, vars.menuspeedVar, 'normal', 'instant', 'double', 'triple', 'quadruple', 'half')
|
||||
menuspeedOptionMenu.pack(side=LEFT)
|
||||
|
||||
heartcolorFrame = Frame(romOptionsFrame)
|
||||
heartcolorFrame.grid(row=2, column=0, sticky=E)
|
||||
heartcolorLabel = Label(heartcolorFrame, text='Heart color')
|
||||
heartcolorLabel.pack(side=LEFT)
|
||||
vars.heartcolorVar = StringVar()
|
||||
vars.heartcolorVar.set('red')
|
||||
heartcolorOptionMenu = OptionMenu(heartcolorFrame, vars.heartcolorVar, 'red', 'blue', 'green', 'yellow', 'random')
|
||||
heartcolorOptionMenu.pack(side=LEFT)
|
||||
|
||||
heartbeepFrame = Frame(romOptionsFrame)
|
||||
heartbeepFrame.grid(row=2, column=1, sticky=E)
|
||||
heartbeepLabel = Label(heartbeepFrame, text='Heartbeep')
|
||||
heartbeepLabel.pack(side=LEFT)
|
||||
vars.heartbeepVar = StringVar()
|
||||
vars.heartbeepVar.set('normal')
|
||||
heartbeepOptionMenu = OptionMenu(heartbeepFrame, vars.heartbeepVar, 'double', 'normal', 'half', 'quarter', 'off')
|
||||
heartbeepOptionMenu.pack(side=LEFT)
|
||||
|
||||
owPalettesFrame = Frame(romOptionsFrame)
|
||||
owPalettesFrame.grid(row=3, column=0, sticky=E)
|
||||
owPalettesLabel = Label(owPalettesFrame, text='Overworld palettes')
|
||||
owPalettesLabel.pack(side=LEFT)
|
||||
vars.owPalettesVar = StringVar()
|
||||
vars.owPalettesVar.set('default')
|
||||
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
owPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
uwPalettesFrame = Frame(romOptionsFrame)
|
||||
uwPalettesFrame.grid(row=3, column=1, sticky=E)
|
||||
uwPalettesLabel = Label(uwPalettesFrame, text='Dungeon palettes')
|
||||
uwPalettesLabel.pack(side=LEFT)
|
||||
vars.uwPalettesVar = StringVar()
|
||||
vars.uwPalettesVar.set('default')
|
||||
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
uwPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
hudPalettesFrame = Frame(romOptionsFrame)
|
||||
hudPalettesFrame.grid(row=4, column=0, sticky=E)
|
||||
hudPalettesLabel = Label(hudPalettesFrame, text='HUD palettes')
|
||||
hudPalettesLabel.pack(side=LEFT)
|
||||
vars.hudPalettesVar = StringVar()
|
||||
vars.hudPalettesVar.set('default')
|
||||
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
hudPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
swordPalettesFrame = Frame(romOptionsFrame)
|
||||
swordPalettesFrame.grid(row=4, column=1, sticky=E)
|
||||
swordPalettesLabel = Label(swordPalettesFrame, text='Sword palettes')
|
||||
swordPalettesLabel.pack(side=LEFT)
|
||||
vars.swordPalettesVar = StringVar()
|
||||
vars.swordPalettesVar.set('default')
|
||||
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
swordPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
shieldPalettesFrame = Frame(romOptionsFrame)
|
||||
shieldPalettesFrame.grid(row=5, column=0, sticky=E)
|
||||
shieldPalettesLabel = Label(shieldPalettesFrame, text='Shield palettes')
|
||||
shieldPalettesLabel.pack(side=LEFT)
|
||||
vars.shieldPalettesVar = StringVar()
|
||||
vars.shieldPalettesVar.set('default')
|
||||
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
shieldPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
spritePoolFrame = Frame(romOptionsFrame)
|
||||
spritePoolFrame.grid(row=5, column=1)
|
||||
baseSpritePoolLabel = Label(spritePoolFrame, text='Sprite Pool:')
|
||||
|
||||
vars.spritePoolCountVar = StringVar()
|
||||
vars.sprite_pool = []
|
||||
def set_sprite_pool(sprite_param):
|
||||
nonlocal vars
|
||||
operation = "add"
|
||||
if isinstance(sprite_param, tuple):
|
||||
operation, sprite_param = sprite_param
|
||||
if isinstance(sprite_param, Sprite) and sprite_param.valid:
|
||||
sprite_param = sprite_param.name
|
||||
if isinstance(sprite_param, str):
|
||||
if operation == "add":
|
||||
vars.sprite_pool.append(sprite_param)
|
||||
elif operation == "remove":
|
||||
vars.sprite_pool.remove(sprite_param)
|
||||
elif operation == "clear":
|
||||
vars.sprite_pool.clear()
|
||||
vars.spritePoolCountVar.set(str(len(vars.sprite_pool)))
|
||||
|
||||
set_sprite_pool(None)
|
||||
vars.spritePoolCountVar.set('0')
|
||||
spritePoolEntry = Label(spritePoolFrame, textvariable=vars.spritePoolCountVar)
|
||||
|
||||
def SpritePoolSelect():
|
||||
nonlocal vars
|
||||
SpriteSelector(parent, set_sprite_pool, randomOnEvent=False, spritePool=vars.sprite_pool)
|
||||
|
||||
def SpritePoolClear():
|
||||
nonlocal vars
|
||||
vars.sprite_pool.clear()
|
||||
vars.spritePoolCountVar.set('0')
|
||||
|
||||
spritePoolSelectButton = Button(spritePoolFrame, text='...', command=SpritePoolSelect)
|
||||
spritePoolClearButton = Button(spritePoolFrame, text='Clear', command=SpritePoolClear)
|
||||
|
||||
baseSpritePoolLabel.pack(side=LEFT)
|
||||
spritePoolEntry.pack(side=LEFT)
|
||||
spritePoolSelectButton.pack(side=LEFT)
|
||||
spritePoolClearButton.pack(side=LEFT)
|
||||
|
||||
return romOptionsFrame, vars, set_sprite
|
||||
|
||||
|
||||
class SpriteSelector():
|
||||
def __init__(self, parent, callback, adjuster=False, randomOnEvent=True, spritePool=None):
|
||||
self.deploy_icons()
|
||||
self.parent = parent
|
||||
self.window = Toplevel(parent)
|
||||
self.callback = callback
|
||||
self.adjuster = adjuster
|
||||
self.randomOnEvent = randomOnEvent
|
||||
self.spritePoolButtons = None
|
||||
|
||||
self.window.wm_title("TAKE ANY ONE YOU WANT")
|
||||
self.window['padx'] = 5
|
||||
self.window['pady'] = 5
|
||||
self.spritesPerRow = 32
|
||||
self.all_sprites = []
|
||||
self.sprite_pool = spritePool
|
||||
|
||||
def open_custom_sprite_dir(_evt):
|
||||
open_file(self.custom_sprite_dir)
|
||||
|
||||
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
||||
|
||||
custom_frametitle = Frame(self.window)
|
||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||
title_link = Label(custom_frametitle, text="(open)", fg="blue", cursor="hand2")
|
||||
title_text.pack(side=LEFT)
|
||||
title_link.pack(side=LEFT)
|
||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||
|
||||
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir, 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
||||
self.icon_section(custom_frametitle, self.custom_sprite_dir, 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||
if not randomOnEvent:
|
||||
self.sprite_pool_section(spritePool)
|
||||
|
||||
frame = Frame(self.window)
|
||||
frame.pack(side=BOTTOM, fill=X, pady=5)
|
||||
|
||||
if self.randomOnEvent:
|
||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||
button.pack(side=RIGHT, padx=(5, 0))
|
||||
|
||||
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
||||
button.pack(side=RIGHT, padx=(5, 0))
|
||||
|
||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
self.randomButtonText = StringVar()
|
||||
button = Button(frame, textvariable=self.randomButtonText, command=self.use_random_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
self.randomButtonText.set("Random")
|
||||
|
||||
self.randomOnEventText = StringVar()
|
||||
self.randomOnHitVar = IntVar()
|
||||
self.randomOnEnterVar = IntVar()
|
||||
self.randomOnExitVar = IntVar()
|
||||
self.randomOnSlashVar = IntVar()
|
||||
self.randomOnItemVar = IntVar()
|
||||
self.randomOnBonkVar = IntVar()
|
||||
self.randomOnRandomVar = IntVar()
|
||||
|
||||
if self.randomOnEvent:
|
||||
button = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Checkbutton(frame, text="Random", command=self.update_random_button, variable=self.randomOnRandomVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
if adjuster:
|
||||
button = Button(frame, text="Current sprite from rom", command=self.use_default_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
set_icon(self.window)
|
||||
self.window.focus()
|
||||
|
||||
def remove_from_sprite_pool(self, button, spritename):
|
||||
self.callback(("remove", spritename))
|
||||
self.spritePoolButtons.buttons.remove(button)
|
||||
button.destroy()
|
||||
|
||||
def add_to_sprite_pool(self, spritename):
|
||||
if isinstance(spritename, str):
|
||||
if spritename == "random":
|
||||
button = Button(self.spritePoolButtons, text="?")
|
||||
button['font'] = font.Font(size=19)
|
||||
button.configure(command=lambda spr="random": self.remove_from_sprite_pool(button, spr))
|
||||
ToolTips.register(button, "Random")
|
||||
self.spritePoolButtons.buttons.append(button)
|
||||
else:
|
||||
spritename = Sprite.get_sprite_from_name(spritename)
|
||||
if isinstance(spritename, Sprite) and spritename.valid:
|
||||
image = get_image_for_sprite(spritename)
|
||||
if image is None:
|
||||
return
|
||||
button = Button(self.spritePoolButtons, image=image)
|
||||
button.configure(command=lambda spr=spritename: self.remove_from_sprite_pool(button, spr.name))
|
||||
ToolTips.register(button, spritename.name +
|
||||
f"\nBy: {spritename.author_name if spritename.author_name else ''}")
|
||||
button.image = image
|
||||
|
||||
self.spritePoolButtons.buttons.append(button)
|
||||
self.grid_fill_sprites(self.spritePoolButtons)
|
||||
|
||||
def sprite_pool_section(self, spritePool):
|
||||
def clear_sprite_pool(_evt):
|
||||
self.callback(("clear", "Clear"))
|
||||
for button in self.spritePoolButtons.buttons:
|
||||
button.destroy()
|
||||
self.spritePoolButtons.buttons.clear()
|
||||
|
||||
frametitle = Frame(self.window)
|
||||
title_text = Label(frametitle, text="Sprite Pool")
|
||||
title_link = Label(frametitle, text="(clear)", fg="blue", cursor="hand2")
|
||||
title_text.pack(side=LEFT)
|
||||
title_link.pack(side=LEFT)
|
||||
title_link.bind("<Button-1>", clear_sprite_pool)
|
||||
|
||||
self.spritePoolButtons = LabelFrame(self.window, labelwidget=frametitle, padx=5, pady=5)
|
||||
self.spritePoolButtons.pack(side=TOP, fill=X)
|
||||
self.spritePoolButtons.buttons = []
|
||||
|
||||
def update_sprites(event):
|
||||
self.spritesPerRow = (event.width - 10) // 38
|
||||
self.grid_fill_sprites(self.spritePoolButtons)
|
||||
|
||||
self.grid_fill_sprites(self.spritePoolButtons)
|
||||
self.spritePoolButtons.bind("<Configure>", update_sprites)
|
||||
|
||||
if spritePool:
|
||||
for sprite in spritePool:
|
||||
self.add_to_sprite_pool(sprite)
|
||||
|
||||
def icon_section(self, frame_label, path, no_results_label):
|
||||
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
|
||||
frame.pack(side=TOP, fill=X)
|
||||
|
||||
sprites = []
|
||||
|
||||
for file in os.listdir(path):
|
||||
sprites.append((file, Sprite(os.path.join(path, file))))
|
||||
|
||||
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
|
||||
|
||||
frame.buttons = []
|
||||
for file, sprite in sprites:
|
||||
image = get_image_for_sprite(sprite)
|
||||
if image is None:
|
||||
continue
|
||||
self.all_sprites.append(sprite)
|
||||
button = Button(frame, image=image, command=lambda spr=sprite: self.select_sprite(spr))
|
||||
ToolTips.register(button, sprite.name +
|
||||
("\nBy: %s" % sprite.author_name if sprite.author_name else "") +
|
||||
f"\nFrom: {file}")
|
||||
button.image = image
|
||||
frame.buttons.append(button)
|
||||
|
||||
if not frame.buttons:
|
||||
label = Label(frame, text=no_results_label)
|
||||
label.pack()
|
||||
|
||||
def update_sprites(event):
|
||||
self.spritesPerRow = (event.width - 10) // 38
|
||||
self.grid_fill_sprites(frame)
|
||||
|
||||
self.grid_fill_sprites(frame)
|
||||
|
||||
frame.bind("<Configure>", update_sprites)
|
||||
|
||||
def grid_fill_sprites(self, frame):
|
||||
for i, button in enumerate(frame.buttons):
|
||||
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||
|
||||
def update_alttpr_sprites(self):
|
||||
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||
self.window.destroy()
|
||||
self.parent.update()
|
||||
|
||||
def on_finish(successful, resultmessage):
|
||||
if successful:
|
||||
messagebox.showinfo("Sprite Updater", resultmessage)
|
||||
else:
|
||||
logging.error(resultmessage)
|
||||
messagebox.showerror("Sprite Updater", resultmessage)
|
||||
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||
|
||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
||||
|
||||
|
||||
def browse_for_sprite(self):
|
||||
sprite = filedialog.askopenfilename(
|
||||
filetypes=[("All Sprite Sources", (".zspr", ".spr", ".sfc", ".smc")),
|
||||
("ZSprite files", ".zspr"),
|
||||
("Sprite files", ".spr"),
|
||||
("Rom Files", (".sfc", ".smc")),
|
||||
("All Files", "*")])
|
||||
try:
|
||||
self.callback(Sprite(sprite))
|
||||
except Exception:
|
||||
self.callback(None)
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
def use_default_sprite(self):
|
||||
self.callback(None)
|
||||
self.window.destroy()
|
||||
|
||||
def use_default_link_sprite(self):
|
||||
if self.randomOnEvent:
|
||||
self.callback(Sprite.default_link_sprite())
|
||||
self.window.destroy()
|
||||
else:
|
||||
self.callback("link")
|
||||
self.add_to_sprite_pool("link")
|
||||
|
||||
def update_random_button(self):
|
||||
if self.randomOnRandomVar.get():
|
||||
randomon = "random"
|
||||
else:
|
||||
randomon = "-hit" if self.randomOnHitVar.get() else ""
|
||||
randomon += "-enter" if self.randomOnEnterVar.get() else ""
|
||||
randomon += "-exit" if self.randomOnExitVar.get() else ""
|
||||
randomon += "-slash" if self.randomOnSlashVar.get() else ""
|
||||
randomon += "-item" if self.randomOnItemVar.get() else ""
|
||||
randomon += "-bonk" if self.randomOnBonkVar.get() else ""
|
||||
|
||||
self.randomOnEventText.set(f"randomon{randomon}" if randomon else None)
|
||||
self.randomButtonText.set("Random On Event" if randomon else "Random")
|
||||
|
||||
def use_random_sprite(self):
|
||||
if not self.randomOnEvent:
|
||||
self.callback("random")
|
||||
self.add_to_sprite_pool("random")
|
||||
return
|
||||
elif self.randomOnEventText.get():
|
||||
self.callback(self.randomOnEventText.get())
|
||||
elif self.sprite_pool:
|
||||
self.callback(random.choice(self.sprite_pool))
|
||||
elif self.all_sprites:
|
||||
self.callback(random.choice(self.all_sprites))
|
||||
else:
|
||||
self.callback(None)
|
||||
self.window.destroy()
|
||||
|
||||
def select_sprite(self, spritename):
|
||||
self.callback(spritename)
|
||||
if self.randomOnEvent:
|
||||
self.window.destroy()
|
||||
else:
|
||||
self.add_to_sprite_pool(spritename)
|
||||
|
||||
def deploy_icons(self):
|
||||
if not os.path.exists(self.custom_sprite_dir):
|
||||
os.makedirs(self.custom_sprite_dir)
|
||||
|
||||
@property
|
||||
def alttpr_sprite_dir(self):
|
||||
return local_path("data", "sprites", "alttpr")
|
||||
|
||||
@property
|
||||
def custom_sprite_dir(self):
|
||||
return local_path("data", "sprites", "custom")
|
||||
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
if not sprite.valid:
|
||||
return None
|
||||
height = 24
|
||||
width = 16
|
||||
|
||||
def draw_sprite_into_gif(add_palette_color, set_pixel_color_index):
|
||||
|
||||
def drawsprite(spr, pal_as_colors, offset):
|
||||
for y, row in enumerate(spr):
|
||||
for x, pal_index in enumerate(row):
|
||||
if pal_index:
|
||||
color = pal_as_colors[pal_index - 1]
|
||||
set_pixel_color_index(x + offset[0], y + offset[1], color)
|
||||
|
||||
add_palette_color(16, (40, 40, 40))
|
||||
shadow = [
|
||||
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
|
||||
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
|
||||
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
|
||||
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
|
||||
]
|
||||
|
||||
drawsprite(shadow, [16], (2, 17))
|
||||
|
||||
palettes = sprite.decode_palette()
|
||||
for i in range(15):
|
||||
add_palette_color(i + 1, palettes[0][i])
|
||||
|
||||
body = sprite.decode16(0x4C0)
|
||||
drawsprite(body, list(range(1, 16)), (0, 8))
|
||||
head = sprite.decode16(0x40)
|
||||
drawsprite(head, list(range(1, 16)), (0, 0))
|
||||
|
||||
def make_gif(callback):
|
||||
gif_header = b'GIF89a'
|
||||
|
||||
gif_lsd = bytearray(7)
|
||||
gif_lsd[0] = width
|
||||
gif_lsd[2] = height
|
||||
gif_lsd[4] = 0xF4 # 32 color palette follows. transparant + 15 for sprite + 1 for shadow=17 which rounds up to 32 as nearest power of 2
|
||||
gif_lsd[5] = 0 # background color is zero
|
||||
gif_lsd[6] = 0 # aspect raio not specified
|
||||
gif_gct = bytearray(3 * 32)
|
||||
|
||||
gif_gce = bytearray(8)
|
||||
gif_gce[0] = 0x21 # start of extention blocked
|
||||
gif_gce[1] = 0xF9 # identifies this as the Graphics Control extension
|
||||
gif_gce[2] = 4 # we are suppling only the 4 four bytes
|
||||
gif_gce[3] = 0x01 # this gif includes transparency
|
||||
gif_gce[4] = gif_gce[5] = 0 # animation frrame delay (unused)
|
||||
gif_gce[6] = 0 # transparent color is index 0
|
||||
gif_gce[7] = 0 # end of gif_gce
|
||||
gif_id = bytearray(10)
|
||||
gif_id[0] = 0x2c
|
||||
# byte 1,2 are image left. 3,4 are image top both are left as zerosuitsamus
|
||||
gif_id[5] = width
|
||||
gif_id[7] = height
|
||||
gif_id[9] = 0 # no local color table
|
||||
|
||||
gif_img_minimum_code_size = bytes([7]) # we choose 7 bits, so that each pixel is represented by a byte, for conviennce.
|
||||
|
||||
clear = 0x80
|
||||
stop = 0x81
|
||||
|
||||
unchunked_image_data = bytearray(height * (width + 1) + 1)
|
||||
# we technically need a Clear code once every 125 bytes, but we do it at the start of every row for simplicity
|
||||
for row in range(height):
|
||||
unchunked_image_data[row * (width + 1)] = clear
|
||||
unchunked_image_data[-1] = stop
|
||||
|
||||
def add_palette_color(index, color):
|
||||
gif_gct[3 * index] = color[0]
|
||||
gif_gct[3 * index + 1] = color[1]
|
||||
gif_gct[3 * index + 2] = color[2]
|
||||
|
||||
def set_pixel_color_index(x, y, color):
|
||||
unchunked_image_data[y * (width + 1) + x + 1] = color
|
||||
|
||||
callback(add_palette_color, set_pixel_color_index)
|
||||
|
||||
def chunk_image(img):
|
||||
for i in range(0, len(img), 255):
|
||||
chunk = img[i:i + 255]
|
||||
yield bytes([len(chunk)])
|
||||
yield chunk
|
||||
|
||||
gif_img = b''.join([gif_img_minimum_code_size] + list(chunk_image(unchunked_image_data)) + [b'\x00'])
|
||||
|
||||
gif = b''.join([gif_header, gif_lsd, gif_gct, gif_gce, gif_id, gif_img, b'\x3b'])
|
||||
|
||||
return gif
|
||||
|
||||
gif_data = make_gif(draw_sprite_into_gif)
|
||||
if gif_only:
|
||||
return gif_data
|
||||
|
||||
image = PhotoImage(data=gif_data)
|
||||
|
||||
return image.zoom(2)
|
||||
|
||||
|
||||
class ToolTips(object):
|
||||
# This class derived from wckToolTips which is available under the following license:
|
||||
|
||||
# Copyright (c) 1998-2007 by Secret Labs AB
|
||||
# Copyright (c) 1998-2007 by Fredrik Lundh
|
||||
#
|
||||
# By obtaining, using, and/or copying this software and/or its
|
||||
# associated documentation, you agree that you have read, understood,
|
||||
# and will comply with the following terms and conditions:
|
||||
#
|
||||
# Permission to use, copy, modify, and distribute this software and its
|
||||
# associated documentation for any purpose and without fee is hereby
|
||||
# granted, provided that the above copyright notice appears in all
|
||||
# copies, and that both that copyright notice and this permission notice
|
||||
# appear in supporting documentation, and that the name of Secret Labs
|
||||
# AB or the author not be used in advertising or publicity pertaining to
|
||||
# distribution of the software without specific, written prior
|
||||
# permission.
|
||||
#
|
||||
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
|
||||
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
label = None
|
||||
window = None
|
||||
active = 0
|
||||
tag = None
|
||||
after_id = None
|
||||
|
||||
@classmethod
|
||||
def getcontroller(cls, widget):
|
||||
if cls.tag is None:
|
||||
|
||||
cls.tag = "ui_tooltip_%d" % id(cls)
|
||||
widget.bind_class(cls.tag, "<Enter>", cls.enter)
|
||||
widget.bind_class(cls.tag, "<Leave>", cls.leave)
|
||||
widget.bind_class(cls.tag, "<Motion>", cls.motion)
|
||||
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
|
||||
|
||||
# pick suitable colors for tooltips
|
||||
try:
|
||||
cls.bg = "systeminfobackground"
|
||||
cls.fg = "systeminfotext"
|
||||
widget.winfo_rgb(cls.fg) # make sure system colors exist
|
||||
widget.winfo_rgb(cls.bg)
|
||||
except Exception:
|
||||
cls.bg = "#ffffe0"
|
||||
cls.fg = "black"
|
||||
|
||||
return cls.tag
|
||||
|
||||
@classmethod
|
||||
def register(cls, widget, text):
|
||||
widget.ui_tooltip_text = text
|
||||
tags = list(widget.bindtags())
|
||||
tags.append(cls.getcontroller(widget))
|
||||
widget.bindtags(tuple(tags))
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, widget):
|
||||
tags = list(widget.bindtags())
|
||||
tags.remove(cls.getcontroller(widget))
|
||||
widget.bindtags(tuple(tags))
|
||||
|
||||
# event handlers
|
||||
@classmethod
|
||||
def enter(cls, event):
|
||||
widget = event.widget
|
||||
if not cls.label:
|
||||
# create and hide balloon help window
|
||||
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
|
||||
cls.popup.overrideredirect(1)
|
||||
cls.popup.withdraw()
|
||||
cls.label = tk.Label(
|
||||
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
|
||||
)
|
||||
cls.label.pack()
|
||||
cls.active = 0
|
||||
cls.xy = event.x_root + 16, event.y_root + 10
|
||||
cls.event_xy = event.x, event.y
|
||||
cls.after_id = widget.after(200, cls.display, widget)
|
||||
|
||||
@classmethod
|
||||
def motion(cls, event):
|
||||
cls.xy = event.x_root + 16, event.y_root + 10
|
||||
cls.event_xy = event.x, event.y
|
||||
|
||||
@classmethod
|
||||
def display(cls, widget):
|
||||
if not cls.active:
|
||||
# display balloon help window
|
||||
text = widget.ui_tooltip_text
|
||||
if callable(text):
|
||||
text = text(widget, cls.event_xy)
|
||||
cls.label.config(text=text)
|
||||
cls.popup.deiconify()
|
||||
cls.popup.lift()
|
||||
cls.popup.geometry("+%d+%d" % cls.xy)
|
||||
cls.active = 1
|
||||
cls.after_id = None
|
||||
|
||||
@classmethod
|
||||
def leave(cls, event):
|
||||
widget = event.widget
|
||||
if cls.active:
|
||||
cls.popup.withdraw()
|
||||
cls.active = 0
|
||||
if cls.after_id:
|
||||
widget.after_cancel(cls.after_id)
|
||||
cls.after_id = None
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
248
LttPClient.py
@@ -1,21 +1,19 @@
|
||||
import argparse
|
||||
import atexit
|
||||
exit_func = atexit.register(input, "Press enter to close.")
|
||||
import threading
|
||||
import time
|
||||
import functools
|
||||
import webbrowser
|
||||
import multiprocessing
|
||||
import socket
|
||||
import os
|
||||
import subprocess
|
||||
import base64
|
||||
import shutil
|
||||
import logging
|
||||
import asyncio
|
||||
from json import loads, dumps
|
||||
|
||||
from random import randrange
|
||||
|
||||
from Utils import get_item_name_from_id
|
||||
|
||||
exit_func = atexit.register(input, "Press enter to close.")
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -28,11 +26,23 @@ from NetUtils import *
|
||||
from worlds.alttp import Regions, Shops
|
||||
from worlds.alttp import Items
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, logger, console_loop, ClientCommandProcessor
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled
|
||||
|
||||
snes_logger = logging.getLogger("SNES")
|
||||
|
||||
from MultiServer import mark_raw
|
||||
|
||||
log_folder = Utils.local_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
|
||||
# Log to file in gui case
|
||||
if gui_enabled:
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
|
||||
filename=os.path.join(log_folder, "LttPClient.txt"), filemode="w", force=True)
|
||||
else:
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
|
||||
logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, "LttPClient.txt"), "w"))
|
||||
|
||||
|
||||
class LttPCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_slow_mode(self, toggle: str = ""):
|
||||
@@ -45,10 +55,26 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
self.output(f"Setting slow mode to {self.ctx.slow_mode}")
|
||||
|
||||
@mark_raw
|
||||
def _cmd_snes(self, snes_address: str = "") -> bool:
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices"""
|
||||
def _cmd_snes(self, snes_options: str = "") -> bool:
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices; and a SNES device number if more than one SNES is detected"""
|
||||
|
||||
snes_address = self.ctx.snes_address
|
||||
snes_device_number = -1
|
||||
|
||||
options = snes_options.split()
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
snes_address = options[0]
|
||||
|
||||
if num_options > 1:
|
||||
try:
|
||||
snes_device_number = int(options[1])
|
||||
except:
|
||||
pass
|
||||
|
||||
self.ctx.snes_reconnect_address = None
|
||||
asyncio.create_task(snes_connect(self.ctx, snes_address if snes_address else self.ctx.snes_address))
|
||||
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number))
|
||||
return True
|
||||
|
||||
def _cmd_snes_close(self) -> bool:
|
||||
@@ -62,8 +88,10 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
|
||||
class Context(CommonContext):
|
||||
command_processor = LttPCommandProcessor
|
||||
def __init__(self, snes_address, server_address, password, found_items):
|
||||
super(Context, self).__init__(server_address, password, found_items)
|
||||
game = "A Link to the Past"
|
||||
|
||||
def __init__(self, snes_address, server_address, password):
|
||||
super(Context, self).__init__(server_address, password)
|
||||
|
||||
# snes stuff
|
||||
self.snes_address = snes_address
|
||||
@@ -73,8 +101,8 @@ class Context(CommonContext):
|
||||
self.snes_reconnect_address = None
|
||||
self.snes_recv_queue = asyncio.Queue()
|
||||
self.snes_request_lock = asyncio.Lock()
|
||||
self.is_sd2snes = False
|
||||
self.snes_write_buffer = []
|
||||
self.snes_connector_lock = threading.Lock()
|
||||
|
||||
self.awaiting_rom = False
|
||||
self.rom = None
|
||||
@@ -95,14 +123,14 @@ class Context(CommonContext):
|
||||
await super(Context, self).server_auth(password_requested)
|
||||
if self.rom is None:
|
||||
self.awaiting_rom = True
|
||||
logger.info(
|
||||
snes_logger.info(
|
||||
'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
|
||||
return
|
||||
self.awaiting_rom = False
|
||||
self.auth = self.rom
|
||||
auth = base64.b64encode(self.rom).decode()
|
||||
await self.send_msgs([{"cmd": 'Connect',
|
||||
'password': self.password, 'name': auth, 'version': Utils._version_tuple,
|
||||
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
|
||||
'tags': get_tags(self),
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
|
||||
}])
|
||||
@@ -143,8 +171,6 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
|
||||
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
|
||||
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
|
||||
|
||||
location_shop_order = [name for name, info in
|
||||
Shops.shop_table.items()] # probably don't leave this here. This relies on python 3.6+ dictionary keys having defined order
|
||||
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
|
||||
|
||||
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
@@ -415,26 +441,32 @@ class SNESState(enum.IntEnum):
|
||||
SNES_ATTACHED = 3
|
||||
|
||||
|
||||
def launch_qusb2snes(ctx: Context):
|
||||
qusb2snes_path = Utils.get_options()["lttp_options"]["qusb2snes"]
|
||||
def launch_sni(ctx: Context):
|
||||
sni_path = Utils.get_options()["lttp_options"]["sni"]
|
||||
|
||||
if not os.path.isfile(qusb2snes_path):
|
||||
qusb2snes_path = Utils.local_path(qusb2snes_path)
|
||||
if not os.path.isdir(sni_path):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
if os.path.isdir(sni_path):
|
||||
for file in os.listdir(sni_path):
|
||||
if file.startswith("sni.") and not file.endswith(".proto"):
|
||||
sni_path = os.path.join(sni_path, file)
|
||||
|
||||
if os.path.isfile(qusb2snes_path):
|
||||
logger.info(f"Attempting to start {qusb2snes_path}")
|
||||
if os.path.isfile(sni_path):
|
||||
snes_logger.info(f"Attempting to start {sni_path}")
|
||||
import subprocess
|
||||
subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path))
|
||||
if Utils.is_frozen(): # if it spawns a visible console, may as well populate it
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path))
|
||||
else:
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
logger.info(
|
||||
f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, "
|
||||
snes_logger.info(
|
||||
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
|
||||
f"please start it yourself if it is not running")
|
||||
|
||||
|
||||
async def _snes_connect(ctx: Context, address: str):
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
|
||||
logger.info("Connecting to QUsb2snes at %s ..." % address)
|
||||
snes_logger.info("Connecting to SNI at %s ..." % address)
|
||||
seen_problems = set()
|
||||
succesful = False
|
||||
while not succesful:
|
||||
@@ -446,11 +478,11 @@ async def _snes_connect(ctx: Context, address: str):
|
||||
# only tell the user about new problems, otherwise silently lay in wait for a working connection
|
||||
if problem not in seen_problems:
|
||||
seen_problems.add(problem)
|
||||
logger.error(f"Error connecting to QUsb2snes ({problem})")
|
||||
snes_logger.error(f"Error connecting to SNI ({problem})")
|
||||
|
||||
if len(seen_problems) == 1:
|
||||
# this is the first problem. Let's try launching QUsb2snes if it isn't already running
|
||||
launch_qusb2snes(ctx)
|
||||
# this is the first problem. Let's try launching SNI if it isn't already running
|
||||
launch_sni(ctx)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
@@ -469,7 +501,7 @@ async def get_snes_devices(ctx: Context):
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
|
||||
|
||||
if not devices:
|
||||
logger.info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.')
|
||||
snes_logger.info('No SNES device found. Please connect a SNES device to SNI.')
|
||||
while not devices:
|
||||
await asyncio.sleep(1)
|
||||
await socket.send(dumps(DeviceList_Request))
|
||||
@@ -481,12 +513,16 @@ async def get_snes_devices(ctx: Context):
|
||||
return devices
|
||||
|
||||
|
||||
async def snes_connect(ctx: Context, address):
|
||||
async def snes_connect(ctx: Context, address, deviceIndex = -1):
|
||||
global SNES_RECONNECT_DELAY
|
||||
if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED:
|
||||
logger.error('Already connected to snes')
|
||||
if ctx.rom:
|
||||
snes_logger.error('Already connected to SNES, with rom loaded.')
|
||||
else:
|
||||
snes_logger.error('Already connected to SNI, likely awaiting a device.')
|
||||
return
|
||||
|
||||
device = None
|
||||
recv_task = None
|
||||
ctx.snes_state = SNESState.SNES_CONNECTING
|
||||
socket = await _snes_connect(ctx, address)
|
||||
@@ -495,19 +531,33 @@ async def snes_connect(ctx: Context, address):
|
||||
|
||||
try:
|
||||
devices = await get_snes_devices(ctx)
|
||||
numDevices = len(devices)
|
||||
|
||||
if len(devices) == 1:
|
||||
if numDevices == 1:
|
||||
device = devices[0]
|
||||
elif ctx.snes_reconnect_address:
|
||||
if ctx.snes_attached_device[1] in devices:
|
||||
device = ctx.snes_attached_device[1]
|
||||
else:
|
||||
device = devices[ctx.snes_attached_device[0]]
|
||||
else:
|
||||
elif numDevices > 1:
|
||||
if deviceIndex == -1:
|
||||
snes_logger.info("Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
|
||||
|
||||
for idx, availableDevice in enumerate(devices):
|
||||
snes_logger.info(str(idx + 1) + ": " + availableDevice)
|
||||
|
||||
elif (deviceIndex < 0) or (deviceIndex - 1) > numDevices:
|
||||
snes_logger.warning("SNES device number out of range")
|
||||
|
||||
else:
|
||||
device = devices[deviceIndex - 1]
|
||||
|
||||
if device is None:
|
||||
await snes_disconnect(ctx)
|
||||
return
|
||||
|
||||
logger.info("Attaching to " + device)
|
||||
snes_logger.info("Attaching to " + device)
|
||||
|
||||
Attach_Request = {
|
||||
"Opcode": "Attach",
|
||||
@@ -517,20 +567,10 @@ async def snes_connect(ctx: Context, address):
|
||||
await ctx.snes_socket.send(dumps(Attach_Request))
|
||||
ctx.snes_state = SNESState.SNES_ATTACHED
|
||||
ctx.snes_attached_device = (devices.index(device), device)
|
||||
|
||||
if 'sd2snes' in device.lower() or 'COM' in device:
|
||||
logger.info("SD2SNES/FXPAK Detected")
|
||||
ctx.is_sd2snes = True
|
||||
await ctx.snes_socket.send(dumps({"Opcode": "Info", "Space": "SNES"}))
|
||||
reply = loads(await ctx.snes_socket.recv())
|
||||
if reply and 'Results' in reply:
|
||||
logger.info(reply['Results'])
|
||||
else:
|
||||
ctx.is_sd2snes = False
|
||||
|
||||
ctx.snes_reconnect_address = address
|
||||
recv_task = asyncio.create_task(snes_recv_loop(ctx))
|
||||
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
|
||||
snes_logger.info(f"Attached to {device}")
|
||||
|
||||
except Exception as e:
|
||||
if recv_task is not None:
|
||||
@@ -543,9 +583,9 @@ async def snes_connect(ctx: Context, address):
|
||||
ctx.snes_socket = None
|
||||
ctx.snes_state = SNESState.SNES_DISCONNECTED
|
||||
if not ctx.snes_reconnect_address:
|
||||
logger.error("Error connecting to snes (%s)" % e)
|
||||
snes_logger.error("Error connecting to snes (%s)" % e)
|
||||
else:
|
||||
logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s")
|
||||
snes_logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s")
|
||||
asyncio.create_task(snes_autoreconnect(ctx))
|
||||
SNES_RECONNECT_DELAY *= 2
|
||||
|
||||
@@ -572,11 +612,11 @@ async def snes_recv_loop(ctx: Context):
|
||||
try:
|
||||
async for msg in ctx.snes_socket:
|
||||
ctx.snes_recv_queue.put_nowait(msg)
|
||||
logger.warning("Snes disconnected")
|
||||
snes_logger.warning("Snes disconnected")
|
||||
except Exception as e:
|
||||
if not isinstance(e, websockets.WebSocketException):
|
||||
logger.exception(e)
|
||||
logger.error("Lost connection to the snes, type /snes to reconnect")
|
||||
snes_logger.exception(e)
|
||||
snes_logger.error("Lost connection to the snes, type /snes to reconnect")
|
||||
finally:
|
||||
socket, ctx.snes_socket = ctx.snes_socket, None
|
||||
if socket is not None and not socket.closed:
|
||||
@@ -589,7 +629,7 @@ async def snes_recv_loop(ctx: Context):
|
||||
ctx.rom = None
|
||||
|
||||
if ctx.snes_reconnect_address:
|
||||
logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s")
|
||||
snes_logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s")
|
||||
asyncio.create_task(snes_autoreconnect(ctx))
|
||||
|
||||
|
||||
@@ -618,11 +658,10 @@ async def snes_read(ctx: Context, address, size):
|
||||
break
|
||||
|
||||
if len(data) != size:
|
||||
logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
|
||||
snes_logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
|
||||
if len(data):
|
||||
logger.error(str(data))
|
||||
logger.warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.'
|
||||
'Try un-selecting and re-selecting the SNES Device.')
|
||||
snes_logger.error(str(data))
|
||||
snes_logger.warning('Communication Failure with SNI')
|
||||
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
return None
|
||||
@@ -641,45 +680,16 @@ async def snes_write(ctx: Context, write_list):
|
||||
return False
|
||||
|
||||
PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
|
||||
|
||||
if ctx.is_sd2snes:
|
||||
cmd = b'\x00\xE2\x20\x48\xEB\x48'
|
||||
|
||||
try:
|
||||
for address, data in write_list:
|
||||
if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)):
|
||||
logger.error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data)))
|
||||
return False
|
||||
for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START):
|
||||
cmd += b'\xA9' # LDA
|
||||
cmd += bytes([byte])
|
||||
cmd += b'\x8F' # STA.l
|
||||
cmd += bytes([ptr & 0xFF, (ptr >> 8) & 0xFF, (ptr >> 16) & 0xFF])
|
||||
|
||||
cmd += b'\xA9\x00\x8F\x00\x2C\x00\x68\xEB\x68\x28\x6C\xEA\xFF\x08'
|
||||
|
||||
PutAddress_Request['Space'] = 'CMD'
|
||||
PutAddress_Request['Operands'] = ["2C00", hex(len(cmd) - 1)[2:], "2C00", "1"]
|
||||
try:
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(cmd)
|
||||
await ctx.snes_socket.send(data)
|
||||
else:
|
||||
logger.warning(f"Could not send data to SNES: {cmd}")
|
||||
except websockets.ConnectionClosed:
|
||||
return False
|
||||
else:
|
||||
PutAddress_Request['Space'] = 'SNES'
|
||||
try:
|
||||
# will pack those requests as soon as qusb2snes actually supports that for real
|
||||
for address, data in write_list:
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||
if ctx.snes_socket is not None:
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data)
|
||||
else:
|
||||
logger.warning(f"Could not send data to SNES: {data}")
|
||||
except websockets.ConnectionClosed:
|
||||
return False
|
||||
snes_logger.warning(f"Could not send data to SNES: {data}")
|
||||
except websockets.ConnectionClosed:
|
||||
return False
|
||||
|
||||
return True
|
||||
finally:
|
||||
@@ -709,9 +719,6 @@ def get_tags(ctx: Context):
|
||||
return tags
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def track_locations(ctx: Context, roomid, roomdata):
|
||||
new_locations = []
|
||||
|
||||
@@ -719,16 +726,16 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
new_locations.append(location_id)
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
|
||||
try:
|
||||
if roomid in location_shop_ids:
|
||||
misc_data = await snes_read(ctx, SHOP_ADDR, (len(location_shop_order) * 3) + 5)
|
||||
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
|
||||
for cnt, b in enumerate(misc_data):
|
||||
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
|
||||
new_check(Shops.SHOP_ID_START + cnt)
|
||||
except Exception as e:
|
||||
logger.info(f"Exception: {e}")
|
||||
snes_logger.info(f"Exception: {e}")
|
||||
|
||||
for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items():
|
||||
try:
|
||||
@@ -737,7 +744,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
roomdata << 4) & loc_mask != 0:
|
||||
new_check(location_id)
|
||||
except Exception as e:
|
||||
logger.exception(f"Exception: {e}")
|
||||
snes_logger.exception(f"Exception: {e}")
|
||||
|
||||
uw_begin = 0x129
|
||||
ow_end = uw_end = 0
|
||||
@@ -820,7 +827,7 @@ async def game_watcher(ctx: Context):
|
||||
await ctx.server_auth(False)
|
||||
|
||||
if ctx.auth and ctx.auth != ctx.rom:
|
||||
logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
|
||||
await ctx.disconnect()
|
||||
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
@@ -862,10 +869,11 @@ async def game_watcher(ctx: Context):
|
||||
|
||||
if recv_index < len(ctx.items_received) and recv_item == 0:
|
||||
item = ctx.items_received[recv_index]
|
||||
recv_index += 1
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), recv_index + 1, len(ctx.items_received)))
|
||||
recv_index += 1
|
||||
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
|
||||
|
||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_ADDR, bytes([item.item]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, bytes([item.player if item.player != ctx.slot else 0]))
|
||||
@@ -896,12 +904,14 @@ async def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a Archipelago Binary Patch file')
|
||||
parser.add_argument('--snes', default='localhost:8080', help='Address of the QUsb2snes server.')
|
||||
parser.add_argument('--snes', default='localhost:8080', help='Address of the SNI server.')
|
||||
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
|
||||
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
|
||||
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
parser.add_argument('--founditems', default=False, action='store_true',
|
||||
help='Show items found by other players for themselves.')
|
||||
if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
|
||||
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
|
||||
if args.diff_file:
|
||||
@@ -910,7 +920,7 @@ async def main():
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile)
|
||||
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(adjustedromfile, romfile)
|
||||
@@ -919,25 +929,33 @@ async def main():
|
||||
logging.exception(e)
|
||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||
|
||||
port = None
|
||||
|
||||
ctx = Context(args.snes, args.connect, args.password, args.founditems)
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
|
||||
ctx = Context(args.snes, args.connect, args.password)
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
from kvui import LttPManager
|
||||
ctx.ui = LttPManager(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
|
||||
|
||||
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address))
|
||||
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
if snes_connect_task:
|
||||
snes_connect_task.cancel()
|
||||
ctx.server_address = None
|
||||
ctx.snes_reconnect_address = None
|
||||
|
||||
await watcher_task
|
||||
|
||||
if ctx.server is not None and not ctx.server.socket.closed:
|
||||
if ctx.server and not ctx.server.socket.closed:
|
||||
await ctx.server.socket.close()
|
||||
if ctx.server_task is not None:
|
||||
if ctx.server_task:
|
||||
await ctx.server_task
|
||||
|
||||
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
|
||||
@@ -947,7 +965,11 @@ async def main():
|
||||
ctx.input_queue.put_nowait(None)
|
||||
ctx.input_requests -= 1
|
||||
|
||||
await input_task
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
665
Main.py
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
from itertools import zip_longest
|
||||
import logging
|
||||
import os
|
||||
@@ -7,30 +6,18 @@ import time
|
||||
import zlib
|
||||
import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, Item
|
||||
from worlds.alttp.Items import ItemFactory, item_name_groups
|
||||
from worlds.alttp.Regions import create_regions, mark_light_world_regions, \
|
||||
lookup_vanilla_location_to_entrance
|
||||
from worlds.alttp.InvertedRegions import create_inverted_regions, mark_dark_world_regions
|
||||
from worlds.alttp.EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
|
||||
from worlds.alttp.Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
|
||||
from worlds.alttp.Rules import set_rules
|
||||
from worlds.alttp.Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, RegionType
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes
|
||||
from Utils import output_path, parse_player_names, get_options, __version__, _version_tuple
|
||||
from worlds.hk import gen_hollow
|
||||
from worlds.hk import create_regions as hk_create_regions
|
||||
from worlds.factorio import gen_factorio, factorio_create_regions
|
||||
from worlds.factorio.Mod import generate_mod
|
||||
from worlds.minecraft import gen_minecraft, fill_minecraft_slot_data, generate_mc_data
|
||||
from worlds.minecraft.Regions import minecraft_create_regions
|
||||
from worlds.generic.Rules import locality_rules
|
||||
from worlds import Games, lookup_any_item_name_to_id
|
||||
import Patch
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from Utils import output_path, get_options, __version__, version_tuple
|
||||
from worlds.generic.Rules import locality_rules, exclusion_rules
|
||||
from worlds import AutoWorld
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
@@ -42,15 +29,6 @@ def get_seed(seed=None):
|
||||
return seed
|
||||
|
||||
|
||||
def get_same_seed(world: MultiWorld, seed_def: tuple) -> str:
|
||||
seeds: Dict[tuple, str] = getattr(world, "__named_seeds", {})
|
||||
if seed_def in seeds:
|
||||
return seeds[seed_def]
|
||||
seeds[seed_def] = str(world.random.randint(0, 2 ** 64))
|
||||
world.__named_seeds = seeds
|
||||
return seeds[seed_def]
|
||||
|
||||
|
||||
def main(args, seed=None):
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
@@ -76,10 +54,9 @@ def main(args, seed=None):
|
||||
world.difficulty = args.difficulty.copy()
|
||||
world.item_functionality = args.item_functionality.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.progressive = args.progressive.copy()
|
||||
world.goal = args.goal.copy()
|
||||
world.local_items = args.local_items.copy()
|
||||
if hasattr(args, "algorithm"): # current GUI options
|
||||
if hasattr(args, "algorithm"): # current GUI options
|
||||
world.algorithm = args.algorithm
|
||||
world.shuffleganon = args.shuffleganon
|
||||
world.custom = args.custom
|
||||
@@ -89,17 +66,6 @@ def main(args, seed=None):
|
||||
world.retro = args.retro.copy()
|
||||
|
||||
world.hints = args.hints.copy()
|
||||
|
||||
world.mapshuffle = args.mapshuffle.copy()
|
||||
world.compassshuffle = args.compassshuffle.copy()
|
||||
world.keyshuffle = args.keyshuffle.copy()
|
||||
world.bigkeyshuffle = args.bigkeyshuffle.copy()
|
||||
world.crystals_needed_for_ganon = {
|
||||
player: world.random.randint(0, 7) if args.crystals_ganon[player] == 'random' else int(
|
||||
args.crystals_ganon[player]) for player in range(1, world.players + 1)}
|
||||
world.crystals_needed_for_gt = {
|
||||
player: world.random.randint(0, 7) if args.crystals_gt[player] == 'random' else int(args.crystals_gt[player])
|
||||
for player in range(1, world.players + 1)}
|
||||
world.open_pyramid = args.open_pyramid.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_shuffle = args.enemy_shuffle.copy()
|
||||
@@ -115,13 +81,10 @@ def main(args, seed=None):
|
||||
world.blue_clock_time = args.blue_clock_time.copy()
|
||||
world.green_clock_time = args.green_clock_time.copy()
|
||||
world.shufflepots = args.shufflepots.copy()
|
||||
world.progressive = args.progressive.copy()
|
||||
world.dungeon_counters = args.dungeon_counters.copy()
|
||||
world.glitch_boots = args.glitch_boots.copy()
|
||||
world.triforce_pieces_available = args.triforce_pieces_available.copy()
|
||||
world.triforce_pieces_required = args.triforce_pieces_required.copy()
|
||||
world.shop_shuffle = args.shop_shuffle.copy()
|
||||
world.shop_shuffle_slots = args.shop_shuffle_slots.copy()
|
||||
world.progression_balancing = args.progression_balancing.copy()
|
||||
world.shuffle_prizes = args.shuffle_prizes.copy()
|
||||
world.sprite_pool = args.sprite_pool.copy()
|
||||
@@ -133,148 +96,68 @@ def main(args, seed=None):
|
||||
world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.copy()
|
||||
world.required_medallions = args.required_medallions.copy()
|
||||
world.game = args.game.copy()
|
||||
import Options
|
||||
for hk_option in Options.hollow_knight_options:
|
||||
setattr(world, hk_option, getattr(args, hk_option, {}))
|
||||
for factorio_option in Options.factorio_options:
|
||||
setattr(world, factorio_option, getattr(args, factorio_option, {}))
|
||||
for minecraft_option in Options.minecraft_options:
|
||||
setattr(world, minecraft_option, getattr(args, minecraft_option, {}))
|
||||
world.set_options(args)
|
||||
world.player_name = args.name.copy()
|
||||
world.alttp_rom = args.rom
|
||||
world.enemizer = args.enemizercli
|
||||
world.sprite = args.sprite.copy()
|
||||
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
|
||||
|
||||
world.rom_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in range(1, world.players + 1)}
|
||||
|
||||
for player in range(1, world.players+1):
|
||||
world.er_seeds[player] = str(world.random.randint(0, 2 ** 64))
|
||||
|
||||
if "-" in world.shuffle[player]:
|
||||
shuffle, seed = world.shuffle[player].split("-", 1)
|
||||
world.shuffle[player] = shuffle
|
||||
if shuffle == "vanilla":
|
||||
world.er_seeds[player] = "vanilla"
|
||||
elif seed.startswith("group-") or args.race:
|
||||
# renamed from team to group to not confuse with existing team name use
|
||||
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
|
||||
else: # not a race or group seed, use set seed as is.
|
||||
world.er_seeds[player] = seed
|
||||
elif world.shuffle[player] == "vanilla":
|
||||
world.er_seeds[player] = "vanilla"
|
||||
world.slot_seeds = {player: random.Random(world.random.getrandbits(64)) for player in
|
||||
range(1, world.players + 1)}
|
||||
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||
|
||||
parsed_names = parse_player_names(args.names, world.players, args.teams)
|
||||
world.teams = len(parsed_names)
|
||||
for i, team in enumerate(parsed_names, 1):
|
||||
if world.players > 1:
|
||||
logger.info('%s%s', 'Team%d: ' % i if world.teams > 1 else 'Players: ', ', '.join(team))
|
||||
for player, name in enumerate(team, 1):
|
||||
world.player_names[player].append(name)
|
||||
logger.info("Found World Types:")
|
||||
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
|
||||
numlength = 8
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
if not cls.hidden:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
|
||||
f"{len(cls.location_names):3} Locations")
|
||||
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}} | "
|
||||
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}}")
|
||||
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
logger.info('')
|
||||
|
||||
for player in world.player_ids:
|
||||
for item_name in args.startinventory[player]:
|
||||
item = Item(item_name, True, lookup_any_item_name_to_id[item_name], player)
|
||||
world.push_precollected(item)
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
for player in world.player_ids:
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].add('Triforce Piece')
|
||||
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].add('Triforce Piece')
|
||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||
world.non_local_items[player] -= item_name_groups['Pendants']
|
||||
world.non_local_items[player] -= item_name_groups['Crystals']
|
||||
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player] -= world.local_items[player]
|
||||
|
||||
# dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
|
||||
if not world.mapshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Maps']
|
||||
logger.info('Creating World.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
|
||||
if not world.compassshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Compasses']
|
||||
|
||||
if not world.keyshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Small Keys']
|
||||
|
||||
if not world.bigkeyshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Big Keys']
|
||||
|
||||
# Not possible to place pendants/crystals out side of boss prizes yet.
|
||||
world.non_local_items[player] -= item_name_groups['Pendants']
|
||||
world.non_local_items[player] -= item_name_groups['Crystals']
|
||||
|
||||
for player in world.hk_player_ids:
|
||||
hk_create_regions(world, player)
|
||||
|
||||
for player in world.factorio_player_ids:
|
||||
factorio_create_regions(world, player)
|
||||
|
||||
for player in world.minecraft_player_ids:
|
||||
minecraft_create_regions(world, player)
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
if world.open_pyramid[player] == 'goal':
|
||||
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'}
|
||||
elif world.open_pyramid[player] == 'auto':
|
||||
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'} and \
|
||||
(world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed'} or not world.shuffle_ganon)
|
||||
else:
|
||||
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(world.open_pyramid[player], world.open_pyramid[player])
|
||||
|
||||
|
||||
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player], world.triforce_pieces_required[player])
|
||||
|
||||
if world.mode[player] != 'inverted':
|
||||
create_regions(world, player)
|
||||
else:
|
||||
create_inverted_regions(world, player)
|
||||
create_shops(world, player)
|
||||
create_dungeons(world, player)
|
||||
|
||||
logger.info('Shuffling the World about.')
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
if world.logic[player] not in ["noglitches", "minorglitches"] and world.shuffle[player] in \
|
||||
{"vanilla", "dungeonssimple", "dungeonsfull", "simple", "restricted", "full"}:
|
||||
world.fix_fake_world[player] = False
|
||||
|
||||
# seeded entrance shuffle
|
||||
old_random = world.random
|
||||
world.random = random.Random(world.er_seeds[player])
|
||||
|
||||
if world.mode[player] != 'inverted':
|
||||
link_entrances(world, player)
|
||||
mark_light_world_regions(world, player)
|
||||
else:
|
||||
link_inverted_entrances(world, player)
|
||||
mark_dark_world_regions(world, player)
|
||||
|
||||
world.random = old_random
|
||||
plando_connect(world, player)
|
||||
|
||||
logger.info('Generating Item Pool.')
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
generate_itempool(world, player)
|
||||
logger.info('Creating Items.')
|
||||
AutoWorld.call_all(world, "create_items")
|
||||
|
||||
logger.info('Calculating Access Rules.')
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
|
||||
for player in world.alttp_player_ids:
|
||||
set_rules(world, player)
|
||||
AutoWorld.call_all(world, "set_rules")
|
||||
|
||||
for player in world.hk_player_ids:
|
||||
gen_hollow(world, player)
|
||||
for player in world.player_ids:
|
||||
exclusion_rules(world, player, args.excluded_locations[player])
|
||||
|
||||
for player in world.factorio_player_ids:
|
||||
gen_factorio(world, player)
|
||||
|
||||
for player in world.minecraft_player_ids:
|
||||
gen_minecraft(world, player)
|
||||
AutoWorld.call_all(world, "generate_basic")
|
||||
|
||||
logger.info("Running Item Plando")
|
||||
|
||||
@@ -283,307 +166,199 @@ def main(args, seed=None):
|
||||
|
||||
distribute_planned(world)
|
||||
|
||||
logger.info('Placing Dungeon Prizes.')
|
||||
logger.info('Running Pre Main Fill.')
|
||||
|
||||
fill_prizes(world)
|
||||
|
||||
logger.info('Placing Dungeon Items.')
|
||||
|
||||
if world.algorithm in ['balanced', 'vt26'] or any(
|
||||
list(args.mapshuffle.values()) + list(args.compassshuffle.values()) +
|
||||
list(args.keyshuffle.values()) + list(args.bigkeyshuffle.values())):
|
||||
fill_dungeons_restrictive(world)
|
||||
else:
|
||||
fill_dungeons(world)
|
||||
AutoWorld.call_all(world, "pre_fill")
|
||||
|
||||
logger.info('Fill the world.')
|
||||
|
||||
if world.algorithm == 'flood':
|
||||
flood_items(world) # different algo, biased towards early game progress items
|
||||
elif world.algorithm == 'vt25':
|
||||
distribute_items_restrictive(world, False)
|
||||
elif world.algorithm == 'vt26':
|
||||
distribute_items_restrictive(world, True)
|
||||
elif world.algorithm == 'balanced':
|
||||
distribute_items_restrictive(world, True)
|
||||
distribute_items_restrictive(world)
|
||||
|
||||
logger.info("Filling Shop Slots")
|
||||
|
||||
ShopSlotFill(world)
|
||||
AutoWorld.call_all(world, 'post_fill')
|
||||
|
||||
if world.players > 1:
|
||||
balance_multiworld_progression(world)
|
||||
|
||||
logger.info('Generating output files.')
|
||||
logger.info(f'Beginning output...')
|
||||
outfilebase = 'AP_' + world.seed_name
|
||||
rom_names = []
|
||||
|
||||
def _gen_rom(team: int, player: int):
|
||||
use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
|
||||
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
|
||||
or world.shufflepots[player] or world.bush_shuffle[player]
|
||||
or world.killable_thieves[player])
|
||||
output = tempfile.TemporaryDirectory()
|
||||
with output as temp_dir:
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
|
||||
rom = LocalRom(args.rom)
|
||||
output_file_futures = []
|
||||
|
||||
patch_rom(world, rom, player, team, use_enemizer)
|
||||
for player in world.player_ids:
|
||||
# skip starting a thread for methods that say "pass".
|
||||
if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
|
||||
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||
output_file_futures.append(pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir))
|
||||
|
||||
if use_enemizer:
|
||||
patch_enemizer(world, team, player, rom, args.enemizercli)
|
||||
def get_entrance_to_region(region: Region):
|
||||
for entrance in region.entrances:
|
||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
|
||||
return entrance
|
||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||
return get_entrance_to_region(entrance.parent_region)
|
||||
|
||||
if args.race:
|
||||
patch_race_rom(rom, world, player)
|
||||
# collect ER hint info
|
||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||
world.shuffle[player] != "vanilla" or world.retro[player]}
|
||||
|
||||
world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash)
|
||||
for region in world.regions:
|
||||
if region.player in er_hint_data and region.locations:
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
for location in region.locations:
|
||||
if type(location.address) == int: # skips events and crystals
|
||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||
er_hint_data[region.player][location.address] = main_entrance.name
|
||||
|
||||
palettes_options={}
|
||||
palettes_options['dungeon']=args.uw_palettes[player]
|
||||
palettes_options['overworld']=args.ow_palettes[player]
|
||||
palettes_options['hud']=args.hud_palettes[player]
|
||||
palettes_options['sword']=args.sword_palettes[player]
|
||||
palettes_options['shield']=args.shield_palettes[player]
|
||||
palettes_options['link']=args.link_palettes[player]
|
||||
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
|
||||
|
||||
apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player],
|
||||
args.fastmenu[player], args.disablemusic[player], args.sprite[player],
|
||||
palettes_options, world, player, True,
|
||||
reduceflashing=args.reduceflashing[player] or args.race,
|
||||
triforcehud=args.triforcehud[player])
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
|
||||
mcsb_name = ''
|
||||
if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
|
||||
world.bigkeyshuffle[player]]):
|
||||
mcsb_name = '-keysanity'
|
||||
elif [world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
|
||||
world.bigkeyshuffle[player]].count(True) == 1:
|
||||
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else \
|
||||
'-compassshuffle' if world.compassshuffle[player] else \
|
||||
'-universal_keys' if world.keyshuffle[player] == "universal" else \
|
||||
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
|
||||
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
|
||||
world.bigkeyshuffle[player]]):
|
||||
mcsb_name = '-%s%s%s%sshuffle' % (
|
||||
'M' if world.mapshuffle[player] else '', 'C' if world.compassshuffle[player] else '',
|
||||
'U' if world.keyshuffle[player] == "universal" else 'S' if world.keyshuffle[player] else '',
|
||||
'B' if world.bigkeyshuffle[player] else '')
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
|
||||
outfilepname = f'_P{player}'
|
||||
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \
|
||||
if world.player_names[player][team] != 'Player%d' % player else ''
|
||||
outfilestuffs = {
|
||||
"logic": world.logic[player], # 0
|
||||
"difficulty": world.difficulty[player], # 1
|
||||
"item_functionality": world.item_functionality[player], # 2
|
||||
"mode": world.mode[player], # 3
|
||||
"goal": world.goal[player], # 4
|
||||
"timer": str(world.timer[player]), # 5
|
||||
"shuffle": world.shuffle[player], # 6
|
||||
"algorithm": world.algorithm, # 7
|
||||
"mscb": mcsb_name, # 8
|
||||
"retro": world.retro[player], # 9
|
||||
"progressive": world.progressive, # A
|
||||
"hints": 'True' if world.hints[player] else 'False' # B
|
||||
}
|
||||
# 0 1 2 3 4 5 6 7 8 9 A B
|
||||
outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % (
|
||||
# 0 1 2 3 4 5 6 7 8 9 A B C
|
||||
# _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints
|
||||
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity-retro
|
||||
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
|
||||
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
|
||||
outfilestuffs["logic"], # 0
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) is int:
|
||||
main_entrance = get_entrance_to_region(location.parent_region)
|
||||
if location.game != "A Link to the Past":
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.dungeon:
|
||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||
'Inverted Ganons Tower': 'Ganons Tower'} \
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
|
||||
outfilestuffs["difficulty"], # 1
|
||||
outfilestuffs["item_functionality"], # 2
|
||||
outfilestuffs["mode"], # 3
|
||||
outfilestuffs["goal"], # 4
|
||||
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
|
||||
oldmancaves = []
|
||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||
for index, take_any in enumerate(takeanyregions):
|
||||
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if
|
||||
world.retro[player]]:
|
||||
item = world.create_item(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
|
||||
outfilestuffs["shuffle"], # 6
|
||||
outfilestuffs["algorithm"], # 7
|
||||
outfilestuffs["mscb"], # 8
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
|
||||
"-retro" if outfilestuffs["retro"] == "True" else "", # 9
|
||||
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
|
||||
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
|
||||
) if not args.outputname else ''
|
||||
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
|
||||
rom.write_to_file(rompath, hide_enemizer=True)
|
||||
if args.create_diff:
|
||||
Patch.create_patch_file(rompath, player=player, player_name = world.player_names[player][team])
|
||||
return player, team, bytes(rom.name)
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor()
|
||||
multidata_task = None
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
rom_futures = []
|
||||
mod_futures = []
|
||||
for team in range(world.teams):
|
||||
for player in world.alttp_player_ids:
|
||||
rom_futures.append(pool.submit(_gen_rom, team, player))
|
||||
for player in world.factorio_player_ids:
|
||||
mod_futures.append(pool.submit(generate_mod, world, player))
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
|
||||
games = {}
|
||||
for slot in world.player_ids:
|
||||
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
||||
games[slot] = world.game[slot]
|
||||
precollected_items = {player: [] for player in range(1, world.players + 1)}
|
||||
for item in world.precollected_items:
|
||||
precollected_items[item.player].append(item.code)
|
||||
precollected_hints = {player: set() for player in range(1, world.players + 1)}
|
||||
# for now special case Factorio tech_tree_information
|
||||
sending_visible_players = set()
|
||||
for player in world.get_game_players("Factorio"):
|
||||
if world.tech_tree_information[player].value == 2:
|
||||
sending_visible_players.add(player)
|
||||
|
||||
def get_entrance_to_region(region: Region):
|
||||
for entrance in region.entrances:
|
||||
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
|
||||
return entrance
|
||||
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
|
||||
return get_entrance_to_region(entrance.parent_region)
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
|
||||
# collect ER hint info
|
||||
er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]}
|
||||
from worlds.alttp.Regions import RegionType
|
||||
for region in world.regions:
|
||||
if region.player in er_hint_data and region.locations:
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
for location in region.locations:
|
||||
if type(location.address) == int: # skips events and crystals
|
||||
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
|
||||
er_hint_data[region.player][location.address] = main_entrance.name
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
# item code None should be event, location.address should then also be None
|
||||
assert location.item.code is not None
|
||||
locations_data[location.player][location.address] = location.item.code, location.item.player
|
||||
if location.player in sending_visible_players and location.item.player != location.player:
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False)
|
||||
precollected_hints[location.player].add(hint)
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
elif location.item.name in args.start_hints[location.item.player]:
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False,
|
||||
er_hint_data.get(location.player, {}).get(location.address, ""))
|
||||
precollected_hints[location.player].add(hint)
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
|
||||
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
|
||||
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
|
||||
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"games": games,
|
||||
"names": [[name for player, name in sorted(world.player_name.items())]],
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"remote_items": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_items},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": get_options()["server_options"],
|
||||
"er_hint_data": er_hint_data,
|
||||
"precollected_items": precollected_items,
|
||||
"precollected_hints": precollected_hints,
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name
|
||||
}
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||
f.write(bytes([1])) # version of format
|
||||
f.write(multidata)
|
||||
|
||||
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
|
||||
main_entrance = get_entrance_to_region(location.parent_region)
|
||||
if location.game != Games.LTTP:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.dungeon:
|
||||
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
|
||||
'Inverted Ganons Tower': 'Ganons Tower'}\
|
||||
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
|
||||
checks_in_area[location.player][dungeonname].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif main_entrance.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
multidata_task = pool.submit(write_multidata)
|
||||
if not check_accessibility_task.result():
|
||||
if not world.can_beat_game():
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
|
||||
oldmancaves = []
|
||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||
for index, take_any in enumerate(takeanyregions):
|
||||
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if world.retro[player]]:
|
||||
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
# retrieve exceptions via .result() if they occured.
|
||||
if multidata_task:
|
||||
multidata_task.result()
|
||||
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures)):
|
||||
if i % 10 == 0:
|
||||
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
|
||||
future.result()
|
||||
|
||||
main_entrance = get_entrance_to_region(region)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
if not args.skip_playthrough:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
if args.create_spoiler:
|
||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||
|
||||
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata(roms, mods):
|
||||
import base64
|
||||
import NetUtils
|
||||
for future in roms:
|
||||
rom_name = future.result()
|
||||
rom_names.append(rom_name)
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
|
||||
games = {}
|
||||
for slot in world.player_ids:
|
||||
client_versions[slot] = (0, 0, 3)
|
||||
games[slot] = world.game[slot]
|
||||
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
|
||||
slot, team, rom_name in rom_names}
|
||||
precollected_items = {player: [] for player in range(1, world.players+1)}
|
||||
for item in world.precollected_items:
|
||||
precollected_items[item.player].append(item.code)
|
||||
precollected_hints = {player: set() for player in range(1, world.players+1)}
|
||||
# for now special case Factorio visibility
|
||||
sending_visible_players = set()
|
||||
for player in world.factorio_player_ids:
|
||||
if world.visibility[player]:
|
||||
sending_visible_players.add(player)
|
||||
|
||||
for i, team in enumerate(parsed_names):
|
||||
for player, name in enumerate(team, 1):
|
||||
if player not in world.alttp_player_ids:
|
||||
connect_names[name] = (i, player)
|
||||
for slot in world.hk_player_ids:
|
||||
slots_data = slot_data[slot] = {}
|
||||
for option_name in Options.hollow_knight_options:
|
||||
option = getattr(world, option_name)[slot]
|
||||
slots_data[option_name] = int(option.value)
|
||||
for slot in world.minecraft_player_ids:
|
||||
slot_data[slot] = fill_minecraft_slot_data(world, slot)
|
||||
|
||||
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
|
||||
for location in world.get_filled_locations():
|
||||
if type(location.address) == int:
|
||||
locations_data[location.player][location.address] = (location.item.code, location.item.player)
|
||||
if location.player in sending_visible_players and location.item.player != location.player:
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False)
|
||||
precollected_hints[location.player].add(hint)
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
elif location.item.name in args.start_hints[location.item.player]:
|
||||
hint = NetUtils.Hint(location.item.player, location.player, location.address,
|
||||
location.item.code, False,
|
||||
er_hint_data.get(location.player, {}).get(location.address, ""))
|
||||
precollected_hints[location.player].add(hint)
|
||||
precollected_hints[location.item.player].add(hint)
|
||||
|
||||
multidata = zlib.compress(pickle.dumps({
|
||||
"slot_data" : slot_data,
|
||||
"games": games,
|
||||
"names": parsed_names,
|
||||
"connect_names": connect_names,
|
||||
"remote_items": {player for player in range(1, world.players + 1) if
|
||||
world.remote_items[player]},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": get_options()["server_options"],
|
||||
"er_hint_data": er_hint_data,
|
||||
"precollected_items": precollected_items,
|
||||
"precollected_hints": precollected_hints,
|
||||
"version": tuple(_version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name
|
||||
}), 9)
|
||||
|
||||
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
|
||||
f.write(bytes([1])) # version of format
|
||||
f.write(multidata)
|
||||
for future in mods:
|
||||
future.result() # collect errors if they occured
|
||||
|
||||
multidata_task = pool.submit(write_multidata, rom_futures, mod_futures)
|
||||
if not check_accessibility_task.result():
|
||||
if not world.can_beat_game():
|
||||
raise Exception("Game appears as unbeatable. Aborting.")
|
||||
else:
|
||||
logger.warning("Location Accessibility requirements not fulfilled.")
|
||||
if multidata_task:
|
||||
multidata_task.result() # retrieve exception if one exists
|
||||
pool.shutdown() # wait for all queued tasks to complete
|
||||
for player in world.minecraft_player_ids: # Doing this after shutdown prevents the .apmc from being generated if there's an error
|
||||
generate_mc_data(world, player)
|
||||
if not args.skip_playthrough:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
if args.create_spoiler: # needs spoiler.hashes to be filled, that depend on rom_futures being done
|
||||
world.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
|
||||
zipfilename = output_path(f"AP_{world.seed_name}.zip")
|
||||
logger.info(f'Creating final archive at {zipfilename}.')
|
||||
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zf:
|
||||
for file in os.scandir(temp_dir):
|
||||
zf.write(file.path, arcname=file.name)
|
||||
|
||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||
return world
|
||||
@@ -599,9 +374,9 @@ def create_playthrough(world):
|
||||
sphere_candidates = set(prog_locations)
|
||||
logging.debug('Building up collection spheres.')
|
||||
while sphere_candidates:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
# build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
# build up spheres of collection radius.
|
||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
|
||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||
|
||||
@@ -632,7 +407,8 @@ def create_playthrough(world):
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, location.item.player)
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
location.item.player)
|
||||
old_item = location.item
|
||||
location.item = None
|
||||
if world.can_beat_game(state_cache[num]):
|
||||
@@ -677,7 +453,8 @@ def create_playthrough(world):
|
||||
|
||||
collection_spheres.append(sphere)
|
||||
|
||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere), len(required_locations))
|
||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere), len(required_locations))
|
||||
if not sphere:
|
||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||
|
||||
@@ -694,16 +471,22 @@ def create_playthrough(world):
|
||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||
return list(pathpairs)
|
||||
|
||||
world.spoiler.paths = dict()
|
||||
for player in range(1, world.players + 1):
|
||||
world.spoiler.paths.update({ str(location) : get_path(state, location.parent_region) for sphere in collection_spheres for location in sphere if location.player == player})
|
||||
if player in world.alttp_player_ids:
|
||||
for path in dict(world.spoiler.paths).values():
|
||||
if any(exit == 'Pyramid Fairy' for (_, exit) in path):
|
||||
if world.mode[player] != 'inverted':
|
||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state, world.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
||||
world.spoiler.paths = {}
|
||||
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
|
||||
for player in topology_worlds:
|
||||
world.spoiler.paths.update(
|
||||
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
||||
sphere if location.player == player})
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
|
||||
if world.mode[player] != 'inverted':
|
||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
||||
|
||||
# we can finally output our playthrough
|
||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])}
|
||||
|
||||
185
MinecraftClient.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import argparse
|
||||
import os, sys
|
||||
import re
|
||||
import atexit
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from base64 import b64decode
|
||||
from time import strftime
|
||||
|
||||
import requests
|
||||
|
||||
import Utils
|
||||
|
||||
atexit.register(input, "Press enter to exit.")
|
||||
|
||||
# 1 or more digits followed by m or g, then optional b
|
||||
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
|
||||
|
||||
|
||||
def prompt_yes_no(prompt):
|
||||
yes_inputs = {'yes', 'ye', 'y'}
|
||||
no_inputs = {'no', 'n'}
|
||||
while True:
|
||||
choice = input(prompt + " [y/n] ").lower()
|
||||
if choice in yes_inputs:
|
||||
return True
|
||||
elif choice in no_inputs:
|
||||
return False
|
||||
else:
|
||||
print('Please respond with "y" or "n".')
|
||||
|
||||
|
||||
# Find Forge jar file; raise error if not found
|
||||
def find_forge_jar(forge_dir):
|
||||
for entry in os.scandir(forge_dir):
|
||||
if ".jar" in entry.name and "forge" in entry.name:
|
||||
print(f"Found forge .jar: {entry.name}")
|
||||
return entry.name
|
||||
raise FileNotFoundError(f"Could not find forge .jar in {forge_dir}.")
|
||||
|
||||
|
||||
# Create mods folder if needed; find AP randomizer jar; return None if not found.
|
||||
def find_ap_randomizer_jar(forge_dir):
|
||||
mods_dir = os.path.join(forge_dir, 'mods')
|
||||
if os.path.isdir(mods_dir):
|
||||
ap_mod_re = re.compile(r"^aprandomizer-[\d\.]+\.jar$")
|
||||
for entry in os.scandir(mods_dir):
|
||||
match = ap_mod_re.match(entry.name)
|
||||
if match:
|
||||
print(f"Found AP randomizer mod: {match.group()}")
|
||||
return match.group()
|
||||
return None
|
||||
else:
|
||||
os.mkdir(mods_dir)
|
||||
print(f"Created mods folder in {forge_dir}")
|
||||
return None
|
||||
|
||||
|
||||
# Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory.
|
||||
def replace_apmc_files(forge_dir, apmc_file):
|
||||
if apmc_file is None:
|
||||
return
|
||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
||||
copy_apmc = True
|
||||
if not os.path.isdir(apdata_dir):
|
||||
os.mkdir(apdata_dir)
|
||||
print(f"Created APData folder in {forge_dir}")
|
||||
for entry in os.scandir(apdata_dir):
|
||||
if entry.name.endswith(".apmc") and entry.is_file():
|
||||
if not os.path.samefile(apmc_file, entry.path):
|
||||
os.remove(entry.path)
|
||||
print(f"Removed {entry.name} in {apdata_dir}")
|
||||
else: # apmc already in apdata
|
||||
copy_apmc = False
|
||||
if copy_apmc:
|
||||
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
|
||||
print(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
||||
|
||||
|
||||
# Check mod version, download new mod from GitHub releases page if needed.
|
||||
def update_mod(forge_dir):
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
|
||||
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
|
||||
resp = requests.get(client_releases_endpoint)
|
||||
if resp.status_code == 200: # OK
|
||||
latest_release = resp.json()[0]
|
||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||
print(f"A new release of the Minecraft AP randomizer mod was found: {latest_release['assets'][0]['name']}")
|
||||
if ap_randomizer is not None:
|
||||
print(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
print(f"You do not have the AP randomizer mod installed.")
|
||||
if prompt_yes_no("Would you like to update?"):
|
||||
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
|
||||
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
|
||||
print("Downloading AP randomizer mod. This may take a moment...")
|
||||
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
|
||||
if apmod_resp.status_code == 200:
|
||||
with open(new_ap_mod, 'wb') as f:
|
||||
f.write(apmod_resp.content)
|
||||
print(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
print(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
print(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
print(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
|
||||
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
|
||||
if not prompt_yes_no("Continue anyways?"):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Check if the EULA is agreed to, and prompt the user to read and agree if necessary.
|
||||
def check_eula(forge_dir):
|
||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
||||
if not os.path.isfile(eula_path):
|
||||
# Create eula.txt
|
||||
with open(eula_path, 'w') as f:
|
||||
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
|
||||
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
|
||||
f.write("eula=false\n")
|
||||
with open(eula_path, 'r+') as f:
|
||||
text = f.read()
|
||||
if 'false' in text:
|
||||
# Prompt user to agree to the EULA
|
||||
print("You need to agree to the Minecraft EULA in order to run the server.")
|
||||
print("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
|
||||
if prompt_yes_no("Do you agree to the EULA?"):
|
||||
f.seek(0)
|
||||
f.write(text.replace('false', 'true'))
|
||||
f.truncate()
|
||||
print(f"Set {eula_path} to true")
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# Run the Forge server. Return process object
|
||||
def run_forge_server(forge_dir, heap_arg):
|
||||
forge_server = find_forge_jar(forge_dir)
|
||||
|
||||
java_exe = os.path.abspath(os.path.join('jre8', 'bin', 'java.exe'))
|
||||
if not os.path.isfile(java_exe):
|
||||
java_exe = "java" # try to fall back on java in the PATH
|
||||
|
||||
heap_arg = max_heap_re.match(max_heap).group()
|
||||
if heap_arg[-1] in ['b', 'B']:
|
||||
heap_arg = heap_arg[:-1]
|
||||
heap_arg = "-Xmx" + heap_arg
|
||||
|
||||
argstring = ' '.join([java_exe, heap_arg, "-jar", forge_server, "-nogui"])
|
||||
print(f"Running Forge server: {argstring}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(argstring)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
||||
|
||||
args = parser.parse_args()
|
||||
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
|
||||
|
||||
# Change to executable's working directory
|
||||
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
|
||||
|
||||
options = Utils.get_options()
|
||||
forge_dir = options["minecraft_options"]["forge_directory"]
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
|
||||
if apmc_file is not None and not os.path.isfile(apmc_file):
|
||||
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
|
||||
if not os.path.isdir(forge_dir):
|
||||
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
|
||||
if not max_heap_re.match(max_heap):
|
||||
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
|
||||
|
||||
update_mod(forge_dir)
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, max_heap)
|
||||
server_process.wait()
|
||||
@@ -1,56 +1,59 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import importlib
|
||||
import pkg_resources
|
||||
|
||||
requirements_files = {'requirements.txt'}
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
|
||||
update_ran = hasattr(sys, "frozen") and getattr(sys, "frozen") # don't run update if environment is frozen/compiled
|
||||
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir("worlds"):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
requirements_files.add(req_file)
|
||||
|
||||
|
||||
def update_command():
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', 'requirements.txt', '--upgrade'])
|
||||
for file in requirements_files:
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
||||
|
||||
|
||||
naming_specialties = {"PyYAML": "yaml", # PyYAML is imported as the name yaml
|
||||
"maseya-z3pr": "maseya",
|
||||
"factorio-rcon-py": "factorio_rcon"}
|
||||
|
||||
|
||||
def update():
|
||||
def update(yes = False, force = False):
|
||||
global update_ran
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), 'requirements.txt')
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(os.path.dirname(__file__), 'requirements.txt')
|
||||
with open(path) as requirementsfile:
|
||||
for line in requirementsfile.readlines():
|
||||
module, remote_version = line.split(">=")
|
||||
module = naming_specialties.get(module, module)
|
||||
try:
|
||||
module = importlib.import_module(module)
|
||||
except:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Required python module {module} not found, press enter to install it')
|
||||
update_command()
|
||||
return
|
||||
else:
|
||||
if hasattr(module, "__version__"):
|
||||
module_version = module.__version__
|
||||
module = module.__name__ # also unloads the module to make it writable
|
||||
if type(module_version) == str:
|
||||
module_version = tuple(int(part.strip()) for part in module_version.split("."))
|
||||
remote_version = tuple(int(part.strip()) for part in remote_version.split("."))
|
||||
if module_version < remote_version:
|
||||
input(f'Required python module {module} is outdated ({module_version}<{remote_version}),'
|
||||
' press enter to upgrade it')
|
||||
update_command()
|
||||
return
|
||||
if force:
|
||||
update_command()
|
||||
return
|
||||
for req_file in requirements_files:
|
||||
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
|
||||
if not os.path.exists(path):
|
||||
path = os.path.join(os.path.dirname(__file__), req_file)
|
||||
with open(path) as requirementsfile:
|
||||
requirements = pkg_resources.parse_requirements(requirementsfile)
|
||||
for requirement in requirements:
|
||||
requirement = str(requirement)
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
except pkg_resources.ResolutionError:
|
||||
if not yes:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
update_command()
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
update()
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Install archipelago requirements')
|
||||
parser.add_argument('-y', '--yes', dest='yes', action='store_true', help='answer "yes" to all questions')
|
||||
parser.add_argument('-f', '--force', dest='force', action='store_true', help='force update')
|
||||
args = parser.parse_args()
|
||||
|
||||
update(args.yes, args.force)
|
||||
|
||||
232
MultiMystery.py
@@ -1,232 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
|
||||
|
||||
def feedback(text: str):
|
||||
logging.info(text)
|
||||
input("Press Enter to ignore and probably crash.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(format='%(message)s', level=logging.INFO)
|
||||
try:
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument('--disable_autohost', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
from Utils import get_public_ipv4, get_options
|
||||
from Mystery import get_seed_name
|
||||
|
||||
options = get_options()
|
||||
|
||||
multi_mystery_options = options["multi_mystery_options"]
|
||||
output_path = options["general_options"]["output_path"]
|
||||
enemizer_path = multi_mystery_options["enemizer_path"]
|
||||
player_files_path = multi_mystery_options["player_files_path"]
|
||||
target_player_count = multi_mystery_options["players"]
|
||||
glitch_triforce = multi_mystery_options["glitch_triforce_room"]
|
||||
race = multi_mystery_options["race"]
|
||||
plando_options = multi_mystery_options["plando_options"]
|
||||
create_spoiler = multi_mystery_options["create_spoiler"]
|
||||
zip_roms = multi_mystery_options["zip_roms"]
|
||||
zip_diffs = multi_mystery_options["zip_diffs"]
|
||||
zip_apmcs = multi_mystery_options["zip_apmcs"]
|
||||
zip_spoiler = multi_mystery_options["zip_spoiler"]
|
||||
zip_multidata = multi_mystery_options["zip_multidata"]
|
||||
zip_format = multi_mystery_options["zip_format"]
|
||||
# zip_password = multi_mystery_options["zip_password"] not at this time
|
||||
player_name = multi_mystery_options["player_name"]
|
||||
meta_file_path = multi_mystery_options["meta_file_path"]
|
||||
weights_file_path = multi_mystery_options["weights_file_path"]
|
||||
pre_roll = multi_mystery_options["pre_roll"]
|
||||
teams = multi_mystery_options["teams"]
|
||||
rom_file = options["lttp_options"]["rom_file"]
|
||||
host = options["server_options"]["host"]
|
||||
port = options["server_options"]["port"]
|
||||
|
||||
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
|
||||
if not os.path.exists(enemizer_path):
|
||||
feedback(
|
||||
f"Enemizer not found at {enemizer_path}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.")
|
||||
if not os.path.exists(rom_file):
|
||||
feedback(f"Base rom is expected as {rom_file} in the Multiworld root folder please place/rename it there.")
|
||||
player_files = []
|
||||
os.makedirs(player_files_path, exist_ok=True)
|
||||
for file in os.listdir(player_files_path):
|
||||
lfile = file.lower()
|
||||
if lfile.endswith(".yaml") and lfile != meta_file_path.lower() and lfile != weights_file_path.lower():
|
||||
player_files.append(file)
|
||||
logging.info(f"Found player's file {file}.")
|
||||
|
||||
player_string = ""
|
||||
for i, file in enumerate(player_files, 1):
|
||||
player_string += f"--p{i} \"{os.path.join(player_files_path, file)}\" "
|
||||
|
||||
if os.path.exists("ArchipelagoMystery.exe"):
|
||||
basemysterycommand = "ArchipelagoMystery.exe" # compiled windows
|
||||
elif os.path.exists("ArchipelagoMystery"):
|
||||
basemysterycommand = "ArchipelagoMystery" # compiled linux
|
||||
else:
|
||||
basemysterycommand = f"py -{py_version} Mystery.py" # source
|
||||
|
||||
weights_file_path = os.path.join(player_files_path, weights_file_path)
|
||||
if os.path.exists(weights_file_path):
|
||||
target_player_count = max(len(player_files), target_player_count)
|
||||
else:
|
||||
target_player_count = len(player_files)
|
||||
|
||||
if target_player_count == 0:
|
||||
feedback(f"No player files found. Please put them in a {player_files_path} folder.")
|
||||
else:
|
||||
logging.info(f"{target_player_count} Players found.")
|
||||
seed_name = get_seed_name(random)
|
||||
command = f"{basemysterycommand} --multi {target_player_count} {player_string} " \
|
||||
f"--rom \"{rom_file}\" --enemizercli \"{enemizer_path}\" " \
|
||||
f"--outputpath \"{output_path}\" --teams {teams} --plando \"{plando_options}\" " \
|
||||
f"--seed_name {seed_name}"
|
||||
|
||||
if create_spoiler:
|
||||
command += " --create_spoiler"
|
||||
if create_spoiler == 2:
|
||||
command += " --skip_playthrough"
|
||||
if zip_diffs:
|
||||
command += " --create_diff"
|
||||
if glitch_triforce:
|
||||
command += " --glitch_triforce"
|
||||
if race:
|
||||
command += " --race"
|
||||
if os.path.exists(os.path.join(player_files_path, meta_file_path)):
|
||||
command += f" --meta {os.path.join(player_files_path, meta_file_path)}"
|
||||
if os.path.exists(weights_file_path):
|
||||
command += f" --weights {weights_file_path}"
|
||||
if pre_roll:
|
||||
command += " --pre_roll"
|
||||
|
||||
logging.info(command)
|
||||
import time
|
||||
|
||||
start = time.perf_counter()
|
||||
text = subprocess.check_output(command, shell=True).decode()
|
||||
logging.info(f"Took {time.perf_counter() - start:.3f} seconds to generate multiworld.")
|
||||
|
||||
multidataname = f"AP_{seed_name}.archipelago"
|
||||
spoilername = f"AP_{seed_name}_Spoiler.txt"
|
||||
romfilename = ""
|
||||
|
||||
if player_name:
|
||||
for file in os.listdir(output_path):
|
||||
if player_name in file:
|
||||
import MultiClient
|
||||
import asyncio
|
||||
|
||||
asyncio.run(MultiClient.run_game(os.path.join(output_path, file)))
|
||||
break
|
||||
|
||||
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs, zip_apmcs)):
|
||||
import zipfile
|
||||
|
||||
compression = {1: zipfile.ZIP_DEFLATED,
|
||||
2: zipfile.ZIP_LZMA,
|
||||
3: zipfile.ZIP_BZIP2}[zip_format]
|
||||
|
||||
typical_zip_ending = {1: "zip",
|
||||
2: "7z",
|
||||
3: "bz2"}[zip_format]
|
||||
|
||||
ziplock = threading.Lock()
|
||||
|
||||
|
||||
def pack_file(file: str):
|
||||
with ziplock:
|
||||
zf.write(os.path.join(output_path, file), file)
|
||||
logging.info(f"Packed {file} into zipfile {zipname}")
|
||||
|
||||
|
||||
def remove_zipped_file(file: str):
|
||||
os.remove(os.path.join(output_path, file))
|
||||
logging.info(f"Removed {file} which is now present in the zipfile")
|
||||
|
||||
|
||||
zipname = os.path.join(output_path, f"AP_{seed_name}.{typical_zip_ending}")
|
||||
|
||||
logging.info(f"Creating zipfile {zipname}")
|
||||
ipv4 = (host if host else get_public_ipv4()) + ":" + str(port)
|
||||
|
||||
|
||||
def _handle_sfc_file(file: str):
|
||||
if zip_roms:
|
||||
pack_file(file)
|
||||
if zip_roms == 2 and player_name.lower() not in file.lower():
|
||||
remove_zipped_file(file)
|
||||
|
||||
|
||||
def _handle_diff_file(file: str):
|
||||
if zip_diffs > 0:
|
||||
pack_file(file)
|
||||
if zip_diffs == 2:
|
||||
remove_zipped_file(file)
|
||||
|
||||
|
||||
def _handle_apmc_file(file: str):
|
||||
if zip_apmcs:
|
||||
pack_file(file)
|
||||
if zip_apmcs == 2:
|
||||
remove_zipped_file(file)
|
||||
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
futures = []
|
||||
files = os.listdir(output_path)
|
||||
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
|
||||
for file in files:
|
||||
if seed_name in file:
|
||||
if file.endswith(".sfc"):
|
||||
futures.append(pool.submit(_handle_sfc_file, file))
|
||||
elif file.endswith(".apbp"):
|
||||
futures.append(pool.submit(_handle_diff_file, file))
|
||||
elif file.endswith(".apmc"):
|
||||
futures.append(pool.submit(_handle_apmc_file, file))
|
||||
# just handle like a diff file for now
|
||||
elif file.endswith(".zip"):
|
||||
futures.append(pool.submit(_handle_diff_file, file))
|
||||
|
||||
if zip_multidata and os.path.exists(os.path.join(output_path, multidataname)):
|
||||
pack_file(multidataname)
|
||||
if zip_multidata == 2:
|
||||
remove_zipped_file(multidataname)
|
||||
|
||||
if zip_spoiler and create_spoiler:
|
||||
pack_file(spoilername)
|
||||
if zip_spoiler == 2:
|
||||
remove_zipped_file(spoilername)
|
||||
|
||||
for future in futures:
|
||||
future.result() # make sure we close the zip AFTER any packing is done
|
||||
|
||||
if not args.disable_autohost:
|
||||
if os.path.exists(os.path.join(output_path, multidataname)):
|
||||
if os.path.exists("ArchipelagoServer.exe"):
|
||||
baseservercommand = "ArchipelagoServer.exe" # compiled windows
|
||||
elif os.path.exists("ArchipelagoServer"):
|
||||
baseservercommand = "ArchipelagoServer" # compiled linux
|
||||
else:
|
||||
baseservercommand = f"py -{py_version} MultiServer.py" # source
|
||||
# don't have a mac to test that. If you try to run compiled on mac, good luck.
|
||||
|
||||
subprocess.call(f"{baseservercommand} --multidata {os.path.join(output_path, multidataname)}")
|
||||
except:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close")
|
||||
463
MultiServer.py
@@ -25,19 +25,16 @@ import prompt_toolkit
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
from fuzzywuzzy import process as fuzzy_process
|
||||
|
||||
from worlds.alttp import Items, Regions
|
||||
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_item_name_to_id, \
|
||||
lookup_any_location_id_to_name, lookup_any_location_name_to_id
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
|
||||
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||
import Utils
|
||||
from Utils import get_item_name_from_id, get_location_name_from_id, \
|
||||
_version_tuple, restricted_loads, Version
|
||||
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
|
||||
version_tuple, restricted_loads, Version
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer
|
||||
|
||||
colorama.init()
|
||||
lttp_console_names = frozenset(set(Items.item_table) | set(Items.item_name_groups) | set(Regions.lookup_name_to_id))
|
||||
all_items = frozenset(lookup_any_item_name_to_id)
|
||||
all_locations = frozenset(lookup_any_location_name_to_id)
|
||||
all_console_names = frozenset(all_items | all_locations)
|
||||
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
@@ -55,7 +52,13 @@ class Client(Endpoint):
|
||||
self.ctx = weakref.ref(ctx)
|
||||
|
||||
|
||||
class Context(Node):
|
||||
team_slot = typing.Tuple[int, int]
|
||||
|
||||
|
||||
class Context:
|
||||
dumper = staticmethod(encode)
|
||||
loader = staticmethod(decode)
|
||||
|
||||
simple_options = {"hint_cost": int,
|
||||
"location_check_points": int,
|
||||
"server_password": str,
|
||||
@@ -67,18 +70,21 @@ class Context(Node):
|
||||
|
||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled",
|
||||
auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2):
|
||||
auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, log_network: bool = False):
|
||||
super(Context, self).__init__()
|
||||
self.log_network = log_network
|
||||
self.endpoints = []
|
||||
self.compatibility: int = compatibility
|
||||
self.shutdown_task = None
|
||||
self.data_filename = None
|
||||
self.save_filename = None
|
||||
self.saving = False
|
||||
self.player_names = {}
|
||||
self.connect_names = {} # names of slots clients can connect to
|
||||
self.player_names: typing.Dict[team_slot, str] = {}
|
||||
self.player_name_lookup: typing.Dict[str, team_slot] = {}
|
||||
self.connect_names = {} # names of slots clients can connect to
|
||||
self.allow_forfeits = {}
|
||||
self.remote_items = set()
|
||||
self.locations:typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
|
||||
self.locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.server_password = server_password
|
||||
@@ -86,21 +92,21 @@ class Context(Node):
|
||||
self.server = None
|
||||
self.countdown_timer = 0
|
||||
self.received_items = {}
|
||||
self.name_aliases: typing.Dict[typing.Tuple[int, int], str] = {}
|
||||
self.name_aliases: typing.Dict[team_slot, str] = {}
|
||||
self.location_checks = collections.defaultdict(set)
|
||||
self.hint_cost = hint_cost
|
||||
self.location_check_points = location_check_points
|
||||
self.hints_used = collections.defaultdict(int)
|
||||
self.hints: typing.Dict[typing.Tuple[int, int], typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
||||
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
||||
self.forfeit_mode: str = forfeit_mode
|
||||
self.remaining_mode: str = remaining_mode
|
||||
self.item_cheat = item_cheat
|
||||
self.running = True
|
||||
self.client_activity_timers: typing.Dict[
|
||||
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last new item check
|
||||
team_slot, datetime.datetime] = {} # datetime of last new item check
|
||||
self.client_connection_timers: typing.Dict[
|
||||
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last connection
|
||||
self.client_game_state: typing.Dict[typing.Tuple[int, int], int] = collections.defaultdict(int)
|
||||
team_slot, datetime.datetime] = {} # datetime of last connection
|
||||
self.client_game_state: typing.Dict[team_slot, int] = collections.defaultdict(int)
|
||||
self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {}
|
||||
self.auto_shutdown = auto_shutdown
|
||||
self.commandprocessor = ServerCommandProcessor(self)
|
||||
@@ -110,20 +116,91 @@ class Context(Node):
|
||||
self.auto_saver_thread = None
|
||||
self.save_dirty = False
|
||||
self.tags = ['AP']
|
||||
self.games = {}
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
|
||||
self.seed_name = ""
|
||||
self.random = random.Random()
|
||||
|
||||
def get_hint_cost(self, slot):
|
||||
if self.hint_cost:
|
||||
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||
return 0
|
||||
# General networking
|
||||
|
||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
|
||||
if not endpoint.socket or not endpoint.socket.open:
|
||||
return False
|
||||
msg = self.dumper(msgs)
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception(f"Exception during send_msgs, could not send {msg}")
|
||||
await self.disconnect(endpoint)
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
return True
|
||||
|
||||
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
|
||||
if not endpoint.socket or not endpoint.socket.open:
|
||||
return False
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception("Exception during send_encoded_msgs")
|
||||
await self.disconnect(endpoint)
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
return True
|
||||
|
||||
def broadcast_all(self, msgs):
|
||||
msgs = self.dumper(msgs)
|
||||
for endpoint in self.endpoints:
|
||||
if endpoint.auth:
|
||||
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
|
||||
|
||||
def broadcast_team(self, team, msgs):
|
||||
msgs = self.dumper(msgs)
|
||||
for client in self.endpoints:
|
||||
if client.auth and client.team == team:
|
||||
asyncio.create_task(self.send_encoded_msgs(client, msgs))
|
||||
|
||||
async def disconnect(self, endpoint):
|
||||
if endpoint in self.endpoints:
|
||||
self.endpoints.remove(endpoint)
|
||||
await on_client_disconnected(self, endpoint)
|
||||
|
||||
# text
|
||||
|
||||
def notify_all(self, text):
|
||||
logging.info("Notice (all): %s" % text)
|
||||
self.broadcast_all([{"cmd": "Print", "text": text}])
|
||||
|
||||
def notify_client(self, client: Client, text: str):
|
||||
if not client.auth:
|
||||
return
|
||||
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
||||
if not client.auth:
|
||||
return
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||
|
||||
# loading
|
||||
|
||||
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
|
||||
with open(multidatapath, 'rb') as f:
|
||||
data = f.read()
|
||||
if multidatapath.lower().endswith(".zip"):
|
||||
import zipfile
|
||||
with zipfile.ZipFile(multidatapath) as zf:
|
||||
for file in zf.namelist():
|
||||
if file.endswith(".archipelago"):
|
||||
data = zf.read(file)
|
||||
break
|
||||
else:
|
||||
raise Exception("No .archipelago found in archive.")
|
||||
else:
|
||||
with open(multidatapath, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
self._load(self._decompress(data), use_embedded_server_options)
|
||||
self._load(self._decompress(data), use_embedded_server_options)
|
||||
self.data_filename = multidatapath
|
||||
|
||||
@staticmethod
|
||||
@@ -136,9 +213,9 @@ class Context(Node):
|
||||
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
|
||||
|
||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||
if mdata_ver > Utils._version_tuple:
|
||||
if mdata_ver > Utils.version_tuple:
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||
f"however this server is of version {Utils._version_tuple}")
|
||||
f"however this server is of version {Utils.version_tuple}")
|
||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||
self.minimum_client_versions = {}
|
||||
for player, version in clients_ver.items():
|
||||
@@ -146,8 +223,10 @@ class Context(Node):
|
||||
|
||||
for team, names in enumerate(decoded_obj['names']):
|
||||
for player, name in enumerate(names, 1):
|
||||
self.player_names[(team, player)] = name
|
||||
self.player_names[team, player] = name
|
||||
self.player_name_lookup[name] = team, player
|
||||
self.seed_name = decoded_obj["seed_name"]
|
||||
self.random.seed(self.seed_name)
|
||||
self.connect_names = decoded_obj['connect_names']
|
||||
self.remote_items = decoded_obj['remote_items']
|
||||
self.locations = decoded_obj['locations']
|
||||
@@ -166,28 +245,7 @@ class Context(Node):
|
||||
server_options = decoded_obj.get("server_options", {})
|
||||
self._set_options(server_options)
|
||||
|
||||
|
||||
def get_players_package(self):
|
||||
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
|
||||
|
||||
def _set_options(self, server_options: dict):
|
||||
for key, value in server_options.items():
|
||||
data_type = self.simple_options.get(key, None)
|
||||
if data_type is not None:
|
||||
if value not in {False, True, None}: # some can be boolean OR text, such as password
|
||||
try:
|
||||
value = data_type(value)
|
||||
except Exception as e:
|
||||
try:
|
||||
raise Exception(f"Could not set server option {key}, skipping.") from e
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
|
||||
setattr(self, key, value)
|
||||
elif key == "disable_item_cheat":
|
||||
self.item_cheat = not bool(value)
|
||||
else:
|
||||
logging.debug(f"Unrecognized server option {key}")
|
||||
# saving
|
||||
|
||||
def save(self, now=False) -> bool:
|
||||
if self.saving:
|
||||
@@ -200,7 +258,7 @@ class Context(Node):
|
||||
|
||||
return False
|
||||
|
||||
def _save(self, exit_save:bool=False) -> bool:
|
||||
def _save(self, exit_save: bool = False) -> bool:
|
||||
try:
|
||||
encoded_save = pickle.dumps(self.get_save())
|
||||
with open(self.save_filename, "wb") as f:
|
||||
@@ -215,8 +273,10 @@ class Context(Node):
|
||||
self.saving = enabled
|
||||
if self.saving:
|
||||
if not self.save_filename:
|
||||
self.save_filename = (self.data_filename[:-11] if self.data_filename.endswith('.archipelago') else (
|
||||
self.data_filename + '_')) + 'apsave'
|
||||
import os
|
||||
name, ext = os.path.splitext(self.data_filename)
|
||||
self.save_filename = name + '.apsave' if ext.lower() in ('.archipelago','.zip') \
|
||||
else self.data_filename + '_' + 'apsave'
|
||||
try:
|
||||
with open(self.save_filename, 'rb') as f:
|
||||
save_data = restricted_loads(zlib.decompress(f.read()))
|
||||
@@ -245,6 +305,7 @@ class Context(Node):
|
||||
atexit.register(self._save, True) # make sure we save on exit too
|
||||
|
||||
def get_save(self) -> dict:
|
||||
self.recheck_hints()
|
||||
d = {
|
||||
"connect_names": self.connect_names,
|
||||
"received_items": self.received_items,
|
||||
@@ -257,6 +318,7 @@ class Context(Node):
|
||||
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
|
||||
"client_connection_timers": tuple(
|
||||
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
|
||||
"random_state": self.random.getstate()
|
||||
}
|
||||
|
||||
return d
|
||||
@@ -277,47 +339,53 @@ class Context(Node):
|
||||
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
|
||||
in savedata["client_activity_timers"]})
|
||||
self.location_checks.update(savedata["location_checks"])
|
||||
|
||||
if "random_state" in savedata:
|
||||
self.random.setstate(savedata["random_state"])
|
||||
logging.info(f'Loaded save file with {sum([len(p) for p in self.received_items.values()])} received items '
|
||||
f'for {len(self.received_items)} players')
|
||||
|
||||
# rest
|
||||
|
||||
def get_hint_cost(self, slot):
|
||||
if self.hint_cost:
|
||||
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||
return 0
|
||||
|
||||
def recheck_hints(self):
|
||||
for team, slot in self.hints:
|
||||
self.hints[team, slot] = {
|
||||
hint.re_check(self, team) for hint in
|
||||
self.hints[team, slot]
|
||||
}
|
||||
|
||||
def get_players_package(self):
|
||||
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
|
||||
|
||||
def _set_options(self, server_options: dict):
|
||||
for key, value in server_options.items():
|
||||
data_type = self.simple_options.get(key, None)
|
||||
if data_type is not None:
|
||||
if value not in {False, True, None}: # some can be boolean OR text, such as password
|
||||
try:
|
||||
value = data_type(value)
|
||||
except Exception as e:
|
||||
try:
|
||||
raise Exception(f"Could not set server option {key}, skipping.") from e
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
|
||||
setattr(self, key, value)
|
||||
elif key == "disable_item_cheat":
|
||||
self.item_cheat = not bool(value)
|
||||
else:
|
||||
logging.debug(f"Unrecognized server option {key}")
|
||||
|
||||
def get_aliased_name(self, team: int, slot: int):
|
||||
if (team, slot) in self.name_aliases:
|
||||
return f"{self.name_aliases[team, slot]} ({self.player_names[team, slot]})"
|
||||
else:
|
||||
return self.player_names[team, slot]
|
||||
|
||||
def notify_all(self, text):
|
||||
logging.info("Notice (all): %s" % text)
|
||||
self.broadcast_all([{"cmd": "Print", "text": text}])
|
||||
|
||||
def notify_client(self, client: Client, text: str):
|
||||
if not client.auth:
|
||||
return
|
||||
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
|
||||
if not client.auth:
|
||||
return
|
||||
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||
|
||||
def broadcast_team(self, team, msgs):
|
||||
msgs = self.dumper(msgs)
|
||||
for client in self.endpoints:
|
||||
if client.auth and client.team == team:
|
||||
asyncio.create_task(self.send_encoded_msgs(client, msgs))
|
||||
|
||||
def broadcast_all(self, msgs):
|
||||
msgs = self.dumper(msgs)
|
||||
for endpoint in self.endpoints:
|
||||
if endpoint.auth:
|
||||
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
|
||||
|
||||
async def disconnect(self, endpoint):
|
||||
await super(Context, self).disconnect(endpoint)
|
||||
await on_client_disconnected(self, endpoint)
|
||||
|
||||
|
||||
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
|
||||
concerns = collections.defaultdict(list)
|
||||
@@ -366,7 +434,8 @@ async def server(websocket, path, ctx: Context):
|
||||
if not isinstance(e, websockets.WebSocketException):
|
||||
logging.exception(e)
|
||||
finally:
|
||||
logging.info("Disconnected")
|
||||
if ctx.log_network:
|
||||
logging.info("Disconnected")
|
||||
await ctx.disconnect(client)
|
||||
|
||||
|
||||
@@ -374,17 +443,21 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
await ctx.send_msgs(client, [{
|
||||
'cmd': 'RoomInfo',
|
||||
'password': ctx.password is not None,
|
||||
'players': [NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name), client.name) for client
|
||||
in ctx.endpoints if client.auth],
|
||||
'players': [
|
||||
NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name),
|
||||
client.name) for client
|
||||
in ctx.endpoints if client.auth],
|
||||
# tags are for additional features in the communication.
|
||||
# Name them by feature or fork, as you feel is appropriate.
|
||||
'tags': ctx.tags,
|
||||
'version': Utils._version_tuple,
|
||||
'version': Utils.version_tuple,
|
||||
'forfeit_mode': ctx.forfeit_mode,
|
||||
'remaining_mode': ctx.remaining_mode,
|
||||
'hint_cost': ctx.hint_cost,
|
||||
'location_check_points': ctx.location_check_points,
|
||||
'datapackage_version': network_data_package["version"],
|
||||
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||
in network_data_package["games"].items()},
|
||||
'seed_name': ctx.seed_name
|
||||
}])
|
||||
|
||||
@@ -398,14 +471,17 @@ async def on_client_joined(ctx: Context, client: Client):
|
||||
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
|
||||
version_str = '.'.join(str(x) for x in client.version)
|
||||
ctx.notify_all(
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has joined the game. "
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
|
||||
f"playing {ctx.games[client.slot]} has joined. "
|
||||
f"Client({version_str}), {client.tags}).")
|
||||
|
||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
async def on_client_left(ctx: Context, client: Client):
|
||||
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
|
||||
ctx.notify_all("%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
|
||||
ctx.notify_all(
|
||||
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
|
||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
@@ -447,7 +523,7 @@ def get_received_items(ctx: Context, team: int, player: int) -> typing.List[Netw
|
||||
|
||||
def send_new_items(ctx: Context):
|
||||
for client in ctx.endpoints:
|
||||
if client.auth: # can't send to disconnected client
|
||||
if client.auth: # can't send to disconnected client
|
||||
items = get_received_items(ctx, client.team, client.slot)
|
||||
if len(items) > client.send_index:
|
||||
asyncio.create_task(ctx.send_msgs(client, [{
|
||||
@@ -504,10 +580,9 @@ def notify_team(ctx: Context, team: int, text: str):
|
||||
ctx.broadcast_team(team, [['Print', {"text": text}]])
|
||||
|
||||
|
||||
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
|
||||
hints = []
|
||||
seeked_item_id = lookup_any_item_name_to_id[item]
|
||||
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
|
||||
for finding_player, check_data in ctx.locations.items():
|
||||
for location_id, result in check_data.items():
|
||||
item_id, receiving_player = result
|
||||
@@ -520,8 +595,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
|
||||
|
||||
|
||||
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
|
||||
|
||||
seeked_location: int = Regions.lookup_name_to_id[location]
|
||||
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
|
||||
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
|
||||
if item_id:
|
||||
found = seeked_location in ctx.location_checks[team, slot]
|
||||
@@ -540,6 +614,7 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
|
||||
text += f" at {hint.entrance}"
|
||||
return text + (". (found)" if hint.found else ".")
|
||||
|
||||
|
||||
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||
parts = []
|
||||
NetUtils.add_json_text(parts, net_item.player, type=NetUtils.JSONTypes.player_id)
|
||||
@@ -557,9 +632,11 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||
NetUtils.add_json_text(parts, ")")
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "ItemSend",
|
||||
"receiving": receiving_player, "sending": net_item.player}
|
||||
"receiving": receiving_player,
|
||||
"item": net_item}
|
||||
|
||||
def get_intended_text(input_text: str, possible_answers: typing.Iterable[str]= all_console_names) -> typing.Tuple[str, bool, str]:
|
||||
|
||||
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
||||
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
|
||||
if len(picks) > 1:
|
||||
dif = picks[0][1] - picks[1][1]
|
||||
@@ -684,11 +761,12 @@ class CommonCommandProcessor(CommandProcessor):
|
||||
"""List all current options. Warning: lists password."""
|
||||
self.output("Current options:")
|
||||
for option in self.ctx.simple_options:
|
||||
if option == "server_password" and self.marker == "!": #Do not display the server password to the client.
|
||||
self.output(f"Option server_password is set to {('*' * random.randint(4,16))}")
|
||||
if option == "server_password" and self.marker == "!": # Do not display the server password to the client.
|
||||
self.output(f"Option server_password is set to {('*' * random.randint(4, 16))}")
|
||||
else:
|
||||
self.output(f"Option {option} is set to {getattr(self.ctx, option)}")
|
||||
|
||||
|
||||
class ClientMessageProcessor(CommonCommandProcessor):
|
||||
marker = "!"
|
||||
|
||||
@@ -715,11 +793,14 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
"""Allow remote administration of the multiworld server"""
|
||||
|
||||
output = f"!admin {command}"
|
||||
if output.lower().startswith("!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
|
||||
if output.lower().startswith(
|
||||
"!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
|
||||
output = f"!admin login {('*' * random.randint(4, 16))}"
|
||||
elif output.lower().startswith("!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
|
||||
elif output.lower().startswith(
|
||||
"!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
|
||||
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
|
||||
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
|
||||
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
|
||||
self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
|
||||
|
||||
if not self.ctx.server_password:
|
||||
self.output("Sorry, Remote administration is disabled")
|
||||
@@ -727,7 +808,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
if not command:
|
||||
if self.is_authenticated():
|
||||
self.output("Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
|
||||
self.output(
|
||||
"Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
|
||||
else:
|
||||
self.output("Usage: !admin login [password]")
|
||||
return True
|
||||
@@ -810,7 +892,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
"Sorry, !remaining requires you to have beaten the game on this server")
|
||||
return False
|
||||
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks from the server's perspective"""
|
||||
|
||||
@@ -846,11 +927,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
def _cmd_getitem(self, item_name: str) -> bool:
|
||||
"""Cheat in an item, if it is enabled on this server"""
|
||||
if self.ctx.item_cheat:
|
||||
item_name, usable, response = get_intended_text(item_name, Items.item_table.keys())
|
||||
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
||||
item_name, usable, response = get_intended_text(item_name,
|
||||
world.item_names)
|
||||
if usable:
|
||||
new_item = NetworkItem(Items.item_table[item_name][2], -1, self.client.slot)
|
||||
new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot)
|
||||
get_received_items(self.ctx, self.client.team, self.client.slot).append(new_item)
|
||||
self.ctx.notify_all('Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, self.client.slot))
|
||||
self.ctx.notify_all(
|
||||
'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team,
|
||||
self.client.slot))
|
||||
send_new_items(self.ctx)
|
||||
return True
|
||||
else:
|
||||
@@ -862,27 +947,31 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
@mark_raw
|
||||
def _cmd_hint(self, item_or_location: str = "") -> bool:
|
||||
"""Use !hint {item_name/location_name}, for example !hint Lamp or !hint Link's House. """
|
||||
"""Use !hint {item_name/location_name},
|
||||
for example !hint Lamp or !hint Link's House to get a spoiler peek for that location or item.
|
||||
If hint costs are on, this will only give you one new result,
|
||||
you can rerun the command to get more in that case."""
|
||||
points_available = get_client_points(self.ctx, self.client)
|
||||
if not item_or_location:
|
||||
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
||||
f"You have {points_available} points.")
|
||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||
self.ctx.hints[self.client.team, self.client.slot]}
|
||||
self.ctx.hints[self.client.team, self.client.slot] = hints
|
||||
notify_hints(self.ctx, self.client.team, list(hints))
|
||||
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
||||
f"You have {points_available} points.")
|
||||
return True
|
||||
else:
|
||||
item_name, usable, response = get_intended_text(item_or_location)
|
||||
world = proxy_worlds[self.ctx.games[self.client.slot]]
|
||||
item_name, usable, response = get_intended_text(item_or_location, world.all_names)
|
||||
if usable:
|
||||
if item_name in Items.hint_blacklist:
|
||||
if item_name in world.hint_blacklist:
|
||||
self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.")
|
||||
hints = []
|
||||
elif item_name in Items.item_name_groups:
|
||||
elif item_name in world.item_name_groups:
|
||||
hints = []
|
||||
for item in Items.item_name_groups[item_name]:
|
||||
for item in world.item_name_groups[item_name]:
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
|
||||
elif item_name in lookup_any_item_name_to_id: # item name
|
||||
elif item_name in world.item_names: # item name
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
|
||||
else: # location name
|
||||
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name)
|
||||
@@ -901,11 +990,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
if not not_found_hints: # everything's been found, no need to pay
|
||||
can_pay = 1000
|
||||
elif cost:
|
||||
can_pay = points_available // cost
|
||||
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
|
||||
else:
|
||||
can_pay = 1000
|
||||
|
||||
random.shuffle(not_found_hints)
|
||||
self.ctx.random.shuffle(not_found_hints)
|
||||
|
||||
hints = found_hints
|
||||
while can_pay > 0:
|
||||
@@ -923,7 +1012,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
if not_found_hints:
|
||||
if hints:
|
||||
self.output(
|
||||
"Could not pay for everything. Rerun the hint later with more points to get the remaining hints.")
|
||||
"There may be more hintables, you can rerun the command to find more.")
|
||||
else:
|
||||
self.output(f"You can't afford the hint. "
|
||||
f"You have {points_available} points and need at least "
|
||||
@@ -959,24 +1048,22 @@ def get_client_points(ctx: Context, client: Client) -> int:
|
||||
|
||||
async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
try:
|
||||
cmd:str = args["cmd"]
|
||||
cmd: str = args["cmd"]
|
||||
except:
|
||||
logging.exception(f"Could not get command from {args}")
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||
"text": f"Could not get command from {args} at `cmd`"}])
|
||||
raise
|
||||
|
||||
if type(cmd) is not str:
|
||||
await ctx.send_msgs(client, [{"cmd": "InvalidCmd", "text": f"Command should be str, got {type(cmd)}"}])
|
||||
return
|
||||
|
||||
if args is not None and type(args) != dict:
|
||||
await ctx.send_msgs(client, [{"cmd": "InvalidArguments",
|
||||
'text': f'Expected Optional[dict], got {type(args)} for {cmd}'}])
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
|
||||
"text": f"Command should be str, got {type(cmd)}"}])
|
||||
return
|
||||
|
||||
if cmd == 'Connect':
|
||||
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
|
||||
'game' not in args:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidArguments', 'text': 'Connect'}])
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': 'Connect'}])
|
||||
return
|
||||
|
||||
errors = set()
|
||||
@@ -996,6 +1083,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
if clients:
|
||||
# likely same player with a "ghosted" slot. We bust the ghost.
|
||||
if "uuid" in args and ctx.client_ids[team, slot] == args["uuid"]:
|
||||
await ctx.send_msgs(clients[0], [{"cmd": "Print", "text": "You are getting kicked "
|
||||
"by yourself reconnecting."}])
|
||||
await clients[0].socket.close() # we have to await the DC of the ghost, so not to create data pasta
|
||||
client.name = ctx.player_names[(team, slot)]
|
||||
client.team = team
|
||||
@@ -1011,10 +1100,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
errors.add('IncompatibleVersion')
|
||||
|
||||
# only exact version match allowed
|
||||
if ctx.compatibility == 0 and args['version'] != _version_tuple:
|
||||
if ctx.compatibility == 0 and args['version'] != version_tuple:
|
||||
errors.add('IncompatibleVersion')
|
||||
if errors:
|
||||
logging.info(f"A client connection was refused due to: {errors}")
|
||||
logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
|
||||
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
|
||||
else:
|
||||
ctx.client_ids[client.team, client.slot] = args["uuid"]
|
||||
@@ -1027,6 +1116,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
"players": ctx.get_players_package(),
|
||||
"missing_locations": get_missing_checks(ctx, client),
|
||||
"checked_locations": get_checked_checks(ctx, client),
|
||||
# get is needed for old multidata that was sparsely populated
|
||||
"slot_data": ctx.slot_data.get(client.slot, {})
|
||||
}]
|
||||
items = get_received_items(ctx, client.team, client.slot)
|
||||
@@ -1038,14 +1128,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
await on_client_joined(ctx, client)
|
||||
|
||||
elif cmd == "GetDataPackage":
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": network_data_package}])
|
||||
exclusions = set(args.get("exclusions", []))
|
||||
if exclusions:
|
||||
games = {name: game_data for name, game_data in network_data_package["games"].items()
|
||||
if name not in exclusions}
|
||||
package = network_data_package.copy()
|
||||
package["games"] = games
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": package}])
|
||||
else:
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": network_data_package}])
|
||||
elif client.auth:
|
||||
if cmd == 'Sync':
|
||||
items = get_received_items(ctx, client.team, client.slot)
|
||||
if items:
|
||||
client.send_index = len(items)
|
||||
await ctx.send_msgs(client, [{"cmd": "ReceivedItems","index": 0,
|
||||
await ctx.send_msgs(client, [{"cmd": "ReceivedItems", "index": 0,
|
||||
"items": items}])
|
||||
|
||||
elif cmd == 'LocationChecks':
|
||||
@@ -1055,7 +1154,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
locs = []
|
||||
for location in args["locations"]:
|
||||
if type(location) is not int or location not in lookup_any_location_id_to_name:
|
||||
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'LocationScouts'}])
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts'}])
|
||||
return
|
||||
target_item, target_player = ctx.locations[client.slot][location]
|
||||
locs.append(NetworkItem(target_item, location, target_player))
|
||||
@@ -1065,13 +1164,27 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
elif cmd == 'StatusUpdate':
|
||||
update_client_status(ctx, client, args["status"])
|
||||
|
||||
if cmd == 'Say':
|
||||
elif cmd == 'Say':
|
||||
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
|
||||
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text" : 'Say'}])
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'Say'}])
|
||||
return
|
||||
|
||||
client.messageprocessor(args["text"])
|
||||
|
||||
elif cmd == "Bounce":
|
||||
games = set(args.get("games", []))
|
||||
tags = set(args.get("tags", []))
|
||||
slots = set(args.get("slots", []))
|
||||
args["cmd"] = "Bounced"
|
||||
msg = ctx.dumper([args])
|
||||
|
||||
for bounceclient in ctx.endpoints:
|
||||
if client.team == bounceclient.team and (ctx.games[bounceclient.slot] in games or
|
||||
set(bounceclient.tags) & tags or
|
||||
bounceclient.slot in slots):
|
||||
await ctx.send_encoded_msgs(bounceclient, msg)
|
||||
|
||||
|
||||
def update_client_status(ctx: Context, client: Client, new_status: ClientStatus):
|
||||
current = ctx.client_game_state[client.team, client.slot]
|
||||
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
|
||||
@@ -1080,9 +1193,12 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
|
||||
ctx.notify_all(finished_msg)
|
||||
if "auto" in ctx.forfeit_mode:
|
||||
forfeit_player(ctx, client.team, client.slot)
|
||||
elif proxy_worlds[ctx.games[client.slot]].forced_auto_forfeit:
|
||||
forfeit_player(ctx, client.team, client.slot)
|
||||
|
||||
ctx.client_game_state[client.team, client.slot] = new_status
|
||||
|
||||
|
||||
class ServerCommandProcessor(CommonCommandProcessor):
|
||||
def __init__(self, ctx: Context):
|
||||
self.ctx = ctx
|
||||
@@ -1190,7 +1306,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
self.ctx.allow_forfeits[(team, slot)] = False
|
||||
self.output(f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
|
||||
self.output(
|
||||
f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
|
||||
@@ -1200,17 +1317,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
"""Sends an item to the specified player"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
if usable:
|
||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||
item = " ".join(item_name)
|
||||
item, usable, response = get_intended_text(item, all_items)
|
||||
world = proxy_worlds[self.ctx.games[slot]]
|
||||
item, usable, response = get_intended_text(item, world.item_names)
|
||||
if usable:
|
||||
for client in self.ctx.endpoints:
|
||||
if client.name == seeked_player:
|
||||
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, 0)
|
||||
get_received_items(self.ctx, client.team, client.slot).append(new_item)
|
||||
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
|
||||
self.ctx.get_aliased_name(client.team, client.slot))
|
||||
send_new_items(self.ctx)
|
||||
return True
|
||||
new_item = NetworkItem(world.item_name_to_id[item], -1, 0)
|
||||
get_received_items(self.ctx, team, slot).append(new_item)
|
||||
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
|
||||
self.ctx.get_aliased_name(team, slot))
|
||||
send_new_items(self.ctx)
|
||||
return True
|
||||
else:
|
||||
self.output(response)
|
||||
return False
|
||||
@@ -1222,27 +1339,27 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
"""Send out a hint for a player's item or location to their team"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
if usable:
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name == seeked_player:
|
||||
item = " ".join(item_or_location)
|
||||
item, usable, response = get_intended_text(item)
|
||||
if usable:
|
||||
if item in Items.item_name_groups:
|
||||
hints = []
|
||||
for item in Items.item_name_groups[item]:
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item))
|
||||
elif item in all_items: # item name
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
else: # location name
|
||||
hints = collect_hints_location(self.ctx, team, slot, item)
|
||||
if hints:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
else:
|
||||
self.output("No hints found.")
|
||||
return True
|
||||
else:
|
||||
self.output(response)
|
||||
return False
|
||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||
item = " ".join(item_or_location)
|
||||
world = proxy_worlds[self.ctx.games[slot]]
|
||||
item, usable, response = get_intended_text(item, world.all_names)
|
||||
if usable:
|
||||
if item in world.item_name_groups:
|
||||
hints = []
|
||||
for item in world.item_name_groups[item]:
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item))
|
||||
elif item in world.item_names: # item name
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
else: # location name
|
||||
hints = collect_hints_location(self.ctx, team, slot, item)
|
||||
if hints:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
else:
|
||||
self.output("No hints found.")
|
||||
return True
|
||||
else:
|
||||
self.output(response)
|
||||
return False
|
||||
|
||||
else:
|
||||
self.output(response)
|
||||
@@ -1270,6 +1387,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
f"{', '.join(known)}")
|
||||
return False
|
||||
|
||||
|
||||
async def console(ctx: Context):
|
||||
session = prompt_toolkit.PromptSession()
|
||||
while ctx.running:
|
||||
@@ -1285,11 +1403,11 @@ async def console(ctx: Context):
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
defaults = Utils.get_options()["server_options"]
|
||||
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
|
||||
parser.add_argument('--host', default=defaults["host"])
|
||||
parser.add_argument('--port', default=defaults["port"], type=int)
|
||||
parser.add_argument('--server_password', default=defaults["server_password"])
|
||||
parser.add_argument('--password', default=defaults["password"])
|
||||
parser.add_argument('--multidata', default=defaults["multidata"])
|
||||
parser.add_argument('--savefile', default=defaults["savefile"])
|
||||
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
|
||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||
@@ -1356,13 +1474,12 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace):
|
||||
logging.basicConfig(force = True,
|
||||
logging.basicConfig(force=True,
|
||||
format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
|
||||
|
||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||
args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode,
|
||||
args.auto_shutdown, args.compatibility)
|
||||
ctx.log_network = args.log_network
|
||||
args.auto_shutdown, args.compatibility, args.log_network)
|
||||
data_filename = args.multidata
|
||||
|
||||
try:
|
||||
@@ -1371,7 +1488,7 @@ async def main(args: argparse.Namespace):
|
||||
import tkinter.filedialog
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago"),))
|
||||
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
|
||||
|
||||
ctx.load(data_filename, args.use_embedded_options)
|
||||
|
||||
|
||||
831
Mystery.py
@@ -1,831 +0,0 @@
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import typing
|
||||
import os
|
||||
from collections import Counter
|
||||
import string
|
||||
|
||||
import ModuleUpdate
|
||||
from worlds.generic import PlandoItem, PlandoConnection
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import parse_yaml
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from Main import get_seed, seeddigits
|
||||
import Options
|
||||
from worlds import lookup_any_item_name_to_id
|
||||
from worlds.alttp.Items import item_name_groups, item_table
|
||||
from worlds.alttp import Bosses
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.alttp.Regions import location_table, key_drop_data
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
|
||||
multiargs, _ = parser.parse_known_args()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--weights',
|
||||
help='Path to the weights file to use for rolling game settings, urls are also valid')
|
||||
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
|
||||
action='store_true')
|
||||
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
|
||||
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
|
||||
parser.add_argument('--teams', default=1, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--create_spoiler', action='store_true')
|
||||
parser.add_argument('--skip_playthrough', action='store_true')
|
||||
parser.add_argument('--pre_roll', action='store_true')
|
||||
parser.add_argument('--rom')
|
||||
parser.add_argument('--enemizercli')
|
||||
parser.add_argument('--outputpath')
|
||||
parser.add_argument('--glitch_triforce', action='store_true')
|
||||
parser.add_argument('--race', action='store_true')
|
||||
parser.add_argument('--meta', default=None)
|
||||
parser.add_argument('--log_output_path', help='Path to store output log')
|
||||
parser.add_argument('--loglevel', default='info', help='Sets log level')
|
||||
parser.add_argument('--create_diff', action="store_true")
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
|
||||
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
|
||||
parser.add_argument('--plando', default="bosses",
|
||||
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
|
||||
parser.add_argument('--seed_name')
|
||||
for player in range(1, multiargs.multi + 1):
|
||||
parser.add_argument(f'--p{player}', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
return args
|
||||
|
||||
def get_seed_name(random):
|
||||
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
if not args:
|
||||
args = mystery_argparse()
|
||||
|
||||
seed = get_seed(args.seed)
|
||||
random.seed(seed)
|
||||
seed_name = args.seed_name if args.seed_name else get_seed_name(random)
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed}")
|
||||
|
||||
if args.race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache = {}
|
||||
if args.weights:
|
||||
try:
|
||||
weights_cache[args.weights] = get_weights(args.weights)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights], 'No description specified')}")
|
||||
if args.meta:
|
||||
try:
|
||||
weights_cache[args.meta] = get_weights(args.meta)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e
|
||||
meta_weights = weights_cache[args.meta]
|
||||
print(f"Meta: {args.meta} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
|
||||
for player in range(1, args.multi + 1):
|
||||
path = getattr(args, f'p{player}')
|
||||
if path:
|
||||
try:
|
||||
if path not in weights_cache:
|
||||
weights_cache[path] = get_weights(path)
|
||||
print(f"P{player} Weights: {path} >> "
|
||||
f"{get_choice('description', weights_cache[path], 'No description specified')}")
|
||||
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery
|
||||
erargs.create_spoiler = args.create_spoiler
|
||||
erargs.create_diff = args.create_diff
|
||||
erargs.glitch_triforce = args.glitch_triforce
|
||||
erargs.race = args.race
|
||||
erargs.skip_playthrough = args.skip_playthrough
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.teams = args.teams
|
||||
|
||||
# set up logger
|
||||
if args.loglevel:
|
||||
erargs.loglevel = args.loglevel
|
||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||
erargs.loglevel]
|
||||
|
||||
if args.log_output_path:
|
||||
import sys
|
||||
class LoggerWriter(object):
|
||||
def __init__(self, writer):
|
||||
self._writer = writer
|
||||
self._msg = ''
|
||||
|
||||
def write(self, message):
|
||||
self._msg = self._msg + message
|
||||
while '\n' in self._msg:
|
||||
pos = self._msg.find('\n')
|
||||
self._writer(self._msg[:pos])
|
||||
self._msg = self._msg[pos + 1:]
|
||||
|
||||
def flush(self):
|
||||
if self._msg != '':
|
||||
self._writer(self._msg)
|
||||
self._msg = ''
|
||||
|
||||
log = logging.getLogger("stderr")
|
||||
log.addHandler(logging.StreamHandler())
|
||||
sys.stderr = LoggerWriter(log.error)
|
||||
os.makedirs(args.log_output_path, exist_ok=True)
|
||||
logging.basicConfig(format='%(message)s', level=loglevel,
|
||||
filename=os.path.join(args.log_output_path, f"{seed}.log"))
|
||||
else:
|
||||
logging.basicConfig(format='%(message)s', level=loglevel)
|
||||
if args.rom:
|
||||
erargs.rom = args.rom
|
||||
|
||||
if args.enemizercli:
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||
for k, v in weights_cache.items()}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = getattr(args, f'p{player}') if getattr(args, f'p{player}') else args.weights
|
||||
|
||||
if args.meta:
|
||||
for player, path in player_path_cache.items():
|
||||
weights_cache[path].setdefault("meta_ignore", [])
|
||||
meta_weights = weights_cache[args.meta]
|
||||
for key in meta_weights:
|
||||
option = get_choice(key, meta_weights)
|
||||
if option is not None:
|
||||
for player, path in player_path_cache.items():
|
||||
players_meta = weights_cache[path].get("meta_ignore", [])
|
||||
if key not in players_meta:
|
||||
weights_cache[path][key] = option
|
||||
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]:
|
||||
weights_cache[path][key] = option
|
||||
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings = settings_cache[path] if settings_cache[path] else \
|
||||
roll_settings(weights_cache[path], args.plando)
|
||||
if args.pre_roll:
|
||||
import yaml
|
||||
if path == args.weights:
|
||||
settings.name = f"Player{player}"
|
||||
elif not settings.name:
|
||||
settings.name = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
|
||||
if "-" not in settings.shuffle and settings.shuffle != "vanilla":
|
||||
settings.shuffle += f"-{random.randint(0, 2 ** 64)}"
|
||||
|
||||
pre_rolled = dict()
|
||||
pre_rolled["original_seed_number"] = seed
|
||||
pre_rolled["original_seed_name"] = seed_name
|
||||
pre_rolled["pre_rolled"] = vars(settings).copy()
|
||||
if "plando_items" in pre_rolled["pre_rolled"]:
|
||||
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in
|
||||
pre_rolled["pre_rolled"]["plando_items"]]
|
||||
if "plando_connections" in pre_rolled["pre_rolled"]:
|
||||
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in
|
||||
pre_rolled["pre_rolled"][
|
||||
"plando_connections"]]
|
||||
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".",
|
||||
f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
|
||||
yaml.dump(pre_rolled, f)
|
||||
for k, v in vars(settings).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
if path == args.weights: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
erargs.names = ",".join(erargs.name[i] for i in range(1, args.multi + 1))
|
||||
del (erargs.name)
|
||||
if args.yaml_output:
|
||||
import yaml
|
||||
important = {}
|
||||
for option, player_settings in vars(erargs).items():
|
||||
if type(player_settings) == dict:
|
||||
if all(type(value) != list for value in player_settings.values()):
|
||||
if len(player_settings.values()) > 1:
|
||||
important[option] = {player: value for player, value in player_settings.items() if
|
||||
player <= args.yaml_output}
|
||||
elif len(player_settings.values()) > 0:
|
||||
important[option] = player_settings[1]
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
|
||||
else:
|
||||
if player_settings != "": # is not empty name
|
||||
important[option] = player_settings
|
||||
else:
|
||||
logging.debug(f"No player settings defined for option '{option}'")
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"mystery_result_{seed}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def get_weights(path):
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme:
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
|
||||
else:
|
||||
with open(path, 'rb') as f:
|
||||
yaml = str(f.read(), "utf-8")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
return parse_yaml(yaml)
|
||||
|
||||
|
||||
def interpret_on_off(value):
|
||||
return {"on": True, "off": False}.get(value, value)
|
||||
|
||||
|
||||
def convert_to_on_off(value):
|
||||
return {True: "on", False: "off"}.get(value, value)
|
||||
|
||||
|
||||
def get_choice(option, root, value=None) -> typing.Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
return interpret_on_off(random.choices(root[option])[0])
|
||||
if type(root[option]) is not dict:
|
||||
return interpret_on_off(root[option])
|
||||
if not root[option]:
|
||||
return value
|
||||
if any(root[option].values()):
|
||||
return interpret_on_off(
|
||||
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
|
||||
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
|
||||
|
||||
|
||||
class SafeDict(dict):
|
||||
def __missing__(self, key):
|
||||
return '{' + key + '}'
|
||||
|
||||
|
||||
def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name_counter[name] += 1
|
||||
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
|
||||
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
|
||||
NUMBER=(name_counter[name] if name_counter[
|
||||
name] > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip().replace(' ', '_')[:16]
|
||||
if new_name == "Archipelago":
|
||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> typing.Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
'none': 'none',
|
||||
'basic': 'basic',
|
||||
'full': 'full',
|
||||
'chaos': 'chaos',
|
||||
'singularity': 'singularity'
|
||||
}
|
||||
|
||||
goals = {
|
||||
'ganon': 'ganon',
|
||||
'crystals': 'crystals',
|
||||
'bosses': 'bosses',
|
||||
'pedestal': 'pedestal',
|
||||
'ganon_pedestal': 'ganonpedestal',
|
||||
'triforce_hunt': 'triforcehunt',
|
||||
'local_triforce_hunt': 'localtriforcehunt',
|
||||
'ganon_triforce_hunt': 'ganontriforcehunt',
|
||||
'local_ganon_triforce_hunt': 'localganontriforcehunt',
|
||||
'ice_rod_hunt': 'icerodhunt',
|
||||
}
|
||||
|
||||
# remove sometime before 1.0.0, warn before
|
||||
legacy_boss_shuffle_options = {
|
||||
# legacy, will go away:
|
||||
'simple': 'basic',
|
||||
'random': 'full',
|
||||
'normal': 'full'
|
||||
}
|
||||
|
||||
legacy_goals = {
|
||||
'dungeons': 'bosses',
|
||||
'fast_ganon': 'crystals',
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
|
||||
|
||||
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
|
||||
logging.debug(f'Applying {new_weights}')
|
||||
new_options = set(new_weights) - set(weights)
|
||||
weights.update(new_weights)
|
||||
if new_options:
|
||||
for new_option in new_options:
|
||||
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
|
||||
f'overwrite a root option. '
|
||||
f'This is probably in error.')
|
||||
return weights
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
|
||||
for option_set in weights["linked_options"]:
|
||||
if "name" not in option_set:
|
||||
raise ValueError("One of your linked options does not have a name.")
|
||||
try:
|
||||
if roll_percentage(option_set["percentage"]):
|
||||
logging.debug(f"Linked option {option_set['name']} triggered.")
|
||||
if "options" in option_set:
|
||||
weights = update_weights(weights, option_set["options"], "Linked", option_set["name"])
|
||||
if "rom_options" in option_set:
|
||||
rom_weights = weights.get("rom", dict())
|
||||
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom",
|
||||
option_set["name"])
|
||||
weights["rom"] = rom_weights
|
||||
else:
|
||||
logging.debug(f"linked option {option_set['name']} skipped.")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
|
||||
f"Please fix your linked option.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def roll_triggers(weights: dict) -> dict:
|
||||
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
|
||||
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
|
||||
for i, option_set in enumerate(weights["triggers"]):
|
||||
try:
|
||||
key = get_choice("option_name", option_set)
|
||||
if key not in weights:
|
||||
logging.warning(f'Specified option name {option_set["option_name"]} did not '
|
||||
f'match with a root option. '
|
||||
f'This is probably in error.')
|
||||
trigger_result = get_choice("option_result", option_set)
|
||||
result = get_choice(key, weights)
|
||||
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
|
||||
if "options" in option_set:
|
||||
weights = update_weights(weights, option_set["options"], "Triggered", option_set["option_name"])
|
||||
|
||||
if "rom_options" in option_set:
|
||||
rom_weights = weights.get("rom", dict())
|
||||
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Triggered Rom",
|
||||
option_set["option_name"])
|
||||
weights["rom"] = rom_weights
|
||||
weights[key] = result
|
||||
except Exception as e:
|
||||
raise ValueError(f"Your trigger number {i+1} is destroyed. "
|
||||
f"Please fix your triggers.") from e
|
||||
return weights
|
||||
|
||||
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
|
||||
if boss_shuffle in legacy_boss_shuffle_options:
|
||||
new_boss_shuffle = legacy_boss_shuffle_options[boss_shuffle]
|
||||
logging.warning(f"Boss shuffle {boss_shuffle} is deprecated, "
|
||||
f"please use {new_boss_shuffle} instead")
|
||||
return new_boss_shuffle
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif "bosses" in plando_options:
|
||||
options = boss_shuffle.lower().split(";")
|
||||
remainder_shuffle = "none" # vanilla
|
||||
bosses = []
|
||||
for boss in options:
|
||||
if boss in legacy_boss_shuffle_options:
|
||||
remainder_shuffle = legacy_boss_shuffle_options[boss_shuffle]
|
||||
logging.warning(f"Boss shuffle {boss} is deprecated, "
|
||||
f"please use {remainder_shuffle} instead")
|
||||
if boss in boss_shuffle_options:
|
||||
remainder_shuffle = boss_shuffle_options[boss]
|
||||
elif "-" in boss:
|
||||
loc, boss_name = boss.split("-")
|
||||
if boss_name not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name {boss_name}")
|
||||
if loc not in available_boss_locations:
|
||||
raise ValueError(f"Unknown Boss Location {loc}")
|
||||
level = ''
|
||||
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
|
||||
# split off level
|
||||
loc = loc.split(" ")
|
||||
level = f" {loc[-1]}"
|
||||
loc = " ".join(loc[:-1])
|
||||
loc = loc.title().replace("Of", "of")
|
||||
if not Bosses.can_place_boss(boss_name.title(), loc, level):
|
||||
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
|
||||
bosses.append(boss)
|
||||
elif boss not in available_boss_names:
|
||||
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
|
||||
else:
|
||||
bosses.append(boss)
|
||||
return ";".join(bosses + [remainder_shuffle])
|
||||
else:
|
||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
|
||||
if "pre_rolled" in weights:
|
||||
pre_rolled = weights["pre_rolled"]
|
||||
|
||||
if "plando_items" in pre_rolled:
|
||||
pre_rolled["plando_items"] = [PlandoItem(item["item"],
|
||||
item["location"],
|
||||
item["world"],
|
||||
item["from_pool"],
|
||||
item["force"]) for item in pre_rolled["plando_items"]]
|
||||
if "items" not in plando_options and pre_rolled["plando_items"]:
|
||||
raise Exception("Item Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
||||
|
||||
if "plando_connections" in pre_rolled:
|
||||
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
|
||||
connection["exit"],
|
||||
connection["direction"]) for connection in
|
||||
pre_rolled["plando_connections"]]
|
||||
if "connections" not in plando_options and pre_rolled["plando_connections"]:
|
||||
raise Exception("Connection Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
||||
|
||||
if "bosses" not in plando_options:
|
||||
try:
|
||||
pre_rolled["shufflebosses"] = get_plando_bosses(pre_rolled["shufflebosses"], plando_options)
|
||||
except Exception as ex:
|
||||
raise Exception("Boss Plando is turned off. Reusing this pre-rolled setting not permitted.") from ex
|
||||
|
||||
if pre_rolled.get("plando_texts") and "texts" not in plando_options:
|
||||
raise Exception("Text Plando is turned off. Reusing this pre-rolled setting not permitted.")
|
||||
|
||||
return argparse.Namespace(**pre_rolled)
|
||||
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
if "triggers" in weights:
|
||||
weights = roll_triggers(weights)
|
||||
|
||||
ret = argparse.Namespace()
|
||||
ret.name = get_choice('name', weights)
|
||||
ret.accessibility = get_choice('accessibility', weights)
|
||||
ret.progression_balancing = get_choice('progression_balancing', weights, True)
|
||||
ret.game = get_choice("game", weights, "A Link to the Past")
|
||||
|
||||
ret.local_items = set()
|
||||
for item_name in weights.get('local_items', []):
|
||||
items = item_name_groups.get(item_name, {item_name})
|
||||
for item in items:
|
||||
if item in lookup_any_item_name_to_id:
|
||||
ret.local_items.add(item)
|
||||
else:
|
||||
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
|
||||
|
||||
ret.non_local_items = set()
|
||||
for item_name in weights.get('non_local_items', []):
|
||||
items = item_name_groups.get(item_name, {item_name})
|
||||
for item in items:
|
||||
if item in lookup_any_item_name_to_id:
|
||||
ret.non_local_items.add(item)
|
||||
else:
|
||||
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
|
||||
|
||||
inventoryweights = weights.get('startinventory', {})
|
||||
startitems = []
|
||||
for item in inventoryweights.keys():
|
||||
itemvalue = get_choice(item, inventoryweights)
|
||||
if isinstance(itemvalue, int):
|
||||
for i in range(int(itemvalue)):
|
||||
startitems.append(item)
|
||||
elif itemvalue:
|
||||
startitems.append(item)
|
||||
ret.startinventory = startitems
|
||||
ret.start_hints = set(weights.get('start_hints', []))
|
||||
|
||||
|
||||
if ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, weights, plando_options)
|
||||
elif ret.game == "Hollow Knight":
|
||||
for option_name, option in Options.hollow_knight_options.items():
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, weights, True)))
|
||||
elif ret.game == "Factorio":
|
||||
for option_name, option in Options.factorio_options.items():
|
||||
if option_name in weights:
|
||||
if issubclass(option, Options.OptionDict): # get_choice should probably land in the Option class
|
||||
setattr(ret, option_name, option.from_any(weights[option_name]))
|
||||
else:
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
|
||||
else:
|
||||
setattr(ret, option_name, option(option.default))
|
||||
elif ret.game == "Minecraft":
|
||||
for option_name, option in Options.minecraft_options.items():
|
||||
if option_name in weights:
|
||||
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
|
||||
else:
|
||||
setattr(ret, option_name, option(option.default))
|
||||
else:
|
||||
raise Exception(f"Unsupported game {ret.game}")
|
||||
return ret
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
glitches_required = get_choice('glitches_required', weights)
|
||||
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'minor_glitches']:
|
||||
logging.warning("Only NMG, OWG and No Logic supported")
|
||||
glitches_required = 'none'
|
||||
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
|
||||
'minor_glitches': 'minorglitches'}[
|
||||
glitches_required]
|
||||
|
||||
ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp")
|
||||
if not ret.dark_room_logic: # None/False
|
||||
ret.dark_room_logic = "none"
|
||||
if ret.dark_room_logic == "sconces":
|
||||
ret.dark_room_logic = "torches"
|
||||
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
|
||||
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
|
||||
|
||||
ret.restrict_dungeon_item_on_boss = get_choice('restrict_dungeon_item_on_boss', weights, False)
|
||||
|
||||
dungeon_items = get_choice('dungeon_items', weights)
|
||||
if dungeon_items == 'full' or dungeon_items == True:
|
||||
dungeon_items = 'mcsb'
|
||||
elif dungeon_items == 'standard':
|
||||
dungeon_items = ""
|
||||
elif not dungeon_items:
|
||||
dungeon_items = ""
|
||||
if "u" in dungeon_items:
|
||||
dungeon_items.replace("s", "")
|
||||
|
||||
ret.mapshuffle = get_choice('map_shuffle', weights, 'm' in dungeon_items)
|
||||
ret.compassshuffle = get_choice('compass_shuffle', weights, 'c' in dungeon_items)
|
||||
ret.keyshuffle = get_choice('smallkey_shuffle', weights,
|
||||
'universal' if 'u' in dungeon_items else 's' in dungeon_items)
|
||||
ret.bigkeyshuffle = get_choice('bigkey_shuffle', weights, 'b' in dungeon_items)
|
||||
|
||||
entrance_shuffle = get_choice('entrance_shuffle', weights, 'vanilla')
|
||||
if entrance_shuffle.startswith('none-'):
|
||||
ret.shuffle = 'vanilla'
|
||||
else:
|
||||
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
|
||||
|
||||
goal = get_choice('goals', weights, 'ganon')
|
||||
|
||||
if goal in legacy_goals:
|
||||
logging.warning(f"Goal {goal} is depcrecated, please use {legacy_goals[goal]} instead.")
|
||||
goal = legacy_goals[goal]
|
||||
ret.goal = goals[goal]
|
||||
|
||||
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
|
||||
# fast ganon + ganon at hole
|
||||
ret.open_pyramid = get_choice('open_pyramid', weights, 'goal')
|
||||
|
||||
ret.crystals_gt = prefer_int(get_choice('tower_open', weights))
|
||||
|
||||
ret.crystals_ganon = prefer_int(get_choice('ganon_open', weights))
|
||||
|
||||
extra_pieces = get_choice('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
ret.triforce_pieces_required = int(get_choice('triforce_pieces_required', weights, 20))
|
||||
ret.triforce_pieces_required = min(max(1, int(ret.triforce_pieces_required)), 90)
|
||||
|
||||
# sum a percentage to required
|
||||
if extra_pieces == 'percentage':
|
||||
percentage = max(100, float(get_choice('triforce_pieces_percentage', weights, 150))) / 100
|
||||
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
|
||||
# vanilla mode (specify how many pieces are)
|
||||
elif extra_pieces == 'available':
|
||||
ret.triforce_pieces_available = int(get_choice('triforce_pieces_available', weights, 30))
|
||||
# required pieces + fixed extra
|
||||
elif extra_pieces == 'extra':
|
||||
extra_pieces = max(0, int(get_choice('triforce_pieces_extra', weights, 10)))
|
||||
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
|
||||
|
||||
# change minimum to required pieces to avoid problems
|
||||
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
|
||||
shuffle_slots = get_choice('shop_shuffle_slots', weights, '0')
|
||||
if str(shuffle_slots).lower() == "random":
|
||||
ret.shop_shuffle_slots = random.randint(0, 30)
|
||||
else:
|
||||
ret.shop_shuffle_slots = int(shuffle_slots)
|
||||
|
||||
ret.shop_shuffle = get_choice('shop_shuffle', weights, '')
|
||||
if not ret.shop_shuffle:
|
||||
ret.shop_shuffle = ''
|
||||
|
||||
ret.mode = get_choice("mode", weights)
|
||||
ret.retro = get_choice("retro", weights)
|
||||
|
||||
ret.hints = get_choice('hints', weights)
|
||||
|
||||
ret.swordless = get_choice('swordless', weights, False)
|
||||
|
||||
ret.difficulty = get_choice('item_pool', weights)
|
||||
|
||||
ret.item_functionality = get_choice('item_functionality', weights)
|
||||
|
||||
boss_shuffle = get_choice('boss_shuffle', weights)
|
||||
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
|
||||
|
||||
ret.enemy_shuffle = bool(get_choice('enemy_shuffle', weights, False))
|
||||
|
||||
|
||||
ret.killable_thieves = get_choice('killable_thieves', weights, False)
|
||||
ret.tile_shuffle = get_choice('tile_shuffle', weights, False)
|
||||
ret.bush_shuffle = get_choice('bush_shuffle', weights, False)
|
||||
|
||||
ret.enemy_damage = {None: 'default',
|
||||
'default': 'default',
|
||||
'shuffled': 'shuffled',
|
||||
'random': 'chaos'
|
||||
}[get_choice('enemy_damage', weights)]
|
||||
|
||||
ret.enemy_health = get_choice('enemy_health', weights)
|
||||
|
||||
ret.shufflepots = get_choice('pot_shuffle', weights)
|
||||
|
||||
ret.beemizer = int(get_choice('beemizer', weights, 0))
|
||||
|
||||
ret.timer = {'none': False,
|
||||
None: False,
|
||||
False: False,
|
||||
'timed': 'timed',
|
||||
'timed_ohko': 'timed-ohko',
|
||||
'ohko': 'ohko',
|
||||
'timed_countdown': 'timed-countdown',
|
||||
'display': 'display'}[get_choice('timer', weights, False)]
|
||||
|
||||
ret.countdown_start_time = int(get_choice('countdown_start_time', weights, 10))
|
||||
ret.red_clock_time = int(get_choice('red_clock_time', weights, -2))
|
||||
ret.blue_clock_time = int(get_choice('blue_clock_time', weights, 2))
|
||||
ret.green_clock_time = int(get_choice('green_clock_time', weights, 4))
|
||||
|
||||
ret.dungeon_counters = get_choice('dungeon_counters', weights, 'default')
|
||||
|
||||
ret.progressive = convert_to_on_off(get_choice('progressive', weights, 'on'))
|
||||
|
||||
ret.shuffle_prizes = get_choice('shuffle_prizes', weights, "g")
|
||||
|
||||
ret.required_medallions = [get_choice("misery_mire_medallion", weights, "random"),
|
||||
get_choice("turtle_rock_medallion", weights, "random")]
|
||||
|
||||
for index, medallion in enumerate(ret.required_medallions):
|
||||
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
|
||||
.get(medallion.lower(), None)
|
||||
if not ret.required_medallions[index]:
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.glitch_boots = get_choice('glitch_boots', weights, True)
|
||||
|
||||
if get_choice("local_keys", weights, "l" in dungeon_items):
|
||||
# () important for ordering of commands, without them the Big Keys section is part of the Small Key else
|
||||
ret.local_items |= item_name_groups["Small Keys"] if ret.keyshuffle else set()
|
||||
ret.local_items |= item_name_groups["Big Keys"] if ret.bigkeyshuffle else set()
|
||||
|
||||
ret.plando_items = []
|
||||
if "items" in plando_options:
|
||||
|
||||
def add_plando_item(item: str, location: str):
|
||||
if item not in item_table:
|
||||
raise Exception(f"Could not plando item {item} as the item was not recognized")
|
||||
if location not in location_table and location not in key_drop_data:
|
||||
raise Exception(
|
||||
f"Could not plando item {item} at location {location} as the location was not recognized")
|
||||
ret.plando_items.append(PlandoItem(item, location, location_world, from_pool, force))
|
||||
|
||||
options = weights.get("plando_items", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
from_pool = get_choice("from_pool", placement, PlandoItem._field_defaults["from_pool"])
|
||||
location_world = get_choice("world", placement, PlandoItem._field_defaults["world"])
|
||||
force = str(get_choice("force", placement, PlandoItem._field_defaults["force"])).lower()
|
||||
if "items" in placement and "locations" in placement:
|
||||
items = placement["items"]
|
||||
locations = placement["locations"]
|
||||
if isinstance(items, dict):
|
||||
item_list = []
|
||||
for key, value in items.items():
|
||||
item_list += [key] * value
|
||||
items = item_list
|
||||
if not items or not locations:
|
||||
raise Exception("You must specify at least one item and one location to place items.")
|
||||
random.shuffle(items)
|
||||
random.shuffle(locations)
|
||||
for item, location in zip(items, locations):
|
||||
add_plando_item(item, location)
|
||||
else:
|
||||
item = get_choice("item", placement, get_choice("items", placement))
|
||||
location = get_choice("location", placement)
|
||||
add_plando_item(item, location)
|
||||
|
||||
ret.plando_texts = {}
|
||||
if "texts" in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
at = str(get_choice("at", placement))
|
||||
if at not in tt:
|
||||
raise Exception(f"No text target \"{at}\" found.")
|
||||
ret.plando_texts[at] = str(get_choice("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement, "both")
|
||||
))
|
||||
|
||||
if 'rom' in weights:
|
||||
romweights = weights['rom']
|
||||
|
||||
ret.sprite_pool = romweights['sprite_pool'] if 'sprite_pool' in romweights else []
|
||||
ret.sprite = get_choice('sprite', romweights, "Link")
|
||||
if 'random_sprite_on_event' in romweights:
|
||||
randomoneventweights = romweights['random_sprite_on_event']
|
||||
if get_choice('enabled', randomoneventweights, False):
|
||||
ret.sprite = 'randomon'
|
||||
ret.sprite += '-hit' if get_choice('on_hit', randomoneventweights, True) else ''
|
||||
ret.sprite += '-enter' if get_choice('on_enter', randomoneventweights, False) else ''
|
||||
ret.sprite += '-exit' if get_choice('on_exit', randomoneventweights, False) else ''
|
||||
ret.sprite += '-slash' if get_choice('on_slash', randomoneventweights, False) else ''
|
||||
ret.sprite += '-item' if get_choice('on_item', randomoneventweights, False) else ''
|
||||
ret.sprite += '-bonk' if get_choice('on_bonk', randomoneventweights, False) else ''
|
||||
ret.sprite = 'randomonall' if get_choice('on_everything', randomoneventweights, False) else ret.sprite
|
||||
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
|
||||
|
||||
if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
|
||||
and 'sprite' in romweights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
|
||||
for key, value in romweights['sprite'].items():
|
||||
if key.startswith('random'):
|
||||
ret.sprite_pool += ['random'] * int(value)
|
||||
else:
|
||||
ret.sprite_pool += [key] * int(value)
|
||||
|
||||
ret.disablemusic = get_choice('disablemusic', romweights, False)
|
||||
ret.triforcehud = get_choice('triforcehud', romweights, 'hide_goal')
|
||||
ret.quickswap = get_choice('quickswap', romweights, True)
|
||||
ret.fastmenu = get_choice('menuspeed', romweights, "normal")
|
||||
ret.reduceflashing = get_choice('reduceflashing', romweights, False)
|
||||
ret.heartcolor = get_choice('heartcolor', romweights, "red")
|
||||
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', romweights, "normal"))
|
||||
ret.ow_palettes = get_choice('ow_palettes', romweights, "default")
|
||||
ret.uw_palettes = get_choice('uw_palettes', romweights, "default")
|
||||
ret.hud_palettes = get_choice('hud_palettes', romweights, "default")
|
||||
ret.sword_palettes = get_choice('sword_palettes', romweights, "default")
|
||||
ret.shield_palettes = get_choice('shield_palettes', romweights, "default")
|
||||
ret.link_palettes = get_choice('link_palettes', romweights, "default")
|
||||
|
||||
else:
|
||||
ret.quickswap = True
|
||||
ret.sprite = "Link"
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
52
NetUtils.py
@@ -1,6 +1,4 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
import enum
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
@@ -94,50 +92,6 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Node:
|
||||
endpoints: typing.List
|
||||
dumper = staticmethod(encode)
|
||||
loader = staticmethod(decode)
|
||||
|
||||
def __init__(self):
|
||||
self.endpoints = []
|
||||
super(Node, self).__init__()
|
||||
self.log_network = 0
|
||||
|
||||
def broadcast_all(self, msgs):
|
||||
msgs = self.dumper(msgs)
|
||||
for endpoint in self.endpoints:
|
||||
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
|
||||
|
||||
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]):
|
||||
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
|
||||
return
|
||||
msg = self.dumper(msgs)
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception(f"Exception during send_msgs, could not send {msg}")
|
||||
await self.disconnect(endpoint)
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
|
||||
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str):
|
||||
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
|
||||
return
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception("Exception during send_msgs")
|
||||
await self.disconnect(endpoint)
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
|
||||
async def disconnect(self, endpoint):
|
||||
if endpoint in self.endpoints:
|
||||
self.endpoints.remove(endpoint)
|
||||
|
||||
|
||||
class Endpoint:
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
@@ -242,7 +196,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
node["color"] = 'white_bg;black'
|
||||
node["color"] = 'blue'
|
||||
return self._handle_color(node)
|
||||
|
||||
|
||||
@@ -307,7 +261,9 @@ class Hint(typing.NamedTuple):
|
||||
else:
|
||||
add_json_text(parts, ".")
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "hint"}
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
"item": NetworkItem(self.item, self.location, self.finding_player)}
|
||||
|
||||
@property
|
||||
def local(self):
|
||||
|
||||
361
Options.py
@@ -1,22 +1,36 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import random
|
||||
|
||||
|
||||
class AssembleOptions(type):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
options = attrs["options"] = {}
|
||||
name_lookup = attrs["name_lookup"] = {}
|
||||
# merge parent class options
|
||||
for base in bases:
|
||||
options.update(base.options)
|
||||
name_lookup.update(name_lookup)
|
||||
if getattr(base, "options", None):
|
||||
options.update(base.options)
|
||||
name_lookup.update(base.name_lookup)
|
||||
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("option_")}
|
||||
if "random" in new_options:
|
||||
raise Exception("Choice option 'random' cannot be manually assigned.")
|
||||
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
|
||||
options.update(new_options)
|
||||
|
||||
# apply aliases, without name_lookup
|
||||
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")})
|
||||
|
||||
# auto-validate schema on __init__
|
||||
if "schema" in attrs.keys():
|
||||
def validate_decorator(func):
|
||||
def validate(self, *args, **kwargs):
|
||||
func(self, *args, **kwargs)
|
||||
self.value = self.schema.validate(self.value)
|
||||
return validate
|
||||
attrs["__init__"] = validate_decorator(attrs["__init__"])
|
||||
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
@@ -25,19 +39,35 @@ class Option(metaclass=AssembleOptions):
|
||||
name_lookup: typing.Dict[int, str]
|
||||
default = 0
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.get_option_name()})"
|
||||
# convert option_name_long into Name Long as displayname, otherwise name_long is the result.
|
||||
# Handled in get_option_name()
|
||||
autodisplayname = False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.get_current_option_name()})"
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.value)
|
||||
|
||||
def get_option_name(self):
|
||||
@property
|
||||
def current_key(self) -> str:
|
||||
return self.name_lookup[self.value]
|
||||
|
||||
def __int__(self):
|
||||
def get_current_option_name(self) -> str:
|
||||
"""For display purposes."""
|
||||
return self.get_option_name(self.value)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value: typing.Any) -> str:
|
||||
if cls.autodisplayname:
|
||||
return cls.name_lookup[value].replace("_", " ").title()
|
||||
else:
|
||||
return cls.name_lookup[value]
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
def __bool__(self):
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
@classmethod
|
||||
@@ -85,18 +115,29 @@ class Toggle(Option):
|
||||
def __int__(self):
|
||||
return int(self.value)
|
||||
|
||||
def get_option_name(self):
|
||||
return bool(self.value)
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ["No", "Yes"][int(value)]
|
||||
|
||||
class DefaultOnToggle(Toggle):
|
||||
default = 1
|
||||
|
||||
|
||||
class Choice(Option):
|
||||
autodisplayname = True
|
||||
|
||||
def __init__(self, value: int):
|
||||
self.value: int = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Choice:
|
||||
text = text.lower()
|
||||
# TODO: turn on after most people have adjusted their yamls to no longer have suboptions with "random" in them
|
||||
# maybe in 0.2?
|
||||
# if text == "random":
|
||||
# return cls(random.choice(list(cls.options.values())))
|
||||
for optionname, value in cls.options.items():
|
||||
if optionname == text.lower():
|
||||
if optionname == text:
|
||||
return cls(value)
|
||||
raise KeyError(
|
||||
f'Could not find option "{text}" for "{cls.__name__}", '
|
||||
@@ -108,6 +149,67 @@ class Choice(Option):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, str):
|
||||
assert other in self.options
|
||||
return other == self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
return other == self.value
|
||||
elif isinstance(other, bool):
|
||||
return other == bool(self.value)
|
||||
else:
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
def __ne__(self, other):
|
||||
if isinstance(other, str):
|
||||
assert other in self.options
|
||||
return other != self.current_key
|
||||
elif isinstance(other, int):
|
||||
assert other in self.name_lookup
|
||||
return other != self.value
|
||||
elif isinstance(other, bool):
|
||||
return other != bool(self.value)
|
||||
else:
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
class Range(Option, int):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
|
||||
def __init__(self, value: int):
|
||||
if value < self.range_start:
|
||||
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
|
||||
elif value > self.range_end:
|
||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__}")
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Range:
|
||||
text = text.lower()
|
||||
if text.startswith("random"):
|
||||
if text == "random-low":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
|
||||
elif text == "random-high":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
|
||||
elif text == "random-middle":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
|
||||
else:
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
return cls(int(text))
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> Range:
|
||||
if type(data) == int:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
return str(self.value)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
|
||||
|
||||
class OptionNameSet(Option):
|
||||
default = frozenset()
|
||||
@@ -139,229 +241,52 @@ class OptionDict(Option):
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||
|
||||
|
||||
class Logic(Choice):
|
||||
option_no_glitches = 0
|
||||
option_minor_glitches = 1
|
||||
option_overworld_glitches = 2
|
||||
option_no_logic = 4
|
||||
alias_owg = 2
|
||||
def get_option_name(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class Objective(Choice):
|
||||
option_crystals = 0
|
||||
# option_pendants = 1
|
||||
option_triforce_pieces = 2
|
||||
option_pedestal = 3
|
||||
option_bingo = 4
|
||||
class OptionList(Option):
|
||||
default = []
|
||||
|
||||
def __init__(self, value: typing.List[str, typing.Any]):
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str):
|
||||
return cls([option.strip() for option in text.split(",")])
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
if type(data) == list:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
|
||||
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
|
||||
|
||||
|
||||
class Goal(Choice):
|
||||
option_kill_ganon = 0
|
||||
option_kill_ganon_and_gt_agahnim = 1
|
||||
option_hand_in = 2
|
||||
|
||||
|
||||
class Accessibility(Choice):
|
||||
option_locations = 0
|
||||
option_items = 1
|
||||
option_beatable = 2
|
||||
|
||||
|
||||
class Crystals(Choice):
|
||||
# can't use IntEnum since there's also random
|
||||
option_0 = 0
|
||||
option_1 = 1
|
||||
option_2 = 2
|
||||
option_3 = 3
|
||||
option_4 = 4
|
||||
option_5 = 5
|
||||
option_6 = 6
|
||||
option_7 = 7
|
||||
option_random = -1
|
||||
|
||||
|
||||
class WorldState(Choice):
|
||||
option_standard = 1
|
||||
option_open = 0
|
||||
option_inverted = 2
|
||||
|
||||
|
||||
class Bosses(Choice):
|
||||
option_vanilla = 0
|
||||
option_simple = 1
|
||||
option_full = 2
|
||||
option_chaos = 3
|
||||
option_singularity = 4
|
||||
|
||||
|
||||
class Enemies(Choice):
|
||||
option_vanilla = 0
|
||||
option_shuffled = 1
|
||||
option_chaos = 2
|
||||
|
||||
|
||||
mapshuffle = Toggle
|
||||
compassshuffle = Toggle
|
||||
keyshuffle = Toggle
|
||||
bigkeyshuffle = Toggle
|
||||
hints = Toggle
|
||||
|
||||
RandomizeDreamers = Toggle
|
||||
RandomizeSkills = Toggle
|
||||
RandomizeCharms = Toggle
|
||||
RandomizeKeys = Toggle
|
||||
RandomizeGeoChests = Toggle
|
||||
RandomizeMaskShards = Toggle
|
||||
RandomizeVesselFragments = Toggle
|
||||
RandomizeCharmNotches = Toggle
|
||||
RandomizePaleOre = Toggle
|
||||
RandomizeRancidEggs = Toggle
|
||||
RandomizeRelics = Toggle
|
||||
RandomizeMaps = Toggle
|
||||
RandomizeStags = Toggle
|
||||
RandomizeGrubs = Toggle
|
||||
RandomizeWhisperingRoots = Toggle
|
||||
RandomizeRocks = Toggle
|
||||
RandomizeSoulTotems = Toggle
|
||||
RandomizePalaceTotems = Toggle
|
||||
RandomizeLoreTablets = Toggle
|
||||
RandomizeLifebloodCocoons = Toggle
|
||||
RandomizeFlames = Toggle
|
||||
|
||||
hollow_knight_randomize_options: typing.Dict[str, Option] = {
|
||||
"RandomizeDreamers": RandomizeDreamers,
|
||||
"RandomizeSkills": RandomizeSkills,
|
||||
"RandomizeCharms": RandomizeCharms,
|
||||
"RandomizeKeys": RandomizeKeys,
|
||||
"RandomizeGeoChests": RandomizeGeoChests,
|
||||
"RandomizeMaskShards": RandomizeMaskShards,
|
||||
"RandomizeVesselFragments": RandomizeVesselFragments,
|
||||
"RandomizeCharmNotches": RandomizeCharmNotches,
|
||||
"RandomizePaleOre": RandomizePaleOre,
|
||||
"RandomizeRancidEggs": RandomizeRancidEggs,
|
||||
"RandomizeRelics": RandomizeRelics,
|
||||
"RandomizeMaps": RandomizeMaps,
|
||||
"RandomizeStags": RandomizeStags,
|
||||
"RandomizeGrubs": RandomizeGrubs,
|
||||
"RandomizeWhisperingRoots": RandomizeWhisperingRoots,
|
||||
"RandomizeRocks": RandomizeRocks,
|
||||
"RandomizeSoulTotems": RandomizeSoulTotems,
|
||||
"RandomizePalaceTotems": RandomizePalaceTotems,
|
||||
"RandomizeLoreTablets": RandomizeLoreTablets,
|
||||
"RandomizeLifebloodCocoons": RandomizeLifebloodCocoons,
|
||||
"RandomizeFlames": RandomizeFlames
|
||||
}
|
||||
|
||||
hollow_knight_skip_options: typing.Dict[str, type(Option)] = {
|
||||
"MILDSKIPS": Toggle,
|
||||
"SPICYSKIPS": Toggle,
|
||||
"FIREBALLSKIPS": Toggle,
|
||||
"ACIDSKIPS": Toggle,
|
||||
"SPIKETUNNELS": Toggle,
|
||||
"DARKROOMS": Toggle,
|
||||
"CURSED": Toggle,
|
||||
"SHADESKIPS": Toggle,
|
||||
}
|
||||
|
||||
hollow_knight_options: typing.Dict[str, type(Option)] = {**hollow_knight_randomize_options,
|
||||
**hollow_knight_skip_options}
|
||||
|
||||
|
||||
class MaxSciencePack(Choice):
|
||||
option_automation_science_pack = 0
|
||||
option_logistic_science_pack = 1
|
||||
option_military_science_pack = 2
|
||||
option_chemical_science_pack = 3
|
||||
option_production_science_pack = 4
|
||||
option_utility_science_pack = 5
|
||||
option_space_science_pack = 6
|
||||
default = 6
|
||||
|
||||
def get_allowed_packs(self):
|
||||
return {option.replace("_", "-") for option, value in self.options.items()
|
||||
if value <= self.value}
|
||||
|
||||
|
||||
class TechCost(Choice):
|
||||
option_very_easy = 0
|
||||
option_easy = 1
|
||||
option_kind = 2
|
||||
option_normal = 3
|
||||
option_hard = 4
|
||||
option_very_hard = 5
|
||||
option_insane = 6
|
||||
default = 3
|
||||
|
||||
|
||||
class FreeSamples(Choice):
|
||||
option_none = 0
|
||||
option_single_craft = 1
|
||||
option_half_stack = 2
|
||||
option_stack = 3
|
||||
default = 3
|
||||
|
||||
|
||||
class TechTreeLayout(Choice):
|
||||
option_single = 0
|
||||
option_small_diamonds = 1
|
||||
option_medium_diamonds = 2
|
||||
option_pyramid = 3
|
||||
option_funnel = 4
|
||||
default = 0
|
||||
|
||||
|
||||
class Visibility(Choice):
|
||||
option_none = 0
|
||||
option_sending = 1
|
||||
default = 1
|
||||
|
||||
|
||||
class FactorioStartItems(OptionDict):
|
||||
default = {"burner-mining-drill": 19, "stone-furnace": 19}
|
||||
|
||||
|
||||
factorio_options: typing.Dict[str, type(Option)] = {"max_science_pack": MaxSciencePack,
|
||||
"tech_tree_layout": TechTreeLayout,
|
||||
"tech_cost": TechCost,
|
||||
"free_samples": FreeSamples,
|
||||
"visibility": Visibility,
|
||||
"random_tech_ingredients": Toggle,
|
||||
"starting_items": FactorioStartItems}
|
||||
|
||||
|
||||
class AdvancementGoal(Choice):
|
||||
option_few = 0
|
||||
option_normal = 1
|
||||
option_many = 2
|
||||
default = 1
|
||||
|
||||
|
||||
class CombatDifficulty(Choice):
|
||||
option_easy = 0
|
||||
option_normal = 1
|
||||
option_hard = 2
|
||||
default = 1
|
||||
|
||||
|
||||
minecraft_options: typing.Dict[str, type(Option)] = {
|
||||
"advancement_goal": AdvancementGoal,
|
||||
"combat_difficulty": CombatDifficulty,
|
||||
"include_hard_advancements": Toggle,
|
||||
"include_insane_advancements": Toggle,
|
||||
"include_postgame_advancements": Toggle,
|
||||
"shuffle_structures": Toggle
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
from worlds.alttp.Options import Logic
|
||||
import argparse
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
keyshuffle = Toggle
|
||||
bigkey_shuffle = Toggle
|
||||
hints = Toggle
|
||||
test = argparse.Namespace()
|
||||
test.logic = Logic.from_text("no_logic")
|
||||
test.mapshuffle = mapshuffle.from_text("ON")
|
||||
test.map_shuffle = map_shuffle.from_text("ON")
|
||||
test.hints = hints.from_text('OFF')
|
||||
try:
|
||||
test.logic = Logic.from_text("overworld_glitches_typo")
|
||||
@@ -371,7 +296,7 @@ if __name__ == "__main__":
|
||||
test.logic_owg = Logic.from_text("owg")
|
||||
except KeyError as e:
|
||||
print(e)
|
||||
if test.mapshuffle:
|
||||
print("Mapshuffle is on")
|
||||
if test.map_shuffle:
|
||||
print("map_shuffle is on")
|
||||
print(f"Hints are {bool(test.hints)}")
|
||||
print(test)
|
||||
|
||||
39
Patch.py
@@ -2,7 +2,6 @@ import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
import lzma
|
||||
import hashlib
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
@@ -10,37 +9,13 @@ import sys
|
||||
from typing import Tuple, Optional
|
||||
|
||||
import Utils
|
||||
from worlds.alttp.Rom import JAP10HASH
|
||||
|
||||
|
||||
current_patch_version = 2
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: str = "") -> str:
|
||||
options = Utils.get_options()
|
||||
if not file_name:
|
||||
file_name = options["lttp_options"]["rom_file"]
|
||||
if not os.path.exists(file_name):
|
||||
file_name = Utils.local_path(file_name)
|
||||
return file_name
|
||||
|
||||
|
||||
def get_base_rom_bytes(file_name: str = "") -> bytes:
|
||||
from worlds.alttp.Rom import read_rom
|
||||
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
|
||||
if not base_rom_bytes:
|
||||
file_name = get_base_rom_path(file_name)
|
||||
base_rom_bytes = bytes(read_rom(open(file_name, "rb")))
|
||||
|
||||
basemd5 = hashlib.md5()
|
||||
basemd5.update(base_rom_bytes)
|
||||
if JAP10HASH != basemd5.hexdigest():
|
||||
raise Exception('Supplied Base Rom does not match known MD5 for JAP(1.0) release. '
|
||||
'Get the correct game and version, then dump it')
|
||||
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
|
||||
return base_rom_bytes
|
||||
|
||||
|
||||
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
from worlds.alttp.Rom import JAP10HASH
|
||||
patch = yaml.dump({"meta": metadata,
|
||||
"patch": patch,
|
||||
"game": "A Link to the Past",
|
||||
@@ -52,6 +27,7 @@ def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
|
||||
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
patch = bsdiff4.diff(get_base_rom_bytes(), rom)
|
||||
@@ -71,6 +47,7 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
|
||||
|
||||
|
||||
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
||||
if not ignore_version and data["compatible_version"] > current_patch_version:
|
||||
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
|
||||
@@ -184,3 +161,11 @@ if __name__ == "__main__":
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
|
||||
|
||||
def read_rom(stream, strip_header=True) -> bytearray:
|
||||
"""Reads rom into bytearray and optionally strips off any smc header"""
|
||||
buffer = bytearray(stream.read())
|
||||
if strip_header and len(buffer) % 0x400 == 0x200:
|
||||
return buffer[0x200:]
|
||||
return buffer
|
||||
|
||||
@@ -6,8 +6,12 @@ Currently, the following games are supported:
|
||||
* The Legend of Zelda: A Link to the Past
|
||||
* Factorio
|
||||
* Minecraft
|
||||
* Subnautica
|
||||
* Slay the Spire
|
||||
* Risk of Rain 2
|
||||
* The Legend of Zelda: Ocarina of Time
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial).
|
||||
For setup and instructions check out our [tutorials page](http://archipelago.gg:48484/tutorial).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
windows binaries.
|
||||
|
||||
@@ -36,6 +40,7 @@ This project makes use of multiple other projects. We wouldn't be here without t
|
||||
|
||||
* [z3randomizer](https://github.com/CaitSith2/z3randomizer)
|
||||
* [Enemizer](https://github.com/Ijwu/Enemizer)
|
||||
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome. We have a few asks of any new contributors.
|
||||
|
||||
201
Utils.py
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import typing
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> typing.Tuple[int, ...]:
|
||||
def tuplize_version(version: str) -> Version:
|
||||
return Version(*(int(piece, 10) for piece in version.split(".")))
|
||||
|
||||
|
||||
@@ -12,8 +12,9 @@ class Version(typing.NamedTuple):
|
||||
minor: int
|
||||
build: int
|
||||
|
||||
__version__ = "0.1.1"
|
||||
_version_tuple = tuplize_version(__version__)
|
||||
|
||||
__version__ = "0.1.7"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
import builtins
|
||||
import os
|
||||
@@ -22,6 +23,7 @@ import sys
|
||||
import pickle
|
||||
import functools
|
||||
import io
|
||||
import collections
|
||||
|
||||
from yaml import load, dump, safe_load
|
||||
|
||||
@@ -49,26 +51,22 @@ def snes_to_pc(value):
|
||||
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
|
||||
|
||||
|
||||
def parse_player_names(names, players, teams):
|
||||
names = tuple(n for n in (n.strip() for n in names.split(",")) if n)
|
||||
if len(names) != len(set(names)):
|
||||
import collections
|
||||
name_counter = collections.Counter(names)
|
||||
raise ValueError(f"Duplicate Player names is not supported, "
|
||||
f'found multiple "{name_counter.most_common(1)[0][0]}".')
|
||||
ret = []
|
||||
while names or len(ret) < teams:
|
||||
team = [n[:16] for n in names[:players]]
|
||||
# 16 bytes in rom per player, which will map to more in unicode, but those characters later get filtered
|
||||
while len(team) != players:
|
||||
team.append(f"Player{len(team) + 1}")
|
||||
ret.append(team)
|
||||
def cache_argsless(function):
|
||||
if function.__code__.co_argcount:
|
||||
raise Exception("Can only cache 0 argument functions with this cache.")
|
||||
|
||||
names = names[players:]
|
||||
return ret
|
||||
result = sentinel = object()
|
||||
|
||||
def _wrap():
|
||||
nonlocal result
|
||||
if result is sentinel:
|
||||
result = function()
|
||||
return result
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
def is_bundled() -> bool:
|
||||
def is_frozen() -> bool:
|
||||
return getattr(sys, 'frozen', False)
|
||||
|
||||
|
||||
@@ -76,7 +74,7 @@ def local_path(*path):
|
||||
if local_path.cached_path:
|
||||
return os.path.join(local_path.cached_path, *path)
|
||||
|
||||
elif is_bundled():
|
||||
elif is_frozen():
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
# we are running in a PyInstaller bundle
|
||||
local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member
|
||||
@@ -118,20 +116,11 @@ def open_file(filename):
|
||||
subprocess.call([open_command, filename])
|
||||
|
||||
|
||||
def close_console():
|
||||
if sys.platform == 'win32':
|
||||
# windows
|
||||
import ctypes.wintypes
|
||||
try:
|
||||
ctypes.windll.kernel32.FreeConsole()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
parse_yaml = safe_load
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_public_ipv4() -> str:
|
||||
import socket
|
||||
import urllib.request
|
||||
@@ -148,6 +137,7 @@ def get_public_ipv4() -> str:
|
||||
return ip
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_public_ipv6() -> str:
|
||||
import socket
|
||||
import urllib.request
|
||||
@@ -161,77 +151,62 @@ def get_public_ipv6() -> str:
|
||||
return ip
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_default_options() -> dict:
|
||||
if not hasattr(get_default_options, "options"):
|
||||
# Refer to host.yaml for comments as to what all these options mean.
|
||||
options = {
|
||||
"general_options": {
|
||||
"output_path": "output",
|
||||
},
|
||||
"factorio_options": {
|
||||
"executable": "factorio\\bin\\x64\\factorio",
|
||||
},
|
||||
"lttp_options": {
|
||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||
"qusb2snes": "QUsb2Snes\\QUsb2Snes.exe",
|
||||
"rom_start": True,
|
||||
# Refer to host.yaml for comments as to what all these options mean.
|
||||
options = {
|
||||
"general_options": {
|
||||
"output_path": "output",
|
||||
},
|
||||
"factorio_options": {
|
||||
"executable": "factorio\\bin\\x64\\factorio",
|
||||
},
|
||||
"lttp_options": {
|
||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
|
||||
},
|
||||
"server_options": {
|
||||
"host": None,
|
||||
"port": 38281,
|
||||
"password": None,
|
||||
"multidata": None,
|
||||
"savefile": None,
|
||||
"disable_save": False,
|
||||
"loglevel": "info",
|
||||
"server_password": None,
|
||||
"disable_item_cheat": False,
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 1000,
|
||||
"forfeit_mode": "goal",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
"compatibility": 2,
|
||||
"log_network": 0
|
||||
},
|
||||
"multi_mystery_options": {
|
||||
"teams": 1,
|
||||
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"pre_roll": False,
|
||||
"player_name": "",
|
||||
"create_spoiler": 1,
|
||||
"zip_roms": 0,
|
||||
"zip_diffs": 2,
|
||||
"zip_spoiler": 0,
|
||||
"zip_multidata": 1,
|
||||
"zip_format": 1,
|
||||
"glitch_triforce_room": 1,
|
||||
"race": 0,
|
||||
"cpu_threads": 0,
|
||||
"max_attempts": 0,
|
||||
"take_first_working": False,
|
||||
"keep_all_seeds": False,
|
||||
"log_output_path": "Output Logs",
|
||||
"log_level": None,
|
||||
"plando_options": "bosses",
|
||||
}
|
||||
},
|
||||
"server_options": {
|
||||
"host": None,
|
||||
"port": 38281,
|
||||
"password": None,
|
||||
"multidata": None,
|
||||
"savefile": None,
|
||||
"disable_save": False,
|
||||
"loglevel": "info",
|
||||
"server_password": None,
|
||||
"disable_item_cheat": False,
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 10,
|
||||
"forfeit_mode": "goal",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
"compatibility": 2,
|
||||
"log_network": 0
|
||||
},
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"spoiler": 2,
|
||||
"glitch_triforce_room": 1,
|
||||
"race": 0,
|
||||
"plando_options": "bosses",
|
||||
},
|
||||
"minecraft_options": {
|
||||
"forge_directory": "Minecraft Forge server",
|
||||
"max_heap_size": "2G"
|
||||
},
|
||||
"oot_options": {
|
||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||
}
|
||||
}
|
||||
|
||||
get_default_options.options = options
|
||||
return get_default_options.options
|
||||
|
||||
|
||||
blacklisted_options = {"multi_mystery_options.cpu_threads",
|
||||
"multi_mystery_options.max_attempts",
|
||||
"multi_mystery_options.take_first_working",
|
||||
"multi_mystery_options.keep_all_seeds",
|
||||
"multi_mystery_options.log_output_path",
|
||||
"multi_mystery_options.log_level"}
|
||||
return options
|
||||
|
||||
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
@@ -242,11 +217,11 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
option_name = '.'.join(new_keys)
|
||||
if key not in dest:
|
||||
dest[key] = value
|
||||
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
|
||||
if filename.endswith("options.yaml"):
|
||||
logging.info(f"Warning: {filename} is missing {option_name}")
|
||||
elif isinstance(value, dict):
|
||||
if not isinstance(dest.get(key, None), dict):
|
||||
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
|
||||
if filename.endswith("options.yaml"):
|
||||
logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.")
|
||||
dest[key] = value
|
||||
else:
|
||||
@@ -254,6 +229,7 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
return dest
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
if not hasattr(get_options, "options"):
|
||||
locations = ("options.yaml", "host.yaml",
|
||||
@@ -309,7 +285,7 @@ def persistent_load() -> typing.Dict[dict]:
|
||||
return storage
|
||||
|
||||
|
||||
def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
|
||||
def get_adjuster_settings(romfile: str, skip_questions: bool = False) -> typing.Tuple[str, bool]:
|
||||
if hasattr(get_adjuster_settings, "adjuster_settings"):
|
||||
adjuster_settings = getattr(get_adjuster_settings, "adjuster_settings")
|
||||
else:
|
||||
@@ -317,11 +293,11 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
|
||||
|
||||
if adjuster_settings:
|
||||
import pprint
|
||||
import Patch
|
||||
from worlds.alttp.Rom import get_base_rom_path
|
||||
adjuster_settings.rom = romfile
|
||||
adjuster_settings.baserom = Patch.get_base_rom_path()
|
||||
adjuster_settings.baserom = get_base_rom_path()
|
||||
adjuster_settings.world = None
|
||||
whitelist = {"disablemusic", "fastmenu", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite"}
|
||||
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
@@ -339,18 +315,20 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
|
||||
adjust_wanted = getattr(get_adjuster_settings, "adjust_wanted")
|
||||
elif persistent_load().get("adjuster", {}).get("never_adjust", False): # never adjust, per user request
|
||||
return romfile, False
|
||||
elif skip_questions:
|
||||
return romfile, False
|
||||
else:
|
||||
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
|
||||
f"{pprint.pformat(printed_options)}\n"
|
||||
f"Enter yes, no or never: ")
|
||||
if adjust_wanted and adjust_wanted.startswith("y"):
|
||||
if hasattr(adjuster_settings, "sprite_pool"):
|
||||
from Adjuster import AdjusterWorld
|
||||
from LttPAdjuster import AdjusterWorld
|
||||
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
|
||||
|
||||
adjusted = True
|
||||
import Adjuster
|
||||
_, romfile = Adjuster.adjust(adjuster_settings)
|
||||
import LttPAdjuster
|
||||
_, romfile = LttPAdjuster.adjust(adjuster_settings)
|
||||
|
||||
if hasattr(adjuster_settings, "world"):
|
||||
delattr(adjuster_settings, "world")
|
||||
@@ -368,6 +346,7 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
|
||||
return romfile, False
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_unique_identifier():
|
||||
uuid = persistent_load().get("client", {}).get("uuid", None)
|
||||
if uuid:
|
||||
@@ -404,4 +383,10 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
|
||||
def restricted_loads(s):
|
||||
"""Helper function analogous to pickle.loads()."""
|
||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||
|
||||
|
||||
class KeyedDefaultDict(collections.defaultdict):
|
||||
def __missing__(self, key):
|
||||
self[key] = value = self.default_factory(key)
|
||||
return value
|
||||
12
WebHost.py
@@ -2,11 +2,21 @@ import os
|
||||
import multiprocessing
|
||||
import logging
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
|
||||
ModuleUpdate.update()
|
||||
|
||||
# in case app gets imported by something like gunicorn
|
||||
import Utils
|
||||
Utils.local_path.cached_path = os.path.dirname(__file__)
|
||||
|
||||
from WebHostLib import app as raw_app
|
||||
from waitress import serve
|
||||
|
||||
from WebHostLib.models import db
|
||||
from WebHostLib.autolauncher import autohost
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
|
||||
@@ -26,7 +36,9 @@ if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||
update_sprites_lttp()
|
||||
app = get_app()
|
||||
create_options_files()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
|
||||
|
||||
@@ -3,10 +3,12 @@ import uuid
|
||||
import base64
|
||||
import socket
|
||||
|
||||
import jinja2.exceptions
|
||||
from pony.flask import Pony
|
||||
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
|
||||
from flask_caching import Cache
|
||||
from flask_compress import Compress
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
from .models import *
|
||||
|
||||
@@ -74,24 +76,53 @@ def register_session():
|
||||
session["_id"] = uuid4() # uniquely identify each session without needing a login
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html", game=game)
|
||||
|
||||
|
||||
# Game sub-pages
|
||||
@app.route('/games/<string:game>/<string:page>')
|
||||
def game_pages(game, page):
|
||||
return render_template(f"/games/{game}/{page}.html")
|
||||
|
||||
|
||||
# Game landing pages
|
||||
@app.route('/games/<game>')
|
||||
def game_page(game):
|
||||
return render_template(f"/games/{game}/{game}.html")
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world.__doc__ if world.__doc__ else "No description provided."
|
||||
return render_template("games/games.html", worlds=worlds)
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
def tutorial(game, file, lang):
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang)
|
||||
|
||||
|
||||
@app.route('/tutorial')
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@app.route('/player-settings')
|
||||
def player_settings_simple():
|
||||
return render_template("playerSettings.html")
|
||||
|
||||
|
||||
@app.route('/weighted-settings')
|
||||
def player_settings():
|
||||
return render_template("weightedSettings.html")
|
||||
@app.route('/faq/<string:lang>/')
|
||||
def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
@@ -128,7 +159,7 @@ def display_log(room: UUID):
|
||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
|
||||
|
||||
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def hostRoom(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
@@ -145,6 +176,11 @@ def hostRoom(room: UUID):
|
||||
return render_template("hostRoom.html", room=room)
|
||||
|
||||
|
||||
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
|
||||
def hostRoomRedirect(room: UUID):
|
||||
return redirect(url_for("hostRoom", room=room))
|
||||
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
return send_from_directory(os.path.join(app.root_path, 'static/static'),
|
||||
@@ -153,4 +189,5 @@ def favicon():
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -4,14 +4,15 @@ from uuid import UUID
|
||||
from flask import Blueprint, abort
|
||||
|
||||
from ..models import Room
|
||||
from .. import cache
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
from . import generate, user # trigger registration
|
||||
|
||||
|
||||
# unsorted/misc endpoints
|
||||
|
||||
|
||||
@api_endpoints.route('/room_status/<suuid:room>')
|
||||
def room_info(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
@@ -22,3 +23,18 @@ def room_info(room: UUID):
|
||||
"last_port": room.last_port,
|
||||
"last_activity": room.last_activity,
|
||||
"timeout": room.timeout}
|
||||
|
||||
|
||||
@api_endpoints.route('/datapackage')
|
||||
@cache.cached()
|
||||
def get_datapackge():
|
||||
from worlds import network_data_package
|
||||
return network_data_package
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
def get_datapackge_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
version_package["version"] = network_data_package["version"]
|
||||
return version_package
|
||||
|
||||
@@ -7,6 +7,7 @@ import concurrent.futures
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
import os
|
||||
|
||||
from pony.orm import db_session, select, commit
|
||||
|
||||
@@ -15,10 +16,13 @@ from Utils import restricted_loads
|
||||
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
|
||||
def __init__(self, lockname: str):
|
||||
lock_folder = "file_locks"
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
os.makedirs(self.lock_folder, exist_ok=True)
|
||||
self.lockname = lockname
|
||||
self.lockfile = f"./{self.lockname}.lck"
|
||||
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
|
||||
|
||||
|
||||
class AlreadyRunningException(Exception):
|
||||
@@ -26,9 +30,6 @@ class AlreadyRunningException(Exception):
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
import os
|
||||
|
||||
|
||||
class Locker(CommonLocker):
|
||||
def __enter__(self):
|
||||
try:
|
||||
@@ -92,10 +93,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
"sid": generation.id,
|
||||
"owner": generation.owner},
|
||||
handle_generation_success, handle_generation_failure)
|
||||
except:
|
||||
except Exception as e:
|
||||
generation.state = STATE_ERROR
|
||||
commit()
|
||||
raise
|
||||
logging.exception(e)
|
||||
else:
|
||||
generation.state = STATE_STARTED
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Mystery import roll_settings
|
||||
from Generate import roll_settings
|
||||
from Utils import parse_yaml
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ class WebHostContext(Context):
|
||||
def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
@@ -144,7 +145,9 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
await ctx.shutdown_task
|
||||
logging.info("Shutting down")
|
||||
|
||||
asyncio.run(main())
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
from WebHostLib import LOGS_FOLDER
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from flask import send_file, Response
|
||||
from flask import send_file, Response, render_template
|
||||
from pony.orm import select
|
||||
|
||||
from Patch import update_patch_data
|
||||
from WebHostLib import app, Slot, Room, Seed
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
import zipfile
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
@@ -45,10 +45,10 @@ def download_raw_patch(seed_id, player_id: int):
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp"
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
|
||||
@app.route("/slot_file/<suuid:seed_id>/<int:player_id>")
|
||||
def download_slot_file(seed_id, player_id: int):
|
||||
seed = Seed.get(id=seed_id)
|
||||
slot_data: Slot = select(patch for patch in seed.slots if
|
||||
@app.route("/slot_file/<suuid:room_id>/<int:player_id>")
|
||||
def download_slot_file(room_id, player_id: int):
|
||||
room = Room.get(id=room_id)
|
||||
slot_data: Slot = select(patch for patch in room.seed.slots if
|
||||
patch.player_id == player_id).first()
|
||||
|
||||
if not slot_data:
|
||||
@@ -57,12 +57,27 @@ def download_slot_file(seed_id, player_id: int):
|
||||
import io
|
||||
|
||||
if slot_data.game == "Minecraft":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](seed_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
from worlds.minecraft import mc_update_output
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
|
||||
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
|
||||
return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
|
||||
elif slot_data.game == "Factorio":
|
||||
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("info.json"):
|
||||
fname = name.rsplit("/", 1)[0]+".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
else:
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
|
||||
@app.route("/templates")
|
||||
@cache.cached()
|
||||
def list_yaml_templates():
|
||||
files = []
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
for world_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
files.append(world_name)
|
||||
return render_template("templates.html", files=files)
|
||||
@@ -9,7 +9,7 @@ from flask import request, flash, redirect, url_for, session, render_template
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from Main import get_seed, seeddigits
|
||||
from Mystery import handle_name
|
||||
from Generate import handle_name
|
||||
import pickle
|
||||
|
||||
from .models import *
|
||||
@@ -80,7 +80,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.create_diff = True
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
@@ -92,8 +91,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
erargs.names = ",".join(erargs.name[i] for i in range(1, playercount + 1))
|
||||
del (erargs.name)
|
||||
ERmain(erargs, seed)
|
||||
|
||||
return upload_to_db(target.name, owner, sid, race)
|
||||
|
||||
45
WebHostLib/lttpsprites.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
import threading
|
||||
import json
|
||||
|
||||
from Utils import local_path
|
||||
from worlds.alttp.Rom import Sprite
|
||||
|
||||
|
||||
def update_sprites_lttp():
|
||||
from tkinter import Tk
|
||||
from LttPAdjuster import get_image_for_sprite
|
||||
from LttPAdjuster import BackgroundTaskProgress
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# Target directories
|
||||
input_dir = local_path("data", "sprites", "alttpr")
|
||||
output_dir = local_path("WebHostLib", "static", "generated")
|
||||
|
||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||
# update sprites through gui.py's functions
|
||||
done = threading.Event()
|
||||
top = Tk()
|
||||
top.withdraw()
|
||||
BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.isSet():
|
||||
top.update()
|
||||
|
||||
spriteData = []
|
||||
|
||||
for file in os.listdir(input_dir):
|
||||
sprite = Sprite(os.path.join(input_dir, file))
|
||||
|
||||
if not sprite.name:
|
||||
print("Warning:", file, "has no name.")
|
||||
sprite.name = file.split(".", 1)[0]
|
||||
if sprite.valid:
|
||||
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
|
||||
image.write(get_image_for_sprite(sprite, True))
|
||||
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
|
||||
else:
|
||||
print(file, "dropped, as it has no valid sprite data.")
|
||||
spriteData.sort(key=lambda entry: entry["name"])
|
||||
with open(f'{output_dir}/spriteData.json', 'w') as file:
|
||||
json.dump({"sprites": spriteData}, file, indent=1)
|
||||
return spriteData
|
||||
73
WebHostLib/options.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
from Utils import __version__
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
import json
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
target_folder = os.path.join("WebHostLib", "static", "generated")
|
||||
|
||||
|
||||
def create():
|
||||
def dictify_range(option):
|
||||
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50}
|
||||
notes = {
|
||||
option.range_start: "minimum value",
|
||||
option.range_end: "maximum value"
|
||||
}
|
||||
return data, notes
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
|
||||
options=world.options, __version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range
|
||||
)
|
||||
|
||||
with open(os.path.join(target_folder, game_name + ".yaml"), "w") as f:
|
||||
f.write(res)
|
||||
|
||||
# Generate JSON files for player-settings pages
|
||||
player_settings = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"name": "Player",
|
||||
},
|
||||
}
|
||||
|
||||
game_options = {}
|
||||
for option_name, option in world.options.items():
|
||||
if option.options:
|
||||
this_option = {
|
||||
"type": "select",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": None,
|
||||
"options": []
|
||||
}
|
||||
|
||||
for sub_option_name, sub_option_id in option.options.items():
|
||||
this_option["options"].append({
|
||||
"name": option.get_option_name(sub_option_id),
|
||||
"value": sub_option_name,
|
||||
})
|
||||
|
||||
if sub_option_id == option.default:
|
||||
this_option["defaultValue"] = sub_option_name
|
||||
|
||||
game_options[option_name] = this_option
|
||||
|
||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
player_settings["gameOptions"] = game_options
|
||||
|
||||
with open(os.path.join(target_folder, game_name + ".json"), "w") as f:
|
||||
f.write(json.dumps(player_settings, indent=2, separators=(',', ': ')))
|
||||
@@ -1,6 +1,6 @@
|
||||
flask>=2.0.0
|
||||
flask>=2.0.1
|
||||
pony>=0.7.14
|
||||
waitress>=2.0.0
|
||||
flask-caching>=1.10.1
|
||||
Flask-Compress>=1.9.0
|
||||
Flask-Compress>=1.10.1
|
||||
Flask-Limiter>=1.4
|
||||
|
||||
50
WebHostLib/static/assets/faq.js
Normal file
@@ -0,0 +1,50 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('faq-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the tutorial is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the tutorial.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`faq_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
|
||||
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
|
||||
for (let i=0; i < headers.length; i++){
|
||||
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
|
||||
headers[i].setAttribute('id', headerId);
|
||||
headers[i].addEventListener('click', () =>
|
||||
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
if (scrollTargetIndex > -1) {
|
||||
try{
|
||||
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
|
||||
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
|
||||
} catch(error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
tutorialWrapper.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
10
WebHostLib/static/assets/faq/faq_en.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## What is a randomizer?
|
||||
Who's on first.
|
||||
|
||||
## What is a multi-world?
|
||||
What's on second.
|
||||
|
||||
## What does multi-game mean?
|
||||
I don't know's on third.
|
||||
49
WebHostLib/static/assets/minecraftTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
206
WebHostLib/static/assets/player-settings.js
Normal file
@@ -0,0 +1,206 @@
|
||||
let gameName = null;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
gameName = document.getElementById('player-settings').getAttribute('data-game');
|
||||
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerHTML = gameName;
|
||||
|
||||
Promise.all([fetchSettingData()]).then((results) => {
|
||||
// Page setup
|
||||
createDefaultSettings(results[0]);
|
||||
buildUI(results[0]);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true))
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
|
||||
// Name input field
|
||||
const playerSettings = JSON.parse(localStorage.getItem(gameName));
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
}).catch((error) => {
|
||||
const url = new URL(window.location.href);
|
||||
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||
})
|
||||
});
|
||||
|
||||
const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject(ajax.responseText);
|
||||
return;
|
||||
}
|
||||
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||
catch(error){ reject(error); }
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/${gameName}.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const createDefaultSettings = (settingData) => {
|
||||
if (!localStorage.getItem(gameName)) {
|
||||
const newSettings = {
|
||||
[gameName]: {},
|
||||
};
|
||||
for (let baseOption of Object.keys(settingData.baseOptions)){
|
||||
newSettings[baseOption] = settingData.baseOptions[baseOption];
|
||||
}
|
||||
for (let gameOption of Object.keys(settingData.gameOptions)){
|
||||
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
||||
}
|
||||
localStorage.setItem(gameName, JSON.stringify(newSettings));
|
||||
}
|
||||
};
|
||||
|
||||
const buildUI = (settingData) => {
|
||||
// Game Options
|
||||
const leftGameOpts = {};
|
||||
const rightGameOpts = {};
|
||||
Object.keys(settingData.gameOptions).forEach((key, index) => {
|
||||
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
|
||||
else { rightGameOpts[key] = settingData.gameOptions[key]; }
|
||||
});
|
||||
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||
};
|
||||
|
||||
const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem(gameName));
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
Object.keys(settings).forEach((setting) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// td Left
|
||||
const tdl = document.createElement('td');
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', setting);
|
||||
label.setAttribute('data-tooltip', settings[setting].description);
|
||||
label.innerText = `${settings[setting].displayName}:`;
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
// td Right
|
||||
const tdr = document.createElement('td');
|
||||
let element = null;
|
||||
|
||||
switch(settings[setting].type){
|
||||
case 'select':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('select-container');
|
||||
let select = document.createElement('select');
|
||||
select.setAttribute('id', setting);
|
||||
select.setAttribute('data-key', setting);
|
||||
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||
settings[setting].options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.setAttribute('value', opt.value);
|
||||
option.innerText = opt.name;
|
||||
if ((isNaN(currentSettings[gameName][setting]) &&
|
||||
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
|
||||
(opt.value === currentSettings[gameName][setting]))
|
||||
{
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateGameSetting(event));
|
||||
element.appendChild(select);
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('range-container');
|
||||
|
||||
let range = document.createElement('input');
|
||||
range.setAttribute('type', 'range');
|
||||
range.setAttribute('data-key', setting);
|
||||
range.setAttribute('min', settings[setting].min);
|
||||
range.setAttribute('max', settings[setting].max);
|
||||
range.value = currentSettings[gameName][setting];
|
||||
range.addEventListener('change', (event) => {
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
element.appendChild(range);
|
||||
|
||||
let rangeVal = document.createElement('span');
|
||||
rangeVal.classList.add('range-value');
|
||||
rangeVal.setAttribute('id', `${setting}-value`);
|
||||
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
element.appendChild(rangeVal);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown setting type: ${settings[setting].type}`);
|
||||
console.error(setting);
|
||||
return;
|
||||
}
|
||||
|
||||
tdr.appendChild(element);
|
||||
tr.appendChild(tdr);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
const updateBaseSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value);
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const updateGameSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem(gameName));
|
||||
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
localStorage.setItem(gameName, JSON.stringify(options));
|
||||
};
|
||||
|
||||
const exportSettings = () => {
|
||||
const settings = JSON.parse(localStorage.getItem(gameName));
|
||||
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
|
||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||
};
|
||||
|
||||
/** Create an anchor and trigger a download of a text file. */
|
||||
const download = (filename, text) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||
downloadLink.setAttribute('download', filename);
|
||||
downloadLink.style.display = 'none';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
};
|
||||
|
||||
const generateGame = (raceMode = false) => {
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: localStorage.getItem(gameName) },
|
||||
presetData: { player: localStorage.getItem(gameName) },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
}).catch((error) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
||||
if (error.response.data.text) {
|
||||
userMessage.innerText += ' ' + error.response.data.text;
|
||||
}
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
Promise.all([fetchSettingData(), fetchSpriteData()]).then((results) => {
|
||||
// Page setup
|
||||
createDefaultSettings(results[0]);
|
||||
buildUI(results[0]);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true))
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
|
||||
// Name input field
|
||||
const playerSettings = JSON.parse(localStorage.getItem('playerSettings'));
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
|
||||
// Sprite options
|
||||
const spriteData = JSON.parse(results[1]);
|
||||
const spriteSelect = document.getElementById('sprite');
|
||||
spriteData.sprites.forEach((sprite) => {
|
||||
if (sprite.name.trim().length === 0) { return; }
|
||||
const option = document.createElement('option');
|
||||
option.setAttribute('value', sprite.name.trim());
|
||||
if (playerSettings.rom.sprite === sprite.name.trim()) { option.selected = true; }
|
||||
option.innerText = sprite.name;
|
||||
spriteSelect.appendChild(option);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
})
|
||||
});
|
||||
|
||||
const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject(ajax.responseText);
|
||||
return;
|
||||
}
|
||||
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||
catch(error){ reject(error); }
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/playerSettings.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const createDefaultSettings = (settingData) => {
|
||||
if (!localStorage.getItem('playerSettings')) {
|
||||
const newSettings = {};
|
||||
for (let roSetting of Object.keys(settingData.readOnly)){
|
||||
newSettings[roSetting] = settingData.readOnly[roSetting];
|
||||
}
|
||||
for (let generalOption of Object.keys(settingData.generalOptions)){
|
||||
newSettings[generalOption] = settingData.generalOptions[generalOption];
|
||||
}
|
||||
for (let gameOption of Object.keys(settingData.gameOptions)){
|
||||
newSettings[gameOption] = settingData.gameOptions[gameOption].defaultValue;
|
||||
}
|
||||
newSettings.rom = {};
|
||||
for (let romOption of Object.keys(settingData.romOptions)){
|
||||
newSettings.rom[romOption] = settingData.romOptions[romOption].defaultValue;
|
||||
}
|
||||
localStorage.setItem('playerSettings', JSON.stringify(newSettings));
|
||||
}
|
||||
};
|
||||
|
||||
const buildUI = (settingData) => {
|
||||
// Game Options
|
||||
const leftGameOpts = {};
|
||||
const rightGameOpts = {};
|
||||
Object.keys(settingData.gameOptions).forEach((key, index) => {
|
||||
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
|
||||
else { rightGameOpts[key] = settingData.gameOptions[key]; }
|
||||
});
|
||||
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
|
||||
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
|
||||
|
||||
// ROM Options
|
||||
const leftRomOpts = {};
|
||||
const rightRomOpts = {};
|
||||
Object.keys(settingData.romOptions).forEach((key, index) => {
|
||||
if (index < Object.keys(settingData.romOptions).length / 2) { leftRomOpts[key] = settingData.romOptions[key]; }
|
||||
else { rightRomOpts[key] = settingData.romOptions[key]; }
|
||||
});
|
||||
document.getElementById('rom-options-left').appendChild(buildOptionsTable(leftRomOpts, true));
|
||||
document.getElementById('rom-options-right').appendChild(buildOptionsTable(rightRomOpts, true));
|
||||
};
|
||||
|
||||
const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const currentSettings = JSON.parse(localStorage.getItem('playerSettings'));
|
||||
const table = document.createElement('table');
|
||||
const tbody = document.createElement('tbody');
|
||||
|
||||
Object.keys(settings).forEach((setting) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
// td Left
|
||||
const tdl = document.createElement('td');
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', setting);
|
||||
label.setAttribute('data-tooltip', settings[setting].description);
|
||||
label.innerText = `${settings[setting].friendlyName}:`;
|
||||
tdl.appendChild(label);
|
||||
tr.appendChild(tdl);
|
||||
|
||||
// td Right
|
||||
const tdr = document.createElement('td');
|
||||
const select = document.createElement('select');
|
||||
select.setAttribute('id', setting);
|
||||
select.setAttribute('data-key', setting);
|
||||
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
|
||||
settings[setting].options.forEach((opt) => {
|
||||
const option = document.createElement('option');
|
||||
option.setAttribute('value', opt.value);
|
||||
option.innerText = opt.name;
|
||||
if ((isNaN(currentSettings[setting]) && (parseInt(opt.value, 10) === parseInt(currentSettings[setting]))) ||
|
||||
(opt.value === currentSettings[setting])) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener('change', (event) => updateSetting(event));
|
||||
tdr.appendChild(select);
|
||||
tr.appendChild(tdr);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
const updateSetting = (event) => {
|
||||
const options = JSON.parse(localStorage.getItem('playerSettings'));
|
||||
if (event.target.getAttribute('data-romOpt')) {
|
||||
options.rom[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
} else {
|
||||
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
}
|
||||
localStorage.setItem('playerSettings', JSON.stringify(options));
|
||||
};
|
||||
|
||||
const exportSettings = () => {
|
||||
const settings = JSON.parse(localStorage.getItem('playerSettings'));
|
||||
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
|
||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
|
||||
};
|
||||
|
||||
/** Create an anchor and trigger a download of a text file. */
|
||||
const download = (filename, text) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||
downloadLink.setAttribute('download', filename);
|
||||
downloadLink.style.display = 'none';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
};
|
||||
|
||||
const generateGame = (raceMode = false) => {
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: localStorage.getItem('playerSettings') },
|
||||
presetData: { player: localStorage.getItem('playerSettings') },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
}).catch((error) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchSpriteData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject('Unable to fetch sprite data.');
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
@@ -17,6 +17,47 @@ window.addEventListener('load', () => {
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'hours',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
if (data === "None")
|
||||
return -1;
|
||||
|
||||
return parseInt(data);
|
||||
}
|
||||
if (data === "None")
|
||||
return data;
|
||||
|
||||
let hours = Math.floor(data / 3600);
|
||||
let minutes = Math.floor((data - (hours * 3600)) / 60);
|
||||
|
||||
if (minutes < 10) {minutes = "0"+minutes;}
|
||||
return hours+':'+minutes;
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'number',
|
||||
render: function (data, type, row) {
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseFloat(data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
{
|
||||
targets: 'fraction',
|
||||
render: function (data, type, row) {
|
||||
let splitted = data.split("/", 1);
|
||||
let current = splitted[0]
|
||||
if (type === "sort" || type === 'type') {
|
||||
return parseInt(current);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
|
||||
// the tbody and render two separate tables.
|
||||
|
||||
@@ -15,8 +15,8 @@ One Server Host exists per Factorio World in an Archipelago Multiworld, any numb
|
||||
## Installation Procedures
|
||||
|
||||
### Dedicated Server Setup
|
||||
You need a dedicated isolated Factorio installation that the FactorioClient can take control over, if you intend to both emit a world and play, you need to follow both this setup and the player setup.
|
||||
This requires two Factorio installations. The easiest and cheapest way to do so is to either buy or register a Factorio on factorio.com, which allows you to download as many Factorio games as you want.
|
||||
You need a dedicated isolated Factorio installation that the FactorioClient can take control over. If you intend to both host a world and play on the same device, you will need two separate Factorio installations; one for the FactorioClient to hook into and control, and one for you to play on.
|
||||
The easiest and cheapest way to do so is to either buy or register a Factorio key on factorio.com, which allows you to download as many Factorio games as you want. If you own a steam copy already you can link your account on the website.
|
||||
1. Download the latest Factorio from https://factorio.com/download for your system, for Windows the recommendation is "win64-manual".
|
||||
|
||||
2. Make sure the Factorio you play and the Factorio you use for hosting do not share paths. If you downloaded the "manual" version, this is already the case, otherwise, go into the hosting Factorio's folder and put the following text into its `config-path.cfg`:
|
||||
@@ -24,28 +24,29 @@ This requires two Factorio installations. The easiest and cheapest way to do so
|
||||
config-path=__PATH__executable__/../../config
|
||||
use-system-read-write-data-directories=false
|
||||
```
|
||||
3. Navigate to where you installed Archipelago and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your Factorio. If you put Factorio into your Archipelago folder, this would already match.
|
||||
|
||||
|
||||
3. In this same folder if there are shortcuts named "mods" and "saves" delete these and replace with folders with the same names.
|
||||
4. Navigate to where you installed ArchipelagoFactorioClient and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your hosting Factorio.exe. If you put Factorio into your Archipelago folder, this would already match.<br>
|
||||
ex.
|
||||
```yaml
|
||||
factorio_options:
|
||||
executable: C:\\Program Files\\factorio\\bin\\x64\\factorio"
|
||||
```
|
||||
### Player Setup
|
||||
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer.
|
||||
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer. If you're connecting to a FactorioClient on the same system you will connect to localhost
|
||||
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Make a fresh world. Using any Factorio, create a savegame with options of your choice and save that world as Archipelago.
|
||||
1. Install the generated Factorio AP Mod (would be in /Mods after step 2 of Setup)
|
||||
|
||||
2. Take that savegame and put it into your Archipelago folder
|
||||
2. Run FactorioClient, it should launch a Factorio server, which you can control with /factorio <original factorio commands>,
|
||||
|
||||
3. Install the generated Factorio AP Mod
|
||||
* It should start up, create a world and become ready for Factorio connections.
|
||||
3. In FactorioClient, do /connect <Archipelago Server Address> to join that multiworld. You can find further commands with /help as well as !help once connected.
|
||||
|
||||
4. Run FactorioClient, it should launch a Factorio server, which you can control with `/factorio <original factorio commands>`,
|
||||
|
||||
* It should say it loaded the Archipelago mod and found a bridge file. If not, the most likely case is that the mod is not correctly installed or activated.
|
||||
* / commands are run on your local client, ! commands are requests for the AP server
|
||||
|
||||
5. In FactorioClient, do `/connect <Archipelago Server Address>` to join that multiworld. You can find further commands with `/help` as well as `!help` once connected.
|
||||
* Players should be able to connect to your Factorio Server and begin playing.
|
||||
|
||||
* / commands are run on your local client, ! commands are requests for the AP server
|
||||
|
||||
* Players should be able to connect to your Factorio Server and begin playing.
|
||||
4. You can join yourself by connecting to address localhost, other people will need to connect to your IP and you may need to port forward for the Factorio Server for those connections.
|
||||
|
||||
|
||||
@@ -1,54 +1,12 @@
|
||||
# Minecraft Randomizer Setup Guide
|
||||
|
||||
#Automatic Hosting Install
|
||||
- download and install [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and choose the `Minecraft Client` module
|
||||
|
||||
## Required Software
|
||||
|
||||
### Server Host
|
||||
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
|
||||
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
|
||||
|
||||
### Players
|
||||
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### Dedicated Server Setup
|
||||
Only one person has to do this setup and host a dedicated server for everyone else playing to connect to.
|
||||
1. Download the 1.16.5 **Minecraft Forge** installer from the link above, making sure to download the most recent recommended version.
|
||||
|
||||
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install server**.
|
||||
- On this page you will also choose where to install the server to remember this directory it's important in the next step.
|
||||
|
||||
3. Navigate to where you installed the server and open `forge-1.16.5-xx.x.x.jar`
|
||||
- Upon first launch of the server it will close and ask you to accept Minecraft's EULA. There will be a new file called `eula.txt` that contains a link to Minecraft's EULA, and a line that you need to change to `eula=true` to accept Minecraft's EULA.
|
||||
- This will create the appropriate directories for you to place the files in the following step.
|
||||
|
||||
4. Place the `aprandomizer-x.x.x.jar` from the link above file into the `mods` folder of the above installation of your forge server.
|
||||
- Once again run the server, it will load up and generate the required directory `APData` for when you are ready to play a game!
|
||||
|
||||
### Basic Player Setup
|
||||
- Purchase and install Minecraft from the above link.
|
||||
|
||||
**You're Done**.
|
||||
|
||||
Players only need to have a Vanilla unmodified version of Minecraft to play!
|
||||
|
||||
### Advanced Player Setup
|
||||
***This is not required to play a randomized minecraft game.***
|
||||
however this recommended as it helps make the experience more enjoyable.
|
||||
|
||||
#### Recomended Mods
|
||||
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
|
||||
|
||||
|
||||
1. Install and run Minecraft from the link above at least once.
|
||||
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install client**.
|
||||
- Start Minecraft forge at least once to create the directories needed for the next steps.
|
||||
3. Navigate to your minecraft install directory and place desired mods `.jar` file the in the `mods` directory.
|
||||
- The default install directories are as follows.
|
||||
- Windows `%APPDATA%\.minecraft\mods`
|
||||
- macOS `~/Library/Application Support/minecraft/mods`
|
||||
- Linux `~/.minecraft/mods`
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
@@ -60,10 +18,10 @@ can all have different options.
|
||||
### Where do I get a YAML file?
|
||||
A basic minecraft yaml will look like this.
|
||||
```yaml
|
||||
description: Template Name
|
||||
description: Basic Minecraft Yaml
|
||||
# Your name in-game. Spaces will be replaced with underscores and
|
||||
# there is a 16 character limit
|
||||
name: YourName
|
||||
name: YourName
|
||||
game: Minecraft
|
||||
|
||||
# Shared Options supported by all games:
|
||||
@@ -71,44 +29,61 @@ accessibility: locations
|
||||
progression_balancing: on
|
||||
# Minecraft Specific Options
|
||||
|
||||
# Number of advancements required (out of 92 total) to spawn the
|
||||
# Ender Dragon and complete the game.
|
||||
advancement_goal:
|
||||
few: 0 #30
|
||||
normal: 1 #50
|
||||
many: 0 #70
|
||||
Minecraft:
|
||||
# Number of advancements required (87 max) to spawn the Ender Dragon and complete the game.
|
||||
advancement_goal: 50
|
||||
|
||||
# Modifies the level of items logically required for exploring
|
||||
# dangerous areas and fighting bosses.
|
||||
combat_difficulty:
|
||||
easy: 0
|
||||
normal: 1
|
||||
hard: 0
|
||||
# Number of dragon egg shards to collect (30 max) before the Ender Dragon will spawn.
|
||||
egg_shards_required: 10
|
||||
|
||||
# Junk-fills certain RNG-reliant or tedious advancements with XP rewards.
|
||||
include_hard_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Number of egg shards available in the pool (30 max).
|
||||
egg_shards_available: 15
|
||||
|
||||
# Junk-fills extremely difficult advancements;
|
||||
# this is only How Did We Get Here? and Adventuring Time.
|
||||
include_insane_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Modifies the level of items logically required for
|
||||
# exploring dangerous areas and fighting bosses.
|
||||
combat_difficulty:
|
||||
easy: 0
|
||||
normal: 1
|
||||
hard: 0
|
||||
|
||||
# Junk-fills certain RNG-reliant or tedious advancements.
|
||||
include_hard_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Junk-fills extremely difficult advancements;
|
||||
# this is only How Did We Get Here? and Adventuring Time.
|
||||
include_insane_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Some advancements require defeating the Ender Dragon;
|
||||
# this will junk-fill them so you won't have to finish to send some items.
|
||||
include_postgame_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Some advancements require defeating the Ender Dragon;
|
||||
# this will junk-fill them, so you won't have to finish them to send some items.
|
||||
include_postgame_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Enables shuffling of villages, outposts, fortresses, bastions, and end cities.
|
||||
shuffle_structures:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
#enables shuffling of villages, outposts, fortresses, bastions, and end cities.
|
||||
shuffle_structures:
|
||||
on: 1
|
||||
off: 0
|
||||
# Adds structure compasses to the item pool,
|
||||
# which point to the nearest indicated structure.
|
||||
structure_compasses:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Replaces a percentage of junk items with bee traps
|
||||
# which spawn multiple angered bees around every player when received.
|
||||
bee_traps:
|
||||
0: 1
|
||||
25: 0
|
||||
50: 0
|
||||
75: 0
|
||||
100: 0
|
||||
```
|
||||
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your Minecraft data file
|
||||
@@ -118,8 +93,7 @@ When you join a multiworld game, you will be asked to provide your YAML file to
|
||||
is done, the host will provide you with either a link to download your data file, or with a zip file containing
|
||||
everyone's data files. Your data file should have a `.apmc` extension.
|
||||
|
||||
Put your data file in your forge servers `APData` folder. Make sure to remove any previous data file that was in there
|
||||
previously.
|
||||
double click on your `.apmc` file to have the minecraft client auto-launch the installed forge server.
|
||||
|
||||
### Connect to the MultiServer
|
||||
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
|
||||
@@ -134,3 +108,23 @@ When the console tells you that you have joined the room, you're ready to begin
|
||||
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
|
||||
forge server.
|
||||
|
||||
|
||||
## Manual Installation Procedures
|
||||
this is only required if you wish to set up a forge install yourself, its recommended to just use the Archipelago Installer.
|
||||
###Required Software
|
||||
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
|
||||
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
|
||||
**DO NOT INSTALL THIS ON YOUR CLIENT**
|
||||
### Dedicated Server Setup
|
||||
Only one person has to do this setup and host a dedicated server for everyone else playing to connect to.
|
||||
1. Download the 1.16.5 **Minecraft Forge** installer from the link above, making sure to download the most recent recommended version.
|
||||
|
||||
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install server**.
|
||||
- On this page you will also choose where to install the server to remember this directory it's important in the next step.
|
||||
|
||||
3. Navigate to where you installed the server and open `forge-1.16.5-xx.x.x.jar`
|
||||
- Upon first launch of the server it will close and ask you to accept Minecraft's EULA. There will be a new file called `eula.txt` that contains a link to Minecraft's EULA, and a line that you need to change to `eula=true` to accept Minecraft's EULA.
|
||||
- This will create the appropriate directories for you to place the files in the following step.
|
||||
|
||||
4. Place the `aprandomizer-x.x.x.jar` from the link above file into the `mods` folder of the above installation of your forge server.
|
||||
- Once again run the server, it will load up and generate the required directory `APData` for when you are ready to play a game!
|
||||
|
||||
69
WebHostLib/static/assets/tutorial/ror2/setup_en.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Risk of Rain 2 Setup Guide
|
||||
|
||||
## Install using r2modman
|
||||
### Install r2modman
|
||||
Head on over to the r2modman page on Thunderstore and follow the installation instructions.
|
||||
|
||||
https://thunderstore.io/package/ebkr/r2modman/
|
||||
|
||||
### Install Archipelago Mod using r2modman
|
||||
You can install the Archipelago mod using r2modman in one of two ways.
|
||||
One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
|
||||
|
||||
https://thunderstore.io/package/ArchipelagoMW/Archipelago/
|
||||
|
||||
You can also search for the "Archipelago" mod in the r2modman interface.
|
||||
The mod manager should automatically install all necessary dependencies as well.
|
||||
|
||||
### Running the Modded Game
|
||||
Click on the "Start modded" button in the top left in r2modman to start the game with the
|
||||
Archipelago mod installed.
|
||||
|
||||
## Joining an Archipelago Session
|
||||
There will be a menu button on the right side of the screen in the character select menu.
|
||||
Click it in order to bring up the in lobby mod config.
|
||||
From here you can expand the Archipelago sections and fill in the relevant info.
|
||||
Keep password blank if there is no password on the server.
|
||||
|
||||
Simply check `Enable Archipelago?` and when you start the run it will automatically connect.
|
||||
|
||||
## Gameplay
|
||||
The Risk of Rain 2 players send checks by causing items to spawn in-game. That means opening chests or killing bosses, generally.
|
||||
An item check is only sent out after a certain number of items are picked up. This count is configurable in the player's YAML.
|
||||
|
||||
## YAML Settings
|
||||
An example YAML would look like this:
|
||||
```yaml
|
||||
description: Ijwu-ror2
|
||||
name: Ijwu
|
||||
|
||||
game:
|
||||
Risk of Rain 2: 1
|
||||
|
||||
Risk of Rain 2:
|
||||
total_locations: 15
|
||||
total_revivals: 4
|
||||
start_with_revive: true
|
||||
item_pickup_step: 1
|
||||
enable_lunar: true
|
||||
```
|
||||
|
||||
| Name | Description | Allowed values |
|
||||
| ---- | ----------- | -------------- |
|
||||
| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. This option is ALSO the total number of items in the item pool for the Risk of Rain player. | 10 - 50 |
|
||||
| total_revivals | The total number of items in the Risk of Rain player's item pool (items other players pick up for them) replaced with `Dio's Best Friend`. | 0 - 5 |
|
||||
| start_with_revive | Starts the player off with a `Dio's Best Friend`. Functionally equivalent to putting a `Dio's Best Friend` in your `starting_inventory`. | true/false |
|
||||
| item_pickup_step | The number of item pickups which you are allowed to claim before they become an Archipelago location check. | 0 - 5 |
|
||||
| enable_lunar | Allows for lunar items to be shuffled into the item pool on behalf of the Risk of Rain player. | true/false |
|
||||
|
||||
Using the example YAML above: the Risk of Rain 2 player will have 15 total items which they can pick up for other players. (total_locations = 15)
|
||||
|
||||
They will have 15 items waiting for them in the item pool which will be distributed out to the multiworld. (total_locations = 15)
|
||||
|
||||
They will complete a location check every second item. (item_pickup_step = 1)
|
||||
|
||||
They will have 4 of the items which other players can grant them replaced with `Dio's Best Friend`. (total_revivals = 4)
|
||||
|
||||
The player will also start with a `Dio's Best Friend`. (start_with_revive = true)
|
||||
|
||||
The player will have lunar items shuffled into the item pool on their behalf. (enable_lunar = true)
|
||||
@@ -139,5 +139,24 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Risk of Rain 2",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Risk of Rain 2 integration for Archipelago multiworld games.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ror2/setup_en.md",
|
||||
"link": "ror2/setup/en",
|
||||
"authors": [
|
||||
"Ijwu"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# A Link to the Past Randomizer Setup Guide
|
||||
|
||||
## Benötigte Software
|
||||
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
|
||||
- Hardware oder Software zum Laden und Abspielen von SNES Rom-Dateien
|
||||
- Ein Emulator, der lua-scripts abspielen kann
|
||||
@@ -15,7 +15,7 @@
|
||||
### Windows
|
||||
1. Lade die Multiworld Utilities herunter und führe die Installation aus. Sei sicher, dass du immer die
|
||||
aktuellste Version installiert hast.**Die Datei befindet sich im "assets"-Kasten unter der jeweiligen Versionsinfo!**.
|
||||
Für normale Multiworld-Spiele lädst du die `Setup.BerserkerMultiworld.exe` herunter.
|
||||
Für normale Multiworld-Spiele lädst du die `Setup.Archipelago.exe` herunter.
|
||||
- Für den Doorrandomizer muss die alternative doors-Variante geladen werden.
|
||||
- Während der Installation fragt dich das Programm nach der japanischen 1.0 ROM-Datei. Wenn du die Software
|
||||
bereits installiert hast und einfach nur updaten willst, wirst du nicht nochmal danach gefragt.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
## Required Software
|
||||
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of running Lua scripts
|
||||
@@ -21,7 +21,7 @@
|
||||
### Windows Setup
|
||||
1. Download and install the MultiWorld Utilities from the link above, making sure to install the most recent version.
|
||||
**The file is located in the assets section at the bottom of the version information**. If you intend to play normal
|
||||
multiworld games, you want `Setup.BerserkerMultiWorld.exe`
|
||||
multiworld games, you want `Setup.Archipelago.exe`
|
||||
- If you intend to play the doors variant of multiworld, you will want to download the alternate doors file.
|
||||
- During the installation process, you will be asked to browse for your Japanese 1.0 ROM file. If you have
|
||||
installed this software before and are simply upgrading now, you will not be prompted to locate your
|
||||
@@ -53,22 +53,6 @@ can all have different options.
|
||||
The [Generate Game](/player-settings) page on the website allows you to configure your personal settings and
|
||||
export a YAML file from them.
|
||||
|
||||
### Advanced YAML configuration
|
||||
A more advanced version of the YAML file can be created using the [Weighted Settings](/weighted-settings) page,
|
||||
which allows you to configure up to three presets. The Weighted Settings page has many options which are
|
||||
primarily represented with sliders. This allows you to choose how likely certain options are to occur relative
|
||||
to other options within a category.
|
||||
|
||||
For example, imagine the generator creates a bucket labeled "Map Shuffle", and places folded pieces of paper
|
||||
into the bucket for each sub-option. Also imagine your chosen value for "On" is 20, and your value for "Off" is 40.
|
||||
|
||||
In this example, sixty pieces of paper are put into the bucket. Twenty for "On" and forty for "Off". When the
|
||||
generator is deciding whether or not to turn on map shuffle for your game, it reaches into this bucket and pulls
|
||||
out a piece of paper at random. In this example, you are much more likely to have map shuffle turned off.
|
||||
|
||||
If you never want an option to be chosen, simply set its value to zero. Remember that each setting must have at
|
||||
lease one option set to a number greater than zero.
|
||||
|
||||
### Verifying your YAML file
|
||||
If you would like to validate your YAML file to make sure it works, you may do so on the
|
||||
[YAML Validator](/mysterycheck) page.
|
||||
@@ -144,7 +128,7 @@ on successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
The recommended way to host a game is to use the hosting service provided on
|
||||
[the website](https://berserkermulti.world/generate). The process is relatively simple:
|
||||
[the website](/generate). The process is relatively simple:
|
||||
|
||||
1. Collect YAML files from your players.
|
||||
2. Create a zip file containing your players' YAML files.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
## Software requerido
|
||||
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Incluido en Multiworld Utilities)
|
||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
|
||||
- Un emulador capaz de ejecutar scripts Lua
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
### Instalación en Windows
|
||||
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
|
||||
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.BerserkerMultiWorld.exe`
|
||||
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.Archipelago.exe`
|
||||
- Si estas interesado en jugar la variante que aleatoriza las puertas internas de las mazmorras, necesitaras bajar 'Setup.BerserkerMultiWorld.Doors.exe'
|
||||
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del archivo una segunda vez.
|
||||
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
|
||||
@@ -136,7 +136,7 @@ por unirte satisfactoriamente a una partida de multiworld!
|
||||
|
||||
## Hospedando una partida de multiworld
|
||||
La manera recomendad para hospedar una partida es usar el servicio proveído en
|
||||
[el sitio web](https://berserkermulti.world/generate). El proceso es relativamente sencillo:
|
||||
[el sitio web](/generate). El proceso es relativamente sencillo:
|
||||
|
||||
1. Recolecta los ficheros YAML de todos los jugadores que participen.
|
||||
2. Crea un fichero ZIP conteniendo esos ficheros.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
|
||||
## Logiciels requis
|
||||
- [Utilitaires du MultiWorld](https://github.com/Berserker66/MultiWorld-Utilities/releases)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Inclus dans les utilitaires précédents)
|
||||
- Une solution logicielle ou matérielle capable de charger et de lancer des fichiers ROM de SNES
|
||||
- Un émulateur capable d'éxécuter des scripts Lua
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Configuration
|
||||
1. Plando features have to be enabled first, before they can be used (opt-in).
|
||||
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\BerserkerMultiWorld`),
|
||||
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`),
|
||||
then open the host.yaml file with a text editor.
|
||||
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the
|
||||
value to
|
||||
@@ -13,7 +13,7 @@
|
||||
### Bosses
|
||||
|
||||
- This module is enabled by default and available to be used on
|
||||
[https://archipelago.gg/generate](https://archipelago.gg/generate)
|
||||
[https://archipelago.gg/generate](/generate)
|
||||
- Plando versions of boss shuffles can be added like any other boss shuffle option in a yaml and weighted.
|
||||
- Boss Plando works as a list of instructions from left to right, if any arenas are empty at the end,
|
||||
it defaults to vanilla
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
let spriteData = null;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const gameSettings = document.getElementById('weighted-settings');
|
||||
Promise.all([fetchWeightedSettingsYaml(), fetchWeightedSettingsJson(), fetchSpriteData()]).then((results) => {
|
||||
// Load YAML into object
|
||||
const sourceData = jsyaml.safeLoad(results[0], { json: true });
|
||||
const wsVersion = sourceData.ws_version;
|
||||
delete sourceData.ws_version; // Do not include the settings version number in the export
|
||||
|
||||
// Check if settings exist in localStorage. If no settings are present, this is a first load (or reset to default)
|
||||
// and the version number should be silently updated
|
||||
if (!localStorage.getItem('weightedSettings1')) {
|
||||
localStorage.setItem('wsVersion', wsVersion);
|
||||
}
|
||||
|
||||
// Update localStorage with three settings objects. Preserve original objects if present.
|
||||
for (let i=1; i<=3; i++) {
|
||||
const localSettings = JSON.parse(localStorage.getItem(`weightedSettings${i}`));
|
||||
const updatedObj = localSettings ? Object.assign(sourceData, localSettings) : sourceData;
|
||||
localStorage.setItem(`weightedSettings${i}`, JSON.stringify(updatedObj));
|
||||
}
|
||||
|
||||
// Build the entire UI
|
||||
buildUI(JSON.parse(results[1]), JSON.parse(results[2]));
|
||||
|
||||
// Populate the UI and add event listeners
|
||||
populateSettings();
|
||||
document.getElementById('preset-number').addEventListener('change', populateSettings);
|
||||
gameSettings.addEventListener('change', handleOptionChange);
|
||||
gameSettings.addEventListener('keyup', handleOptionChange);
|
||||
|
||||
document.getElementById('export-button').addEventListener('click', exportSettings);
|
||||
document.getElementById('reset-to-default').addEventListener('click', resetToDefaults);
|
||||
adjustHeaderWidth();
|
||||
|
||||
if (localStorage.getItem('wsVersion') !== wsVersion) {
|
||||
const userWarning = document.getElementById('user-warning');
|
||||
const messageSpan = document.createElement('span');
|
||||
messageSpan.innerHTML = "A new version of the weighted settings file is available. Click here to update!" +
|
||||
"<br />Be aware this will also reset your presets, so you should export them now if you want to save them.";
|
||||
userWarning.appendChild(messageSpan);
|
||||
userWarning.style.display = 'block';
|
||||
userWarning.addEventListener('click', resetToDefaults);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
gameSettings.innerHTML = `
|
||||
<h2>Something went wrong while loading your game settings page.</h2>
|
||||
<h2>${error}</h2>
|
||||
<h2><a href="${window.location.origin}">Click here to return to safety!</a></h2>
|
||||
`
|
||||
});
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
|
||||
});
|
||||
|
||||
const fetchWeightedSettingsYaml = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject("Unable to fetch source yaml file.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.yaml` ,true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const fetchWeightedSettingsJson = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject('Unable to fetch JSON schema file');
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const fetchSpriteData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status !== 200) {
|
||||
reject('Unable to fetch sprite data.');
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
const handleOptionChange = (event) => {
|
||||
if(!event.target.matches('.setting')) { return; }
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
|
||||
const settingString = event.target.getAttribute('data-setting');
|
||||
document.getElementById(settingString).innerText = event.target.value;
|
||||
if(getSettingValue(settings, settingString) !== false){
|
||||
const keys = settingString.split('.');
|
||||
switch (keys.length) {
|
||||
case 1:
|
||||
settings[keys[0]] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
break;
|
||||
case 2:
|
||||
settings[keys[0]][keys[1]] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
break;
|
||||
case 3:
|
||||
settings[keys[0]][keys[1]][keys[2]] = isNaN(event.target.value) ?
|
||||
event.target.value : parseInt(event.target.value, 10);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown setting string received: ${settingString}`)
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the updated settings object bask to localStorage
|
||||
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(settings));
|
||||
}else{
|
||||
console.warn(`Unknown setting string received: ${settingString}`)
|
||||
}
|
||||
};
|
||||
|
||||
const populateSettings = () => {
|
||||
buildSpriteOptions();
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
|
||||
const settingsInputs = Array.from(document.querySelectorAll('.setting'));
|
||||
settingsInputs.forEach((input) => {
|
||||
const settingString = input.getAttribute('data-setting');
|
||||
const settingValue = getSettingValue(settings, settingString);
|
||||
if(settingValue !== false){
|
||||
input.value = settingValue;
|
||||
document.getElementById(settingString).innerText = settingValue;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the value of the settings object, or false if the settings object does not exist
|
||||
* @param settings
|
||||
* @param keyString
|
||||
* @returns {string} | bool
|
||||
*/
|
||||
const getSettingValue = (settings, keyString) => {
|
||||
const keys = keyString.split('.');
|
||||
let currentVal = settings;
|
||||
keys.forEach((key) => {
|
||||
if(typeof(key) === 'string' && currentVal.hasOwnProperty(key)){
|
||||
currentVal = currentVal[key];
|
||||
}else{
|
||||
currentVal = false;
|
||||
}
|
||||
});
|
||||
return currentVal;
|
||||
};
|
||||
|
||||
const exportSettings = () => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
|
||||
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
|
||||
download(`${settings.description}.yaml`, yamlText);
|
||||
};
|
||||
|
||||
const resetToDefaults = () => {
|
||||
[1, 2, 3].forEach((presetNumber) => localStorage.removeItem(`weightedSettings${presetNumber}`));
|
||||
location.reload();
|
||||
};
|
||||
|
||||
/** Create an anchor and trigger a download of a text file. */
|
||||
const download = (filename, text) => {
|
||||
const downloadLink = document.createElement('a');
|
||||
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
|
||||
downloadLink.setAttribute('download', filename);
|
||||
downloadLink.style.display = 'none';
|
||||
document.body.appendChild(downloadLink);
|
||||
downloadLink.click();
|
||||
document.body.removeChild(downloadLink);
|
||||
};
|
||||
|
||||
const buildUI = (settings, spriteData) => {
|
||||
const settingsWrapper = document.getElementById('settings-wrapper');
|
||||
const settingTypes = {
|
||||
gameOptions: 'Game Options',
|
||||
romOptions: 'ROM Options',
|
||||
}
|
||||
|
||||
Object.keys(settingTypes).forEach((settingTypeKey) => {
|
||||
const sectionHeader = document.createElement('h2');
|
||||
sectionHeader.innerText = settingTypes[settingTypeKey];
|
||||
settingsWrapper.appendChild(sectionHeader);
|
||||
|
||||
Object.values(settings[settingTypeKey]).forEach((setting) => {
|
||||
if (typeof(setting.inputType) === 'undefined' || !setting.inputType){
|
||||
console.error(setting);
|
||||
throw new Error('Setting with no inputType specified.');
|
||||
}
|
||||
|
||||
switch(setting.inputType){
|
||||
case 'text':
|
||||
// Currently, all text input is handled manually because there is very little of it
|
||||
return;
|
||||
case 'range':
|
||||
buildRangeSettings(settingsWrapper, setting);
|
||||
return;
|
||||
default:
|
||||
console.error(setting);
|
||||
throw new Error('Unhandled inputType specified.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Build sprite options
|
||||
const spriteOptionsHeader = document.createElement('h2');
|
||||
spriteOptionsHeader.innerText = 'Sprite Options';
|
||||
settingsWrapper.appendChild(spriteOptionsHeader);
|
||||
|
||||
const spriteOptionsWrapper = document.createElement('div');
|
||||
spriteOptionsWrapper.setAttribute('id', 'sprite-options-wrapper');
|
||||
spriteOptionsWrapper.className = 'setting-wrapper';
|
||||
settingsWrapper.appendChild(spriteOptionsWrapper);
|
||||
|
||||
// Append sprite picker
|
||||
settingsWrapper.appendChild(buildSpritePicker(spriteData));
|
||||
};
|
||||
|
||||
const buildSpriteOptions = () => {
|
||||
const spriteOptionsWrapper = document.getElementById('sprite-options-wrapper');
|
||||
|
||||
// Clear the contents of the wrapper div
|
||||
while(spriteOptionsWrapper.firstChild){
|
||||
spriteOptionsWrapper.removeChild(spriteOptionsWrapper.lastChild);
|
||||
}
|
||||
|
||||
const spriteOptionsTitle = document.createElement('span');
|
||||
spriteOptionsTitle.className = 'title-span';
|
||||
spriteOptionsTitle.innerText = 'Alternate Sprites';
|
||||
spriteOptionsWrapper.appendChild(spriteOptionsTitle);
|
||||
|
||||
const spriteOptionsDescription = document.createElement('span');
|
||||
spriteOptionsDescription.className = 'description-span';
|
||||
spriteOptionsDescription.innerHTML = 'Choose an alternate sprite to play the game with. Additional randomization ' +
|
||||
'options are documented in the ' +
|
||||
'<a href="https://github.com/Berserker66/MultiWorld-Utilities/blob/main/playerSettings.yaml#L374">settings file</a>.';
|
||||
spriteOptionsWrapper.appendChild(spriteOptionsDescription);
|
||||
|
||||
const spriteOptionsTable = document.createElement('table');
|
||||
spriteOptionsTable.setAttribute('id', 'sprite-options-table');
|
||||
spriteOptionsTable.className = 'option-set';
|
||||
const tbody = document.createElement('tbody');
|
||||
tbody.setAttribute('id', 'sprites-tbody');
|
||||
|
||||
const currentPreset = document.getElementById('preset-number').value;
|
||||
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${currentPreset}`));
|
||||
|
||||
// Manually add a row for random sprites
|
||||
addSpriteRow(tbody, playerSettings, 'random');
|
||||
|
||||
// Add a row for each sprite currently present in the player's settings
|
||||
Object.keys(playerSettings.rom.sprite).forEach((spriteName) => {
|
||||
if(['random'].indexOf(spriteName) > -1) return;
|
||||
addSpriteRow(tbody, playerSettings, spriteName)
|
||||
});
|
||||
|
||||
spriteOptionsTable.appendChild(tbody);
|
||||
spriteOptionsWrapper.appendChild(spriteOptionsTable);
|
||||
};
|
||||
|
||||
const buildRangeSettings = (parentElement, settings) => {
|
||||
// Ensure we are operating on a range-specific setting
|
||||
if(typeof(settings.inputType) === 'undefined' || settings.inputType !== 'range'){
|
||||
throw new Error('Invalid input type provided to buildRangeSettings func.');
|
||||
}
|
||||
|
||||
const settingWrapper = document.createElement('div');
|
||||
settingWrapper.className = 'setting-wrapper';
|
||||
|
||||
if(typeof(settings.friendlyName) !== 'undefined' && settings.friendlyName){
|
||||
const sectionTitle = document.createElement('span');
|
||||
sectionTitle.className = 'title-span';
|
||||
sectionTitle.innerText = settings.friendlyName;
|
||||
settingWrapper.appendChild(sectionTitle);
|
||||
}
|
||||
|
||||
if(settings.description){
|
||||
const description = document.createElement('span');
|
||||
description.className = 'description-span';
|
||||
description.innerText = settings.description;
|
||||
settingWrapper.appendChild(description);
|
||||
}
|
||||
|
||||
// Create table
|
||||
const optionSetTable = document.createElement('table');
|
||||
optionSetTable.className = 'option-set';
|
||||
|
||||
// Create table body
|
||||
const tbody = document.createElement('tbody');
|
||||
Object.keys(settings.subOptions).forEach((setting) => {
|
||||
// Overwrite setting key name with real object
|
||||
setting = settings.subOptions[setting];
|
||||
const settingId = (Math.random() * 1000000).toString();
|
||||
|
||||
// Create rows for each option
|
||||
const optionRow = document.createElement('tr');
|
||||
|
||||
// Option name td
|
||||
const optionName = document.createElement('td');
|
||||
optionName.className = 'option-name';
|
||||
const optionLabel = document.createElement('label');
|
||||
optionLabel.setAttribute('for', settingId);
|
||||
optionLabel.setAttribute('data-tooltip', setting.description);
|
||||
optionLabel.innerText = setting.friendlyName;
|
||||
optionName.appendChild(optionLabel);
|
||||
optionRow.appendChild(optionName);
|
||||
|
||||
// Option value td
|
||||
const optionValue = document.createElement('td');
|
||||
optionValue.className = 'option-value';
|
||||
const input = document.createElement('input');
|
||||
input.className = 'setting';
|
||||
input.setAttribute('id', settingId);
|
||||
input.setAttribute('type', 'range');
|
||||
input.setAttribute('min', '0');
|
||||
input.setAttribute('max', '100');
|
||||
input.setAttribute('data-setting', setting.keyString);
|
||||
input.value = setting.defaultValue;
|
||||
optionValue.appendChild(input);
|
||||
const valueDisplay = document.createElement('span');
|
||||
valueDisplay.setAttribute('id', setting.keyString);
|
||||
valueDisplay.innerText = setting.defaultValue;
|
||||
optionValue.appendChild(valueDisplay);
|
||||
optionRow.appendChild(optionValue);
|
||||
tbody.appendChild(optionRow);
|
||||
});
|
||||
|
||||
optionSetTable.appendChild(tbody);
|
||||
settingWrapper.appendChild(optionSetTable);
|
||||
parentElement.appendChild(settingWrapper);
|
||||
};
|
||||
|
||||
const addSpriteRow = (tbody, playerSettings, spriteName) => {
|
||||
const rowId = (Math.random() * 1000000).toString();
|
||||
const optionId = (Math.random() * 1000000).toString();
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('id', rowId);
|
||||
|
||||
// Option Name
|
||||
const optionName = document.createElement('td');
|
||||
optionName.className = 'option-name';
|
||||
const label = document.createElement('label');
|
||||
label.htmlFor = optionId;
|
||||
label.innerText = spriteName;
|
||||
optionName.appendChild(label);
|
||||
|
||||
if(['random', 'random_sprite_on_event'].indexOf(spriteName) === -1) {
|
||||
const deleteButton = document.createElement('span');
|
||||
deleteButton.setAttribute('data-sprite', spriteName);
|
||||
deleteButton.setAttribute('data-row-id', rowId);
|
||||
deleteButton.innerText = ' (❌)';
|
||||
deleteButton.className = 'delete-button';
|
||||
optionName.appendChild(deleteButton);
|
||||
deleteButton.addEventListener('click', removeSpriteOption);
|
||||
}
|
||||
|
||||
tr.appendChild(optionName);
|
||||
|
||||
// Option Value
|
||||
const optionValue = document.createElement('td');
|
||||
optionValue.className = 'option-value';
|
||||
const input = document.createElement('input');
|
||||
input.className = 'setting';
|
||||
input.setAttribute('id', optionId);
|
||||
input.setAttribute('type', 'range');
|
||||
input.setAttribute('min', '0');
|
||||
input.setAttribute('max', '100');
|
||||
input.setAttribute('data-setting', `rom.sprite.${spriteName}`);
|
||||
input.value = "50";
|
||||
optionValue.appendChild(input);
|
||||
|
||||
// Value display
|
||||
const valueDisplay = document.createElement('span');
|
||||
valueDisplay.setAttribute('id', `rom.sprite.${spriteName}`);
|
||||
valueDisplay.innerText = playerSettings.rom.sprite.hasOwnProperty(spriteName) ?
|
||||
playerSettings.rom.sprite[spriteName] : '0';
|
||||
optionValue.appendChild(valueDisplay);
|
||||
|
||||
tr.appendChild(optionValue);
|
||||
tbody.appendChild(tr);
|
||||
};
|
||||
|
||||
const addSpriteOption = (event) => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
|
||||
const spriteName = event.target.getAttribute('data-sprite');
|
||||
|
||||
if (Object.keys(playerSettings.rom.sprite).indexOf(spriteName) !== -1) {
|
||||
// Do not add the same sprite twice
|
||||
return;
|
||||
}
|
||||
|
||||
// Add option to playerSettings object
|
||||
playerSettings.rom.sprite[event.target.getAttribute('data-sprite')] = 50;
|
||||
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
|
||||
|
||||
// Add <tr> to #sprite-options-table
|
||||
const tbody = document.getElementById('sprites-tbody');
|
||||
addSpriteRow(tbody, playerSettings, spriteName);
|
||||
};
|
||||
|
||||
const removeSpriteOption = (event) => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
|
||||
const spriteName = event.target.getAttribute('data-sprite');
|
||||
|
||||
// Remove option from playerSettings object
|
||||
delete playerSettings.rom.sprite[spriteName];
|
||||
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
|
||||
|
||||
// Remove <tr> from #sprite-options-table
|
||||
const tr = document.getElementById(event.target.getAttribute('data-row-id'));
|
||||
tr.parentNode.removeChild(tr);
|
||||
};
|
||||
|
||||
const buildSpritePicker = (spriteData) => {
|
||||
const spritePicker = document.createElement('div');
|
||||
spritePicker.setAttribute('id', 'sprite-picker');
|
||||
|
||||
// Build description
|
||||
const description = document.createElement('span');
|
||||
description.innerText = 'To add a sprite to your playable list, click the one you want below.';
|
||||
spritePicker.appendChild(description);
|
||||
|
||||
const sprites = document.createElement('div');
|
||||
sprites.setAttribute('id', 'sprite-picker-sprites');
|
||||
spriteData.sprites.forEach((sprite) => {
|
||||
const spriteImg = document.createElement('img');
|
||||
let spriteGifFile = sprite.file.split('.');
|
||||
spriteGifFile.pop();
|
||||
spriteGifFile = spriteGifFile.join('.') + '.gif';
|
||||
spriteImg.setAttribute('src', `static/static/sprites/${spriteGifFile}`);
|
||||
spriteImg.setAttribute('data-sprite', sprite.file.split('.')[0]);
|
||||
spriteImg.setAttribute('alt', sprite.name);
|
||||
|
||||
// Wrap the image in a span to allow for tooltip presence
|
||||
const imgWrapper = document.createElement('span');
|
||||
imgWrapper.className = 'sprite-img-wrapper';
|
||||
imgWrapper.setAttribute('data-tooltip', `${sprite.name}${sprite.author ? `, by ${sprite.author}` : ''}`);
|
||||
imgWrapper.appendChild(spriteImg);
|
||||
imgWrapper.setAttribute('data-sprite', sprite.name);
|
||||
sprites.appendChild(imgWrapper);
|
||||
imgWrapper.addEventListener('click', addSpriteOption);
|
||||
});
|
||||
|
||||
spritePicker.appendChild(sprites);
|
||||
return spritePicker;
|
||||
};
|
||||
|
||||
const generateGame = (raceMode = false) => {
|
||||
const presetNumber = document.getElementById('preset-number').value;
|
||||
axios.post('/api/generate', {
|
||||
weights: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
|
||||
presetData: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
|
||||
playerCount: 1,
|
||||
race: raceMode ? '1' : '0',
|
||||
}).then((response) => {
|
||||
window.location.href = response.data.url;
|
||||
}).catch((error) => {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = 'Something went wrong and your game could not be generated.';
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
@@ -1,705 +0,0 @@
|
||||
{
|
||||
"readOnly": {
|
||||
"description": "Generated by MultiWorld website",
|
||||
"triforce_pieces_mode": "available",
|
||||
"triforce_pieces_available": 30,
|
||||
"triforce_pieces_required": 20,
|
||||
"shuffle_prizes": "none",
|
||||
"timer": "none",
|
||||
"glitch_boots": "on",
|
||||
"key_drop_shuffle": "off",
|
||||
"experimental": "off",
|
||||
"debug": "off"
|
||||
},
|
||||
"generalOptions": {
|
||||
"name": "PlayerName"
|
||||
},
|
||||
"gameOptions": {
|
||||
"goals": {
|
||||
"type": "select",
|
||||
"friendlyName": "Goal",
|
||||
"description": "Choose the condition for winning the game",
|
||||
"defaultValue": "ganon",
|
||||
"options": [
|
||||
{
|
||||
"name": "Kill Ganon",
|
||||
"value": "ganon"
|
||||
},
|
||||
{
|
||||
"name": "Fast Ganon (Pyramid Always Open)",
|
||||
"value": "crystals"
|
||||
},
|
||||
{
|
||||
"name": "All Bosses",
|
||||
"value": "bosses"
|
||||
},
|
||||
{
|
||||
"name": "Master Sword Pedestal",
|
||||
"value": "pedestal"
|
||||
},
|
||||
{
|
||||
"name": "Master Sword Pedestal + Ganon",
|
||||
"value": "ganon_pedestal"
|
||||
},
|
||||
{
|
||||
"name": "Triforce Hunt",
|
||||
"value": "triforce_hunt"
|
||||
},
|
||||
{
|
||||
"name": "Triforce Hunt + Ganon",
|
||||
"value": "ganon_triforce_hunt"
|
||||
},
|
||||
{
|
||||
"name": "Ice Rod Hunt",
|
||||
"value": "ice_rod_hunt"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mode": {
|
||||
"type": "select",
|
||||
"friendlyName": "World State",
|
||||
"description": "Choose the state of the game world",
|
||||
"defaultValue": "standard",
|
||||
"options": [
|
||||
{
|
||||
"name": "Standard",
|
||||
"value": "standard"
|
||||
},
|
||||
{
|
||||
"name": "Open",
|
||||
"value": "open"
|
||||
},
|
||||
{
|
||||
"name": "Inverted",
|
||||
"value": "inverted"
|
||||
}
|
||||
]
|
||||
},
|
||||
"accessibility": {
|
||||
"type": "select",
|
||||
"friendlyName": "Accessibility",
|
||||
"description": "Choose how much of the world will be available",
|
||||
"defaultValue": "locations",
|
||||
"options": [
|
||||
{
|
||||
"name": "Locations Guaranteed",
|
||||
"value": "locations"
|
||||
},
|
||||
{
|
||||
"name": "Items Guaranteed",
|
||||
"value": "items"
|
||||
},
|
||||
{
|
||||
"name": "Beatable Only",
|
||||
"value": "none"
|
||||
}
|
||||
]
|
||||
},
|
||||
"progressive": {
|
||||
"type": "select",
|
||||
"friendlyName": "Progressive Items",
|
||||
"description": "Turn progressive items on or off, or randomize them",
|
||||
"defaultValue": "on",
|
||||
"options": [
|
||||
{
|
||||
"name": "All Progressive",
|
||||
"value": "on"
|
||||
},
|
||||
{
|
||||
"name": "None Progressive",
|
||||
"value": "off"
|
||||
},
|
||||
{
|
||||
"name": "Randomize Each",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tower_open": {
|
||||
"type": "select",
|
||||
"friendlyName": "Ganon's Tower Access",
|
||||
"description": "Choose how many crystals are required to open Ganon's Tower",
|
||||
"defaultValue": 7,
|
||||
"options": [
|
||||
{
|
||||
"name": "7 Crystals",
|
||||
"value": 7
|
||||
},
|
||||
{
|
||||
"name": "6 Crystals",
|
||||
"value": 6
|
||||
},
|
||||
{
|
||||
"name": "5 Crystals",
|
||||
"value": 5
|
||||
},
|
||||
{
|
||||
"name": "4 Crystals",
|
||||
"value": 4
|
||||
},
|
||||
{
|
||||
"name": "3 Crystals",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"name": "2 Crystals",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"name": "1 Crystals",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "0 Crystals",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"name": "Random",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ganon_open": {
|
||||
"type": "select",
|
||||
"friendlyName": "Ganon Vulnerable",
|
||||
"description": "Choose how many crystals are required to kill Ganon",
|
||||
"defaultValue": 7,
|
||||
"options": [
|
||||
{
|
||||
"name": "7 Crystals",
|
||||
"value": 7
|
||||
},
|
||||
{
|
||||
"name": "6 Crystals",
|
||||
"value": 6
|
||||
},
|
||||
{
|
||||
"name": "5 Crystals",
|
||||
"value": 5
|
||||
},
|
||||
{
|
||||
"name": "4 Crystals",
|
||||
"value": 4
|
||||
},
|
||||
{
|
||||
"name": "3 Crystals",
|
||||
"value": 3
|
||||
},
|
||||
{
|
||||
"name": "2 Crystals",
|
||||
"value": 2
|
||||
},
|
||||
{
|
||||
"name": "1 Crystals",
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"name": "0 Crystals",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"name": "Random",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"retro": {
|
||||
"type": "select",
|
||||
"friendlyName": "Retro Mode",
|
||||
"description": "Choose if you want to play in retro mode",
|
||||
"defaultValue": "off",
|
||||
"options": [
|
||||
{
|
||||
"name": "Disabled",
|
||||
"value": "off"
|
||||
},
|
||||
{
|
||||
"name": "Enabled",
|
||||
"value": "on"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hints": {
|
||||
"type": "select",
|
||||
"friendlyName": "Hints",
|
||||
"description": "Choose to enable or disable tile hints",
|
||||
"defaultValue": "on",
|
||||
"options": [
|
||||
{
|
||||
"name": "Enabled",
|
||||
"value": "on"
|
||||
},
|
||||
{
|
||||
"name": "Disabled",
|
||||
"value": "off"
|
||||
}
|
||||
]
|
||||
},
|
||||
"weapons": {
|
||||
"type": "select",
|
||||
"friendlyName": "Sword Locations",
|
||||
"description": "Choose where you will find your swords",
|
||||
"defaultValue": "assured",
|
||||
"options": [
|
||||
{
|
||||
"name": "Assured",
|
||||
"value": "assured"
|
||||
},
|
||||
{
|
||||
"name": "Vanilla",
|
||||
"value": "vanilla"
|
||||
},
|
||||
{
|
||||
"name": "Swordless",
|
||||
"value": "swordless"
|
||||
},
|
||||
{
|
||||
"name": "Randomized",
|
||||
"value": "randomized"
|
||||
}
|
||||
]
|
||||
},
|
||||
"glitches_required":{
|
||||
"type": "select",
|
||||
"friendlyName": "Glitches Required",
|
||||
"description": "Choose which glitches will be considered in-logic",
|
||||
"defaultValue": "none",
|
||||
"options": [
|
||||
{
|
||||
"name": "None",
|
||||
"value": "none"
|
||||
},
|
||||
{
|
||||
"name": "Minor Glitches",
|
||||
"value": "minor_glitches"
|
||||
},
|
||||
{
|
||||
"name": "Overworld Glitches",
|
||||
"value": "overworld_glitches"
|
||||
},
|
||||
{
|
||||
"name": "No Logic",
|
||||
"value": "no_logic"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dark_room_logic": {
|
||||
"type": "select",
|
||||
"friendlyName": "Dark Room Logic",
|
||||
"description": "Choose your logical access to dark rooms",
|
||||
"defaultValue": "lamp",
|
||||
"options": [
|
||||
{
|
||||
"name": "Lamp Required",
|
||||
"value": "lamp"
|
||||
},
|
||||
{
|
||||
"name": "Torches Lightable",
|
||||
"value": "torches"
|
||||
},
|
||||
{
|
||||
"name": "Always In-Logic",
|
||||
"value": "none"
|
||||
}
|
||||
]
|
||||
},
|
||||
"dungeon_items": {
|
||||
"type": "select",
|
||||
"friendlyName": "Dungeon Item Shuffle",
|
||||
"description": "Choose which dungeon items you want shuffled",
|
||||
"defaultValue": "none",
|
||||
"options": [
|
||||
{
|
||||
"name": "None",
|
||||
"value": "none"
|
||||
},
|
||||
{
|
||||
"name": "Map & Compass",
|
||||
"value": "mc"
|
||||
},
|
||||
{
|
||||
"name": "Small Keys Only",
|
||||
"value": "s"
|
||||
},
|
||||
{
|
||||
"name": "Big Keys Only",
|
||||
"value": "b"
|
||||
},
|
||||
{
|
||||
"name": "Small and Big Keys",
|
||||
"value": "sb"
|
||||
},
|
||||
{
|
||||
"name": "Full Keysanity",
|
||||
"value": "mscb"
|
||||
},
|
||||
{
|
||||
"name": "Universal Small Keys",
|
||||
"value": "u"
|
||||
}
|
||||
]
|
||||
},
|
||||
"entrance_shuffle": {
|
||||
"type": "select",
|
||||
"friendlyName": "Entrance Shuffle",
|
||||
"description": "Shuffles the game map. Not recommended for beginners",
|
||||
"defaultValue": "none",
|
||||
"options": [
|
||||
{
|
||||
"name": "None",
|
||||
"value": "none"
|
||||
},
|
||||
{
|
||||
"name": "Only Dungeons, Simple",
|
||||
"value": "dungeonssimple"
|
||||
},
|
||||
{
|
||||
"name": "Only Dungeons, Full",
|
||||
"value": "dungeonsfull"
|
||||
},
|
||||
{
|
||||
"name": "Simple",
|
||||
"value": "simple"
|
||||
},
|
||||
{
|
||||
"name": "Restricted",
|
||||
"value": "restricted"
|
||||
},
|
||||
{
|
||||
"name": "Full",
|
||||
"value": "full"
|
||||
},
|
||||
{
|
||||
"name": "Crossed",
|
||||
"value": "crossed"
|
||||
},
|
||||
{
|
||||
"name": "Insanity",
|
||||
"value": "insanity"
|
||||
}
|
||||
]
|
||||
},
|
||||
"item_pool": {
|
||||
"type": "select",
|
||||
"friendlyName": "Item Pool",
|
||||
"description": "Changes the available upgrade items (1/2 Magic, hearts, sword upgrades, etc)",
|
||||
"defaultValue": "normal",
|
||||
"options": [
|
||||
{
|
||||
"name": "Easy",
|
||||
"value": "easy"
|
||||
},
|
||||
{
|
||||
"name": "Normal",
|
||||
"value": "normal"
|
||||
},
|
||||
{
|
||||
"name": "Hard",
|
||||
"value": "hard"
|
||||
},
|
||||
{
|
||||
"name": "Expert",
|
||||
"value": "expert"
|
||||
}
|
||||
]
|
||||
},
|
||||
"item_functionality": {
|
||||
"type": "select",
|
||||
"friendlyName": "Item Functionality",
|
||||
"description": "Changes the abilities of your items",
|
||||
"defaultValue": "normal",
|
||||
"options": [
|
||||
{
|
||||
"name": "Easy",
|
||||
"value": "easy"
|
||||
},
|
||||
{
|
||||
"name": "Normal",
|
||||
"value": "normal"
|
||||
},
|
||||
{
|
||||
"name": "Hard",
|
||||
"value": "hard"
|
||||
},
|
||||
{
|
||||
"name": "Expert",
|
||||
"value": "expert"
|
||||
}
|
||||
]
|
||||
},
|
||||
"enemy_shuffle": {
|
||||
"type": "select",
|
||||
"friendlyName": "Enemy Shuffle",
|
||||
"description": "Randomize the enemies which appear throughout the game",
|
||||
"defaultValue": "off",
|
||||
"options": [
|
||||
{
|
||||
"name": "Disabled",
|
||||
"value": "off"
|
||||
},
|
||||
{
|
||||
"name": "Enabled",
|
||||
"value": "on"
|
||||
}
|
||||
]
|
||||
},
|
||||
"boss_shuffle": {
|
||||
"type": "select",
|
||||
"friendlyName": "Boss Shuffle",
|
||||
"description": "Shuffle the bosses within dungeons",
|
||||
"defaultValue": "none",
|
||||
"options": [
|
||||
{
|
||||
"name": "Disabled",
|
||||
"value": "none"
|
||||
},
|
||||
{
|
||||
"name": "Simple",
|
||||
"value": "simple"
|
||||
},
|
||||
{
|
||||
"name": "Full",
|
||||
"value": "full"
|
||||
},
|
||||
{
|
||||
"name": "Singularity",
|
||||
"value": "singularity"
|
||||
},
|
||||
{
|
||||
"name": "Random",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"shop_shuffle": {
|
||||
"type": "select",
|
||||
"friendlyName": "Shop Shuffle",
|
||||
"description": "Shuffles the content and prices of shops throughout Hyrule",
|
||||
"defaultValue": "none",
|
||||
"options": [
|
||||
{
|
||||
"name": "None",
|
||||
"value": "none"
|
||||
},
|
||||
{
|
||||
"name": "Inventory",
|
||||
"value": "f"
|
||||
},
|
||||
{
|
||||
"name": "Prices",
|
||||
"value": "p"
|
||||
},
|
||||
{
|
||||
"name": "Capacity Upgrades",
|
||||
"value": "u"
|
||||
},
|
||||
{
|
||||
"name": "Inventory and Prices",
|
||||
"value": "fp"
|
||||
},
|
||||
{
|
||||
"name": "Inventory, Prices, and Upgrades",
|
||||
"value": "fpu"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"romOptions": {
|
||||
"disablemusic": {
|
||||
"type": "select",
|
||||
"friendlyName": "Game Music",
|
||||
"description": "Choose to enable or disable in-game music",
|
||||
"defaultValue": "off",
|
||||
"options": [
|
||||
{
|
||||
"name": "Enabled",
|
||||
"value": "off"
|
||||
},
|
||||
{
|
||||
"name": "Disabled",
|
||||
"value": "on"
|
||||
}
|
||||
]
|
||||
},
|
||||
"quickswap": {
|
||||
"type": "select",
|
||||
"friendlyName": "Item Quick-Swap",
|
||||
"description": "Enable or disable quick-swap using the L+R buttons",
|
||||
"defaultValue": "on",
|
||||
"options": [
|
||||
{
|
||||
"name": "Enabled",
|
||||
"value": "on"
|
||||
},
|
||||
{
|
||||
"name": "Disabled",
|
||||
"value": "off"
|
||||
}
|
||||
]
|
||||
},
|
||||
"menuspeed": {
|
||||
"type": "select",
|
||||
"friendlyName": "Menu Speed",
|
||||
"description": "Changes the animation speed of the in-game menu",
|
||||
"defaultValue": "normal",
|
||||
"options": [
|
||||
{
|
||||
"name": "Normal",
|
||||
"value": "normal"
|
||||
},
|
||||
{
|
||||
"name": "Instant",
|
||||
"value": "instant"
|
||||
},
|
||||
{
|
||||
"name": "Double",
|
||||
"value": "double"
|
||||
},
|
||||
{
|
||||
"name": "Triple",
|
||||
"value": "triple"
|
||||
},
|
||||
{
|
||||
"name": "Quadruple",
|
||||
"value": "quadruple"
|
||||
},
|
||||
{
|
||||
"name": "Half-Speed",
|
||||
"value": "half"
|
||||
}
|
||||
]
|
||||
},
|
||||
"heartbeep": {
|
||||
"type": "select",
|
||||
"friendlyName": "Heart-Beep Speed",
|
||||
"description": "Change the frequency of the heart beep alert when you are at low health",
|
||||
"defaultValue": "normal",
|
||||
"options": [
|
||||
{
|
||||
"name": "Double Speed",
|
||||
"value": "double"
|
||||
},
|
||||
{
|
||||
"name": "Normal",
|
||||
"value": "normal"
|
||||
},
|
||||
{
|
||||
"name": "Half-Speed",
|
||||
"value": "half"
|
||||
},
|
||||
{
|
||||
"name": "Quarter-Speed",
|
||||
"value": "quarter"
|
||||
},
|
||||
{
|
||||
"name": "Disabled",
|
||||
"value": "off"
|
||||
}
|
||||
]
|
||||
},
|
||||
"heartcolor": {
|
||||
"type": "select",
|
||||
"friendlyName": "Heart Color",
|
||||
"description": "Change the color of your hearts in-game",
|
||||
"defaultValue": "red",
|
||||
"options": [
|
||||
{
|
||||
"name": "Red",
|
||||
"value": "red"
|
||||
},
|
||||
{
|
||||
"name": "Blue",
|
||||
"value": "blue"
|
||||
},
|
||||
{
|
||||
"name": "Green",
|
||||
"value": "green"
|
||||
},
|
||||
{
|
||||
"name": "Yellow",
|
||||
"value": "yellow"
|
||||
},
|
||||
{
|
||||
"name": "Random",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ow_palettes": {
|
||||
"type": "select",
|
||||
"friendlyName": "Overworld Palette",
|
||||
"description": "Change the colors of the overworld",
|
||||
"defaultValue": "default",
|
||||
"options": [
|
||||
{
|
||||
"name": "Vanilla",
|
||||
"value": "default"
|
||||
},
|
||||
{
|
||||
"name": "Randomized",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"uw_palettes": {
|
||||
"type": "select",
|
||||
"friendlyName": "Underworld Palette",
|
||||
"description": "Change the colors of the underworld",
|
||||
"defaultValue": "default",
|
||||
"options": [
|
||||
{
|
||||
"name": "Vanilla",
|
||||
"value": "default"
|
||||
},
|
||||
{
|
||||
"name": "Randomized",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hud_palettes": {
|
||||
"type": "select",
|
||||
"friendlyName": "HUD Palette",
|
||||
"description": "Change the colors of the user-interface",
|
||||
"defaultValue": "default",
|
||||
"options": [
|
||||
{
|
||||
"name": "Vanilla",
|
||||
"value": "default"
|
||||
},
|
||||
{
|
||||
"name": "Randomized",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sword_palettes": {
|
||||
"type": "select",
|
||||
"friendlyName": "Sword Palette",
|
||||
"description": "Change the colors of the swords, within reason",
|
||||
"defaultValue": "default",
|
||||
"options": [
|
||||
{
|
||||
"name": "Vanilla",
|
||||
"value": "default"
|
||||
},
|
||||
{
|
||||
"name": "Randomized",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sprite": {
|
||||
"type": "select",
|
||||
"friendlyName": "Sprite",
|
||||
"description": "Choose a sprite to play as!",
|
||||
"defaultValue": "link",
|
||||
"options": [
|
||||
{
|
||||
"name": "Random",
|
||||
"value": "random"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |
|
Before Width: | Height: | Size: 541 B |