mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-07 15:13:52 -08:00
Compare commits
790 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97d6e80556 | ||
|
|
d5abadc6d0 | ||
|
|
d08d716966 | ||
|
|
3ee4be2e33 | ||
|
|
9172cc4925 | ||
|
|
7f03a86dee | ||
|
|
1603bab1da | ||
|
|
70aae514be | ||
|
|
5fa1185d6d | ||
|
|
3a2a584ad3 | ||
|
|
c42f53d64f | ||
|
|
450e0eacf4 | ||
|
|
aa40e811f1 | ||
|
|
af96f71190 | ||
|
|
9e4cb6ee33 | ||
|
|
5d0748983b | ||
|
|
c4981e4b91 | ||
|
|
3f36c436ad | ||
|
|
db456cbcf1 | ||
|
|
c0b8384319 | ||
|
|
13036539b7 | ||
|
|
5a2e477dba | ||
|
|
f003c7130f | ||
|
|
0558351a12 | ||
|
|
3f20bdaaa2 | ||
|
|
3bf367d630 | ||
|
|
706fc19ab4 | ||
|
|
4fe024041d | ||
|
|
7afbf8b45b | ||
|
|
e1fc44f4e0 | ||
|
|
21fbb545e8 | ||
|
|
6cd08ea8dc | ||
|
|
85efee1432 | ||
|
|
ba9974fe2a | ||
|
|
98a038e39e | ||
|
|
33477202b9 | ||
|
|
9c74d648f8 | ||
|
|
feb2e0be03 | ||
|
|
84e76eadd9 | ||
|
|
c1a73e7839 | ||
|
|
75625b143c | ||
|
|
c10e17d24c | ||
|
|
21d465bcb8 | ||
|
|
47c1300f30 | ||
|
|
e7d8149d74 | ||
|
|
a3220ac72d | ||
|
|
994621372c | ||
|
|
9d3cbb19f9 | ||
|
|
3110763052 | ||
|
|
6f12ed38d9 | ||
|
|
efb4e5a7b3 | ||
|
|
a15689e380 | ||
|
|
548d893eaa | ||
|
|
1ec9ab5568 | ||
|
|
a767d7723c | ||
|
|
a60c6176be | ||
|
|
83cfd6ec05 | ||
|
|
f673dfb7cf | ||
|
|
22d8b0ef30 | ||
|
|
763edf00f2 | ||
|
|
b7128e6ee2 | ||
|
|
db56f4a6b7 | ||
|
|
3fa253bac5 | ||
|
|
d7509972e4 | ||
|
|
49a0f473ce | ||
|
|
520e5feefb | ||
|
|
0992087e9a | ||
|
|
246a5c568b | ||
|
|
c083716627 | ||
|
|
31c15c257c | ||
|
|
dcb6da30ef | ||
|
|
c46abd7e65 | ||
|
|
f478b65815 | ||
|
|
8363d1749b | ||
|
|
b3ae4b86e4 | ||
|
|
6566dde8d0 | ||
|
|
7b0b243607 | ||
|
|
d768379a8a | ||
|
|
5e84900ac4 | ||
|
|
73ae180437 | ||
|
|
2097164d32 | ||
|
|
9f0a8e6d48 | ||
|
|
5ca737886b | ||
|
|
11285fb0aa | ||
|
|
82de3c95e2 | ||
|
|
b0bf66bdcb | ||
|
|
8af5855af6 | ||
|
|
383d0f1a66 | ||
|
|
4dfa1e3227 | ||
|
|
1a63ed970a | ||
|
|
5b5d96971e | ||
|
|
71767e8b79 | ||
|
|
bd0b7ea80a | ||
|
|
744b12345a | ||
|
|
2770014988 | ||
|
|
31b93dc2f4 | ||
|
|
81397936ef | ||
|
|
722af0a3ca | ||
|
|
6641b13511 | ||
|
|
5a03c0edd6 | ||
|
|
9dbafd3b4b | ||
|
|
1f5d1532e3 | ||
|
|
1f61d8322c | ||
|
|
0c27dbe746 | ||
|
|
a3951c2621 | ||
|
|
c381df6563 | ||
|
|
39ff471772 | ||
|
|
33c8d307ed | ||
|
|
26b336d6db | ||
|
|
fbd5bfd382 | ||
|
|
e0d6503590 | ||
|
|
b10d9040df | ||
|
|
415f045fd8 | ||
|
|
f4e34372be | ||
|
|
50264993b0 | ||
|
|
45a6598d18 | ||
|
|
b205972e44 | ||
|
|
3d19c39001 | ||
|
|
428177bdca | ||
|
|
c21bd11b66 | ||
|
|
beb4949044 | ||
|
|
1b4659276c | ||
|
|
affd707717 | ||
|
|
48ed394d02 | ||
|
|
4f00f5509f | ||
|
|
47c5c407ef | ||
|
|
a27d09f81a | ||
|
|
2fb765455c | ||
|
|
639e6f9a6c | ||
|
|
3e40de72b2 | ||
|
|
686812ee9e | ||
|
|
80c3b8bbca | ||
|
|
824b932961 | ||
|
|
7c3ba3bc42 | ||
|
|
c638a2cfb6 | ||
|
|
6e29101ecf | ||
|
|
6b4445e122 | ||
|
|
f7e89695e5 | ||
|
|
9cb24280fa | ||
|
|
cf20c0781f | ||
|
|
cd1c38515b | ||
|
|
a5ca4f1611 | ||
|
|
fc022c98f2 | ||
|
|
52aebc3094 | ||
|
|
2ef60c0cd9 | ||
|
|
10411466d8 | ||
|
|
a6cfed0da2 | ||
|
|
5d29184801 | ||
|
|
f4762cb3f2 | ||
|
|
899e9331fa | ||
|
|
cc3d5e60a1 | ||
|
|
97f6003582 | ||
|
|
b217e734cb | ||
|
|
09fb956ba6 | ||
|
|
d8dedbe7fa | ||
|
|
b07345cee7 | ||
|
|
4709902819 | ||
|
|
af9ab30bdf | ||
|
|
f5e82c0417 | ||
|
|
a9f6317032 | ||
|
|
cf09c2aa3d | ||
|
|
a53d4219b3 | ||
|
|
bd8e1f6531 | ||
|
|
3658c9f8e3 | ||
|
|
6a912c128d | ||
|
|
71f30b72f4 | ||
|
|
2dc8b77ddc | ||
|
|
16cd2760a4 | ||
|
|
55bfc71269 | ||
|
|
d623cd5ce0 | ||
|
|
4bbf8858b0 | ||
|
|
5626ff1582 | ||
|
|
28f5236719 | ||
|
|
f9e1db41e9 | ||
|
|
61ffdff207 | ||
|
|
3bcd85aa0a | ||
|
|
8b60a9e2f0 | ||
|
|
4cd9711de3 | ||
|
|
2ffa0d0e7f | ||
|
|
586af0de1d | ||
|
|
e90b2c3a5c | ||
|
|
3d6c82861a | ||
|
|
fc3b8c40be | ||
|
|
54cd32872e | ||
|
|
c178006acc | ||
|
|
4e43166e1f | ||
|
|
452026165f | ||
|
|
82b8b313f0 | ||
|
|
b529f95798 | ||
|
|
2d55cf4bbf | ||
|
|
62e0e0bb55 | ||
|
|
83a40d4394 | ||
|
|
4937156021 | ||
|
|
24596899c9 | ||
|
|
cd3f0eabfb | ||
|
|
34af785e87 | ||
|
|
34cfe7d1df | ||
|
|
2f9e530fd8 | ||
|
|
ca8f6c2439 | ||
|
|
4a8ba0575f | ||
|
|
77ec8d4141 | ||
|
|
61ae51b30c | ||
|
|
f26d2d5f20 | ||
|
|
fd07bc3f2c | ||
|
|
8316a1902d | ||
|
|
650fd5d792 | ||
|
|
82d3e4bc92 | ||
|
|
8eb1f0258c | ||
|
|
80c86f34a4 | ||
|
|
3ed7b9f60c | ||
|
|
77c18ac819 | ||
|
|
0d6c23e4f2 | ||
|
|
ec9ef21cc0 | ||
|
|
43323e59ce | ||
|
|
9ada4df151 | ||
|
|
d42d77d3d3 | ||
|
|
2007549e01 | ||
|
|
987bbc761a | ||
|
|
0b096528d4 | ||
|
|
fa56541b3a | ||
|
|
beb15aa99a | ||
|
|
ca9bf48ffa | ||
|
|
b9941e40c1 | ||
|
|
e8639988ce | ||
|
|
c32f3d6e96 | ||
|
|
60697cc8ba | ||
|
|
c0d3f140f3 | ||
|
|
d5934a88a7 | ||
|
|
db2731dfb7 | ||
|
|
97ee73d79f | ||
|
|
48ce19a923 | ||
|
|
4f28c3fa46 | ||
|
|
449f4ee92f | ||
|
|
79041bdf21 | ||
|
|
655d14ed6e | ||
|
|
5d0d9c2890 | ||
|
|
f10163e7d2 | ||
|
|
666e3b5333 | ||
|
|
2b124aaff4 | ||
|
|
00d62fc23f | ||
|
|
aa87b78dde | ||
|
|
6c71bd40fb | ||
|
|
ed40043448 | ||
|
|
5cf7e6e24b | ||
|
|
720ef936da | ||
|
|
30755b2067 | ||
|
|
04f67c114e | ||
|
|
ea707a0bc5 | ||
|
|
f43475f33b | ||
|
|
739d4d0038 | ||
|
|
e756a77c70 | ||
|
|
bcfa5d0a7e | ||
|
|
45f92536a6 | ||
|
|
6b0b78d8e0 | ||
|
|
c336cdc5df | ||
|
|
6ea8d07c8f | ||
|
|
5c25a08dc1 | ||
|
|
fe7f109127 | ||
|
|
583819c4ae | ||
|
|
cb8da2e757 | ||
|
|
fdc96115e4 | ||
|
|
e019ec5ff7 | ||
|
|
e4838f6d2b | ||
|
|
10837e75b2 | ||
|
|
46590c3163 | ||
|
|
e64d5c5f17 | ||
|
|
0e0cc0ad16 | ||
|
|
8ff01ca979 | ||
|
|
9508a9afc6 | ||
|
|
704a0e3078 | ||
|
|
9bf9f2c611 | ||
|
|
71c869e65b | ||
|
|
2897fa4003 | ||
|
|
7f020857d1 | ||
|
|
2217a9304d | ||
|
|
5a389b4855 | ||
|
|
bdb9b7803c | ||
|
|
4622b3fe36 | ||
|
|
402afd15db | ||
|
|
82aca3bce4 | ||
|
|
756c6554c9 | ||
|
|
3b9753aaf4 | ||
|
|
4472ef20fe | ||
|
|
c152790011 | ||
|
|
4e3b8a5178 | ||
|
|
375a0ff208 | ||
|
|
57831f0eba | ||
|
|
c9a3f67121 | ||
|
|
6af1f98c88 | ||
|
|
8e35372aad | ||
|
|
0f4d285223 | ||
|
|
192e592cda | ||
|
|
1c2c1f286f | ||
|
|
6e25af9493 | ||
|
|
050927008a | ||
|
|
2fe5459c56 | ||
|
|
8fbbaf7fcb | ||
|
|
2f5bdc5cf9 | ||
|
|
17833a0bfc | ||
|
|
f4e71df946 | ||
|
|
be070b79af | ||
|
|
ef8eefd3b4 | ||
|
|
83f46f6b2b | ||
|
|
6b4bdf569c | ||
|
|
7a9f6e2a8e | ||
|
|
ce95ff65bd | ||
|
|
28e724da98 | ||
|
|
a43b027cde | ||
|
|
4b5e36ebf2 | ||
|
|
89c05cfcae | ||
|
|
f8569db21b | ||
|
|
34eba2655e | ||
|
|
1625860bd9 | ||
|
|
f3ddfb96f3 | ||
|
|
66e198cbb6 | ||
|
|
33c747a881 | ||
|
|
20d61d14e0 | ||
|
|
833de94ab0 | ||
|
|
c8d6250ada | ||
|
|
d38e1185bb | ||
|
|
fdb8ae0cb5 | ||
|
|
b57306beac | ||
|
|
af6e159644 | ||
|
|
54e50f69e1 | ||
|
|
3f415b8265 | ||
|
|
8ccdb56bf1 | ||
|
|
17ed957c6b | ||
|
|
e4564abe41 | ||
|
|
f16b29b16b | ||
|
|
ef8af7d618 | ||
|
|
79e33899a8 | ||
|
|
11fc220d4d | ||
|
|
a94a30168c | ||
|
|
19704920a4 | ||
|
|
e4f4c1f1be | ||
|
|
065931cae7 | ||
|
|
78443bffac | ||
|
|
a8b105267c | ||
|
|
f7bd637073 | ||
|
|
3e6f7f0fad | ||
|
|
e301b67e49 | ||
|
|
952d878442 | ||
|
|
8f66f94ffa | ||
|
|
d79acef59e | ||
|
|
e66a2a7c30 | ||
|
|
2f04b93fdb | ||
|
|
818e99b39d | ||
|
|
96ffe95404 | ||
|
|
438e53d25e | ||
|
|
ca4b0acd92 | ||
|
|
f8deb1bd7f | ||
|
|
d8de84e417 | ||
|
|
eb602aedc3 | ||
|
|
b539892cc0 | ||
|
|
ba13d2179d | ||
|
|
c7a315ac97 | ||
|
|
b1fb793ea4 | ||
|
|
62db9ad982 | ||
|
|
652c9943c2 | ||
|
|
9f62575abe | ||
|
|
2fd87f703e | ||
|
|
d3780cd9d5 | ||
|
|
0376705e47 | ||
|
|
f1fddac655 | ||
|
|
6acd08431e | ||
|
|
317f7116c4 | ||
|
|
bf8e99140e | ||
|
|
6c949c3a52 | ||
|
|
76d591bab5 | ||
|
|
d10cab824a | ||
|
|
a93d633d25 | ||
|
|
9ebab4a382 | ||
|
|
cd53dcfe43 | ||
|
|
87ceef230f | ||
|
|
a06e81a0ba | ||
|
|
59e87e0d27 | ||
|
|
76d1460d0f | ||
|
|
1985423a97 | ||
|
|
f5afc84cd2 | ||
|
|
1217179f8a | ||
|
|
29a207b73e | ||
|
|
f7ecf02beb | ||
|
|
c5193ffdd9 | ||
|
|
916ba2ea41 | ||
|
|
3348dce122 | ||
|
|
53e6ca6e34 | ||
|
|
0fed7f1295 | ||
|
|
6ade832029 | ||
|
|
50ba9a56f7 | ||
|
|
990141df47 | ||
|
|
50f7541ef7 | ||
|
|
6a6962b3b9 | ||
|
|
3314ad0315 | ||
|
|
9a4a96eedd | ||
|
|
ff4a9d1761 | ||
|
|
df2d4a557e | ||
|
|
d831923a54 | ||
|
|
594183d751 | ||
|
|
bddaa954ab | ||
|
|
f4a7777018 | ||
|
|
8fc9a9c55e | ||
|
|
8e457d9b8f | ||
|
|
aa37c9bf81 | ||
|
|
89cbd05600 | ||
|
|
a5e9c4af03 | ||
|
|
ea753cd8bf | ||
|
|
46e9fd7ae3 | ||
|
|
96d7277a22 | ||
|
|
c937167a11 | ||
|
|
0c59ad7e22 | ||
|
|
fa1b93252c | ||
|
|
0d9e186e18 | ||
|
|
b7aa5a17b7 | ||
|
|
d55a057a4d | ||
|
|
72976da3a4 | ||
|
|
81afbb55cf | ||
|
|
d1709764ef | ||
|
|
4f7e3d7a45 | ||
|
|
4ca53a6ee0 | ||
|
|
efe02e2591 | ||
|
|
391f42b4f2 | ||
|
|
cff5db446d | ||
|
|
858d4c74ce | ||
|
|
f56bf0db73 | ||
|
|
4801bb1178 | ||
|
|
8b2433584d | ||
|
|
bde02f696b | ||
|
|
0afbe7988e | ||
|
|
345d4c58f3 | ||
|
|
6c44ffaf7a | ||
|
|
16454dbc33 | ||
|
|
89c6fd6ac4 | ||
|
|
ea8b6e6438 | ||
|
|
c0b25e1f6e | ||
|
|
df0335f739 | ||
|
|
1ffe5fc7bb | ||
|
|
cf070e6dd9 | ||
|
|
f9a9189687 | ||
|
|
9daf1abcd9 | ||
|
|
8c525a5e33 | ||
|
|
952a155003 | ||
|
|
7f35f6f8f4 | ||
|
|
8b9e278593 | ||
|
|
655ebcdb07 | ||
|
|
ac534a6881 | ||
|
|
59529eba4e | ||
|
|
1cef10b309 | ||
|
|
c3070be14a | ||
|
|
5570440eb1 | ||
|
|
ec0a5df5a1 | ||
|
|
8411b76ee5 | ||
|
|
c0ff90fc86 | ||
|
|
f9c8816c43 | ||
|
|
822e8941ed | ||
|
|
7ac9bd8591 | ||
|
|
68a5784650 | ||
|
|
67f324b939 | ||
|
|
8db8c60e75 | ||
|
|
8e569a1d1f | ||
|
|
3caf8bc82b | ||
|
|
3da028415f | ||
|
|
104df1915d | ||
|
|
bfb6d44195 | ||
|
|
df0e8bc027 | ||
|
|
442b6ced35 | ||
|
|
111e11924f | ||
|
|
061cc69a6a | ||
|
|
f9950e1f01 | ||
|
|
895d259589 | ||
|
|
4ea80f34fa | ||
|
|
77878bf714 | ||
|
|
f85dde6323 | ||
|
|
6441f92c9f | ||
|
|
25b9fc8b6a | ||
|
|
090678776e | ||
|
|
9be6d443d7 | ||
|
|
678253d037 | ||
|
|
bd561fd191 | ||
|
|
38b5ee7314 | ||
|
|
11245462f0 | ||
|
|
351a5b87bf | ||
|
|
b780257098 | ||
|
|
4e1f1551ea | ||
|
|
b82e3f2a8a | ||
|
|
a82bf1bb32 | ||
|
|
abc0220cfa | ||
|
|
f17e6f9afd | ||
|
|
16e6b9eed7 | ||
|
|
323415ba9c | ||
|
|
ae97b5e704 | ||
|
|
6b8b30c3c7 | ||
|
|
0df2b2221d | ||
|
|
e2b36dfa7d | ||
|
|
4e18f24f3b | ||
|
|
b0d5a51768 | ||
|
|
b3d2c22373 | ||
|
|
cace88e8fa | ||
|
|
9c09d84c71 | ||
|
|
2d27665369 | ||
|
|
45266caa8d | ||
|
|
feb1a59902 | ||
|
|
fdec4157da | ||
|
|
4e84b20925 | ||
|
|
f952ad5913 | ||
|
|
be27586203 | ||
|
|
9dc3f3f38b | ||
|
|
f39defbe06 | ||
|
|
890f71a477 | ||
|
|
bc8e8c5daf | ||
|
|
37f12809a1 | ||
|
|
f5c0b847a9 | ||
|
|
44d6c3c07e | ||
|
|
da1a2b2957 | ||
|
|
9f6fa2bd05 | ||
|
|
5d68dc568f | ||
|
|
ee1ea881e8 | ||
|
|
87add88436 | ||
|
|
7643609e09 | ||
|
|
73727ab0d1 | ||
|
|
007a393ab5 | ||
|
|
4ed185a155 | ||
|
|
fbb220ce85 | ||
|
|
0c57d35402 | ||
|
|
8cc045f370 | ||
|
|
80c90c0a00 | ||
|
|
c1c92647ca | ||
|
|
033adceb6f | ||
|
|
e57e92bfee | ||
|
|
4d68000692 | ||
|
|
44b5423afc | ||
|
|
a1a7729c3b | ||
|
|
071b0eeb77 | ||
|
|
fafc17c7d3 | ||
|
|
7599302920 | ||
|
|
7f8d7231a4 | ||
|
|
b1196885d7 | ||
|
|
494cfb3c04 | ||
|
|
6a65981103 | ||
|
|
f508f93d69 | ||
|
|
411d4434a3 | ||
|
|
d41fce6f91 | ||
|
|
282e7b4006 | ||
|
|
b4c3c5deea | ||
|
|
683514d891 | ||
|
|
e9beb21a98 | ||
|
|
bc47f78264 | ||
|
|
b002f7f862 | ||
|
|
05dac999a8 | ||
|
|
242595b725 | ||
|
|
48dd1a1aa6 | ||
|
|
3aacaffe6b | ||
|
|
61b875256f | ||
|
|
14dc450631 | ||
|
|
6352056528 | ||
|
|
bd4f24844b | ||
|
|
062615b6b1 | ||
|
|
6c9293e4f6 | ||
|
|
24802d64c7 | ||
|
|
5e8a686bb6 | ||
|
|
279ab89a61 | ||
|
|
29ed40051d | ||
|
|
8d05aa6262 | ||
|
|
694f942c06 | ||
|
|
105a2d4e13 | ||
|
|
1ee62912fd | ||
|
|
abacca34ee | ||
|
|
3e4e69735e | ||
|
|
4afc351933 | ||
|
|
23b8070b9d | ||
|
|
e53b5324f5 | ||
|
|
25bbbdbecd | ||
|
|
d739d04380 | ||
|
|
f7da0265c4 | ||
|
|
82ae21420d | ||
|
|
89984a0d09 | ||
|
|
fc62b4e0bd | ||
|
|
2e2ca1665b | ||
|
|
1b27fc495f | ||
|
|
51c38fc628 | ||
|
|
74c30ce09a | ||
|
|
859316353e | ||
|
|
63c9bea724 | ||
|
|
df435eb693 | ||
|
|
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 |
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -12,10 +12,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
6
.github/workflows/unittests.yml
vendored
6
.github/workflows/unittests.yml
vendored
@@ -12,15 +12,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
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
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -4,9 +4,14 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.pyc
|
||||
*.pyd
|
||||
*.sfc
|
||||
*.z64
|
||||
*.n64
|
||||
*.wixobj
|
||||
*.lck
|
||||
*.db3
|
||||
@@ -16,7 +21,6 @@
|
||||
*.apsave
|
||||
|
||||
build
|
||||
/build_factorio/
|
||||
bundle/components.wxs
|
||||
dist
|
||||
README.html
|
||||
@@ -38,6 +42,7 @@ success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
@@ -147,3 +152,7 @@ dmypy.json
|
||||
cython_debug/
|
||||
|
||||
Archipelago.zip
|
||||
|
||||
#minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
669
BaseClasses.py
669
BaseClasses.py
File diff suppressed because it is too large
Load Diff
317
CommonClient.py
317
CommonClient.py
@@ -1,19 +1,29 @@
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import typing
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
|
||||
from Utils import Version
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
@@ -29,13 +39,13 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
asyncio.create_task(self.ctx.connect(address if address else None))
|
||||
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
|
||||
return True
|
||||
|
||||
def _cmd_disconnect(self) -> bool:
|
||||
"""Disconnect from a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
asyncio.create_task(self.ctx.disconnect())
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
@@ -47,9 +57,12 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks, from your local game state"""
|
||||
if not self.ctx.game:
|
||||
self.output("No game set, cannot determine missing checks.")
|
||||
return False
|
||||
count = 0
|
||||
checked_count = 0
|
||||
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].item_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:
|
||||
@@ -68,9 +81,20 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output("No missing location checks found.")
|
||||
return True
|
||||
|
||||
def _cmd_items(self):
|
||||
"""List all item names for the currently running game."""
|
||||
self.output(f"Item Names for {self.ctx.game}")
|
||||
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
|
||||
self.output(item_name)
|
||||
|
||||
def _cmd_locations(self):
|
||||
"""List all location names for the currently running game."""
|
||||
self.output(f"Location Names for {self.ctx.game}")
|
||||
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
|
||||
self.output(location_name)
|
||||
|
||||
def _cmd_ready(self):
|
||||
"""Send ready status to server."""
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
state = ClientStatus.CLIENT_READY
|
||||
@@ -78,16 +102,22 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
else:
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]))
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]))
|
||||
raw = self.ctx.on_user_say(raw)
|
||||
if raw:
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
|
||||
|
||||
|
||||
class CommonContext():
|
||||
starting_reconnect_delay = 5
|
||||
current_reconnect_delay = starting_reconnect_delay
|
||||
command_processor = ClientCommandProcessor
|
||||
game: None
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: int = ClientCommandProcessor
|
||||
game = None
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
@@ -96,6 +126,13 @@ class CommonContext():
|
||||
self.server_task = None
|
||||
self.server: typing.Optional[Endpoint] = None
|
||||
self.server_version = Version(0, 0, 0)
|
||||
self.hint_cost: typing.Optional[int] = None
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
# own state
|
||||
self.finished_game = False
|
||||
@@ -105,16 +142,18 @@ class CommonContext():
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set()
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.List[int] = []
|
||||
self.checked_locations: typing.List[int] = []
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = set() # server state
|
||||
self.locations_info = {}
|
||||
|
||||
self.input_queue = asyncio.Queue()
|
||||
self.input_requests = 0
|
||||
|
||||
self.last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# game state
|
||||
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
|
||||
self.exit_event = asyncio.Event()
|
||||
@@ -124,6 +163,15 @@ class CommonContext():
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.set_getters(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
|
||||
@property
|
||||
def total_locations(self) -> typing.Optional[int]:
|
||||
"""Will return None until connected."""
|
||||
if self.checked_locations or self.missing_locations:
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.auth = None
|
||||
self.items_received = []
|
||||
@@ -134,22 +182,24 @@ class CommonContext():
|
||||
self.server = None
|
||||
self.server_task = None
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
def set_getters(self, data_package: dict, network=False):
|
||||
if not network: # local data; check if newer data was already downloaded
|
||||
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
|
||||
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})')
|
||||
@@ -186,25 +236,118 @@ class CommonContext():
|
||||
def event_invalid_slot(self):
|
||||
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
def event_invalid_game(self):
|
||||
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
logger.info('Enter the password required to join this game:')
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
payload = {
|
||||
"cmd": 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
}
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
await self.send_msgs([payload])
|
||||
|
||||
async def console_input(self):
|
||||
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))
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
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
|
||||
|
||||
def on_user_say(self, text: str) -> typing.Optional[str]:
|
||||
"""Gets called before sending a Say to the server from the user.
|
||||
Returned text is sent, or sending is aborted if None is returned."""
|
||||
return text
|
||||
|
||||
def update_permissions(self, permissions: typing.Dict[str, int]):
|
||||
for permission_name, permission_flag in permissions.items():
|
||||
try:
|
||||
flag = Permission(permission_flag)
|
||||
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
|
||||
self.permissions[permission_name] = flag.name
|
||||
except Exception as e: # safeguard against permissions that may be implemented in the future
|
||||
logger.exception(e)
|
||||
|
||||
async def shutdown(self):
|
||||
self.server_address = None
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
await self.server_task
|
||||
|
||||
while self.input_requests > 0:
|
||||
self.input_queue.put_nowait(None)
|
||||
self.input_requests -= 1
|
||||
self.keep_alive_task.cancel()
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||
self.last_death_link = max(data["time"], self.last_death_link)
|
||||
text = data.get("cause", "")
|
||||
if text:
|
||||
logger.info(f"DeathLink: {text}")
|
||||
else:
|
||||
logger.info(f"DeathLink: Received from {data['source']}")
|
||||
|
||||
async def send_death(self, death_text: str = ""):
|
||||
logger.info("DeathLink: Sending death to your friends...")
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot],
|
||||
"cause": death_text
|
||||
}
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
else:
|
||||
self.tags -= {"DeathLink"}
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
|
||||
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
|
||||
so we send a payload to prevent drop and if we were dropped anyway this will cause an auto-reconnect."""
|
||||
seconds_elapsed = 0
|
||||
while not ctx.exit_event.is_set():
|
||||
await asyncio.sleep(1) # short sleep to not block program shutdown
|
||||
if ctx.server and ctx.slot:
|
||||
seconds_elapsed += 1
|
||||
if seconds_elapsed > seconds_between_checks:
|
||||
await ctx.send_msgs([{"cmd": "Bounce", "slots": [ctx.slot]}])
|
||||
seconds_elapsed = 0
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
@@ -240,25 +383,25 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.error('Unable to connect to multiworld server at cached address. '
|
||||
'Please use the connect button above.')
|
||||
else:
|
||||
logger.error('Connection refused by the multiworld server')
|
||||
logger.exception('Connection refused by the multiworld server')
|
||||
except websockets.InvalidURI:
|
||||
logger.exception('Failed to connect to the multiworld server (invalid URI)')
|
||||
except (OSError, websockets.InvalidURI):
|
||||
logger.error('Failed to connect to the multiworld server')
|
||||
logger.exception('Failed to connect to the multiworld server')
|
||||
except Exception as e:
|
||||
logger.error('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
if not isinstance(e, websockets.WebSocketException):
|
||||
logger.exception(e)
|
||||
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
|
||||
asyncio.create_task(server_autoreconnect(ctx))
|
||||
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
|
||||
ctx.current_reconnect_delay *= 2
|
||||
|
||||
|
||||
async def server_autoreconnect(ctx: CommonContext):
|
||||
await asyncio.sleep(ctx.current_reconnect_delay)
|
||||
if ctx.server_address and ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx))
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
|
||||
|
||||
async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
@@ -282,14 +425,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
||||
if args['password']:
|
||||
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.")
|
||||
ctx.update_permissions(args.get("permissions", {}))
|
||||
if "games" in args:
|
||||
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
|
||||
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']
|
||||
ctx.remaining_mode = args['remaining_mode']
|
||||
|
||||
if len(args['players']) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
@@ -313,7 +458,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
errors = args["errors"]
|
||||
if 'InvalidSlot' in errors:
|
||||
ctx.event_invalid_slot()
|
||||
|
||||
elif 'InvalidGame' in errors:
|
||||
ctx.event_invalid_game()
|
||||
elif 'SlotAlreadyTaken' in errors:
|
||||
raise Exception('Player slot already in use for that team')
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
@@ -348,8 +494,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
# This list is used to only send to the server what is reported as ACTUALLY Missing.
|
||||
# This also serves to allow an easy visual of what locations were already checked previously
|
||||
# when /missing is used for the client side view of what is missing.
|
||||
ctx.missing_locations = args["missing_locations"]
|
||||
ctx.checked_locations = args["checked_locations"]
|
||||
ctx.missing_locations = set(args["missing_locations"])
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
|
||||
elif cmd == 'ReceivedItems':
|
||||
start_index = args["index"]
|
||||
@@ -378,6 +524,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.consume_players_package(args["players"])
|
||||
if "hint_points" in args:
|
||||
ctx.hint_points = args['hint_points']
|
||||
if "checked_locations" in args:
|
||||
checked = set(args["checked_locations"])
|
||||
ctx.checked_locations |= checked
|
||||
ctx.missing_locations -= checked
|
||||
if "permissions" in args:
|
||||
ctx.update_permissions(args["permissions"])
|
||||
|
||||
elif cmd == 'Print':
|
||||
ctx.on_print(args)
|
||||
@@ -388,19 +540,27 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
elif cmd == 'InvalidPacket':
|
||||
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||
|
||||
elif cmd == "Bounced":
|
||||
tags = args.get("tags", [])
|
||||
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
||||
ctx.on_package(cmd, args)
|
||||
|
||||
|
||||
async def console_loop(ctx: CommonContext):
|
||||
import sys
|
||||
commandprocessor = ctx.command_processor(ctx)
|
||||
queue = asyncio.Queue()
|
||||
stream_input(sys.stdin, queue)
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
input_text = await asyncio.get_event_loop().run_in_executor(
|
||||
None, sys.stdin.readline
|
||||
)
|
||||
input_text = input_text.strip()
|
||||
input_text = await queue.get()
|
||||
queue.task_done()
|
||||
|
||||
if ctx.input_requests > 0:
|
||||
ctx.input_requests -= 1
|
||||
@@ -410,4 +570,69 @@ async def console_loop(ctx: CommonContext):
|
||||
if input_text:
|
||||
commandprocessor(input_text)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
def get_base_parser(description=None):
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=description)
|
||||
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 sys.stdout: # If terminal output exists, offer gui-less mode
|
||||
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame"}
|
||||
game = "Archipelago"
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.games.get(self.slot, None)
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
|
||||
input_task = None
|
||||
if gui_enabled:
|
||||
from kvui import TextManager
|
||||
ctx.ui = TextManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
await ctx.shutdown()
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.")
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
|
||||
255
FF1Client.py
Normal file
255
FF1Client.py
Normal file
@@ -0,0 +1,255 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
|
||||
get_base_parser
|
||||
|
||||
SYSTEM_MESSAGE_ID = 0
|
||||
|
||||
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
|
||||
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
|
||||
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
|
||||
CONNECTION_CONNECTED_STATUS = "Connected"
|
||||
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
|
||||
class FF1CommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_nes(self):
|
||||
"""Check NES Connection State"""
|
||||
if isinstance(self.ctx, FF1Context):
|
||||
logger.info(f"NES Status: {self.ctx.nes_status}")
|
||||
|
||||
|
||||
class FF1Context(CommonContext):
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
self.nes_streams: (StreamReader, StreamWriter) = None
|
||||
self.nes_sync_task = None
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.game = 'Final Fantasy'
|
||||
self.awaiting_rom = False
|
||||
|
||||
command_processor = FF1CommandProcessor
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FF1Context, self).server_auth(password_requested)
|
||||
if not self.auth:
|
||||
self.awaiting_rom = True
|
||||
logger.info('Awaiting connection to NES to get Player information')
|
||||
return
|
||||
|
||||
await self.send_connect()
|
||||
|
||||
def _set_message(self, msg: str, msg_id: int):
|
||||
self.messages[(time.time(), msg_id)] = msg
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
self.game = self.games.get(self.slot, None)
|
||||
asyncio.create_task(parse_locations(self.locations_array, self, True))
|
||||
elif cmd == 'Print':
|
||||
msg = args['text']
|
||||
if ': !' not in msg:
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == "ReceivedItems":
|
||||
msg = f"Received {', '.join([self.item_name_getter(item.item) for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == 'PrintJSON':
|
||||
print_type = args['type']
|
||||
item = args['item']
|
||||
receiving_player_id = args['receiving']
|
||||
receiving_player_name = self.player_names[receiving_player_id]
|
||||
sending_player_id = item.player
|
||||
sending_player_name = self.player_names[item.player]
|
||||
if print_type == 'Hint':
|
||||
msg = f"Hint: Your {self.item_name_getter(item.item)} is at" \
|
||||
f" {self.player_names[item.player]}'s {self.location_name_getter(item.location)}"
|
||||
self._set_message(msg, item.item)
|
||||
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
|
||||
if sending_player_id == self.slot:
|
||||
if receiving_player_id == self.slot:
|
||||
msg = f"You found your own {self.item_name_getter(item.item)}"
|
||||
else:
|
||||
msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}"
|
||||
else:
|
||||
if receiving_player_id == sending_player_id:
|
||||
msg = f"{sending_player_name} found their {self.item_name_getter(item.item)}"
|
||||
else:
|
||||
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
|
||||
f"{receiving_player_name}"
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
|
||||
def get_payload(ctx: FF1Context):
|
||||
current_time = time.time()
|
||||
return json.dumps(
|
||||
{
|
||||
"items": [item.item for item in ctx.items_received],
|
||||
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
|
||||
if key[0] > current_time - 10}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
|
||||
if locations_array == ctx.locations_array and not force:
|
||||
return
|
||||
else:
|
||||
# print("New values")
|
||||
ctx.locations_array = locations_array
|
||||
locations_checked = []
|
||||
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": 30}
|
||||
])
|
||||
ctx.finished_game = True
|
||||
for location in ctx.missing_locations:
|
||||
# index will be - 0x100 or 0x200
|
||||
index = location
|
||||
if location < 0x200:
|
||||
# Location is a chest
|
||||
index -= 0x100
|
||||
flag = 0x04
|
||||
else:
|
||||
# Location is an NPC
|
||||
index -= 0x200
|
||||
flag = 0x02
|
||||
|
||||
# print(f"Location: {ctx.location_name_getter(location)}")
|
||||
# print(f"Index: {str(hex(index))}")
|
||||
# print(f"value: {locations_array[index] & flag != 0}")
|
||||
if locations_array[index] & flag != 0:
|
||||
locations_checked.append(location)
|
||||
if locations_checked:
|
||||
# print([ctx.location_name_getter(location) for location in locations_checked])
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
])
|
||||
|
||||
|
||||
async def nes_sync_task(ctx: FF1Context):
|
||||
logger.info("Starting nes connector. Use /nes for status information")
|
||||
while not ctx.exit_event.is_set():
|
||||
error_status = None
|
||||
if ctx.nes_streams:
|
||||
(reader, writer) = ctx.nes_streams
|
||||
msg = get_payload(ctx).encode()
|
||||
writer.write(msg)
|
||||
writer.write(b'\n')
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to two fields:
|
||||
# 1. A keepalive response of the Players Name (always)
|
||||
# 2. An array representing the memory values of the locations area (if in game)
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=5)
|
||||
data_decoded = json.loads(data.decode())
|
||||
# print(data_decoded)
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError as e:
|
||||
logger.debug("Read failed due to Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
except ConnectionResetError:
|
||||
logger.debug("Connection Lost, Reconnecting")
|
||||
error_status = CONNECTION_RESET_STATUS
|
||||
writer.close()
|
||||
ctx.nes_streams = None
|
||||
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
|
||||
if not error_status:
|
||||
logger.info("Successfully Connected to NES")
|
||||
ctx.nes_status = CONNECTION_CONNECTED_STATUS
|
||||
else:
|
||||
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
|
||||
elif error_status:
|
||||
ctx.nes_status = error_status
|
||||
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
|
||||
else:
|
||||
try:
|
||||
logger.debug("Attempting to connect to NES")
|
||||
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
|
||||
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
|
||||
except TimeoutError:
|
||||
logger.debug("Connection Timed Out, Trying Again")
|
||||
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
|
||||
continue
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||
continue
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
Utils.init_logging("FF1Client")
|
||||
|
||||
async def main(args):
|
||||
ctx = FF1Context(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
from kvui import FF1Manager
|
||||
ctx.ui = FF1Manager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
ui_task = None
|
||||
|
||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.nes_sync_task:
|
||||
await ctx.nes_sync_task
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
import colorama
|
||||
|
||||
parser = get_base_parser()
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
@@ -4,133 +4,27 @@ import logging
|
||||
import json
|
||||
import string
|
||||
import copy
|
||||
import sys
|
||||
import subprocess
|
||||
import factorio_rcon
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
|
||||
import factorio_rcon
|
||||
import colorama
|
||||
import asyncio
|
||||
from queue import Queue
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
|
||||
from MultiServer import mark_raw
|
||||
|
||||
import Utils
|
||||
import random
|
||||
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
from worlds.factorio.Technologies import lookup_id_to_name
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("FactorioClient", exception_logger="Client")
|
||||
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
|
||||
get_base_parser
|
||||
from MultiServer import mark_raw
|
||||
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
|
||||
|
||||
# Log to file in gui case
|
||||
if getattr(sys, "frozen", False) and not "--nogui" in sys.argv:
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
|
||||
filename=os.path.join("logs", "FactorioClient.txt"), filemode="w")
|
||||
else:
|
||||
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
|
||||
logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w"))
|
||||
from worlds.factorio import Factorio
|
||||
|
||||
gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
|
||||
|
||||
def get_kivy_app():
|
||||
os.environ["KIVY_NO_CONSOLELOG"] = "1"
|
||||
os.environ["KIVY_NO_FILELOG"] = "1"
|
||||
os.environ["KIVY_NO_ARGS"] = "1"
|
||||
from kivy.app import App
|
||||
from kivy.base import ExceptionHandler, ExceptionManager, Config
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.textinput import TextInput
|
||||
from kivy.uix.recycleview import RecycleView
|
||||
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
|
||||
from kivy.lang import Builder
|
||||
|
||||
|
||||
class FactorioManager(App):
|
||||
def __init__(self, ctx):
|
||||
super(FactorioManager, self).__init__()
|
||||
self.ctx = ctx
|
||||
self.commandprocessor = ctx.command_processor(ctx)
|
||||
self.icon = r"data/icon.png"
|
||||
|
||||
def build(self):
|
||||
self.grid = GridLayout()
|
||||
self.grid.cols = 1
|
||||
|
||||
self.tabs = TabbedPanel()
|
||||
self.tabs.default_tab_text = "All"
|
||||
self.title = "Archipelago Factorio Client"
|
||||
pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("FactorioServer", "Factorio Server Log"),
|
||||
("FactorioWatcher", "Bridge Data Log"),
|
||||
]
|
||||
self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs))
|
||||
for logger_name, display_name in pairs:
|
||||
bridge_logger = logging.getLogger(logger_name)
|
||||
panel = TabbedPanelItem(text=display_name)
|
||||
panel.content = UILog(bridge_logger)
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
self.grid.add_widget(self.tabs)
|
||||
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
|
||||
textinput.bind(on_text_validate=self.on_message)
|
||||
self.grid.add_widget(textinput)
|
||||
self.commandprocessor("/help")
|
||||
return self.grid
|
||||
|
||||
def on_stop(self):
|
||||
self.ctx.exit_event.set()
|
||||
|
||||
def on_message(self, textinput: TextInput):
|
||||
try:
|
||||
input_text = textinput.text.strip()
|
||||
textinput.text = ""
|
||||
|
||||
if self.ctx.input_requests > 0:
|
||||
self.ctx.input_requests -= 1
|
||||
self.ctx.input_queue.put_nowait(input_text)
|
||||
elif input_text:
|
||||
self.commandprocessor(input_text)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
|
||||
def on_address(self, text: str):
|
||||
print(text)
|
||||
|
||||
|
||||
class LogtoUI(logging.Handler):
|
||||
def __init__(self, on_log):
|
||||
super(LogtoUI, self).__init__(logging.DEBUG)
|
||||
self.on_log = on_log
|
||||
|
||||
def handle(self, record: logging.LogRecord) -> None:
|
||||
self.on_log(record)
|
||||
|
||||
|
||||
class UILog(RecycleView):
|
||||
cols = 1
|
||||
|
||||
def __init__(self, *loggers_to_handle, **kwargs):
|
||||
super(UILog, self).__init__(**kwargs)
|
||||
self.data = []
|
||||
for logger in loggers_to_handle:
|
||||
logger.addHandler(LogtoUI(self.on_log))
|
||||
|
||||
def on_log(self, record: logging.LogRecord) -> None:
|
||||
self.data.append({"text": record.getMessage()})
|
||||
|
||||
|
||||
class E(ExceptionHandler):
|
||||
def handle_exception(self, inst):
|
||||
logger.exception(inst)
|
||||
return ExceptionManager.RAISE
|
||||
|
||||
|
||||
ExceptionManager.add_handler(E())
|
||||
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Builder.load_file(Utils.local_path("data", "client.kv"))
|
||||
return FactorioManager
|
||||
|
||||
class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
ctx: FactorioContext
|
||||
@@ -139,95 +33,119 @@ class FactorioCommandProcessor(ClientCommandProcessor):
|
||||
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:
|
||||
if self.ctx.rcon_client:
|
||||
get_info(self.ctx, self.ctx.rcon_client) # retrieve current auth code
|
||||
else:
|
||||
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"
|
||||
|
||||
# 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.send_index: int = 0
|
||||
self.rcon_client = None
|
||||
self.awaiting_bridge = False
|
||||
self.raw_json_text_parser = RawJSONtoTextParser(self)
|
||||
self.write_data_path = None
|
||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(FactorioContext, self).server_auth(password_requested)
|
||||
|
||||
await self.send_msgs([{"cmd": 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': ['AP'],
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
|
||||
}])
|
||||
if self.rcon_client:
|
||||
await 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_connect()
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
super(FactorioContext, self).on_print(args)
|
||||
if self.rcon_client:
|
||||
cleaned_text = args['text'].replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
|
||||
f"{cleaned_text}\")")
|
||||
self.print_to_game(args['text'])
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
|
||||
logger.info(text)
|
||||
if self.rcon_client:
|
||||
text = self.factorio_json_text_parser(args["data"])
|
||||
cleaned_text = text.replace('"', '')
|
||||
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
|
||||
f"{cleaned_text}\")")
|
||||
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
|
||||
self.print_to_game(text)
|
||||
super(FactorioContext, self).on_print_json(args)
|
||||
|
||||
@property
|
||||
def savegame_name(self) -> str:
|
||||
return f"AP_{self.seed_name}_{self.auth}.zip"
|
||||
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
|
||||
|
||||
def print_to_game(self, text):
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if self.rcon_client:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||
super(FactorioContext, self).on_deathlink(data)
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
# catch up sync anything that is already cleared.
|
||||
if "checked_locations" in args and 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
|
||||
next_bridge = time.perf_counter() + 1
|
||||
try:
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.awaiting_bridge and ctx.rcon_client:
|
||||
if ctx.awaiting_bridge and ctx.rcon_client and time.perf_counter() > next_bridge:
|
||||
next_bridge = time.perf_counter() + 1
|
||||
ctx.awaiting_bridge = False
|
||||
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
|
||||
if data["slot_name"] != ctx.auth:
|
||||
logger.warning(f"Connected World is not the expected one {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:
|
||||
logger.warning(
|
||||
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 "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
|
||||
await ctx.update_death_link(data["death_link"])
|
||||
|
||||
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(
|
||||
bridge_logger.debug(
|
||||
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)
|
||||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
if "DeathLink" in ctx.tags:
|
||||
await ctx.send_death()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
@@ -273,26 +191,34 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
|
||||
while not factorio_queue.empty():
|
||||
msg = factorio_queue.get()
|
||||
factorio_server_logger.info(msg)
|
||||
factorio_queue.task_done()
|
||||
|
||||
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')")
|
||||
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
|
||||
factorio_server_logger.debug(msg)
|
||||
else:
|
||||
factorio_server_logger.info(msg)
|
||||
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}\t{ctx.send_index}\t{player_name}')
|
||||
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
|
||||
ctx.send_index += 1
|
||||
if commands:
|
||||
ctx.rcon_client.send_commands(commands)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
@@ -306,13 +232,16 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
factorio_process.wait(5)
|
||||
|
||||
|
||||
def get_info(ctx, rcon_client):
|
||||
async 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"]
|
||||
# 0.2.0 addition, not present earlier
|
||||
death_link = bool(info.get("death_link", False))
|
||||
await ctx.update_death_link(death_link)
|
||||
|
||||
|
||||
async def factorio_spinup_server(ctx: FactorioContext):
|
||||
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
savegame_name = os.path.abspath("Archipelago.zip")
|
||||
if not os.path.exists(savegame_name):
|
||||
logger.info(f"Creating savegame {savegame_name}")
|
||||
@@ -335,55 +264,63 @@ async def factorio_spinup_server(ctx: FactorioContext):
|
||||
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(".")))
|
||||
elif "Write data path: " in msg:
|
||||
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
|
||||
if "AppData" in ctx.write_data_path:
|
||||
logger.warning("It appears your mods are loaded from Appdata, "
|
||||
"this can lead to problems with multiple Factorio instances. "
|
||||
"If this is the case, you will get a file locked error running Factorio.")
|
||||
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)
|
||||
|
||||
if ctx.mod_version == ctx.__class__.mod_version:
|
||||
raise Exception("No Archipelago mod was loaded. Aborting.")
|
||||
await get_info(ctx, rcon_client)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.error("Aborted Factorio Server Bridge")
|
||||
logger.exception(e)
|
||||
logger.error("Aborted Factorio Server Bridge")
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
logger.info(f"Got World Information from AP Mod for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||
|
||||
logger.info(
|
||||
f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
|
||||
return True
|
||||
finally:
|
||||
factorio_process.terminate()
|
||||
factorio_process.wait(5)
|
||||
return False
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = FactorioContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
input_task = None
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
ui_app = get_kivy_app()(ctx)
|
||||
ui_task = asyncio.create_task(ui_app.async_run(), name="UI")
|
||||
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
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
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")
|
||||
succesful_launch = await factorio_server_task
|
||||
if succesful_launch:
|
||||
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
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await progression_watcher
|
||||
await factorio_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 is not None:
|
||||
await ctx.server_task
|
||||
|
||||
while ctx.input_requests > 0:
|
||||
ctx.input_queue.put_nowait(None)
|
||||
ctx.input_requests -= 1
|
||||
await ctx.shutdown()
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
@@ -407,33 +344,28 @@ class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
|
||||
|
||||
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 = get_base_parser(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))
|
||||
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"]
|
||||
bin_dir = os.path.dirname(executable)
|
||||
if not os.path.exists(bin_dir):
|
||||
raise FileNotFoundError(f"Path {bin_dir} does not exist or could not be accessed.")
|
||||
if not os.path.isdir(bin_dir):
|
||||
raise FileNotFoundError(f"Path {bin_dir} is not a directory.")
|
||||
if not os.path.exists(executable):
|
||||
if os.path.exists(executable + ".exe"):
|
||||
|
||||
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.")
|
||||
|
||||
83
Fill.py
83
Fill.py
@@ -4,9 +4,8 @@ import collections
|
||||
import itertools
|
||||
|
||||
from BaseClasses import CollectionState, Location, MultiWorld
|
||||
from worlds.alttp.Items import ItemFactory
|
||||
from worlds.alttp.Regions import key_drop_data
|
||||
from worlds.generic import PlandoItem
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
|
||||
class FillError(RuntimeError):
|
||||
@@ -37,7 +36,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
|
||||
|
||||
for item_to_place in items_to_place:
|
||||
if world.accessibility[item_to_place.player] == 'none':
|
||||
if world.accessibility[item_to_place.player] == 'minimal':
|
||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) if single_player_placement else not has_beaten_game
|
||||
else:
|
||||
@@ -53,11 +52,10 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
else:
|
||||
# we filled all reachable spots. Maybe the game can be beaten anyway?
|
||||
unplaced_items.append(item_to_place)
|
||||
if world.accessibility[item_to_place.player] != 'none' and world.can_beat_game():
|
||||
if world.accessibility[item_to_place.player] != 'minimal' and world.can_beat_game():
|
||||
logging.warning(
|
||||
f'Not all items placed. Game beatable anyway. (Could not place {item_to_place})')
|
||||
continue
|
||||
|
||||
raise FillError(f'No more spots to place {item_to_place}, locations {locations} are invalid. '
|
||||
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
|
||||
|
||||
@@ -69,7 +67,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
|
||||
def distribute_items_restrictive(world: MultiWorld, 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()
|
||||
@@ -80,6 +78,7 @@ def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_lo
|
||||
progitempool = []
|
||||
nonexcludeditempool = []
|
||||
localrestitempool = {player: [] for player in range(1, world.players + 1)}
|
||||
nonlocalrestitempool = []
|
||||
restitempool = []
|
||||
|
||||
for item in world.itempool:
|
||||
@@ -87,57 +86,16 @@ def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_lo
|
||||
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]:
|
||||
elif item.name in world.local_items[item.player].value:
|
||||
localrestitempool[item.player].append(item)
|
||||
elif item.name in world.non_local_items[item.player].value:
|
||||
nonlocalrestitempool.append(item)
|
||||
else:
|
||||
restitempool.append(item)
|
||||
|
||||
standard_keyshuffle_players = set()
|
||||
|
||||
# fill in gtower locations with trash first
|
||||
for player in world.get_game_players("A Link to the Past"):
|
||||
if not gftower_trash or not world.ganonstower_vanilla[player] or \
|
||||
world.logic[player] in {'owglitches', 'hybridglitches', "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:
|
||||
@@ -162,15 +120,20 @@ def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_lo
|
||||
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}')
|
||||
|
||||
@@ -232,7 +195,7 @@ def flood_items(world: MultiWorld):
|
||||
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
|
||||
@@ -373,6 +336,8 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
|
||||
|
||||
|
||||
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:
|
||||
@@ -383,7 +348,7 @@ def distribute_planned(world: MultiWorld):
|
||||
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)
|
||||
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
|
||||
@@ -444,4 +409,4 @@ def distribute_planned(world: MultiWorld):
|
||||
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_names[player]})") from e
|
||||
raise Exception(f"Error running plando for player {player} ({world.player_name[player]})") from e
|
||||
|
||||
416
Generate.py
416
Generate.py
@@ -12,17 +12,16 @@ import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
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
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
import Options
|
||||
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
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
categories = set(AutoWorldRegister.world_types)
|
||||
@@ -39,17 +38,16 @@ def mystery_argparse():
|
||||
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('--teams', default=1, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
|
||||
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('--lttp_rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
|
||||
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"], help="Path to the 1.0 JP SM 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),
|
||||
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
|
||||
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"')
|
||||
@@ -92,7 +90,8 @@ def main(args=None, callback=ERmain):
|
||||
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')}")
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
del(meta_weights["meta_description"])
|
||||
if args.samesettings:
|
||||
raise Exception("Cannot mix --samesettings with --meta")
|
||||
else:
|
||||
@@ -114,36 +113,24 @@ def main(args=None, callback=ERmain):
|
||||
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}")
|
||||
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
|
||||
f"{', '.join(args.plando)}")
|
||||
|
||||
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.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery
|
||||
erargs.create_spoiler = args.spoiler > 0
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
erargs.skip_playthrough = args.spoiler < 2
|
||||
erargs.outputname = seed_name
|
||||
erargs.outputpath = args.outputpath
|
||||
erargs.teams = args.teams
|
||||
|
||||
# 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]
|
||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
||||
|
||||
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.lttp_rom = args.lttp_rom
|
||||
erargs.sm_rom = args.sm_rom
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||
@@ -153,17 +140,17 @@ def main(args=None, callback=ERmain):
|
||||
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
|
||||
for category_name, category_dict in meta_weights.items():
|
||||
for key in category_dict:
|
||||
option = get_choice(key, category_dict)
|
||||
if option is not None:
|
||||
for player, path in player_path_cache.items():
|
||||
if category_name is None:
|
||||
weights_cache[path][key] = option
|
||||
elif category_name not in weights_cache[path]:
|
||||
raise Exception(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
weights_cache[path][category_name][key] = option
|
||||
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
@@ -179,6 +166,8 @@ def main(args=None, callback=ERmain):
|
||||
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:
|
||||
@@ -189,8 +178,9 @@ def main(args=None, callback=ERmain):
|
||||
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 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 = {}
|
||||
@@ -239,7 +229,7 @@ def convert_to_on_off(value):
|
||||
return {True: "on", False: "off"}.get(value, value)
|
||||
|
||||
|
||||
def get_choice(option, root, value=None) -> typing.Any:
|
||||
def get_choice_legacy(option, root, value=None) -> typing.Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
@@ -254,6 +244,20 @@ def get_choice(option, root, value=None) -> typing.Any:
|
||||
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 + '}'
|
||||
@@ -267,7 +271,7 @@ def handle_name(name: str, player: int, name_counter: Counter):
|
||||
name] > 1 else ''),
|
||||
player=player,
|
||||
PLAYER=(player if player > 1 else '')))
|
||||
new_name = new_name.strip().replace(' ', '_')[:16]
|
||||
new_name = new_name.strip()[:16]
|
||||
if new_name == "Archipelago":
|
||||
raise Exception(f"You cannot name yourself \"{new_name}\"")
|
||||
return new_name
|
||||
@@ -347,10 +351,10 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def roll_triggers(weights: dict) -> dict:
|
||||
def roll_triggers(weights: dict, triggers: list) -> 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"]):
|
||||
weights["_Generator_Version"] = Utils.__version__
|
||||
for i, option_set in enumerate(triggers):
|
||||
try:
|
||||
currently_targeted_weights = weights
|
||||
category = option_set.get("option_category", None)
|
||||
@@ -412,12 +416,38 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
|
||||
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
if not option.supports_weighting:
|
||||
player_option = option.from_any(game_weights[option_key])
|
||||
else:
|
||||
player_option = option.from_any(get_choice(option_key, game_weights))
|
||||
setattr(ret, option_key, player_option)
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||
else:
|
||||
# verify item names existing
|
||||
if getattr(player_option, "verify_item_name", False):
|
||||
for item_name in player_option.value:
|
||||
if item_name not in AutoWorldRegister.world_types[ret.game].item_names:
|
||||
raise Exception(f"Item {item_name} from option {player_option} "
|
||||
f"is not a valid item name from {ret.game}")
|
||||
elif getattr(player_option, "verify_location_name", False):
|
||||
for location_name in player_option.value:
|
||||
if location_name not in AutoWorldRegister.world_types[ret.game].location_names:
|
||||
raise Exception(f"Location {location_name} from option {player_option} "
|
||||
f"is not a valid location name from {ret.game}")
|
||||
else:
|
||||
setattr(ret, option_key, option(option.default))
|
||||
|
||||
|
||||
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)
|
||||
weights = roll_triggers(weights, weights["triggers"])
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
@@ -438,64 +468,33 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
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)
|
||||
for option_key in Options.per_game_common_options:
|
||||
if option_key in weights:
|
||||
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
|
||||
|
||||
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.")
|
||||
if "triggers" in game_weights:
|
||||
weights = roll_triggers(weights, game_weights["triggers"])
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
inventoryweights = game_weights.get('start_inventory', {})
|
||||
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(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.")
|
||||
ret.name = get_choice('name', weights)
|
||||
for option_key, option in Options.common_options.items():
|
||||
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
|
||||
|
||||
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):
|
||||
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":
|
||||
for option_key, option in world_type.options.items():
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
handle_option(ret, game_weights, option_key, option)
|
||||
if "items" in plando_options:
|
||||
ret.plando_items = roll_item_plando(world_type, game_weights)
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if "connections" in plando_options:
|
||||
@@ -505,7 +504,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement, "both")
|
||||
get_choice("direction", placement)
|
||||
))
|
||||
elif ret.game == "A Link to the Past":
|
||||
roll_alttp_settings(ret, game_weights, plando_options)
|
||||
@@ -514,8 +513,48 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
|
||||
return ret
|
||||
|
||||
|
||||
def roll_item_plando(world_type, weights):
|
||||
plando_items = []
|
||||
|
||||
def add_plando_item(item: str, location: str):
|
||||
if item not in world_type.item_name_to_id:
|
||||
raise Exception(f"Could not plando item {item} as the item was not recognized")
|
||||
if location not in world_type.location_name_to_id:
|
||||
raise Exception(
|
||||
f"Could not plando item {item} at location {location} as the location was not recognized")
|
||||
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)
|
||||
return plando_items
|
||||
|
||||
|
||||
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
glitches_required = get_choice('glitches_required', weights)
|
||||
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'
|
||||
@@ -523,7 +562,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
|
||||
glitches_required]
|
||||
|
||||
ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp")
|
||||
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":
|
||||
@@ -531,93 +570,61 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
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')
|
||||
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('goals', weights, 'ganon')
|
||||
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('open_pyramid', weights, 'goal')
|
||||
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
|
||||
|
||||
extra_pieces = get_choice('triforce_pieces_mode', weights, 'available')
|
||||
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
|
||||
|
||||
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice('triforce_pieces_required', weights, 20))
|
||||
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('triforce_pieces_percentage', weights, 150))) / 100
|
||||
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('triforce_pieces_available', weights, 30))
|
||||
get_choice_legacy('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)))
|
||||
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('shop_shuffle', weights, '')
|
||||
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
|
||||
if not ret.shop_shuffle:
|
||||
ret.shop_shuffle = ''
|
||||
|
||||
ret.mode = get_choice("mode", weights)
|
||||
ret.retro = get_choice("retro", weights)
|
||||
ret.mode = get_choice_legacy("mode", weights)
|
||||
|
||||
ret.hints = get_choice('hints', weights)
|
||||
ret.difficulty = get_choice_legacy('item_pool', weights)
|
||||
|
||||
ret.swordless = get_choice('swordless', weights, False)
|
||||
ret.item_functionality = get_choice_legacy('item_functionality', weights)
|
||||
|
||||
ret.difficulty = get_choice('item_pool', weights)
|
||||
|
||||
ret.item_functionality = get_choice('item_functionality', weights)
|
||||
|
||||
boss_shuffle = get_choice('boss_shuffle', weights)
|
||||
boss_shuffle = get_choice_legacy('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)]
|
||||
'random': 'chaos', # to be removed
|
||||
'chaos': 'chaos',
|
||||
}[get_choice_legacy('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.enemy_health = get_choice_legacy('enemy_health', weights)
|
||||
|
||||
ret.timer = {'none': False,
|
||||
None: False,
|
||||
@@ -626,21 +633,19 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
'timed_ohko': 'timed-ohko',
|
||||
'ohko': 'ohko',
|
||||
'timed_countdown': 'timed-countdown',
|
||||
'display': 'display'}[get_choice('timer', weights, False)]
|
||||
'display': 'display'}[get_choice_legacy('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.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('dungeon_counters', weights, 'default')
|
||||
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
|
||||
|
||||
ret.progressive = convert_to_on_off(get_choice('progressive', weights, 'on'))
|
||||
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
|
||||
|
||||
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")]
|
||||
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"} \
|
||||
@@ -648,88 +653,45 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
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 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("text", placement))
|
||||
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("percentage", placement, 100)):
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
ret.plando_connections.append(PlandoConnection(
|
||||
get_choice("entrance", placement),
|
||||
get_choice("exit", placement),
|
||||
get_choice("direction", placement, "both")
|
||||
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('sprite', weights, "Link")
|
||||
ret.sprite = get_choice_legacy('sprite', weights, "Link")
|
||||
if 'random_sprite_on_event' in weights:
|
||||
randomoneventweights = weights['random_sprite_on_event']
|
||||
if get_choice('enabled', randomoneventweights, False):
|
||||
if get_choice_legacy('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 += '-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('use_weighted_sprite_pool', randomoneventweights, False)) \
|
||||
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'):
|
||||
@@ -737,20 +699,10 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
else:
|
||||
ret.sprite_pool += [key] * int(value)
|
||||
|
||||
ret.disablemusic = get_choice('disablemusic', weights, False)
|
||||
ret.triforcehud = get_choice('triforcehud', weights, 'hide_goal')
|
||||
ret.quickswap = get_choice('quickswap', weights, True)
|
||||
ret.fastmenu = get_choice('menuspeed', weights, "normal")
|
||||
ret.reduceflashing = get_choice('reduceflashing', weights, False)
|
||||
ret.heartcolor = get_choice('heartcolor', weights, "red")
|
||||
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', weights, "normal"))
|
||||
ret.ow_palettes = get_choice('ow_palettes', weights, "default")
|
||||
ret.uw_palettes = get_choice('uw_palettes', weights, "default")
|
||||
ret.hud_palettes = get_choice('hud_palettes', weights, "default")
|
||||
ret.sword_palettes = get_choice('sword_palettes', weights, "default")
|
||||
ret.shield_palettes = get_choice('shield_palettes', weights, "default")
|
||||
ret.link_palettes = get_choice('link_palettes', weights, "default")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
167
LttPAdjuster.py
167
LttPAdjuster.py
@@ -15,12 +15,12 @@ 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
|
||||
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings
|
||||
from Utils import output_path, local_path, open_file
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, open_file, get_cert_none_ssl_context, persistent_store
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
@@ -44,13 +44,14 @@ 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.
|
||||
(default: %(default)s)
|
||||
''')
|
||||
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
|
||||
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
|
||||
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
|
||||
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
|
||||
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
|
||||
@@ -100,14 +101,16 @@ 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()
|
||||
if args.update_sprites:
|
||||
run_sprite_update()
|
||||
sys.exit()
|
||||
args.music = not args.disablemusic
|
||||
# set up logger
|
||||
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
|
||||
args.loglevel]
|
||||
logging.basicConfig(format='%(message)s', level=loglevel)
|
||||
|
||||
if args.update_sprites:
|
||||
run_sprite_update()
|
||||
sys.exit()
|
||||
|
||||
if not os.path.isfile(args.rom):
|
||||
adjustGUI()
|
||||
else:
|
||||
@@ -116,7 +119,6 @@ def main():
|
||||
sys.exit(1)
|
||||
|
||||
args, path = adjust(args=args)
|
||||
from Utils import persistent_store
|
||||
if isinstance(args.sprite, Sprite):
|
||||
args.sprite = args.sprite.name
|
||||
persistent_store("adjuster", "last_settings_3", args)
|
||||
@@ -150,8 +152,9 @@ 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,
|
||||
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world)
|
||||
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,
|
||||
deathlink=args.deathlink)
|
||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||
rom.write_to_file(path)
|
||||
|
||||
@@ -195,15 +198,16 @@ 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.deathlink = bool(rom_vars.DeathLinkVar.get())
|
||||
guiargs.rom = romVar2.get()
|
||||
guiargs.baserom = romVar.get()
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
@@ -220,8 +224,6 @@ def adjustGUI():
|
||||
messagebox.showerror(title="Error while adjusting Rom", message=str(e))
|
||||
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)
|
||||
@@ -238,12 +240,16 @@ def adjustGUI():
|
||||
def run_sprite_update():
|
||||
import threading
|
||||
done = threading.Event()
|
||||
top = Tk()
|
||||
top.withdraw()
|
||||
BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
try:
|
||||
top = Tk()
|
||||
except:
|
||||
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
|
||||
else:
|
||||
top.withdraw()
|
||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.isSet():
|
||||
top.update()
|
||||
print("Done updating sprites")
|
||||
task.do_events()
|
||||
logging.info("Done updating sprites")
|
||||
|
||||
|
||||
def update_sprites(task, on_finish=None):
|
||||
@@ -251,7 +257,7 @@ def update_sprites(task, on_finish=None):
|
||||
successful = True
|
||||
sprite_dir = local_path("data", "sprites", "alttpr")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
|
||||
ctx = get_cert_none_ssl_context()
|
||||
def finished():
|
||||
task.close_window()
|
||||
if on_finish:
|
||||
@@ -259,7 +265,7 @@ def update_sprites(task, on_finish=None):
|
||||
|
||||
try:
|
||||
task.update_status("Downloading alttpr sprites list")
|
||||
with urlopen('https://alttpr.com/sprites') as response:
|
||||
with urlopen('https://alttpr.com/sprites', context=ctx) 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)
|
||||
@@ -272,26 +278,26 @@ def update_sprites(task, on_finish=None):
|
||||
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]
|
||||
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)
|
||||
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:
|
||||
with urlopen(sprite_url, context=ctx) 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 = []
|
||||
@@ -313,7 +319,7 @@ def update_sprites(task, on_finish=None):
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
|
||||
type(e).__name__, e)
|
||||
type(e).__name__, e)
|
||||
successful = False
|
||||
|
||||
for rem_task in as_completed(rem_tasks):
|
||||
@@ -324,7 +330,7 @@ def update_sprites(task, on_finish=None):
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
|
||||
type(e).__name__, e)
|
||||
type(e).__name__, e)
|
||||
successful = False
|
||||
|
||||
if successful:
|
||||
@@ -362,7 +368,7 @@ class BackgroundTask(object):
|
||||
event = self.queue.get_nowait()
|
||||
event()
|
||||
if self.running:
|
||||
#if self is no longer running self.window may no longer be valid
|
||||
# if self is no longer running self.window may no longer be valid
|
||||
self.window.update_idletasks()
|
||||
except queue.Empty:
|
||||
pass
|
||||
@@ -397,12 +403,39 @@ class BackgroundTaskProgress(BackgroundTask):
|
||||
def update_status(self, text):
|
||||
self.queue_event(lambda: self.label_var.set(text))
|
||||
|
||||
def do_events(self):
|
||||
self.parent.update()
|
||||
|
||||
# only call this in an event callback
|
||||
def close_window(self):
|
||||
self.stop()
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
class BackgroundTaskProgressNullWindow(BackgroundTask):
|
||||
def __init__(self, code_to_run, *args):
|
||||
super().__init__(None, code_to_run, *args)
|
||||
|
||||
def process_queue(self):
|
||||
try:
|
||||
while True:
|
||||
if not self.running:
|
||||
return
|
||||
event = self.queue.get_nowait()
|
||||
event()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def do_events(self):
|
||||
self.process_queue()
|
||||
|
||||
def update_status(self, text):
|
||||
self.queue_event(lambda: logging.info(text))
|
||||
|
||||
def close_window(self):
|
||||
self.stop()
|
||||
|
||||
|
||||
def get_rom_frame(parent=None):
|
||||
romFrame = Frame(parent)
|
||||
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
|
||||
@@ -411,9 +444,8 @@ def get_rom_frame(parent=None):
|
||||
|
||||
def RomSelect():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc")), ("All Files", "*")])
|
||||
import Patch
|
||||
try:
|
||||
Patch.get_base_rom_bytes(rom) # throws error on checksum fail
|
||||
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))
|
||||
@@ -421,6 +453,7 @@ def get_rom_frame(parent=None):
|
||||
romVar.set(rom)
|
||||
romSelectButton['state'] = "disabled"
|
||||
romSelectButton["text"] = "ROM verified"
|
||||
|
||||
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
|
||||
|
||||
baseRomLabel.pack(side=LEFT)
|
||||
@@ -439,22 +472,27 @@ def get_rom_options_frame(parent=None):
|
||||
romOptionsFrame.rowconfigure(i, weight=1)
|
||||
vars = Namespace()
|
||||
|
||||
vars.disableMusicVar = IntVar()
|
||||
disableMusicCheckbutton = Checkbutton(romOptionsFrame, text="Disable music", variable=vars.disableMusicVar)
|
||||
disableMusicCheckbutton.grid(row=0, column=0, sticky=E)
|
||||
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)
|
||||
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)",
|
||||
variable=vars.disableFlashingVar)
|
||||
disableFlashingCheckbutton.grid(row=6, column=0, sticky=W)
|
||||
|
||||
vars.DeathLinkVar = IntVar(value=0)
|
||||
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
|
||||
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
|
||||
|
||||
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):
|
||||
@@ -485,14 +523,15 @@ def get_rom_options_frame(parent=None):
|
||||
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
|
||||
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
|
||||
|
||||
fastMenuFrame = Frame(romOptionsFrame)
|
||||
fastMenuFrame.grid(row=1, column=1, sticky=E)
|
||||
fastMenuLabel = Label(fastMenuFrame, text='Menu speed')
|
||||
fastMenuLabel.pack(side=LEFT)
|
||||
vars.fastMenuVar = StringVar()
|
||||
vars.fastMenuVar.set('normal')
|
||||
fastMenuOptionMenu = OptionMenu(fastMenuFrame, vars.fastMenuVar, 'normal', 'instant', 'double', 'triple', 'quadruple', 'half')
|
||||
fastMenuOptionMenu.pack(side=LEFT)
|
||||
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)
|
||||
@@ -518,7 +557,8 @@ def get_rom_options_frame(parent=None):
|
||||
owPalettesLabel.pack(side=LEFT)
|
||||
vars.owPalettesVar = StringVar()
|
||||
vars.owPalettesVar.set('default')
|
||||
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale',
|
||||
'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
owPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
uwPalettesFrame = Frame(romOptionsFrame)
|
||||
@@ -527,7 +567,8 @@ def get_rom_options_frame(parent=None):
|
||||
uwPalettesLabel.pack(side=LEFT)
|
||||
vars.uwPalettesVar = StringVar()
|
||||
vars.uwPalettesVar.set('default')
|
||||
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale',
|
||||
'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
uwPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
hudPalettesFrame = Frame(romOptionsFrame)
|
||||
@@ -536,7 +577,8 @@ def get_rom_options_frame(parent=None):
|
||||
hudPalettesLabel.pack(side=LEFT)
|
||||
vars.hudPalettesVar = StringVar()
|
||||
vars.hudPalettesVar.set('default')
|
||||
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout',
|
||||
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
hudPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
swordPalettesFrame = Frame(romOptionsFrame)
|
||||
@@ -545,7 +587,8 @@ def get_rom_options_frame(parent=None):
|
||||
swordPalettesLabel.pack(side=LEFT)
|
||||
vars.swordPalettesVar = StringVar()
|
||||
vars.swordPalettesVar.set('default')
|
||||
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout',
|
||||
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
swordPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
shieldPalettesFrame = Frame(romOptionsFrame)
|
||||
@@ -554,7 +597,8 @@ def get_rom_options_frame(parent=None):
|
||||
shieldPalettesLabel.pack(side=LEFT)
|
||||
vars.shieldPalettesVar = StringVar()
|
||||
vars.shieldPalettesVar.set('default')
|
||||
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'random', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout',
|
||||
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
|
||||
shieldPalettesOptionMenu.pack(side=LEFT)
|
||||
|
||||
spritePoolFrame = Frame(romOptionsFrame)
|
||||
@@ -563,6 +607,7 @@ def get_rom_options_frame(parent=None):
|
||||
|
||||
vars.spritePoolCountVar = StringVar()
|
||||
vars.sprite_pool = []
|
||||
|
||||
def set_sprite_pool(sprite_param):
|
||||
nonlocal vars
|
||||
operation = "add"
|
||||
@@ -632,8 +677,10 @@ class SpriteSelector():
|
||||
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.')
|
||||
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)
|
||||
|
||||
@@ -683,7 +730,8 @@ class SpriteSelector():
|
||||
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 = Checkbutton(frame, text="Random", command=self.update_random_button,
|
||||
variable=self.randomOnRandomVar)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
if adjuster:
|
||||
@@ -805,7 +853,6 @@ class SpriteSelector():
|
||||
|
||||
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")),
|
||||
@@ -819,7 +866,6 @@ class SpriteSelector():
|
||||
self.callback(None)
|
||||
self.window.destroy()
|
||||
|
||||
|
||||
def use_default_sprite(self):
|
||||
self.callback(None)
|
||||
self.window.destroy()
|
||||
@@ -923,7 +969,8 @@ def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
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[
|
||||
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)
|
||||
@@ -943,7 +990,8 @@ def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
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.
|
||||
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
|
||||
@@ -1100,5 +1148,6 @@ class ToolTips(object):
|
||||
widget.after_cancel(cls.after_id)
|
||||
cls.after_id = None
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
||||
613
Main.py
613
Main.py
@@ -1,197 +1,123 @@
|
||||
from itertools import zip_longest
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
import zlib
|
||||
import concurrent.futures
|
||||
import pickle
|
||||
import tempfile
|
||||
import zipfile
|
||||
from typing import Dict, Tuple
|
||||
from typing import Dict, Tuple, Optional
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, Region, Item
|
||||
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 worlds.alttp.Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
|
||||
from worlds.alttp.Dungeons import fill_dungeons, fill_dungeons_restrictive
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
from worlds.alttp.ItemPool import difficulties, fill_prizes
|
||||
from Utils import output_path, parse_player_names, get_options, __version__, version_tuple
|
||||
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
|
||||
import Patch
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
|
||||
def get_seed(seed=None):
|
||||
if seed is None:
|
||||
random.seed(None)
|
||||
return random.randint(0, pow(10, seeddigits) - 1)
|
||||
return seed
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
|
||||
if not baked_server_options:
|
||||
baked_server_options = get_options()["server_options"]
|
||||
if args.outputpath:
|
||||
os.makedirs(args.outputpath, exist_ok=True)
|
||||
output_path.cached_path = args.outputpath
|
||||
|
||||
start = time.perf_counter()
|
||||
|
||||
# initialize the world
|
||||
world = MultiWorld(args.multi)
|
||||
|
||||
logger = logging.getLogger('')
|
||||
world.seed = get_seed(seed)
|
||||
if args.race:
|
||||
world.secure()
|
||||
else:
|
||||
world.random.seed(world.seed)
|
||||
world.seed_name = str(args.outputname if args.outputname else world.seed)
|
||||
logger = logging.getLogger()
|
||||
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
|
||||
|
||||
world.shuffle = args.shuffle.copy()
|
||||
world.logic = args.logic.copy()
|
||||
world.mode = args.mode.copy()
|
||||
world.swordless = args.swordless.copy()
|
||||
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
|
||||
world.algorithm = args.algorithm
|
||||
world.shuffleganon = args.shuffleganon
|
||||
world.custom = args.custom
|
||||
world.customitemarray = args.customitemarray
|
||||
|
||||
world.accessibility = args.accessibility.copy()
|
||||
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.open_pyramid = args.open_pyramid.copy()
|
||||
world.boss_shuffle = args.shufflebosses.copy()
|
||||
world.enemy_shuffle = args.enemy_shuffle.copy()
|
||||
world.enemy_health = args.enemy_health.copy()
|
||||
world.enemy_damage = args.enemy_damage.copy()
|
||||
world.killable_thieves = args.killable_thieves.copy()
|
||||
world.bush_shuffle = args.bush_shuffle.copy()
|
||||
world.tile_shuffle = args.tile_shuffle.copy()
|
||||
world.beemizer = args.beemizer.copy()
|
||||
world.beemizer_total_chance = args.beemizer_total_chance.copy()
|
||||
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
|
||||
world.timer = args.timer.copy()
|
||||
world.countdown_start_time = args.countdown_start_time.copy()
|
||||
world.red_clock_time = args.red_clock_time.copy()
|
||||
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.progression_balancing = args.progression_balancing.copy()
|
||||
world.shuffle_prizes = args.shuffle_prizes.copy()
|
||||
world.sprite_pool = args.sprite_pool.copy()
|
||||
world.dark_room_logic = args.dark_room_logic.copy()
|
||||
world.plando_items = args.plando_items.copy()
|
||||
world.plando_texts = args.plando_texts.copy()
|
||||
world.plando_connections = args.plando_connections.copy()
|
||||
world.er_seeds = getattr(args, "er_seeds", {})
|
||||
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()
|
||||
world.set_options(args)
|
||||
world.player_name = args.name.copy()
|
||||
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.slot_seeds = {player: random.Random(world.random.getrandbits(64)) for player in
|
||||
range(1, world.players + 1)}
|
||||
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
# system for sharing ER layouts
|
||||
for player in world.get_game_players("A Link to the Past"):
|
||||
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:
|
||||
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"
|
||||
|
||||
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
|
||||
|
||||
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():
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | {len(cls.location_names):3} Locations")
|
||||
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}}")
|
||||
|
||||
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)
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
logger.info('')
|
||||
for player in world.get_game_players("A Link to the Past"):
|
||||
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
|
||||
|
||||
for player in world.player_ids:
|
||||
for item_name in args.startinventory[player]:
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
for item_name, count in world.start_inventory[player].value.items():
|
||||
for _ in range(count):
|
||||
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')
|
||||
|
||||
# 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']
|
||||
|
||||
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']
|
||||
# This could probably use a more elegant solution.
|
||||
elif world.keyshuffle[player] == True and world.mode[player] == "Standard":
|
||||
world.local_items[player].add("Small Key (Hyrule Castle)")
|
||||
if not world.bigkeyshuffle[player]:
|
||||
world.non_local_items[player] -= item_name_groups['Big Keys']
|
||||
world.local_items[player].value.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']
|
||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player] -= world.local_items[player]
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
|
||||
logger.info('Creating World.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
@@ -203,11 +129,14 @@ def main(args, seed=None):
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
|
||||
AutoWorld.call_all(world, "set_rules")
|
||||
|
||||
for player in world.player_ids:
|
||||
exclusion_rules(world, player, args.excluded_locations[player])
|
||||
exclusion_rules(world, player, world.exclude_locations[player].value)
|
||||
|
||||
AutoWorld.call_all(world, "generate_basic")
|
||||
|
||||
@@ -218,309 +147,199 @@ def main(args, seed=None):
|
||||
|
||||
distribute_planned(world)
|
||||
|
||||
logger.info('Placing Dungeon Prizes.')
|
||||
logger.info('Running Pre Main Fill.')
|
||||
|
||||
fill_prizes(world)
|
||||
AutoWorld.call_all(world, "pre_fill")
|
||||
|
||||
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)
|
||||
|
||||
logger.info('Fill the world.')
|
||||
logger.info(f'Filling the world with {len(world.itempool)} items.')
|
||||
|
||||
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, output_directory:str):
|
||||
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])
|
||||
|
||||
rom = LocalRom(args.rom)
|
||||
|
||||
patch_rom(world, rom, player, team, use_enemizer)
|
||||
|
||||
if use_enemizer:
|
||||
patch_enemizer(world, team, player, rom, args.enemizercli, output_directory)
|
||||
|
||||
if args.race:
|
||||
patch_race_rom(rom, world, player)
|
||||
|
||||
world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash)
|
||||
|
||||
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]
|
||||
|
||||
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])
|
||||
|
||||
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 '')
|
||||
|
||||
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
|
||||
|
||||
outfilestuffs["difficulty"], # 1
|
||||
outfilestuffs["item_functionality"], # 2
|
||||
outfilestuffs["mode"], # 3
|
||||
outfilestuffs["goal"], # 4
|
||||
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
|
||||
|
||||
outfilestuffs["shuffle"], # 6
|
||||
outfilestuffs["algorithm"], # 7
|
||||
outfilestuffs["mscb"], # 8
|
||||
|
||||
"-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 = os.path.join(output_directory, f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
|
||||
rom.write_to_file(rompath, hide_enemizer=True)
|
||||
Patch.create_patch_file(rompath, player=player, player_name=world.player_names[player][team])
|
||||
os.unlink(rompath)
|
||||
return player, team, bytes(rom.name)
|
||||
|
||||
pool = concurrent.futures.ThreadPoolExecutor()
|
||||
|
||||
output = tempfile.TemporaryDirectory()
|
||||
with output as temp_dir:
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
rom_futures = []
|
||||
output_file_futures = []
|
||||
for team in range(world.teams):
|
||||
for player in world.get_game_players("A Link to the Past"):
|
||||
rom_futures.append(pool.submit(_gen_rom, team, player, temp_dir))
|
||||
for player in world.player_ids:
|
||||
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
|
||||
with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
|
||||
check_accessibility_task = pool.submit(world.fulfills_accessibility)
|
||||
|
||||
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)
|
||||
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
|
||||
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))
|
||||
|
||||
# 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]}
|
||||
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
|
||||
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)
|
||||
|
||||
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")
|
||||
# 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]}
|
||||
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
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
|
||||
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
checks_in_area = {player: {area: list() for area in ordered_areas}
|
||||
for player in range(1, world.players + 1)}
|
||||
|
||||
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 != "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
|
||||
for player in range(1, world.players + 1):
|
||||
checks_in_area[player]["Total"] = 0
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata(roms, outputs):
|
||||
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] = world.worlds[slot].get_required_client_version()
|
||||
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 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)
|
||||
|
||||
for i, team in enumerate(parsed_names):
|
||||
for player, name in enumerate(team, 1):
|
||||
if player not in world.get_game_players("A Link to the Past"):
|
||||
connect_names[name] = (i, player)
|
||||
|
||||
for slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
|
||||
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)
|
||||
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 location.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[location.player]["Light World"].append(location.address)
|
||||
elif location.parent_region.type == RegionType.DarkWorld:
|
||||
checks_in_area[location.player]["Dark World"].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 = zlib.compress(pickle.dumps({
|
||||
"slot_data": slot_data,
|
||||
"games": games,
|
||||
"names": parsed_names,
|
||||
"connect_names": connect_names,
|
||||
"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
|
||||
}), 9)
|
||||
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
|
||||
world.get_game_players("A Link to the Past") 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
|
||||
|
||||
with open(os.path.join(temp_dir, '%s.archipelago' % outfilebase), 'wb') as f:
|
||||
f.write(bytes([1])) # version of format
|
||||
f.write(multidata)
|
||||
for future in outputs:
|
||||
future.result() # collect errors if they occured
|
||||
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
|
||||
|
||||
multidata_task = pool.submit(write_multidata, rom_futures, output_file_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
|
||||
if not args.skip_playthrough:
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata():
|
||||
import NetUtils
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
minimum_versions = {"server": (0, 1, 8), "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: [item.code for item in world_precollected]
|
||||
for player, world_precollected in world.precollected_items.items()}
|
||||
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 slot in world.player_ids:
|
||||
slot_data[slot] = world.worlds[slot].fill_slot_data()
|
||||
if world.worlds[slot].sending_visible:
|
||||
sending_visible_players.add(slot)
|
||||
|
||||
def precollect_hint(location):
|
||||
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)
|
||||
|
||||
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:
|
||||
precollect_hint(location)
|
||||
elif location.name in world.start_location_hints[location.player]:
|
||||
precollect_hint(location)
|
||||
elif location.item.name in world.start_hints[location.item.player]:
|
||||
precollect_hint(location)
|
||||
|
||||
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},
|
||||
"remote_start_inventory": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_start_inventory},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": baked_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)
|
||||
|
||||
multidata = zlib.compress(pickle.dumps(multidata), 9)
|
||||
|
||||
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
|
||||
f.write(bytes([1])) # version of format
|
||||
f.write(multidata)
|
||||
|
||||
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.")
|
||||
|
||||
# retrieve exceptions via .result() if they occured.
|
||||
multidata_task.result()
|
||||
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
|
||||
if i % 10 == 0 or i == len(output_file_futures):
|
||||
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
|
||||
future.result()
|
||||
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
if args.create_spoiler: # needs spoiler.hashes to be filled, that depend on rom_futures being done
|
||||
|
||||
if args.spoiler:
|
||||
world.spoiler.to_file(os.path.join(temp_dir, '%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(os.path.join(temp_dir, file), arcname=file.name)
|
||||
zf.write(file.path, arcname=file.name)
|
||||
|
||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||
return world
|
||||
@@ -536,7 +355,6 @@ 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
|
||||
@@ -556,7 +374,7 @@ def create_playthrough(world):
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([world.accessibility[location.item.player] != 'none' for location in sphere_candidates]):
|
||||
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
@@ -586,9 +404,9 @@ def create_playthrough(world):
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
for item in (i for i in world.precollected_items if i.advancement):
|
||||
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
world.precollected_items.remove(item)
|
||||
world.precollected_items[item.player].remove(item)
|
||||
world.state.remove(item)
|
||||
if not world.can_beat_game():
|
||||
world.push_precollected(item)
|
||||
@@ -641,21 +459,20 @@ def create_playthrough(world):
|
||||
{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"):
|
||||
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))
|
||||
# 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])}
|
||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in
|
||||
chain.from_iterable(world.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
for i, sphere in enumerate(collection_spheres):
|
||||
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
|
||||
|
||||
271
MinecraftClient.py
Normal file
271
MinecraftClient.py
Normal file
@@ -0,0 +1,271 @@
|
||||
import argparse
|
||||
import os, sys
|
||||
import re
|
||||
import atexit
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
import logging
|
||||
|
||||
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]?$")
|
||||
forge_version = "1.17.1-37.1.1"
|
||||
|
||||
|
||||
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".')
|
||||
|
||||
|
||||
# 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):
|
||||
for entry in os.scandir(mods_dir):
|
||||
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
|
||||
logging.info(f"Found AP randomizer mod: {entry.name}")
|
||||
return entry.name
|
||||
return None
|
||||
else:
|
||||
os.mkdir(mods_dir)
|
||||
logging.info(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)
|
||||
logging.info(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)
|
||||
logging.info(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)))
|
||||
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
|
||||
|
||||
|
||||
def read_apmc_file(apmc_file):
|
||||
from base64 import b64decode
|
||||
import json
|
||||
|
||||
with open(apmc_file, 'r') as f:
|
||||
data = json.loads(b64decode(f.read()))
|
||||
return data
|
||||
|
||||
|
||||
# Check mod version, download new mod from GitHub releases page if needed.
|
||||
def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
ap_randomizer = find_ap_randomizer_jar(forge_dir)
|
||||
|
||||
if apmc_file is not None:
|
||||
data = read_apmc_file(apmc_file)
|
||||
minecraft_version = data.get('minecraft_version', '')
|
||||
|
||||
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
|
||||
try:
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(apmc_file is None or minecraft_version in release['assets'][0]['name']),
|
||||
resp.json()))
|
||||
if ap_randomizer != latest_release['assets'][0]['name']:
|
||||
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
|
||||
f"{latest_release['assets'][0]['name']}")
|
||||
if ap_randomizer is not None:
|
||||
logging.info(f"Your current mod is {ap_randomizer}.")
|
||||
else:
|
||||
logging.info(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'])
|
||||
logging.info("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)
|
||||
logging.info(f"Wrote new mod file to {new_ap_mod}")
|
||||
if old_ap_mod is not None:
|
||||
os.remove(old_ap_mod)
|
||||
logging.info(f"Removed old mod file from {old_ap_mod}")
|
||||
else:
|
||||
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
|
||||
logging.error(f"Please report this issue on the Archipelago Discord server.")
|
||||
sys.exit(1)
|
||||
except StopIteration:
|
||||
logging.warning(f"No compatible mod version found for {minecraft_version}.")
|
||||
if not prompt_yes_no("Run server anyway?"):
|
||||
sys.exit(0)
|
||||
else:
|
||||
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
|
||||
logging.error(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
|
||||
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
|
||||
logging.info("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()
|
||||
logging.info(f"Set {eula_path} to true")
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# get the current JDK16
|
||||
def find_jdk_dir() -> str:
|
||||
for entry in os.listdir():
|
||||
if os.path.isdir(entry) and entry.startswith("jdk16"):
|
||||
return os.path.abspath(entry)
|
||||
|
||||
|
||||
# get the java exe location
|
||||
def find_jdk() -> str:
|
||||
jdk = find_jdk_dir()
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
return jdk_exe
|
||||
|
||||
|
||||
# Download Corretto 16 (Amazon JDK)
|
||||
def download_java():
|
||||
jdk = find_jdk_dir()
|
||||
if jdk is not None:
|
||||
print(f"Removing old JDK...")
|
||||
from shutil import rmtree
|
||||
rmtree(jdk)
|
||||
|
||||
print(f"Downloading Java...")
|
||||
jdk_url = "https://corretto.aws/downloads/latest/amazon-corretto-16-x64-windows-jdk.zip"
|
||||
resp = requests.get(jdk_url)
|
||||
if resp.status_code == 200: # OK
|
||||
print(f"Extracting...")
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
|
||||
zf.extractall()
|
||||
else:
|
||||
print(f"Error downloading Java (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)
|
||||
|
||||
|
||||
# download and install forge
|
||||
def install_forge(directory: str):
|
||||
jdk = find_jdk()
|
||||
if jdk is not None:
|
||||
print(f"Downloading Forge {forge_version}...")
|
||||
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
|
||||
resp = requests.get(forge_url)
|
||||
if resp.status_code == 200: # OK
|
||||
forge_install_jar = os.path.join(directory, "forge_install.jar")
|
||||
if not os.path.exists(directory):
|
||||
os.mkdir(directory)
|
||||
with open(forge_install_jar, 'wb') as f:
|
||||
f.write(resp.content)
|
||||
print(f"Installing Forge...")
|
||||
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar+ "\"", "--installServer", "\"" + directory + "\""])
|
||||
install_process = Popen(argstring)
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
|
||||
# Run the Forge server. Return process object
|
||||
def run_forge_server(forge_dir: str, heap_arg):
|
||||
|
||||
java_exe = find_jdk()
|
||||
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
|
||||
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, "win_args.txt")
|
||||
win_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
win_args.append(line.strip())
|
||||
|
||||
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
|
||||
logging.info(f"Running Forge server: {argstring}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(argstring)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
Utils.init_logging("MinecraftClient")
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
|
||||
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
|
||||
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
|
||||
parser.add_argument('--prerelease', default=False, action='store_true',
|
||||
help="Auto-update prerelease versions.")
|
||||
|
||||
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 args.install:
|
||||
print("Installing Java and Minecraft Forge")
|
||||
download_java()
|
||||
install_forge(forge_dir)
|
||||
sys.exit(0)
|
||||
|
||||
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, apmc_file, args.prerelease)
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, max_heap)
|
||||
server_process.wait()
|
||||
@@ -23,27 +23,44 @@ def update_command():
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
|
||||
|
||||
|
||||
def update():
|
||||
def update(yes = False, force = False):
|
||||
global update_ran
|
||||
if not update_ran:
|
||||
update_ran = True
|
||||
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:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
input(f'Requirement {requirement} is not satisfied, press enter to install it')
|
||||
update_command()
|
||||
return
|
||||
for line in requirementsfile:
|
||||
if line.startswith('https://'):
|
||||
# extract name and version from url
|
||||
url = line.split(';')[0]
|
||||
wheel = line.split('/')[-1]
|
||||
name, version, _ = wheel.split('-',2)
|
||||
line = f'{name}=={version}'
|
||||
requirements = pkg_resources.parse_requirements(line)
|
||||
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)
|
||||
|
||||
725
MultiServer.py
725
MultiServer.py
File diff suppressed because it is too large
Load Diff
114
NetUtils.py
114
NetUtils.py
@@ -1,5 +1,5 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
|
||||
import logging
|
||||
import typing
|
||||
import enum
|
||||
@@ -15,8 +15,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
|
||||
# optional
|
||||
type: str
|
||||
color: str
|
||||
# mainly for items, optional
|
||||
found: bool
|
||||
# owning player for location/item
|
||||
player: int
|
||||
|
||||
|
||||
class ClientStatus(enum.IntEnum):
|
||||
@@ -27,6 +27,25 @@ class ClientStatus(enum.IntEnum):
|
||||
CLIENT_GOAL = 30
|
||||
|
||||
|
||||
class Permission(enum.IntEnum):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
|
||||
@staticmethod
|
||||
def from_text(text: str):
|
||||
data = 0
|
||||
if "auto" in text:
|
||||
data |= 0b110
|
||||
elif "goal" in text:
|
||||
data |= 0b010
|
||||
if "enabled" in text:
|
||||
data |= 0b001
|
||||
return Permission(data)
|
||||
|
||||
|
||||
class NetworkPlayer(typing.NamedTuple):
|
||||
team: int
|
||||
slot: int
|
||||
@@ -45,7 +64,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
data = obj._asdict()
|
||||
data["class"] = obj.__class__.__name__
|
||||
return data
|
||||
if isinstance(obj, (tuple, list)):
|
||||
if isinstance(obj, (tuple, list, set)):
|
||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||
@@ -94,53 +113,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]) -> 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
|
||||
|
||||
async def disconnect(self, endpoint):
|
||||
if endpoint in self.endpoints:
|
||||
self.endpoints.remove(endpoint)
|
||||
|
||||
|
||||
class Endpoint:
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
|
||||
@@ -168,11 +140,11 @@ class HandlerMeta(type):
|
||||
break
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
if orig_init:
|
||||
orig_init(self, *args, **kwargs)
|
||||
# turn functions into bound methods
|
||||
self.handlers = {name: method.__get__(self, type(self)) for name, method in
|
||||
handlers.items()}
|
||||
if orig_init:
|
||||
orig_init(self, *args, **kwargs)
|
||||
|
||||
attrs['__init__'] = __init__
|
||||
return super(HandlerMeta, mcs).__new__(mcs, name, bases, attrs)
|
||||
@@ -198,8 +170,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return "".join(self.handle_node(section) for section in input_object)
|
||||
|
||||
def handle_node(self, node: JSONMessagePart):
|
||||
type = node.get("type", None)
|
||||
handler = self.handlers.get(type, self.handlers["text"])
|
||||
node_type = node.get("type", None)
|
||||
handler = self.handlers.get(node_type, self.handlers["text"])
|
||||
return handler(node)
|
||||
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
@@ -222,11 +194,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_item_name(self, node: JSONMessagePart):
|
||||
# todo: use a better info source
|
||||
from worlds.alttp.Items import progression_items
|
||||
node["color"] = 'green' if node.get("found", False) else 'cyan'
|
||||
if node["text"] in progression_items:
|
||||
node["color"] += ";white_bg"
|
||||
node["color"] = 'cyan'
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_item_id(self, node: JSONMessagePart):
|
||||
@@ -235,16 +203,16 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
node["color"] = 'blue_bg;white'
|
||||
node["color"] = 'green'
|
||||
return self._handle_color(node)
|
||||
|
||||
def _handle_location_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_name_getter(item_id)
|
||||
return self._handle_item_name(node)
|
||||
return self._handle_location_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
node["color"] = 'white_bg;black'
|
||||
node["color"] = 'blue'
|
||||
return self._handle_color(node)
|
||||
|
||||
|
||||
@@ -270,6 +238,14 @@ def add_json_text(parts: list, text: typing.Any, **kwargs) -> None:
|
||||
parts.append({"text": str(text), **kwargs})
|
||||
|
||||
|
||||
def add_json_item(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
|
||||
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.item_id, **kwargs})
|
||||
|
||||
|
||||
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
|
||||
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
|
||||
|
||||
|
||||
class Hint(typing.NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
@@ -294,9 +270,9 @@ class Hint(typing.NamedTuple):
|
||||
add_json_text(parts, "[Hint]: ")
|
||||
add_json_text(parts, self.receiving_player, type="player_id")
|
||||
add_json_text(parts, "'s ")
|
||||
add_json_text(parts, self.item, type="item_id", found=self.found)
|
||||
add_json_item(parts, self.item, self.receiving_player)
|
||||
add_json_text(parts, " is at ")
|
||||
add_json_text(parts, self.location, type="location_id")
|
||||
add_json_location(parts, self.location, self.finding_player)
|
||||
add_json_text(parts, " in ")
|
||||
add_json_text(parts, self.finding_player, type="player_id")
|
||||
if self.entrance:
|
||||
@@ -304,14 +280,16 @@ class Hint(typing.NamedTuple):
|
||||
add_json_text(parts, self.entrance, type="entrance_name")
|
||||
else:
|
||||
add_json_text(parts, "'s World")
|
||||
add_json_text(parts, ". ")
|
||||
if self.found:
|
||||
add_json_text(parts, ". (found)")
|
||||
add_json_text(parts, "(found)", type="color", color="green")
|
||||
else:
|
||||
add_json_text(parts, ".")
|
||||
add_json_text(parts, "(not found)", type="color", color="red")
|
||||
|
||||
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
|
||||
"receiving": self.receiving_player,
|
||||
"item": NetworkItem(self.item, self.location, self.finding_player)}
|
||||
"item": NetworkItem(self.item, self.location, self.finding_player),
|
||||
"found": self.found}
|
||||
|
||||
@property
|
||||
def local(self):
|
||||
|
||||
237
OoTAdjuster.py
Normal file
237
OoTAdjuster.py
Normal file
@@ -0,0 +1,237 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
from itertools import chain
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from Options import Choice, Range, Toggle
|
||||
from worlds.oot import OOTWorld
|
||||
from worlds.oot.Cosmetics import patch_cosmetics
|
||||
from worlds.oot.Options import cosmetic_options, sfx_options
|
||||
from worlds.oot.Rom import Rom, compress_rom_file
|
||||
from worlds.oot.N64Patch import apply_patch_file
|
||||
from Utils import local_path
|
||||
|
||||
logger = logging.getLogger('OoTAdjuster')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
parser.add_argument('--rom', default='',
|
||||
help='Path to an OoT randomized ROM to adjust.')
|
||||
parser.add_argument('--vanilla_rom', default='',
|
||||
help='Path to a vanilla OoT ROM for patching.')
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
parser.add_argument('--'+name, default=None,
|
||||
help=option.__doc__)
|
||||
parser.add_argument('--is_glitched', default=False, action='store_true',
|
||||
help='Setting this to true will enable protection on kokiri tunic colors for weirdshot.')
|
||||
parser.add_argument('--deathlink',
|
||||
help='Enable DeathLink system', action='store_true')
|
||||
|
||||
args = parser.parse_args()
|
||||
if not os.path.isfile(args.rom):
|
||||
adjustGUI()
|
||||
else:
|
||||
adjust(args)
|
||||
|
||||
def adjustGUI():
|
||||
from tkinter import Tk, LEFT, BOTTOM, TOP, E, W, \
|
||||
StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \
|
||||
OptionMenu, filedialog, messagebox, ttk
|
||||
from argparse import Namespace
|
||||
from Main import __version__ as MWVersion
|
||||
|
||||
window = tk.Tk()
|
||||
window.wm_title(f"Archipelago {MWVersion} OoT Adjuster")
|
||||
set_icon(window)
|
||||
|
||||
opts = Namespace()
|
||||
|
||||
# Select ROM
|
||||
romDialogFrame = Frame(window)
|
||||
romLabel = Label(romDialogFrame, text='Rom/patch to adjust')
|
||||
vanillaLabel = Label(romDialogFrame, text='OoT Base Rom')
|
||||
opts.rom = StringVar()
|
||||
opts.vanilla_rom = StringVar(value="The Legend of Zelda - Ocarina of Time.z64")
|
||||
romEntry = Entry(romDialogFrame, textvariable=opts.rom)
|
||||
vanillaEntry = Entry(romDialogFrame, textvariable=opts.vanilla_rom)
|
||||
|
||||
def RomSelect():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".z64", ".n64", ".apz5")), ("All Files", "*")])
|
||||
opts.rom.set(rom)
|
||||
def VanillaSelect():
|
||||
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".z64", ".n64")), ("All Files", "*")])
|
||||
opts.vanilla_rom.set(rom)
|
||||
|
||||
romSelectButton = Button(romDialogFrame, text='Select Rom', command=RomSelect)
|
||||
vanillaSelectButton = Button(romDialogFrame, text='Select Rom', command=VanillaSelect)
|
||||
romDialogFrame.pack(side=TOP, expand=True, fill=X)
|
||||
romLabel.pack(side=LEFT)
|
||||
romEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
romSelectButton.pack(side=LEFT)
|
||||
vanillaLabel.pack(side=LEFT)
|
||||
vanillaEntry.pack(side=LEFT, expand=True, fill=X)
|
||||
vanillaSelectButton.pack(side=LEFT)
|
||||
|
||||
# Cosmetic options
|
||||
romSettingsFrame = Frame(window)
|
||||
|
||||
def dropdown_option(type, option_name, row, column):
|
||||
if type == 'cosmetic':
|
||||
option = cosmetic_options[option_name]
|
||||
elif type == 'sfx':
|
||||
option = sfx_options[option_name]
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=row, column=column, sticky=E)
|
||||
optionLabel = Label(optionFrame, text=option.displayname)
|
||||
optionLabel.pack(side=LEFT)
|
||||
setattr(opts, option_name, StringVar())
|
||||
getattr(opts, option_name).set(option.name_lookup[option.default])
|
||||
optionMenu = OptionMenu(optionFrame, getattr(opts, option_name), *option.name_lookup.values())
|
||||
optionMenu.pack(side=LEFT)
|
||||
|
||||
dropdown_option('cosmetic', 'default_targeting', 0, 0)
|
||||
dropdown_option('cosmetic', 'display_dpad', 0, 1)
|
||||
dropdown_option('cosmetic', 'correct_model_colors', 0, 2)
|
||||
dropdown_option('cosmetic', 'background_music', 1, 0)
|
||||
dropdown_option('cosmetic', 'fanfares', 1, 1)
|
||||
dropdown_option('cosmetic', 'ocarina_fanfares', 1, 2)
|
||||
dropdown_option('cosmetic', 'kokiri_color', 2, 0)
|
||||
dropdown_option('cosmetic', 'goron_color', 2, 1)
|
||||
dropdown_option('cosmetic', 'zora_color', 2, 2)
|
||||
dropdown_option('cosmetic', 'silver_gauntlets_color', 3, 0)
|
||||
dropdown_option('cosmetic', 'golden_gauntlets_color', 3, 1)
|
||||
dropdown_option('cosmetic', 'mirror_shield_frame_color', 3, 2)
|
||||
dropdown_option('cosmetic', 'navi_color_default_inner', 4, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_default_outer', 4, 1)
|
||||
dropdown_option('cosmetic', 'navi_color_enemy_inner', 5, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_enemy_outer', 5, 1)
|
||||
dropdown_option('cosmetic', 'navi_color_npc_inner', 6, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_npc_outer', 6, 1)
|
||||
dropdown_option('cosmetic', 'navi_color_prop_inner', 7, 0)
|
||||
dropdown_option('cosmetic', 'navi_color_prop_outer', 7, 1)
|
||||
# sword_trail_duration, 8, 2
|
||||
dropdown_option('cosmetic', 'sword_trail_color_inner', 8, 0)
|
||||
dropdown_option('cosmetic', 'sword_trail_color_outer', 8, 1)
|
||||
dropdown_option('cosmetic', 'bombchu_trail_color_inner', 9, 0)
|
||||
dropdown_option('cosmetic', 'bombchu_trail_color_outer', 9, 1)
|
||||
dropdown_option('cosmetic', 'boomerang_trail_color_inner', 10, 0)
|
||||
dropdown_option('cosmetic', 'boomerang_trail_color_outer', 10, 1)
|
||||
dropdown_option('cosmetic', 'heart_color', 11, 0)
|
||||
dropdown_option('cosmetic', 'magic_color', 12, 0)
|
||||
dropdown_option('cosmetic', 'a_button_color', 11, 1)
|
||||
dropdown_option('cosmetic', 'b_button_color', 11, 2)
|
||||
dropdown_option('cosmetic', 'c_button_color', 12, 1)
|
||||
dropdown_option('cosmetic', 'start_button_color', 12, 2)
|
||||
|
||||
dropdown_option('sfx', 'sfx_navi_overworld', 14, 0)
|
||||
dropdown_option('sfx', 'sfx_navi_enemy', 14, 1)
|
||||
dropdown_option('sfx', 'sfx_low_hp', 14, 2)
|
||||
dropdown_option('sfx', 'sfx_menu_cursor', 15, 0)
|
||||
dropdown_option('sfx', 'sfx_menu_select', 15, 1)
|
||||
dropdown_option('sfx', 'sfx_nightfall', 15, 2)
|
||||
dropdown_option('sfx', 'sfx_horse_neigh', 16, 0)
|
||||
dropdown_option('sfx', 'sfx_hover_boots', 16, 1)
|
||||
dropdown_option('sfx', 'sfx_ocarina', 16, 2)
|
||||
|
||||
# Special cases
|
||||
# Sword trail duration is a range
|
||||
option = cosmetic_options['sword_trail_duration']
|
||||
optionFrame = Frame(romSettingsFrame)
|
||||
optionFrame.grid(row=8, column=2, sticky=E)
|
||||
optionLabel = Label(optionFrame, text=option.displayname)
|
||||
optionLabel.pack(side=LEFT)
|
||||
setattr(opts, 'sword_trail_duration', StringVar())
|
||||
getattr(opts, 'sword_trail_duration').set(option.default)
|
||||
optionMenu = OptionMenu(optionFrame, getattr(opts, 'sword_trail_duration'), *range(4, 21))
|
||||
optionMenu.pack(side=LEFT)
|
||||
|
||||
# Glitched is a checkbox
|
||||
opts.is_glitched = IntVar(value=0)
|
||||
glitched_checkbox = Checkbutton(romSettingsFrame, text="Glitched Logic?", variable=opts.is_glitched)
|
||||
glitched_checkbox.grid(row=17, column=0, sticky=W)
|
||||
|
||||
# Deathlink is a checkbox
|
||||
opts.deathlink = IntVar(value=0)
|
||||
deathlink_checkbox = Checkbutton(romSettingsFrame, text="DeathLink (Team Deaths)", variable=opts.deathlink)
|
||||
deathlink_checkbox.grid(row=17, column=1, sticky=W)
|
||||
|
||||
romSettingsFrame.pack(side=TOP)
|
||||
|
||||
def adjustRom():
|
||||
try:
|
||||
guiargs = Namespace()
|
||||
options = vars(opts)
|
||||
for o in options:
|
||||
result = options[o].get()
|
||||
if result == 'true':
|
||||
result = True
|
||||
if result == 'false':
|
||||
result = False
|
||||
setattr(guiargs, o, result)
|
||||
guiargs.sword_trail_duration = int(guiargs.sword_trail_duration)
|
||||
path = adjust(guiargs)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
messagebox.showerror(title="Error while adjusting Rom", message=str(e))
|
||||
else:
|
||||
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
|
||||
|
||||
# Adjust button
|
||||
bottomFrame = Frame(window)
|
||||
adjustButton = Button(bottomFrame, text='Adjust Rom', command=adjustRom)
|
||||
adjustButton.pack(side=BOTTOM, padx=(5, 5))
|
||||
bottomFrame.pack(side=BOTTOM, pady=(5, 5))
|
||||
|
||||
window.mainloop()
|
||||
|
||||
def set_icon(window):
|
||||
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
|
||||
window.tk.call('wm', 'iconphoto', window._w, logo)
|
||||
|
||||
def adjust(args):
|
||||
# Create a fake world and OOTWorld to use as a base
|
||||
world = MultiWorld(1)
|
||||
world.slot_seeds = {1: random}
|
||||
ootworld = OOTWorld(world, 1)
|
||||
# Set options in the fake OOTWorld
|
||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||
result = getattr(args, name, None)
|
||||
if result is None:
|
||||
if issubclass(option, Choice):
|
||||
result = option.name_lookup[option.default]
|
||||
elif issubclass(option, Range) or issubclass(option, Toggle):
|
||||
result = option.default
|
||||
else:
|
||||
raise Exception("Unsupported option type")
|
||||
setattr(ootworld, name, result)
|
||||
ootworld.logic_rules = 'glitched' if args.is_glitched else 'glitchless'
|
||||
ootworld.death_link = args.deathlink
|
||||
|
||||
if os.path.splitext(args.rom)[-1] in ['.z64', '.n64']:
|
||||
# Load up the ROM
|
||||
rom = Rom(file=args.rom, force_use=True)
|
||||
elif os.path.splitext(args.rom)[-1] == '.apz5':
|
||||
# Load vanilla ROM
|
||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||
# Patch file
|
||||
apply_patch_file(rom, args.rom)
|
||||
else:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||
# Call patch_cosmetics
|
||||
patch_cosmetics(ootworld, rom)
|
||||
rom.write_byte(rom.sym('DEATH_LINK'), args.deathlink)
|
||||
# Output new file
|
||||
path_pieces = os.path.splitext(args.rom)
|
||||
decomp_path = path_pieces[0] + '-adjusted-decomp.n64'
|
||||
comp_path = path_pieces[0] + '-adjusted.n64'
|
||||
rom.write_to_file(decomp_path)
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
os.remove(decomp_path)
|
||||
return comp_path
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
246
Options.py
246
Options.py
@@ -7,12 +7,15 @@ 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:
|
||||
if hasattr(base, "options"):
|
||||
if getattr(base, "options", None):
|
||||
options.update(base.options)
|
||||
name_lookup.update(name_lookup)
|
||||
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)
|
||||
|
||||
@@ -26,28 +29,50 @@ class AssembleOptions(type):
|
||||
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)
|
||||
|
||||
|
||||
class Option(metaclass=AssembleOptions):
|
||||
value: int
|
||||
name_lookup: typing.Dict[int, str]
|
||||
default = 0
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.get_option_name()})"
|
||||
# convert option_name_long into Name Long as displayname, otherwise name_long is the result.
|
||||
# Handled in get_option_name()
|
||||
autodisplayname = False
|
||||
|
||||
# can be weighted between selections
|
||||
supports_weighting = True
|
||||
|
||||
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
|
||||
@@ -61,6 +86,7 @@ class Toggle(Option):
|
||||
default = 0
|
||||
|
||||
def __init__(self, value: int):
|
||||
assert value == 0 or value == 1
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
@@ -95,20 +121,28 @@ 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()
|
||||
if text == "random":
|
||||
return cls(random.choice(list(cls.name_lookup)))
|
||||
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__}", '
|
||||
@@ -120,6 +154,36 @@ class Choice(Option):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, self.__class__):
|
||||
return other.value == self.value
|
||||
elif 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, self.__class__):
|
||||
return other.value != self.value
|
||||
elif 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)
|
||||
elif other is None:
|
||||
return False
|
||||
else:
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
|
||||
class Range(Option, int):
|
||||
range_start = 0
|
||||
@@ -152,8 +216,8 @@ class Range(Option, int):
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self):
|
||||
return str(self.value)
|
||||
def get_option_name(self, value):
|
||||
return str(value)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.value)
|
||||
@@ -178,9 +242,11 @@ class OptionNameSet(Option):
|
||||
|
||||
class OptionDict(Option):
|
||||
default = {}
|
||||
supports_weighting = False
|
||||
value: typing.Dict[str, typing.Any]
|
||||
|
||||
def __init__(self, value: typing.Dict[str, typing.Any]):
|
||||
self.value: typing.Dict[str, typing.Any] = value
|
||||
self.value = value
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
|
||||
@@ -189,31 +255,167 @@ class OptionDict(Option):
|
||||
else:
|
||||
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
|
||||
|
||||
def get_option_name(self):
|
||||
return str(self.value)
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(f"{key}: {v}" for key, v in value.items())
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
|
||||
|
||||
class ItemDict(OptionDict):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
|
||||
def __init__(self, value: typing.Dict[str, int]):
|
||||
if any(item_count < 1 for item_count in value.values()):
|
||||
raise Exception("Cannot have non-positive item counts.")
|
||||
super(ItemDict, self).__init__(value)
|
||||
|
||||
|
||||
class OptionList(Option):
|
||||
default = []
|
||||
supports_weighting = False
|
||||
value: list
|
||||
|
||||
def __init__(self, value: typing.List[typing.Any]):
|
||||
self.value = value or []
|
||||
super(OptionList, self).__init__()
|
||||
|
||||
@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 ", ".join(map(str, value))
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
|
||||
|
||||
class OptionSet(Option):
|
||||
default = frozenset()
|
||||
supports_weighting = False
|
||||
value: set
|
||||
|
||||
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
|
||||
self.value = set(value)
|
||||
super(OptionSet, self).__init__()
|
||||
|
||||
@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)
|
||||
elif type(data) == set:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
return ", ".join(value)
|
||||
|
||||
def __contains__(self, item):
|
||||
return item in self.value
|
||||
|
||||
|
||||
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
|
||||
|
||||
|
||||
class Accessibility(Choice):
|
||||
"""Set rules for reachability of your items/locations.
|
||||
Locations: ensure everything can be reached and acquired.
|
||||
Items: ensure all logically relevant items can be acquired.
|
||||
Minimal: ensure what is needed to reach your goal can be acquired."""
|
||||
|
||||
option_locations = 0
|
||||
option_items = 1
|
||||
option_beatable = 2
|
||||
option_minimal = 2
|
||||
alias_none = 2
|
||||
default = 1
|
||||
|
||||
|
||||
class ProgressionBalancing(DefaultOnToggle):
|
||||
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
|
||||
|
||||
|
||||
common_options = {
|
||||
"progression_balancing": ProgressionBalancing,
|
||||
"accessibility": Accessibility
|
||||
}
|
||||
|
||||
|
||||
class ItemSet(OptionSet):
|
||||
# implemented by Generate
|
||||
verify_item_name = True
|
||||
|
||||
|
||||
class LocalItems(ItemSet):
|
||||
"""Forces these items to be in their native world."""
|
||||
displayname = "Local Items"
|
||||
|
||||
|
||||
class NonLocalItems(ItemSet):
|
||||
"""Forces these items to be outside their native world."""
|
||||
displayname = "Not Local Items"
|
||||
|
||||
|
||||
class StartInventory(ItemDict):
|
||||
"""Start with these items."""
|
||||
verify_item_name = True
|
||||
displayname = "Start Inventory"
|
||||
|
||||
|
||||
class StartHints(ItemSet):
|
||||
"""Start with these item's locations prefilled into the !hint command."""
|
||||
displayname = "Start Hints"
|
||||
|
||||
|
||||
class StartLocationHints(OptionSet):
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
displayname = "Start Location Hints"
|
||||
|
||||
|
||||
class ExcludeLocations(OptionSet):
|
||||
"""Prevent these locations from having an important item"""
|
||||
displayname = "Excluded Locations"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
displayname = "Death Link"
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
"local_items": LocalItems,
|
||||
"non_local_items": NonLocalItems,
|
||||
"start_inventory": StartInventory,
|
||||
"start_hints": StartHints,
|
||||
"start_location_hints": StartLocationHints,
|
||||
"exclude_locations": ExcludeLocations
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
from worlds.alttp.Options import Logic
|
||||
import argparse
|
||||
mapshuffle = Toggle
|
||||
compassshuffle = Toggle
|
||||
|
||||
map_shuffle = Toggle
|
||||
compass_shuffle = Toggle
|
||||
keyshuffle = Toggle
|
||||
bigkeyshuffle = 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")
|
||||
@@ -223,7 +425,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)
|
||||
|
||||
113
Patch.py
113
Patch.py
@@ -1,8 +1,9 @@
|
||||
# TODO: convert this into a system like AutoWorld
|
||||
|
||||
import bsdiff4
|
||||
import yaml
|
||||
import os
|
||||
import lzma
|
||||
import hashlib
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import zipfile
|
||||
@@ -10,77 +11,89 @@ import sys
|
||||
from typing import Tuple, Optional
|
||||
|
||||
import Utils
|
||||
from worlds.alttp.Rom import JAP10HASH
|
||||
|
||||
current_patch_version = 2
|
||||
current_patch_version = 3
|
||||
|
||||
GAME_ALTTP = "A Link to the Past"
|
||||
GAME_SM = "Super Metroid"
|
||||
GAME_SOE = "Secret of Evermore"
|
||||
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
|
||||
|
||||
preferred_endings = {
|
||||
GAME_ALTTP: "apbp",
|
||||
GAME_SM: "apm3",
|
||||
GAME_SOE: "apsoe"
|
||||
}
|
||||
|
||||
|
||||
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 generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import JAP10HASH as HASH
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import JAP10HASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
|
||||
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:
|
||||
patch = yaml.dump({"meta": metadata,
|
||||
"patch": patch,
|
||||
"game": "A Link to the Past",
|
||||
"game": game,
|
||||
# minimum version of patch system expected for patching to be successful
|
||||
"compatible_version": 1,
|
||||
"compatible_version": 3,
|
||||
"version": current_patch_version,
|
||||
"base_checksum": JAP10HASH})
|
||||
"base_checksum": HASH})
|
||||
return patch.encode(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
|
||||
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
patch = bsdiff4.diff(get_base_rom_bytes(), rom)
|
||||
return generate_yaml(patch, metadata)
|
||||
patch = bsdiff4.diff(get_base_rom_data(game), rom)
|
||||
return generate_yaml(patch, metadata, game)
|
||||
|
||||
|
||||
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
|
||||
player: int = 0, player_name: str = "") -> str:
|
||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
|
||||
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
|
||||
"player_id": player,
|
||||
"player_name": player_name}
|
||||
bytes = generate_patch(load_bytes(rom_file_to_patch),
|
||||
meta)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".apbp"
|
||||
meta,
|
||||
game)
|
||||
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
|
||||
".apbp" if game == GAME_ALTTP else ".apm3")
|
||||
write_lzma(bytes, target)
|
||||
return target
|
||||
|
||||
|
||||
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
|
||||
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
|
||||
game_name = data["game"]
|
||||
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.")
|
||||
patched_data = bsdiff4.patch(get_base_rom_bytes(), data["patch"])
|
||||
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
|
||||
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
|
||||
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
|
||||
target = os.path.splitext(patch_file)[0] + ".sfc"
|
||||
return data["meta"], target, patched_data
|
||||
|
||||
|
||||
def get_base_rom_data(game: str):
|
||||
if game == GAME_ALTTP:
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == "alttp": # old version for A Link to the Past
|
||||
from worlds.alttp.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import get_base_rom_bytes
|
||||
elif game == GAME_SOE:
|
||||
file_name = Utils.get_options()["soe_options"]["rom_file"]
|
||||
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
|
||||
else:
|
||||
raise RuntimeError("Selected game for base rom not found.")
|
||||
return get_base_rom_bytes()
|
||||
|
||||
|
||||
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
data, target, patched_data = create_rom_bytes(patch_file)
|
||||
with open(target, "wb") as f:
|
||||
@@ -91,7 +104,7 @@ def create_rom_file(patch_file: str) -> Tuple[dict, str]:
|
||||
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
|
||||
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
|
||||
data["meta"]["server"] = server
|
||||
bytes = generate_yaml(data["patch"], data["meta"])
|
||||
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
|
||||
return lzma.compress(bytes)
|
||||
|
||||
|
||||
@@ -105,6 +118,14 @@ def write_lzma(data: bytes, path: str):
|
||||
f.write(data)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = Utils.get_public_ipv4()
|
||||
options = Utils.get_options()['server_options']
|
||||
@@ -136,7 +157,13 @@ if __name__ == "__main__":
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
|
||||
elif rom.endswith(".apm3"):
|
||||
print(f"Applying patch {rom}")
|
||||
data, target = create_rom_file(rom)
|
||||
print(f"Created rom {target}.")
|
||||
if 'server' in data:
|
||||
Utils.persistent_store("servers", data['hash'], data['server'])
|
||||
print(f"Host is {data['server']}")
|
||||
elif rom.endswith(".archipelago"):
|
||||
import json
|
||||
import zlib
|
||||
@@ -162,7 +189,7 @@ if __name__ == "__main__":
|
||||
|
||||
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
|
||||
data = zfr.read(zfinfo)
|
||||
if zfinfo.filename.endswith(".apbp"):
|
||||
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
|
||||
data = update_patch_data(data, server)
|
||||
with ziplock:
|
||||
zfw.writestr(zfinfo, data)
|
||||
@@ -183,4 +210,4 @@ if __name__ == "__main__":
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
input("Press enter to close.")
|
||||
input("Press enter to close.")
|
||||
15
README.md
15
README.md
@@ -6,8 +6,16 @@ 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
|
||||
* Timespinner
|
||||
* Super Metroid
|
||||
* Secret of Evermore
|
||||
* Final Fantasy
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial).
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
windows binaries.
|
||||
|
||||
@@ -29,13 +37,14 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
|
||||
## Running Archipelago
|
||||
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
|
||||
|
||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/Berserker66/MultiWorld-Utilities/wiki/Running-from-source).
|
||||
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/ArchipelagoMW/Archipelago/wiki/Running-from-source).
|
||||
|
||||
## Related Repositories
|
||||
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.
|
||||
|
||||
* [z3randomizer](https://github.com/CaitSith2/z3randomizer)
|
||||
* [z3randomizer](https://github.com/ArchipelagoMW/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.
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import argparse
|
||||
import atexit
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import base64
|
||||
import shutil
|
||||
import logging
|
||||
import asyncio
|
||||
from json import loads, dumps
|
||||
|
||||
from Utils import get_item_name_from_id
|
||||
from Utils import get_item_name_from_id, init_logging
|
||||
|
||||
exit_func = atexit.register(input, "Press enter to close.")
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
if __name__ == "__main__":
|
||||
init_logging("SNIClient", exception_logger="Client")
|
||||
|
||||
import colorama
|
||||
|
||||
from NetUtils import *
|
||||
|
||||
from worlds.alttp import Regions, Shops
|
||||
from worlds.alttp import Items
|
||||
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
|
||||
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, logger, console_loop, ClientCommandProcessor
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Patch import GAME_ALTTP, GAME_SM
|
||||
|
||||
|
||||
snes_logger = logging.getLogger("SNES")
|
||||
|
||||
from MultiServer import mark_raw
|
||||
|
||||
|
||||
class DeathState(enum.IntEnum):
|
||||
killing_player = 1
|
||||
alive = 2
|
||||
dead = 3
|
||||
|
||||
|
||||
class LttPCommandProcessor(ClientCommandProcessor):
|
||||
ctx: Context
|
||||
|
||||
def _cmd_slow_mode(self, toggle: str = ""):
|
||||
"""Toggle slow mode, which limits how fast you send / receive items."""
|
||||
if toggle:
|
||||
@@ -40,10 +53,27 @@ 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), name="SNES Connect")
|
||||
return True
|
||||
|
||||
def _cmd_snes_close(self) -> bool:
|
||||
@@ -55,6 +85,19 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
||||
else:
|
||||
return False
|
||||
|
||||
# Left here for quick re-addition for debugging.
|
||||
# def _cmd_snes_write(self, address, data):
|
||||
# """Write the specified byte (base10) to the SNES' memory address (base16)."""
|
||||
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
|
||||
# self.output("No attached SNES Device.")
|
||||
# return False
|
||||
#
|
||||
# snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
|
||||
# asyncio.create_task(snes_flush_writes(self.ctx))
|
||||
# self.output("Data Sent")
|
||||
# return True
|
||||
|
||||
|
||||
class Context(CommonContext):
|
||||
command_processor = LttPCommandProcessor
|
||||
game = "A Link to the Past"
|
||||
@@ -71,6 +114,9 @@ class Context(CommonContext):
|
||||
self.snes_recv_queue = asyncio.Queue()
|
||||
self.snes_request_lock = asyncio.Lock()
|
||||
self.snes_write_buffer = []
|
||||
self.snes_connector_lock = threading.Lock()
|
||||
self.death_state = DeathState.alive # for death link flop behaviour
|
||||
self.killing_player_task = None
|
||||
|
||||
self.awaiting_rom = False
|
||||
self.rom = None
|
||||
@@ -86,22 +132,66 @@ class Context(CommonContext):
|
||||
raise Exception('Invalid ROM detected, '
|
||||
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
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,
|
||||
'tags': get_tags(self),
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
|
||||
}])
|
||||
await self.send_connect(name=auth)
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if not self.killing_player_task or self.killing_player_task.done():
|
||||
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
|
||||
super(Context, self).on_deathlink(data)
|
||||
|
||||
async def handle_deathlink_state(self, currently_dead: bool):
|
||||
# in this state we only care about triggering a death send
|
||||
if self.death_state == DeathState.alive:
|
||||
if currently_dead:
|
||||
self.death_state = DeathState.dead
|
||||
await self.send_death()
|
||||
# in this state we care about confirming a kill, to move state to dead
|
||||
elif self.death_state == DeathState.killing_player:
|
||||
# this is being handled in deathlink_kill_player(ctx) already
|
||||
pass
|
||||
# in this state we wait until the player is alive again
|
||||
elif self.death_state == DeathState.dead:
|
||||
if not currently_dead:
|
||||
self.death_state = DeathState.alive
|
||||
|
||||
|
||||
async def deathlink_kill_player(ctx: Context):
|
||||
ctx.death_state = DeathState.killing_player
|
||||
while ctx.death_state == DeathState.killing_player and \
|
||||
ctx.snes_state == SNESState.SNES_ATTACHED:
|
||||
if ctx.game == GAME_ALTTP:
|
||||
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0373, bytes([8])) # deal 1 full heart of damage at next opportunity
|
||||
elif ctx.game == GAME_SM:
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([0, 0])) # set current health to 0
|
||||
if not ctx.death_link_allow_survive:
|
||||
snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0
|
||||
await snes_flush_writes(ctx)
|
||||
await asyncio.sleep(1)
|
||||
gamemode = None
|
||||
if ctx.game == GAME_ALTTP:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
if not gamemode or gamemode[0] in DEATH_MODES:
|
||||
ctx.death_state = DeathState.dead
|
||||
elif ctx.game == GAME_SM:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
||||
health = await snes_read(ctx, WRAM_START + 0x09C2, 2)
|
||||
if health is not None:
|
||||
health = health[0] | (health[1] << 8)
|
||||
if not gamemode or gamemode[0] in SM_DEATH_MODES or (ctx.death_link_allow_survive and health is not None and health > 0):
|
||||
ctx.death_state = DeathState.dead
|
||||
ctx.last_death_link = time.time()
|
||||
|
||||
|
||||
def color_item(item_id: int, green: bool = False) -> str:
|
||||
@@ -114,6 +204,7 @@ def color_item(item_id: int, green: bool = False) -> str:
|
||||
|
||||
SNES_RECONNECT_DELAY = 5
|
||||
|
||||
# LttP
|
||||
ROM_START = 0x000000
|
||||
WRAM_START = 0xF50000
|
||||
WRAM_SIZE = 0x20000
|
||||
@@ -124,6 +215,7 @@ ROMNAME_SIZE = 0x15
|
||||
|
||||
INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
ENDGAME_MODES = {0x19, 0x1a}
|
||||
DEATH_MODES = {0x12}
|
||||
|
||||
SAVEDATA_START = WRAM_START + 0xF000
|
||||
SAVEDATA_SIZE = 0x500
|
||||
@@ -139,6 +231,21 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
|
||||
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
|
||||
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
|
||||
|
||||
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
|
||||
|
||||
# SM
|
||||
SM_ROMNAME_START = 0x1C4F00
|
||||
|
||||
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
SM_ENDGAME_MODES = {0x26, 0x27}
|
||||
SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
|
||||
|
||||
SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes
|
||||
SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
|
||||
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
|
||||
|
||||
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
|
||||
|
||||
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
|
||||
|
||||
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
@@ -362,7 +469,7 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
|
||||
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
|
||||
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_uw.items()}
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
|
||||
|
||||
location_table_npc = {'Mushroom': 0x1000,
|
||||
'King Zora': 0x2,
|
||||
@@ -378,7 +485,7 @@ location_table_npc = {'Mushroom': 0x1000,
|
||||
'Stumpy': 0x8,
|
||||
'Bombos Tablet': 0x200}
|
||||
|
||||
location_table_npc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_npc.items()}
|
||||
location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()}
|
||||
|
||||
location_table_ow = {'Flute Spot': 0x2a,
|
||||
'Sunken Treasure': 0x3b,
|
||||
@@ -393,14 +500,15 @@ location_table_ow = {'Flute Spot': 0x2a,
|
||||
'Bumper Cave Ledge': 0x4a,
|
||||
'Floating Island': 0x5}
|
||||
|
||||
location_table_ow_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_ow.items()}
|
||||
location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()}
|
||||
|
||||
location_table_misc = {'Bottle Merchant': (0x3c9, 0x2),
|
||||
'Purple Chest': (0x3c9, 0x10),
|
||||
"Link's Uncle": (0x3c6, 0x1),
|
||||
'Hobo': (0x3c9, 0x1)}
|
||||
|
||||
location_table_misc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_misc.items()}
|
||||
location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()}
|
||||
|
||||
|
||||
class SNESState(enum.IntEnum):
|
||||
SNES_DISCONNECTED = 0
|
||||
@@ -416,23 +524,27 @@ def launch_sni(ctx: Context):
|
||||
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"):
|
||||
lower_file = file.lower()
|
||||
if (lower_file.startswith("sni.") and not lower_file.endswith(".proto")) or lower_file == "sni":
|
||||
sni_path = os.path.join(sni_path, file)
|
||||
|
||||
if os.path.isfile(sni_path):
|
||||
logger.info(f"Attempting to start {sni_path}")
|
||||
import subprocess
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
snes_logger.info(f"Attempting to start {sni_path}")
|
||||
import sys
|
||||
if not sys.stdout: # 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(
|
||||
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 SNI at %s ..." % address)
|
||||
snes_logger.info("Connecting to SNI at %s ..." % address)
|
||||
seen_problems = set()
|
||||
succesful = False
|
||||
while not succesful:
|
||||
@@ -444,7 +556,7 @@ 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 SNI ({problem})")
|
||||
snes_logger.error(f"Error connecting to SNI ({problem})")
|
||||
|
||||
if len(seen_problems) == 1:
|
||||
# this is the first problem. Let's try launching SNI if it isn't already running
|
||||
@@ -467,24 +579,38 @@ 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. Please connect a SNES device to SNI.')
|
||||
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))
|
||||
reply = loads(await socket.recv())
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
|
||||
|
||||
|
||||
await verify_snes_app(socket)
|
||||
await socket.close()
|
||||
return devices
|
||||
|
||||
|
||||
async def snes_connect(ctx: Context, address):
|
||||
async def verify_snes_app(socket):
|
||||
AppVersion_Request = {
|
||||
"Opcode": "AppVersion",
|
||||
}
|
||||
await socket.send(dumps(AppVersion_Request))
|
||||
|
||||
app: str = loads(await socket.recv())["Results"][0]
|
||||
if "SNI" not in app:
|
||||
snes_logger.warning(f"Warning: Did not find SNI as the endpoint, instead {app} was found.")
|
||||
|
||||
|
||||
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)
|
||||
@@ -493,19 +619,34 @@ 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",
|
||||
@@ -518,6 +659,7 @@ async def snes_connect(ctx: Context, address):
|
||||
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:
|
||||
@@ -530,9 +672,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
|
||||
|
||||
@@ -545,11 +687,6 @@ async def snes_disconnect(ctx: Context):
|
||||
|
||||
|
||||
async def snes_autoreconnect(ctx: Context):
|
||||
# unfortunately currently broken. See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1033
|
||||
# with prompt_toolkit.shortcuts.ProgressBar() as pb:
|
||||
# for _ in pb(range(100)):
|
||||
# await asyncio.sleep(RECONNECT_DELAY/100)
|
||||
|
||||
await asyncio.sleep(SNES_RECONNECT_DELAY)
|
||||
if ctx.snes_reconnect_address and ctx.snes_socket is None:
|
||||
await snes_connect(ctx, ctx.snes_reconnect_address)
|
||||
@@ -559,11 +696,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:
|
||||
@@ -576,7 +713,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))
|
||||
|
||||
|
||||
@@ -605,10 +742,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('Communication Failure with SNI')
|
||||
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
|
||||
@@ -634,7 +771,7 @@ async def snes_write(ctx: Context, write_list):
|
||||
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}")
|
||||
snes_logger.warning(f"Could not send data to SNES: {data}")
|
||||
except websockets.ConnectionClosed:
|
||||
return False
|
||||
|
||||
@@ -660,12 +797,6 @@ async def snes_flush_writes(ctx: Context):
|
||||
await snes_write(ctx, writes)
|
||||
|
||||
|
||||
# kept as function for easier wrapping by plugins
|
||||
def get_tags(ctx: Context):
|
||||
tags = ['AP']
|
||||
return tags
|
||||
|
||||
|
||||
async def track_locations(ctx: Context, roomid, roomdata):
|
||||
new_locations = []
|
||||
|
||||
@@ -673,7 +804,8 @@ 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:
|
||||
@@ -682,7 +814,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
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:
|
||||
@@ -691,7 +823,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
|
||||
@@ -743,7 +875,6 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
|
||||
|
||||
if new_locations:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
|
||||
@@ -760,11 +891,25 @@ async def game_watcher(ctx: Context):
|
||||
|
||||
if not ctx.rom:
|
||||
ctx.finished_game = False
|
||||
rom = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE)
|
||||
ctx.death_link_allow_survive = False
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
|
||||
if game_name is None:
|
||||
continue
|
||||
elif game_name == b"SM":
|
||||
ctx.game = GAME_SM
|
||||
else:
|
||||
ctx.game = GAME_ALTTP
|
||||
|
||||
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
|
||||
continue
|
||||
|
||||
ctx.rom = rom
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
|
||||
SM_DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
@@ -774,67 +919,134 @@ 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)
|
||||
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
|
||||
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
|
||||
if gamemode is None or gameend is None or game_timer is None or \
|
||||
(gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES):
|
||||
continue
|
||||
if ctx.game == GAME_ALTTP:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
||||
currently_dead = gamemode[0] in DEATH_MODES
|
||||
await ctx.handle_deathlink_state(currently_dead)
|
||||
|
||||
delay = 7 if ctx.slow_mode else 2
|
||||
if gameend[0]:
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if time.perf_counter() - perf_counter < delay:
|
||||
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
|
||||
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
|
||||
if gamemode is None or gameend is None or game_timer is None or \
|
||||
(gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES):
|
||||
continue
|
||||
|
||||
delay = 7 if ctx.slow_mode else 2
|
||||
if gameend[0]:
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if time.perf_counter() - perf_counter < delay:
|
||||
continue
|
||||
else:
|
||||
perf_counter = time.perf_counter()
|
||||
else:
|
||||
perf_counter = time.perf_counter()
|
||||
else:
|
||||
game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24)
|
||||
if abs(game_timer - prev_game_timer) < (delay * 60):
|
||||
game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24)
|
||||
if abs(game_timer - prev_game_timer) < (delay * 60):
|
||||
continue
|
||||
else:
|
||||
prev_game_timer = game_timer
|
||||
|
||||
if gamemode in ENDGAME_MODES: # triforce room and credits
|
||||
continue
|
||||
else:
|
||||
prev_game_timer = game_timer
|
||||
|
||||
if gamemode in ENDGAME_MODES: # triforce room and credits
|
||||
continue
|
||||
data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8)
|
||||
if data is None:
|
||||
continue
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2]
|
||||
roomid = data[4] | (data[5] << 8)
|
||||
roomdata = data[6]
|
||||
scout_location = data[7]
|
||||
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2]
|
||||
roomid = data[4] | (data[5] << 8)
|
||||
roomdata = data[6]
|
||||
scout_location = data[7]
|
||||
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, len(ctx.items_received)))
|
||||
|
||||
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, 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([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0]))
|
||||
if scout_location > 0 and scout_location in ctx.locations_info:
|
||||
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
|
||||
bytes([scout_location]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
|
||||
bytes([ctx.locations_info[scout_location][0]]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
|
||||
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location][1])]))
|
||||
|
||||
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]))
|
||||
if scout_location > 0 and scout_location in ctx.locations_info:
|
||||
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, bytes([scout_location]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, bytes([ctx.locations_info[scout_location][0]]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, bytes([ctx.locations_info[scout_location][1]]))
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
if scout_location > 0 and scout_location not in ctx.locations_scouted:
|
||||
ctx.locations_scouted.add(scout_location)
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
||||
await track_locations(ctx, roomid, roomdata)
|
||||
elif ctx.game == GAME_SM:
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
|
||||
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
||||
currently_dead = gamemode[0] in SM_DEATH_MODES
|
||||
await ctx.handle_deathlink_state(currently_dead)
|
||||
if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES:
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
continue
|
||||
|
||||
if scout_location > 0 and scout_location not in ctx.locations_scouted:
|
||||
ctx.locations_scouted.add(scout_location)
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
||||
await track_locations(ctx, roomid, roomdata)
|
||||
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x680, 4)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2] | (data[3] << 8)
|
||||
|
||||
while (recv_index < recv_item):
|
||||
itemAdress = recv_index * 8
|
||||
message = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
|
||||
# worldId = message[0] | (message[1] << 8) # unused
|
||||
# itemId = message[2] | (message[3] << 8) # unused
|
||||
itemIndex = (message[4] | (message[5] << 8)) >> 3
|
||||
|
||||
recv_index += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
|
||||
from worlds.sm.Locations import locations_start_id
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
|
||||
|
||||
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
# recv_itemOutPtr = data[0] | (data[1] << 8) # unused
|
||||
itemOutPtr = data[2] | (data[3] << 8)
|
||||
|
||||
from worlds.sm.Items import items_start_id
|
||||
if itemOutPtr < len(ctx.items_received):
|
||||
item = ctx.items_received[itemOutPtr]
|
||||
itemId = item.item - items_start_id
|
||||
|
||||
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
|
||||
itemOutPtr += 1
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
|
||||
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), itemOutPtr, len(ctx.items_received)))
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
@@ -846,61 +1058,75 @@ async def run_game(romfile):
|
||||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = argparse.ArgumentParser()
|
||||
parser = get_base_parser()
|
||||
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 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.')
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logging.info("Patch file was supplied. Creating sfc rom..")
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
args.connect = meta["server"]
|
||||
if "server" in meta:
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile)
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(adjustedromfile, romfile)
|
||||
adjustedromfile = romfile
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||
if args.diff_file.endswith(".apsoe"):
|
||||
import webbrowser
|
||||
webbrowser.open("http://www.evermizer.com/apclient/" +
|
||||
(f"#server={meta['server']}" if "server" in meta else ""))
|
||||
logging.info("Starting Evermizer Client in your Browser...")
|
||||
import time
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
elif args.diff_file.endswith((".apbp", "apz3")):
|
||||
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(adjustedromfile, romfile)
|
||||
adjustedromfile = romfile
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||
else:
|
||||
asyncio.create_task(run_game(romfile))
|
||||
|
||||
ctx = Context(args.snes, args.connect, args.password)
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
asyncio.create_task(snes_connect(ctx, ctx.snes_address))
|
||||
input_task = None
|
||||
if gui_enabled:
|
||||
from kvui import SNIManager
|
||||
ctx.ui = SNIManager(ctx)
|
||||
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
|
||||
else:
|
||||
ui_task = None
|
||||
if sys.stdin:
|
||||
input_task = asyncio.create_task(console_loop(ctx), name="Input")
|
||||
|
||||
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
|
||||
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
ctx.server_address = None
|
||||
ctx.snes_reconnect_address = None
|
||||
|
||||
await watcher_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
|
||||
|
||||
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
|
||||
await ctx.snes_socket.close()
|
||||
if snes_connect_task:
|
||||
snes_connect_task.cancel()
|
||||
await watcher_task
|
||||
await ctx.shutdown()
|
||||
|
||||
while ctx.input_requests > 0:
|
||||
ctx.input_queue.put_nowait(None)
|
||||
ctx.input_requests -= 1
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
await input_task
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
@@ -909,4 +1135,3 @@ if __name__ == '__main__':
|
||||
loop.run_until_complete(main())
|
||||
loop.close()
|
||||
colorama.deinit()
|
||||
atexit.unregister(exit_func)
|
||||
190
Utils.py
190
Utils.py
@@ -1,9 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import builtins
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import pickle
|
||||
import functools
|
||||
import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> typing.Tuple[int, ...]:
|
||||
def tuplize_version(version: str) -> Version:
|
||||
return Version(*(int(piece, 10) for piece in version.split(".")))
|
||||
|
||||
|
||||
@@ -13,18 +23,9 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.2.2"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
import builtins
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import pickle
|
||||
import functools
|
||||
import io
|
||||
import collections
|
||||
|
||||
from yaml import load, dump, safe_load
|
||||
|
||||
try:
|
||||
@@ -51,24 +52,6 @@ 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)):
|
||||
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)
|
||||
|
||||
names = names[players:]
|
||||
return ret
|
||||
|
||||
|
||||
def cache_argsless(function):
|
||||
if function.__code__.co_argcount:
|
||||
raise Exception("Can only cache 0 argument functions with this cache.")
|
||||
@@ -137,35 +120,46 @@ def open_file(filename):
|
||||
parse_yaml = safe_load
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
||||
|
||||
|
||||
def get_cert_none_ssl_context():
|
||||
import ssl
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_public_ipv4() -> str:
|
||||
import socket
|
||||
import urllib.request
|
||||
import logging
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
try:
|
||||
ip = urllib.request.urlopen('https://checkip.amazonaws.com/').read().decode('utf8').strip()
|
||||
ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
|
||||
except Exception as e:
|
||||
try:
|
||||
ip = urllib.request.urlopen('https://v4.ident.me').read().decode('utf8').strip()
|
||||
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
|
||||
except:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, so no point in erroring out
|
||||
return ip
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_public_ipv6() -> str:
|
||||
import socket
|
||||
import urllib.request
|
||||
import logging
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
ctx = get_cert_none_ssl_context()
|
||||
try:
|
||||
ip = urllib.request.urlopen('https://v6.ident.me').read().decode('utf8').strip()
|
||||
ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
pass # we could be offline, in a local game, or ipv6 may not be available
|
||||
return ip
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_default_options() -> dict:
|
||||
# Refer to host.yaml for comments as to what all these options mean.
|
||||
@@ -176,6 +170,14 @@ def get_default_options() -> dict:
|
||||
"factorio_options": {
|
||||
"executable": "factorio\\bin\\x64\\factorio",
|
||||
},
|
||||
"sm_options": {
|
||||
"rom_file": "Super Metroid (JU).sfc",
|
||||
"sni": "SNI",
|
||||
"rom_start": True,
|
||||
},
|
||||
"soe_options": {
|
||||
"rom_file": "Secret of Evermore (USA).sfc",
|
||||
},
|
||||
"lttp_options": {
|
||||
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
|
||||
"sni": "SNI",
|
||||
@@ -195,6 +197,7 @@ def get_default_options() -> dict:
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 10,
|
||||
"forfeit_mode": "goal",
|
||||
"collect_mode": "disabled",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
"compatibility": 2,
|
||||
@@ -211,39 +214,38 @@ def get_default_options() -> dict:
|
||||
"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",
|
||||
}
|
||||
}
|
||||
|
||||
return 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"}
|
||||
|
||||
|
||||
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
|
||||
import logging
|
||||
for key, value in src.items():
|
||||
new_keys = keys.copy()
|
||||
new_keys.append(key)
|
||||
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:
|
||||
dest[key] = update_options(value, dest[key], filename, new_keys)
|
||||
return dest
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_options() -> dict:
|
||||
if not hasattr(get_options, "options"):
|
||||
@@ -292,7 +294,6 @@ def persistent_load() -> typing.Dict[dict]:
|
||||
with open(path, "r") as f:
|
||||
storage = unsafe_parse_yaml(f.read())
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.debug(f"Could not read store: {e}")
|
||||
if storage is None:
|
||||
storage = {}
|
||||
@@ -300,7 +301,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:
|
||||
@@ -308,11 +309,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"):
|
||||
@@ -325,11 +326,12 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
|
||||
if sprite_pool:
|
||||
printed_options["sprite_pool"] = sprite_pool
|
||||
|
||||
|
||||
if hasattr(get_adjuster_settings, "adjust_wanted"):
|
||||
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"
|
||||
@@ -350,7 +352,6 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
|
||||
return romfile, False
|
||||
else:
|
||||
adjusted = False
|
||||
import logging
|
||||
if not hasattr(get_adjuster_settings, "adjust_wanted"):
|
||||
logging.info(f"Skipping post-patch adjustment")
|
||||
get_adjuster_settings.adjuster_settings = adjuster_settings
|
||||
@@ -358,6 +359,7 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
|
||||
return romfile, adjusted
|
||||
return romfile, False
|
||||
|
||||
|
||||
@cache_argsless
|
||||
def get_unique_identifier():
|
||||
uuid = persistent_load().get("client", {}).get("uuid", None)
|
||||
@@ -377,16 +379,28 @@ safe_builtins = {
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
|
||||
self.options_module = importlib.import_module("Options")
|
||||
self.net_utils_module = importlib.import_module("NetUtils")
|
||||
self.generic_properties_module = importlib.import_module("worlds.generic")
|
||||
|
||||
def find_class(self, module, name):
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by MultiServer -> savegame/multidata
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
|
||||
import NetUtils
|
||||
return getattr(NetUtils, name)
|
||||
if module == "Options":
|
||||
import Options
|
||||
obj = getattr(Options, name)
|
||||
if issubclass(obj, Options.Option):
|
||||
return getattr(self.net_utils_module, name)
|
||||
# Options and Plando are unpickled by WebHost -> Generate
|
||||
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
|
||||
return getattr(self.generic_properties_module, name)
|
||||
if module.endswith("Options"):
|
||||
if module == "Options":
|
||||
mod = self.options_module
|
||||
else:
|
||||
mod = importlib.import_module(module)
|
||||
obj = getattr(mod, name)
|
||||
if issubclass(obj, self.options_module.Option):
|
||||
return obj
|
||||
# Forbid everything else.
|
||||
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
|
||||
@@ -401,4 +415,62 @@ def restricted_loads(s):
|
||||
class KeyedDefaultDict(collections.defaultdict):
|
||||
def __missing__(self, key):
|
||||
self[key] = value = self.default_factory(key)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def get_text_between(text: str, start: str, end: str) -> str:
|
||||
return text[text.index(start) + len(start): text.rindex(end)]
|
||||
|
||||
|
||||
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
|
||||
|
||||
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = local_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
root_logger.setLevel(loglevel)
|
||||
file_handler = logging.FileHandler(
|
||||
os.path.join(log_folder, f"{name}.txt"),
|
||||
write_mode,
|
||||
encoding="utf-8-sig")
|
||||
file_handler.setFormatter(logging.Formatter(log_format))
|
||||
root_logger.addHandler(file_handler)
|
||||
if sys.stdout:
|
||||
root_logger.addHandler(
|
||||
logging.StreamHandler(sys.stdout)
|
||||
)
|
||||
|
||||
# Relay unhandled exceptions to logger.
|
||||
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
|
||||
orig_hook = sys.excepthook
|
||||
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
logging.getLogger(exception_logger).exception("Uncaught exception",
|
||||
exc_info=(exc_type, exc_value, exc_traceback))
|
||||
return orig_hook(exc_type, exc_value, exc_traceback)
|
||||
|
||||
handle_exception._wrapped = True
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
|
||||
def stream_input(stream, queue):
|
||||
def queuer():
|
||||
while 1:
|
||||
text = stream.readline().strip()
|
||||
if text:
|
||||
queue.put_nowait(text)
|
||||
|
||||
from threading import Thread
|
||||
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
10
WebHost.py
10
WebHost.py
@@ -14,7 +14,7 @@ from WebHostLib import app as raw_app
|
||||
from waitress import serve
|
||||
|
||||
from WebHostLib.models import db
|
||||
from WebHostLib.autolauncher import autohost
|
||||
from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
@@ -36,11 +36,17 @@ if __name__ == "__main__":
|
||||
multiprocessing.freeze_support()
|
||||
multiprocessing.set_start_method('spawn')
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
|
||||
update_sprites_lttp()
|
||||
try:
|
||||
update_sprites_lttp()
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.warning("Could not update LttP sprites.")
|
||||
app = get_app()
|
||||
create_options_files()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFGEN"]:
|
||||
autogen(app.config)
|
||||
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
|
||||
if app.config["DEBUG"]:
|
||||
autohost(app.config)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 *
|
||||
|
||||
@@ -21,13 +22,14 @@ Pony(app)
|
||||
app.jinja_env.filters['any'] = any
|
||||
app.jinja_env.filters['all'] = all
|
||||
|
||||
app.config["SELFHOST"] = True
|
||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["SELFLAUNCH"] = True
|
||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 # 4 megabyte limit
|
||||
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
|
||||
# if you want to deploy, make sure you have a non-guessable secret key
|
||||
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
|
||||
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
|
||||
@@ -81,50 +83,32 @@ def page_not_found(err):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
|
||||
games_list = {
|
||||
"A Link to the Past": ("The Legend of Zelda: A Link to the Past",
|
||||
"""
|
||||
The Legend of Zelda: A Link to the Past is an action/adventure game. Take on the role of
|
||||
Link, a boy who is destined to save the land of Hyrule. Delve through three palaces and nine
|
||||
dungeons on your quest to rescue the descendents of the seven wise men and defeat the evil
|
||||
Ganon!"""),
|
||||
"Factorio": ("Factorio",
|
||||
"""
|
||||
Factorio is a game about automation. You play as an engineer who has crash landed on the planet
|
||||
Nauvis, an inhospitable world filled with dangerous creatures called biters. Build a factory,
|
||||
research new technologies, and become more efficient in your quest to build a rocket and return home.
|
||||
"""),
|
||||
"Minecraft": ("Minecraft",
|
||||
"""
|
||||
Minecraft is a game about creativity. In a world made entirely of cubes, you explore, discover, mine,
|
||||
craft, and try not to explode. Delve deep into the earth and discover abandoned mines, ancient
|
||||
structures, and materials to create a portal to another world. Defeat the Ender Dragon, and claim
|
||||
victory!""")
|
||||
}
|
||||
# Start Playing Page
|
||||
@app.route('/start-playing')
|
||||
def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
# Player settings pages
|
||||
@app.route('/games/<string:game>/player-settings')
|
||||
def player_settings(game):
|
||||
return render_template(f"player-settings.html")
|
||||
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")
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
def game_info(game, lang):
|
||||
return render_template('gameInfo.html', game=game, lang=lang)
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
def games():
|
||||
return render_template("games/games.html", games_list=games_list)
|
||||
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("supportedGames.html", worlds=worlds)
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
@@ -132,18 +116,18 @@ 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('/weighted-settings')
|
||||
def weighted_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>')
|
||||
def viewSeed(seed: UUID):
|
||||
def view_seed(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
if not seed:
|
||||
abort(404)
|
||||
@@ -158,7 +142,7 @@ def new_room(seed: UUID):
|
||||
abort(404)
|
||||
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
|
||||
commit()
|
||||
return redirect(url_for("hostRoom", room=room.id))
|
||||
return redirect(url_for("host_room", room=room.id))
|
||||
|
||||
|
||||
def _read_log(path: str):
|
||||
@@ -172,29 +156,26 @@ def _read_log(path: str):
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID):
|
||||
# noinspection PyTypeChecker
|
||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
|
||||
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
def hostRoom(room: UUID):
|
||||
def host_room(room: UUID):
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if request.method == "POST":
|
||||
if room.owner == session["_id"]:
|
||||
cmd = request.form["cmd"]
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
if cmd:
|
||||
Command(room=room, commandtext=cmd)
|
||||
commit()
|
||||
|
||||
with db_session:
|
||||
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
|
||||
|
||||
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():
|
||||
@@ -202,6 +183,11 @@ def favicon():
|
||||
'favicon.ico', mimetype='image/vnd.microsoft.icon')
|
||||
|
||||
|
||||
@app.route('/discord')
|
||||
def discord():
|
||||
return redirect("https://discord.gg/archipelago")
|
||||
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from pony.orm import commit
|
||||
|
||||
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
|
||||
from WebHostLib.check import get_yaml_data, roll_options
|
||||
from WebHostLib.generate import get_meta
|
||||
|
||||
|
||||
@api_endpoints.route('/generate', methods=['POST'])
|
||||
@@ -16,7 +17,7 @@ def generate_api():
|
||||
try:
|
||||
options = {}
|
||||
race = False
|
||||
|
||||
meta_options_source = {}
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
@@ -24,14 +25,17 @@ def generate_api():
|
||||
return {"text": options}, 400
|
||||
if "race" in request.form:
|
||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||
meta_options_source = request.form
|
||||
|
||||
json_data = request.get_json()
|
||||
if json_data:
|
||||
meta_options_source = json_data
|
||||
if 'weights' in json_data:
|
||||
# example: options = {"player1weights" : {<weightsdata>}}
|
||||
options = json_data["weights"]
|
||||
if "race" in json_data:
|
||||
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"]))
|
||||
|
||||
if not options:
|
||||
return {"text": "No options found. Expected file attachment or json weights."
|
||||
}, 400
|
||||
@@ -39,7 +43,8 @@ def generate_api():
|
||||
if len(options) > app.config["MAX_ROLL"]:
|
||||
return {"text": "Max size of multiworld exceeded",
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
|
||||
meta = get_meta(meta_options_source)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options)
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
@@ -48,7 +53,7 @@ def generate_api():
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=json.dumps({"race": race}), state=STATE_QUEUED,
|
||||
meta=json.dumps(meta), state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
commit()
|
||||
return {"text": f"Generation of seed {gen.id} started successfully.",
|
||||
|
||||
@@ -89,14 +89,14 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
|
||||
options = restricted_loads(generation.options)
|
||||
logging.info(f"Generating {generation.id} for {len(options)} players")
|
||||
pool.apply_async(gen_game, (options,),
|
||||
{"race": meta["race"],
|
||||
{"meta": meta,
|
||||
"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
|
||||
|
||||
@@ -110,6 +110,26 @@ def autohost(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
launch_room(room, config)
|
||||
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autohost reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running, name="AP_Autohost").start()
|
||||
|
||||
|
||||
def autogen(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autogen"):
|
||||
|
||||
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
|
||||
initargs=(config["PONY"],)) as generator_pool:
|
||||
@@ -129,22 +149,17 @@ def autohost(config: dict):
|
||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||
|
||||
while 1:
|
||||
time.sleep(0.50)
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
rooms = select(
|
||||
room for room in Room if
|
||||
room.last_activity >= datetime.utcnow() - timedelta(days=3))
|
||||
for room in rooms:
|
||||
launch_room(room, config)
|
||||
to_start = select(
|
||||
generation for generation in Generation if generation.state == STATE_QUEUED)
|
||||
for generation in to_start:
|
||||
launch_generator(generator_pool, generation)
|
||||
except AlreadyRunningException:
|
||||
pass
|
||||
logging.info("Autogen reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running).start()
|
||||
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
||||
|
||||
|
||||
multiworlds = {}
|
||||
|
||||
@@ -49,9 +49,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
|
||||
elif file.filename.endswith(".yaml"):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
elif file.filename.endswith(".txt"):
|
||||
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
|
||||
options[file.filename] = zfile.open(file, "r").read()
|
||||
else:
|
||||
options = {file.filename: file.read()}
|
||||
@@ -73,7 +71,8 @@ def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[
|
||||
results[filename] = f"Failed to parse YAML data in {filename}: {e}"
|
||||
else:
|
||||
try:
|
||||
rolled_results[filename] = roll_settings(yaml_data, plando_options={"bosses"})
|
||||
rolled_results[filename] = roll_settings(yaml_data,
|
||||
plando_options={"bosses", "items", "connections", "texts"})
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
else:
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import websockets
|
||||
import asyncio
|
||||
import socket
|
||||
@@ -11,7 +10,7 @@ import time
|
||||
import random
|
||||
import pickle
|
||||
|
||||
|
||||
import Utils
|
||||
from .models import *
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
@@ -20,6 +19,7 @@ from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
|
||||
|
||||
class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
ctx: WebHostContext
|
||||
|
||||
def _cmd_video(self, platform, user):
|
||||
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
|
||||
if platform.lower().startswith("t"): # twitch
|
||||
@@ -37,6 +37,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
|
||||
|
||||
# inject
|
||||
import MultiServer
|
||||
|
||||
MultiServer.client_message_processor = CustomClientMessageProcessor
|
||||
del (MultiServer)
|
||||
|
||||
@@ -48,7 +49,7 @@ class DBCommandProcessor(ServerCommandProcessor):
|
||||
|
||||
class WebHostContext(Context):
|
||||
def __init__(self):
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", 0, 2)
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
self.tags = ["AP", "WebHost"]
|
||||
@@ -56,7 +57,7 @@ class WebHostContext(Context):
|
||||
def listen_to_db_commands(self):
|
||||
cmdprocessor = DBCommandProcessor(self)
|
||||
|
||||
while self.running:
|
||||
while not self.exit_event.is_set():
|
||||
with db_session:
|
||||
commands = select(command for command in Command if command.room.id == self.room_id)
|
||||
if commands:
|
||||
@@ -88,11 +89,11 @@ class WebHostContext(Context):
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
|
||||
@db_session
|
||||
def _save(self, exit_save:bool = False) -> bool:
|
||||
def _save(self, exit_save: bool = False) -> bool:
|
||||
room = Room.get(id=self.room_id)
|
||||
room.multisave = pickle.dumps(self.get_save())
|
||||
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
|
||||
room.last_activity = datetime.utcnow()
|
||||
return True
|
||||
|
||||
@@ -101,6 +102,7 @@ class WebHostContext(Context):
|
||||
d["video"] = [(tuple(playerslot), videodata) for playerslot, videodata in self.video.items()]
|
||||
return d
|
||||
|
||||
|
||||
def get_random_port():
|
||||
return random.randint(49152, 65535)
|
||||
|
||||
@@ -111,11 +113,7 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
async def main():
|
||||
|
||||
logging.basicConfig(format='[%(asctime)s] %(message)s',
|
||||
level=logging.INFO,
|
||||
handlers=[
|
||||
logging.FileHandler(os.path.join(LOGS_FOLDER, f"{room_id}.txt"), 'a', 'utf-8-sig')])
|
||||
Utils.init_logging(str(room_id), write_mode="a")
|
||||
ctx = WebHostContext()
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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 Patch import update_patch_data, preferred_endings
|
||||
from WebHostLib import app, Slot, Room, Seed, cache
|
||||
import zipfile
|
||||
|
||||
|
||||
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
|
||||
def download_patch(room_id, patch_id):
|
||||
patch = Slot.get(id=patch_id)
|
||||
@@ -19,7 +20,8 @@ def download_patch(room_id, patch_id):
|
||||
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
|
||||
patch_data = io.BytesIO(patch_data)
|
||||
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}.apbp"
|
||||
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
|
||||
f"{preferred_endings[patch.game]}"
|
||||
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
|
||||
|
||||
|
||||
@@ -28,27 +30,10 @@ def download_spoiler(seed_id):
|
||||
return Response(Seed.get(id=seed_id).spoiler, mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route("/dl_raw_patch/<suuid:seed_id>/<int:player_id>")
|
||||
def download_raw_patch(seed_id, player_id: int):
|
||||
seed = Seed.get(id=seed_id)
|
||||
patch = select(patch for patch in seed.slots if
|
||||
patch.player_id == player_id).first()
|
||||
|
||||
if not patch:
|
||||
return "Patch not found"
|
||||
else:
|
||||
import io
|
||||
|
||||
patch_data = update_patch_data(patch.data, server="")
|
||||
patch_data = io.BytesIO(patch_data)
|
||||
|
||||
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 +42,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)
|
||||
@@ -2,19 +2,32 @@ import os
|
||||
import tempfile
|
||||
import random
|
||||
import json
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from typing import Dict, Optional as TypeOptional
|
||||
|
||||
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 BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name
|
||||
import pickle
|
||||
|
||||
from .models import *
|
||||
from WebHostLib import app
|
||||
from .check import get_yaml_data, roll_options
|
||||
from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict) -> dict:
|
||||
meta = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||
"remaining_mode": options_source.get("forfeit_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
}
|
||||
return meta
|
||||
|
||||
|
||||
@app.route('/generate', methods=['GET', 'POST'])
|
||||
@@ -31,6 +44,14 @@ def generate(race=False):
|
||||
flash(options)
|
||||
else:
|
||||
results, gen_options = roll_options(options)
|
||||
# get form data -> server settings
|
||||
meta = get_meta(request.form)
|
||||
meta["race"] = race
|
||||
|
||||
if race:
|
||||
meta["item_cheat"] = False
|
||||
meta["remaining"] = False
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
elif len(gen_options) > app.config["MAX_ROLL"]:
|
||||
@@ -40,7 +61,8 @@ def generate(race=False):
|
||||
gen = Generation(
|
||||
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
|
||||
# convert to json compatible
|
||||
meta=json.dumps({"race": race}), state=STATE_QUEUED,
|
||||
meta=json.dumps(meta),
|
||||
state=STATE_QUEUED,
|
||||
owner=session["_id"])
|
||||
commit()
|
||||
|
||||
@@ -48,18 +70,24 @@ def generate(race=False):
|
||||
else:
|
||||
try:
|
||||
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
|
||||
race=race, owner=session["_id"].int)
|
||||
meta=meta, owner=session["_id"].int)
|
||||
except BaseException as e:
|
||||
from .autolauncher import handle_generation_failure
|
||||
handle_generation_failure(e)
|
||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": "+ str(e)))
|
||||
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
|
||||
|
||||
return redirect(url_for("viewSeed", seed=seed_id))
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
|
||||
return render_template("generate.html", race=race)
|
||||
|
||||
|
||||
def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, object] = {}
|
||||
|
||||
meta.setdefault("hint_cost", 10)
|
||||
race = meta.get("race", False)
|
||||
del (meta["race"])
|
||||
try:
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
@@ -69,14 +97,13 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
if race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
seedname = "M" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
|
||||
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwrittin in mystery
|
||||
erargs.create_spoiler = not race
|
||||
erargs.spoiler = 0 if race else 2
|
||||
erargs.race = race
|
||||
erargs.skip_playthrough = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
@@ -85,17 +112,18 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
for k, v in settings.items():
|
||||
if v is not None:
|
||||
getattr(erargs, k)[player] = v
|
||||
if hasattr(erargs, k):
|
||||
getattr(erargs, k)[player] = v
|
||||
else:
|
||||
setattr(erargs, k, {player: v})
|
||||
|
||||
if not erargs.name[player]:
|
||||
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)
|
||||
ERmain(erargs, seed, baked_server_options=meta)
|
||||
|
||||
return upload_to_db(target.name, owner, sid, race)
|
||||
return upload_to_db(target.name, sid, owner, race)
|
||||
except BaseException as e:
|
||||
if sid:
|
||||
with db_session:
|
||||
@@ -103,7 +131,7 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
|
||||
if gen is not None:
|
||||
gen.state = STATE_ERROR
|
||||
meta = json.loads(gen.meta)
|
||||
meta["error"] = (e.__class__.__name__ + ": "+ str(e))
|
||||
meta["error"] = (e.__class__.__name__ + ": " + str(e))
|
||||
gen.meta = json.dumps(meta)
|
||||
|
||||
commit()
|
||||
@@ -115,7 +143,7 @@ def wait_seed(seed: UUID):
|
||||
seed_id = seed
|
||||
seed = Seed.get(id=seed_id)
|
||||
if seed:
|
||||
return redirect(url_for("viewSeed", seed=seed_id))
|
||||
return redirect(url_for("view_seed", seed=seed_id))
|
||||
generation = Generation.get(id=seed_id)
|
||||
|
||||
if not generation:
|
||||
@@ -125,37 +153,19 @@ def wait_seed(seed: UUID):
|
||||
return render_template("waitSeed.html", seed_id=seed_id)
|
||||
|
||||
|
||||
def upload_to_db(folder, owner, sid, race:bool):
|
||||
slots = set()
|
||||
spoiler = ""
|
||||
|
||||
multidata = None
|
||||
def upload_to_db(folder, sid, owner, race):
|
||||
for file in os.listdir(folder):
|
||||
file = os.path.join(folder, file)
|
||||
if file.endswith(".apbp"):
|
||||
player_text = file.split("_P", 1)[1]
|
||||
player_name = player_text.split("_", 1)[1].split(".", 1)[0]
|
||||
player_id = int(player_text.split(".", 1)[0].split("_", 1)[0])
|
||||
slots.add(Slot(data=open(file, "rb").read(),
|
||||
player_id=player_id, player_name = player_name, game = "A Link to the Past"))
|
||||
elif file.endswith(".txt"):
|
||||
spoiler = open(file, "rt", encoding="utf-8-sig").read()
|
||||
elif file.endswith(".archipelago"):
|
||||
multidata = open(file, "rb").read()
|
||||
if multidata:
|
||||
with db_session:
|
||||
if sid:
|
||||
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
|
||||
id=sid, meta=json.dumps({"race": race, "tags": ["generated"]}))
|
||||
else:
|
||||
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
|
||||
meta=json.dumps({"race": race, "tags": ["generated"]}))
|
||||
for patch in slots:
|
||||
patch.seed = seed
|
||||
if sid:
|
||||
gen = Generation.get(id=sid)
|
||||
if gen is not None:
|
||||
gen.delete()
|
||||
return seed.id
|
||||
else:
|
||||
raise Exception("Multidata required (.archipelago), but not found.")
|
||||
if file.endswith(".zip"):
|
||||
with db_session:
|
||||
with zipfile.ZipFile(file) as zfile:
|
||||
res = upload_zip_to_db(zfile, owner, {"race": race}, sid)
|
||||
if type(res) == "str":
|
||||
raise Exception(res)
|
||||
elif res:
|
||||
seed = res
|
||||
gen = Generation.get(id=seed.id)
|
||||
if gen is not None:
|
||||
gen.delete()
|
||||
return seed.id
|
||||
raise Exception("Generation zipfile not found.")
|
||||
|
||||
@@ -10,6 +10,7 @@ def update_sprites_lttp():
|
||||
from tkinter import Tk
|
||||
from LttPAdjuster import get_image_for_sprite
|
||||
from LttPAdjuster import BackgroundTaskProgress
|
||||
from LttPAdjuster import BackgroundTaskProgressNullWindow
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# Target directories
|
||||
@@ -19,11 +20,15 @@ def update_sprites_lttp():
|
||||
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())
|
||||
try:
|
||||
top = Tk()
|
||||
except:
|
||||
task = BackgroundTaskProgressNullWindow(update_sprites, lambda successful, resultmessage: done.set())
|
||||
else:
|
||||
top.withdraw()
|
||||
task = BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
|
||||
while not done.isSet():
|
||||
top.update()
|
||||
task.do_events()
|
||||
|
||||
spriteData = []
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class Seed(db.Entity):
|
||||
creation_time = Required(datetime, default=lambda: datetime.utcnow())
|
||||
slots = Set(Slot)
|
||||
spoiler = Optional(LongStr, lazy=True)
|
||||
meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
|
||||
|
||||
|
||||
class Command(db.Entity):
|
||||
@@ -53,5 +53,5 @@ class Generation(db.Entity):
|
||||
id = PrimaryKey(UUID, default=uuid4)
|
||||
owner = Required(UUID)
|
||||
options = Required(buffer, lazy=True)
|
||||
meta = Required(str, default=lambda: "{\"race\": false}")
|
||||
meta = Required(LongStr, default=lambda: "{\"race\": false}")
|
||||
state = Required(int, default=0, index=True)
|
||||
|
||||
@@ -5,23 +5,43 @@ import yaml
|
||||
import json
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import Options
|
||||
|
||||
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
|
||||
|
||||
def default_converter(default_value):
|
||||
if isinstance(default_value, (set, frozenset)):
|
||||
return list(default_value)
|
||||
return default_value
|
||||
|
||||
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
|
||||
options={**world.options, **Options.per_game_common_options},
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range, default_converter=default_converter,
|
||||
)
|
||||
|
||||
with open(os.path.join(target_folder, game_name + ".yaml"), "w") as f:
|
||||
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
||||
|
||||
with open(os.path.join(target_folder, 'configs', 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/",
|
||||
"game": game_name,
|
||||
"name": "Player",
|
||||
},
|
||||
}
|
||||
@@ -29,26 +49,41 @@ def create():
|
||||
game_options = {}
|
||||
for option_name, option in world.options.items():
|
||||
if option.options:
|
||||
this_option = {
|
||||
game_options[option_name] = this_option = {
|
||||
"type": "select",
|
||||
"friendlyName": option.friendly_name if hasattr(option, "friendly_name") else option_name,
|
||||
"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():
|
||||
for sub_option_id, sub_option_name in option.name_lookup.items():
|
||||
this_option["options"].append({
|
||||
"name": sub_option_name,
|
||||
"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
|
||||
this_option["options"].append({
|
||||
"name": "Random",
|
||||
"value": "random",
|
||||
})
|
||||
|
||||
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:
|
||||
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
|
||||
|
||||
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
|
||||
f.write(json.dumps(player_settings, indent=2, separators=(',', ': ')))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
flask>=2.0.1
|
||||
flask>=2.0.2
|
||||
pony>=0.7.14
|
||||
waitress>=2.0.0
|
||||
flask-caching>=1.10.1
|
||||
|
||||
53
WebHostLib/static/assets/faq.js
Normal file
53
WebHostLib/static/assets/faq.js
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
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>`;
|
||||
});
|
||||
});
|
||||
52
WebHostLib/static/assets/faq/faq_en.md
Normal file
52
WebHostLib/static/assets/faq/faq_en.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Frequently Asked Questions
|
||||
|
||||
## What is a randomizer?
|
||||
A randomizer is a modification of a video game which reorganizes the items required to progress through the game.
|
||||
A normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a
|
||||
randomized game, you might first find item C, then A, then B.
|
||||
|
||||
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
|
||||
play a randomized game. Putting items in non-standard locations can require the player to think about the game world
|
||||
and the items they encounter in new and interesting ways.
|
||||
|
||||
## What happens if an item is placed somewhere it is impossible to get?
|
||||
The randomizer has many strict sets of rules it must follow when generating a game. One of the functions of these
|
||||
rules is to ensure items necessary to complete the game will be accessible to the player. Many games also have a
|
||||
subset of rules allowing certain items to be placed in normally unreachable locations, provided the player has
|
||||
indicated they are comfortable exploiting certain glitches in the game.
|
||||
|
||||
## What is a multi-world?
|
||||
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example,
|
||||
in a two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each
|
||||
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
|
||||
player B, the item will be sent to player B's world over the internet.
|
||||
|
||||
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
|
||||
their game.
|
||||
|
||||
## What happens if a person has to leave early?
|
||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all
|
||||
the items in that game which belong to other players are sent out automatically, so other players can continue to
|
||||
play.
|
||||
|
||||
## What does multi-game mean?
|
||||
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world
|
||||
allows players to randomize any of a number of supported games, and send items between them. This allows players of
|
||||
different games to interact with one another in a single multiplayer environment.
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
|
||||
the website is not required to generate them.
|
||||
|
||||
## How do I get started?
|
||||
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others,
|
||||
please join our [Discord server](https://discord.gg/8Z65BR2). There are always people ready to answer any questions
|
||||
you might have.
|
||||
|
||||
## I want to add a game to the Archipelago randomizer. How do I do that?
|
||||
The best way to get started is to take a look at our [code on GitHub](https://github.com/ArchipelagoMW/Archipelago).
|
||||
There, you will find examples of games in the [worlds](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds)
|
||||
folder, as well as some [documentation](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs) on our
|
||||
network interfaces.
|
||||
|
||||
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.
|
||||
53
WebHostLib/static/assets/gameInfo.js
Normal file
53
WebHostLib/static/assets/gameInfo.js
Normal file
@@ -0,0 +1,53 @@
|
||||
window.addEventListener('load', () => {
|
||||
const gameInfo = document.getElementById('game-info');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, this game's info page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the info page.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/gameInfo/` +
|
||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
gameInfo.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);
|
||||
gameInfo.innerHTML =
|
||||
`<h2>This page is out of logic!</h2>
|
||||
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
|
||||
});
|
||||
});
|
||||
26
WebHostLib/static/assets/gameInfo/en_A Link to the Past.md
Normal file
26
WebHostLib/static/assets/gameInfo/en_A Link to the Past.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# A Link to the Past
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
|
||||
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
|
||||
they would in the vanilla game.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
All main inventory items, collectables, and ammunition can be shuffled, and all locations in the game which could
|
||||
contain any of those items may have their contents changed.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
|
||||
limit certain items to your own world.
|
||||
|
||||
## What does another world's item look like in LttP?
|
||||
Items belonging to other worlds are represented by a Power Star from Super Mario World.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When the player receives an item, Link will hold the item above his head and display it to the world. It's good for
|
||||
business!
|
||||
|
||||
29
WebHostLib/static/assets/gameInfo/en_Factorio.md
Normal file
29
WebHostLib/static/assets/gameInfo/en_Factorio.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Factorio
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
In Factorio, the research tree is shuffled, causing certain technologies to be obtained in a non-standard order.
|
||||
Recipe costs, technology requirements, and science pack requirements may also be shuffled at the player's discretion.
|
||||
|
||||
## What Factorio items can appear in other players' worlds?
|
||||
Factorio's technologies are removed from its tech tree and placed into other players' worlds. When those technologies
|
||||
are found, they are sent back to Factorio along with, optionally, free samples of those technologies.
|
||||
|
||||
## What is a free sample?
|
||||
A free sample is a single or stack of items in Factorio, granted by a technology received from another world. For
|
||||
example, receiving the technology
|
||||
`Portable Solar Panel` may also grant the player a stack of portable solar panels,
|
||||
and place them directly into the player's inventory.
|
||||
|
||||
## What does another world's item look like in Factorio?
|
||||
In Factorio, items which need to be sent to other worlds appear in the tech tree as new research items. They are
|
||||
represented by the Archipelago icon, and must be researched as if it were a normal technology. Upon successful
|
||||
completion of research, the item will be sent to its home world.
|
||||
|
||||
## When the engineer receives an item, what happens?
|
||||
When the player receives a technology, it is instantly learned and able to be crafted. A message will appear in the
|
||||
chat log to notify the player, and if free samples are enabled the player may also receive some items directly to
|
||||
their inventory.
|
||||
21
WebHostLib/static/assets/gameInfo/en_Final Fantasy.md
Normal file
21
WebHostLib/static/assets/gameInfo/en_Final Fantasy.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Final Fantasy 1 (NES)
|
||||
|
||||
## Where is the settings page?
|
||||
Unlike most games on Archipelago.gg, Final Fantasy 1's settings are controlled entirely by the original randomzier.
|
||||
You can find an exhaustive list of documented settings [here](https://finalfantasyrandomizer.com/)
|
||||
|
||||
## What does randomization do to this game?
|
||||
A better questions is what isn't randomized at this point. Enemies stats and spell, character spells, shop inventory
|
||||
and boss stats and spells are all commonly randomized. Unlike most other randomizers it is also most standard to shuffle
|
||||
progression items and non-progression items into separate pools and then redistribute them to their respective
|
||||
locations. So ,for example, Princess Sarah may have the CANOE instead of the LUTE; however, she will never have a Heal
|
||||
Pot or some armor. There are plenty of other things that can be randomized on our
|
||||
[main randomizer site](https://finalfantasyrandomizer.com/)
|
||||
|
||||
## What Final Fantasy items can appear in other players' worlds?
|
||||
All items can appear in other players worlds. This includes consumables, shards, weapons, armor and, of course,
|
||||
key items.
|
||||
|
||||
## What does another world's item look like in Final Fantasy
|
||||
All local and remote items appear the same. It will say that you received an item and then BOTH the client log and
|
||||
the emulator will display what was found external to the in-game text box.
|
||||
22
WebHostLib/static/assets/gameInfo/en_Minecraft.md
Normal file
22
WebHostLib/static/assets/gameInfo/en_Minecraft.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Minecraft
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Recipes are removed from the crafting book and shuffled into the item pool. It can also optionally change which
|
||||
structures appear in each dimension. Crafting recipes are re-learned when they are received from other players as
|
||||
item checks, and occasionally when completing your own achievements.
|
||||
|
||||
## What is considered a location check in minecraft?
|
||||
Location checks in are completed when the player completes various Minecraft achievements. Opening the advancements
|
||||
menu in-game by pressing "L" will display outstanding achievements.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When the player receives an item in Minecraft, it either unlocks crafting recipes or puts items into the player's
|
||||
inventory directly.
|
||||
|
||||
## What is the victory condition?
|
||||
Victory is achieved when the player kills the Ender Dragon, enters the portal in The End, and completes the credits
|
||||
sequence either by skipping it or watching hit play out.
|
||||
26
WebHostLib/static/assets/gameInfo/en_Ocarina of Time.md
Normal file
26
WebHostLib/static/assets/gameInfo/en_Ocarina of Time.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Ocarina of Time
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
|
||||
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
|
||||
they would in the vanilla game.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
All main inventory items, collectables, and ammunition can be shuffled, and all locations in the game which could
|
||||
contain any of those items may have their contents changed. Gold Skultulla locations may also be included as necessary
|
||||
checks at the user's discretion.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
|
||||
limit certain items to your own world.
|
||||
|
||||
## What does another world's item look like in OoT?
|
||||
Items belonging to other worlds are represented by an Ocarina of Time.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When the player receives an item, Link will hold the item above his head and display it to the world. It's good for
|
||||
business!
|
||||
36
WebHostLib/static/assets/gameInfo/en_Risk of Rain 2.md
Normal file
36
WebHostLib/static/assets/gameInfo/en_Risk of Rain 2.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Risk of Rain 2
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Risk of Rain is already a random game, by virtue of being a roguelite. The Archipelago mod implements pure multiworld
|
||||
functionality in which certain chests (made clear via a location check progress bar) will send an item out to the
|
||||
multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants
|
||||
by other players in other worlds.
|
||||
|
||||
## What Risk of Rain items can appear in other players' worlds?
|
||||
The Risk of Rain items are:
|
||||
* `Common Item` (White items)
|
||||
* `Uncommon Item` (Green items)
|
||||
* `Boss Item` (Yellow items)
|
||||
* `Legendary Item` (Red items)
|
||||
* `Lunar Item` (Blue items)
|
||||
* `Equipment` (Orange items)
|
||||
* `Dio's Best Friend` (Used if you set the YAML setting `total_revives_available` above `0`)
|
||||
|
||||
Each item grants you a random in-game item from the category it belongs to.
|
||||
|
||||
When an item is granted by another world to the Risk of Rain player (one of the items listed above) then a random
|
||||
in-game item of that tier will appear in the Risk of Rain player's inventory. If the item grant is an `Equipment`
|
||||
and the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground
|
||||
and _the new equipment_ will take it's place. (If you want the old one back, pick it up.)
|
||||
|
||||
## What does another world's item look like in Risk of Rain?
|
||||
When the Risk of Rain player fills up their location check bar then the next spawned item will become an item grant for another
|
||||
player's world. The item in Risk of Rain will disappear in a poof of smoke and the grant will automatically go out to the multiworld.
|
||||
|
||||
## What is the item pickup step?
|
||||
The item pickup step is a YAML setting which allows you to set how many items you need to spawn before the _next_
|
||||
item that is spawned disappears (in a poof of smoke) and goes out to the multiworld.
|
||||
29
WebHostLib/static/assets/gameInfo/en_Secret of Evermore.md
Normal file
29
WebHostLib/static/assets/gameInfo/en_Secret of Evermore.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Secret of Evermore
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all options
|
||||
necessary to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Items which would normally be acquired throughout the game have been moved around! Progression logic remains,
|
||||
so the game is always able to be completed. However, because of the item shuffle, the player may need to access certain
|
||||
areas before they would in the vanilla game. For example, the Windwalker (flying machine) is accessible as soon as any
|
||||
weapon is obtained.
|
||||
|
||||
Additional help can be found in the [guide](https://github.com/black-sliver/evermizer/blob/master/guide.md).
|
||||
|
||||
## What items and locations get shuffled?
|
||||
All gourds/chests/pots, boss drops and alchemists are shuffled. Alchemy ingredients, sniff spot items, call bead spells
|
||||
and the dog can be randomized using yaml options.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the items which can be shuffled may also be placed in another player's world.
|
||||
Specific items can be limited to your own world using plando.
|
||||
|
||||
## What does another world's item look like in Secret of Evermore?
|
||||
Secret of Evermore will display "Sent an Item". Check the client output if you want to know which.
|
||||
|
||||
## What happens when the player receives an item?
|
||||
When the player receives an item, a popup will appear to show which item was received. Items won't be received while a
|
||||
script is active such as when visiting Nobilia Market or during most Boss Fights. Once all scripts have ended, items
|
||||
will be received.
|
||||
27
WebHostLib/static/assets/gameInfo/en_Subnautica.md
Normal file
27
WebHostLib/static/assets/gameInfo/en_Subnautica.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Subnautica
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
The most noticeable change is the complete removal of freestanding technologies. The technology blueprints normally
|
||||
awarded from scanning those items have been shuffled into location checks throughout the AP item pool.
|
||||
|
||||
## What is the goal of Subnautica when randomized?
|
||||
The goal remains unchanged. Cure the plague, build the Neptune Escape Rocket, and escape into space.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
Most of the technologies the player will need throughout the game will be shuffled. Location checks in Subnautica are
|
||||
data pads and technology lockers.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Most technologies may be shuffled into another player's world.
|
||||
|
||||
## What does another world's item look like in Subnautica?
|
||||
Location checks in Subnautica are data pads and technology lockers. Opening one of these will send an item to
|
||||
another player's world.
|
||||
|
||||
## When the player receives a technology, what happens?
|
||||
When the player receives a technology, the chat log displays a notification the technology has been received.
|
||||
|
||||
25
WebHostLib/static/assets/gameInfo/en_Super Metroid.md
Normal file
25
WebHostLib/static/assets/gameInfo/en_Super Metroid.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Super Metroid
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
|
||||
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
|
||||
they would in the vanilla game.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
All power-ups and ammunition can be shuffled, and all locations in the game which could contain any of those items
|
||||
may have their contents changed.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
|
||||
limit certain items to your own world.
|
||||
|
||||
## What does another world's item look like in Super Metroid?
|
||||
A unique item sprite has been added to the game to represent items belonging to another world.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When the player receives an item, a text box will appear to show which item was received, and from whom.
|
||||
|
||||
28
WebHostLib/static/assets/gameInfo/en_Timespinner.md
Normal file
28
WebHostLib/static/assets/gameInfo/en_Timespinner.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Timespinner
|
||||
|
||||
## Where is the settings page?
|
||||
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
|
||||
you need to configure and export a config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
|
||||
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
|
||||
they would in the vanilla game. All rings and spells are also randomized into those item locations, therefor you can no longer craft them at the alchemist
|
||||
|
||||
## What is the goal of Timespinner when randomized?
|
||||
The goal remains unchanged. Kill the Sandman\Nightmare!
|
||||
|
||||
## What items and locations get shuffled?
|
||||
All main inventory items, orbs, collectables, and familiers can be shuffled, and all locations in the game which could
|
||||
contain any of those items may have their contents changed.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
|
||||
limit certain items to your own world.
|
||||
|
||||
## What does another world's item look like in Timespinner?
|
||||
Items belonging to other worlds are represented by the vanilla item [Elemental Beads](https://timespinnerwiki.com/Use_Items), Elemental Beads have no use in the randomizer
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
When the player receives an item, the same items popup will be displayed as when you would normally obtain the item
|
||||
|
||||
2
WebHostLib/static/assets/md5.min.js
vendored
Normal file
2
WebHostLib/static/assets/md5.min.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Copyright © 2011 Sebastian Tschan, https://blueimp.net
|
||||
!function(n){"use strict";function d(n,t){var r=(65535&n)+(65535&t);return(n>>16)+(t>>16)+(r>>16)<<16|65535&r}function f(n,t,r,e,o,u){return d((c=d(d(t,n),d(e,u)))<<(f=o)|c>>>32-f,r);var c,f}function l(n,t,r,e,o,u,c){return f(t&r|~t&e,n,t,o,u,c)}function v(n,t,r,e,o,u,c){return f(t&e|r&~e,n,t,o,u,c)}function g(n,t,r,e,o,u,c){return f(t^r^e,n,t,o,u,c)}function m(n,t,r,e,o,u,c){return f(r^(t|~e),n,t,o,u,c)}function i(n,t){var r,e,o,u;n[t>>5]|=128<<t%32,n[14+(t+64>>>9<<4)]=t;for(var c=1732584193,f=-271733879,i=-1732584194,a=271733878,h=0;h<n.length;h+=16)c=l(r=c,e=f,o=i,u=a,n[h],7,-680876936),a=l(a,c,f,i,n[h+1],12,-389564586),i=l(i,a,c,f,n[h+2],17,606105819),f=l(f,i,a,c,n[h+3],22,-1044525330),c=l(c,f,i,a,n[h+4],7,-176418897),a=l(a,c,f,i,n[h+5],12,1200080426),i=l(i,a,c,f,n[h+6],17,-1473231341),f=l(f,i,a,c,n[h+7],22,-45705983),c=l(c,f,i,a,n[h+8],7,1770035416),a=l(a,c,f,i,n[h+9],12,-1958414417),i=l(i,a,c,f,n[h+10],17,-42063),f=l(f,i,a,c,n[h+11],22,-1990404162),c=l(c,f,i,a,n[h+12],7,1804603682),a=l(a,c,f,i,n[h+13],12,-40341101),i=l(i,a,c,f,n[h+14],17,-1502002290),c=v(c,f=l(f,i,a,c,n[h+15],22,1236535329),i,a,n[h+1],5,-165796510),a=v(a,c,f,i,n[h+6],9,-1069501632),i=v(i,a,c,f,n[h+11],14,643717713),f=v(f,i,a,c,n[h],20,-373897302),c=v(c,f,i,a,n[h+5],5,-701558691),a=v(a,c,f,i,n[h+10],9,38016083),i=v(i,a,c,f,n[h+15],14,-660478335),f=v(f,i,a,c,n[h+4],20,-405537848),c=v(c,f,i,a,n[h+9],5,568446438),a=v(a,c,f,i,n[h+14],9,-1019803690),i=v(i,a,c,f,n[h+3],14,-187363961),f=v(f,i,a,c,n[h+8],20,1163531501),c=v(c,f,i,a,n[h+13],5,-1444681467),a=v(a,c,f,i,n[h+2],9,-51403784),i=v(i,a,c,f,n[h+7],14,1735328473),c=g(c,f=v(f,i,a,c,n[h+12],20,-1926607734),i,a,n[h+5],4,-378558),a=g(a,c,f,i,n[h+8],11,-2022574463),i=g(i,a,c,f,n[h+11],16,1839030562),f=g(f,i,a,c,n[h+14],23,-35309556),c=g(c,f,i,a,n[h+1],4,-1530992060),a=g(a,c,f,i,n[h+4],11,1272893353),i=g(i,a,c,f,n[h+7],16,-155497632),f=g(f,i,a,c,n[h+10],23,-1094730640),c=g(c,f,i,a,n[h+13],4,681279174),a=g(a,c,f,i,n[h],11,-358537222),i=g(i,a,c,f,n[h+3],16,-722521979),f=g(f,i,a,c,n[h+6],23,76029189),c=g(c,f,i,a,n[h+9],4,-640364487),a=g(a,c,f,i,n[h+12],11,-421815835),i=g(i,a,c,f,n[h+15],16,530742520),c=m(c,f=g(f,i,a,c,n[h+2],23,-995338651),i,a,n[h],6,-198630844),a=m(a,c,f,i,n[h+7],10,1126891415),i=m(i,a,c,f,n[h+14],15,-1416354905),f=m(f,i,a,c,n[h+5],21,-57434055),c=m(c,f,i,a,n[h+12],6,1700485571),a=m(a,c,f,i,n[h+3],10,-1894986606),i=m(i,a,c,f,n[h+10],15,-1051523),f=m(f,i,a,c,n[h+1],21,-2054922799),c=m(c,f,i,a,n[h+8],6,1873313359),a=m(a,c,f,i,n[h+15],10,-30611744),i=m(i,a,c,f,n[h+6],15,-1560198380),f=m(f,i,a,c,n[h+13],21,1309151649),c=m(c,f,i,a,n[h+4],6,-145523070),a=m(a,c,f,i,n[h+11],10,-1120210379),i=m(i,a,c,f,n[h+2],15,718787259),f=m(f,i,a,c,n[h+9],21,-343485551),c=d(c,r),f=d(f,e),i=d(i,o),a=d(a,u);return[c,f,i,a]}function a(n){for(var t="",r=32*n.length,e=0;e<r;e+=8)t+=String.fromCharCode(n[e>>5]>>>e%32&255);return t}function h(n){var t=[];for(t[(n.length>>2)-1]=void 0,e=0;e<t.length;e+=1)t[e]=0;for(var r=8*n.length,e=0;e<r;e+=8)t[e>>5]|=(255&n.charCodeAt(e/8))<<e%32;return t}function e(n){for(var t,r="0123456789abcdef",e="",o=0;o<n.length;o+=1)t=n.charCodeAt(o),e+=r.charAt(t>>>4&15)+r.charAt(15&t);return e}function r(n){return unescape(encodeURIComponent(n))}function o(n){return a(i(h(t=r(n)),8*t.length));var t}function u(n,t){return function(n,t){var r,e,o=h(n),u=[],c=[];for(u[15]=c[15]=void 0,16<o.length&&(o=i(o,8*n.length)),r=0;r<16;r+=1)u[r]=909522486^o[r],c[r]=1549556828^o[r];return e=i(u.concat(h(t)),512+8*t.length),a(i(c.concat(e),640))}(r(n),r(t))}function t(n,t,r){return t?r?u(t,n):e(u(t,n)):r?o(n):e(o(n))}"function"==typeof define&&define.amd?define(function(){return t}):"object"==typeof module&&module.exports?module.exports=t:n.md5=t}(this);
|
||||
49
WebHostLib/static/assets/minecraftTracker.js
Normal file
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
52
WebHostLib/static/assets/ootTracker.js
Normal file
52
WebHostLib/static/assets/ootTracker.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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, small keys, and boss keys in the location-table
|
||||
const types = ['counter', 'smallkeys', 'bosskeys'];
|
||||
for (let j = 0; j < types.length; j++) {
|
||||
let counters = document.getElementsByClassName(types[j]);
|
||||
const fakeCounters = fakeDOM.getElementsByClassName(types[j]);
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,13 +1,28 @@
|
||||
let gameName = null;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const urlMatches = window.location.href.match(/^.*\/(.*)\/player-settings/);
|
||||
gameName = decodeURIComponent(urlMatches[1]);
|
||||
gameName = document.getElementById('player-settings').getAttribute('data-game');
|
||||
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerHTML = gameName;
|
||||
document.getElementById('game-name').innerText = gameName;
|
||||
|
||||
Promise.all([fetchSettingData()]).then((results) => {
|
||||
let settingHash = localStorage.getItem(`${gameName}-hash`);
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
localStorage.setItem(`${gameName}-hash`, md5(results[0]));
|
||||
localStorage.removeItem(gameName);
|
||||
settingHash = md5(results[0]);
|
||||
}
|
||||
|
||||
if (settingHash !== md5(results[0])) {
|
||||
const userMessage = document.getElementById('user-message');
|
||||
userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.";
|
||||
userMessage.style.display = "block";
|
||||
userMessage.addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
// Page setup
|
||||
createDefaultSettings(results[0]);
|
||||
buildUI(results[0]);
|
||||
@@ -15,7 +30,7 @@ window.addEventListener('load', () => {
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true))
|
||||
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
|
||||
document.getElementById('generate-game').addEventListener('click', () => generateGame());
|
||||
|
||||
// Name input field
|
||||
@@ -29,6 +44,12 @@ window.addEventListener('load', () => {
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettings = () => {
|
||||
localStorage.removeItem(gameName);
|
||||
localStorage.removeItem(`${gameName}-hash`)
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
@@ -40,7 +61,7 @@ const fetchSettingData = () => new Promise((resolve, reject) => {
|
||||
try{ resolve(JSON.parse(ajax.responseText)); }
|
||||
catch(error){ reject(error); }
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/${gameName}.json`, true);
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
|
||||
@@ -84,28 +105,68 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', setting);
|
||||
label.setAttribute('data-tooltip', settings[setting].description);
|
||||
label.innerText = `${settings[setting].friendlyName}:`;
|
||||
label.innerText = `${settings[setting].displayName}:`;
|
||||
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) => updateGameSetting(event));
|
||||
tdr.appendChild(select);
|
||||
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);
|
||||
});
|
||||
|
||||
49
WebHostLib/static/assets/supermetroidTracker.js
Normal file
49
WebHostLib/static/assets/supermetroidTracker.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;
|
||||
});
|
||||
}
|
||||
});
|
||||
49
WebHostLib/static/assets/timespinnerTracker.js
Normal file
49
WebHostLib/static/assets/timespinnerTracker.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;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,9 @@ window.addEventListener('load', () => {
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
|
||||
32
WebHostLib/static/assets/tutorial/Subnautica/setup_en.md
Normal file
32
WebHostLib/static/assets/tutorial/Subnautica/setup_en.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Subnautica Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Subnautica](https://store.steampowered.com/app/264710/Subnautica/)
|
||||
- [QModManager4](https://www.nexusmods.com/subnautica/mods/201)
|
||||
- [Archipelago Mod for Subnautica](https://github.com/Berserker66/ArchipelagoSubnauticaModSrc/releases)
|
||||
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
1. Install QModManager4 as per its instructions.
|
||||
|
||||
2. The folder you installed QModManager4 into will now have a /QMods directory. It might appear after a start of Subnautica. You can also create this folder yourself.
|
||||
|
||||
3. Unpack the Archipelago Mod into this folder, so that Subnautica/QMods/Archipelago/ is a valid path.
|
||||
|
||||
4. Start Subnautica. You should see a Connect Menu in the topleft of your main Menu.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you don't see the connect window check that you see a qmodmanager_log-Subnautica.txt in Subnautica, if not QModManager4 is not correctly installed, otherwise open it and look for `[Info : BepInEx] Loading [Archipelago 1.0.0.0]`, version number doesn't matter. If it doesn't show this, then QModManager4 didn't find the Archipelago mod, so check your paths.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. In Host, enter the address of the server, such as archipelago.gg:38281, your server host should be able to tell you this.
|
||||
|
||||
2. In Password enter the server password if one exists, otherwise leave blank.
|
||||
|
||||
3. In PlayerName enter your "name" field from the yaml, or website config.
|
||||
|
||||
4. Hit Connect. If it says succesfully authenticated you can now create a new savegame or resume the correct savegame.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Advanced Game Options Guide
|
||||
|
||||
|
||||
The Archipelago system generates games using player configuration files as input. Generally these are going to be
|
||||
YAML files and each player will have one of these containing their custom settings for the randomized game they want to play.
|
||||
On the website when you customize your settings from one of the game player settings pages which you can reach from the
|
||||
[supported games page](/games). Clicking on the export settings button at the bottom will provide you with a pre-filled out
|
||||
YAML with your options. The player settings page also has an option to download a fully filled out yaml containing every
|
||||
option with every available setting for the available options.
|
||||
|
||||
## YAML Formatting
|
||||
YAML files are a format of <span data-tooltip="Allegedly.">human-readable</span> markup config files. The basic syntax
|
||||
of a yaml file will have `root` and then different levels of `nested` text that the generator "reads" in order to determine
|
||||
your settings. To nest text, the correct syntax is **two spaces over** from its root option. A YAML file can be edited
|
||||
with whatever text editor you choose to use though I personally recommend that you use [Sublime Text](https://www.sublimetext.com/).
|
||||
This program out of the box supports the correct formatting for the YAML file, so you will be able to tab and get proper
|
||||
highlighting for any potential errors made while editing the file. If using any other text editor such as Notepad or
|
||||
Notepad++ whenever you move to nest an option that it is done with two spaces and not tabs.
|
||||
|
||||
Typical YAML format will look as follows:
|
||||
```yaml
|
||||
root_option:
|
||||
nested_option_one:
|
||||
option_one_setting_one: 1
|
||||
option_one_setting_two: 0
|
||||
nested_option_two:
|
||||
option_two_setting_one: 14
|
||||
option_two_setting_two: 43
|
||||
```
|
||||
|
||||
In Archipelago YAML options are always written out in full lowercase with underscores separating any words. The numbers
|
||||
following the colons here are weights. The generator will read the weight of every option the roll that option that many
|
||||
times, the next option as many times as its numbered and so forth. For the above example `nested_option_one` will have
|
||||
`option_one_setting_one` 1 time and `option_one_setting_two` 0 times so `option_one_setting_one` is guaranteed to occur.
|
||||
For `nested_option_two`, `option_two_setting_one` will be rolled 14 times and `option_two_setting_two` will be rolled 43
|
||||
times against each other. This means `option_two_setting_two` will be more likely to occur but it isn't guaranteed adding
|
||||
more randomness and "mystery" to your settings. Every configurable setting supports weights.
|
||||
|
||||
### Root Options
|
||||
Currently there are only a few options that are root options. Everything else should be nested within one of these root
|
||||
options or in some cases nested within other nested options. The only options that should exist in root are `description`,
|
||||
`name`, `game`, `requires`, `accessibility`, `progression_balancing`, `triggers`, and the name of the games you want
|
||||
settings for.
|
||||
* `description` is ignored by the generator and is simply a good way for you to organize if you have multiple files using
|
||||
this to detail the intention of the file.
|
||||
* `name` is the player name you would like to use and is used for your slot data to connect with most games. This can also
|
||||
be filled with multiple names each having a weight to it.
|
||||
* `game` is where either your chosen game goes or if you would like can be filled with multiple games each with different
|
||||
weights.
|
||||
* `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this
|
||||
is good for detailing the version of Archipelago this YAML was prepared for as if it is rolled on an older version may be
|
||||
missing settings and as such will not work as expected. If any plando is used in the file then requiring it here to ensure
|
||||
it will be used is good practice.
|
||||
* `accessibility` determines the level of access to the game the generation will expect you to have in order to reach your
|
||||
completion goal. This supports `items`, `locations`, and `none` and is set to `locations` by default.
|
||||
* `items` will guarantee you can acquire all items in your world but may not be able to access all locations. This mostly
|
||||
comes into play if there is any entrance shuffle in the seed as locations without items in them can be placed in areas
|
||||
that make them unreachable.
|
||||
* `none` will only guarantee that the seed is beatable. You will be guaranteed able to finish the seed logically but
|
||||
may not be able to access all locations or acquire all items. A good example of this is having a big key in the big chest
|
||||
in a dungeon in ALTTP making it impossible to get and finish the dungeon.
|
||||
* `progression_balancing` is a system the Archipelago generator uses to try and reduce "BK mode" as much as possible. This
|
||||
primarily involves moving necessary progression items into earlier logic spheres to make the games more accessible so that
|
||||
players almost always have something to do. This can be turned `on` or `off` and is `on` by default.
|
||||
* `triggers` is one of the more advanced options that allows you to create conditional adjustments. You can read more
|
||||
about this [here](/tutorial/archipelago/triggers/en).
|
||||
|
||||
### Game Options
|
||||
|
||||
One of your root settings will be the name of the game you would like to populate with settings in the format
|
||||
`GameName`. since it is possible to give a weight to any option it is possible to have one file that can generate a seed
|
||||
for you where you don't know which game you'll play. For these cases you'll want to fill the game options for every game
|
||||
that can be rolled by these settings. If a game can be rolled it **must** have a settings section even if it is empty.
|
||||
|
||||
#### Universal Game Options
|
||||
|
||||
Some options in Archipelago can be used by every game but must still be placed within the relevant game's section.
|
||||
Currently, these options are `start_inventory`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints`,
|
||||
`exclude_locations`, and various [plando options](/tutorial/archipelago/plando/en).
|
||||
* `start_inventory` will give any items defined here to you at the beginning of your game. The format for this must be
|
||||
the name as it appears in the game files and the amount you would like to start with. For example `Rupees(5): 6` which
|
||||
will give you 30 rupees.
|
||||
* `start_hints` gives you free server hints for the defined item/s at the beginning of the game allowing you to hint for
|
||||
the location without using any hint points.
|
||||
* `local_items` will force any items you want to be in your world instead of being in another world.
|
||||
* `non_local_items` is the inverse of `local_items` forcing any items you want to be in another world and won't be located
|
||||
in your own.
|
||||
* `start_location_hints` allows you to define a location which you can then hint for to find out what item is located in
|
||||
it to see how important the location is.
|
||||
* `exclude_locations` lets you define any locations that you don't want to do and during generation will force a "junk"
|
||||
item which isn't necessary for progression to go in these locations.
|
||||
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
|
||||
description: An example using various advanced options
|
||||
name: Example Player
|
||||
game: A Link to the Past
|
||||
requires:
|
||||
version: 0.2.0
|
||||
accessibility: none
|
||||
progression_balancing: on
|
||||
A Link to the Past:
|
||||
smallkey_shuffle:
|
||||
original_dungeon: 1
|
||||
any_world: 1
|
||||
start_inventory:
|
||||
Pegasus Boots: 1
|
||||
Bombs (3): 2
|
||||
start_hints:
|
||||
- Hammer
|
||||
local_items:
|
||||
- Bombos
|
||||
- Ether
|
||||
- Quake
|
||||
non_local_items:
|
||||
- Moon Pearl
|
||||
start_location_hints:
|
||||
- Spike Cave
|
||||
exclude_locations:
|
||||
- Cave 45
|
||||
triggers:
|
||||
- option_category: A Link to the Past
|
||||
option_name: smallkey_shuffle
|
||||
option_result: any_world
|
||||
options:
|
||||
A Link to the Past:
|
||||
bigkey_shuffle: any_world
|
||||
map_shuffle: any_world
|
||||
compass_shuffle: any_world
|
||||
```
|
||||
|
||||
#### This is a fully functional yaml file that will do all the following things:
|
||||
* `description` gives us a general overview so if we pull up this file later we can understand the intent.
|
||||
* `name` is `Example Player` and this will be used in the server console when sending and receiving items.
|
||||
* `game` is set to `A Link to the Past` meaning that is what game we will play with this file.
|
||||
* `requires` is set to require release version 0.2.0 or higher.
|
||||
* `accesibility` is set to `none` which will set this seed to beatable only meaning some locations and items may be
|
||||
completely inaccessible but the seed will still be completable.
|
||||
* `progression_balancing` is set on meaning we will likely receive important items earlier increasing the chance of having
|
||||
things to do.
|
||||
* `A Link to the Past` defines a location for us to nest all the game options we would like to use for our game `A Link to the Past`.
|
||||
* `smallkey_shuffle` is an option for A Link to the Past which determines how dungeon small keys are shuffled. In this example
|
||||
we have a 1/2 chance for them to either be placed in their original dungeon and a 1/2 chance for them to be placed anywhere
|
||||
amongst the multiworld.
|
||||
* `start_inventory` defines an area for us to determine what items we would like to start the seed with. For this example
|
||||
we have:
|
||||
* `Pegasus Boots: 1` which gives us 1 copy of the Pegasus Boots
|
||||
* `Bombs (3)` gives us 2 packs of 3 bombs or 6 total bombs
|
||||
* `start_hints` gives us a starting hint for the hammer available at the beginning of the multiworld which we can use with no cost.
|
||||
* `local_items` forces the `Bombos`, `Ether`, and `Quake` medallions to all be placed within our own world, meaning we
|
||||
have to find it ourselves.
|
||||
* `non_local_items` forces the `Moon Pearl` to be placed in someone else's world, meaning we won't be able to find it.
|
||||
* `start_location_hints` gives us a starting hint for the `Spike Cave` location available at the beginning of the multiworld
|
||||
that can be used for no cost.
|
||||
* `exclude_locations` forces a not important item to be placed on the `Cave 45` location.
|
||||
* `triggers` allows us to define a trigger such that if our `smallkey_shuffle` option happens to roll the `any_world`
|
||||
result it will also ensure that `bigkey_shuffle`, `map_shuffle`, and `compass_shuffle` are also forced to the `any_world`
|
||||
result.
|
||||
180
WebHostLib/static/assets/tutorial/archipelago/plando_en.md
Normal file
180
WebHostLib/static/assets/tutorial/archipelago/plando_en.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Archipelago Plando Guide
|
||||
|
||||
## What is Plando?
|
||||
The purposes of randomizers is to randomize the items in a game to give a new experience.
|
||||
Plando takes this concept and changes it up by allowing you to plan out certain aspects of the game by placing certain
|
||||
items in certain locations, certain bosses in certain rooms, edit text for certain NPCs/signs, or even force certain region
|
||||
connections. Each of these options are going to be detailed separately as `item plando`, `boss plando`, `text plando`,
|
||||
and `connection plando`. Every game in archipelago supports item plando but the other plando options are only supported
|
||||
by certain games. Currently, Minecraft and LTTP both support connection plando, but only LTTP supports text and boss plando.
|
||||
|
||||
### Enabling Plando
|
||||
On the website plando will already be enabled. If you will be generating the game locally plando features must be enabled (opt-in).
|
||||
* To opt-in go to the archipelago installation (default: `C:\ProgramData\Archipelago`), open the host.yaml with a text
|
||||
editor and find the `plando_options` key. The available plando modules can be enabled by adding them after this such as
|
||||
`plando_options: bosses, items, texts, connections`.
|
||||
|
||||
## Item Plando
|
||||
Item plando allows a player to place an item in a specific location or specific locations, place multiple items into
|
||||
a list of specific locations both in their own game or in another player's game. **Note that there's a very good chance that
|
||||
cross-game plando could very well be broken i.e. placing on of your items in someone else's world playing a different game.**
|
||||
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, and either item and location, or items and locations.
|
||||
* `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or false
|
||||
and defaults to true if omitted.
|
||||
* `world` is the target world to place the item in.
|
||||
* It gets ignored if only one world is generated.
|
||||
* Can be a number, name, true, false, or null. False is the default.
|
||||
* If a number is used it targets that slot or player number in the multiworld.
|
||||
* If a name is used it will target the world with that player name.
|
||||
* If set to true it will be any player's world besides your own.
|
||||
* If set to false it will target your own world.
|
||||
* If set to null it will target a random world in the multiworld.
|
||||
* `force` determines whether the generator will fail if the item can't be placed in the location can be true, false,
|
||||
or silent. Silent is the default.
|
||||
* If set to true the item must be placed and the generator will throw an error if it is unable to do so.
|
||||
* If set to false the generator will log a warning if the placement can't be done but will still generate.
|
||||
* If set to silent and the placement fails it will be ignored entirely.
|
||||
* `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and if
|
||||
omitted will default to 100.
|
||||
* Single Placement is when you use a plando block to place a single item at a single location.
|
||||
* `item` is the item you would like to place and `location` is the location to place it.
|
||||
* Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted.
|
||||
* `items` defines the items to use and a number letting you place multiple of it.
|
||||
* `locations` is a list of possible locations those items can be placed in.
|
||||
* Using the multi placement method, placements are picked randomly.
|
||||
|
||||
### Available Items
|
||||
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
|
||||
* [Factorio Non-Progressive](https://wiki.factorio.com/Technologies) Note that these use the *internal names*. For example, `advanced-electronics`
|
||||
* [Factorio Progressive](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/factorio/Technologies.py#L374)
|
||||
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Items.py#L14)
|
||||
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/Items.py#L61)
|
||||
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Items.py#L8)
|
||||
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Items.py#L13)
|
||||
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/items.json)
|
||||
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Items.py#L11)
|
||||
|
||||
### Available Locations
|
||||
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L429)
|
||||
* [Factorio](https://wiki.factorio.com/Technologies) Same as items
|
||||
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Locations.py#L18)
|
||||
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LocationList.py#L38)
|
||||
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Locations.py#L17) This is a special
|
||||
case. The locations are "ItemPickup[number]" up to the maximum set in the yaml.
|
||||
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Locations.py)
|
||||
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/locations.json)
|
||||
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Locations.py#L13)
|
||||
|
||||
|
||||
A list of all available items and locations can also be found in the [server's datapackage](/api/datapackage).
|
||||
### Examples
|
||||
```yaml
|
||||
plando_items:
|
||||
# example block 1 - Timespinner
|
||||
- item:
|
||||
Empire Orb: 1
|
||||
Radiant Orb: 1
|
||||
location: Starter Chest 1
|
||||
from_pool: true
|
||||
world: true
|
||||
percentage: 50
|
||||
|
||||
# example block 2 - Ocarina of Time
|
||||
- items:
|
||||
Kokiri Sword: 1
|
||||
Biggoron Sword: 1
|
||||
Bow: 1
|
||||
Magic Meter: 1
|
||||
Progressive Strength Upgrade: 3
|
||||
Progressive Hookshot: 2
|
||||
locations:
|
||||
- Deku Tree Slingshot Chest
|
||||
- Dodongos Cavern Bomb Bag Chest
|
||||
- Jabu Jabus Belly Boomerang Chest
|
||||
- Bottom of the Well Lens of Truth Chest
|
||||
- Forest Temple Bow Chest
|
||||
- Fire Temple Megaton Hammer Chest
|
||||
- Water Temple Longshot Chest
|
||||
- Shadow Temple Hover Boots Chest
|
||||
- Spirit Temple Silver Gauntlets Chest
|
||||
world: false
|
||||
|
||||
# example block 3 - Slay the Spire
|
||||
- items:
|
||||
Boss Relic: 3
|
||||
locations:
|
||||
Boss Relic 1
|
||||
Boss Relic 2
|
||||
Boss Relic 3
|
||||
|
||||
# example block 4 - Factorio
|
||||
- items:
|
||||
progressive-electric-energy-distribution: 2
|
||||
electric-energy-accumulators: 1
|
||||
progressive-turret: 2
|
||||
locations:
|
||||
military
|
||||
gun-turret
|
||||
logistic-science-pack
|
||||
steel-processing
|
||||
percentage: 80
|
||||
force: true
|
||||
```
|
||||
1. This block has a 50% chance to occur, and if it does will place either the Empire Orb or Radiant Orb on another player's
|
||||
Starter Chest 1 and removes the chosen item from the item pool.
|
||||
2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots
|
||||
in their own dungeon major item chests.
|
||||
3. This block will always trigger and will lock boss relics on the bosses.
|
||||
4. This block has an 80% chance of occuring and when it does will place all but 1 of the items randomly among the four
|
||||
locations chosen here.
|
||||
|
||||
## Boss Plando
|
||||
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
|
||||
[relevant guide](/tutorial/zelda3/plando/en)
|
||||
|
||||
## Text Plando
|
||||
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
|
||||
[relevant guide](/tutorial/zelda3/plando/en)
|
||||
|
||||
## Connections Plando
|
||||
This is currently only supported by Minecraft and A Link to the Past. As the way that these games interact with
|
||||
their connections is different I will only explain the basics here while more specifics for Link to the Past connection
|
||||
plando can be found in its plando guide.
|
||||
* The options for connections are `percentage`, `entrance`, `exit`, and `direction`. Each of these options support subweights.
|
||||
* `percentage` is the percentage chance for this connection from 0 to 100 and defaults to 100.
|
||||
* Every connection has an `entrance` and an `exit`. These can be unlinked like in A Link to the Past insanity entrance shuffle.
|
||||
* `direction` can be `both`, `entrance`, or `exit` and determines in which direction this connection will operate.
|
||||
|
||||
[Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
|
||||
|
||||
[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Regions.py#L62)
|
||||
|
||||
### Examples
|
||||
```yaml
|
||||
plando_connections:
|
||||
# example block 1 - Link to the Past
|
||||
- entrance: Cave Shop (Lake Hylia)
|
||||
exit: Cave 45
|
||||
direction: entrance
|
||||
- entrance: Cave 45
|
||||
exit: Cave Shop (Lake Hylia)
|
||||
direction: entrance
|
||||
- entrance: Agahnims Tower
|
||||
exit: Old Man Cave Exit (West)
|
||||
direction: exit
|
||||
|
||||
# example block 2 - Minecraft
|
||||
- entrance: Overworld Structure 1
|
||||
exit: Nether Fortress
|
||||
direction: both
|
||||
- entrance: Overworld Structure 2
|
||||
exit: Village
|
||||
direction: both
|
||||
```
|
||||
|
||||
1. These connections are decoupled so going into the lake hylia cave shop will take you to the inside of cave 45 and
|
||||
when you leave the interior you will exit to the cave 45 ledge. Going into the cave 45 entrance will then take you to the
|
||||
lake hylia cave shop. Walking into the entrance for the old man cave and Agahnim's Tower entrance will both take you to
|
||||
their locations as normal but leaving old man cave will exit at Agahnim's Tower.
|
||||
2. This will force a nether fortress and a village to be the overworld structures for your game. Note that for the Minecraft
|
||||
connection plando to work structure shuffle must be enabled.
|
||||
79
WebHostLib/static/assets/tutorial/archipelago/setup_en.md
Normal file
79
WebHostLib/static/assets/tutorial/archipelago/setup_en.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Archipelago Setup Guide
|
||||
|
||||
## Installing the Archipelago software
|
||||
The most recent public release of Archipelago can be found [here](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
Run the exe file, and after accepting the license agreement you will be prompted on which components you would like to install.
|
||||
The generator allows you to generate multiworld games on your computer. The ROM setups are optional but are required if
|
||||
anyone in the game that you generate wants to play any of those games as they are needed to generate the relevant patch
|
||||
files. The server will allow you to host the multiworld on your machine but this also requires you to port forward. The
|
||||
default port for Archipelago is `38281`. If you are unsure how to do this there are plenty of other guides on the internet
|
||||
that will be more suited to your hardware. The `Clients` are what you use to connect your game to the multiworld. If the
|
||||
game/games you plan to play are available here go ahead and install these as well. If the game you choose to play is
|
||||
supported by Archipelago but not listed in the installation check the relevant tutorial.
|
||||
|
||||
## Generating a game
|
||||
|
||||
### Creating a YAML
|
||||
In a multiworld there must be one YAML per world. Any number of players can play on each world using either the game's
|
||||
native coop system or using archipelago's coop support. Each world will hold one slot in the multiworld and will have a
|
||||
slot name and, if the relevant game requires it, files to associate it with that multiworld. If multiple people plan to
|
||||
play in one world cooperatively then they will only need one YAML for their coop world, but if each player is planning on
|
||||
playing their own game then they will each need a YAML. These YAML files can be generated by going to the relevant game's
|
||||
player settings page, entering the name they want to use for the game, setting the options to what they would like to
|
||||
play with and then clicking on the export settings button. This will then download a YAML file that will contain all of
|
||||
these options and this can then be given to whoever is going to generate the game.
|
||||
|
||||
### Gather all player YAMLS
|
||||
All players that wish to play in the generated multiworld must have a YAML file which contains the settings that they wish to play with.
|
||||
A YAML is a file which contains human readable markup. In other words, this is a settings file kind of like an INI file or a TOML file.
|
||||
Each player can go to the game's player settings page in order to determine the settings how they want them and then download a YAML file containing these settings.
|
||||
After getting the YAML files of each participant for your multiworld game, these can all either be placed together in the
|
||||
`Archipelago\Players` folder or compressed into a ZIP folder to then be uploaded to the [website generator](/generate).
|
||||
If rolling locally ensure that the folder is clear of any files you do not wish to include in the game such as the
|
||||
included default player settings files.
|
||||
|
||||
#### Changing local host settings for generation
|
||||
Sometimes there are various settings that you may want to change before rolling a seed such as enabling race mode,
|
||||
auto-forfeit, plando support, or setting a password. All of these settings plus other options are able to be changed by
|
||||
modifying the `host.yaml` file in the base `Archipelago` folder. The settings chosen here are baked into
|
||||
the serverdata file that gets output with the other files after generation so if rolling locally ensure this file is edited
|
||||
to your liking *before* rolling the seed.
|
||||
|
||||
### Rolling the seed
|
||||
|
||||
#### On the Website
|
||||
After gathering the YAML files together in one location, select all of the files and compress them into a .zip folder.
|
||||
Next go to the [Start Playing](/start-playing) page and click on `generate a randomized game` to reach the website generator.
|
||||
Here, you can adjust some server settings such as forfeit rules and the cost for a player to use a hint before generation.
|
||||
After adjusting the host settings to your liking click on the Upload File button and using the explorer window that opens,
|
||||
navigate to the location where you zipped the player files and upload this zip. The page will generate your game and refresh
|
||||
multiple times to check on completion status. After the generation completes you will be on a Seed Info page that provides
|
||||
the seed, the date/time of creation, a link to the spoiler log, if available, and links to any rooms created from this seed.
|
||||
To begin playing, click on `Create New Room`, which will take you to the room page. From here you can navigate back to thse
|
||||
Seed Info page or to the Tracker page. Sharing the link to this page with your friends will provide them with the
|
||||
necessary info and files for them to connect to the multiworld.
|
||||
|
||||
#### Rolling using the generation program
|
||||
After gathering the YAML files together in the `Archipelago\Players` folder, run the program `ArchipelagoGenerate.exe`
|
||||
in the base `Archipelago` folder. This will then open a console window and either silently close itself or spit out an
|
||||
error. If you receive an error, it is likely due to an error in the YAML file. If the error is unhelpful in figuring
|
||||
out the issue asking in the ***#tech-support*** channel of our Discord for help with finding it is highly recommended.
|
||||
The generator will put a zip folder into your `Archipelago\output` folder with the format `AP_XXXXXXXXX`.zip.
|
||||
This contains the patch files and relevant mods for the players as well as the serverdata for the host.
|
||||
|
||||
## Hosting a multiworld
|
||||
|
||||
### Uploading the seed to the website
|
||||
The easiest and most recommended method is to generate the game on the website which will allow you to create a private
|
||||
room with all the necessary files you can share, as well as hosting the game and supporting item trackers for various games.
|
||||
If for some reason the seed was rolled on a machine, then either the resulting zip file or the resulting `AP_XXXXX.archipelago`
|
||||
inside the zip file can be uploaded to the [upload page](/uploads). This will give a page with the seed info and have a
|
||||
link to the spoiler if it exists. Click on Create New room and then share the link for the room with the other players
|
||||
so that they can download their patches or mods. The room will also have a link to a Multiworld Tracker and tell you
|
||||
what the players need to connect to from their clients.
|
||||
|
||||
### Hosting a seed locally
|
||||
For this we'll assume you have already port forwarding `38281` and have generated a seed that is still in the `outputs`
|
||||
folder. Next, you'll want to run `ArchipelagoServer.exe`. A window will open in order to open the multiworld data for the
|
||||
game. You can either use the generated zip folder or extract the .archipelago file and use it. If everything worked correctly the console window should tell you it's now hosting a game with the IP, port, and password that clients will need in order to connect.
|
||||
Extract the patch and mod files then send those to your friends, and you're done!
|
||||
69
WebHostLib/static/assets/tutorial/archipelago/triggers_en.md
Normal file
69
WebHostLib/static/assets/tutorial/archipelago/triggers_en.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Archipelago Triggers Guide
|
||||
|
||||
## What are triggers?
|
||||
Triggers allow you to customize your game settings by allowing you to define certain options or even a variety of
|
||||
settings to occur or "trigger" under certain conditions. These are essentially "if, then statements" for options in your game.
|
||||
A good example of what you can do with triggers is the custom
|
||||
[mercenary mode](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml)
|
||||
that was created using entirely triggers and plando. For more information on plando you can reference
|
||||
[this guide](/tutorial/archipelago/plando/en) or [this guide](/tutorial/zelda3/plando/en).
|
||||
|
||||
## Trigger use
|
||||
Triggers have to be defined in the root of the yaml file meaning it must be outside of a game section.
|
||||
The best place to do this is the bottom of the yaml.
|
||||
- Triggers comprise of the trigger section and then each trigger must have an `option_category`, `option_name`, and
|
||||
`option_result` from which it will react to and then an `options` section where the definition of what will happen.
|
||||
- `option_category` is the defining section from which the option is defined in.
|
||||
- Example: `A Link to the Past`
|
||||
- This is the root category the option is located in. If the option you're triggering off of is in root then you
|
||||
would use `null`, otherwise this is the game for which you want this option trigger to activate.
|
||||
- `option_name` is the option setting from which the triggered choice is going to react to.
|
||||
- Example: `shop_item_slots`
|
||||
- This can be any option from any category defined in the yaml file in either root or a game section except for `game`.
|
||||
- `option_result` is the result of this option setting from which you would like to react.
|
||||
- Example: `15`
|
||||
- Each trigger must be used for exactly one option result. If you would like the same thing to occur with multiple
|
||||
results you would need multiple triggers for this.
|
||||
- `options` is where you define what will happen when this is detected. This can be something as simple as ensuring
|
||||
another option also gets selected or placing an item in a certain location.
|
||||
- Example:
|
||||
```yaml
|
||||
A Link to the Past:
|
||||
start_inventory:
|
||||
Rupees (300): 2
|
||||
```
|
||||
This format must be:
|
||||
|
||||
```yaml
|
||||
root option:
|
||||
option to change:
|
||||
desired result
|
||||
```
|
||||
|
||||
### Examples
|
||||
The above examples all together will end up looking like this:
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: A Link to the Past
|
||||
option_name: shop_item_slots
|
||||
option_result: 15
|
||||
options:
|
||||
A Link to the Past:
|
||||
start_inventory:
|
||||
Rupees(300): 2
|
||||
```
|
||||
|
||||
For this example if the generator happens to roll 15 shuffled in shop item slots for your game you'll be granted 600 rupees at the beginning.
|
||||
These can also be used to change other options.
|
||||
|
||||
For example:
|
||||
```yaml
|
||||
triggers:
|
||||
- option_category: Timespinner
|
||||
option_name: SpecificKeycards
|
||||
option_result: true
|
||||
options:
|
||||
Timespinner:
|
||||
Inverted: true
|
||||
```
|
||||
In this example if your world happens to roll SpecificKeycards then your game will also start in inverted.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
BIN
WebHostLib/static/assets/tutorial/factorio/factorio-download.png
Normal file
BIN
WebHostLib/static/assets/tutorial/factorio/factorio-download.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 627 KiB |
@@ -1,49 +1,128 @@
|
||||
# Factorio Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
##### Players
|
||||
- [Factorio](https://factorio.com) - Needed by Players and Hosts
|
||||
|
||||
### Server Host
|
||||
- [Factorio](https://factorio.com)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
##### Server Hosts
|
||||
- [Factorio](https://factorio.com) - Needed by Players and Hosts
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) - Needed by Hosts
|
||||
|
||||
### Players
|
||||
- [Factorio](https://factorio.com)
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
## General Concept
|
||||
### What is a config file and why do I need one?
|
||||
Your config file contains a set of configuration options which provide the generator with information about how it
|
||||
should generate your game. Each player of a multiworld will provide their own config file. This setup allows each
|
||||
player to enjoy an experience customized for their taste, and different players in the same multiworld can all have
|
||||
different options.
|
||||
|
||||
One Server Host exists per Factorio World in an Archipelago Multiworld, any number of modded Factorio players can then connect to that world. From the view of Archipelago, this Factorio host is a client.
|
||||
## Installation Procedures
|
||||
### Where do I get a config file?
|
||||
The [Player Settings](/games/Factorio/player-settings) page on the website allows you to configure
|
||||
your personal settings and export a config file from them.
|
||||
|
||||
### Verifying your config file
|
||||
If you would like to validate your config file to make sure it works, you may do so on
|
||||
the [YAML Validator](/mysterycheck) page.
|
||||
|
||||
## Connecting to Someone Else's Factorio Game
|
||||
Connecting to someone else's game is the simplest way to play Factorio with Archipelago. It allows multiple
|
||||
people to play in a single world, all contributing to the completion of the seed.
|
||||
|
||||
1. Acquire the Archipelago mod for this seed. It should be named `AP_*.zip`, where `*` is the seed number.
|
||||
2. Copy the mod file into your Factorio `mods` folder, which by default is located at:
|
||||
`C:\Users\YourName\AppData\Roaming\Factorio\mods`
|
||||
3. Get the server address from the person hosting the game you are joining
|
||||
4. Launch Factorio
|
||||
5. Click on "Multiplayer" in the main menu
|
||||
6. Click on "Connect to address"
|
||||
7. Enter the address into this box
|
||||
8. Click "Connect"
|
||||
|
||||
## Prepare to Host Your Own Factorio Game
|
||||
|
||||
### Defining Some Terms
|
||||
In Archipelago, multiple Factorio worlds may be played simultaneously. Each of these worlds must be hosted by a
|
||||
Factorio server, which is connected to the Archipelago Server via middleware.
|
||||
|
||||
This guide uses the following terms to refer to the software:
|
||||
- **Factorio Client** - The Factorio instance which will be used to play the game.
|
||||
- **Factorio Server** - The Factorio instance which will be used to host the Factorio world. Any number of
|
||||
Factorio Clients may connect to this server.
|
||||
- **Archipelago Client** - The middleware software used to connect the Factorio Server to the Archipelago Server.
|
||||
- **Archipelago Server** - The central Archipelago server, which connects all games to each other.
|
||||
|
||||
### What a Playable State Looks Like
|
||||
- An Archipelago Server
|
||||
- The generated Factorio Mod, created as a result of running `ArchipelagoGenerate.exe`
|
||||
- One running instance of `ArchipelagoFactorioClient.exe` (the Archipelago Client) per Factorio world
|
||||
- A running modded Factorio Server, which should have been started by the Archipelago Client automatically
|
||||
- A running modded Factorio Client
|
||||
|
||||
### 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.
|
||||
1. Download the latest Factorio from https://factorio.com/download for your system, for Windows the recommendation is "win64-manual".
|
||||
To play Factorio with Archipelago, a dedicated server setup is required. This dedicated Factorio Server must be
|
||||
installed separately from your main Factorio Client installation. The recommended way to install two instances
|
||||
of Factorio on your computer is to download the Factorio installer file directly from
|
||||
[factorio.com](https://factorio.com/download).
|
||||
|
||||
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`:
|
||||
```ini
|
||||
config-path=__PATH__executable__/../../config
|
||||
use-system-read-write-data-directories=false
|
||||
#### If you purchased Factorio on Steam, GOG, etc.
|
||||
You can register your copy of Factorio on [factorio.com](https://factorio.com/). You will be required to
|
||||
create an account, if you have not done so already. As part of that process, you will be able to enter your
|
||||
Factorio product code. This will allow you to download the game directly from their website.
|
||||
|
||||
#### Download the Standalone Version
|
||||
It is recommended to download the standalone version of Factorio for use as a dedicated server. Doing so prevents
|
||||
any potential conflicts with your currently-installed version of Factorio. Download the file by clicking on the
|
||||
button appropriate to your operating system, and extract the folder to a convenient location (we recommend
|
||||
C:\Factorio or similar).<br />
|
||||
<img src="/static/assets/tutorial/factorio/factorio-download.png" />
|
||||
|
||||
Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`.
|
||||
You will be asked to log-in to your Factorio account using the same credentials you used on Factorio's website.
|
||||
After you have logged in, you may close the game.
|
||||
|
||||
#### Configure your Archipelago Installation
|
||||
You must modify your `host.yaml` file inside your Archipelago installation directory so that it points to your
|
||||
standalone Factorio executable. Here is an example of the appropriate setup, note the double `\\` are required:
|
||||
```yaml
|
||||
factorio_options:
|
||||
executable: C:\\factorio\\bin\\x64\\factorio"
|
||||
```
|
||||
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.
|
||||
|
||||
With all that complete, you are now able to...
|
||||
|
||||
### Player Setup
|
||||
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer.
|
||||
## Host Your Own Factorio Game
|
||||
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
1. Obtain the Factorio mod for this Archipelago seed. It should be named `AP_*.zip`, where `*` is the seed number.
|
||||
2. Install the mod into your Factorio Server by copying the zip file into the `mods` folder.
|
||||
3. Install the mod into your Factorio Client by copying the zip file into the `mods` folder, which is likely located
|
||||
at `C:\Users\YourName\AppData\Roaming\Factorio\mods`.
|
||||
4. Obtain the Archipelago Server address from the website's host room, or from the server host.
|
||||
5. Run your Archipelago Client, which is named `ArchilepagoFactorioClient.exe`. This was installed along with
|
||||
Archipelago if you chose to include it during the installation process.
|
||||
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
|
||||
<br /><img src="/static/assets/tutorial/factorio/connect-to-ap-server.png" />
|
||||
7. Launch your Factorio Client
|
||||
8. Click on "Multiplayer" in the main menu
|
||||
9. Click on "Connect to address"
|
||||
10. Enter `localhost` into the server address box
|
||||
11. Click "Connect"
|
||||
|
||||
1. Install the generated Factorio AP Mod (would be in <Factorio Directory>/Mods after step 2 of Setup)
|
||||
For additional client features, issue the `/help` command in the Archipelago Client. Once connected to the AP
|
||||
server, you can also issue the `!help` command to learn about additional commands like `!hint`.
|
||||
|
||||
2. Run FactorioClient, it should launch a Factorio server, which you can control with `/factorio <original factorio commands>`,
|
||||
|
||||
* It should start up, create a world and become ready for Factorio connections.
|
||||
## Allowing Other People to Join Your Game
|
||||
1. Ensure your Archipelago Client is running.
|
||||
2. Ensure port `34197` is forwarded to the computer running the Archipelago Client.
|
||||
3. Obtain your IP address by visiting [this website](https://whatismyip.com/).
|
||||
4. Provide your IP address to anyone you want to join your game, and have them follow the steps for
|
||||
"Connecting to Someone Else's Factorio Game" above.
|
||||
|
||||
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.
|
||||
## Troubleshooting
|
||||
In case any problems should occur, the Archipelago Client will create a file `FactorioClient.txt` in the `/logs`.
|
||||
The contents of this file may help you troubleshoot an issue on your own and is vital for requesting help from other
|
||||
people in Archipelago.
|
||||
|
||||
* / 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.
|
||||
## Additional Resources
|
||||
- [Alternate Tutorial by Umenen](https://docs.google.com/document/d/1yZPAaXB-QcetD8FJsmsFrenAHO5V6Y2ctMAyIoT9jS4)
|
||||
- [Factorio Speedrun Guide](https://www.youtube.com/watch?v=ExLrmK1c7tA)
|
||||
- [Factorio Wiki](https://wiki.factorio.com/)
|
||||
|
||||
114
WebHostLib/static/assets/tutorial/ff1/multiworld_en.md
Normal file
114
WebHostLib/static/assets/tutorial/ff1/multiworld_en.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Final Fantasy 1 (NES) Multiworld Setup Guide
|
||||
|
||||
## Required Software
|
||||
- The FF1Client which is bundled with [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- The [BizHawk](http://tasvideos.org/BizHawk.html) emulator. Versions 2.3.1 and higher are supported.
|
||||
Version 2.7 is recommended
|
||||
- Your Final Fantasy (USA Edition) ROM file, probably named `Final Fantasy (USA).nes`. Neither Archipelago.gg nor the
|
||||
Final Fantasy Randomizer Community can supply you with this.
|
||||
|
||||
## Installation Procedures
|
||||
1. Download and install the latest version of [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
1. On Windows, download Setup.Archipelago.<HighestVersion>.exe and run it
|
||||
2. Assign Bizhawk version 2.3.1 or higher as your default program for launching `.nes` files.
|
||||
1. Extract your Bizhawk folder to your Desktop, or somewhere you will remember. Below are optional additional
|
||||
steps for loading ROMs more conveniently
|
||||
1. Right-click on a ROM file and select **Open with...**
|
||||
2. Check the box next to **Always use this app to open .nes files**
|
||||
3. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
|
||||
4. Browse for `EmuHawk.exe` located inside your Bizhawk folder (from step 1) and click **Open**.
|
||||
|
||||
## Playing a Multiworld
|
||||
Playing a multiworld on Archipelago.gg has 3 key components:
|
||||
1. The Server which is hosting a game for all players.
|
||||
2. The Client Program. For Final Fantasy 1, it is a standalone program but other randomizers may build it in.
|
||||
3. The Game itself, in this case running on Bizhawk, which then connects to the Client running on your computer.
|
||||
|
||||
To set this up the following steps are required:
|
||||
1. (Each Player) Generate your own yaml file and randomized ROM
|
||||
2. (Host Only) Generate a randomized game with you and 0 or more players using Archipelago
|
||||
3. (Host Only) Run the Archipelago Server
|
||||
4. (Each Player) Run your client program and connect it to the Server
|
||||
5. (Each Player) Run your game and connect it to your client program
|
||||
6. (Each Player) Play the game and have fun!
|
||||
|
||||
### Obtaining your Archipelago yaml file and randomized ROM
|
||||
Unlike most other Archipelago.gg games Final Fantasy 1 is randomized by the
|
||||
[main randomizer](https://finalfantasyrandomizer.com/). Generate a game by going to the site and performing the
|
||||
following steps:
|
||||
1. Select the randomization options (also known as `Flags` in the community) of your choice. If you do not know what
|
||||
you prefer, or it is your first time playing select the "Archipelago" preset on the main page.
|
||||
2. Go to the `Beta` tab and ensure `Archipelago` is enabled. Set your player name to any name that represents you.
|
||||
3. Upload you `Final Fantasy(USA).nes` (and click `Remember ROM` for the future!)
|
||||
4. Press the `NEW` button beside `Seed` a few times
|
||||
5. Click `GENERATE ROM`
|
||||
|
||||
It should download two files. One is the `*.nes` file which your emulator will run and the other is the yaml file
|
||||
required by Archipelago.gg
|
||||
|
||||
### Generating the Multiworld and Starting the Server
|
||||
The game can be generated locally or by Archipelago.gg.
|
||||
|
||||
#### Generating on Archipelago.gg (Recommended)
|
||||
1. Gather all yaml files
|
||||
2. Create a zip file containing all of the yaml files. Make sure it is a `*.zip` not a `*.7z` or a `*.rar`
|
||||
3. Navigate to the [Generate Page](https://archipelago.gg/generate) and click `Upload File`
|
||||
1. For your first game keep `Forfeit Permission` as `Automatic on goal completion`. Forfeiting actually means
|
||||
giving out all of the items remaining in your game in this case so you do not block anyone else.
|
||||
2. For your first game keep `Hint Cost` at 10%
|
||||
4. Select your zip file
|
||||
|
||||
#### Generating Locally
|
||||
1. Navigate to your Archipelago install directory
|
||||
2. Empty the `Players` directory then fill it with one yaml per player including your own which you got from the
|
||||
finalfantasyrandomizer website above
|
||||
3. Run `ArchipelagoGenerate.exe` (double-click it in File Explorer)
|
||||
4. You will find your generated game in the `output` directory
|
||||
|
||||
#### Starting the server
|
||||
If you generated on Archipelago.gg click `Create New Room` on the results page to start your server
|
||||
If you generated locally simply navigate to the [Host Game Page](https://archipelago.gg/uploads) and upload the file
|
||||
in the `output` directory
|
||||
|
||||
### Running the Client Program and Connecting to the Server
|
||||
1. Navigate to your Archipelago install folder and run `ArchipelagoFF1Client.exe`
|
||||
2. Notice the `/connect command` on the server hosting page (It should look like `/connect archipelago.gg:*****` where
|
||||
***** are numbers)
|
||||
3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should
|
||||
already say `archipelago.gg`) and click `connect`
|
||||
|
||||
#### Running Your Game and Connecting to the Client Program
|
||||
1. Open Bizhawk 2.3.1 or higher and load your ROM OR
|
||||
click your ROM file if it is already associated with the extension `*.nes`
|
||||
2. Click on the Tools menu and click on **Lua Console**
|
||||
3. Click the folder button to open a new Lua script. (CTL-O or **Script** -> **Open Script**)
|
||||
4. Navigate to the location you installed Archipelago to. Open data/lua/FF1/ff1_connector.lua
|
||||
1. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception
|
||||
close your emulator entirely, restart it and re-run these steps
|
||||
2. If it says `Must use a version of bizhawk 2.3.1 or higher`, double-check your Bizhawk version by clicking
|
||||
**Help** -> **About**
|
||||
|
||||
### Play the game
|
||||
When the client shows both NES and server are connected you are good to go. You can check the connection status of the
|
||||
NES at any time by running `/nes`
|
||||
|
||||
### Helpful Commands
|
||||
Commands are broken into two types: `/` and `!` commands. The difference is that `/commands` are local to your machine
|
||||
and game whereas `!` commands ask the server. Most of the time you can use local commands.
|
||||
|
||||
#### Local Commands
|
||||
- `/connect <address with port number>` connect to the multiworld server
|
||||
- `/disconnect` if you accidentally connected to the wrong port run this to disconnect and then reconnect using
|
||||
- `/nes` Shows the current status of the NES connection
|
||||
- `/received` Displays all the items you have found or been sent
|
||||
- `/missing` Displays all the locations along with their current status (checked/missing)
|
||||
- Just typing anything will broadcast a message to all players
|
||||
|
||||
#### Remote Commands
|
||||
- `!hint <item name>` Tells you at which location in whose game your Item is. Note you need to have checked some locations
|
||||
to earn a hint. You can check how many you have by just running `!hint`
|
||||
- `!forfeit` If you didn't turn on auto-forfeit or you allowed forfeiting prior to goal completion. Remember that
|
||||
"forfeiting" actually means sending out your remaining items in your world
|
||||
|
||||
#### Host only (on Archipelago.gg)
|
||||
`/forfeit <Player Name>` Forfeits someone regardless of settings and game completion status
|
||||
@@ -2,52 +2,8 @@
|
||||
|
||||
## 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`
|
||||
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) (update 1.17.1)
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) (select `Minecraft Client` during installation.)
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
@@ -58,56 +14,7 @@ each player to enjoy an experience customized for their taste, and different pla
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a YAML file?
|
||||
A basic minecraft yaml will look like this.
|
||||
```yaml
|
||||
description: Template Name
|
||||
# Your name in-game. Spaces will be replaced with underscores and
|
||||
# there is a 16 character limit
|
||||
name: YourName
|
||||
game: Minecraft
|
||||
|
||||
# Shared Options supported by all games:
|
||||
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
|
||||
|
||||
# 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 with XP rewards.
|
||||
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
|
||||
|
||||
#enables shuffling of villages, outposts, fortresses, bastions, and end cities.
|
||||
shuffle_structures:
|
||||
on: 1
|
||||
off: 0
|
||||
```
|
||||
|
||||
you can customize your settings by visiting the [minecraft player settings](/games/Minecraft/player-settings)
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
@@ -118,19 +25,34 @@ 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.
|
||||
make sure to leave this window open as this is your server console.
|
||||
|
||||
### Connect to the MultiServer
|
||||
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
|
||||
status by typing `/op YourMinecraftUsername` in the forge server console then connecting in your Minecraft client.
|
||||
Using minecraft 1.17.1 connect to the server `localhost`.
|
||||
|
||||
Once in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
|
||||
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281. `(Password)`
|
||||
is only required if the Archipleago server you are using has a password set.
|
||||
|
||||
### Play the game
|
||||
When the console tells you that you have joined the room, you're ready to begin playing. Congratulations
|
||||
When the console tells you that you have joined the room, you're all set. Congratulations
|
||||
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
|
||||
forge server.
|
||||
forge server. to star the game once everyone is ready type `/start`.
|
||||
|
||||
### Useful commands
|
||||
- `!help` displays a list all server commands
|
||||
- `!hint` will display how many hint points you have, along with any hints that have been given that are related to your game.
|
||||
- `!hint (item)` will ask the server to tell you where (item) is
|
||||
- `!hint_location (location)` will ask the server to tell you what item is on (location)
|
||||
|
||||
## Manual Installation
|
||||
it is highly recommended to ues the Archipelago installer to handle the installation of the forge server for you.
|
||||
support will not be given for those wishing to manually install forge. but for those of you who know how, and wish to
|
||||
do so the following links are the versions of the software we use.
|
||||
### Manual install Software links
|
||||
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.17.1.html)
|
||||
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
|
||||
**DO NOT INSTALL THIS ON YOUR CLIENT**
|
||||
- [Java 16](https://docs.aws.amazon.com/corretto/latest/corretto-16-ug/downloads-list.html)
|
||||
|
||||
|
||||
@@ -1,52 +1,12 @@
|
||||
# Guia instalación de Minecraft Randomizer
|
||||
|
||||
#Instalacion automatica para el huesped de partida
|
||||
- descarga e instala [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and activa el modulo `Minecraft Client`
|
||||
|
||||
## Software Requerido
|
||||
|
||||
### Servidor
|
||||
- [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)
|
||||
|
||||
### Jugadores
|
||||
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
|
||||
|
||||
## Procedimiento de instalación
|
||||
|
||||
### Instalación de servidor dedicado
|
||||
Solo una persona ha de realizar este proceso y hospedar un servidor dedicado para que los demas jueguen conectandose a él.
|
||||
1. Descarga el instalador de **Minecraft Forge** 1.16.15 desde el enlace proporcionado, siempre asegurandose de bajar la version mas reciente.
|
||||
|
||||
2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elije **install server**.
|
||||
- En esta pagina elegiras ademas donde instalar el servidor, importante recordar esta localización en el siguiente paso.
|
||||
|
||||
3. Navega al directorio donde hayas instalado el servidor y abre `forge-1.16.5-xx.x.x.jar`
|
||||
- La primera vez que lances el servidor se cerrara (o no aparecerá nada en absoluto), debería haber un fichero nuevo en el directorio llamado `eula.txt`, el cual que contiene un enlace al EULA de minecraft, cambia la linea a `eula=true` para aceptar el EULA y poder utilizar el software de servidor.
|
||||
- Esto creara la estructura de directorios apropiada para el siguiente paso
|
||||
|
||||
4. Coloca el fichero `aprandomizer-x.x.x.jar` del segundo enlace en el directorio `mods`
|
||||
- Cuando se ejecute el servidor de nuevo, generara el directorio `APData` que se necesitara para jugar
|
||||
|
||||
### Instalación basica para jugadores
|
||||
- Compra e instala Minecraft a traves del tercer enlace.
|
||||
**Y listo!**.
|
||||
Los jugadores solo necesitan una version no modificada de Minecraft para jugar!
|
||||
|
||||
### Instalación avanzada para jugadores
|
||||
***Esto no es requerido para jugar a minecraft randomizado.***
|
||||
Sin embargo lo recomendamos porque hace la experiencia mas llevadera.
|
||||
|
||||
#### Recomended Mods
|
||||
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
|
||||
|
||||
|
||||
1. Instala y ejecuta Minecraft al menos una vez.
|
||||
2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elige **install client**.
|
||||
- Ejecuta Minecraft forge al menos una vez para generar los directorios necesarios para el siguiente paso.
|
||||
3. Navega a la carpeta de instalación de Minecraft y colocal los mods que quieras en el directorio `mods`
|
||||
- Los directorios por defecto de instalación son:
|
||||
- Windows `%APPDATA%\.minecraft\mods`
|
||||
- macOS `~/Library/Application Support/minecraft/mods`
|
||||
- Linux `~/.minecraft/mods`
|
||||
|
||||
## Configura tu fichero YAML
|
||||
|
||||
### Que es un fichero YAML y potque necesito uno?
|
||||
@@ -58,42 +18,71 @@ pueden tener diferentes opciones
|
||||
### Where do I get a YAML file?
|
||||
Un fichero basico yaml para minecraft tendra este aspecto.
|
||||
```yaml
|
||||
# Usado para describir tu yaml. Util si tienes multiples ficheros
|
||||
description: Template Name
|
||||
# Tu nombre en el juego. Los espacios son reemplazados por guiones bajos, limitado a 16 caracteres
|
||||
name: YourName
|
||||
description: Basic Minecraft Yaml
|
||||
# Tu nombre en el juego. Espacios seran sustituidos por guinoes bajos y
|
||||
# hay un limite de 16 caracteres
|
||||
name: TuNombre
|
||||
game: Minecraft
|
||||
accessibility: locations
|
||||
# Recomendado no activar esto ya que el pool de objetos de Minecraft es bastante escueto, ademas hay muchas maneras alternativas de obtener los objetivos de Minecraft.
|
||||
progression_balancing: off
|
||||
# Cuantos avances se necesitan para hacer aparecer el Ender Dragon y acabar el juego. few = 30, normal = 50 , many = 70
|
||||
advancement_goal:
|
||||
few: 0
|
||||
normal: 1
|
||||
many: 0
|
||||
# Modifica el nivel de objetos lógicamente requeridos para explorar areas peligrosas y pelear contra jefes.
|
||||
combat_difficulty:
|
||||
easy: 0
|
||||
normal: 1
|
||||
hard: 0
|
||||
# Avances que sean tediosos o basados en suerte tendran simplemente experiencia o cosas no necesarias
|
||||
include_hard_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Los avances extremadamente difíciles no seran requeridos; esto afecta a How Did We Get Here? y Adventuring Time.
|
||||
include_insane_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Los avances posteriores a Ender Dragon no tendrán objetos necesarios para que otros jugadores en el caso de un MW acaben su partida.
|
||||
include_postgame_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
# Actualmente desactivado; permite la mezcla de pueblos, puestos, fortalezas, bastiones y cuidades.
|
||||
shuffle_structures:
|
||||
on: 0
|
||||
off: 1
|
||||
```
|
||||
|
||||
# Opciones compartidas por todos los juegos:
|
||||
accessibility: locations
|
||||
progression_balancing: on
|
||||
# Opciones Especficicas para Minecraft
|
||||
|
||||
Minecraft:
|
||||
# Numero de logros requeridos (87 max) para que aparezca el Ender Dragon y completar el juego.
|
||||
advancement_goal: 50
|
||||
|
||||
# Numero de trozos de huevo de dragon a obtener (30 max) antes de que el Ender Dragon aparezca.
|
||||
egg_shards_required: 10
|
||||
|
||||
# Numero de huevos disponibles en la partida (30 max).
|
||||
egg_shards_available: 15
|
||||
|
||||
# Modifica el nivel de objetos logicamente requeridos para
|
||||
# explorar areas peligrosas y luchar contra jefes.
|
||||
combat_difficulty:
|
||||
easy: 0
|
||||
normal: 1
|
||||
hard: 0
|
||||
|
||||
# Si off, los logros que dependan de suerte o sean tediosos tendran objetos de apoyo, no necesarios para completar el juego.
|
||||
include_hard_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Si off, los logros muy dificiles tendran objetos de apoyo, no necesarios para completar el juego.
|
||||
# Solo afecta a How Did We Get Here? and Adventuring Time.
|
||||
include_insane_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Algunos logros requieren derrotar al Ender Dragon;
|
||||
# Si esto se queda en off, dichos logros no tendran objetos necesarios.
|
||||
include_postgame_advancements:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Permite el mezclado de villas, puesto, fortalezas, bastiones y ciudades de END.
|
||||
shuffle_structures:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Añade brujulas de estructura al juego,
|
||||
# apuntaran a la estructura correspondiente mas cercana.
|
||||
structure_compasses:
|
||||
on: 0
|
||||
off: 1
|
||||
|
||||
# Reemplaza un porcentaje de objetos innecesarios por trampas abeja
|
||||
# las cuales crearan multiples abejas agresivas alrededor de los jugadores cuando se reciba.
|
||||
bee_traps:
|
||||
0: 1
|
||||
25: 0
|
||||
50: 0
|
||||
75: 0
|
||||
100: 0
|
||||
```
|
||||
|
||||
## Unirse a un juego MultiWorld
|
||||
|
||||
@@ -104,18 +93,39 @@ Cuando te unes a un juego multiworld, se te pedirá que entregues tu fichero YAM
|
||||
Una vez la generación acabe, el anfitrión te dará un enlace a tu fichero de datos o un zip con los ficheros de todos.
|
||||
Tu fichero de datos tiene una extensión `.apmc`.
|
||||
|
||||
Pon tu fichero de datos en el directorio `APData` de tu forge server. Asegurate de eliminar los que hubiera anteriormente
|
||||
|
||||
Haz doble click en tu fichero `.apmc` para que se arranque el cliente de minecraft y el servidor forge se ejecute.
|
||||
|
||||
### Conectar al multiserver
|
||||
Despues de poner tu fichero en el directorio `APData`, arranca el Forge server y asegurate que tienes el estado OP
|
||||
tecleando `/op TuUsuarioMinecraft` en la consola del servidor y entonces conectate con tu cliente Minecraft.
|
||||
|
||||
Una vez en juego introduce `/connect <AP-Address> (<Password>)` donde `<AP-Address>` es la dirección del servidor
|
||||
Archipelago. `(<Password>)`
|
||||
Una vez en juego introduce `/connect <AP-Address> (Port) (<Password>)` donde `<AP-Address>` es la dirección del servidor. `(Port)` solo es requerido si el servidor Archipelago no esta usando el puerto por defecto 38281.
|
||||
`(<Password>)`
|
||||
solo se necesita si el servidor Archipleago tiene un password activo.
|
||||
|
||||
|
||||
### Jugar al juego
|
||||
Cuando la consola te diga que te has unido a la sala, estas lista/o para empezar a jugar. Felicidades
|
||||
por unirte exitosamente a un juego multiworld! Llegados a este punto cualquier jugador adicional puede conectarse a tu servidor forge.
|
||||
|
||||
## Procedimiento de instalación manual
|
||||
Solo es requerido si quieres usar una instalacion de forge por ti mismo, recomendamos usar el instalador de Archipelago
|
||||
###Software Requerido
|
||||
- [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)
|
||||
**NO INSTALES ESTO EN TU CLIENTE MINECRAFT**
|
||||
|
||||
|
||||
### Instalación de servidor dedicado
|
||||
Solo una persona ha de realizar este proceso y hospedar un servidor dedicado para que los demas jueguen conectandose a él.
|
||||
1. Descarga el instalador de **Minecraft Forge** 1.16.5 desde el enlace proporcionado, siempre asegurandose de bajar la version mas reciente.
|
||||
|
||||
2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elije **install server**.
|
||||
- En esta pagina elegiras ademas donde instalar el servidor, importante recordar esta localización en el siguiente paso.
|
||||
|
||||
3. Navega al directorio donde hayas instalado el servidor y abre `forge-1.16.5-xx.x.x.jar`
|
||||
- La primera vez que lances el servidor se cerrara (o no aparecerá nada en absoluto), debería haber un fichero nuevo en el directorio llamado `eula.txt`, el cual que contiene un enlace al EULA de minecraft, cambia la linea a `eula=true` para aceptar el EULA y poder utilizar el software de servidor.
|
||||
- Esto creara la estructura de directorios apropiada para el siguiente paso
|
||||
|
||||
4. Coloca el fichero `aprandomizer-x.x.x.jar` del segundo enlace en el directorio `mods`
|
||||
- Cuando se ejecute el servidor de nuevo, generara el directorio `APData` que se necesitara para jugar
|
||||
|
||||
98
WebHostLib/static/assets/tutorial/ror2/setup_en.md
Normal file
98
WebHostLib/static/assets/tutorial/ror2/setup_en.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 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/](https://thunderstore.io/package/ebkr/r2modman/)
|
||||
|
||||
### Install Archipelago Mod using r2modman
|
||||
You can install the Archipelago mod using r2modman in one of two ways.
|
||||
|
||||
[https://thunderstore.io/package/ArchipelagoMW/Archipelago/](https://thunderstore.io/package/ArchipelagoMW/Archipelago/)
|
||||
|
||||
One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
|
||||
|
||||
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
|
||||
item_weights:
|
||||
default: 50
|
||||
new: 0
|
||||
uncommon: 0
|
||||
legendary: 0
|
||||
lunartic: 0
|
||||
chaos: 0
|
||||
no_scraps: 0
|
||||
even: 0
|
||||
scraps_only: 0
|
||||
item_pool_presets: true
|
||||
# custom item weights
|
||||
green_scrap: 16
|
||||
red_scrap: 4
|
||||
yellow_scrap: 1
|
||||
white_scrap: 32
|
||||
common_item: 64
|
||||
uncommon_item: 32
|
||||
legendary_item: 8
|
||||
boss_item: 4
|
||||
lunar_item: 16
|
||||
equipment: 32
|
||||
```
|
||||
|
||||
| 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 - 100 |
|
||||
| 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 |
|
||||
| item_weights | Each option here is a preset item weight that can be used to customize your generate item pool with certain settings. | default, new, uncommon, legendary, lunartic, chaos, no_scraps, even, scraps_only |
|
||||
| item_pool_presets | A simple toggle to determine whether the item_weight presets are used or the custom item pool as defined below | true/false |
|
||||
| custom item weights | Each defined item here is a single item in the pool that will have a weight against the other items when the item pool gets generated. These values can be modified to adjust how frequently certain items appear | 0-100|
|
||||
|
||||
|
||||
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)
|
||||
|
||||
The player will have the default preset generated item pool with the custom item weights being ignored. (item_weights: default and item_pool_presets: true)
|
||||
@@ -0,0 +1,119 @@
|
||||
# Secret of Evermore Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [SNI](https://github.com/alttpo/sni/releases) v0.0.59 or newer (included in Archipelago 0.2.1 setup)
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of connecting to SNI with ROM access
|
||||
- [snes9x-rr](https://github.com/gocha/snes9x-rr/releases) or
|
||||
- [BizHawk](http://tasvideos.org/BizHawk.html) or
|
||||
- [bsnes-plus-nwa](https://github.com/black-sliver/bsnes-plus)
|
||||
- Or SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Secret of Evermore US ROM file, probably named `Secret of Evermore (USA).sfc`
|
||||
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
### What is a config file and why do I need one?
|
||||
Your config file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own config file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a config file?
|
||||
The [Player Settings](/games/Secret%20of%20Evermore/player-settings) page on the website allows you to configure your
|
||||
personal settings and export a config file from them.
|
||||
|
||||
### Verifying your config file
|
||||
If you would like to validate your config file to make sure it works, you may do so on the
|
||||
[YAML Validator](/mysterycheck) page.
|
||||
|
||||
## Generating a Single-Player Game
|
||||
Stand-alone "Evermizer" has a way of balancing single-player games, but may not always be on par feature-wise.
|
||||
Head over to [evermizer.com](https://evermizer.com) if you want to try the official stand-alone, otherwise read below.
|
||||
|
||||
1. Navigate to the [Player Settings](/games/Secret%20of%20Evermore/player-settings) page, configure your options, and
|
||||
click the "Generate Game" button.
|
||||
2. You will be presented with a "Seed Info" page.
|
||||
3. Click the "Create New Room" link.
|
||||
4. You will be presented with a server page, from which you can download your patch file.
|
||||
5. Run your patch file through [apbpatch](https://evermizer.com/apbpatch) and load it in your emulator or console.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your patch file and create your ROM
|
||||
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
|
||||
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
|
||||
everyone's patch files. Your patch file should have a `.apsoe` extension.
|
||||
|
||||
Put your patch file on your desktop or somewhere convenient, open [apbpatch](https://evermizer.com/apbpatch) and
|
||||
generate your ROM from it. Load the ROM file in your emulator or console.
|
||||
|
||||
### Connect to SNI
|
||||
|
||||
#### With an emulator
|
||||
Start SNI either from the Archipelago install folder or the stand-alone version.
|
||||
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
|
||||
|
||||
##### snes9x-rr
|
||||
1. Load your ROM file if it hasn't already been loaded.
|
||||
2. Click on the File menu and hover on **Lua Scripting**
|
||||
3. Click on **New Lua Script Window...**
|
||||
4. In the new window, click **Browse...**
|
||||
5. Select the `Connector.lua` file from your SNI installation:
|
||||
* `SNI/lua/x86/Connector.lua` for 32bit snes9x-rr or
|
||||
* `SNI/lua/x64/Connector.lua` for "x64" snes9x-rr
|
||||
6. Leave the Lua window open while you are playing.
|
||||
|
||||
* If the script window complains about missing `socket.dll` make sure it is in the lua directory.
|
||||
* If the script window complains about "Bad EXE format", use the other Connector above (x86 <-> x64)
|
||||
|
||||
##### BizHawk
|
||||
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
|
||||
these menu options:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Once you have changed the loaded core, you must restart BizHawk.
|
||||
2. Load your ROM file if it hasn't already been loaded.
|
||||
3. Click on the Tools menu and click on **Lua Console**
|
||||
4. Click the button to open a new Lua script.
|
||||
5. Select any `Connector.lua` file from your SNI installation
|
||||
|
||||
##### bsnes-plus-nwa
|
||||
This should automatically connect to SNI.
|
||||
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
|
||||
|
||||
#### With hardware
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not
|
||||
done so already, please do this now. SD2SNES and FXPak Pro users may download the appropriate firmware
|
||||
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
|
||||
[on this page](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Copy the ROM file to your SD card.
|
||||
2. Load the ROM file from the menu.
|
||||
|
||||
### Open the client
|
||||
Open [ap-soeclient](http://evermizer.com/apclient) in a modern browser. Do not switch tabs, open it in a new window
|
||||
if you want to use the browser while playing. Do not minimize the window with the client.
|
||||
|
||||
The client should automatically connect to SNI, the "SNES" status should change to green.
|
||||
|
||||
### Connect to the Archipelago Server
|
||||
Enter `/connect server:port` in the client's command prompt and press enter. You'll find `server:port` on the same page
|
||||
that had the patch file.
|
||||
|
||||
### Play the game
|
||||
When the game is loaded but not yet past the intro cutscene, the "Game" status is yellow. When the client shows "AP" as
|
||||
green and "Game" as yellow, you're ready to play. The intro can be skipped pressing the START button and "Game" should
|
||||
change to green. Congratulations on successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
|
||||
|
||||
1. Collect config files from your players.
|
||||
2. Create a zip file containing your players' config files.
|
||||
3. Upload that zip file to the website linked above.
|
||||
4. Wait a moment while the seed is generated.
|
||||
5. When the seed is generated, you will be redirected to a "Seed Info" page.
|
||||
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
|
||||
so they may download their patch files from there.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
|
||||
players in the game. Any observers may also be given the link to this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
126
WebHostLib/static/assets/tutorial/super-metroid/multiworld_en.md
Normal file
126
WebHostLib/static/assets/tutorial/super-metroid/multiworld_en.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Super Metroid Setup Guide
|
||||
|
||||
## Required Software
|
||||
- [Super Metroid Client](https://github.com/ArchipelagoMW/SuperMetroidClient/releases)
|
||||
- **sniConnector.lua** (located on the client download page)
|
||||
- [SNI](https://github.com/alttpo/sni/releases) (Included in the Super Metroid Client)
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of connecting to SNI
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Super Metroid ROM file, probably named `Super Metroid (Japan, USA).sfc`
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### Windows Setup
|
||||
1. Download and install the Super Metroid Client 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**.
|
||||
2. During setup, you will be asked to locate your base ROM file. This is your Super Metroid ROM file.
|
||||
3. If you are using an emulator, you should assign your Lua capable emulator as your default program
|
||||
for launching ROM files.
|
||||
1. Extract your emulator's folder to your Desktop, or somewhere you will remember.
|
||||
2. Right-click on a ROM file and select **Open with...**
|
||||
3. Check the box next to **Always use this app to open .sfc files**
|
||||
4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
|
||||
5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside
|
||||
the folder you extracted in step one.
|
||||
|
||||
### Macintosh Setup
|
||||
- We need volunteers to help fill this section! Please contact **Farrak Kilhn** on Discord if you want to help.
|
||||
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
### What is a config file and why do I need one?
|
||||
Your config file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own config file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a config file?
|
||||
The [Player Settings](/games/Super%20Metroid/player-settings) page on the website allows you to configure your
|
||||
personal settings and export a config file from them.
|
||||
|
||||
### Verifying your config file
|
||||
If you would like to validate your config file to make sure it works, you may do so on the
|
||||
[YAML Validator](/mysterycheck) page.
|
||||
|
||||
## Generating a Single-Player Game
|
||||
1. Navigate to the [Player Settings](/games/Super%20Metroid/player-settings) page, configure your options, and click
|
||||
the "Generate Game" button.
|
||||
2. You will be presented with a "Seed Info" page.
|
||||
3. Click the "Create New Room" link.
|
||||
4. You will be presented with a server page, from which you can download your patch file.
|
||||
5. Double-click on your patch file, and the Super Metroid Client will launch automatically, create your ROM from
|
||||
the patch file, and open your emulator for you.
|
||||
6. Since this is a single-player game, you will no longer need the client, so feel free to close it.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your patch file and create your ROM
|
||||
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
|
||||
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
|
||||
everyone's patch files. Your patch file should have a `.apm3` extension.
|
||||
|
||||
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically
|
||||
launch the client, and will also create your ROM in the same place as your patch file.
|
||||
|
||||
### Connect to the client
|
||||
|
||||
#### With an emulator
|
||||
When the client launched automatically, SNI should have also automatically launched in the background.
|
||||
If this is its first time launching, you may be prompted to allow it to communicate through the Windows
|
||||
Firewall.
|
||||
|
||||
##### snes9x Multitroid
|
||||
1. Load your ROM file if it hasn't already been loaded.
|
||||
2. Click on the File menu and hover on **Lua Scripting**
|
||||
3. Click on **New Lua Script Window...**
|
||||
4. In the new window, click **Browse...**
|
||||
5. Select the `sniConnector.lua` file you downloaded above
|
||||
|
||||
##### BizHawk
|
||||
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
|
||||
these menu options:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Once you have changed the loaded core, you must restart BizHawk.
|
||||
2. Load your ROM file if it hasn't already been loaded.
|
||||
3. Click on the Tools menu and click on **Lua Console**
|
||||
4. Click the button to open a new Lua script.
|
||||
5. Select the `sniConnector.lua` file you downloaded above
|
||||
|
||||
#### With hardware
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not
|
||||
done so already, please do this now. SD2SNES and FXPak Pro users may download the appropriate firmware
|
||||
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
|
||||
[on this page](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
1. Close your emulator, which may have auto-launched.
|
||||
2. Power on your device and load the ROM.
|
||||
|
||||
### Connect to the Archipelago Server
|
||||
The patch file which launched your client should have automatically connected you to the AP Server.
|
||||
There are a few reasons this may not happen however, including if the game is hosted on the website but
|
||||
was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host
|
||||
for the address of the server, and copy/paste it into the "Server" input field then press enter.
|
||||
|
||||
The client will attempt to reconnect to the new server address, and should momentarily show "Server
|
||||
Status: Connected".
|
||||
|
||||
### Play the game
|
||||
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations
|
||||
on successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
|
||||
|
||||
1. Collect config files from your players.
|
||||
2. Create a zip file containing your players' config files.
|
||||
3. Upload that zip file to the website linked above.
|
||||
4. Wait a moment while the seed is generated.
|
||||
5. When the seed is generated, you will be redirected to a "Seed Info" page.
|
||||
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
|
||||
so they may download their patch files from there.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
|
||||
players in the game. Any observers may also be given the link to this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
60
WebHostLib/static/assets/tutorial/timespinner/setup_en.md
Normal file
60
WebHostLib/static/assets/tutorial/timespinner/setup_en.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Timespinner Randomizer Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Timespinner (steam)](https://store.steampowered.com/app/368620/Timespinner/) or [Timespinner (drm free)](https://www.humblebundle.com/store/timespinner)
|
||||
- [Timespinner Randomizer](https://github.com/JarnoWesthof/TsRandomizer)
|
||||
|
||||
## General Concept
|
||||
|
||||
The timespinner Randomizer loads Timespinner.exe from the same folder, and alters its state in memory to allow for randomization of the items
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
Download latest version of [Timespinner Randomizer](https://github.com/JarnoWesthof/TsRandomizer) you can find the .zip files on the releases page, download the zip for your current platform. Then extract the zip to the folder where your Timespinner game is installed. Then just run TsRandomizer.exe instead of Timespinner.exe to start the game in randomized mode, for more info see the [readme](https://github.com/JarnoWesthof/TsRandomizer)
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Run TsRandomizer.exe
|
||||
2. Select "New Game"
|
||||
3. Switch "<< Select Seed >>" to "<< Archiplago >>" by pressing left on the controller or keyboard
|
||||
4. Select "<< Archiplago >>" to open a new menu where you can enter your Archipelago login credentails
|
||||
* NOTE: the input fields support Ctrl + V pasting of values
|
||||
5. Select "Connect"
|
||||
6. If all went well you will be taken back the difficulty selection menu and the game will start as soon as you select a difficulty
|
||||
|
||||
## YAML Settings
|
||||
An example YAML would look like this:
|
||||
```yaml
|
||||
description: Default Timespinner Template
|
||||
name: Lunais{number} # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
|
||||
game:
|
||||
Timespinner: 1
|
||||
requires:
|
||||
version: 0.1.8
|
||||
Timespinner:
|
||||
StartWithJewelryBox: # Start with Jewelry Box unlocked
|
||||
false: 50
|
||||
true: 0
|
||||
DownloadableItems: # With the tablet you will be able to download items at terminals
|
||||
false: 50
|
||||
true: 50
|
||||
FacebookMode: # Requires Oculus Rift(ng) to spot the weakspots in walls and floors
|
||||
false: 50
|
||||
true: 0
|
||||
StartWithMeyef: # Start with Meyef, ideal for when you want to play multiplayer
|
||||
false: 50
|
||||
true: 50
|
||||
QuickSeed: # Start with Talaria Attachment, Nyoom!
|
||||
false: 50
|
||||
true: 0
|
||||
SpecificKeycards: # Keycards can only open corresponding doors
|
||||
false: 0
|
||||
true: 50
|
||||
Inverted: # Start in the past
|
||||
false: 50
|
||||
true: 50
|
||||
```
|
||||
* All Options are either enabled or not, if values are specified for both true & false the generator will select one based on weight
|
||||
* The Timespinner Randomizer option "StinkyMaw" is currently always enabled for Archipelago generated seeds
|
||||
* The Timespinner Randomizer options "ProgressiveVerticalMovement" & "ProgressiveKeycards" are currently not supported on Archipelago generated seeds
|
||||
@@ -1,4 +1,65 @@
|
||||
[
|
||||
{
|
||||
"gameTitle": "Archipelago",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/setup_en.md",
|
||||
"link": "archipelago/setup/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Using Advanced Settings",
|
||||
"description": "A guide to reading yaml files and editing them to fully customize your game.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/advanced_settings_en.md",
|
||||
"link": "archipelago/advanced_settings/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Triggers Guide",
|
||||
"description": "A guide to setting up and using triggers in your game settings.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/triggers_en.md",
|
||||
"link": "archipelago/triggers/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Plando Guide",
|
||||
"description": "A guide to understanding and using plando for your game.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "archipelago/plando_en.md",
|
||||
"link": "archipelago/plando/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: A Link to the Past",
|
||||
"tutorials": [
|
||||
@@ -86,6 +147,33 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: Ocarina of Time",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Ocarina of Time software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "zelda5/setup_en.md",
|
||||
"link": "zelda5/setup/en",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "zelda5/setup_es.md",
|
||||
"link": "zelda5/setup/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Factorio",
|
||||
"tutorials": [
|
||||
@@ -98,7 +186,8 @@
|
||||
"filename": "factorio/setup_en.md",
|
||||
"link": "factorio/setup/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
"Berserker",
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -139,5 +228,119 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Timespinner",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Timespinner randomizer connected to an Archipelago Multiworld",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "timespinner/setup_en.md",
|
||||
"link": "timespinner/setup/en",
|
||||
"authors": [
|
||||
"Jarno"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Subnautica",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Subnautica randomizer connected to an Archipelago Multiworld",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Subnautica/setup_en.md",
|
||||
"link": "Subnautica/setup/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Super Metroid",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Super Metroid Client on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "super-metroid/multiworld_en.md",
|
||||
"link": "super-metroid/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Secret of Evermore",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to playing Secret of Evermore randomizer. This guide covers single-player, multiworld and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "secret-of-evermore/multiworld_en.md",
|
||||
"link": "secret-of-evermore/multiworld/en",
|
||||
"authors": [
|
||||
"Black Sliver"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Final Fantasy",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to playing Final Fantasy multiworld. This guide only covers playing multiworld.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ff1/multiworld_en.md",
|
||||
"link": "ff1/multiworld/en",
|
||||
"authors": [
|
||||
"jat2980"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -74,3 +74,4 @@ Below is a list of MSU packs which, so far as we know, are safe to stream. More
|
||||
we learn of them. If you know of any we missed, please let us know!
|
||||
- Vanilla Game Music
|
||||
- [Smooth McGroove](https://drive.google.com/open?id=1JDa1jCKg5hG0Km6xNpmIgf4kDMOxVp3n)
|
||||
- Zelda community member Amarith assembled the following list for the purpose of competitive restreams. While we have not ourselves verified this list, all submissions required VoD proof they were not muted. Generally speaking, MSU-1 packs are less safe if they contain lyrics at any point. This list was only tested on Twitch and results for other platforms may vary. [Restream-Safe List](https://tinyurl.com/MSUsApprovedForLeagueChannels)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## Benötigte Software
|
||||
- [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
|
||||
- [SNI](https://github.com/alttpo/sni/releases) (Integriert in Archipelago)
|
||||
- Hardware oder Software zum Laden und Abspielen von SNES Rom-Dateien fähig zu einer Internetverbindung
|
||||
- Ein Emulator, der mit SNI verbinden kann
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
- Ein SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), oder andere kompatible Hardware
|
||||
@@ -42,7 +42,7 @@ jeder Spieler sein Spiel nach seinem eigenen Geschmack gestalten, während ander
|
||||
Einstellungen wählen können!
|
||||
|
||||
### Wo bekomme ich so eine YAML-Datei her?
|
||||
Die [Player Settings](/player-settings) Seite auf der Website ermöglicht das einfache Erstellen und Herunterladen
|
||||
Die [Player Settings](/games/A Link to the Past/player-settings) Seite auf der Website ermöglicht das einfache Erstellen und Herunterladen
|
||||
deiner eigenen `yaml` Datei. Drei verschiedene Voreinstellungen können dort gespeichert werden.
|
||||
|
||||
### Deine YAML-Datei ist gewichtet!
|
||||
@@ -74,12 +74,12 @@ bei der [YAML Validator](/mysterycheck) Seite tun.
|
||||
### Erhalte deine Patch-Datei und erstelle dein ROM
|
||||
Wenn du an einem MultiWorld-Spiel teilnehmen möchtest, wirst du in der Regel vom Host nach deiner YAML-Datei gefragt.
|
||||
Sobald du diese weitergegeben hast, wird der Host einen Link bereitstellen, wo du deinen Patch oder eine .zip-Datei
|
||||
mit allen Patches herunterladen kannst. Die Patch-Datei hat immer die Endung `.bmbp`.
|
||||
mit allen Patches herunterladen kannst. Die Patch-Datei hat immer die Endung `.apbp`.
|
||||
|
||||
### Mit dem Client verbinden
|
||||
|
||||
#### Via Emulator
|
||||
Wenn der client den Emulator automatisch gestartet hat, wird QUsb2Snes ebenfalls im Hintergrund gestartet.
|
||||
Wenn der client den Emulator automatisch gestartet hat, wird SNI ebenfalls im Hintergrund gestartet.
|
||||
Wenn dies das erste Mal ist, wird möglicherweise ein Fenster angezeigt, wo man bestätigen muss, dass das Programm
|
||||
durch die Windows Firewall kommunizieren darf.
|
||||
|
||||
@@ -88,8 +88,9 @@ durch die Windows Firewall kommunizieren darf.
|
||||
2. Klicke auf den Reiter "File" oben im Menü und wähle **Lua Scripting**
|
||||
3. Klicke auf **New Lua Script Window...**
|
||||
4. Im sich neu öffnenden Fenster, klicke auf **Browse...**
|
||||
5. Navigiere zum Ort, wo du snes9x Multitroid installiert hast, öffne den `lua`-Ordner und öffne `multibridge.lua`
|
||||
6. Schaue im Lua-Fenster nach einem Namen, der dir zugeteilt wird und schaue im Client (WebUI im Browser), ob dort
|
||||
5. Navigiere zum Verzeichnis, wo du Archipelago installiert hast und dort in den Unterordner `SNI`.
|
||||
6. Wähle dort die `Connector.lua` und klicke auf Öffnen.
|
||||
7. Schaue im Lua-Fenster nach einem Namen, der dir zugeteilt wird und schaue im Client (WebUI im Browser), ob dort
|
||||
"Snes Device: Connected" mit demselben Namen dort steht (in der oberen linken Ecke).
|
||||
|
||||
##### BizHawk
|
||||
@@ -99,9 +100,8 @@ durch die Windows Firewall kommunizieren darf.
|
||||
2. Lade die entsprechende ROM-Datei, wenn sie nicht schon automatisch geladen wurde.
|
||||
3. Klicke auf das Tools-Menü und klicke auf **Lua Console**
|
||||
4. Klicke auf den Button um ein neues Lua-Script zu öffnen.
|
||||
5. Navigiere zum Verzeichnis, wo du die Multiworld Utilities installiert hast und dort in folgende Ordner:
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Wähle dort die `luabridge.lua` und klicke auf Öffnen.
|
||||
5. Navigiere zum Verzeichnis, wo du Archipelago installiert hast und dort in den Unterordner `SNI`.
|
||||
6. Wähle dort die `Connector.lua` und klicke auf Öffnen.
|
||||
7. Schaue im Lua-Fenster nach einem Namen, der dir zugeteilt wird und schaue im Client (WebUI im Browser), ob dort
|
||||
"Snes Device: Connected" mit demselben Namen dort steht (in der oberen linken Ecke)
|
||||
|
||||
@@ -111,15 +111,11 @@ das noch nicht getan hast, so tue dies am besten jetzt! SD2SNES und FXPak Pro Nu
|
||||
[hier](https://github.com/RedGuyyyy/sd2snes/releases). Nutzer ähnlicher Hardware finden Hilfestellung
|
||||
[auf dieser Seite](http://usb2snes.com/#supported-platforms).
|
||||
|
||||
**UM MIT HARDWARE ZU VERBINDEN WIRD AKTUELL EINE ALTE VERSION VON QUSB2SNES BENÖTIGT
|
||||
([v0.7.16](https://github.com/Skarsnik/QUsb2snes/releases/tag/v0.7.16)).**
|
||||
Neuere Versionen funktionieren möglicherweise nur eingeschränkt, fehlerhaft oder gar nicht!
|
||||
|
||||
1. Schließe deinen Emulator, falls er automatisch gestartet haben sollte.
|
||||
2. Schließe QUsb2Snes, welches automatisch mit dem Client gestartet wurde (in der Taskleiste zu finden).
|
||||
3. Starte die richtige version von QUsb2Snes (v0.7.16).
|
||||
4. Starte deine (Original-)Konsole und lade die ROM-Datei.
|
||||
5. Schaue auf dein Clientfenster, welches nun "Snes Device: Connected" und den namen deiner Konsole
|
||||
2. Start SNI
|
||||
3. Starte deine (Original-)Konsole und lade die ROM-Datei.
|
||||
4. Schaue auf dein Clientfenster, welches nun "Snes Device: Connected" und den namen deiner Konsole
|
||||
zeigen sollte.
|
||||
|
||||
### Mit dem MultiServer verbinden
|
||||
@@ -137,7 +133,7 @@ können du und deine Freunde loslegen! Glückwunsch zum erfolgreichen Beitritt z
|
||||
|
||||
## Ein Multiworld-Spiel hosten
|
||||
Die Empfohlene Art, ein Spiel zu hosten, ist, den Service auf
|
||||
[der website](https://berserkermulti.world/generate) zu nutzen. Das Ganze ist recht einfach:
|
||||
[der website](/generate) zu nutzen. Das Ganze ist recht einfach:
|
||||
|
||||
1. Lasse dir von deinen Mitspielern die YAML-Datei zuschicken.
|
||||
2. Erstelle einen Zip-komprimierten Ordner´, in den du alle YAML-Dateien deiner Spieler einfügst.
|
||||
|
||||
@@ -1,98 +1,71 @@
|
||||
# A Link to the Past Randomizer Setup Guide
|
||||
|
||||
<div id="tutorial-video-container">
|
||||
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/mJKEHaiyR_Y" frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
## Required Software
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
|
||||
- [Z3Client](https://github.com/ArchipelagoMW/Z3Client/releases) or the SNIClient included with
|
||||
[Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- If installing Archipelago, make sure to check the box for SNIClient -> A Link to the Past Patch Setup during install, or SNI will not be included
|
||||
- [SNI](https://github.com/alttpo/sni/releases) (Included in both Z3Client and SNIClient)
|
||||
- Hardware or software capable of loading and playing SNES ROM files
|
||||
- An emulator capable of running Lua scripts
|
||||
- An emulator capable of connecting to SNI
|
||||
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
|
||||
[BizHawk](http://tasvideos.org/BizHawk.html))
|
||||
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
|
||||
- Your Japanese v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### 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.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
|
||||
ROM file a second time.
|
||||
- You may also be prompted to install Microsoft Visual C++. If you already have this software on your computer
|
||||
(possibly because a Steam game installed it already), the installer will not prompt you to install it again.
|
||||
1. Download and install your preferred client from the link above, making sure to install the most recent version.
|
||||
**The installer file is located in the assets section at the bottom of the version information**.
|
||||
- During setup, you will be asked to locate your base ROM file. This is your Japanese Link to the Past ROM file.
|
||||
|
||||
2. If you are using an emulator, you should assign your Lua capable emulator as your default program
|
||||
for launching ROM files.
|
||||
1. Extract your emulator's folder to your Desktop, or somewhere you will remember.
|
||||
2. Right click on a ROM file and select **Open with...**
|
||||
2. Right-click on a ROM file and select **Open with...**
|
||||
3. Check the box next to **Always use this app to open .sfc files**
|
||||
4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
|
||||
5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside
|
||||
the folder you extracted in step one.
|
||||
|
||||
### Macintosh Setup
|
||||
- We need volunteers to help fill this section! Please contact **Farrak Kilhn** on Discord if you want to help.
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
Your YAML file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows
|
||||
### What is a config file and why do I need one?
|
||||
Your config file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own config file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a YAML file?
|
||||
The [Generate Game](/player-settings) page on the website allows you to configure your personal settings and
|
||||
export a YAML file from them.
|
||||
### Where do I get a config file?
|
||||
The [Player Settings](/games/A%20Link%20to%20the%20Past/player-settings) page on the website allows you to configure
|
||||
your personal settings and export a config 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
|
||||
### Verifying your config file
|
||||
If you would like to validate your config file to make sure it works, you may do so on the
|
||||
[YAML Validator](/mysterycheck) page.
|
||||
|
||||
## Generating a Single-Player Game
|
||||
1. Navigate to the [Generate Game](/player-settings), configure your options, and click the "Generate Game" button.
|
||||
2. You will be presented with a "Seed Info" page, where you can download your patch file.
|
||||
3. Double-click on your patch file, and the emulator should launch with your game automatically. As the
|
||||
Client is unnecessary for single player games, you may close it and the WebUI.
|
||||
1. Navigate to the [Player Settings](/games/A%20Link%20to%20the%20Past/player-settings) page, configure your options,
|
||||
and click the "Generate Game" button.
|
||||
2. You will be presented with a "Seed Info" page.
|
||||
3. Click the "Create New Room" link.
|
||||
4. You will be presented with a server page, from which you can download your patch file.
|
||||
5. Double-click on your patch file, and the Z3Client will launch automatically, create your ROM from
|
||||
the patch file, and open your emulator for you.
|
||||
6. Since this is a single-player game, you will no longer need the client, so feel free to close it.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your patch file and create your ROM
|
||||
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that
|
||||
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
|
||||
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
|
||||
everyone's patch files. Your patch file should have a `.bmbp` extension.
|
||||
everyone's patch files. Your patch file should have a `.apbp` extension.
|
||||
|
||||
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically
|
||||
launch the client, and will also create your ROM file in the same place as your patch file.
|
||||
launch the client, and will also create your ROM in the same place as your patch file.
|
||||
|
||||
### Connect to the client
|
||||
|
||||
#### With an emulator
|
||||
When the client launched automatically, QUsb2Snes should have also automatically launched in the background.
|
||||
When the client launched automatically, SNI should have also automatically launched in the background.
|
||||
If this is its first time launching, you may be prompted to allow it to communicate through the Windows
|
||||
Firewall.
|
||||
|
||||
@@ -101,23 +74,22 @@ Firewall.
|
||||
2. Click on the File menu and hover on **Lua Scripting**
|
||||
3. Click on **New Lua Script Window...**
|
||||
4. In the new window, click **Browse...**
|
||||
5. Browse to the location you extracted snes9x Multitroid to, enter the `lua` folder, and choose `multibridge.lua`
|
||||
6. Observe a name has been assigned to you, and that the client shows "SNES Device: Connected", with that same
|
||||
name in the upper left corner.
|
||||
5. Select the connector lua file included with your client
|
||||
- Z3Client users should download `sniConnector.lua` from the client download page
|
||||
- SNIClient users should look in their Archipelago folder for `/sni/lua`
|
||||
|
||||
##### BizHawk
|
||||
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
|
||||
these menu options:
|
||||
these menu options:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Once you have changed the loaded core, you must restart BizHawk.
|
||||
2. Load your ROM file if it hasn't already been loaded.
|
||||
3. Click on the Tools menu and click on **Lua Console**
|
||||
4. Click the button to open a new Lua script.
|
||||
5. Browse to your MultiWorld Utilities installation directory, and into the following directories:
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Select `luabridge.lua` and click Open.
|
||||
7. Observe a name has been assigned to you, and that the client shows "SNES Device: Connected", with that same
|
||||
name in the upper left corner.
|
||||
4. Click Script -> Open Script...
|
||||
5. Select the `Connector.lua` file you downloaded above
|
||||
- Z3Client users should download `sniConnector.lua` from the client download page
|
||||
- SNIClient users should look in their Archipelago folder for `/sni/lua`
|
||||
6. Run the script by double-clicking it in the listing
|
||||
|
||||
#### With hardware
|
||||
This guide assumes you have downloaded the correct firmware for your device. If you have not
|
||||
@@ -127,54 +99,30 @@ done so already, please do this now. SD2SNES and FXPak Pro users may download th
|
||||
|
||||
1. Close your emulator, which may have auto-launched.
|
||||
2. Power on your device and load the ROM.
|
||||
3. Observe the client window now shows "SNES Device: Connected", and lists the name of your device.
|
||||
|
||||
### Connect to the MultiServer
|
||||
The patch file which launched your client should have automatically connected you to the MultiServer.
|
||||
### Connect to the Archipelago Server
|
||||
The patch file which launched your client should have automatically connected you to the AP Server.
|
||||
There are a few reasons this may not happen however, including if the game is hosted on the website but
|
||||
was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host
|
||||
for the address of the server, and copy/paste it into the "Server" input field then press enter.
|
||||
|
||||
The client will attempt to reconnect to the new server address, and should momentarily show "Server
|
||||
Status: Connected". If the client does not connect after a few moments, you may need to refresh the page.
|
||||
Status: Connected".
|
||||
|
||||
### Play the game
|
||||
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations
|
||||
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](/generate). The process is relatively simple:
|
||||
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
|
||||
|
||||
1. Collect YAML files from your players.
|
||||
2. Create a zip file containing your players' YAML files.
|
||||
1. Collect config files from your players.
|
||||
2. Create a zip file containing your players' config files.
|
||||
3. Upload that zip file to the website linked above.
|
||||
4. Wait a moment while the seed is generated.
|
||||
5. When the seed is generated, you will be redirected to a "Seed Info" page.
|
||||
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
|
||||
so they may download their patch files from there.
|
||||
**Note:** The patch files provided on this page will allow players to automatically connect to the server,
|
||||
while the patch files on the "Seed Info" page will not.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. You should also provide this link
|
||||
to your players, so they can watch the progress of the game. Any observers may also be given the link to
|
||||
this page.
|
||||
so they may download their patch files from there.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
|
||||
players in the game. Any observers may also be given the link to this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
|
||||
## Auto-Tracking
|
||||
If you would like to use auto-tracking for your game, several pieces of software provide this functionality.
|
||||
The recommended software for auto-tracking is currently
|
||||
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
|
||||
|
||||
### Installation
|
||||
1. Download the appropriate installation file for your computer (Windows users want the `.msi` file).
|
||||
2. During the installation process, you may be asked to install the Microsoft Visual Studio Build Tools. A link
|
||||
to this software is provided during the installation procedure, and it must be installed manually.
|
||||
|
||||
### Enable auto-tracking
|
||||
1. With OpenTracker launched, click the Tracking menu at the top of the window, then choose **AutoTracker...**
|
||||
2. Click the **Get Devices** button
|
||||
3. Select your SNES device from the drop-down list
|
||||
4. If you would like to track small keys and dungeon items, check the box labeled **Race Illegal Tracking**
|
||||
5. Click the **Start Autotracking** button
|
||||
6. Close the AutoTracker window, as it is no longer necessary
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Cada jugador en una partida de multiworld proveerá su propio fichero YAML. Esta
|
||||
que cada jugador disfrute de una experiencia personalizada a su gusto, y cada jugador dentro de la misma partida de multiworld puede tener diferentes opciones.
|
||||
|
||||
### Donde puedo obtener un fichero YAML?
|
||||
La página "[Generate Game](/player-settings)" en el sitio web te permite configurar tu configuración personal y
|
||||
La página "[Generate Game](/games/A%20Link%20to%20the%20Past/player-settings)" en el sitio web te permite configurar tu configuración personal y
|
||||
descargar un fichero "YAML".
|
||||
|
||||
### Configuración YAML avanzada
|
||||
@@ -67,7 +67,7 @@ Si quieres validar que tu fichero YAML para asegurarte que funciona correctament
|
||||
[YAML Validator](/mysterycheck).
|
||||
|
||||
## Generar una partida para un jugador
|
||||
1. Navega a [la pagina Generate game](/player-settings), configura tus opciones, haz click en el boton "Generate game".
|
||||
1. Navega a [la pagina Generate game](/games/A%20Link%20to%20the%20Past/player-settings), configura tus opciones, haz click en el boton "Generate game".
|
||||
2. Se te redigirá a una pagina "Seed Info", donde puedes descargar tu archivo de parche.
|
||||
3. Haz doble click en tu fichero de parche, y el emulador debería ejecutar tu juego automáticamente. Como el
|
||||
Cliente no es necesario para partidas de un jugador, puedes cerrarlo junto a la pagina web (que tiene como titulo "Multiworld WebUI") que se ha abierto automáticamente.
|
||||
|
||||
@@ -49,7 +49,7 @@ sur comment il devrait générer votre seed. Chaque joueur d'un multiwolrd devra
|
||||
joueur d'apprécier une expérience customisée selon ses goûts, et les différents joueurs d'un même multiworld peuvent avoir différentes options.
|
||||
|
||||
### Où est-ce que j'obtiens un fichier YAML ?
|
||||
La page [Génération de partie](/player-settings) vous permet de configurer vos paramètres personnels et de les exporter vers un fichier YAML.
|
||||
La page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-settings) vous permet de configurer vos paramètres personnels et de les exporter vers un fichier YAML.
|
||||
|
||||
### Configuration avancée du fichier YAML
|
||||
Une version plus avancée du fichier YAML peut être créée en utilisant la page des [paramètres de pondération](/weighted-settings), qui vous permet
|
||||
@@ -71,7 +71,7 @@ Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous
|
||||
[Validateur de YAML](/mysterycheck).
|
||||
|
||||
## Générer une partie pour un joueur
|
||||
1. Aller sur la page [Génération de partie](/player-settings), configurez vos options, et cliquez sur le bouton "Generate Game".
|
||||
1. Aller sur la page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-settings), configurez vos options, et cliquez sur le bouton "Generate Game".
|
||||
2. Il vous sera alors présenté une page d'informations sur la seed, où vous pourrez télécharger votre patch.
|
||||
3. Double-cliquez sur le patch et l'émulateur devrait se lancer automatiquement avec la seed. Etant donné que le client
|
||||
n'est pas requis pour les parties à un joueur, vous pouvez le fermer ainsi que l'interface Web (WebUI).
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
- Example: `simple`
|
||||
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as
|
||||
a last instruction.
|
||||
- [Available Bosses](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L135)
|
||||
- [Available Arenas](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L186)
|
||||
- [Available Bosses](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L135)
|
||||
- [Available Arenas](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L150)
|
||||
|
||||
#### Examples
|
||||
```yaml
|
||||
@@ -79,8 +79,8 @@ boss_shuffle:
|
||||
- placements are picked randomly, not sorted in any way
|
||||
- Warning: Placing non-Dungeon Prizes on Prize locations and
|
||||
Prizes on non-Prize locations will break the game in various ways.
|
||||
- [Available Items](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Items.py#L26)
|
||||
- [Available Locations](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Regions.py#L418)
|
||||
- [Available Items](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
|
||||
- [Available Locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L434)
|
||||
|
||||
#### Examples
|
||||
```yaml
|
||||
@@ -133,10 +133,10 @@ Link's House and removes the picked item from the item pool.
|
||||
- `\n` is a newline.
|
||||
- `@` is the entered player's name.
|
||||
- Warning: Text Mapper does not support full unicode.
|
||||
- [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756)
|
||||
- [Alphabet](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L758)
|
||||
- at is the location within the game to attach the text to.
|
||||
- can be weighted.
|
||||
- [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498)
|
||||
- [List of targets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L1499)
|
||||
|
||||
#### Example
|
||||
```yaml
|
||||
@@ -160,7 +160,7 @@ This has a 50% chance to trigger at all. If it does, it throws a coin between `u
|
||||
- entrance is the overworld door
|
||||
- exit is the underworld exit
|
||||
- direction can be `both`, `entrance` or `exit`
|
||||
- doors can be found in [this file](https://github.com/Berserker66/MultiWorld-Utilities/blob/main/EntranceShuffle.py)
|
||||
- doors can be found in [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
|
||||
|
||||
|
||||
#### Example
|
||||
|
||||
387
WebHostLib/static/assets/tutorial/zelda5/setup_en.md
Normal file
387
WebHostLib/static/assets/tutorial/zelda5/setup_en.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# Setup Guide for Ocarina of time Archipelago
|
||||
|
||||
## Important
|
||||
|
||||
As we are using Z5Client and Bizhawk, this guide is only applicable to Windows.
|
||||
|
||||
## Required Software
|
||||
|
||||
- [bizhawk+script+Z5Client](https://github.com/ArchipelagoMW/Z5Client/releases) We recommend download Z5Client-setup as it makes some steps automatic.
|
||||
|
||||
## Install Emulator and client
|
||||
|
||||
Download getBizhawk.ps1 from previous link. Place it on the folder where you want your emulator to be installed, right click on it and select "Run with PowerShell". This will download all the needed dependencies used by the emulator. This can take a while.
|
||||
|
||||
It is strongly recommended to associate N64 rom extension (\*.n64) to the Bizhawk we've just installed. To do so, we simple have to search any N64 rom we happened to own, right click and select "Open with...", we unfold the list that appears and select the bottom option "Look for another application", we browse to Bizhawk folder and select EmuHawk.exe
|
||||
|
||||
Place the ootMulti.lua file from the previous link inside the "lua" folder from the just installed emulator.
|
||||
|
||||
Install the Z5Client using its setup.
|
||||
|
||||
## Configuring your YAML file
|
||||
|
||||
### What is a YAML file and why do I need one?
|
||||
Your YAML file contains a set of configuration options which provide the generator with information about how
|
||||
it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows
|
||||
each player to enjoy an experience customized for their taste, and different players in the same multiworld
|
||||
can all have different options.
|
||||
|
||||
### Where do I get a YAML file?
|
||||
|
||||
A basic OOT yaml will look like this. There are lots of cosmetic options that have been removed for the sake of this tutorial, if you want to see a complete list, download [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and look for the sample file in the "Players" folder.
|
||||
```yaml
|
||||
description: Default Ocarina of Time Template # Used to describe your yaml. Useful if you have multiple files
|
||||
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
|
||||
name: YourName
|
||||
game:
|
||||
Ocarina of Time: 1
|
||||
requires:
|
||||
version: 0.1.7 # Version of Archipelago required for this yaml to work as expected.
|
||||
# Shared Options supported by all games:
|
||||
accessibility:
|
||||
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
|
||||
locations: 50 # Guarantees you will be able to access all locations, and therefore all items
|
||||
none: 0 # Guarantees only that the game is beatable. You may not be able to access all locations or acquire all items
|
||||
progression_balancing:
|
||||
on: 50 # A system to reduce BK, as in times during which you can't do anything by moving your items into an earlier access sphere to make it likely you have stuff to do
|
||||
off: 0 # Turn this off if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
|
||||
Ocarina of Time:
|
||||
logic_rules: # Set the logic used for the generator.
|
||||
glitchless: 50
|
||||
glitched: 0
|
||||
no_logic: 0
|
||||
logic_no_night_tokens_without_suns_song: # Nighttime skulltulas will logically require Sun's Song.
|
||||
false: 50
|
||||
true: 0
|
||||
open_forest: # Set the state of Kokiri Forest and the path to Deku Tree.
|
||||
open: 50
|
||||
closed_deku: 0
|
||||
closed: 0
|
||||
open_kakariko: # Set the state of the Kakariko Village gate.
|
||||
open: 50
|
||||
zelda: 0
|
||||
closed: 0
|
||||
open_door_of_time: # Open the Door of Time by default, without the Song of Time.
|
||||
false: 0
|
||||
true: 50
|
||||
zora_fountain: # Set the state of King Zora, blocking the way to Zora's Fountain.
|
||||
open: 0
|
||||
adult: 0
|
||||
closed: 50
|
||||
gerudo_fortress: # Set the requirements for access to Gerudo Fortress.
|
||||
normal: 0
|
||||
fast: 50
|
||||
open: 0
|
||||
bridge: # Set the requirements for the Rainbow Bridge.
|
||||
open: 0
|
||||
vanilla: 0
|
||||
stones: 0
|
||||
medallions: 50
|
||||
dungeons: 0
|
||||
tokens: 0
|
||||
trials: # Set the number of required trials in Ganon's Castle.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 50 # minimum value
|
||||
6: 0 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
starting_age: # Choose which age Link will start as.
|
||||
child: 50
|
||||
adult: 0
|
||||
triforce_hunt: # Gather pieces of the Triforce scattered around the world to complete the game.
|
||||
false: 50
|
||||
true: 0
|
||||
triforce_goal: # Number of Triforce pieces required to complete the game. Total number placed determined by the Item Pool setting.
|
||||
# you can add additional values between minimum and maximum
|
||||
1: 0 # minimum value
|
||||
50: 0 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
20: 50
|
||||
bombchus_in_logic: # Bombchus are properly considered in logic. The first found pack will have 20 chus; Kokiri Shop and Bazaar sell refills; bombchus open Bombchu Bowling.
|
||||
false: 50
|
||||
true: 0
|
||||
bridge_stones: # Set the number of Spiritual Stones required for the rainbow bridge.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
3: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
bridge_medallions: # Set the number of medallions required for the rainbow bridge.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
6: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
bridge_rewards: # Set the number of dungeon rewards required for the rainbow bridge.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
9: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
bridge_tokens: # Set the number of Gold Skulltula Tokens required for the rainbow bridge.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
100: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
shuffle_mapcompass: # Control where to shuffle dungeon maps and compasses.
|
||||
remove: 0
|
||||
startwith: 50
|
||||
vanilla: 0
|
||||
dungeon: 0
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_smallkeys: # Control where to shuffle dungeon small keys.
|
||||
remove: 0
|
||||
vanilla: 0
|
||||
dungeon: 50
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_fortresskeys: # Control where to shuffle the Gerudo Fortress small keys.
|
||||
vanilla: 50
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_bosskeys: # Control where to shuffle boss keys, except the Ganon's Castle Boss Key.
|
||||
remove: 0
|
||||
vanilla: 0
|
||||
dungeon: 50
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_ganon_bosskey: # Control where to shuffle the Ganon's Castle Boss Key.
|
||||
remove: 50
|
||||
vanilla: 0
|
||||
dungeon: 0
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
on_lacs: 0
|
||||
enhance_map_compass: # Map tells if a dungeon is vanilla or MQ. Compass tells what the dungeon reward is.
|
||||
false: 50
|
||||
true: 0
|
||||
lacs_condition: # Set the requirements for the Light Arrow Cutscene in the Temple of Time.
|
||||
vanilla: 50
|
||||
stones: 0
|
||||
medallions: 0
|
||||
dungeons: 0
|
||||
tokens: 0
|
||||
lacs_stones: # Set the number of Spiritual Stones required for LACS.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
3: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
lacs_medallions: # Set the number of medallions required for LACS.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
6: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
lacs_rewards: # Set the number of dungeon rewards required for LACS.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
9: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
lacs_tokens: # Set the number of Gold Skulltula Tokens required for LACS.
|
||||
# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
100: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
shuffle_song_items: # Set where songs can appear.
|
||||
song: 50
|
||||
dungeon: 0
|
||||
any: 0
|
||||
shopsanity: # Randomizes shop contents. Set to "off" to not shuffle shops; "0" shuffles shops but does not allow multiworld items in shops.
|
||||
0: 0
|
||||
1: 0
|
||||
2: 0
|
||||
3: 0
|
||||
4: 0
|
||||
random_value: 0
|
||||
off: 50
|
||||
tokensanity: # Token rewards from Gold Skulltulas are shuffled into the pool.
|
||||
off: 50
|
||||
dungeons: 0
|
||||
overworld: 0
|
||||
all: 0
|
||||
shuffle_scrubs: # Shuffle the items sold by Business Scrubs, and set the prices.
|
||||
off: 50
|
||||
low: 0
|
||||
regular: 0
|
||||
random_prices: 0
|
||||
shuffle_cows: # Cows give items when Epona's Song is played.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_kokiri_sword: # Shuffle Kokiri Sword into the item pool.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_ocarinas: # Shuffle the Fairy Ocarina and Ocarina of Time into the item pool.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_weird_egg: # Shuffle the Weird Egg from Malon at Hyrule Castle.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_gerudo_card: # Shuffle the Gerudo Membership Card into the item pool.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_beans: # Adds a pack of 10 beans to the item pool and changes the bean salesman to sell one item for 60 rupees.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_medigoron_carpet_salesman: # Shuffle the items sold by Medigoron and the Haunted Wasteland Carpet Salesman.
|
||||
false: 50
|
||||
true: 0
|
||||
skip_child_zelda: # Game starts with Zelda's Letter, the item at Zelda's Lullaby, and the relevant events already completed.
|
||||
false: 50
|
||||
true: 0
|
||||
no_escape_sequence: # Skips the tower collapse sequence between the Ganondorf and Ganon fights.
|
||||
false: 0
|
||||
true: 50
|
||||
no_guard_stealth: # The crawlspace into Hyrule Castle skips straight to Zelda.
|
||||
false: 0
|
||||
true: 50
|
||||
no_epona_race: # Epona can always be summoned with Epona's Song.
|
||||
false: 0
|
||||
true: 50
|
||||
skip_some_minigame_phases: # Dampe Race and Horseback Archery give both rewards if the second condition is met on the first attempt.
|
||||
false: 0
|
||||
true: 50
|
||||
complete_mask_quest: # All masks are immediately available to borrow from the Happy Mask Shop.
|
||||
false: 50
|
||||
true: 0
|
||||
useful_cutscenes: # Reenables the Poe cutscene in Forest Temple, Darunia in Fire Temple, and Twinrova introduction. Mostly useful for glitched.
|
||||
false: 50
|
||||
true: 0
|
||||
fast_chests: # All chest animations are fast. If disabled, major items have a slow animation.
|
||||
false: 0
|
||||
true: 50
|
||||
free_scarecrow: # Pulling out the ocarina near a scarecrow spot spawns Pierre without needing the song.
|
||||
false: 50
|
||||
true: 0
|
||||
fast_bunny_hood: # Bunny Hood lets you move 1.5x faster like in Majora's Mask.
|
||||
false: 50
|
||||
true: 0
|
||||
chicken_count: # Controls the number of Cuccos for Anju to give an item as child.
|
||||
\# you can add additional values between minimum and maximum
|
||||
0: 0 # minimum value
|
||||
7: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
hints: # Gossip Stones can give hints about item locations.
|
||||
none: 0
|
||||
mask: 0
|
||||
agony: 0
|
||||
always: 50
|
||||
hint_dist: # Choose the hint distribution to use. Affects the frequency of strong hints, which items are always hinted, etc.
|
||||
balanced: 50
|
||||
ddr: 0
|
||||
league: 0
|
||||
mw2: 0
|
||||
scrubs: 0
|
||||
strong: 0
|
||||
tournament: 0
|
||||
useless: 0
|
||||
very_strong: 0
|
||||
text_shuffle: # Randomizes text in the game for comedic effect.
|
||||
none: 50
|
||||
except_hints: 0
|
||||
complete: 0
|
||||
damage_multiplier: # Controls the amount of damage Link takes.
|
||||
half: 0
|
||||
normal: 50
|
||||
double: 0
|
||||
quadruple: 0
|
||||
ohko: 0
|
||||
no_collectible_hearts: # Hearts will not drop from enemies or objects.
|
||||
false: 50
|
||||
true: 0
|
||||
starting_tod: # Change the starting time of day.
|
||||
default: 50
|
||||
sunrise: 0
|
||||
morning: 0
|
||||
noon: 0
|
||||
afternoon: 0
|
||||
sunset: 0
|
||||
evening: 0
|
||||
midnight: 0
|
||||
witching_hour: 0
|
||||
start_with_consumables: # Start the game with full Deku Sticks and Deku Nuts.
|
||||
false: 50
|
||||
true: 0
|
||||
start_with_rupees: # Start with a full wallet. Wallet upgrades will also fill your wallet.
|
||||
false: 50
|
||||
true: 0
|
||||
item_pool_value: # Changes the number of items available in the game.
|
||||
plentiful: 0
|
||||
balanced: 50
|
||||
scarce: 0
|
||||
minimal: 0
|
||||
junk_ice_traps: # Adds ice traps to the item pool.
|
||||
off: 0
|
||||
normal: 50
|
||||
on: 0
|
||||
mayhem: 0
|
||||
onslaught: 0
|
||||
ice_trap_appearance: # Changes the appearance of ice traps as freestanding items.
|
||||
major_only: 50
|
||||
junk_only: 0
|
||||
anything: 0
|
||||
logic_earliest_adult_trade: # Earliest item that can appear in the adult trade sequence.
|
||||
pocket_egg: 0
|
||||
pocket_cucco: 0
|
||||
cojiro: 0
|
||||
odd_mushroom: 0
|
||||
poachers_saw: 0
|
||||
broken_sword: 0
|
||||
prescription: 50
|
||||
eyeball_frog: 0
|
||||
eyedrops: 0
|
||||
claim_check: 0
|
||||
logic_latest_adult_trade: # Latest item that can appear in the adult trade sequence.
|
||||
pocket_egg: 0
|
||||
pocket_cucco: 0
|
||||
cojiro: 0
|
||||
odd_mushroom: 0
|
||||
poachers_saw: 0
|
||||
broken_sword: 0
|
||||
prescription: 0
|
||||
eyeball_frog: 0
|
||||
eyedrops: 0
|
||||
claim_check: 50
|
||||
|
||||
```
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
### Obtain your OOT patch file
|
||||
|
||||
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that
|
||||
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 `.z5ap` extension.
|
||||
|
||||
Double click on your `.z5ap` file to start Z5Client and start the ROM patch process. Once the process is finished (this can take a while), the emulator will be started automatically (If we associated the extension to the emulator as recommended)
|
||||
|
||||
### Connect to multiserver
|
||||
Once both the Z5Client and the emulator are started we must connect them. Within the emulator we click on the "Tools" menu and select "Lua console". In the new window click on the folder icon and look for the ootMulti.lua file. Once the file is loaded it will connect automatically to Z5Client.
|
||||
|
||||
Note: We strongly advise you don't open any emulator menu while it and Z5client are connected, as the script will halt and disconnects can happen. If you get disconnected just double click on the script again.
|
||||
|
||||
To connect the client to the multiserver simply put address:port on the textfield on top and press enter (if the server uses password, type on the bottom textfield /connect <address>:<port> [password], to connect)
|
||||
|
||||
Now you are ready to start your adventure in Hyrule.
|
||||
372
WebHostLib/static/assets/tutorial/zelda5/setup_es.md
Normal file
372
WebHostLib/static/assets/tutorial/zelda5/setup_es.md
Normal file
@@ -0,0 +1,372 @@
|
||||
# Guia instalación de Ocarina of time Archipelago
|
||||
|
||||
## Nota importante
|
||||
|
||||
Al usar el cliente y bizhawk, esta guia solo es aplicable en Windows.
|
||||
|
||||
## Software Requerido
|
||||
|
||||
- [bizhawk+script+Z5Client](https://github.com/ArchipelagoMW/Z5Client/releases) Recomendamos bajar el setup de Z5client ya que automatizara varios pasos mas adelante
|
||||
|
||||
## Instala emulador y cliente
|
||||
|
||||
Descarga el fichero getBizhawk.ps1 del enlace anterior. Colocalo en la carpeta donde desees instalar el emulador, haz click derecho en él y selecciona "Ejecutar con PowerShell". Esto descargará todas las dependencias necesarias para el emulador. Puede tardar un rato.
|
||||
|
||||
Es recomendable asociar la extensión de las roms de N64 (\*.n64) al bizhawk que hemos instalado anteriormente. Para hacerlo simplemente debemos buscar alguna rom de n64 que tengamos, hacer click derecho, seleccionar "Abrir con...", desplegar la lista y buscar la opción "Buscar otra aplicación", navegar hasta el directorio de bizhawk y seleccionar EmuHawk.exe
|
||||
|
||||
Situa el fichero ootMulti.lua del enlace anterior en la carpeta "lua" del emulador recien instalado.
|
||||
|
||||
Instala el cliente Z5Client.
|
||||
|
||||
## Configura tu fichero YAML
|
||||
|
||||
### Que es un fichero YAML y por qué necesito uno?
|
||||
Tu fichero YAML contiene un numero de opciones que proveen al generador con información sobre como debe generar tu juego.
|
||||
Cada jugador de un multiworld entregara u propio fichero YAML.
|
||||
Esto permite que cada jugador disfrute de una experiencia personalizada a su gusto y diferentes jugadores dentro del mismo multiworld
|
||||
pueden tener diferentes opciones
|
||||
|
||||
### Where do I get a YAML file?
|
||||
Un fichero basico yaml para OOT tendra este aspecto. (Hay muchas opciones cosméticas que se han ignorado para este tutorial, si quieres ver una lista completa, descarga (Archipelago)[https://github.com/ArchipelagoMW/Archipelago/releases] y buscar el fichero de ejemplo en el directorio "Players"))
|
||||
```yaml
|
||||
description: Default Ocarina of Time Template # Describe tu fichero yalm
|
||||
\# Tu nombre en el juego. Los espacio seran reemplazados por _ y hay un limite de 16 caracteres
|
||||
name: YourName{number}
|
||||
game:
|
||||
Ocarina of Time: 1
|
||||
requires:
|
||||
version: 0.1.7 # Version de archipelago minima.
|
||||
\# Opciones compartidas por todos los juegos:
|
||||
accessibility:
|
||||
items: 0 # Garantiza que puedes obtener todos los objetos pero no todas las localizaciones
|
||||
locations: 50 # Garantiza que puedes obtener todas las localizaciones
|
||||
none: 0 # Solo garantiza que el juego pueda completarse.
|
||||
progression_balancing:
|
||||
on: 50 # Un sistema para reducir tiempos de espera en una partida multiworld
|
||||
off: 0
|
||||
Ocarina of Time:
|
||||
logic_rules: # Logica usada por el randomizer.
|
||||
glitchless: 50
|
||||
glitched: 0
|
||||
no_logic: 0
|
||||
logic_no_night_tokens_without_suns_song: # Las skulltulas nocturnas requeriran la cancion del sol por logica
|
||||
false: 50
|
||||
true: 0
|
||||
open_forest: # Indica el estado del bosque Kokiri y el camino al Arbol Deku.
|
||||
open: 50
|
||||
closed_deku: 0
|
||||
closed: 0
|
||||
open_kakariko: # Indica el estado de la puerta de Kakariko hacia la montaña de la muerte.
|
||||
open: 50
|
||||
zelda: 0
|
||||
closed: 0
|
||||
open_door_of_time: # Abre la puerta del tiempo sin la cancion del tiempo.
|
||||
false: 0
|
||||
true: 50
|
||||
zora_fountain: # Indica el estado del rey zora bloqueando el camino a la fuente Zora.
|
||||
open: 0
|
||||
adult: 0
|
||||
closed: 50
|
||||
gerudo_fortress: # Indica los requerimientos para acceder a la fortaleza Gerudo.
|
||||
normal: 0
|
||||
fast: 50
|
||||
open: 0
|
||||
bridge: # Indica los requerimientos para el puente arco iris.
|
||||
open: 0
|
||||
vanilla: 0
|
||||
stones: 0
|
||||
medallions: 50
|
||||
dungeons: 0
|
||||
tokens: 0
|
||||
trials: # Numero de pruebas dentro del castillo de Ganon.
|
||||
0: 50 # minimum value
|
||||
6: 0 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
starting_age: # Indica la edad con la que empieza link.
|
||||
child: 50
|
||||
adult: 0
|
||||
triforce_hunt: # Reune piezas de trifuerza para completar el juego.
|
||||
false: 50
|
||||
true: 0
|
||||
triforce_goal: # Numero de piezas de trifuerza requeridas. El numero de piezas disponibles es determinado por la opcion "Item pool".
|
||||
1: 0 # minimum value
|
||||
50: 0 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
20: 50
|
||||
bombchus_in_logic: # Los bombchus son considerados para la logica. El primer pack encontrado da 20 chus y las tiendas kokiri y el bazaar los venden. Bombchus abren la bolera.
|
||||
false: 50
|
||||
true: 0
|
||||
bridge_stones: # Numero de piedras para abrir el puente arco iris.
|
||||
0: 0 # minimum value
|
||||
3: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
bridge_medallions: # Numero de medallones para abrir el puente arco iris.
|
||||
0: 0 # minimum value
|
||||
6: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
bridge_rewards: # Numero de mazmorras (cualquier combinacion de medallones y piedras) para abrir el puente arco iris.
|
||||
0: 0 # minimum value
|
||||
9: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
bridge_tokens: # Numero de skultullas de oro requeridas para el puente arco iris.
|
||||
0: 0 # minimum value
|
||||
100: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
shuffle_mapcompass: # Controla donde pueden aparecer los mapas y las brujulas.
|
||||
remove: 0
|
||||
startwith: 50
|
||||
vanilla: 0
|
||||
dungeon: 0
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_smallkeys: # Controla donde pueden aparecer las llaves pequeñas.
|
||||
remove: 0
|
||||
vanilla: 0
|
||||
dungeon: 50
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_fortresskeys: # Controla donde pueden aparecer las llaves de la fortaleza Gerudo.
|
||||
vanilla: 50
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_bosskeys: # Controla donde pueden aparecer las llaves de jefe (excepto la llave del castillo de ganon).
|
||||
remove: 0
|
||||
vanilla: 0
|
||||
dungeon: 50
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
shuffle_ganon_bosskey: # Controla donde puede aparecer la llave del jefe del castillo de Ganon.
|
||||
remove: 50
|
||||
vanilla: 0
|
||||
dungeon: 0
|
||||
overworld: 0
|
||||
any_dungeon: 0
|
||||
keysanity: 0
|
||||
on_lacs: 0
|
||||
enhance_map_compass: # El mapa indica si una dungeon es clasica o Master Quest. Las brujulas indican la recompensa de mazmorra.
|
||||
false: 50
|
||||
true: 0
|
||||
lacs_condition: # Marca el requerimiento para la escena de las flechas de luz (LACS) en el templo del tiempo.
|
||||
vanilla: 50
|
||||
stones: 0
|
||||
medallions: 0
|
||||
dungeons: 0
|
||||
tokens: 0
|
||||
lacs_stones: # Marca el numero de piedras espirituales requeridas para LACS
|
||||
0: 0 # minimum value
|
||||
3: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
lacs_medallions: # Marca el numero de medallones requeridas para LACS.
|
||||
0: 0 # minimum value
|
||||
6: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
lacs_rewards: # Marca el numero de recompensas de mazmorra requeridas para LACS.
|
||||
0: 0 # minimum value
|
||||
9: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
lacs_tokens: # Marca el numero de Skulltulas de oro requeridas para LACS.
|
||||
0: 0 # minimum value
|
||||
100: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
shuffle_song_items: # Marca donde pueden aparecer las canciones.
|
||||
song: 50
|
||||
dungeon: 0
|
||||
any: 0
|
||||
shopsanity: # Aleatoriza el contenido de las tiendas. "off" para no mezclar las tiendas; "0" mezcla las tiendas pero no permite objetos unicos en ellas.
|
||||
0: 0
|
||||
1: 0
|
||||
2: 0
|
||||
3: 0
|
||||
4: 0
|
||||
random_value: 0
|
||||
off: 50
|
||||
tokensanity: # Indica si las Skulltulas de oro pueden tener objetos que no sean su ficha.
|
||||
off: 50
|
||||
dungeons: 0
|
||||
overworld: 0
|
||||
all: 0
|
||||
shuffle_scrubs: # Aleatoriza los objetos de los Scrubs vendedores y marca su precio.
|
||||
off: 50
|
||||
low: 0
|
||||
regular: 0
|
||||
random_prices: 0
|
||||
shuffle_cows: # Las vacas dan objetos cuando les tocas las cancion de Epona.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_kokiri_sword: # Aleatoriza la posicion de la espada Kokiri.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_ocarinas: # Aleatoriza la posicion de las ocarinas.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_weird_egg: # Aleatoriza la posicion del huevo extraño.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_gerudo_card: # Aleatoriza la posicion de la tarjeta de membresia Gerudo.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_beans: # Añade un pack de 10 judias magicas al juego y el vendedor vende un solo objeto por 60 rupias.
|
||||
false: 50
|
||||
true: 0
|
||||
shuffle_medigoron_carpet_salesman: # Aleatoriza el objeto que vende Medigoron y el vendedor de la alfombra voladora del paramo maldito.
|
||||
false: 50
|
||||
true: 0
|
||||
skip_child_zelda: # Empieza el juego con la carta de zelda, el objeto que daria impa al enseñar la nana de zelda. Y zelda se considera ya visitada (puedes ir directamente a ver a Saria al bosque y a Malon al rancho)
|
||||
false: 50
|
||||
true: 0
|
||||
no_escape_sequence: # Elimina la huida de link y zelda despues de ganar a Ganondorf.
|
||||
false: 0
|
||||
true: 50
|
||||
no_guard_stealth: # Elimina la escena de sigilo antes de ver a Zelda.
|
||||
false: 0
|
||||
true: 50
|
||||
no_epona_race: # No necesitas hacer la carrera para invocar a Epona.
|
||||
false: 0
|
||||
true: 50
|
||||
skip_some_minigame_phases: # La carrera de Dampe y el minijuego de arco a caballo dan ambras recompensas a la vez si se cumplen las condiciones.
|
||||
false: 0
|
||||
true: 50
|
||||
complete_mask_quest: # Todas las mascaras estan disponibles.
|
||||
false: 50
|
||||
true: 0
|
||||
useful_cutscenes: # Ciertas escenas se mantienen (como los Poes del templo del bosque, Darunia o Twinrova. Principalmente util para modos con Glitches.
|
||||
false: 50
|
||||
true: 0
|
||||
fast_chests: # Los cofres siempre se cogen rapido. Si se desactiva, los objetos importantes tienen animacion lenta. (IMPORTANTE: TODOS LOS OBJETOS QUE VAYAN A OTROS MUNDOS SE CONSIDERAN IMPORTANTES)
|
||||
false: 0
|
||||
true: 50
|
||||
free_scarecrow: # Sacara la ocraina cerca de un punto con espantapajaros invoca a Pierre sin necesidad de la cancion.
|
||||
false: 50
|
||||
true: 0
|
||||
fast_bunny_hood: # La capucha conejo mejora tu velocidad como en Majora's Mask.
|
||||
false: 50
|
||||
true: 0
|
||||
chicken_count: # Numero de Cuccos que Anju necesita en el corral para que te de el objeto.
|
||||
0: 0 # minimum value
|
||||
7: 50 # maximum value
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
hints: # Marca el requerimiento para que las piedras chivatas den pistas.
|
||||
none: 0
|
||||
mask: 0
|
||||
agony: 0
|
||||
always: 50
|
||||
hint_dist: # Elije la distribucion de pistas
|
||||
balanced: 50
|
||||
ddr: 0
|
||||
league: 0
|
||||
mw2: 0
|
||||
scrubs: 0
|
||||
strong: 0
|
||||
tournament: 0
|
||||
useless: 0
|
||||
very_strong: 0
|
||||
damage_multiplier: # Controla el daño que recibe Link.
|
||||
half: 0
|
||||
normal: 50
|
||||
double: 0
|
||||
quadruple: 0
|
||||
ohko: 0
|
||||
no_collectible_hearts: # No caen corazones de enemigos u objetos.
|
||||
false: 50
|
||||
true: 0
|
||||
starting_tod: # Cambia el momento del dia al empezar el juego.
|
||||
default: 50
|
||||
sunrise: 0
|
||||
morning: 0
|
||||
noon: 0
|
||||
afternoon: 0
|
||||
sunset: 0
|
||||
evening: 0
|
||||
midnight: 0
|
||||
witching_hour: 0
|
||||
start_with_consumables: # Empieza el juego con el maximo de palos y nueves Deku que pueda llevar Link.
|
||||
false: 50
|
||||
true: 0
|
||||
start_with_rupees: # Empieza el juego con la cartera llena. Las mejoras de cartera vienen llenas.
|
||||
false: 50
|
||||
true: 0
|
||||
item_pool_value: # Cambia el numero de objetos disponibles en el juego.
|
||||
plentiful: 0
|
||||
balanced: 50
|
||||
scarce: 0
|
||||
minimal: 0
|
||||
junk_ice_traps: # Añade trampas de hielo.
|
||||
off: 0
|
||||
normal: 50
|
||||
on: 0
|
||||
mayhem: 0
|
||||
onslaught: 0
|
||||
ice_trap_appearance: # Cambia la apariencia de las trampas de hielo cuando aparecen como objetos fuera de cofres.
|
||||
major_only: 50
|
||||
junk_only: 0
|
||||
anything: 0
|
||||
logic_earliest_adult_trade: # Objeto mas bajo que puede aparecer en la secuencia de cambios de Link Adulto.
|
||||
pocket_egg: 0
|
||||
pocket_cucco: 0
|
||||
cojiro: 0
|
||||
odd_mushroom: 0
|
||||
poachers_saw: 0
|
||||
broken_sword: 0
|
||||
prescription: 50
|
||||
eyeball_frog: 0
|
||||
eyedrops: 0
|
||||
claim_check: 0
|
||||
logic_latest_adult_trade: # Objeto mas tardio que puede aparecer en la secuencia de cambios de Link Adulto.
|
||||
pocket_egg: 0
|
||||
pocket_cucco: 0
|
||||
cojiro: 0
|
||||
odd_mushroom: 0
|
||||
poachers_saw: 0
|
||||
broken_sword: 0
|
||||
prescription: 0
|
||||
eyeball_frog: 0
|
||||
eyedrops: 0
|
||||
claim_check: 50
|
||||
|
||||
```
|
||||
|
||||
## Unirse a un juego MultiWorld
|
||||
|
||||
### Obten tu parche
|
||||
|
||||
|
||||
Cuando te unes a un juego multiworld, se te pedirá que entregues tu fichero YAML a quien sea que hospede el juego multiworld.
|
||||
Una vez la generación acabe, el anfitrión te dará un enlace a tu fichero de datos o un zip con los ficheros de todos.
|
||||
Tu fichero de datos tiene una extensión `.z5ap`.
|
||||
|
||||
Haz doble click en tu fichero `.z5ap` para que se arranque el Z5Client y realize el parcheado de la ROM. Una vez acabe el parcheado de la rom (esto puede llevar un tiempo) se abrira automaticamente el emulador (Si se ha asociado la extensión al emulador tal como hemos recomendado)
|
||||
|
||||
### Conectar al multiserver
|
||||
Una vez arrancado tanto el Z5Client como el emulador hay que conectarlo entre ellos, para ello simplemente accede al menú "Tools" y selecciona "Lua console". En la nueva ventana, dale al icono de la carpeta y busca el fichero ootMulti.lua. Al cargar dicho fichero se conectara automaticamente con el cliente.
|
||||
|
||||
Nota: Es muy recomendable que no se abra ningún menú del emulador mientras esten emulador y Z5Client conectados, ya que el script de conexión se para en ese caso y pueden provocar desconexiones. Si se pierde la conexion, simplemente haz doble click en el script de nuevo.
|
||||
|
||||
Para conectar el cliente con el servidor simplemente pon la direccion_IP:puerto en la caja de texto de arriba y presiona enter (si el servidor tiene contraseña, en la caja de texto de abajo escribir /connect direccion:puerto contraseña, para conectar)
|
||||
|
||||
Y ya estas listo, para emprender tu aventura por Hyrule.
|
||||
@@ -1,486 +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/generated/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/generated/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.';
|
||||
if (error.response.data.text) {
|
||||
userMessage.innerText += ' ' + error.response.data.text;
|
||||
}
|
||||
userMessage.classList.add('visible');
|
||||
window.scrollTo(0, 0);
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,442 +0,0 @@
|
||||
# What is this file?
|
||||
# This file contains options which allow you to configure your multiworld experience while allowing others
|
||||
# to play how they want as well.
|
||||
|
||||
# How do I use it?
|
||||
# The options in this file are weighted. This means the higher number you assign to a value, the more
|
||||
# chances you have for that option to be chosen. For example, an option like this:
|
||||
#
|
||||
# map_shuffle:
|
||||
# on: 5
|
||||
# off: 15
|
||||
#
|
||||
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off
|
||||
|
||||
# I've never seen a file like this before. What characters am I allowed to use?
|
||||
# This is a .yaml file. You are allowed to use most characters.
|
||||
# To test if your yaml is valid or not, you can use this website:
|
||||
# http://www.yamllint.com/
|
||||
|
||||
# For use with the weighted-settings page on the website. Changing this value will cause all users to be prompted
|
||||
# to update their settings. The version number should match the current released version number, and the revision
|
||||
# should be updated manually by whoever edits this file.
|
||||
ws_version: 4.1.1 rev0
|
||||
|
||||
description: Template Name # Used to describe your yaml. Useful if you have multiple files
|
||||
name: YourName # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
|
||||
### Logic Section ###
|
||||
glitches_required: # Determine the logic required to complete the seed
|
||||
none: 50 # No glitches required
|
||||
minor_glitches: 0 # Puts fake flipper, waterwalk, super bunny shenanigans, and etc into logic
|
||||
overworld_glitches: 0 # Assumes the player has knowledge of both overworld major glitches (boots clips, mirror clips) and minor glitches (fake flipper, super bunny shenanigans, water walk and etc.)
|
||||
no_logic: 0 # Your own items are placed with no regard to any logic; such as your Fire Rod can be on your Trinexx.
|
||||
# Other players items are placed into your world under OWG logic
|
||||
dark_room_logic: # Logic for unlit dark rooms
|
||||
lamp: 50 # require the Lamp for these rooms to be considered accessible.
|
||||
torches: 0 # in addition to lamp, allow the fire rod and presence of easily accessible torches for access
|
||||
none: 0 # all dark rooms are always considered doable, meaning this may force completion of rooms in complete darkness
|
||||
restrict_dungeon_item_on_boss: # aka ambrosia boss items
|
||||
on: 0 # prevents unshuffled compasses, maps and keys to be boss drops, they can still drop keysanity and other players' items
|
||||
off: 50
|
||||
### End of Logic Section ###
|
||||
meta_ignore: # Nullify options specified in the meta.yaml file. Adding an option here guarantees it will not occur in your seed, even if the .yaml file specifies it
|
||||
mode:
|
||||
- inverted # Never play inverted seeds
|
||||
retro:
|
||||
- on # Never play retro seeds
|
||||
weapons:
|
||||
- swordless # Never play a swordless seed
|
||||
map_shuffle: # Shuffle dungeon maps into the world and other dungeons, including other players' worlds
|
||||
on: 0
|
||||
off: 50
|
||||
compass_shuffle: # Shuffle compasses into the world and other dungeons, including other players' worlds
|
||||
on: 0
|
||||
off: 50
|
||||
smallkey_shuffle: # Shuffle small keys into the world and other dungeons, including other players' worlds
|
||||
on: 0
|
||||
universal: 0 # allows small keys to be used in any dungeon and adds shops to buy more
|
||||
off: 50
|
||||
bigkey_shuffle: # Shuffle big keys into the world and other dungeons, including other players' worlds
|
||||
on: 0
|
||||
off: 50
|
||||
local_keys: # Keep small keys and big keys local to your world
|
||||
on: 0
|
||||
off: 50
|
||||
dungeon_items: # Alternative to the 4 shuffles and local_keys above this, does nothing until the respective 4 shuffles and local_keys above are deleted
|
||||
mc: 0 # Shuffle maps and compasses
|
||||
none: 50 # Shuffle none of the 4
|
||||
mcsb: 0 # Shuffle all of the 4, any combination of m, c, s and b will shuffle the respective item, or not if it's missing, so you can add more options here
|
||||
lmcsb: 0 # Like mcsb above, but with keys kept local to your world. l is what makes your keys local, or not if it's missing
|
||||
ub: 0 # universal small keys and shuffled big keys
|
||||
# you can add more combos of these letters here
|
||||
dungeon_counters:
|
||||
on: 0 # Always display amount of items checked in a dungeon
|
||||
pickup: 50 # Show when compass is picked up
|
||||
default: 0 # Show when compass is picked up if the compass itself is shuffled
|
||||
off: 0 # Never show item count in dungeons
|
||||
accessibility:
|
||||
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
|
||||
locations: 50 # Guarantees you will be able to access all locations, and therefore all items
|
||||
none: 0 # Guarantees only that the game is beatable. You may not be able to access all locations or acquire all items
|
||||
progressive: # Enable or disable progressive items (swords, shields, bow)
|
||||
on: 50 # All items are progressive
|
||||
off: 0 # No items are progressive
|
||||
random: 0 # Randomly decides for all items. Swords could be progressive, shields might not be
|
||||
entrance_shuffle: # Documentation: https://alttpr.com/en/options#entrance_shuffle
|
||||
none: 50 # Vanilla game map. All entrances and exits lead to their original locations. You probably want this option
|
||||
dungeonssimple: 0 # Shuffle just dungeons amongst each other, swapping dungeons entirely, so Hyrule Castle is always 1 dungeon
|
||||
dungeonsfull: 0 # Shuffle any dungeon entrance with any dungeon interior, so Hyrule Castle can be 4 different dungeons
|
||||
simple: 0 # Entrances are grouped together before being randomized. Simple uses the most strict grouping rules
|
||||
restricted: 0 # Less strict than simple
|
||||
full: 0 # Less strict than restricted
|
||||
crossed: 0 # Less strict than full
|
||||
insanity: 0 # Very few grouping rules. Good luck
|
||||
goals:
|
||||
ganon: 50 # Climb GT, defeat Agahnim 2, and then kill Ganon
|
||||
fast_ganon: 0 # Only killing Ganon is required. The hole is always open. However, items may still be placed in GT
|
||||
dungeons: 0 # Defeat the boss of all dungeons, including Agahnim's tower and GT (Aga 2)
|
||||
pedestal: 0 # Pull the Triforce from the Master Sword pedestal
|
||||
ganon_pedestal: 0 # Pull the Master Sword pedestal, then kill Ganon
|
||||
triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout the worlds, then turn them in to Murahadala in front of Hyrule Castle
|
||||
local_triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout your world, then turn them in to Murahadala in front of Hyrule Castle
|
||||
ganon_triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout the worlds, then kill Ganon
|
||||
local_ganon_triforce_hunt: 0 # Collect 20 of 30 Triforce pieces spread throughout your world, then kill Ganon
|
||||
ice_rod_hunt: 0 # You start with everything needed to 216 the seed. Find the Ice rod, then kill Trinexx at Turtle rock.
|
||||
pyramid_open:
|
||||
goal: 50 # Opens the pyramid if the goal requires you to kill Ganon, unless the goal is Slow Ganon or All Dungeons
|
||||
auto: 0 # Same as Goal, but also opens when any non-dungeon entrance shuffle is used
|
||||
yes: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt
|
||||
no: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower
|
||||
triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces.
|
||||
extra: 0 # available = triforce_pieces_extra + triforce_pieces_required
|
||||
percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required
|
||||
available: 50 # available = triforce_pieces_available
|
||||
triforce_pieces_extra: # Set to how many extra triforces pieces are available to collect in the world.
|
||||
# Format "pieces: chance"
|
||||
0: 0
|
||||
5: 50
|
||||
10: 50
|
||||
15: 0
|
||||
20: 0
|
||||
triforce_pieces_percentage: # Set to how many triforce pieces according to a percentage of the required ones, are available to collect in the world.
|
||||
# Format "pieces: chance"
|
||||
100: 0 #No extra
|
||||
150: 50 #Half the required will be added as extra
|
||||
200: 0 #There are the double of the required ones available.
|
||||
triforce_pieces_available: # Set to how many triforces pieces are available to collect in the world. Default is 30. Max is 90, Min is 1
|
||||
# Format "pieces: chance"
|
||||
25: 0
|
||||
30: 50
|
||||
40: 0
|
||||
50: 0
|
||||
triforce_pieces_required: # Set to how many out of X triforce pieces you need to win the game in a triforce hunt. Default is 20. Max is 90, Min is 1
|
||||
# Format "pieces: chance"
|
||||
15: 0
|
||||
20: 50
|
||||
30: 0
|
||||
40: 0
|
||||
50: 0
|
||||
tower_open: # Crystals required to open GT
|
||||
'0': 80
|
||||
'1': 70
|
||||
'2': 60
|
||||
'3': 50
|
||||
'4': 40
|
||||
'5': 30
|
||||
'6': 20
|
||||
'7': 10
|
||||
random: 0
|
||||
ganon_open: # Crystals required to hurt Ganon
|
||||
'0': 80
|
||||
'1': 70
|
||||
'2': 60
|
||||
'3': 50
|
||||
'4': 40
|
||||
'5': 30
|
||||
'6': 20
|
||||
'7': 10
|
||||
random: 0
|
||||
mode:
|
||||
standard: 50 # Begin the game by rescuing Zelda from her cell and escorting her to the Sanctuary
|
||||
open: 50 # Begin the game from your choice of Link's House or the Sanctuary
|
||||
inverted: 0 # Begin in the Dark World. The Moon Pearl is required to avoid bunny-state in Light World, and the Light World game map is altered
|
||||
retro:
|
||||
on: 0 # you must buy a quiver to use the bow, take-any caves and an old-man cave are added to the world. You may need to find your sword from the old man's cave
|
||||
off: 50
|
||||
hints:
|
||||
'on': 50 # Hint tiles sometimes give item location hints
|
||||
'off': 0 # Hint tiles provide gameplay tips
|
||||
weapons: # Specifically, swords
|
||||
randomized: 0 # Swords are placed randomly throughout the world
|
||||
assured: 50 # Begin with a sword, the rest are placed randomly throughout the world
|
||||
vanilla: 0 # Swords are placed in vanilla locations in your own game (Uncle, Pyramid Fairy, Smiths, Pedestal)
|
||||
swordless: 0 # Your swords are replaced by rupees. Gameplay changes have been made to accommodate this change
|
||||
item_pool:
|
||||
easy: 0 # Doubled upgrades, progressives, and etc
|
||||
normal: 50 # Item availability remains unchanged from vanilla game
|
||||
hard: 0 # Reduced upgrade availability (max: 14 hearts, blue mail, tempered sword, fire shield, no silvers unless swordless)
|
||||
expert: 0 # Minimum upgrade availability (max: 8 hearts, green mail, master sword, fighter shield, no silvers unless swordless)
|
||||
item_functionality:
|
||||
easy: 0 # Allow Hammer to damage ganon, Allow Hammer tablet collection, Allow swordless medallion use everywhere.
|
||||
normal: 50 # Vanilla item functionality
|
||||
hard: 0 # Reduced helpfulness of items (potions less effective, can't catch faeries, cape uses double magic, byrna does not grant invulnerability, boomerangs do not stun, silvers disabled outside ganon)
|
||||
expert: 0 # Vastly reduces the helpfulness of items (potions barely effective, can't catch faeries, cape uses double magic, byrna does not grant invulnerability, boomerangs and hookshot do not stun, silvers disabled outside ganon)
|
||||
progression_balancing:
|
||||
on: 50 # A system to reduce BK, as in times during which you can't do anything by moving your items into an earlier access sphere to make it likely you have stuff to do
|
||||
off: 0 # Turn this off if you don't mind a longer multiworld, or can glitch around missing items.
|
||||
tile_shuffle: # Randomize the tile layouts in flying tile rooms
|
||||
on: 0
|
||||
off: 50
|
||||
misery_mire_medallion: # required medallion to open Misery Mire front entrance
|
||||
random: 50
|
||||
ether: 0
|
||||
bombos: 0
|
||||
quake: 0
|
||||
turtle_rock_medallion: # required medallion to open Turtle Rock front entrance
|
||||
random: 50
|
||||
ether: 0
|
||||
bombos: 0
|
||||
quake: 0
|
||||
### Enemizer Section ###
|
||||
boss_shuffle:
|
||||
none: 50 # Vanilla bosses
|
||||
simple: 0 # Existing bosses except Ganon and Agahnim are shuffled throughout dungeons
|
||||
full: 0 # 3 bosses can occur twice
|
||||
random: 0 # Any boss can appear any amount of times
|
||||
singularity: 0 # Picks a boss, tries to put it everywhere that works, if there's spaces remaining it picks a boss to fill those
|
||||
enemy_shuffle: # Randomize enemy placement
|
||||
on: 0
|
||||
off: 50
|
||||
killable_thieves: # Make thieves killable
|
||||
on: 0 # Usually turned on together with enemy_shuffle to make annoying thief placement more manageable
|
||||
off: 50
|
||||
bush_shuffle: # Randomize the chance that bushes have enemies and the enemies under said bush
|
||||
on: 0
|
||||
off: 50
|
||||
enemy_damage:
|
||||
default: 50 # Vanilla enemy damage
|
||||
shuffled: 0 # Enemies deal 0 to 4 hearts and armor helps
|
||||
random: 0 # Enemies deal 0 to 8 hearts and armor just reshuffles the damage
|
||||
enemy_health:
|
||||
default: 50 # Vanilla enemy HP
|
||||
easy: 0 # Enemies have reduced health
|
||||
hard: 0 # Enemies have increased health
|
||||
expert: 0 # Enemies have greatly increased health
|
||||
pot_shuffle:
|
||||
'on': 0 # Keys, items, and buttons hidden under pots in dungeons are shuffled with other pots in their supertile
|
||||
'off': 50 # Default pot item locations
|
||||
### End of Enemizer Section ###
|
||||
beemizer: # Remove items from the global item pool and replace them with single bees and bee traps
|
||||
0: 50 # No bee traps are placed
|
||||
1: 0 # 25% of rupees, bombs and arrows are replaced with bees, of which 60% are traps and 40% single bees
|
||||
2: 0 # 50% of rupees, bombs and arrows are replaced with bees, of which 70% are traps and 30% single bees
|
||||
3: 0 # 75% of rupees, bombs and arrows are replaced with bees, of which 80% are traps and 20% single bees
|
||||
4: 0 # 100% of rupees, bombs and arrows are replaced with bees, of which 90% are traps and 10% single bees
|
||||
### Shop Settings ###
|
||||
shop_shuffle_slots: # Maximum amount of shop slots to be filled with regular item pool items (such as Moon Pearl)
|
||||
0: 50
|
||||
10: 0
|
||||
20: 0
|
||||
30: 0
|
||||
shop_shuffle:
|
||||
none: 50
|
||||
g: 0 # Generate new default inventories for overworld/underworld shops, and unique shops
|
||||
f: 0 # Generate new default inventories for every shop independently
|
||||
p: 0 # Randomize the prices of the items in shop inventories
|
||||
u: 0 # Shuffle capacity upgrades into the item pool (and allow them to traverse the multiworld)
|
||||
w: 0 # Consider witch's hut like any other shop and shuffle/randomize it too
|
||||
gp: 0 # Shuffle inventories and randomize prices
|
||||
fp: 0 # Randomize items in every shop and their prices
|
||||
ufp: 0 # Randomize items and prices in every shop, and include capacity upgrades in item pool
|
||||
wfp: 0 # Randomize items and prices in every shop, and include potion shop inventory in shuffle
|
||||
ufpw: 0 # Randomize items and prices in every shop, shuffle potion shop inventory, and include capacity upgrades
|
||||
# You can add more combos
|
||||
### End of Shop Section ###
|
||||
shuffle_prizes: # aka drops
|
||||
none: 0 # do not shuffle prize packs
|
||||
g: 50 # shuffle "general" price packs, as in enemy, tree pull, dig etc.
|
||||
b: 0 # shuffle "bonk" price packs
|
||||
bg: 0 # shuffle both
|
||||
timer:
|
||||
none: 50 # No timer will be displayed.
|
||||
timed: 0 # Starts with clock at zero. Green clocks subtract 4 minutes (total 20). Blue clocks subtract 2 minutes (total 10). Red clocks add two minutes (total 10). Winner is the player with the lowest time at the end.
|
||||
timed_ohko: 0 # Starts the clock at ten minutes. Green clocks add five minutes (total 25). As long as the clock as at zero, Link will die in one hit.
|
||||
ohko: 0 # Timer always at zero. Permanent OHKO.
|
||||
timed_countdown: 0 # Starts the clock with forty minutes. Same clocks as timed mode, but if the clock hits zero you lose. You can still keep playing, though.
|
||||
display: 0 # Displays a timer, but otherwise does not affect gameplay or the item pool.
|
||||
countdown_start_time: # For timed_ohko and timed_countdown timer modes, the amount of time in minutes to start with
|
||||
0: 0 # For timed_ohko, starts in OHKO mode when starting the game
|
||||
10: 50
|
||||
20: 0
|
||||
30: 0
|
||||
60: 0
|
||||
red_clock_time: # For all timer modes, the amount of time in minutes to gain or lose when picking up a red clock
|
||||
-2: 50
|
||||
1: 0
|
||||
blue_clock_time: # For all timer modes, the amount of time in minutes to gain or lose when picking up a blue clock
|
||||
1: 0
|
||||
2: 50
|
||||
green_clock_time: # For all timer modes, the amount of time in minutes to gain or lose when picking up a green clock
|
||||
4: 50
|
||||
10: 0
|
||||
15: 0
|
||||
# Can be uncommented to use it
|
||||
# local_items: # Force certain items to appear in your world only, not across the multiworld. Recognizes some group names, like "Swords"
|
||||
# - "Moon Pearl"
|
||||
# - "Small Keys"
|
||||
# - "Big Keys"
|
||||
# Can be uncommented to use it
|
||||
# startinventory: # Begin the file with the listed items/upgrades
|
||||
# Pegasus Boots: on
|
||||
# Bomb Upgrade (+10): 4
|
||||
# Arrow Upgrade (+10): 4
|
||||
glitch_boots:
|
||||
on: 50 # Start with Pegasus Boots in any glitched logic mode that makes use of them
|
||||
off: 0
|
||||
linked_options:
|
||||
- name: crosskeys
|
||||
options: # These overwrite earlier options if the percentage chance triggers
|
||||
entrance_shuffle: crossed
|
||||
bigkey_shuffle: true
|
||||
compass_shuffle: true
|
||||
map_shuffle: true
|
||||
smallkey_shuffle: true
|
||||
percentage: 0 # Set this to the percentage chance you want crosskeys
|
||||
- name: localcrosskeys
|
||||
options: # These overwrite earlier options if the percentage chance triggers
|
||||
entrance_shuffle: crossed
|
||||
bigkey_shuffle: true
|
||||
compass_shuffle: true
|
||||
map_shuffle: true
|
||||
smallkey_shuffle: true
|
||||
local_items: # Forces keys to be local to your own world
|
||||
- "Small Keys"
|
||||
- "Big Keys"
|
||||
percentage: 0 # Set this to the percentage chance you want local crosskeys
|
||||
- name: enemizer
|
||||
options:
|
||||
boss_shuffle: # Subchances can be injected too, which then get rolled
|
||||
simple: 1
|
||||
full: 1
|
||||
random: 1
|
||||
singularity: 1
|
||||
enemy_damage:
|
||||
shuffled: 1
|
||||
random: 1
|
||||
enemy_health:
|
||||
easy: 1
|
||||
hard: 1
|
||||
expert: 1
|
||||
percentage: 0 # Set this to the percentage chance you want enemizer
|
||||
### door rando only options ###
|
||||
door_shuffle: # Only available if the host uses the doors branch, it is ignored otherwise
|
||||
vanilla: 50 # Everything should be like in vanilla
|
||||
basic: 0 # Dungeons are shuffled within themselves
|
||||
crossed: 0 # Dungeons are shuffled across each other
|
||||
intensity: # Only available if the host uses the doors branch, it is ignored otherwise
|
||||
1: 50 # Shuffles normal doors and spiral staircases
|
||||
2: 0 # And shuffles open edges and straight staircases
|
||||
3: 0 # And shuffles dungeon lobbies
|
||||
random: 0 # Picks one of those at random
|
||||
key_drop_shuffle: # Only available if the host uses the doors branch, it is ignored otherwise
|
||||
on: 0 # Enables the small keys dropped by enemies or under pots, and the big key dropped by the Ball & Chain guard to be shuffled into the pool. This extends the number of checks to 249.
|
||||
off: 50
|
||||
experimental: # Only available if the host uses the doors branch, it is ignored otherwise
|
||||
on: 0 # Enables experimental features.
|
||||
off: 50
|
||||
debug: # Only available if the host uses the doors branch, it is ignored otherwise
|
||||
on: 0 # Enables debugging features. Currently, these are the Item collection counter. (overwrites total triforce pieces) and Castle Gate closed indicator.
|
||||
off: 50
|
||||
### end of door rando only options ###
|
||||
rom:
|
||||
#sprite_pool: # When specified, limits the pool of sprites used for randomon-event to the specified pool. Uncomment to use this.
|
||||
# - link
|
||||
# - pride link
|
||||
# - penguin link
|
||||
# - random # You can specify random multiple times for however many potentially unique random sprites you want in your pool.
|
||||
sprite: # Enter the name of your preferred sprite and weight it appropriately
|
||||
random: 0
|
||||
link: 50 # To add other sprites: open the gui/Creator, go to adjust, select a sprite and write down the name the gui calls it
|
||||
disablemusic: # If "on", all in-game music will be disabled
|
||||
on: 0
|
||||
off: 50
|
||||
quickswap: # Enable switching items by pressing the L+R shoulder buttons
|
||||
on: 50
|
||||
off: 0
|
||||
triforcehud: # Disable visibility of the triforce hud unless collecting a piece or speaking to Murahadala
|
||||
normal: 50 # original behavior (always visible)
|
||||
hide_goal: 0 # hide counter until a piece is collected or speaking to Murahadala
|
||||
hide_required: 0 # Always visible, but required amount is invisible until determined by Murahadala
|
||||
hide_both: 0 # Hide both under above circumstances
|
||||
reduceflashing: # Reduces instances of flashing such as lightning attacks, weather, ether and more.
|
||||
on: 50
|
||||
off: 0
|
||||
menuspeed: # Controls how fast the item menu opens and closes
|
||||
normal: 50
|
||||
instant: 0
|
||||
double: 0
|
||||
triple: 0
|
||||
quadruple: 0
|
||||
half: 0
|
||||
heartcolor: # Controls the color of your health hearts
|
||||
red: 50
|
||||
blue: 0
|
||||
green: 0
|
||||
yellow: 0
|
||||
random: 0
|
||||
heartbeep: # Controls the frequency of the low-health beeping
|
||||
double: 0
|
||||
normal: 50
|
||||
half: 0
|
||||
quarter: 0
|
||||
off: 0
|
||||
ow_palettes: # Change the colors of the overworld
|
||||
default: 50 # No changes
|
||||
random: 0 # Shuffle the colors, with harmony in mind
|
||||
blackout: 0 # everything black / blind mode
|
||||
grayscale: 0
|
||||
negative: 0
|
||||
classic: 0
|
||||
dizzy: 0
|
||||
sick: 0
|
||||
puke: 0
|
||||
uw_palettes: # Change the colors of caves and dungeons
|
||||
default: 50 # No changes
|
||||
random: 0 # Shuffle the colors, with harmony in mind
|
||||
blackout: 0 # everything black / blind mode
|
||||
grayscale: 0
|
||||
negative: 0
|
||||
classic: 0
|
||||
dizzy: 0
|
||||
sick: 0
|
||||
puke: 0
|
||||
hud_palettes: # Change the colors of the hud
|
||||
default: 50 # No changes
|
||||
random: 0 # Shuffle the colors, with harmony in mind
|
||||
blackout: 0 # everything black / blind mode
|
||||
grayscale: 0
|
||||
negative: 0
|
||||
classic: 0
|
||||
dizzy: 0
|
||||
sick: 0
|
||||
puke: 0
|
||||
sword_palettes: # Change the colors of swords
|
||||
default: 50 # No changes
|
||||
random: 0 # Shuffle the colors, with harmony in mind
|
||||
blackout: 0 # everything black / blind mode
|
||||
grayscale: 0
|
||||
negative: 0
|
||||
classic: 0
|
||||
dizzy: 0
|
||||
sick: 0
|
||||
puke: 0
|
||||
shield_palettes: # Change the colors of shields
|
||||
default: 50 # No changes
|
||||
random: 0 # Shuffle the colors, with harmony in mind
|
||||
blackout: 0 # everything black / blind mode
|
||||
grayscale: 0
|
||||
negative: 0
|
||||
classic: 0
|
||||
dizzy: 0
|
||||
sick: 0
|
||||
puke: 0
|
||||
@@ -25,6 +25,21 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#generate-game-form{
|
||||
#generate-game-form-wrapper table td{
|
||||
text-align: left;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
#generate-form-button-row{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#file-input{
|
||||
display: none;
|
||||
}
|
||||
|
||||
.interactive{
|
||||
color: #ffef00;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ html{
|
||||
width: 200px;
|
||||
height: calc(156px - 40px);
|
||||
padding-top: 40px;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#mid-left-button{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#tutorial-wrapper{
|
||||
.markdown{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70rem;
|
||||
@@ -10,20 +10,20 @@
|
||||
color: #eeffeb;
|
||||
}
|
||||
|
||||
#tutorial-wrapper img{
|
||||
.markdown img{
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#tutorial-wrapper p{
|
||||
.markdown p{
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#tutorial-wrapper a{
|
||||
.markdown a{
|
||||
color: #ffef00;
|
||||
}
|
||||
|
||||
#tutorial-wrapper h1{
|
||||
.markdown h1{
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
@@ -34,7 +34,7 @@
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
#tutorial-wrapper h2{
|
||||
.markdown h2{
|
||||
font-size: 2rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
@@ -46,7 +46,7 @@
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
|
||||
#tutorial-wrapper h3{
|
||||
.markdown h3{
|
||||
font-size: 1.70rem;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
@@ -55,44 +55,50 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#tutorial-wrapper h4{
|
||||
.markdown h4{
|
||||
font-size: 1.5rem;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#tutorial-wrapper h5{
|
||||
.markdown h5{
|
||||
font-size: 1.25rem;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#tutorial-wrapper h6{
|
||||
.markdown h6{
|
||||
font-size: 1.25rem;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
color: #434343;
|
||||
}
|
||||
|
||||
#tutorial-wrapper h3, #tutorial-wrapper h4, #tutorial-wrapper h5,#tutorial-wrapper h6{
|
||||
.markdown h3, .markdown h4, .markdown h5,.markdown h6{
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#tutorial-wrapper ul{
|
||||
.markdown h4, .markdown h5,.markdown h6{
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown ul{
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown ol{
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown li{
|
||||
|
||||
}
|
||||
|
||||
#tutorial-wrapper ol{
|
||||
|
||||
}
|
||||
|
||||
#tutorial-wrapper li{
|
||||
|
||||
}
|
||||
|
||||
#tutorial-wrapper pre{
|
||||
.markdown pre{
|
||||
margin-top: 0;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background-color: #ffeeab;
|
||||
@@ -101,7 +107,7 @@
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#tutorial-wrapper code{
|
||||
.markdown code{
|
||||
background-color: #ffeeab;
|
||||
border-radius: 4px;
|
||||
padding-left: 0.25rem;
|
||||
@@ -109,12 +115,30 @@
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#tutorial-wrapper #tutorial-video-container{
|
||||
.markdown #tutorial-video-container{
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#tutorial-wrapper #language-selector-wrapper{
|
||||
.markdown #language-selector-wrapper{
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.markdown table{
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown table th{
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
border: 1px solid #eeffeb;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.markdown table td{
|
||||
text-align: left;
|
||||
border: 1px solid #eeffeb;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
html{
|
||||
background-image: url('../../static/backgrounds/grass/grass-0007-large.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 650px 650px;
|
||||
}
|
||||
|
||||
#player-settings{
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
}
|
||||
|
||||
#player-settings #player-settings-button-row{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
#player-settings code{
|
||||
background-color: #d9cd8e;
|
||||
border-radius: 4px;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#player-settings #user-message{
|
||||
display: none;
|
||||
width: calc(100% - 8px);
|
||||
background-color: #ffe86b;
|
||||
border-radius: 4px;
|
||||
color: #000000;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#player-settings #user-message.visible{
|
||||
display: block;
|
||||
}
|
||||
|
||||
#player-settings h1{
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
#player-settings h2{
|
||||
font-size: 2rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffe993;
|
||||
text-transform: lowercase;
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
|
||||
#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#player-settings a{
|
||||
color: #ffef00;
|
||||
}
|
||||
|
||||
#player-settings input:not([type]){
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#player-settings input:not([type]):focus{
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
#player-settings select{
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
#player-settings #game-options, #player-settings #rom-options{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#player-settings .left, #player-settings .right{
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table select{
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
#player-settings table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
margin-right: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px), all and (orientation: portrait){
|
||||
#player-settings #game-options, #player-settings #rom-options{
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#player-settings .left, #player-settings .right{
|
||||
flex-grow: unset;
|
||||
}
|
||||
|
||||
#game-options table label, #rom-options table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
102
WebHostLib/static/styles/minecraftTracker.css
Normal file
102
WebHostLib/static/styles/minecraftTracker.css
Normal file
@@ -0,0 +1,102 @@
|
||||
#player-tracker-wrapper{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory-table{
|
||||
border-top: 2px solid #000000;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 384px;
|
||||
background-color: #42b149;
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired{
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory-table div.item-count {
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-family: "Minecraftia", monospace;
|
||||
font-weight: bold;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 384px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: #42b149;
|
||||
padding: 0 3px 3px;
|
||||
font-family: "Minecraftia", monospace;
|
||||
font-size: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#location-table th{
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
#location-table td{
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#location-table td.counter {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#location-table td.toggle-arrow {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#location-table tr#Total-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table img{
|
||||
height: 100%;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
#location-table tbody.locations {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#location-table td.location-name {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
136
WebHostLib/static/styles/ootTracker.css
Normal file
136
WebHostLib/static/styles/ootTracker.css
Normal file
@@ -0,0 +1,136 @@
|
||||
#player-tracker-wrapper{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory-table{
|
||||
border-top: 2px solid #000000;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 448px;
|
||||
background-color: rgb(60, 114, 157);
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired{
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory-table div.item-count {
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
bottom: 0px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 448px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: rgb(60, 114, 157);
|
||||
padding: 0 3px 3px;
|
||||
font-family: monospace;
|
||||
font-size: 15px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#location-table th{
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
#location-table td{
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#location-table td.counter {
|
||||
text-align: right;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
#location-table td.toggle-arrow {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#location-table tr#Total-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table img{
|
||||
height: 100%;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
#location-table tbody.locations {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#location-table td.location-name {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.right-align {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table td:first-child {
|
||||
width: 272px;
|
||||
}
|
||||
|
||||
.location-category td:first-child {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
#inventory-table img.acquired#lullaby{
|
||||
filter: sepia(100%) hue-rotate(-60deg); /* css trick to hue-shift a static image */
|
||||
}
|
||||
|
||||
#inventory-table img.acquired#epona{
|
||||
filter: sepia(100%) hue-rotate(-20deg) saturate(250%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired#saria{
|
||||
filter: sepia(100%) hue-rotate(60deg) saturate(150%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired#sun{
|
||||
filter: sepia(100%) hue-rotate(15deg) saturate(200%) brightness(120%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired#time{
|
||||
filter: sepia(100%) hue-rotate(160deg) saturate(150%);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
html{
|
||||
background-image: url('../../static/backgrounds/grass/grass-0007-large.png');
|
||||
background-image: url('../static/backgrounds/grass/grass-0007-large.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 650px 650px;
|
||||
}
|
||||
@@ -101,8 +101,28 @@ html{
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table select{
|
||||
width: 250px;
|
||||
#player-settings table .select-container{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#player-settings table .select-container select{
|
||||
min-width: 200px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table .range-container{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#player-settings table .range-container input[type=range]{
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table .range-value{
|
||||
min-width: 20px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
#player-settings table label{
|
||||
@@ -113,7 +133,7 @@ html{
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px), all and (orientation: portrait){
|
||||
#player-settings #game-options, #player-settings #rom-options{
|
||||
#player-settings #game-options{
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -122,7 +142,7 @@ html{
|
||||
flex-grow: unset;
|
||||
}
|
||||
|
||||
#game-options table label, #rom-options table label{
|
||||
#game-options table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
}
|
||||
12
WebHostLib/static/styles/startPlaying.css
Normal file
12
WebHostLib/static/styles/startPlaying.css
Normal file
@@ -0,0 +1,12 @@
|
||||
#start-playing-wrapper{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#start-playing{
|
||||
width: 700px;
|
||||
min-height: 240px;
|
||||
text-align: center;
|
||||
}
|
||||
3
WebHostLib/static/styles/subnautica/subnautica.css
Normal file
3
WebHostLib/static/styles/subnautica/subnautica.css
Normal file
@@ -0,0 +1,3 @@
|
||||
#subnautica{
|
||||
margin: 1rem;
|
||||
}
|
||||
104
WebHostLib/static/styles/supermetroidTracker.css
Normal file
104
WebHostLib/static/styles/supermetroidTracker.css
Normal file
@@ -0,0 +1,104 @@
|
||||
#player-tracker-wrapper{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory-table{
|
||||
border-top: 2px solid #000000;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 384px;
|
||||
background-color: #546E7A;
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired{
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory-table div.item-count {
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-family: "Minecraftia", monospace;
|
||||
font-weight: bold;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 384px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: #546E7A;
|
||||
color: #000000;
|
||||
padding: 0 3px 3px;
|
||||
font-size: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#location-table th{
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
#location-table td{
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#location-table td.counter {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#location-table td.toggle-arrow {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#location-table tr#Total-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table img{
|
||||
height: 100%;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
#location-table tbody.locations {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#location-table td.location-name {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
101
WebHostLib/static/styles/timespinnerTracker.css
Normal file
101
WebHostLib/static/styles/timespinnerTracker.css
Normal file
@@ -0,0 +1,101 @@
|
||||
#player-tracker-wrapper{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory-table{
|
||||
border-top: 2px solid #000000;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 384px;
|
||||
background-color: #8d60a7;
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired{
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory-table div.item-count {
|
||||
position: absolute;
|
||||
color: white;
|
||||
font-family: "Minecraftia", monospace;
|
||||
font-weight: bold;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 384px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: #8d60a7;
|
||||
padding: 0 3px 3px;
|
||||
font-size: 14px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#location-table th{
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
#location-table td{
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#location-table td.counter {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#location-table td.toggle-arrow {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#location-table tr#Total-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table img{
|
||||
height: 100%;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
#location-table tbody.locations {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#location-table td.location-name {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
#weighted-settings{
|
||||
width: 60rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
}
|
||||
|
||||
#user-warning, #weighted-settings #user-message{
|
||||
display: none;
|
||||
width: calc(100% - 8px);
|
||||
background-color: #ffe86b;
|
||||
border-radius: 4px;
|
||||
color: #000000;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#weighted-settings #user-message.visible{
|
||||
display: block;
|
||||
}
|
||||
|
||||
#weighted-settings code{
|
||||
background-color: #d9cd8e;
|
||||
border-radius: 4px;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#weighted-settings h1{
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
#weighted-settings h2{
|
||||
font-size: 2rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffe993;
|
||||
text-transform: lowercase;
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
|
||||
#weighted-settings h3, #weighted-settings h4, #weighted-settings h5, #weighted-settings h6{
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#weighted-settings .instructions{
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#weighted-settings #settings-wrapper .setting-wrapper{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#weighted-settings #settings-wrapper .setting-wrapper .title-span{
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
#weighted-settings #settings-wrapper{
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
#weighted-settings #settings-wrapper #sprite-picker{
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
#weighted-settings #settings-wrapper #sprite-picker #sprite-picker-sprites{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
#weighted-settings #settings-wrapper #sprite-picker .sprite-img-wrapper{
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
/* Center tooltip text for sprite images */
|
||||
#weighted-settings #settings-wrapper #sprite-picker .sprite-img-wrapper::after{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#weighted-settings #settings-wrapper #sprite-picker .sprite-img-wrapper img{
|
||||
width: 32px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
#weighted-settings table.option-set{
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
#weighted-settings table.option-set td.option-name{
|
||||
width: 150px;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
#weighted-settings table.option-set td.option-name .delete-button{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#weighted-settings table.option-set td.option-value{
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
#weighted-settings table.option-set td.option-value input[type=range]{
|
||||
width: 90%;
|
||||
min-width: 300px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#weighted-settings #weighted-settings-button-row{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#weighted-settings a{
|
||||
color: #ffef00;
|
||||
}
|
||||
|
||||
#weighted-settings input:not([type]){
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#weighted-settings input:not([type]):focus{
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
#weighted-settings select{
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
html{
|
||||
background-image: url('../../static/backgrounds/grass/grass-0007-large.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 650px 650px;
|
||||
}
|
||||
|
||||
#player-settings{
|
||||
max-width: 1000px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
}
|
||||
|
||||
#player-settings #player-settings-button-row{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
#player-settings code{
|
||||
background-color: #d9cd8e;
|
||||
border-radius: 4px;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#player-settings #user-message{
|
||||
display: none;
|
||||
width: calc(100% - 8px);
|
||||
background-color: #ffe86b;
|
||||
border-radius: 4px;
|
||||
color: #000000;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#player-settings #user-message.visible{
|
||||
display: block;
|
||||
}
|
||||
|
||||
#player-settings h1{
|
||||
font-size: 2.5rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
#player-settings h2{
|
||||
font-size: 2rem;
|
||||
font-weight: normal;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffe993;
|
||||
text-transform: lowercase;
|
||||
text-shadow: 1px 1px 2px #000000;
|
||||
}
|
||||
|
||||
#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#player-settings a{
|
||||
color: #ffef00;
|
||||
}
|
||||
|
||||
#player-settings input:not([type]){
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
#player-settings input:not([type]):focus{
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
#player-settings select{
|
||||
border: 1px solid #000000;
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
min-width: 150px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
#player-settings #game-options, #player-settings #rom-options{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#player-settings .left, #player-settings .right{
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table select{
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
#player-settings table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
margin-right: 4px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px), all and (orientation: portrait){
|
||||
#player-settings #game-options, #player-settings #rom-options{
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#player-settings .left, #player-settings .right{
|
||||
flex-grow: unset;
|
||||
}
|
||||
|
||||
#game-options table label, #rom-options table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
17
WebHostLib/templates/faq.html
Normal file
17
WebHostLib/templates/faq.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<title>Frequently Asked Questions</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/faq.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="faq-wrapper" data-lang="{{ lang }}" class="markdown">
|
||||
<!-- Content generated by JavaScript -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
WebHostLib/templates/gameInfo.html
Normal file
17
WebHostLib/templates/gameInfo.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ game }} Info</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game }}">
|
||||
<!-- Populated my JS / MD -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>A Link to the Past</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/zelda3/zelda3.css") }}" />
|
||||
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="zelda3">
|
||||
Coming Soon™
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>Factorio</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/factorio/factorio.css") }}" />
|
||||
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="factorio">
|
||||
Coming Soon™
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>Minecraft</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/minecraft/minecraft.css") }}" />
|
||||
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<div id="minecraft">
|
||||
Coming Soon™
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -11,34 +11,111 @@
|
||||
{% include 'header/oceanHeader.html' %}
|
||||
<div id="generate-game-wrapper">
|
||||
<div id="generate-game" class="grass-island">
|
||||
<h1>Upload Config{% if race %} (Race Mode){% endif %}</h1>
|
||||
<h1>Generate Game{% if race %} (Race Mode){% endif %}</h1>
|
||||
<p>
|
||||
This page allows you to generate a game by uploading a yaml file or a zip file containing yaml files.
|
||||
If you do not have a config (yaml) file yet, you may create one on the
|
||||
<a href="/player-settings">Player Settings</a> page. If you would like more advanced options,
|
||||
the <a href="/weighted-settings">Weighted Settings</a> page might be what you're looking for.
|
||||
This page allows you to generate a game by uploading a config file or a zip file containing config
|
||||
files. If you do not have a config (.yaml) file yet, you may create one on the game's settings page,
|
||||
which you can find via the <a href="{{ url_for("games") }}">supported games list</a>.
|
||||
</p>
|
||||
<p>
|
||||
{% if race -%}
|
||||
This game will be generated in race mode, meaning the spoiler log will be unavailable,
|
||||
roms will be encrypted, and single-player games will have no multidata files.
|
||||
This game will be generated in race mode,
|
||||
meaning the spoiler log will be unavailable and game specific protections will be in place,
|
||||
like ROM encryption or cheat mode removal.
|
||||
{%- else -%}
|
||||
If you would like to generate a race game,
|
||||
<a href="{{ url_for("generate", race=True) }}">click here.</a> Race games are generated without
|
||||
a spoiler log, the ROMs are encrypted, and single-player games will not include a multidata file.
|
||||
<a href="{{ url_for("generate", race=True) }}">click here.</a><br />
|
||||
Race games are generated without a spoiler log and game specific protections will be in place,
|
||||
like ROM encryption or cheat mode removal.
|
||||
{%- endif -%}
|
||||
</p>
|
||||
<p>
|
||||
After generation is complete, you will have the option to download a patch file.
|
||||
This patch file can be opened with the
|
||||
<a href="https://github.com/ArchipelagoMW/Archipelago/releases">client</a>, which can be
|
||||
used to to create a rom file. In-browser patching is planned for the future.
|
||||
</p>
|
||||
<div id="generate-game-form-wrapper">
|
||||
<form id="generate-game-form" method="post" enctype="multipart/form-data">
|
||||
<input id="file-input" type="file" name="file">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="forfeit_mode">Forfeit Permission:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="A forfeit releases all remaining items from the locations
|
||||
in your world.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select name="forfeit_mode" id="forfeit_mode">
|
||||
<option value="auto">Automatic on goal completion</option>
|
||||
<option value="goal">Allow !forfeit after goal completion</option>
|
||||
<option value="auto-enabled">Automatic on goal completion and manual !forfeit</option>
|
||||
<option value="enabled">Manual !forfeit</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<label for="collect_mode">Collect Permission:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="A collect releases all of your remaining items to you
|
||||
from across the multiworld.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select name="collect_mode" id="collect_mode">
|
||||
<option value="goal">Allow !collect after goal completion</option>
|
||||
<option value="auto">Automatic on goal completion</option>
|
||||
<option value="auto-enabled">Automatic on goal completion and manual !collect</option>
|
||||
<option value="enabled">Manual !collect</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<label for="remaining_mode">Remaining Permission:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="Remaining lists all items still in your world by name only.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select name="remaining_mode" id="remaining_mode">
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="goal">Allow !remaining after goal completion</option>
|
||||
<option value="enabled">Manual !remaining</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<label for="hint_cost"> Hint Cost:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="After gathering this many checks, players can !hint <itemname>
|
||||
to get the location of that hint item.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select name="hint_cost" id="hint_cost">
|
||||
{% for n in range(0, 110, 5) %}
|
||||
<option {% if n == 10 %}selected="selected" {% endif %} value="{{ n }}">
|
||||
{% if n > 100 %}Off{% else %}{{ n }}%{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="generate-form-button-row">
|
||||
<input id="file-input" type="file" name="file">
|
||||
</div>
|
||||
</form>
|
||||
<button id="generate-game-button">Upload</button>
|
||||
<button id="generate-game-button">Upload File</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Amount</th>
|
||||
<th>Order Received</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -28,6 +29,7 @@
|
||||
<tr>
|
||||
<td>{{ name | item_name }}</td>
|
||||
<td>{{ count }}</td>
|
||||
<td>{{received_items[name]}}</td>
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user