Compare commits
341 Commits
0.3.0
...
show_all_h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87152f17f5 | ||
|
|
b9fb4de878 | ||
|
|
bcd7096e1d | ||
|
|
b206f2846a | ||
|
|
8a8bc6aa34 | ||
|
|
bce7c258c3 | ||
|
|
cea7278faf | ||
|
|
d7a9b98ce8 | ||
|
|
7dcde12e2e | ||
|
|
ba2a5c4744 | ||
|
|
39ac3c38bf | ||
|
|
61f751a1db | ||
|
|
5f2193f2e4 | ||
|
|
98b714f84a | ||
|
|
2a0198b618 | ||
|
|
cd9f8f3119 | ||
|
|
37b569eca6 | ||
|
|
c1b099d44e | ||
|
|
d317111d20 | ||
|
|
3f1d216d28 | ||
|
|
0ca3d73ae9 | ||
|
|
1972d531b9 | ||
|
|
5006c79a00 | ||
|
|
8788ee1aa7 | ||
|
|
17ba73b0b8 | ||
|
|
0407df83b7 | ||
|
|
f140aadafe | ||
|
|
b41c6185e4 | ||
|
|
aa3d7f5e21 | ||
|
|
efadf6fdf4 | ||
|
|
12863e9b04 | ||
|
|
1843618c99 | ||
|
|
d20ade7ff8 | ||
|
|
4e5071fd68 | ||
|
|
df90ff4ddb | ||
|
|
a798e8aea2 | ||
|
|
6e918edce1 | ||
|
|
80ff5a18b1 | ||
|
|
d112cc585f | ||
|
|
3fec33f56c | ||
|
|
68674deb00 | ||
|
|
a9e530721d | ||
|
|
03e9034a98 | ||
|
|
6970c5ce97 | ||
|
|
10b3803a7f | ||
|
|
a7e8c82633 | ||
|
|
6d4c4295b3 | ||
|
|
47edc356ad | ||
|
|
b551e3a2ad | ||
|
|
a9c32bc2e2 | ||
|
|
60c7be87f8 | ||
|
|
2bac78b4a4 | ||
|
|
c4769eeebb | ||
|
|
51341f6255 | ||
|
|
c7a32dc91b | ||
|
|
3623678c93 | ||
|
|
a5d516e179 | ||
|
|
2045905c9b | ||
|
|
26c027a075 | ||
|
|
b86ee20f3f | ||
|
|
50c75e9684 | ||
|
|
d87c3d5323 | ||
|
|
247f674749 | ||
|
|
74fe03414c | ||
|
|
65d213c494 | ||
|
|
05a51346f9 | ||
|
|
6c525e1fe6 | ||
|
|
5be00e28dd | ||
|
|
d81dbbd951 | ||
|
|
83dee9d667 | ||
|
|
7d79cff66f | ||
|
|
0a63bd0fc6 | ||
|
|
55d8c8c928 | ||
|
|
681f7041dc | ||
|
|
d5f15e6408 | ||
|
|
70d510dff8 | ||
|
|
2a5c128267 | ||
|
|
e5a1052089 | ||
|
|
8c64f6221e | ||
|
|
0869a2acc3 | ||
|
|
e7ea827f02 | ||
|
|
84b6ece31d | ||
|
|
1bcc5b6582 | ||
|
|
c8c025ac34 | ||
|
|
d82d70ac97 | ||
|
|
3e86fd4e57 | ||
|
|
964eda13cc | ||
|
|
c16815b16d | ||
|
|
74ee8ec459 | ||
|
|
22ea72c1b2 | ||
|
|
613dc4184a | ||
|
|
9a471aff1b | ||
|
|
e69e42cabc | ||
|
|
1281426075 | ||
|
|
8b1baafddf | ||
|
|
ee65d7e5fa | ||
|
|
df0ae205cd | ||
|
|
1cbd384569 | ||
|
|
e47527087e | ||
|
|
517a2db9d8 | ||
|
|
fbf993566d | ||
|
|
25bea47872 | ||
|
|
78f22e895e | ||
|
|
fa3925cd74 | ||
|
|
d9418d5ce1 | ||
|
|
103f9e0b85 | ||
|
|
a2fc3d5b71 | ||
|
|
c66d64b9d8 | ||
|
|
0dd67f40ba | ||
|
|
f5dc39ddf0 | ||
|
|
6b47776b11 | ||
|
|
2b73c7f9e4 | ||
|
|
4558ac66fa | ||
|
|
d0a98949f5 | ||
|
|
e13e7f286c | ||
|
|
0045e3f9f7 | ||
|
|
ff608b72a2 | ||
|
|
19c3c8056b | ||
|
|
d31c24bbf7 | ||
|
|
768f9497fd | ||
|
|
20be691f36 | ||
|
|
3dd3f045e6 | ||
|
|
6d3538a35b | ||
|
|
1a0bfecb5f | ||
|
|
5d3b4c8efd | ||
|
|
8adc0dd7eb | ||
|
|
2cb71c5352 | ||
|
|
b6068f4519 | ||
|
|
21a6b0143d | ||
|
|
28949853f7 | ||
|
|
65c83393bb | ||
|
|
960988ddcd | ||
|
|
fb99dca83e | ||
|
|
e786243738 | ||
|
|
cec0e2cbfb | ||
|
|
dadd7d4693 | ||
|
|
dc558f906c | ||
|
|
8184e99409 | ||
|
|
ac87629550 | ||
|
|
1c231b703a | ||
|
|
a66b11e6ec | ||
|
|
4f24c4ea78 | ||
|
|
a800b148a2 | ||
|
|
1710e15e49 | ||
|
|
a332d4935d | ||
|
|
9b855c7de0 | ||
|
|
e8be80ccd7 | ||
|
|
c661da57d8 | ||
|
|
4165f58414 | ||
|
|
7126b7bca0 | ||
|
|
a7f647e3ca | ||
|
|
e901a87afd | ||
|
|
9eb237b3af | ||
|
|
909ea9dc99 | ||
|
|
86013328d6 | ||
|
|
0c80cd017f | ||
|
|
2b8a0f8cd8 | ||
|
|
e1926c973e | ||
|
|
f515f680a4 | ||
|
|
effba9fdec | ||
|
|
388f064307 | ||
|
|
bb15485965 | ||
|
|
cb9db5dff1 | ||
|
|
3b644a0af1 | ||
|
|
8ce2ecfaac | ||
|
|
bdd9ca76ee | ||
|
|
44ae50083d | ||
|
|
e5d999c755 | ||
|
|
4e90ebc7d9 | ||
|
|
dbf0458575 | ||
|
|
e6e44b8747 | ||
|
|
2b702528fd | ||
|
|
23144ff204 | ||
|
|
764b6c78c5 | ||
|
|
051e19e9c1 | ||
|
|
ad99850192 | ||
|
|
c93eeb3607 | ||
|
|
551cf8442f | ||
|
|
90d506ee7c | ||
|
|
45bca78e75 | ||
|
|
11faca1940 | ||
|
|
47b179dec4 | ||
|
|
05efbe0af8 | ||
|
|
48a7587c5a | ||
|
|
ff82145633 | ||
|
|
dcc703f454 | ||
|
|
07f66fb15a | ||
|
|
c0fb7d9f9a | ||
|
|
2b6fc6dd3a | ||
|
|
e147495fb9 | ||
|
|
b2e65a19a2 | ||
|
|
44638ccc1a | ||
|
|
5f4b2cfa52 | ||
|
|
0bc2301530 | ||
|
|
d1eda38745 | ||
|
|
dc10421531 | ||
|
|
00f5975a3c | ||
|
|
b41f444013 | ||
|
|
89b4060a06 | ||
|
|
98ca001da6 | ||
|
|
b0b41711d4 | ||
|
|
3f691d6977 | ||
|
|
977159e572 | ||
|
|
9e15e754c2 | ||
|
|
c085ee47ed | ||
|
|
a5ca118bbf | ||
|
|
521122fd4f | ||
|
|
86933d8150 | ||
|
|
976f34c19f | ||
|
|
a56340663c | ||
|
|
e3900e9f99 | ||
|
|
e8b1362172 | ||
|
|
f6d857b5b5 | ||
|
|
aa9f43dea1 | ||
|
|
513ab62ce7 | ||
|
|
a020dea277 | ||
|
|
19dd447dcb | ||
|
|
eb1abd9222 | ||
|
|
9ab7c8d9e5 | ||
|
|
1e592b4681 | ||
|
|
40a08d0d84 | ||
|
|
517e72f442 | ||
|
|
ea51df432d | ||
|
|
c27bfc515e | ||
|
|
7fad0b0f51 | ||
|
|
76663f819b | ||
|
|
666760f0cf | ||
|
|
2d73f2f46e | ||
|
|
c8e54bbcd0 | ||
|
|
76a4dce66a | ||
|
|
c102d602b3 | ||
|
|
e711490f6c | ||
|
|
c801cdbb3b | ||
|
|
9d638671bb | ||
|
|
4a703481ba | ||
|
|
897cbb9826 | ||
|
|
bb710cc360 | ||
|
|
5eab07d8d6 | ||
|
|
894a30b9bd | ||
|
|
e8579771a5 | ||
|
|
09670a4475 | ||
|
|
ff783cf9a5 | ||
|
|
46d31c3ee3 | ||
|
|
3e8c821c02 | ||
|
|
50eaf712a9 | ||
|
|
f476747ade | ||
|
|
d8d881085f | ||
|
|
fd6e1b3046 | ||
|
|
d6697924cb | ||
|
|
3001926ae4 | ||
|
|
578451fcfa | ||
|
|
d57bdf6dc3 | ||
|
|
0309fac592 | ||
|
|
9ecd320c8c | ||
|
|
c326566bd2 | ||
|
|
4f10dbb896 | ||
|
|
cb6d377796 | ||
|
|
b5f58b0a03 | ||
|
|
9ee5fae476 | ||
|
|
81feb2fd5e | ||
|
|
75a76fb184 | ||
|
|
21f1ccbfb4 | ||
|
|
0f5a7cda6c | ||
|
|
acd7bce903 | ||
|
|
1afacd28a1 | ||
|
|
6e171d19f0 | ||
|
|
66921499ad | ||
|
|
249972c7fd | ||
|
|
dae0e233b8 | ||
|
|
8bb566a250 | ||
|
|
6a25bbeef0 | ||
|
|
6286ac4a3b | ||
|
|
447f99ea15 | ||
|
|
587d4dc8b6 | ||
|
|
b5613ffcf5 | ||
|
|
1fe82b1312 | ||
|
|
a4daa78c0b | ||
|
|
618bdfc917 | ||
|
|
8e68aa0ccd | ||
|
|
df3757657e | ||
|
|
0eea1a1d89 | ||
|
|
15dcdca6fc | ||
|
|
7a6aef03e7 | ||
|
|
c61f3b9110 | ||
|
|
42fecc7491 | ||
|
|
0acca6dd64 | ||
|
|
ec00d1b710 | ||
|
|
f093e90c23 | ||
|
|
3d1f6d9b82 | ||
|
|
9bdcbb9008 | ||
|
|
491e6c8730 | ||
|
|
d32d268d97 | ||
|
|
30c447b9f3 | ||
|
|
2def8f35ad | ||
|
|
f2055daf1a | ||
|
|
944571ea89 | ||
|
|
f7c601b863 | ||
|
|
7315da2ccb | ||
|
|
2f7f6a0b58 | ||
|
|
3f43051c35 | ||
|
|
535c35310d | ||
|
|
8fbe6a4511 | ||
|
|
07ff0f1026 | ||
|
|
a080288e3e | ||
|
|
71bd87f293 | ||
|
|
574e2abba8 | ||
|
|
cffa772801 | ||
|
|
66bd793306 | ||
|
|
0eb37883ca | ||
|
|
356384ab05 | ||
|
|
8c2c6877b6 | ||
|
|
d1d40d8a60 | ||
|
|
b026a0a372 | ||
|
|
73bcd0058a | ||
|
|
0cf396e5d6 | ||
|
|
1bc09d4292 | ||
|
|
97d0c51db1 | ||
|
|
ed1c11267c | ||
|
|
a3e1ac896f | ||
|
|
37d9eb2752 | ||
|
|
05e267a0bd | ||
|
|
d1f0a29a02 | ||
|
|
fb2e780c56 | ||
|
|
ba3257f850 | ||
|
|
215d5e9adf | ||
|
|
5392b32d5c | ||
|
|
4dd0a75914 | ||
|
|
a2212002ae | ||
|
|
91ccee3513 | ||
|
|
2a593d5d0a | ||
|
|
a93b3d79aa | ||
|
|
938ab32cda | ||
|
|
6f5ab05345 | ||
|
|
95f8647f09 | ||
|
|
06c8caa3cc | ||
|
|
d206a562df | ||
|
|
a0a290e481 | ||
|
|
266ff0c520 | ||
|
|
931bf7da16 | ||
|
|
fe4a26d034 | ||
|
|
dca70a99ad |
43
.github/workflows/build.yml
vendored
@@ -5,9 +5,41 @@ name: Build
|
||||
on: workflow_dispatch
|
||||
|
||||
jobs:
|
||||
# build-release-windows: # LF volunteer; RCs will still be built and signed by hand
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-win-py38: # RCs will still be built and signed by hand
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-windows-amd64.zip -OutFile sni.zip
|
||||
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
|
||||
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip
|
||||
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
|
||||
- name: Build
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools
|
||||
pip install -r requirements.txt
|
||||
python setup.py build --yes
|
||||
$NAME="$(ls build)".Split('.',2)[1]
|
||||
$ZIP_NAME="Archipelago_$NAME.7z"
|
||||
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
|
||||
New-Item -Path dist -ItemType Directory -Force
|
||||
cd build
|
||||
Rename-Item exe.$NAME Archipelago
|
||||
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
|
||||
- name: Store 7z
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.ZIP_NAME }}
|
||||
path: dist/${{ env.ZIP_NAME }}
|
||||
retention-days: 7 # keep for 7 days, should be enough
|
||||
|
||||
build-ubuntu1804:
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
@@ -31,15 +63,16 @@ jobs:
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/6.4/ubuntu.16.04-x64.7z
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
|
||||
# pygobject is an optional dependency for kivy that's not in requirements
|
||||
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
@@ -56,8 +89,10 @@ jobs:
|
||||
with:
|
||||
name: ${{ env.APPIMAGE_NAME }}
|
||||
path: dist/${{ env.APPIMAGE_NAME }}
|
||||
retention-days: 7
|
||||
- name: Store .tar.gz
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: ${{ env.TAR_NAME }}
|
||||
path: dist/${{ env.TAR_NAME }}
|
||||
retention-days: 7
|
||||
|
||||
6
.github/workflows/release.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
# build-release-macos: # LF volunteer
|
||||
|
||||
build-release-ubuntu1804:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-18.04
|
||||
steps:
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
@@ -51,11 +51,11 @@ jobs:
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
|
||||
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.81/sni-v0.0.81-manylinux2014-amd64.tar.xz
|
||||
tar xf sni-*.tar.xz
|
||||
rm sni-*.tar.xz
|
||||
mv sni-* SNI
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/6.4/ubuntu.16.04-x64.7z
|
||||
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z
|
||||
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
|
||||
- name: Build
|
||||
run: |
|
||||
|
||||
15
.github/workflows/unittests.yml
vendored
@@ -7,17 +7,22 @@ on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
name: Test Python ${{ matrix.python.version }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python:
|
||||
- {version: '3.8'}
|
||||
- {version: '3.9'}
|
||||
#- {version: '3.10'}
|
||||
- {version: '3.10'}
|
||||
include:
|
||||
- python: {version: '3.8'} # win7 compat
|
||||
os: windows-latest
|
||||
- python: {version: '3.10'} # current
|
||||
os: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -29,7 +34,7 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8 pytest
|
||||
python ModuleUpdate.py --yes --force
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
|
||||
2
.gitignore
vendored
@@ -77,6 +77,7 @@ MANIFEST
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
installer.log
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
@@ -154,6 +155,7 @@ cython_debug/
|
||||
#minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
|
||||
#pyenv
|
||||
.python-version
|
||||
|
||||
130
BaseClasses.py
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from enum import Enum, unique
|
||||
from enum import unique, IntEnum, IntFlag
|
||||
import logging
|
||||
import json
|
||||
import functools
|
||||
from collections import OrderedDict, Counter, deque
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
|
||||
import typing # this can go away when Python 3.8 support is dropped
|
||||
import secrets
|
||||
import random
|
||||
|
||||
@@ -22,6 +23,8 @@ class Group(TypedDict, total=False):
|
||||
players: Set[int]
|
||||
item_pool: Set[str]
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
@@ -35,7 +38,7 @@ class MultiWorld():
|
||||
plando_texts: List[Dict[str, str]]
|
||||
plando_items: List[List[Dict[str, Any]]]
|
||||
plando_connections: List
|
||||
worlds: Dict[int, Any]
|
||||
worlds: Dict[int, auto_world]
|
||||
groups: Dict[int, Group]
|
||||
itempool: List[Item]
|
||||
is_race: bool = False
|
||||
@@ -46,6 +49,7 @@ class MultiWorld():
|
||||
local_items: Dict[int, Options.LocalItems]
|
||||
non_local_items: Dict[int, Options.NonLocalItems]
|
||||
progression_balancing: Dict[int, Options.ProgressionBalancing]
|
||||
completion_condition: Dict[int, Callable[[CollectionState], bool]]
|
||||
|
||||
class AttributeProxy():
|
||||
def __init__(self, rule):
|
||||
@@ -212,29 +216,51 @@ class MultiWorld():
|
||||
for player in self.player_ids:
|
||||
for item_link in self.item_links[player].value:
|
||||
if item_link["name"] in item_links:
|
||||
if item_links[item_link["name"]]["game"] != self.game[player]:
|
||||
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
|
||||
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
|
||||
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
|
||||
item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", []))
|
||||
item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", []))
|
||||
item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", []))
|
||||
else:
|
||||
if item_link["name"] in self.player_name.values():
|
||||
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
|
||||
item_links[item_link["name"]] = {
|
||||
"players": {player: item_link["replacement_item"]},
|
||||
"item_pool": set(item_link["item_pool"]),
|
||||
"game": self.game[player]
|
||||
"exclude": set(item_link.get("exclude", [])),
|
||||
"game": self.game[player],
|
||||
"local_items": set(item_link.get("local_items", [])),
|
||||
"non_local_items": set(item_link.get("non_local_items", []))
|
||||
}
|
||||
|
||||
for name, item_link in item_links.items():
|
||||
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
|
||||
pool = set()
|
||||
local_items = set()
|
||||
non_local_items = set()
|
||||
for item in item_link["item_pool"]:
|
||||
pool |= current_item_name_groups.get(item, {item})
|
||||
for item in item_link["exclude"]:
|
||||
pool -= current_item_name_groups.get(item, {item})
|
||||
for item in item_link["local_items"]:
|
||||
local_items |= current_item_name_groups.get(item, {item})
|
||||
for item in item_link["non_local_items"]:
|
||||
non_local_items |= current_item_name_groups.get(item, {item})
|
||||
local_items &= pool
|
||||
non_local_items &= pool
|
||||
item_link["item_pool"] = pool
|
||||
item_link["local_items"] = local_items
|
||||
item_link["non_local_items"] = non_local_items
|
||||
|
||||
for group_name, item_link in item_links.items():
|
||||
game = item_link["game"]
|
||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||
group["item_pool"] = item_link["item_pool"]
|
||||
group["replacement_items"] = item_link["players"]
|
||||
group["local_items"] = item_link["local_items"]
|
||||
group["non_local_items"] = item_link["non_local_items"]
|
||||
|
||||
# intended for unittests
|
||||
def set_default_common_options(self):
|
||||
@@ -267,6 +293,9 @@ class MultiWorld():
|
||||
def get_player_name(self, player: int) -> str:
|
||||
return self.player_name[player]
|
||||
|
||||
def get_file_safe_player_name(self, player: int) -> str:
|
||||
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
|
||||
|
||||
def initialize_regions(self, regions=None):
|
||||
for region in regions if regions else self.regions:
|
||||
region.world = self
|
||||
@@ -557,9 +586,20 @@ class MultiWorld():
|
||||
return False
|
||||
|
||||
|
||||
PathValue = Tuple[str, Optional["PathValue"]]
|
||||
|
||||
|
||||
class CollectionState():
|
||||
additional_init_functions: List[Callable] = []
|
||||
additional_copy_functions: List[Callable] = []
|
||||
prog_items: typing.Counter[Tuple[str, int]]
|
||||
world: MultiWorld
|
||||
reachable_regions: Dict[int, Set[Region]]
|
||||
blocked_connections: Dict[int, Set[Entrance]]
|
||||
events: Set[Location]
|
||||
path: Dict[Union[Region, Entrance], PathValue]
|
||||
locations_checked: Set[Location]
|
||||
stale: Dict[int, bool]
|
||||
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
|
||||
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
|
||||
|
||||
def __init__(self, parent: MultiWorld):
|
||||
self.prog_items = Counter()
|
||||
@@ -597,6 +637,7 @@ class CollectionState():
|
||||
if new_region in rrp:
|
||||
bc.remove(connection)
|
||||
elif connection.can_reach(self):
|
||||
assert new_region, "tried to search through an Entrance with no Region"
|
||||
rrp.add(new_region)
|
||||
bc.remove(connection)
|
||||
bc.update(new_region.exits)
|
||||
@@ -627,7 +668,8 @@ class CollectionState():
|
||||
spot: Union[Location, Entrance, Region, str],
|
||||
resolution_hint: Optional[str] = None,
|
||||
player: Optional[int] = None) -> bool:
|
||||
if not hasattr(spot, "can_reach"):
|
||||
if isinstance(spot, str):
|
||||
assert isinstance(player, int), "can_reach: player is required if spot is str"
|
||||
# try to resolve a name
|
||||
if resolution_hint == 'Location':
|
||||
spot = self.world.get_location(spot, player)
|
||||
@@ -638,7 +680,7 @@ class CollectionState():
|
||||
spot = self.world.get_region(spot, player)
|
||||
return spot.can_reach(self)
|
||||
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Set[Location] = None):
|
||||
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
|
||||
if locations is None:
|
||||
locations = self.world.get_filled_locations()
|
||||
new_locations = True
|
||||
@@ -650,6 +692,7 @@ class CollectionState():
|
||||
new_locations = reachable_events - self.events
|
||||
for event in new_locations:
|
||||
self.events.add(event)
|
||||
assert isinstance(event.item, Item), "tried to collect Event with no Item"
|
||||
self.collect(event.item, True, event)
|
||||
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
@@ -664,7 +707,7 @@ class CollectionState():
|
||||
def count(self, item: str, player: int) -> int:
|
||||
return self.prog_items[item, player]
|
||||
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1):
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
found: int = 0
|
||||
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||
found += self.prog_items[item_name, player]
|
||||
@@ -672,7 +715,7 @@ class CollectionState():
|
||||
return True
|
||||
return False
|
||||
|
||||
def count_group(self, item_name_group: str, player: int):
|
||||
def count_group(self, item_name_group: str, player: int) -> int:
|
||||
found: int = 0
|
||||
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
|
||||
found += self.prog_items[item_name, player]
|
||||
@@ -747,7 +790,7 @@ class CollectionState():
|
||||
or (self.has('Bombs (10)', player) and enemies < 6))
|
||||
|
||||
def can_shoot_arrows(self, player: int) -> bool:
|
||||
if self.world.retro[player]:
|
||||
if self.world.retro_bow[player]:
|
||||
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
|
||||
return self.has('Bow', player) or self.has('Silver Bow', player)
|
||||
|
||||
@@ -868,7 +911,7 @@ class CollectionState():
|
||||
|
||||
|
||||
@unique
|
||||
class RegionType(int, Enum):
|
||||
class RegionType(IntEnum):
|
||||
Generic = 0
|
||||
LightWorld = 1
|
||||
DarkWorld = 2
|
||||
@@ -1023,7 +1066,7 @@ class Boss():
|
||||
return f"Boss({self.name})"
|
||||
|
||||
|
||||
class LocationProgressType(Enum):
|
||||
class LocationProgressType(IntEnum):
|
||||
DEFAULT = 1
|
||||
PRIORITY = 2
|
||||
EXCLUDED = 3
|
||||
@@ -1095,19 +1138,29 @@ class Location:
|
||||
return "at " + self.name.replace("_", " ").replace("-", " ")
|
||||
|
||||
|
||||
class Item():
|
||||
class ItemClassification(IntFlag):
|
||||
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
|
||||
progression = 0b0001 # Item that is logically relevant
|
||||
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
|
||||
trap = 0b0100 # detrimental or entirely useless (nothing) item
|
||||
skip_balancing = 0b1000 # should technically never occur on its own
|
||||
# Item that is logically relevant, but progression balancing should not touch.
|
||||
# Typically currency or other counted items.
|
||||
progression_skip_balancing = 0b1001 # only progression gets balanced
|
||||
|
||||
def as_flag(self) -> int:
|
||||
"""As Network API flag int."""
|
||||
return int(self & 0b0111)
|
||||
|
||||
|
||||
class Item:
|
||||
location: Optional[Location] = None
|
||||
world: Optional[MultiWorld] = None
|
||||
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
|
||||
name: str
|
||||
game: str = "Generic"
|
||||
type: str = None
|
||||
# indicates if this is a negative impact item. Causes these to be handled differently by various games.
|
||||
trap: bool = False
|
||||
# change manually to ensure that a specific non-progression item never goes on an excluded location
|
||||
never_exclude = False
|
||||
# item is not considered by progression balancing despite being progression
|
||||
skip_in_prog_balancing: bool = False
|
||||
classification: ItemClassification
|
||||
|
||||
# need to find a decent place for these to live and to allow other games to register texts if they want.
|
||||
pedestal_credit_text: str = "and the Unknown Item"
|
||||
@@ -1122,9 +1175,9 @@ class Item():
|
||||
map: bool = False
|
||||
compass: bool = False
|
||||
|
||||
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
|
||||
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
|
||||
self.name = name
|
||||
self.advancement = advancement
|
||||
self.classification = classification
|
||||
self.player = player
|
||||
self.code = code
|
||||
|
||||
@@ -1136,9 +1189,25 @@ class Item():
|
||||
def pedestal_hint_text(self):
|
||||
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
|
||||
|
||||
@property
|
||||
def advancement(self) -> bool:
|
||||
return ItemClassification.progression in self.classification
|
||||
|
||||
@property
|
||||
def skip_in_prog_balancing(self) -> bool:
|
||||
return ItemClassification.progression_skip_balancing in self.classification
|
||||
|
||||
@property
|
||||
def useful(self) -> bool:
|
||||
return ItemClassification.useful in self.classification
|
||||
|
||||
@property
|
||||
def trap(self) -> bool:
|
||||
return ItemClassification.trap in self.classification
|
||||
|
||||
@property
|
||||
def flags(self) -> int:
|
||||
return self.advancement + (self.never_exclude << 1) + (self.trap << 2)
|
||||
return self.classification.as_flag()
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.name == other.name and self.player == other.player
|
||||
@@ -1437,6 +1506,19 @@ class Spoiler():
|
||||
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
|
||||
|
||||
|
||||
class Tutorial(NamedTuple):
|
||||
"""Class to build website tutorial pages from a .md file in the world's /docs folder. Order is as follows.
|
||||
Name of the tutorial as it will appear on the site. Concise description covering what the guide will entail.
|
||||
Language the guide is written in. Name of the file ex 'setup_en.md'. Name of the link on the site; game name is
|
||||
filled automatically so 'setup/en' etc. Author or authors."""
|
||||
tutorial_name: str
|
||||
description: str
|
||||
language: str
|
||||
file_name: str
|
||||
link: str
|
||||
authors: List[str]
|
||||
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
|
||||
@@ -1449,4 +1531,4 @@ def get_seed(seed=None) -> int:
|
||||
|
||||
from worlds import AutoWorld
|
||||
|
||||
auto_world = AutoWorld.World
|
||||
auto_world = AutoWorld.World
|
||||
|
||||
@@ -1,229 +1,51 @@
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
|
||||
import websockets
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
from CommonClient import gui_enabled, console_loop, logger, server_autoreconnect, get_base_parser, \
|
||||
keep_alive
|
||||
from worlds.checksfinder import ChecksFinderWorld
|
||||
from NetUtils import NetworkItem, ClientStatus
|
||||
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
|
||||
CommonContext, server_loop
|
||||
|
||||
|
||||
class ClientCommandProcessor(CommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
self.ctx = ctx
|
||||
|
||||
def output(self, text: str):
|
||||
logger.info(text)
|
||||
|
||||
def _cmd_exit(self) -> bool:
|
||||
"""Close connections and client"""
|
||||
self.ctx.exit_event.set()
|
||||
return True
|
||||
|
||||
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), 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(), name="disconnecting")
|
||||
return True
|
||||
|
||||
def _cmd_received(self) -> bool:
|
||||
"""List all received items"""
|
||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
"""List all missing location checks, from your local game state"""
|
||||
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].location_name_to_id.items():
|
||||
if location_id < 0:
|
||||
continue
|
||||
if location_id not in self.ctx.locations_checked:
|
||||
if location_id in self.ctx.missing_locations:
|
||||
self.output('Missing: ' + location)
|
||||
count += 1
|
||||
elif location_id in self.ctx.checked_locations:
|
||||
self.output('Checked: ' + location)
|
||||
count += 1
|
||||
checked_count += 1
|
||||
|
||||
if count:
|
||||
self.output(
|
||||
f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}")
|
||||
else:
|
||||
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)
|
||||
|
||||
class ChecksFinderClientCommandProcessor(ClientCommandProcessor):
|
||||
def _cmd_resync(self):
|
||||
"""Manually trigger a resync."""
|
||||
self.output(f"Syncing items.")
|
||||
self.ctx.syncing = True
|
||||
|
||||
def _cmd_ready(self):
|
||||
"""Send ready status to server."""
|
||||
self.ctx.ready = not self.ctx.ready
|
||||
if self.ctx.ready:
|
||||
state = ClientStatus.CLIENT_READY
|
||||
self.output("Readied up.")
|
||||
else:
|
||||
state = ClientStatus.CLIENT_CONNECTED
|
||||
self.output("Unreadied.")
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def default(self, raw: str):
|
||||
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():
|
||||
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
|
||||
items_handling: typing.Optional[int] = None
|
||||
current_energy_link_value = 0 # to display in UI, gets set by server
|
||||
class ChecksFinderContext(CommonContext):
|
||||
command_processor: int = ChecksFinderClientCommandProcessor
|
||||
game = "ChecksFinder"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
super(ChecksFinderContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.server_address = server_address
|
||||
self.password = password
|
||||
self.syncing = False
|
||||
self.awaiting_bridge = False
|
||||
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
|
||||
self.ready = False
|
||||
self.team = None
|
||||
self.slot = None
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.items_received = []
|
||||
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()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.slow_mode = False
|
||||
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 server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(ChecksFinderContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
self.auth = None
|
||||
self.items_received = []
|
||||
self.locations_info = {}
|
||||
self.server_version = Version(0, 0, 0)
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
self.server = None
|
||||
self.server_task = None
|
||||
await super(ChecksFinderContext, self).connection_closed()
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
# 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
|
||||
|
||||
if data_package["version"] > network_data_package["version"]:
|
||||
Utils.persistent_store("datapackage", "latest", network_data_package)
|
||||
|
||||
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})')
|
||||
|
||||
self.item_name_getter = get_item_name_from_id
|
||||
|
||||
def get_location_name_from_address(address: int):
|
||||
return locations_lookup.get(address, f'Unknown location (ID:{address})')
|
||||
|
||||
self.location_name_getter = get_location_name_from_address
|
||||
os.remove(root + "/" + file)
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
@@ -232,344 +54,53 @@ class CommonContext():
|
||||
else:
|
||||
return []
|
||||
|
||||
async def disconnect(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task is not None:
|
||||
await self.server_task
|
||||
|
||||
async def send_msgs(self, msgs):
|
||||
if not self.server or not self.server.socket.open or self.server.socket.closed:
|
||||
return
|
||||
await self.server.socket.send(encode(msgs))
|
||||
|
||||
def consume_players_package(self, package: typing.List[tuple]):
|
||||
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
|
||||
self.player_names[0] = "Archipelago"
|
||||
|
||||
def event_invalid_slot(self):
|
||||
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
|
||||
|
||||
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, 'items_handling': self.items_handling,
|
||||
'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):
|
||||
await self.disconnect()
|
||||
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):
|
||||
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):
|
||||
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()
|
||||
await super(ChecksFinderContext, self).shutdown()
|
||||
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
|
||||
for root, dirs, files in os.walk(path):
|
||||
for file in files:
|
||||
if file.find("obtain") <= -1:
|
||||
os.remove(root+"/"+file)
|
||||
|
||||
# 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 = ""):
|
||||
if self.server and self.server.socket:
|
||||
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 server_loop(ctx: CommonContext, address=None):
|
||||
cached_address = None
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
|
||||
if address is None: # set through CLI or APBP
|
||||
address = ctx.server_address
|
||||
|
||||
# Wait for the user to provide a multiworld server address
|
||||
if not address:
|
||||
logger.info('Please connect to an Archipelago server.')
|
||||
return
|
||||
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
port = urllib.parse.urlparse(address).port or 38281
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
ctx.server = Endpoint(socket)
|
||||
logger.info('Connected')
|
||||
ctx.server_address = address
|
||||
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
|
||||
async for data in ctx.server.socket:
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||
except ConnectionRefusedError:
|
||||
if cached_address:
|
||||
logger.error('Unable to connect to multiworld server at cached address. '
|
||||
'Please use the connect button above.')
|
||||
else:
|
||||
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.exception('Failed to connect to the multiworld server')
|
||||
except Exception as 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), name="server auto reconnect")
|
||||
ctx.current_reconnect_delay *= 2
|
||||
|
||||
|
||||
async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
try:
|
||||
cmd = args["cmd"]
|
||||
except:
|
||||
logger.exception(f"Could not get command from {args}")
|
||||
raise
|
||||
if cmd == 'RoomInfo':
|
||||
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
|
||||
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
|
||||
else:
|
||||
logger.info('--------------------------------')
|
||||
logger.info('Room Information:')
|
||||
logger.info('--------------------------------')
|
||||
version = args["version"]
|
||||
ctx.server_version = tuple(version)
|
||||
version = ".".join(str(item) for item in version)
|
||||
|
||||
logger.info(f'Server protocol version: {version}')
|
||||
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
|
||||
if args['password']:
|
||||
logger.info('Password required')
|
||||
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'])
|
||||
|
||||
if len(args['players']) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
args['players'].sort()
|
||||
current_team = -1
|
||||
logger.info('Connected Players:')
|
||||
for network_player in args['players']:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
|
||||
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name Datapackage")
|
||||
ctx.set_getters(args['data'], network=True)
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
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:
|
||||
raise Exception('Server reported your client version as incompatible')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
raise Exception('The item handling flags requested by the client are not supported')
|
||||
# last to check, recoverable problem
|
||||
elif 'InvalidPassword' in errors:
|
||||
logger.error('Invalid password')
|
||||
ctx.password = None
|
||||
await ctx.server_auth(True)
|
||||
elif errors:
|
||||
raise Exception("Unknown connection errors: " + str(errors))
|
||||
else:
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
|
||||
elif cmd == 'Connected':
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
ctx.consume_players_package(args["players"])
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
msgs.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
if ctx.locations_scouted:
|
||||
msgs.append({"cmd": "LocationScouts",
|
||||
"locations": list(ctx.locations_scouted)})
|
||||
if msgs:
|
||||
await ctx.send_msgs(msgs)
|
||||
if ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
|
||||
# Get the server side view of missing as of time of connecting.
|
||||
# This list is used to only send to the server what is reported as ACTUALLY Missing.
|
||||
# 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 = set(args["missing_locations"])
|
||||
ctx.checked_locations = set(args["checked_locations"])
|
||||
for ss in ctx.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
elif cmd == 'ReceivedItems':
|
||||
start_index = args["index"]
|
||||
|
||||
if start_index == 0:
|
||||
ctx.items_received = []
|
||||
elif start_index != len(ctx.items_received):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
if ctx.locations_checked:
|
||||
sync_msg.append({"cmd": "LocationChecks",
|
||||
"locations": list(ctx.locations_checked)})
|
||||
await ctx.send_msgs(sync_msg)
|
||||
if start_index == len(ctx.items_received):
|
||||
for item in args['items']:
|
||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item))
|
||||
f.close()
|
||||
ctx.items_received.append(NetworkItem(*item))
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == 'LocationInfo':
|
||||
for item, location, player in args['locations']:
|
||||
if location not in ctx.locations_info:
|
||||
ctx.locations_info[location] = (item, player)
|
||||
ctx.watcher_event.set()
|
||||
|
||||
elif cmd == "RoomUpdate":
|
||||
if "players" in args:
|
||||
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
|
||||
for ss in ctx.checked_locations:
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
|
||||
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
|
||||
f.close()
|
||||
if "permissions" in args:
|
||||
ctx.update_permissions(args["permissions"])
|
||||
if cmd in {"ReceivedItems"}:
|
||||
start_index = args["index"]
|
||||
if start_index != len(self.items_received):
|
||||
for item in args['items']:
|
||||
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
|
||||
f.write(str(NetworkItem(*item).item))
|
||||
f.close()
|
||||
|
||||
elif cmd == 'Print':
|
||||
ctx.on_print(args)
|
||||
if cmd in {"RoomUpdate"}:
|
||||
if "checked_locations" in args:
|
||||
for ss in self.checked_locations:
|
||||
filename = f"send{ss}"
|
||||
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/" + filename), 'w') as f:
|
||||
f.close()
|
||||
|
||||
elif cmd == 'PrintJSON':
|
||||
ctx.on_print_json(args)
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
elif cmd == 'InvalidPacket':
|
||||
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||
class ChecksFinderManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago ChecksFinder Client"
|
||||
|
||||
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"])
|
||||
elif cmd == "SetReply":
|
||||
if args["key"] == "EnergyLink":
|
||||
ctx.current_energy_link_value = args["value"]
|
||||
if ctx.ui:
|
||||
ctx.ui.set_new_energy_link_value()
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
||||
ctx.on_package(cmd, args)
|
||||
self.ui = ChecksFinderManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: CommonContext):
|
||||
async def game_watcher(ctx: ChecksFinderContext):
|
||||
from worlds.checksfinder.Locations import lookup_id_to_name
|
||||
while not ctx.exit_event.is_set():
|
||||
if ctx.syncing == True:
|
||||
@@ -598,38 +129,12 @@ async def game_watcher(ctx: CommonContext):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
game = "ChecksFinder"
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
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 = ChecksFinderContext(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 ChecksFinderManager
|
||||
ctx.ui = ChecksFinderManager(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")
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
progression_watcher = asyncio.create_task(
|
||||
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
|
||||
|
||||
@@ -639,11 +144,6 @@ if __name__ == '__main__':
|
||||
await progression_watcher
|
||||
|
||||
await ctx.shutdown()
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
@@ -651,8 +151,5 @@ if __name__ == '__main__':
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
367
CommonClient.py
@@ -6,6 +6,9 @@ import sys
|
||||
import typing
|
||||
import time
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import websockets
|
||||
|
||||
import Utils
|
||||
@@ -14,13 +17,14 @@ if __name__ == "__main__":
|
||||
Utils.init_logging("TextClient", exception_logger="Client")
|
||||
|
||||
from MultiServer import CommandProcessor
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
|
||||
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
|
||||
from Utils import Version, stream_input
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
import os
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
# without terminal we have to use gui mode
|
||||
# without terminal, we have to use gui mode
|
||||
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
|
||||
|
||||
|
||||
@@ -39,12 +43,14 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
def _cmd_connect(self, address: str = "") -> bool:
|
||||
"""Connect to a MultiWorld Server"""
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = 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
|
||||
self.ctx.username = None
|
||||
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
|
||||
return True
|
||||
|
||||
@@ -52,7 +58,7 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
"""List all received items"""
|
||||
logger.info(f'{len(self.ctx.items_received)} received items:')
|
||||
for index, item in enumerate(self.ctx.items_received, 1):
|
||||
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
|
||||
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
|
||||
return True
|
||||
|
||||
def _cmd_missing(self) -> bool:
|
||||
@@ -104,32 +110,67 @@ class ClientCommandProcessor(CommandProcessor):
|
||||
self.output("Unreadied.")
|
||||
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
|
||||
|
||||
def _cmd_show_all_hints(self):
|
||||
"""Allows the player to see all hints, not just the ones that apply to them."""
|
||||
asyncio.create_task(self.ctx.update_show_all_hints("ShowAllHints" not in self.ctx.tags))
|
||||
|
||||
def default(self, raw: str):
|
||||
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():
|
||||
class CommonContext:
|
||||
# Should be adjusted as needed in subclasses
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
|
||||
# datapackage
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
|
||||
# defaults
|
||||
starting_reconnect_delay: int = 5
|
||||
current_reconnect_delay: int = starting_reconnect_delay
|
||||
command_processor: int = ClientCommandProcessor
|
||||
game = None
|
||||
command_processor: type(CommandProcessor) = ClientCommandProcessor
|
||||
ui = None
|
||||
keep_alive_task = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
current_energy_link_value = 0 # to display in UI, gets set by server
|
||||
ui_task: typing.Optional[asyncio.Task] = None
|
||||
input_task: typing.Optional[asyncio.Task] = None
|
||||
keep_alive_task: typing.Optional[asyncio.Task] = None
|
||||
server_task: typing.Optional[asyncio.Task] = None
|
||||
server: typing.Optional[Endpoint] = None
|
||||
server_version: Version = Version(0, 0, 0)
|
||||
current_energy_link_value: int = 0 # to display in UI, gets set by server
|
||||
|
||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# remaining type info
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
server_address: str
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
# locations
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
missing_locations: typing.Set[int]
|
||||
checked_locations: typing.Set[int] # server state
|
||||
locations_info: typing.Dict[int, NetworkItem]
|
||||
|
||||
# internals
|
||||
# current message box through kvui
|
||||
_messagebox = None
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
# server state
|
||||
self.server_address = server_address
|
||||
self.username = None
|
||||
self.password = password
|
||||
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.hint_cost = None
|
||||
self.slot_info = {}
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
@@ -144,26 +185,23 @@ class CommonContext():
|
||||
self.auth = None
|
||||
self.seed_name = None
|
||||
|
||||
self.locations_checked: typing.Set[int] = set() # local state
|
||||
self.locations_scouted: typing.Set[int] = set()
|
||||
self.locations_checked = set() # local state
|
||||
self.locations_scouted = set()
|
||||
self.items_received = []
|
||||
self.missing_locations: typing.Set[int] = set()
|
||||
self.checked_locations: typing.Set[int] = set() # server state
|
||||
self.locations_info: typing.Dict[int, NetworkItem] = {}
|
||||
self.missing_locations = set()
|
||||
self.checked_locations = 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.player_names = {0: "Archipelago"}
|
||||
self.exit_event = asyncio.Event()
|
||||
self.watcher_event = asyncio.Event()
|
||||
|
||||
self.slow_mode = False
|
||||
self.jsontotextparser = JSONtoTextParser(self)
|
||||
self.set_getters(network_data_package)
|
||||
self.update_datapackage(network_data_package)
|
||||
|
||||
# execution
|
||||
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
|
||||
@@ -175,50 +213,25 @@ class CommonContext():
|
||||
return len(self.checked_locations | self.missing_locations)
|
||||
|
||||
async def connection_closed(self):
|
||||
self.reset_server_state()
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
|
||||
def reset_server_state(self):
|
||||
self.auth = None
|
||||
self.slot = None
|
||||
self.team = None
|
||||
self.items_received = []
|
||||
self.locations_info = {}
|
||||
self.server_version = Version(0, 0, 0)
|
||||
if self.server and self.server.socket is not None:
|
||||
await self.server.socket.close()
|
||||
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
|
||||
|
||||
if data_package["version"] > network_data_package["version"]:
|
||||
Utils.persistent_store("datapackage", "latest", network_data_package)
|
||||
|
||||
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})')
|
||||
|
||||
self.item_name_getter = get_item_name_from_id
|
||||
|
||||
def get_location_name_from_address(address: int):
|
||||
return locations_lookup.get(address, f'Unknown location (ID:{address})')
|
||||
|
||||
self.location_name_getter = get_location_name_from_address
|
||||
|
||||
@property
|
||||
def endpoints(self):
|
||||
if self.server:
|
||||
return [self.server]
|
||||
else:
|
||||
return []
|
||||
self.hint_cost = None
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
|
||||
async def disconnect(self):
|
||||
if self.server and not self.server.socket.closed:
|
||||
@@ -247,6 +260,13 @@ class CommonContext():
|
||||
self.password = await self.console_input()
|
||||
return self.password
|
||||
|
||||
async def get_username(self):
|
||||
if not self.auth:
|
||||
self.auth = self.username
|
||||
if not self.auth:
|
||||
logger.info('Enter slot name:')
|
||||
self.auth = await self.console_input()
|
||||
|
||||
async def send_connect(self, **kwargs):
|
||||
payload = {
|
||||
'cmd': 'Connect',
|
||||
@@ -266,6 +286,13 @@ class CommonContext():
|
||||
await self.disconnect()
|
||||
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
|
||||
|
||||
def slot_concerns_self(self, slot) -> bool:
|
||||
if slot == self.slot:
|
||||
return True
|
||||
if slot in self.slot_info:
|
||||
return self.slot in self.slot_info[slot].group_members
|
||||
return False
|
||||
|
||||
def on_print(self, args: dict):
|
||||
logger.info(args["text"])
|
||||
|
||||
@@ -295,7 +322,8 @@ class CommonContext():
|
||||
logger.exception(e)
|
||||
|
||||
async def shutdown(self):
|
||||
self.server_address = None
|
||||
self.server_address = ""
|
||||
self.username = None
|
||||
if self.server and not self.server.socket.closed:
|
||||
await self.server.socket.close()
|
||||
if self.server_task:
|
||||
@@ -305,6 +333,54 @@ class CommonContext():
|
||||
self.input_queue.put_nowait(None)
|
||||
self.input_requests -= 1
|
||||
self.keep_alive_task.cancel()
|
||||
if self.ui_task:
|
||||
await self.ui_task
|
||||
if self.input_task:
|
||||
self.input_task.cancel()
|
||||
|
||||
# DataPackage
|
||||
async def prepare_datapackage(self, relevant_games: typing.Set[str],
|
||||
remote_datepackage_versions: typing.Dict[str, int]):
|
||||
"""Validate that all data is present for the current multiworld.
|
||||
Download, assimilate and cache missing data from the server."""
|
||||
# by documentation any game can use Archipelago locations/items -> always relevant
|
||||
relevant_games.add("Archipelago")
|
||||
|
||||
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
needed_updates: typing.Set[str] = set()
|
||||
for game in relevant_games:
|
||||
remote_version: int = remote_datepackage_versions[game]
|
||||
|
||||
if remote_version == 0: # custom datapackage for this game
|
||||
needed_updates.add(game)
|
||||
continue
|
||||
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
|
||||
# no action required if local version is new enough
|
||||
if remote_version > local_version:
|
||||
cache_version: int = cache_package.get(game, {}).get("version", 0)
|
||||
# download remote version if cache is not new enough
|
||||
if remote_version > cache_version:
|
||||
needed_updates.add(game)
|
||||
else:
|
||||
self.update_game(cache_package[game])
|
||||
if needed_updates:
|
||||
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
|
||||
|
||||
def update_game(self, game_package: dict):
|
||||
for item_name, item_id in game_package["item_name_to_id"].items():
|
||||
self.item_names[item_id] = item_name
|
||||
for location_name, location_id in game_package["location_name_to_id"].items():
|
||||
self.location_names[location_id] = location_name
|
||||
|
||||
def update_datapackage(self, data_package: dict):
|
||||
for game, gamedata in data_package["games"].items():
|
||||
self.update_game(gamedata)
|
||||
|
||||
def consume_network_datapackage(self, data_package: dict):
|
||||
self.update_datapackage(data_package)
|
||||
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
|
||||
current_cache.update(data_package["games"])
|
||||
Utils.persistent_store("datapackage", "games", current_cache)
|
||||
|
||||
# DeathLink hooks
|
||||
|
||||
@@ -330,7 +406,16 @@ class CommonContext():
|
||||
}
|
||||
}])
|
||||
|
||||
async def update_death_link(self, death_link):
|
||||
async def update_show_all_hints(self, show_all_hints: bool):
|
||||
old_tags = self.tags.copy()
|
||||
if show_all_hints:
|
||||
self.tags.add("ShowAllHints")
|
||||
else:
|
||||
self.tags -= {"ShowAllHints"}
|
||||
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 update_death_link(self, death_link: bool):
|
||||
old_tags = self.tags.copy()
|
||||
if death_link:
|
||||
self.tags.add("DeathLink")
|
||||
@@ -339,6 +424,48 @@ class CommonContext():
|
||||
if old_tags != self.tags and self.server and not self.server.socket.closed:
|
||||
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
|
||||
|
||||
def gui_error(self, title: str, text: typing.Union[Exception, str]):
|
||||
"""Displays an error messagebox"""
|
||||
if not self.ui:
|
||||
return
|
||||
title = title or "Error"
|
||||
from kvui import MessageBox
|
||||
if self._messagebox:
|
||||
self._messagebox.dismiss()
|
||||
# make "Multiple exceptions" look nice
|
||||
text = str(text).replace('[Errno', '\n[Errno').strip()
|
||||
# split long messages into title and text
|
||||
parts = title.split('. ', 1)
|
||||
if len(parts) == 1:
|
||||
parts = title.split(', ', 1)
|
||||
if len(parts) > 1:
|
||||
text = parts[1] + '\n\n' + text
|
||||
title = parts[0]
|
||||
# display error
|
||||
self._messagebox = MessageBox(title, text, error=True)
|
||||
self._messagebox.open()
|
||||
|
||||
def run_gui(self):
|
||||
"""Import kivy UI system and start running it as self.ui_task."""
|
||||
from kvui import GameManager
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
self.ui = TextManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def run_cli(self):
|
||||
if sys.stdin:
|
||||
# steam overlay breaks when starting console_loop
|
||||
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
|
||||
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")
|
||||
else:
|
||||
self.input_task = asyncio.create_task(console_loop(self), name="Input")
|
||||
|
||||
|
||||
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)
|
||||
@@ -354,7 +481,6 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
|
||||
|
||||
|
||||
async def server_loop(ctx: CommonContext, address=None):
|
||||
cached_address = None
|
||||
if ctx.server and ctx.server.socket:
|
||||
logger.error('Already connected')
|
||||
return
|
||||
@@ -367,12 +493,20 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
logger.info('Please connect to an Archipelago server.')
|
||||
return
|
||||
|
||||
address = f"ws://{address}" if "://" not in address else address
|
||||
port = urllib.parse.urlparse(address).port or 38281
|
||||
address = f"ws://{address}" if "://" not in address \
|
||||
else address.replace("archipelago://", "ws://")
|
||||
|
||||
server_url = urllib.parse.urlparse(address)
|
||||
if server_url.username:
|
||||
ctx.username = server_url.username
|
||||
if server_url.password:
|
||||
ctx.password = server_url.password
|
||||
port = server_url.port or 38281
|
||||
|
||||
logger.info(f'Connecting to Archipelago server at {address}')
|
||||
try:
|
||||
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
|
||||
ctx.ui.update_address_bar(server_url.netloc)
|
||||
ctx.server = Endpoint(socket)
|
||||
logger.info('Connected')
|
||||
ctx.server_address = address
|
||||
@@ -381,18 +515,22 @@ async def server_loop(ctx: CommonContext, address=None):
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
|
||||
except ConnectionRefusedError:
|
||||
if cached_address:
|
||||
logger.error('Unable to connect to multiworld server at cached address. '
|
||||
'Please use the connect button above.')
|
||||
else:
|
||||
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.exception('Failed to connect to the multiworld server')
|
||||
except ConnectionRefusedError as e:
|
||||
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except websockets.InvalidURI as e:
|
||||
msg = 'Failed to connect to the multiworld server (invalid URI)'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except OSError as e:
|
||||
msg = 'Failed to connect to the multiworld server'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
except Exception as e:
|
||||
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
|
||||
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
|
||||
logger.exception(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error(msg, e)
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address:
|
||||
@@ -415,7 +553,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
raise
|
||||
if cmd == 'RoomInfo':
|
||||
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
|
||||
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
|
||||
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
|
||||
logger.info(msg, extra={'compact_gui': True})
|
||||
ctx.gui_error('Error', msg)
|
||||
else:
|
||||
logger.info('--------------------------------')
|
||||
logger.info('Room Information:')
|
||||
@@ -429,33 +569,32 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
if args['password']:
|
||||
logger.info('Password required')
|
||||
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'])
|
||||
|
||||
if len(args['players']) < 1:
|
||||
players = args.get("players", [])
|
||||
if len(players) < 1:
|
||||
logger.info('No player connected')
|
||||
else:
|
||||
args['players'].sort()
|
||||
players.sort()
|
||||
current_team = -1
|
||||
logger.info('Connected Players:')
|
||||
for network_player in args['players']:
|
||||
for network_player in players:
|
||||
if network_player.team != current_team:
|
||||
logger.info(f' Team #{network_player.team + 1}')
|
||||
current_team = network_player.team
|
||||
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
|
||||
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
|
||||
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
|
||||
# update datapackage
|
||||
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
|
||||
|
||||
await ctx.server_auth(args['password'])
|
||||
|
||||
elif cmd == 'DataPackage':
|
||||
logger.info("Got new ID/Name Datapackage")
|
||||
ctx.set_getters(args['data'], network=True)
|
||||
logger.info("Got new ID/Name DataPackage")
|
||||
ctx.consume_network_datapackage(args['data'])
|
||||
|
||||
elif cmd == 'ConnectionRefused':
|
||||
errors = args["errors"]
|
||||
@@ -463,8 +602,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
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:
|
||||
raise Exception('Server reported your client version as incompatible')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
@@ -480,8 +617,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
raise Exception('Connection refused by the multiworld host, no reason provided')
|
||||
|
||||
elif cmd == 'Connected':
|
||||
ctx.username = ctx.auth
|
||||
ctx.team = args["team"]
|
||||
ctx.slot = args["slot"]
|
||||
# int keys get lost in JSON transfer
|
||||
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
|
||||
ctx.consume_players_package(args["players"])
|
||||
msgs = []
|
||||
if ctx.locations_checked:
|
||||
@@ -561,7 +701,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
|
||||
|
||||
async def console_loop(ctx: CommonContext):
|
||||
import sys
|
||||
commandprocessor = ctx.command_processor(ctx)
|
||||
queue = asyncio.Queue()
|
||||
stream_input(sys.stdin, queue)
|
||||
@@ -596,52 +735,50 @@ if __name__ == '__main__':
|
||||
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||
game = "Archipelago"
|
||||
game = "" # empty matches any game since 0.3.2
|
||||
items_handling = 0 # don't receive any NetworkItems
|
||||
|
||||
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.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.games.get(self.slot, None)
|
||||
self.game = self.slot_info[self.slot].game
|
||||
|
||||
|
||||
async def main(args):
|
||||
ctx = TextContext(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
ctx.server_address = args.connect
|
||||
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")
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
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.")
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.url:
|
||||
url = urllib.parse.urlparse(args.url)
|
||||
args.connect = url.netloc
|
||||
if url.username:
|
||||
args.name = urllib.parse.unquote(url.username)
|
||||
if url.password:
|
||||
args.password = urllib.parse.unquote(url.password)
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
58
FF1Client.py
@@ -39,6 +39,7 @@ class FF1CommandProcessor(ClientCommandProcessor):
|
||||
|
||||
class FF1Context(CommonContext):
|
||||
command_processor = FF1CommandProcessor
|
||||
game = 'Final Fantasy'
|
||||
items_handling = 0b111 # full remote
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
@@ -48,7 +49,6 @@ class FF1Context(CommonContext):
|
||||
self.messages = {}
|
||||
self.locations_array = None
|
||||
self.nes_status = CONNECTION_INITIAL_STATUS
|
||||
self.game = 'Final Fantasy'
|
||||
self.awaiting_rom = False
|
||||
self.display_msgs = True
|
||||
|
||||
@@ -68,14 +68,13 @@ class FF1Context(CommonContext):
|
||||
|
||||
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']])}"
|
||||
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
|
||||
self._set_message(msg, SYSTEM_MESSAGE_ID)
|
||||
elif cmd == 'PrintJSON':
|
||||
print_type = args['type']
|
||||
@@ -85,23 +84,35 @@ class FF1Context(CommonContext):
|
||||
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)}"
|
||||
msg = f"Hint: Your {self.item_names[item.item]} is at" \
|
||||
f" {self.player_names[item.player]}'s {self.location_names[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)}"
|
||||
msg = f"You found your own {self.item_names[item.item]}"
|
||||
else:
|
||||
msg = f"You sent {self.item_name_getter(item.item)} to {receiving_player_name}"
|
||||
msg = f"You sent {self.item_names[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)}"
|
||||
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
|
||||
else:
|
||||
msg = f"{sending_player_name} sent {self.item_name_getter(item.item)} to " \
|
||||
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
|
||||
f"{receiving_player_name}"
|
||||
self._set_message(msg, item.item)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FF1Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Final Fantasy 1 Client"
|
||||
|
||||
self.ui = FF1Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: FF1Context):
|
||||
current_time = time.time()
|
||||
@@ -139,13 +150,13 @@ async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bo
|
||||
index -= 0x200
|
||||
flag = 0x02
|
||||
|
||||
# print(f"Location: {ctx.location_name_getter(location)}")
|
||||
# print(f"Location: {ctx.location_names[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])
|
||||
# print([ctx.location_names[location] for location in locations_checked])
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "LocationChecks",
|
||||
"locations": locations_checked}
|
||||
@@ -175,6 +186,9 @@ async def nes_sync_task(ctx: FF1Context):
|
||||
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.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
|
||||
"the ROM using the same link but adding your slot name")
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -232,14 +246,8 @@ if __name__ == '__main__':
|
||||
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.run_gui()
|
||||
ctx.run_cli()
|
||||
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
@@ -250,20 +258,12 @@ if __name__ == '__main__':
|
||||
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()
|
||||
args = parser.parse_args()
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
@@ -5,10 +5,12 @@ import json
|
||||
import string
|
||||
import copy
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import random
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import factorio_rcon
|
||||
import colorama
|
||||
import asyncio
|
||||
@@ -118,10 +120,24 @@ class FactorioContext(CommonContext):
|
||||
gained = int(args["original_value"] - args["value"])
|
||||
gained_text = Utils.format_SI_prefix(gained) + "J"
|
||||
if gained:
|
||||
logger.info(f"EnergyLink: Received {gained_text}. "
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
logger.debug(f"EnergyLink: Received {gained_text}. "
|
||||
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
|
||||
self.rcon_client.send_command(f"/ap-energylink {gained}")
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class FactorioManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("FactorioServer", "Factorio Server Log"),
|
||||
("FactorioWatcher", "Bridge Data Log"),
|
||||
]
|
||||
base_title = "Archipelago Factorio Client"
|
||||
|
||||
self.ui = FactorioManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
@@ -134,7 +150,9 @@ async def game_watcher(ctx: FactorioContext):
|
||||
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:
|
||||
if not ctx.auth:
|
||||
pass # auth failed, wait for new attempt
|
||||
elif data["slot_name"] != ctx.auth:
|
||||
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
|
||||
elif data["seed_name"] != ctx.seed_name:
|
||||
bridge_logger.warning(
|
||||
@@ -184,7 +202,7 @@ async def game_watcher(ctx: FactorioContext):
|
||||
}]))
|
||||
ctx.rcon_client.send_command(
|
||||
f"/ap-energylink -{value}")
|
||||
logger.info(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||
logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
@@ -326,8 +344,10 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
logger.error("Aborted Factorio Server Bridge")
|
||||
logger.exception(e, extra={"compact_gui": True})
|
||||
msg = "Aborted Factorio Server Bridge"
|
||||
logger.error(msg)
|
||||
ctx.gui_error(msg, e)
|
||||
ctx.exit_event.set()
|
||||
|
||||
else:
|
||||
@@ -343,15 +363,11 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
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:
|
||||
from kvui import FactorioManager
|
||||
ctx.ui = FactorioManager(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")
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
|
||||
successful_launch = await factorio_server_task
|
||||
if successful_launch:
|
||||
@@ -367,12 +383,6 @@ async def main(args):
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
@@ -413,7 +423,5 @@ if __name__ == '__main__':
|
||||
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main(args))
|
||||
loop.close()
|
||||
asyncio.run(main(args))
|
||||
colorama.deinit()
|
||||
|
||||
76
Fill.py
@@ -144,7 +144,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
for item in itempool:
|
||||
if item.advancement:
|
||||
progitempool.append(item)
|
||||
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
|
||||
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations
|
||||
nonexcludeditempool.append(item)
|
||||
elif item.name in world.local_items[item.player].value:
|
||||
localrestitempool[item.player].append(item)
|
||||
@@ -166,7 +166,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
defaultlocations = locations[LocationProgressType.DEFAULT]
|
||||
excludedlocations = locations[LocationProgressType.EXCLUDED]
|
||||
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool)
|
||||
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True)
|
||||
if prioritylocations:
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
@@ -220,15 +220,15 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
|
||||
restitempool, defaultlocations = fast_fill(
|
||||
world, restitempool, defaultlocations)
|
||||
unplaced = progitempool + restitempool
|
||||
unfilled = [location.name for location in defaultlocations]
|
||||
unfilled = defaultlocations
|
||||
|
||||
if unplaced or unfilled:
|
||||
logging.warning(
|
||||
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
|
||||
items_counter = Counter([location.item.player for location in world.get_locations() if location.item])
|
||||
locations_counter = Counter([location.player for location in world.get_locations()])
|
||||
items_counter.update([item.player for item in unplaced])
|
||||
locations_counter.update([location.player for location in unfilled])
|
||||
items_counter = Counter(location.item.player for location in world.get_locations() if location.item)
|
||||
locations_counter = Counter(location.player for location in world.get_locations())
|
||||
items_counter.update(item.player for item in unplaced)
|
||||
locations_counter.update(location.player for location in unfilled)
|
||||
print_data = {"items": items_counter, "locations": locations_counter}
|
||||
logging.info(f'Per-Player counts: {print_data})')
|
||||
|
||||
@@ -305,29 +305,42 @@ def flood_items(world: MultiWorld) -> None:
|
||||
|
||||
def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
|
||||
# Overall progression balancing algorithm:
|
||||
# Overall progression balancing algorithm:
|
||||
# Gather up all locations in a sphere.
|
||||
# Define a threshold value based on the player with the most available locations.
|
||||
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
|
||||
# which gives more locations available by this sphere.
|
||||
balanceable_players = {player for player in world.player_ids if world.progression_balancing[player]}
|
||||
balanceable_players: typing.Dict[int, float] = {
|
||||
player: world.progression_balancing[player] / 100
|
||||
for player in world.player_ids
|
||||
if world.progression_balancing[player] > 0
|
||||
}
|
||||
if not balanceable_players:
|
||||
logging.info('Skipping multiworld progression balancing.')
|
||||
else:
|
||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||
state = CollectionState(world)
|
||||
logging.debug(balanceable_players)
|
||||
state: CollectionState = CollectionState(world)
|
||||
checked_locations: typing.Set[Location] = set()
|
||||
unchecked_locations = set(world.get_locations())
|
||||
unchecked_locations: typing.Set[Location] = set(world.get_locations())
|
||||
|
||||
reachable_locations_count = {
|
||||
reachable_locations_count: typing.Dict[int, int] = {
|
||||
player: 0
|
||||
for player in world.player_ids
|
||||
if len(world.get_filled_locations(player)) != 0
|
||||
}
|
||||
total_locations_count = Counter(location.player for location in world.get_locations() if not location.locked)
|
||||
balanceable_players = {player for player in balanceable_players if total_locations_count[player]}
|
||||
sphere_num = 1
|
||||
moved_item_count = 0
|
||||
total_locations_count: typing.Counter[int] = Counter(
|
||||
location.player
|
||||
for location in world.get_locations()
|
||||
if not location.locked
|
||||
)
|
||||
balanceable_players = {
|
||||
player: balanceable_players[player]
|
||||
for player in balanceable_players
|
||||
if total_locations_count[player]
|
||||
}
|
||||
sphere_num: int = 1
|
||||
moved_item_count: int = 0
|
||||
|
||||
def get_sphere_locations(sphere_state: CollectionState,
|
||||
locations: typing.Set[Location]) -> typing.Set[Location]:
|
||||
@@ -357,13 +370,19 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
sphere_num += 1
|
||||
|
||||
if checked_locations:
|
||||
# The 10% threshold can be modified for "progression balancing strength"
|
||||
# right now it approximates the old 20/216 bound.
|
||||
threshold_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
|
||||
reachable_locations_count)) - 0.10
|
||||
logging.debug(f"Threshold: {threshold_percentage}")
|
||||
balancing_players = {player for player, reachables in reachable_locations_count.items() if
|
||||
item_percentage(player, reachables) < threshold_percentage and player in balanceable_players}
|
||||
max_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
|
||||
reachable_locations_count))
|
||||
threshold_percentages = {
|
||||
player: max_percentage * balanceable_players[player]
|
||||
for player in balanceable_players
|
||||
}
|
||||
logging.debug(f"Thresholds: {threshold_percentages}")
|
||||
balancing_players = {
|
||||
player
|
||||
for player, reachables in reachable_locations_count.items()
|
||||
if (player in threshold_percentages
|
||||
and item_percentage(player, reachables) < threshold_percentages[player])
|
||||
}
|
||||
if balancing_players:
|
||||
balancing_state = state.copy()
|
||||
balancing_unchecked_locations = unchecked_locations.copy()
|
||||
@@ -389,8 +408,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
if not location.locked:
|
||||
balancing_reachables[location.player] += 1
|
||||
if world.has_beaten_game(balancing_state) or all(
|
||||
item_percentage(player, reachables) >= threshold_percentage
|
||||
for player, reachables in balancing_reachables.items()):
|
||||
item_percentage(player, reachables) >= threshold_percentages[player]
|
||||
for player, reachables in balancing_reachables.items()
|
||||
if player in threshold_percentages):
|
||||
break
|
||||
elif not balancing_sphere:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
@@ -402,7 +422,9 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
items_to_replace: typing.List[Location] = []
|
||||
for player in balancing_players:
|
||||
locations_to_test = unlocked_locations[player]
|
||||
items_to_test = candidate_items[player]
|
||||
items_to_test = list(candidate_items[player])
|
||||
items_to_test.sort()
|
||||
world.random.shuffle(items_to_test)
|
||||
while items_to_test:
|
||||
testing = items_to_test.pop()
|
||||
reducing_state = state.copy()
|
||||
@@ -420,7 +442,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
|
||||
else:
|
||||
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
|
||||
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
|
||||
if p < threshold_percentage:
|
||||
if p < threshold_percentages[player]:
|
||||
items_to_replace.append(testing)
|
||||
|
||||
replaced_items = False
|
||||
|
||||
117
Generate.py
@@ -3,7 +3,7 @@ import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import typing
|
||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||
import os
|
||||
from collections import Counter
|
||||
import string
|
||||
@@ -15,7 +15,7 @@ ModuleUpdate.update()
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
@@ -32,7 +32,7 @@ def mystery_argparse():
|
||||
options = get_options()
|
||||
defaults = options["generator"]
|
||||
|
||||
def resolve_path(path: str, resolver: typing.Callable[[str], str]) -> str:
|
||||
def resolve_path(path: str, resolver: Callable[[str], str]) -> str:
|
||||
return path if os.path.isabs(path) else resolver(path)
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
@@ -64,7 +64,7 @@ def mystery_argparse():
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
args.plando: Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
|
||||
return args, options
|
||||
|
||||
|
||||
@@ -83,21 +83,21 @@ def main(args=None, callback=ERmain):
|
||||
if args.race:
|
||||
random.seed() # reset to time-based random source
|
||||
|
||||
weights_cache = {}
|
||||
weights_cache: Dict[str, Tuple[Any, ...]] = {}
|
||||
if args.weights_file_path and os.path.exists(args.weights_file_path):
|
||||
try:
|
||||
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
|
||||
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
|
||||
print(f"Weights: {args.weights_file_path} >> "
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
|
||||
f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}")
|
||||
|
||||
if args.meta_file_path and os.path.exists(args.meta_file_path):
|
||||
try:
|
||||
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
|
||||
weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
|
||||
meta_weights = weights_cache[args.meta_file_path]
|
||||
meta_weights = weights_cache[args.meta_file_path][-1]
|
||||
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
|
||||
del(meta_weights["meta_description"])
|
||||
if args.samesettings:
|
||||
@@ -108,17 +108,22 @@ def main(args=None, callback=ERmain):
|
||||
player_files = {}
|
||||
for file in os.scandir(args.player_files_path):
|
||||
fname = file.name
|
||||
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
if file.is_file() and not file.name.startswith(".") and \
|
||||
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
|
||||
path = os.path.join(args.player_files_path, fname)
|
||||
try:
|
||||
weights_cache[fname] = read_weights_yaml(path)
|
||||
weights_cache[fname] = read_weights_yamls(path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
print(f"P{player_id} Weights: {fname} >> "
|
||||
f"{get_choice('description', weights_cache[fname], 'No description specified')}")
|
||||
player_files[player_id] = fname
|
||||
player_id += 1
|
||||
|
||||
# sort dict for consistent results across platforms:
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
|
||||
for filename, yaml_data in weights_cache.items():
|
||||
for yaml in yaml_data:
|
||||
print(f"P{player_id} Weights: {filename} >> "
|
||||
f"{get_choice('description', yaml, 'No description specified')}")
|
||||
player_files[player_id] = filename
|
||||
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} with plando: "
|
||||
@@ -141,8 +146,9 @@ def main(args=None, callback=ERmain):
|
||||
erargs.sm_rom = args.sm_rom
|
||||
erargs.enemizercli = args.enemizercli
|
||||
|
||||
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
|
||||
for k, v in weights_cache.items()}
|
||||
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
|
||||
{fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
|
||||
for fname, yamls in weights_cache.items()}
|
||||
player_path_cache = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
player_path_cache[player] = player_files.get(player, args.weights_file_path)
|
||||
@@ -153,38 +159,45 @@ def main(args=None, callback=ERmain):
|
||||
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]:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
weights_cache[path][category_name][key] = option
|
||||
for yaml in weights_cache[path]:
|
||||
if category_name is None:
|
||||
yaml[key] = option
|
||||
elif category_name not in yaml:
|
||||
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
|
||||
else:
|
||||
yaml[category_name][key] = option
|
||||
|
||||
name_counter = Counter()
|
||||
erargs.player_settings = {}
|
||||
for player in range(1, args.multi + 1):
|
||||
|
||||
player = 1
|
||||
while player <= args.multi:
|
||||
path = player_path_cache[player]
|
||||
if path:
|
||||
try:
|
||||
settings = settings_cache[path] if settings_cache[path] else \
|
||||
roll_settings(weights_cache[path], args.plando)
|
||||
for k, v in vars(settings).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
|
||||
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
|
||||
for settingsObject in settings:
|
||||
for k, v in vars(settingsObject).items():
|
||||
if v is not None:
|
||||
try:
|
||||
getattr(erargs, k)[player] = v
|
||||
except AttributeError:
|
||||
setattr(erargs, k, {player: v})
|
||||
except Exception as e:
|
||||
raise Exception(f"Error setting {k} to {v} for player {player}") from e
|
||||
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
else:
|
||||
raise RuntimeError(f'No weights specified for player {player}')
|
||||
if path == args.weights_file_path: # if name came from the weights file, just use base player name
|
||||
erargs.name[player] = f"Player{player}"
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
if len(set(erargs.name.values())) != len(erargs.name):
|
||||
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
|
||||
@@ -214,17 +227,17 @@ def main(args=None, callback=ERmain):
|
||||
callback(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yaml(path):
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
try:
|
||||
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
|
||||
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
|
||||
else:
|
||||
with open(path, 'rb') as f:
|
||||
yaml = str(f.read(), "utf-8")
|
||||
yaml = str(f.read(), "utf-8-sig")
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to read weights ({path})") from e
|
||||
|
||||
return parse_yaml(yaml)
|
||||
return tuple(parse_yamls(yaml))
|
||||
|
||||
|
||||
def interpret_on_off(value) -> bool:
|
||||
@@ -235,7 +248,7 @@ def convert_to_on_off(value) -> str:
|
||||
return {True: "on", False: "off"}.get(value, value)
|
||||
|
||||
|
||||
def get_choice_legacy(option, root, value=None) -> typing.Any:
|
||||
def get_choice_legacy(option, root, value=None) -> Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
@@ -250,7 +263,7 @@ def get_choice_legacy(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:
|
||||
def get_choice(option, root, value=None) -> Any:
|
||||
if option not in root:
|
||||
return value
|
||||
if type(root[option]) is list:
|
||||
@@ -283,16 +296,16 @@ def handle_name(name: str, player: int, name_counter: Counter):
|
||||
return new_name
|
||||
|
||||
|
||||
def prefer_int(input_data: str) -> typing.Union[str, int]:
|
||||
def prefer_int(input_data: str) -> Union[str, int]:
|
||||
try:
|
||||
return int(input_data)
|
||||
except:
|
||||
return input_data
|
||||
|
||||
|
||||
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
|
||||
{'Agahnim', 'Agahnim2', 'Ganon'}}
|
||||
available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
|
||||
Bosses.boss_location_table}
|
||||
|
||||
boss_shuffle_options = {None: 'none',
|
||||
@@ -317,7 +330,7 @@ goals = {
|
||||
}
|
||||
|
||||
|
||||
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
|
||||
def roll_percentage(percentage: Union[int, float]) -> bool:
|
||||
"""Roll a percentage chance.
|
||||
percentage is expected to be in range [0, 100]"""
|
||||
return random.random() < (float(percentage) / 100)
|
||||
@@ -387,7 +400,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
|
||||
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str:
|
||||
if boss_shuffle in boss_shuffle_options:
|
||||
return boss_shuffle_options[boss_shuffle]
|
||||
elif "bosses" in plando_options:
|
||||
@@ -439,7 +452,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
setattr(ret, option_key, option(option.default))
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
|
||||
def roll_settings(weights: dict, plando_options: Set[str] = frozenset(("bosses",))):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
|
||||
54
Launcher.py
@@ -15,16 +15,11 @@ import sys
|
||||
from typing import Iterable, Sequence, Callable, Union, Optional
|
||||
import subprocess
|
||||
import itertools
|
||||
from Utils import is_frozen, user_path, local_path, init_logging
|
||||
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
|
||||
is_windows, is_macos, is_linux
|
||||
from shutil import which
|
||||
import shlex
|
||||
from enum import Enum, auto
|
||||
import logging
|
||||
|
||||
|
||||
is_linux = sys.platform.startswith('linux')
|
||||
is_macos = sys.platform == 'darwin'
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
|
||||
def open_host_yaml():
|
||||
@@ -42,25 +37,19 @@ def open_host_yaml():
|
||||
|
||||
|
||||
def open_patch():
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if isfile(get_exe(c)[-1]):
|
||||
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
filename = open_filename('Select patch', (('Patches', suffixes),))
|
||||
except Exception as e:
|
||||
logging.error("Could not load tkinter, which is likely not installed. "
|
||||
"This attempt was made because Launcher.open_patch was used.")
|
||||
raise e
|
||||
messagebox('Error', str(e), error=True)
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
suffixes = []
|
||||
for c in components:
|
||||
if isfile(get_exe(c)[-1]):
|
||||
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
|
||||
isinstance(c.file_identifier, SuffixIdentifier) else []
|
||||
filename = tkinter.filedialog.askopenfilename(filetypes=(('Patches', ' '.join(suffixes)),))
|
||||
file, component = identify(filename)
|
||||
file, _, component = identify(filename)
|
||||
if file and component:
|
||||
subprocess.Popen([*get_exe(component), file])
|
||||
launch([*get_exe(component), file], component.cli)
|
||||
|
||||
|
||||
def browse_files():
|
||||
@@ -142,7 +131,7 @@ components: Iterable[Component] = (
|
||||
# Factorio
|
||||
Component('Factorio Client', 'FactorioClient'),
|
||||
# Minecraft
|
||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon',
|
||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
||||
file_identifier=SuffixIdentifier('.apmc')),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
@@ -152,6 +141,8 @@ components: Iterable[Component] = (
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# ChecksFinder
|
||||
Component('ChecksFinder Client', 'ChecksFinderClient'),
|
||||
# Starcraft 2
|
||||
Component('Starcraft 2 Client', 'Starcraft2Client'),
|
||||
# Functions
|
||||
Component('Open host.yaml', func=open_host_yaml),
|
||||
Component('Open Patch', func=open_patch),
|
||||
@@ -165,11 +156,11 @@ icon_paths = {
|
||||
|
||||
def identify(path: Union[None, str]):
|
||||
if path is None:
|
||||
return None, None
|
||||
return None, None, None
|
||||
for component in components:
|
||||
if component.handles_file(path):
|
||||
return path, component.script_name
|
||||
return (None, None) if '/' in path or '\\' in path else (None, path)
|
||||
return path, component.script_name, component
|
||||
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
|
||||
|
||||
|
||||
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
|
||||
@@ -215,14 +206,7 @@ def launch(exe, in_terminal=False):
|
||||
|
||||
|
||||
def run_gui():
|
||||
if not sys.stdout:
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label # this kills stdout
|
||||
else:
|
||||
from kivy.app import App
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.floatlayout import FloatLayout as ContainerLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.label import Label
|
||||
from kvui import App, ContainerLayout, GridLayout, Button, Label
|
||||
|
||||
class Launcher(App):
|
||||
base_title: str = "Archipelago Launcher"
|
||||
@@ -284,7 +268,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
args = {}
|
||||
|
||||
if "Patch|Game|Component" in args:
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
file, component, _ = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
if component:
|
||||
|
||||
@@ -20,10 +20,15 @@ from tkinter.constants import DISABLED, NORMAL
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
|
||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
|
||||
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
|
||||
get_adjuster_settings, tkinter_center_window, init_logging
|
||||
from Patch import GAME_ALTTP
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
@@ -40,9 +45,9 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
|
||||
|
||||
parser.add_argument('--rom', default='ER_base.sfc', help='Path to an ALttP rom to adjust.')
|
||||
parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.')
|
||||
parser.add_argument('--baserom', default='Zelda no Densetsu - Kamigami no Triforce (Japan).sfc',
|
||||
help='Path to an ALttP JAP(1.0) rom to use as a base.')
|
||||
help='Path to an ALttP Japan(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('--menuspeed', default='normal', const='normal', nargs='?',
|
||||
@@ -53,6 +58,7 @@ def main():
|
||||
''')
|
||||
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('--allowcollect', help='Allow collection of other player items', 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'],
|
||||
@@ -127,10 +133,13 @@ def main():
|
||||
|
||||
def adjust(args):
|
||||
start = time.perf_counter()
|
||||
init_logging("LttP Adjuster")
|
||||
logger = logging.getLogger('Adjuster')
|
||||
logger.info('Patching ROM.')
|
||||
vanillaRom = args.baserom
|
||||
if os.path.splitext(args.rom)[-1].lower() == '.apbp':
|
||||
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
|
||||
vanillaRom = local_path(vanillaRom)
|
||||
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
|
||||
import Patch
|
||||
meta, args.rom = Patch.create_rom_file(args.rom)
|
||||
|
||||
@@ -155,7 +164,7 @@ def adjust(args):
|
||||
|
||||
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)
|
||||
deathlink=args.deathlink, allowcollect=args.allowcollect)
|
||||
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
|
||||
rom.write_to_file(path)
|
||||
|
||||
@@ -210,6 +219,7 @@ def adjustGUI():
|
||||
guiargs.music = bool(rom_vars.MusicVar.get())
|
||||
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
|
||||
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
|
||||
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
|
||||
guiargs.rom = romVar2.get()
|
||||
guiargs.baserom = romVar.get()
|
||||
guiargs.sprite = rom_vars.sprite
|
||||
@@ -246,6 +256,7 @@ def adjustGUI():
|
||||
guiargs.music = bool(rom_vars.MusicVar.get())
|
||||
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
|
||||
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
|
||||
guiargs.allowcollect = bool(rom_vars.AllowCollectVar.get())
|
||||
guiargs.baserom = romVar.get()
|
||||
if isinstance(rom_vars.sprite, Sprite):
|
||||
guiargs.sprite = rom_vars.sprite.name
|
||||
@@ -502,24 +513,29 @@ def get_rom_frame(parent=None):
|
||||
|
||||
def get_rom_options_frame(parent=None):
|
||||
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
|
||||
defaults = {
|
||||
"auto_apply": 'ask',
|
||||
"music": True,
|
||||
"reduceflashing": True,
|
||||
"deathlink": False,
|
||||
"sprite": None,
|
||||
"quickswap": True,
|
||||
"menuspeed": 'normal',
|
||||
"heartcolor": 'red',
|
||||
"heartbeep": 'normal',
|
||||
"ow_palettes": 'default',
|
||||
"uw_palettes": 'default',
|
||||
"hud_palettes": 'default',
|
||||
"sword_palettes": 'default',
|
||||
"shield_palettes": 'default',
|
||||
"sprite_pool": [],
|
||||
"allowcollect": False,
|
||||
}
|
||||
if not adjuster_settings:
|
||||
adjuster_settings = Namespace()
|
||||
adjuster_settings.auto_apply = 'ask'
|
||||
adjuster_settings.music = True
|
||||
adjuster_settings.reduceflashing = True
|
||||
adjuster_settings.deathlink = False
|
||||
adjuster_settings.sprite = None
|
||||
adjuster_settings.quickswap = True
|
||||
adjuster_settings.menuspeed = 'normal'
|
||||
adjuster_settings.heartcolor = 'red'
|
||||
adjuster_settings.heartbeep = 'normal'
|
||||
adjuster_settings.ow_palettes = 'default'
|
||||
adjuster_settings.uw_palettes = 'default'
|
||||
adjuster_settings.hud_palettes = 'default'
|
||||
adjuster_settings.sword_palettes = 'default'
|
||||
adjuster_settings.shield_palettes = 'default'
|
||||
if not hasattr(adjuster_settings, 'sprite_pool'):
|
||||
adjuster_settings.sprite_pool = []
|
||||
for key, defaultvalue in defaults.items():
|
||||
if not hasattr(adjuster_settings, key):
|
||||
setattr(adjuster_settings, key, defaultvalue)
|
||||
|
||||
romOptionsFrame = LabelFrame(parent, text="Rom options")
|
||||
romOptionsFrame.columnconfigure(0, weight=1)
|
||||
@@ -542,6 +558,10 @@ def get_rom_options_frame(parent=None):
|
||||
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
|
||||
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
|
||||
|
||||
vars.AllowCollectVar = IntVar(value=adjuster_settings.allowcollect)
|
||||
AllowCollectCheckbutton = Checkbutton(romOptionsFrame, text="Allow Collect", variable=vars.AllowCollectVar)
|
||||
AllowCollectCheckbutton.grid(row=8, column=0, sticky=W)
|
||||
|
||||
spriteDialogFrame = Frame(romOptionsFrame)
|
||||
spriteDialogFrame.grid(row=0, column=1)
|
||||
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
|
||||
@@ -703,7 +723,7 @@ def get_rom_options_frame(parent=None):
|
||||
|
||||
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
|
||||
autoApplyFrame = Frame(romOptionsFrame)
|
||||
autoApplyFrame.grid(row=8, column=0, columnspan=2, sticky=W)
|
||||
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
|
||||
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files")
|
||||
filler.pack(side=TOP, expand=True, fill=X)
|
||||
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
|
||||
@@ -1243,4 +1263,4 @@ class ToolTips(object):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
main()
|
||||
|
||||
41
Main.py
@@ -1,4 +1,3 @@
|
||||
import copy
|
||||
import collections
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
@@ -17,7 +16,7 @@ from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import 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.generic.Rules import locality_rules, exclusion_rules, group_locality_rules
|
||||
from worlds import AutoWorld
|
||||
|
||||
ordered_areas = (
|
||||
@@ -86,12 +85,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
numlength = 8
|
||||
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
|
||||
if not cls.hidden:
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
|
||||
f"{len(cls.location_names):3} Locations")
|
||||
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}} | "
|
||||
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}}")
|
||||
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} "
|
||||
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - "
|
||||
f"{max(cls.item_id_to_name):{numlength}}) | "
|
||||
f"{len(cls.location_names):3} "
|
||||
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - "
|
||||
f"{max(cls.location_id_to_name):{numlength}})")
|
||||
|
||||
AutoWorld.call_stage(world, "assert_generate")
|
||||
|
||||
AutoWorld.call_all(world, "generate_early")
|
||||
|
||||
@@ -125,6 +126,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
if world.players > 1:
|
||||
for player in world.player_ids:
|
||||
locality_rules(world, player)
|
||||
group_locality_rules(world)
|
||||
else:
|
||||
world.non_local_items[1].value = set()
|
||||
world.local_items[1].value = set()
|
||||
@@ -142,13 +144,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
# temporary home for item links, should be moved out of Main
|
||||
for group_id, group in world.groups.items():
|
||||
def find_common_pool(players: Set[int], shared_pool: Set[str]):
|
||||
advancement = set()
|
||||
classifications = collections.defaultdict(int)
|
||||
counters = {player: {name: 0 for name in shared_pool} for player in players}
|
||||
for item in world.itempool:
|
||||
if item.player in counters and item.name in shared_pool:
|
||||
counters[item.player][item.name] += 1
|
||||
if item.advancement:
|
||||
advancement.add(item.name)
|
||||
classifications[item.name] |= item.classification
|
||||
|
||||
for player in players.copy():
|
||||
if all([counters[player][item] == 0 for item in shared_pool]):
|
||||
@@ -166,18 +167,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
else:
|
||||
for player in players:
|
||||
del(counters[player][item])
|
||||
return counters, advancement
|
||||
return counters, classifications
|
||||
|
||||
common_item_count, common_advancement_items = find_common_pool(group["players"], group["item_pool"])
|
||||
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
|
||||
if not common_item_count:
|
||||
continue
|
||||
|
||||
new_itempool = []
|
||||
for item_name, item_count in next(iter(common_item_count.values())).items():
|
||||
advancement = item_name in common_advancement_items
|
||||
for _ in range(item_count):
|
||||
new_item = group["world"].create_item(item_name)
|
||||
new_item.advancement = advancement
|
||||
# mangle together all original classification bits
|
||||
new_item.classification |= classifications[item_name]
|
||||
new_itempool.append(new_item)
|
||||
|
||||
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
|
||||
@@ -262,7 +263,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
# collect ER hint info
|
||||
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
|
||||
world.shuffle[player] != "vanilla" or world.retro[player]}
|
||||
world.shuffle[player] != "vanilla" or world.retro_caves[player]}
|
||||
|
||||
for region in world.regions:
|
||||
if region.player in er_hint_data and region.locations:
|
||||
@@ -302,7 +303,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
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]]:
|
||||
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
|
||||
item = world.create_item(
|
||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
@@ -326,11 +327,13 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
slot_data = {}
|
||||
client_versions = {}
|
||||
games = {}
|
||||
minimum_versions = {"server": (0, 2, 4), "clients": client_versions}
|
||||
minimum_versions = {"server": AutoWorld.World.required_server_version, "clients": client_versions}
|
||||
slot_info = {}
|
||||
names = [[name for player, name in sorted(world.player_name.items())]]
|
||||
for slot in world.player_ids:
|
||||
client_versions[slot] = world.worlds[slot].get_required_client_version()
|
||||
player_world: AutoWorld.World = world.worlds[slot]
|
||||
minimum_versions["server"] = max(minimum_versions["server"], player_world.required_server_version)
|
||||
client_versions[slot] = player_world.required_client_version
|
||||
games[slot] = world.game[slot]
|
||||
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
|
||||
world.player_types[slot])
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import argparse
|
||||
import os, sys
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import atexit
|
||||
import shutil
|
||||
from subprocess import Popen
|
||||
from shutil import copyfile
|
||||
from time import strftime
|
||||
@@ -10,12 +13,12 @@ import logging
|
||||
import requests
|
||||
|
||||
import Utils
|
||||
from Utils import is_windows
|
||||
|
||||
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):
|
||||
@@ -31,8 +34,8 @@ def prompt_yes_no(prompt):
|
||||
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):
|
||||
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
|
||||
mods_dir = os.path.join(forge_dir, 'mods')
|
||||
if os.path.isdir(mods_dir):
|
||||
for entry in os.scandir(mods_dir):
|
||||
@@ -46,8 +49,8 @@ def find_ap_randomizer_jar(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):
|
||||
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
|
||||
if apmc_file is None:
|
||||
return
|
||||
apdata_dir = os.path.join(forge_dir, 'APData')
|
||||
@@ -69,27 +72,21 @@ def replace_apmc_files(forge_dir, apmc_file):
|
||||
|
||||
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
|
||||
return json.loads(b64decode(f.read()))
|
||||
|
||||
|
||||
# Check mod version, download new mod from GitHub releases page if needed.
|
||||
def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
|
||||
"""Check mod version, download new mod from GitHub releases page if needed. """
|
||||
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']),
|
||||
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
|
||||
(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: "
|
||||
@@ -125,8 +122,8 @@ def update_mod(forge_dir, apmc_file, get_prereleases=False):
|
||||
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):
|
||||
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
|
||||
eula_path = os.path.join(forge_dir, "eula.txt")
|
||||
if not os.path.isfile(eula_path):
|
||||
# Create eula.txt
|
||||
@@ -149,31 +146,39 @@ def check_eula(forge_dir):
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# get the current JDK16
|
||||
def find_jdk_dir() -> str:
|
||||
def find_jdk_dir(version: str) -> str:
|
||||
"""get the specified versions jdk directory"""
|
||||
for entry in os.listdir():
|
||||
if os.path.isdir(entry) and entry.startswith("jdk16"):
|
||||
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
|
||||
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):
|
||||
def find_jdk(version: str) -> str:
|
||||
"""get the java exe location"""
|
||||
|
||||
if is_windows:
|
||||
jdk = find_jdk_dir(version)
|
||||
jdk_exe = os.path.join(jdk, "bin", "java.exe")
|
||||
if os.path.isfile(jdk_exe):
|
||||
return jdk_exe
|
||||
else:
|
||||
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
|
||||
if not jdk_exe:
|
||||
raise Exception("Could not find Java. Is Java installed on the system?")
|
||||
return jdk_exe
|
||||
|
||||
|
||||
# Download Corretto 16 (Amazon JDK)
|
||||
def download_java():
|
||||
jdk = find_jdk_dir()
|
||||
def download_java(java: str):
|
||||
"""Download Corretto (Amazon JDK)"""
|
||||
|
||||
jdk = find_jdk_dir(java)
|
||||
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"
|
||||
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
|
||||
resp = requests.get(jdk_url)
|
||||
if resp.status_code == 200: # OK
|
||||
print(f"Extracting...")
|
||||
@@ -188,10 +193,11 @@ def download_java():
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# download and install forge
|
||||
def install_forge(directory: str):
|
||||
jdk = find_jdk()
|
||||
if jdk is not None:
|
||||
def install_forge(directory: str, forge_version: str, java_version: str):
|
||||
"""download and install forge"""
|
||||
|
||||
java_exe = find_jdk(java_version)
|
||||
if java_exe 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)
|
||||
@@ -202,70 +208,144 @@ def install_forge(directory: str):
|
||||
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 = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
|
||||
install_process.wait()
|
||||
os.remove(forge_install_jar)
|
||||
|
||||
|
||||
# Run the Forge server. Return process object
|
||||
def run_forge_server(forge_dir: str, heap_arg):
|
||||
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
|
||||
"""Run the Forge server."""
|
||||
|
||||
java_exe = find_jdk()
|
||||
java_exe = find_jdk(java_version)
|
||||
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()
|
||||
heap_arg = max_heap_re.match(heap_arg).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 = []
|
||||
os_args = "win_args.txt" if is_windows else "unix_args.txt"
|
||||
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
|
||||
forge_args = []
|
||||
with open(args_file) as argfile:
|
||||
for line in argfile:
|
||||
win_args.append(line.strip())
|
||||
forge_args.extend(line.strip().split(" "))
|
||||
|
||||
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
|
||||
logging.info(f"Running Forge server: {argstring}")
|
||||
args = [java_exe, heap_arg, *forge_args, "-nogui"]
|
||||
logging.info(f"Running Forge server: {args}")
|
||||
os.chdir(forge_dir)
|
||||
return Popen(argstring)
|
||||
return Popen(args)
|
||||
|
||||
|
||||
def get_minecraft_versions(version, release_channel="release"):
|
||||
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
|
||||
resp = requests.get(version_file_endpoint)
|
||||
local = False
|
||||
if resp.status_code == 200: # OK
|
||||
try:
|
||||
data = resp.json()
|
||||
except requests.exceptions.JSONDecodeError:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
else:
|
||||
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
|
||||
local = True
|
||||
|
||||
if local:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
try:
|
||||
if version:
|
||||
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
|
||||
else:
|
||||
return resp.json()[release_channel][0]
|
||||
except StopIteration:
|
||||
logging.error(f"No compatible mod version found for client version {version}.")
|
||||
|
||||
|
||||
def is_correct_forge(forge_dir) -> bool:
|
||||
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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.")
|
||||
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('--release_channel', '-r', dest="channel", type=str, action='store',
|
||||
help="Specify release channel to use.")
|
||||
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
|
||||
help="specify java version.")
|
||||
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
|
||||
help="specify forge version. (Minecraft Version-Forge Version)")
|
||||
|
||||
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"]
|
||||
channel = args.channel or options["minecraft_options"]["release_channel"]
|
||||
apmc_data = None
|
||||
data_version = None
|
||||
|
||||
if apmc_file is None and not args.install:
|
||||
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
|
||||
|
||||
if apmc_file is not None:
|
||||
apmc_data = read_apmc_file(apmc_file)
|
||||
data_version = apmc_data.get('client_version', '')
|
||||
|
||||
versions = get_minecraft_versions(data_version, channel)
|
||||
|
||||
forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"])
|
||||
max_heap = options["minecraft_options"]["max_heap_size"]
|
||||
forge_version = args.forge or versions["forge"]
|
||||
java_version = args.java or versions["java"]
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
|
||||
if args.install:
|
||||
print("Installing Java and Minecraft Forge")
|
||||
download_java()
|
||||
install_forge(forge_dir)
|
||||
if is_windows:
|
||||
print("Installing Java")
|
||||
download_java(java_version)
|
||||
if not is_correct_forge(forge_dir):
|
||||
print("Installing Minecraft Forge")
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
else:
|
||||
print("Correct Forge version already found, skipping install.")
|
||||
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 apmc_data is None:
|
||||
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
|
||||
|
||||
if is_windows:
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
if prompt_yes_no("Did not find java directory. Download and install java now?"):
|
||||
download_java(java_version)
|
||||
java_dir = find_jdk_dir(java_version)
|
||||
if java_dir is None or not os.path.isdir(java_dir):
|
||||
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
|
||||
|
||||
if not is_correct_forge(forge_dir):
|
||||
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
|
||||
install_forge(forge_dir, forge_version, java_version)
|
||||
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)
|
||||
update_mod(forge_dir, f"MC{forge_version.split('-')[0]}", channel != "release")
|
||||
replace_apmc_files(forge_dir, apmc_file)
|
||||
check_eula(forge_dir)
|
||||
server_process = run_forge_server(forge_dir, max_heap)
|
||||
server_process = run_forge_server(forge_dir, java_version, max_heap)
|
||||
server_process.wait()
|
||||
|
||||
@@ -3,7 +3,8 @@ import sys
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
|
||||
requirements_files = {'requirements.txt'}
|
||||
local_dir = os.path.dirname(__file__)
|
||||
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
|
||||
|
||||
if sys.version_info < (3, 8, 6):
|
||||
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
|
||||
@@ -11,7 +12,7 @@ if sys.version_info < (3, 8, 6):
|
||||
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
|
||||
|
||||
if not update_ran:
|
||||
for entry in os.scandir("worlds"):
|
||||
for entry in os.scandir(os.path.join(local_dir, "worlds")):
|
||||
if entry.is_dir():
|
||||
req_file = os.path.join(entry.path, "requirements.txt")
|
||||
if os.path.exists(req_file):
|
||||
@@ -61,6 +62,9 @@ if __name__ == "__main__":
|
||||
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')
|
||||
parser.add_argument('-a', '--append', nargs="*", dest='additional_requirements',
|
||||
help='List paths to additional requirement files.')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.additional_requirements:
|
||||
requirements_files.update(args.additional_requirements)
|
||||
update(args.yes, args.force)
|
||||
|
||||
226
MultiServer.py
@@ -23,8 +23,11 @@ ModuleUpdate.update()
|
||||
|
||||
import websockets
|
||||
import colorama
|
||||
|
||||
from thefuzz import process as fuzzy_process
|
||||
try:
|
||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||
from pony.orm.dbapiprovider import OperationalError
|
||||
except ImportError:
|
||||
OperationalError = ConnectionError
|
||||
|
||||
import NetUtils
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
@@ -37,6 +40,7 @@ from Utils import get_item_name_from_id, get_location_name_from_id, \
|
||||
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
|
||||
SlotType
|
||||
|
||||
min_client_version = Version(0, 1, 6)
|
||||
colorama.init()
|
||||
|
||||
# functions callable on storable data on the server by clients
|
||||
@@ -181,6 +185,7 @@ class Context:
|
||||
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
|
||||
self.seed_name = ""
|
||||
self.groups = {}
|
||||
self.group_collected: typing.Dict[int, typing.Set[int]] = {}
|
||||
self.random = random.Random()
|
||||
self.stored_data = {}
|
||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||
@@ -301,7 +306,7 @@ class Context:
|
||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||
self.minimum_client_versions = {}
|
||||
for player, version in clients_ver.items():
|
||||
self.minimum_client_versions[player] = Utils.Version(*version)
|
||||
self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version)
|
||||
|
||||
self.clients = {}
|
||||
for team, names in enumerate(decoded_obj['names']):
|
||||
@@ -404,12 +409,16 @@ class Context:
|
||||
def save_regularly():
|
||||
import time
|
||||
while not self.exit_event.is_set():
|
||||
time.sleep(self.auto_save_interval)
|
||||
if self.save_dirty:
|
||||
logging.debug("Saving via thread.")
|
||||
try:
|
||||
time.sleep(self.auto_save_interval)
|
||||
if self.save_dirty:
|
||||
logging.debug("Saving via thread.")
|
||||
self._save()
|
||||
except OperationalError as e:
|
||||
logging.exception(e)
|
||||
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
else:
|
||||
self.save_dirty = False
|
||||
self._save()
|
||||
|
||||
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
|
||||
self.auto_saver_thread.start()
|
||||
|
||||
@@ -432,6 +441,7 @@ class Context:
|
||||
"client_connection_timers": tuple(
|
||||
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
|
||||
"random_state": self.random.getstate(),
|
||||
"group_collected": dict(self.group_collected),
|
||||
"stored_data": self.stored_data,
|
||||
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
|
||||
"server_password": self.server_password, "password": self.password, "forfeit_mode":
|
||||
@@ -445,22 +455,9 @@ class Context:
|
||||
def set_save(self, savedata: dict):
|
||||
if self.connect_names != savedata["connect_names"]:
|
||||
raise Exception("This savegame does not appear to match the loaded multiworld.")
|
||||
if "version" not in savedata:
|
||||
# upgrade from version 1
|
||||
# this is not perfect but good enough for old games to continue
|
||||
for old, items in savedata["received_items"].items():
|
||||
self.received_items[(*old, True)] = items
|
||||
self.received_items[(*old, False)] = items.copy()
|
||||
for (team, slot, remote) in self.received_items:
|
||||
# remove start inventory from items, since this is separate now
|
||||
start_inventory = get_start_inventory(self, slot, slot in self.remote_start_inventory)
|
||||
if start_inventory:
|
||||
del self.received_items[team, slot, remote][:len(start_inventory)]
|
||||
logging.info("Upgraded save data")
|
||||
elif savedata["version"] > self.save_version:
|
||||
if savedata["version"] > self.save_version:
|
||||
raise Exception("This savegame is newer than the server.")
|
||||
else:
|
||||
self.received_items = savedata["received_items"]
|
||||
self.received_items = savedata["received_items"]
|
||||
self.hints_used.update(savedata["hints_used"])
|
||||
self.hints.update(savedata["hints"])
|
||||
|
||||
@@ -486,6 +483,9 @@ class Context:
|
||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||
self.compatibility = savedata["game_options"]["compatibility"]
|
||||
|
||||
if "group_collected" in savedata:
|
||||
self.group_collected = savedata["group_collected"]
|
||||
|
||||
if "stored_data" in savedata:
|
||||
self.stored_data = savedata["stored_data"]
|
||||
# count items and slots from lists for item_handling = remote
|
||||
@@ -510,6 +510,11 @@ class Context:
|
||||
def get_players_package(self):
|
||||
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
|
||||
|
||||
def slot_set(self, slot) -> typing.Set[int]:
|
||||
"""Returns the slot IDs that concern that slot,
|
||||
as in expands groups out and returns back the input for solo."""
|
||||
return self.groups.get(slot, {slot})
|
||||
|
||||
def _set_options(self, server_options: dict):
|
||||
for key, value in server_options.items():
|
||||
data_type = self.simple_options.get(key, None)
|
||||
@@ -547,35 +552,48 @@ class Context:
|
||||
collect_player(self, client.team, client.slot)
|
||||
|
||||
|
||||
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
|
||||
"""Send and remember hints"""
|
||||
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
|
||||
"""Send and remember hints."""
|
||||
if only_new:
|
||||
hints = [hint for hint in hints if hint not in ctx.hints[team, hint.finding_player]]
|
||||
if not hints:
|
||||
return
|
||||
concerns = collections.defaultdict(list)
|
||||
for hint in hints:
|
||||
net_msg = hint.as_network_message()
|
||||
if hint.receiving_player in ctx.groups:
|
||||
for player in ctx.groups[hint.receiving_player]:
|
||||
concerns[player].append(net_msg)
|
||||
else:
|
||||
concerns[hint.receiving_player].append(net_msg)
|
||||
if not hint.local and net_msg not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(net_msg)
|
||||
all_hints = collections.defaultdict(list)
|
||||
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
|
||||
data = (hint, hint.as_network_message())
|
||||
for player in ctx.slot_set(hint.receiving_player):
|
||||
concerns[player].append(data)
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(data)
|
||||
for slot, clients in ctx.clients[team].items():
|
||||
if not clients or slot == hint.finding_player or slot in ctx.slot_set(hint.receiving_player) or \
|
||||
(ctx.games[slot] != "Archipelago" and all(["ShowAllHints" not in client.tags for client in clients])):
|
||||
continue
|
||||
for client in [client for client in clients if "ShowAllHints" in client.tags or ctx.games[slot] == "Archipelago"]:
|
||||
all_hints[client].append(data)
|
||||
# remember hints in all cases
|
||||
if not hint.found:
|
||||
ctx.hints[team, hint.finding_player].add(hint)
|
||||
if hint.receiving_player in ctx.groups:
|
||||
for player in ctx.groups[hint.receiving_player]:
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in ctx.hints[team, hint.finding_player]:
|
||||
ctx.hints[team, hint.finding_player].add(hint)
|
||||
for player in ctx.slot_set(hint.receiving_player):
|
||||
ctx.hints[team, player].add(hint)
|
||||
else:
|
||||
ctx.hints[team, hint.receiving_player].add(hint)
|
||||
for text in (format_hint(ctx, team, hint) for hint in hints):
|
||||
logging.info("Notice (Team #%d): %s" % (team + 1, text))
|
||||
|
||||
if hints:
|
||||
for slot, clients in ctx.clients[team].items():
|
||||
client_hints = concerns[slot]
|
||||
if client_hints:
|
||||
for client in clients:
|
||||
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
||||
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(ctx, team, hint)))
|
||||
|
||||
for slot, hint_data in concerns.items():
|
||||
clients = ctx.clients[team].get(slot)
|
||||
if not clients:
|
||||
continue
|
||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
||||
for client in clients:
|
||||
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
||||
|
||||
for client, hint_data in all_hints.items():
|
||||
client_hints = [datum[1] for datum in sorted(hint_data)]
|
||||
asyncio.create_task(ctx.send_msgs(client, client_hints))
|
||||
|
||||
|
||||
def update_aliases(ctx: Context, team: int):
|
||||
@@ -624,9 +642,9 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
await ctx.send_msgs(client, [{
|
||||
'cmd': 'RoomInfo',
|
||||
'password': bool(ctx.password),
|
||||
# TODO remove around 0.4
|
||||
'players': players,
|
||||
# TODO remove around 0.2.5 in favor of slot_info ?
|
||||
# Maybe convert into a list of games that are present to fetch relevant datapackage entries before Connect?
|
||||
# TODO convert to list of games present in 0.4
|
||||
'games': [ctx.games[x] for x in range(1, len(ctx.games) + 1)],
|
||||
# tags are for additional features in the communication.
|
||||
# Name them by feature or fork, as you feel is appropriate.
|
||||
@@ -759,12 +777,12 @@ def update_checked_locations(ctx: Context, team: int, slot: int):
|
||||
def forfeit_player(ctx: Context, team: int, slot: int):
|
||||
"""register any locations that are in the multidata"""
|
||||
all_locations = set(ctx.locations[slot])
|
||||
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
|
||||
ctx.notify_all("%s (Team #%d) has released all remaining items from their world." % (ctx.player_names[(team, slot)], team + 1))
|
||||
register_location_checks(ctx, team, slot, all_locations)
|
||||
update_checked_locations(ctx, team, slot)
|
||||
|
||||
|
||||
def collect_player(ctx: Context, team: int, slot: int):
|
||||
def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
|
||||
"""register any locations that are in the multidata, pointing towards this player"""
|
||||
all_locations = collections.defaultdict(set)
|
||||
for source_slot, location_data in ctx.locations.items():
|
||||
@@ -772,11 +790,19 @@ def collect_player(ctx: Context, team: int, slot: int):
|
||||
if values[1] == slot:
|
||||
all_locations[source_slot].add(location_id)
|
||||
|
||||
ctx.notify_all("%s (Team #%d) has collected" % (ctx.player_names[(team, slot)], team + 1))
|
||||
ctx.notify_all("%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1))
|
||||
for source_player, location_ids in all_locations.items():
|
||||
register_location_checks(ctx, team, source_player, location_ids, count_activity=False)
|
||||
update_checked_locations(ctx, team, source_player)
|
||||
|
||||
if not is_group:
|
||||
for group, group_players in ctx.groups.items():
|
||||
if slot in group_players:
|
||||
group_collected_players = ctx.group_collected.setdefault(group, set())
|
||||
group_collected_players.add(slot)
|
||||
if set(group_players) == group_collected_players:
|
||||
collect_player(ctx, team, group, True)
|
||||
|
||||
|
||||
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
items = []
|
||||
@@ -787,8 +813,7 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
|
||||
|
||||
|
||||
def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem):
|
||||
targets = ctx.groups.get(target_slot, [target_slot])
|
||||
for target in targets:
|
||||
for target in ctx.slot_set(target_slot):
|
||||
for item in items:
|
||||
if item.player != target_slot:
|
||||
get_received_items(ctx, team, target, False).append(item)
|
||||
@@ -826,16 +851,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
|
||||
|
||||
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
|
||||
hints = []
|
||||
slots = []
|
||||
slots: typing.Set[int] = {slot}
|
||||
for group_id, group in ctx.groups.items():
|
||||
if slot in group:
|
||||
slots.append(group_id)
|
||||
slots.add(group_id)
|
||||
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
|
||||
for finding_player, check_data in ctx.locations.items():
|
||||
for location_id, result in check_data.items():
|
||||
item_id, receiving_player, item_flags = result
|
||||
|
||||
if (receiving_player == slot or receiving_player in slots) and item_id == seeked_item_id:
|
||||
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
|
||||
if receiving_player in slots and item_id == seeked_item_id:
|
||||
found = location_id in ctx.location_checks[team, finding_player]
|
||||
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
|
||||
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
|
||||
@@ -893,7 +916,7 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
|
||||
|
||||
|
||||
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
|
||||
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
|
||||
picks = Utils.get_fuzzy_results(input_text, possible_answers, limit=2)
|
||||
if len(picks) > 1:
|
||||
dif = picks[0][1] - picks[1][1]
|
||||
if picks[0][1] == 100:
|
||||
@@ -1046,7 +1069,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
@mark_raw
|
||||
def _cmd_admin(self, command: str = ""):
|
||||
"""Allow remote administration of the multiworld server"""
|
||||
"""Allow remote administration of the multiworld server
|
||||
Usage: "!admin login <password>" in order to log in to the remote interface.
|
||||
Once logged in, you can then use "!admin <command>" to issue commands.
|
||||
If you need further help once logged in. use "!admin /help" """
|
||||
|
||||
output = f"!admin {command}"
|
||||
if output.lower().startswith(
|
||||
@@ -1091,7 +1117,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return self.ctx.commandprocessor(command)
|
||||
|
||||
def _cmd_players(self) -> bool:
|
||||
"""Get information about connected and missing players"""
|
||||
"""Get information about connected and missing players."""
|
||||
if len(self.ctx.player_names) < 10:
|
||||
self.ctx.notify_all(get_players_string(self.ctx))
|
||||
else:
|
||||
@@ -1103,8 +1129,12 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.output(get_status_string(self.ctx, self.client.team))
|
||||
return True
|
||||
|
||||
def _cmd_release(self) -> bool:
|
||||
"""Sends remaining items in your world to their recipients."""
|
||||
return self._cmd_forfeit()
|
||||
|
||||
def _cmd_forfeit(self) -> bool:
|
||||
"""Surrender and send your remaining items out to their recipients"""
|
||||
"""Surrender and send your remaining items out to their recipients. Use release in the future."""
|
||||
if self.ctx.allow_forfeits.get((self.client.team, self.client.slot), False):
|
||||
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
||||
return True
|
||||
@@ -1113,7 +1143,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return True
|
||||
elif "disabled" in self.ctx.forfeit_mode:
|
||||
self.output(
|
||||
"Sorry, client forfeiting has been disabled on this server. You can ask the server admin for a /forfeit")
|
||||
"Sorry, client item releasing has been disabled on this server. You can ask the server admin for a /release")
|
||||
return False
|
||||
else: # is auto or goal
|
||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||
@@ -1121,8 +1151,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
return True
|
||||
else:
|
||||
self.output(
|
||||
"Sorry, client forfeiting requires you to have beaten the game on this server."
|
||||
" You can ask the server admin for a /forfeit")
|
||||
"Sorry, client item releasing requires you to have beaten the game on this server."
|
||||
" You can ask the server admin for a /release")
|
||||
return False
|
||||
|
||||
def _cmd_collect(self) -> bool:
|
||||
@@ -1261,7 +1291,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
elif not for_location and hint_name in world.item_name_groups: # item group name
|
||||
hints = []
|
||||
for item in world.item_name_groups[hint_name]:
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
|
||||
if item in world.item_name_to_id: # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
|
||||
elif not for_location and hint_name in world.item_names: # item name
|
||||
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
|
||||
else: # location name
|
||||
@@ -1388,9 +1419,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
else:
|
||||
team, slot = ctx.connect_names[args['name']]
|
||||
game = ctx.games[slot]
|
||||
if "IgnoreGame" not in args["tags"] and args['game'] != game:
|
||||
ignore_game = "IgnoreGame" in args["tags"] or ( # IgnoreGame is deprecated. TODO: remove after 0.3.3?
|
||||
("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game"))
|
||||
if not ignore_game and args['game'] != game:
|
||||
errors.add('InvalidGame')
|
||||
minver = ctx.minimum_client_versions[slot]
|
||||
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
|
||||
if minver > args['version']:
|
||||
errors.add('IncompatibleVersion')
|
||||
if args.get('items_handling', None) is None:
|
||||
@@ -1450,7 +1483,13 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
|
||||
elif cmd == "GetDataPackage":
|
||||
exclusions = args.get("exclusions", [])
|
||||
if exclusions:
|
||||
if "games" in args:
|
||||
games = {name: game_data for name, game_data in network_data_package["games"].items()
|
||||
if name in set(args.get("games", []))}
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": {"games": games}}])
|
||||
# TODO: remove exclusions behaviour around 0.5.0
|
||||
elif exclusions:
|
||||
exclusions = set(exclusions)
|
||||
games = {name: game_data for name, game_data in network_data_package["games"].items()
|
||||
if name not in exclusions}
|
||||
@@ -1458,6 +1497,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
package["games"] = games
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": package}])
|
||||
|
||||
else:
|
||||
await ctx.send_msgs(client, [{"cmd": "DataPackage",
|
||||
"data": network_data_package}])
|
||||
@@ -1513,7 +1553,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
|
||||
elif cmd == 'LocationScouts':
|
||||
locs = []
|
||||
create_as_hint = args.get("create_as_hint", False)
|
||||
create_as_hint: int = int(args.get("create_as_hint", 0))
|
||||
hints = []
|
||||
for location in args["locations"]:
|
||||
if type(location) is not int or location not in lookup_any_location_id_to_name:
|
||||
@@ -1526,7 +1566,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
if create_as_hint:
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||
notify_hints(ctx, client.team, hints)
|
||||
notify_hints(ctx, client.team, hints, only_new=create_as_hint == 2)
|
||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||
|
||||
elif cmd == 'StatusUpdate':
|
||||
@@ -1673,43 +1713,48 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
self.output(f"Could not find player {player_name} to collect")
|
||||
return False
|
||||
|
||||
@mark_raw
|
||||
def _cmd_release(self, player_name: str) -> bool:
|
||||
"""Send out the remaining items from a player to their intended recipients."""
|
||||
return self._cmd_forfeit(player_name)
|
||||
|
||||
@mark_raw
|
||||
def _cmd_forfeit(self, player_name: str) -> bool:
|
||||
"""Send out the remaining items from a player to their intended recipients"""
|
||||
"""Send out the remaining items from a player to their intended recipients."""
|
||||
seeked_player = player_name.lower()
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
forfeit_player(self.ctx, team, slot)
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to forfeit")
|
||||
self.output(f"Could not find player {player_name} to release")
|
||||
return False
|
||||
|
||||
@mark_raw
|
||||
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
||||
"""Allow the specified player to use the !forfeit command"""
|
||||
"""Allow the specified player to use the !release command."""
|
||||
seeked_player = player_name.lower()
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
self.ctx.allow_forfeits[(team, slot)] = True
|
||||
self.output(f"Player {player_name} is now allowed to use the !forfeit command at any time.")
|
||||
self.output(f"Player {player_name} is now allowed to use the !release command at any time.")
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to allow the !forfeit command for.")
|
||||
self.output(f"Could not find player {player_name} to allow the !release command for.")
|
||||
return False
|
||||
|
||||
@mark_raw
|
||||
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
||||
""""Disallow the specified player from using the !forfeit command"""
|
||||
""""Disallow the specified player from using the !release command."""
|
||||
seeked_player = player_name.lower()
|
||||
for (team, slot), name in self.ctx.player_names.items():
|
||||
if name.lower() == seeked_player:
|
||||
self.ctx.allow_forfeits[(team, slot)] = False
|
||||
self.output(
|
||||
f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
|
||||
f"Player {player_name} has to follow the server restrictions on use of the !release command.")
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
|
||||
self.output(f"Could not find player {player_name} to forbid the !release command for.")
|
||||
return False
|
||||
|
||||
def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *item_name: str) -> bool:
|
||||
@@ -1753,7 +1798,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
if item in world.item_name_groups:
|
||||
hints = []
|
||||
for item in world.item_name_groups[item]:
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item))
|
||||
if item in world.item_name_to_id: # ensure item has an ID
|
||||
hints.extend(collect_hints(self.ctx, team, slot, item))
|
||||
else: # item name
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
|
||||
@@ -1795,7 +1841,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
|
||||
def _cmd_option(self, option_name: str, option: str):
|
||||
"""Set options for the server. Warning: expires on restart"""
|
||||
"""Set options for the server."""
|
||||
|
||||
attrtype = self.ctx.simple_options.get(option_name, None)
|
||||
if attrtype:
|
||||
@@ -1932,18 +1978,8 @@ async def main(args: argparse.Namespace):
|
||||
|
||||
try:
|
||||
if not data_filename:
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error("Could not load tkinter, which is likely not installed. "
|
||||
"This attempt was made because no .archipelago file was provided as argument. "
|
||||
"Either provide a file or ensure the tkinter package is installed.")
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
|
||||
filetypes = (("Multiworld data", (".archipelago", ".zip")),)
|
||||
data_filename = Utils.open_filename("Select multiworld data", filetypes)
|
||||
|
||||
ctx.load(data_filename, args.use_embedded_options)
|
||||
|
||||
|
||||
@@ -96,6 +96,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
_encode = JSONEncoder(
|
||||
ensure_ascii=False,
|
||||
check_circular=False,
|
||||
separators=(',', ':'),
|
||||
).encode
|
||||
|
||||
|
||||
@@ -111,6 +112,7 @@ def get_any_version(data: dict) -> Version:
|
||||
whitelist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
}
|
||||
|
||||
custom_hooks = {
|
||||
@@ -234,7 +236,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
node["color"] = 'cyan'
|
||||
elif flags & 0b001: # advancement
|
||||
node["color"] = 'plum'
|
||||
elif flags & 0b010: # never_exclude
|
||||
elif flags & 0b010: # useful
|
||||
node["color"] = 'slateblue'
|
||||
elif flags & 0b100: # trap
|
||||
node["color"] = 'salmon'
|
||||
@@ -244,7 +246,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_item_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.item_name_getter(item_id)
|
||||
node["text"] = self.ctx.item_names[item_id]
|
||||
return self._handle_item_name(node)
|
||||
|
||||
def _handle_location_name(self, node: JSONMessagePart):
|
||||
@@ -253,7 +255,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
|
||||
def _handle_location_id(self, node: JSONMessagePart):
|
||||
item_id = int(node["text"])
|
||||
node["text"] = self.ctx.location_name_getter(item_id)
|
||||
node["text"] = self.ctx.location_names[item_id]
|
||||
return self._handle_location_name(node)
|
||||
|
||||
def _handle_entrance_name(self, node: JSONMessagePart):
|
||||
|
||||
82
OoTClient.py
@@ -48,9 +48,12 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
script_version: int = 1
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
|
||||
|
||||
class OoTCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
@@ -60,6 +63,13 @@ class OoTCommandProcessor(ClientCommandProcessor):
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
logger.info(f"N64 Status: {self.ctx.n64_status}")
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggle deathlink from client. Overrides default setting."""
|
||||
if isinstance(self.ctx, OoTContext):
|
||||
self.ctx.deathlink_client_override = True
|
||||
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
|
||||
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
|
||||
|
||||
|
||||
class OoTContext(CommonContext):
|
||||
command_processor = OoTCommandProcessor
|
||||
@@ -76,6 +86,8 @@ class OoTContext(CommonContext):
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
self.deathlink_client_override = False
|
||||
self.version_warning = False
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -91,6 +103,18 @@ class OoTContext(CommonContext):
|
||||
self.deathlink_pending = True
|
||||
super().on_deathlink(data)
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class OoTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Ocarina of Time Client"
|
||||
|
||||
self.ui = OoTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
def get_payload(ctx: OoTContext):
|
||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||
@@ -108,8 +132,8 @@ def get_payload(ctx: OoTContext):
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
|
||||
# Turn on deathlink if it is on
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled:
|
||||
# Turn on deathlink if it is on, and if the client hasn't overriden it
|
||||
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
|
||||
await ctx.update_death_link(True)
|
||||
ctx.deathlink_enabled = True
|
||||
|
||||
@@ -152,21 +176,30 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to five fields:
|
||||
# Data will return a dict with up to six fields:
|
||||
# 1. str: player name (always)
|
||||
# 2. bool: deathlink active (always)
|
||||
# 3. dict[str, bool]: checked locations
|
||||
# 4. bool: whether Link is currently at 0 HP
|
||||
# 5. bool: whether the game currently registers as complete
|
||||
# 2. int: script version (always)
|
||||
# 3. bool: deathlink active (always)
|
||||
# 4. dict[str, bool]: checked locations
|
||||
# 5. bool: whether Link is currently at 0 HP
|
||||
# 6. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
reported_version = data_decoded.get('scriptVersion', 0)
|
||||
if reported_version == script_version:
|
||||
if ctx.game is not None and 'locations' in data_decoded:
|
||||
# Not just a keep alive ping, parse
|
||||
asyncio.create_task(parse_payload(data_decoded, ctx, False))
|
||||
if not ctx.auth:
|
||||
ctx.auth = data_decoded['playerName']
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth(False)
|
||||
else:
|
||||
if not ctx.version_warning:
|
||||
logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}. "
|
||||
"Please update to the latest version. "
|
||||
"Your connection to the Archipelago server will not be accepted.")
|
||||
ctx.version_warning = True
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
@@ -226,7 +259,7 @@ async def patch_and_run_game(apz5_file):
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom = Rom(Utils.get_options()["oot_options"]["rom_file"])
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file)
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
@@ -253,13 +286,8 @@ if __name__ == '__main__':
|
||||
ctx = OoTContext(args.connect, args.password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
|
||||
if gui_enabled:
|
||||
input_task = None
|
||||
from kvui import OoTManager
|
||||
ctx.ui = OoTManager(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.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
|
||||
|
||||
@@ -271,17 +299,9 @@ if __name__ == '__main__':
|
||||
if ctx.n64_sync_task:
|
||||
await ctx.n64_sync_task
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.close()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
399
Options.py
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
import math
|
||||
import numbers
|
||||
import typing
|
||||
import random
|
||||
|
||||
from schema import Schema, And, Or
|
||||
from schema import Schema, And, Or, Optional
|
||||
from Utils import get_fuzzy_results
|
||||
|
||||
|
||||
class AssembleOptions(type):
|
||||
class AssembleOptions(abc.ABCMeta):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
options = attrs["options"] = {}
|
||||
name_lookup = attrs["name_lookup"] = {}
|
||||
@@ -24,8 +28,12 @@ class AssembleOptions(type):
|
||||
options.update(new_options)
|
||||
|
||||
# apply aliases, without name_lookup
|
||||
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")})
|
||||
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
|
||||
name.startswith("alias_")}
|
||||
|
||||
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
|
||||
|
||||
options.update(aliases)
|
||||
|
||||
# auto-validate schema on __init__
|
||||
if "schema" in attrs.keys():
|
||||
@@ -76,7 +84,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.get_current_option_name()})"
|
||||
|
||||
def __hash__(self):
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.value)
|
||||
|
||||
@property
|
||||
@@ -101,11 +109,173 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
return bool(self.value)
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any):
|
||||
def from_any(cls, data: typing.Any) -> Option[T]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Toggle(Option[int]):
|
||||
class NumericOption(Option[int], numbers.Integral):
|
||||
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
|
||||
# `int` is not a `numbers.Integral` according to the official typestubs
|
||||
# (even though isinstance(5, numbers.Integral) == True)
|
||||
# https://github.com/python/typing/issues/272
|
||||
# https://github.com/python/mypy/issues/3186
|
||||
# https://github.com/microsoft/pyright/issues/1575
|
||||
|
||||
def __eq__(self, other: typing.Any) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value == other.value
|
||||
else:
|
||||
return typing.cast(bool, self.value == other)
|
||||
|
||||
def __lt__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value < other.value
|
||||
else:
|
||||
return self.value < other
|
||||
|
||||
def __le__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value <= other.value
|
||||
else:
|
||||
return self.value <= other
|
||||
|
||||
def __gt__(self, other: typing.Union[int, NumericOption]) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value > other.value
|
||||
else:
|
||||
return self.value > other
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.value)
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.value
|
||||
|
||||
def __mul__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value * other.value
|
||||
else:
|
||||
return self.value * other
|
||||
|
||||
def __rmul__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return other.value * self.value
|
||||
else:
|
||||
return other * self.value
|
||||
|
||||
def __sub__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value - other.value
|
||||
else:
|
||||
return self.value - other
|
||||
|
||||
def __rsub__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value - self.value
|
||||
else:
|
||||
return left - self.value
|
||||
|
||||
def __add__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value + other.value
|
||||
else:
|
||||
return self.value + other
|
||||
|
||||
def __radd__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value + self.value
|
||||
else:
|
||||
return left + self.value
|
||||
|
||||
def __truediv__(self, other: typing.Any) -> typing.Any:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value / other.value
|
||||
else:
|
||||
return self.value / other
|
||||
|
||||
def __rtruediv__(self, left: typing.Any) -> typing.Any:
|
||||
if isinstance(left, NumericOption):
|
||||
return left.value / self.value
|
||||
else:
|
||||
return left / self.value
|
||||
|
||||
def __abs__(self) -> typing.Any:
|
||||
return abs(self.value)
|
||||
|
||||
def __and__(self, other: typing.Any) -> int:
|
||||
return self.value & int(other)
|
||||
|
||||
def __ceil__(self) -> int:
|
||||
return math.ceil(self.value)
|
||||
|
||||
def __floor__(self) -> int:
|
||||
return math.floor(self.value)
|
||||
|
||||
def __floordiv__(self, other: typing.Any) -> int:
|
||||
return self.value // int(other)
|
||||
|
||||
def __invert__(self) -> int:
|
||||
return ~(self.value)
|
||||
|
||||
def __lshift__(self, other: typing.Any) -> int:
|
||||
return self.value << int(other)
|
||||
|
||||
def __mod__(self, other: typing.Any) -> int:
|
||||
return self.value % int(other)
|
||||
|
||||
def __neg__(self) -> int:
|
||||
return -(self.value)
|
||||
|
||||
def __or__(self, other: typing.Any) -> int:
|
||||
return self.value | int(other)
|
||||
|
||||
def __pos__(self) -> int:
|
||||
return +(self.value)
|
||||
|
||||
def __pow__(self, exponent: numbers.Complex, modulus: typing.Optional[numbers.Integral] = None) -> int:
|
||||
if not (modulus is None):
|
||||
assert isinstance(exponent, numbers.Integral)
|
||||
return pow(self.value, exponent, modulus) # type: ignore
|
||||
return self.value ** exponent # type: ignore
|
||||
|
||||
def __rand__(self, other: typing.Any) -> int:
|
||||
return int(other) & self.value
|
||||
|
||||
def __rfloordiv__(self, other: typing.Any) -> int:
|
||||
return int(other) // self.value
|
||||
|
||||
def __rlshift__(self, other: typing.Any) -> int:
|
||||
return int(other) << self.value
|
||||
|
||||
def __rmod__(self, other: typing.Any) -> int:
|
||||
return int(other) % self.value
|
||||
|
||||
def __ror__(self, other: typing.Any) -> int:
|
||||
return int(other) | self.value
|
||||
|
||||
def __round__(self, ndigits: typing.Optional[int] = None) -> int:
|
||||
return round(self.value, ndigits)
|
||||
|
||||
def __rpow__(self, base: typing.Any) -> typing.Any:
|
||||
return base ** self.value
|
||||
|
||||
def __rrshift__(self, other: typing.Any) -> int:
|
||||
return int(other) >> self.value
|
||||
|
||||
def __rshift__(self, other: typing.Any) -> int:
|
||||
return self.value >> int(other)
|
||||
|
||||
def __rxor__(self, other: typing.Any) -> int:
|
||||
return int(other) ^ self.value
|
||||
|
||||
def __trunc__(self) -> int:
|
||||
return math.trunc(self.value)
|
||||
|
||||
def __xor__(self, other: typing.Any) -> int:
|
||||
return self.value ^ int(other)
|
||||
|
||||
|
||||
class Toggle(NumericOption):
|
||||
option_false = 0
|
||||
option_true = 1
|
||||
default = 0
|
||||
@@ -130,24 +300,6 @@ class Toggle(Option[int]):
|
||||
else:
|
||||
return cls(data)
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Toggle):
|
||||
return self.value == other.value
|
||||
else:
|
||||
return self.value == other
|
||||
|
||||
def __gt__(self, other):
|
||||
if isinstance(other, Toggle):
|
||||
return self.value > other.value
|
||||
else:
|
||||
return self.value > other
|
||||
|
||||
def __bool__(self):
|
||||
return bool(self.value)
|
||||
|
||||
def __int__(self):
|
||||
return int(self.value)
|
||||
|
||||
@classmethod
|
||||
def get_option_name(cls, value):
|
||||
return ["No", "Yes"][int(value)]
|
||||
@@ -159,7 +311,7 @@ class DefaultOnToggle(Toggle):
|
||||
default = 1
|
||||
|
||||
|
||||
class Choice(Option[int]):
|
||||
class Choice(NumericOption):
|
||||
auto_display_name = True
|
||||
|
||||
def __init__(self, value: int):
|
||||
@@ -216,7 +368,7 @@ class Choice(Option[int]):
|
||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||
|
||||
|
||||
class Range(Option[int], int):
|
||||
class Range(NumericOption):
|
||||
range_start = 0
|
||||
range_end = 1
|
||||
|
||||
@@ -231,47 +383,111 @@ class Range(Option[int], int):
|
||||
def from_text(cls, text: str) -> Range:
|
||||
text = text.lower()
|
||||
if text.startswith("random"):
|
||||
if text == "random-low":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
|
||||
elif text == "random-high":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
|
||||
elif text == "random-middle":
|
||||
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
|
||||
elif text.startswith("random-range-"):
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
|
||||
else:
|
||||
return cls(int(round(random.randint(random_range[0], random_range[1]))))
|
||||
else:
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
return cls.weighted_range(text)
|
||||
elif text == "default" and hasattr(cls, "default"):
|
||||
return cls(cls.default)
|
||||
elif text == "high":
|
||||
return cls(cls.range_end)
|
||||
elif text == "low":
|
||||
return cls(cls.range_start)
|
||||
elif cls.range_start == 0 \
|
||||
and hasattr(cls, "default") \
|
||||
and cls.default != 0 \
|
||||
and text in ("true", "false"):
|
||||
# these are the conditions where "true" and "false" make sense
|
||||
if text == "true":
|
||||
return cls(cls.default)
|
||||
else: # "false"
|
||||
return cls(0)
|
||||
return cls(int(text))
|
||||
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.range_start, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
@classmethod
|
||||
def custom_range(cls, text) -> Range:
|
||||
textsplit = text.split("-")
|
||||
try:
|
||||
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
|
||||
random_range.sort()
|
||||
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
|
||||
raise Exception(
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
if text.startswith("random-range-low"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
||||
elif text.startswith("random-range-middle"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||
elif text.startswith("random-range-high"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
||||
else:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
|
||||
@classmethod
|
||||
def from_any(cls, data: typing.Any) -> Range:
|
||||
if type(data) == int:
|
||||
return cls(data)
|
||||
return cls.from_text(str(data))
|
||||
|
||||
def get_option_name(self, value):
|
||||
@classmethod
|
||||
def get_option_name(cls, value: int) -> str:
|
||||
return str(value)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
||||
return int(round(random.triangular(lower, end, tri), 0))
|
||||
|
||||
|
||||
class SpecialRange(Range):
|
||||
special_range_cutoff = 0
|
||||
special_range_names: typing.Dict[str, int] = {}
|
||||
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> Range:
|
||||
text = text.lower()
|
||||
if text in cls.special_range_names:
|
||||
return cls(cls.special_range_names[text])
|
||||
return super().from_text(text)
|
||||
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
if text == "random-low":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
|
||||
elif text == "random-high":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
|
||||
elif text == "random-middle":
|
||||
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
return cls.custom_range(text)
|
||||
elif text == "random":
|
||||
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
|
||||
else:
|
||||
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
|
||||
f"Acceptable values are: random, random-high, random-middle, random-low, "
|
||||
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
|
||||
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
|
||||
|
||||
|
||||
class VerifyKeys:
|
||||
valid_keys = frozenset()
|
||||
@@ -300,13 +516,17 @@ class VerifyKeys:
|
||||
if self.verify_item_name:
|
||||
for item_name in self.value:
|
||||
if item_name not in world.item_names:
|
||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||
raise Exception(f"Item {item_name} from option {self} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
f"is not a valid item name from {world.game}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
elif self.verify_location_name:
|
||||
for location_name in self.value:
|
||||
if location_name not in world.location_names:
|
||||
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
|
||||
raise Exception(f"Location {location_name} from option {self} "
|
||||
f"is not a valid location name from {world.game}")
|
||||
f"is not a valid location name from {world.game}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
|
||||
|
||||
|
||||
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
|
||||
@@ -411,9 +631,18 @@ class Accessibility(Choice):
|
||||
default = 1
|
||||
|
||||
|
||||
class ProgressionBalancing(DefaultOnToggle):
|
||||
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
|
||||
class ProgressionBalancing(SpecialRange):
|
||||
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
|
||||
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck."""
|
||||
default = 50
|
||||
range_start = 0
|
||||
range_end = 99
|
||||
display_name = "Progression Balancing"
|
||||
special_range_names = {
|
||||
"disabled": 0,
|
||||
"normal": 50,
|
||||
"extreme": 99,
|
||||
}
|
||||
|
||||
|
||||
common_options = {
|
||||
@@ -451,6 +680,7 @@ class StartHints(ItemSet):
|
||||
class StartLocationHints(OptionSet):
|
||||
"""Start with these locations and their item prefilled into the !hint command"""
|
||||
display_name = "Start Location Hints"
|
||||
verify_location_name = True
|
||||
|
||||
|
||||
class ExcludeLocations(OptionSet):
|
||||
@@ -477,10 +707,31 @@ class ItemLinks(OptionList):
|
||||
{
|
||||
"name": And(str, len),
|
||||
"item_pool": [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None)
|
||||
Optional("exclude"): [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None),
|
||||
Optional("local_items"): [And(str, len)],
|
||||
Optional("non_local_items"): [And(str, len)]
|
||||
}
|
||||
])
|
||||
|
||||
@staticmethod
|
||||
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
|
||||
pool = set()
|
||||
for item_name in items:
|
||||
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
|
||||
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
|
||||
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
|
||||
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
|
||||
|
||||
raise Exception(f"Item {item_name} from item link {item_link} "
|
||||
f"is not a valid item from {world.game} for {pool_name}. "
|
||||
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
|
||||
if allow_item_groups:
|
||||
pool |= world.item_name_groups.get(item_name, {item_name})
|
||||
else:
|
||||
pool |= {item_name}
|
||||
return pool
|
||||
|
||||
def verify(self, world):
|
||||
super(ItemLinks, self).verify(world)
|
||||
existing_links = set()
|
||||
@@ -488,13 +739,25 @@ class ItemLinks(OptionList):
|
||||
if link["name"] in existing_links:
|
||||
raise Exception(f"You cannot have more than one link named {link['name']}.")
|
||||
existing_links.add(link["name"])
|
||||
for item_name in link["item_pool"]:
|
||||
if item_name not in world.item_names and item_name not in world.item_name_groups:
|
||||
raise Exception(f"Item {item_name} from item link {link} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
if link["replacement_item"] and link["replacement_item"] not in world.item_names:
|
||||
raise Exception(f"Item {link['replacement_item']} from item link {link} "
|
||||
f"is not a valid item name from {world.game}")
|
||||
|
||||
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
|
||||
local_items = set()
|
||||
non_local_items = set()
|
||||
|
||||
if "exclude" in link:
|
||||
pool -= self.verify_items(link["exclude"], link["name"], "exclude", world)
|
||||
if link["replacement_item"]:
|
||||
self.verify_items([link["replacement_item"]], link["name"], "replacement_item", world, False)
|
||||
if "local_items" in link:
|
||||
local_items = self.verify_items(link["local_items"], link["name"], "local_items", world)
|
||||
local_items &= pool
|
||||
if "non_local_items" in link:
|
||||
non_local_items = self.verify_items(link["non_local_items"], link["name"], "non_local_items", world)
|
||||
non_local_items &= pool
|
||||
|
||||
intersection = local_items.intersection(non_local_items)
|
||||
if intersection:
|
||||
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
|
||||
11
Patch.py
@@ -12,6 +12,9 @@ import zipfile
|
||||
import sys
|
||||
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
|
||||
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
import Utils
|
||||
|
||||
current_patch_version = 4
|
||||
@@ -175,14 +178,14 @@ preferred_endings = {
|
||||
|
||||
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
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
|
||||
elif game == GAME_SM:
|
||||
from worlds.sm.Rom import JAP10HASH as HASH
|
||||
from worlds.sm.Rom import SMJUHASH as HASH
|
||||
elif game == GAME_SOE:
|
||||
from worlds.soe.Patch import USHASH as HASH
|
||||
elif game == GAME_SMZ3:
|
||||
from worlds.alttp.Rom import JAP10HASH as ALTTPHASH
|
||||
from worlds.sm.Rom import JAP10HASH as SMHASH
|
||||
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
|
||||
from worlds.sm.Rom import SMJUHASH as SMHASH
|
||||
HASH = ALTTPHASH + SMHASH
|
||||
else:
|
||||
raise RuntimeError(f"Selected game {game} for base rom not found.")
|
||||
|
||||
10
README.md
@@ -21,6 +21,11 @@ Currently, the following games are supported:
|
||||
* Meritous
|
||||
* Super Metroid/Link to the Past combo randomizer (SMZ3)
|
||||
* ChecksFinder
|
||||
* ArchipIDLE
|
||||
* Hollow Knight
|
||||
* The Witness
|
||||
* Sonic Adventure 2: Battle
|
||||
* Starcraft 2: Wings of Liberty
|
||||
|
||||
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
|
||||
@@ -61,7 +66,10 @@ Contributions are welcome. We have a few asks of any new contributors.
|
||||
|
||||
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
|
||||
|
||||
For adding a new game to Archipelago please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
||||
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
|
||||
|
||||
## FAQ
|
||||
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
|
||||
|
||||
## Code of Conduct
|
||||
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
|
||||
|
||||
168
SNIClient.py
@@ -10,22 +10,29 @@ import base64
|
||||
import shutil
|
||||
import logging
|
||||
import asyncio
|
||||
import enum
|
||||
import typing
|
||||
|
||||
from json import loads, dumps
|
||||
|
||||
from Utils import init_logging
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import init_logging, messagebox
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SNIClient", exception_logger="Client")
|
||||
|
||||
import colorama
|
||||
import websockets
|
||||
|
||||
from NetUtils import *
|
||||
from NetUtils import ClientStatus, color
|
||||
from worlds.alttp import Regions, Shops
|
||||
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
|
||||
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
|
||||
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
|
||||
import Utils
|
||||
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3
|
||||
|
||||
snes_logger = logging.getLogger("SNES")
|
||||
@@ -71,7 +78,10 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
|
||||
snes_device_number = int(options[1])
|
||||
|
||||
self.ctx.snes_reconnect_address = None
|
||||
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
|
||||
if self.ctx.snes_connect_task:
|
||||
self.ctx.snes_connect_task.cancel()
|
||||
self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number),
|
||||
name="SNES Connect")
|
||||
return True
|
||||
|
||||
def _cmd_snes_close(self) -> bool:
|
||||
@@ -108,6 +118,7 @@ class Context(CommonContext):
|
||||
command_processor = SNIClientCommandProcessor
|
||||
game = "A Link to the Past"
|
||||
items_handling = None # set in game_watcher
|
||||
snes_connect_task: typing.Optional[asyncio.Task] = None
|
||||
|
||||
def __init__(self, snes_address, server_address, password):
|
||||
super(Context, self).__init__(server_address, password)
|
||||
@@ -124,6 +135,8 @@ class Context(CommonContext):
|
||||
self.snes_connector_lock = threading.Lock()
|
||||
self.death_state = DeathState.alive # for death link flop behaviour
|
||||
self.killing_player_task = None
|
||||
self.allow_collect = False
|
||||
self.slow_mode = False
|
||||
|
||||
self.awaiting_rom = False
|
||||
self.rom = None
|
||||
@@ -172,6 +185,11 @@ class Context(CommonContext):
|
||||
if not currently_dead:
|
||||
self.death_state = DeathState.alive
|
||||
|
||||
async def shutdown(self):
|
||||
await super(Context, self).shutdown()
|
||||
if self.snes_connect_task:
|
||||
await self.snes_connect_task
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
if "checked_locations" in args and args["checked_locations"]:
|
||||
@@ -182,6 +200,19 @@ class Context(CommonContext):
|
||||
# Once the games handled by SNIClient gets made to be remote items, this will no longer be needed.
|
||||
asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class SNIManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("SNES", "SNES"),
|
||||
]
|
||||
base_title = "Archipelago SNI Client"
|
||||
|
||||
self.ui = SNIManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
|
||||
async def deathlink_kill_player(ctx: Context):
|
||||
ctx.death_state = DeathState.killing_player
|
||||
@@ -504,6 +535,18 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
|
||||
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
|
||||
|
||||
boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss',
|
||||
'Desert Palace - Boss',
|
||||
'Tower of Hera - Boss',
|
||||
'Palace of Darkness - Boss',
|
||||
'Swamp Palace - Boss',
|
||||
'Skull Woods - Boss',
|
||||
"Thieves' Town - Boss",
|
||||
'Ice Palace - Boss',
|
||||
'Misery Mire - Boss',
|
||||
'Turtle Rock - Boss',
|
||||
'Sahasrahla'}}
|
||||
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
|
||||
|
||||
location_table_npc = {'Mushroom': 0x1000,
|
||||
@@ -611,7 +654,7 @@ async def _snes_connect(ctx: Context, address: str):
|
||||
return snes_socket
|
||||
|
||||
|
||||
async def get_snes_devices(ctx: Context):
|
||||
async def get_snes_devices(ctx: Context) -> typing.List[str]:
|
||||
socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll
|
||||
DeviceList_Request = {
|
||||
"Opcode": "DeviceList",
|
||||
@@ -619,19 +662,20 @@ async def get_snes_devices(ctx: Context):
|
||||
}
|
||||
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
|
||||
reply: dict = loads(await socket.recv())
|
||||
devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
|
||||
|
||||
if not devices:
|
||||
snes_logger.info('No SNES device found. Please connect a SNES device to SNI.')
|
||||
while not devices:
|
||||
await asyncio.sleep(1)
|
||||
while not devices and not ctx.exit_event.is_set():
|
||||
await asyncio.sleep(0.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)
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else []
|
||||
if devices:
|
||||
await verify_snes_app(socket)
|
||||
await socket.close()
|
||||
return devices
|
||||
return sorted(devices)
|
||||
|
||||
|
||||
async def verify_snes_app(socket):
|
||||
@@ -849,7 +893,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
def new_check(location_id):
|
||||
new_locations.append(location_id)
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
location = ctx.location_names[location_id]
|
||||
snes_logger.info(
|
||||
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
|
||||
@@ -861,7 +905,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
location = Shops.SHOP_ID_START + cnt
|
||||
if int(b) and location not in ctx.locations_checked:
|
||||
new_check(location)
|
||||
if location in ctx.checked_locations and location not in ctx.locations_checked \
|
||||
if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \
|
||||
and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot:
|
||||
if not int(b):
|
||||
shop_data[cnt] += 1
|
||||
@@ -889,8 +933,9 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
uw_unchecked[location_id] = (roomid, mask)
|
||||
uw_begin = min(uw_begin, roomid)
|
||||
uw_end = max(uw_end, roomid + 1)
|
||||
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked and \
|
||||
location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
|
||||
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
|
||||
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
|
||||
and ctx.locations_info[location_id].player != ctx.slot:
|
||||
uw_begin = min(uw_begin, roomid)
|
||||
uw_end = max(uw_end, roomid + 1)
|
||||
uw_checked[location_id] = (roomid, mask)
|
||||
@@ -921,7 +966,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
ow_unchecked[location_id] = screenid
|
||||
ow_begin = min(ow_begin, screenid)
|
||||
ow_end = max(ow_end, screenid + 1)
|
||||
if location_id in ctx.checked_locations and location_id in ctx.locations_info \
|
||||
if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \
|
||||
and ctx.locations_info[location_id].player != ctx.slot:
|
||||
ow_checked[location_id] = screenid
|
||||
|
||||
@@ -945,8 +990,9 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
for location_id, mask in location_table_npc_id.items():
|
||||
if npc_value & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
|
||||
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
|
||||
if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \
|
||||
and location_id not in ctx.locations_checked and location_id in ctx.locations_info \
|
||||
and ctx.locations_info[location_id].player != ctx.slot:
|
||||
npc_value |= mask
|
||||
npc_value_changed = True
|
||||
if npc_value_changed:
|
||||
@@ -962,7 +1008,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
||||
assert (0x3c6 <= offset <= 0x3c9)
|
||||
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
|
||||
if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
|
||||
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
|
||||
misc_data_changed = True
|
||||
misc_data[offset - 0x3c6] |= mask
|
||||
@@ -988,13 +1034,18 @@ async def game_watcher(ctx: Context):
|
||||
if not ctx.rom:
|
||||
ctx.finished_game = False
|
||||
ctx.death_link_allow_survive = False
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
|
||||
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
|
||||
if game_name is None:
|
||||
continue
|
||||
elif game_name[:2] == b"SM":
|
||||
ctx.game = GAME_SM
|
||||
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
|
||||
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
|
||||
# versions lower than 0.3.0 dont have item handling flag nor remote item support
|
||||
romVersion = int(game_name[2:5].decode('UTF-8'))
|
||||
if romVersion < 30:
|
||||
ctx.items_handling = 0b001 # full local
|
||||
else:
|
||||
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
|
||||
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
|
||||
else:
|
||||
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
|
||||
if game_name == b"ZSM":
|
||||
@@ -1013,6 +1064,7 @@ async def game_watcher(ctx: Context):
|
||||
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.allow_collect = bool(death_link[0] & 0b100)
|
||||
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:
|
||||
@@ -1074,9 +1126,9 @@ async def game_watcher(ctx: Context):
|
||||
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.item_names[item.item], 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
|
||||
ctx.location_names[item.location], recv_index, len(ctx.items_received)))
|
||||
|
||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
|
||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
@@ -1131,7 +1183,7 @@ async def game_watcher(ctx: Context):
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
location = ctx.location_names[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]}])
|
||||
@@ -1148,7 +1200,10 @@ async def game_watcher(ctx: Context):
|
||||
if itemOutPtr < len(ctx.items_received):
|
||||
item = ctx.items_received[itemOutPtr]
|
||||
itemId = item.item - items_start_id
|
||||
locationId = (item.location - locations_start_id) if item.location >= 0 else 0x00
|
||||
if bool(ctx.items_handling & 0b010):
|
||||
locationId = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF
|
||||
else:
|
||||
locationId = 0x00 #backward compat
|
||||
|
||||
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
|
||||
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
|
||||
@@ -1157,9 +1212,9 @@ async def game_watcher(ctx: Context):
|
||||
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.item_names[item.item], 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_name_getter(item.location), itemOutPtr, len(ctx.items_received)))
|
||||
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
||||
await snes_flush_writes(ctx)
|
||||
elif ctx.game == GAME_SMZ3:
|
||||
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
|
||||
@@ -1200,7 +1255,7 @@ async def game_watcher(ctx: Context):
|
||||
location_id = locations_start_id + itemIndex
|
||||
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
location = ctx.location_names[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]}])
|
||||
|
||||
@@ -1221,8 +1276,8 @@ async def game_watcher(ctx: Context):
|
||||
itemOutPtr += 1
|
||||
snes_buffered_write(ctx, SMZ3_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)))
|
||||
color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
|
||||
@@ -1248,7 +1303,11 @@ async def main():
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logging.info("Patch file was supplied. Creating sfc rom..")
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
try:
|
||||
meta, romfile = Patch.create_rom_file(args.diff_file)
|
||||
except Exception as e:
|
||||
messagebox('Error', str(e), True)
|
||||
raise
|
||||
if "server" in meta:
|
||||
args.connect = meta["server"]
|
||||
logging.info(f"Wrote rom file to {romfile}")
|
||||
@@ -1260,7 +1319,7 @@ async def main():
|
||||
import time
|
||||
time.sleep(3)
|
||||
sys.exit()
|
||||
elif args.diff_file.endswith((".apbp", "apz3")):
|
||||
elif args.diff_file.endswith((".apbp", ".apz3", ".aplttp")):
|
||||
adjustedromfile, adjusted = get_alttp_settings(romfile)
|
||||
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
|
||||
else:
|
||||
@@ -1269,17 +1328,12 @@ async def main():
|
||||
ctx = Context(args.snes, args.connect, args.password)
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
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")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
ctx.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()
|
||||
@@ -1288,21 +1342,12 @@ async def main():
|
||||
ctx.snes_reconnect_address = None
|
||||
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()
|
||||
|
||||
if ui_task:
|
||||
await ui_task
|
||||
|
||||
if input_task:
|
||||
input_task.cancel()
|
||||
|
||||
|
||||
def get_alttp_settings(romfile: str):
|
||||
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
|
||||
adjusted = False
|
||||
adjustedromfile = ''
|
||||
if lastSettings:
|
||||
choice = 'no'
|
||||
@@ -1310,7 +1355,7 @@ def get_alttp_settings(romfile: str):
|
||||
|
||||
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
|
||||
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
|
||||
"reduceflashing", "deathlink"}
|
||||
"reduceflashing", "deathlink", "allowcollect"}
|
||||
printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist}
|
||||
if hasattr(lastSettings, "sprite_pool"):
|
||||
sprite_pool = {}
|
||||
@@ -1325,8 +1370,13 @@ def get_alttp_settings(romfile: str):
|
||||
|
||||
if gui_enabled:
|
||||
|
||||
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
|
||||
applyPromptWindow = Tk()
|
||||
try:
|
||||
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
|
||||
applyPromptWindow = Tk()
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed.')
|
||||
return '', False
|
||||
|
||||
applyPromptWindow.resizable(False, False)
|
||||
applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick())
|
||||
logo = PhotoImage(file=Utils.local_path('data', 'icon.png'))
|
||||
@@ -1410,7 +1460,7 @@ def get_alttp_settings(romfile: str):
|
||||
if hasattr(lastSettings, "world"):
|
||||
delattr(lastSettings, "world")
|
||||
else:
|
||||
adjusted = False;
|
||||
adjusted = False
|
||||
if adjusted:
|
||||
try:
|
||||
shutil.move(adjustedromfile, romfile)
|
||||
@@ -1424,7 +1474,5 @@ def get_alttp_settings(romfile: str):
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(main())
|
||||
loop.close()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
|
||||
802
Starcraft2Client.py
Normal file
@@ -0,0 +1,802 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import multiprocessing
|
||||
import logging
|
||||
import asyncio
|
||||
import os.path
|
||||
|
||||
import nest_asyncio
|
||||
import sc2
|
||||
|
||||
from sc2.main import run_game
|
||||
from sc2.data import Race
|
||||
from sc2.bot_ai import BotAI
|
||||
from sc2.player import Bot
|
||||
|
||||
from worlds.sc2wol.Regions import MissionInfo
|
||||
from worlds.sc2wol.MissionTables import lookup_id_to_mission
|
||||
from worlds.sc2wol.Items import lookup_id_to_name, item_table
|
||||
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
|
||||
from worlds.sc2wol import SC2WoLWorld
|
||||
|
||||
from Utils import init_logging
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging("SC2Client", exception_logger="Client")
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
sc2_logger = logging.getLogger("Starcraft2")
|
||||
|
||||
import colorama
|
||||
|
||||
from NetUtils import *
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
nest_asyncio.apply()
|
||||
|
||||
|
||||
class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
ctx: SC2Context
|
||||
|
||||
def _cmd_disable_mission_check(self) -> bool:
|
||||
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
|
||||
the next mission in a chain the other player is doing."""
|
||||
self.ctx.missions_unlocked = True
|
||||
sc2_logger.info("Mission check has been disabled")
|
||||
|
||||
def _cmd_play(self, mission_id: str = "") -> bool:
|
||||
"""Start a Starcraft 2 mission"""
|
||||
|
||||
options = mission_id.split()
|
||||
num_options = len(options)
|
||||
|
||||
if num_options > 0:
|
||||
mission_number = int(options[0])
|
||||
|
||||
self.ctx.play_mission(mission_number)
|
||||
|
||||
else:
|
||||
sc2_logger.info(
|
||||
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
|
||||
|
||||
return True
|
||||
|
||||
def _cmd_available(self) -> bool:
|
||||
"""Get what missions are currently available to play"""
|
||||
|
||||
request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
|
||||
return True
|
||||
|
||||
def _cmd_unfinished(self) -> bool:
|
||||
"""Get what missions are currently available to play and have not had all locations checked"""
|
||||
|
||||
request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
|
||||
return True
|
||||
|
||||
|
||||
class SC2Context(CommonContext):
|
||||
command_processor = StarcraftClientProcessor
|
||||
game = "Starcraft 2 Wings of Liberty"
|
||||
items_handling = 0b111
|
||||
difficulty = -1
|
||||
all_in_choice = 0
|
||||
mission_req_table = None
|
||||
items_rec_to_announce = []
|
||||
rec_announce_pos = 0
|
||||
items_sent_to_announce = []
|
||||
sent_announce_pos = 0
|
||||
announcements = []
|
||||
announcement_pos = 0
|
||||
sc2_run_task: typing.Optional[asyncio.Task] = None
|
||||
missions_unlocked = False
|
||||
current_tooltip = None
|
||||
last_loc_list = None
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(SC2Context, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected"}:
|
||||
self.difficulty = args["slot_data"]["game_difficulty"]
|
||||
self.all_in_choice = args["slot_data"]["all_in_map"]
|
||||
slot_req_table = args["slot_data"]["mission_req"]
|
||||
self.mission_req_table = {}
|
||||
# Compatibility for 0.3.2 server data.
|
||||
if "category" not in next(iter(slot_req_table)):
|
||||
for i, mission_data in enumerate(slot_req_table.values()):
|
||||
mission_data["category"] = wol_default_categories[i]
|
||||
for mission in slot_req_table:
|
||||
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
|
||||
|
||||
if cmd in {"PrintJSON"}:
|
||||
if "receiving" in args:
|
||||
if self.slot_concerns_self(args["receiving"]):
|
||||
self.announcements.append(args["data"])
|
||||
return
|
||||
if "item" in args:
|
||||
if self.slot_concerns_self(args["item"].player):
|
||||
self.announcements.append(args["data"])
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
|
||||
from kivy.app import App
|
||||
from kivy.clock import Clock
|
||||
from kivy.uix.tabbedpanel import TabbedPanelItem
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.lang import Builder
|
||||
from kivy.uix.label import Label
|
||||
from kivy.uix.button import Button
|
||||
from kivy.uix.floatlayout import FloatLayout
|
||||
from kivy.properties import StringProperty
|
||||
|
||||
import Utils
|
||||
|
||||
class HoverableButton(HoverBehavior, Button):
|
||||
pass
|
||||
|
||||
class MissionButton(HoverableButton):
|
||||
tooltip_text = StringProperty("Test")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HoverableButton, self).__init__(*args, **kwargs)
|
||||
self.layout = FloatLayout()
|
||||
self.popuplabel = ServerToolTip(text=self.text)
|
||||
self.layout.add_widget(self.popuplabel)
|
||||
|
||||
def on_enter(self):
|
||||
self.popuplabel.text = self.tooltip_text
|
||||
|
||||
if self.ctx.current_tooltip:
|
||||
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||
|
||||
if self.tooltip_text == "":
|
||||
self.ctx.current_tooltip = None
|
||||
else:
|
||||
App.get_running_app().root.add_widget(self.layout)
|
||||
self.ctx.current_tooltip = self.layout
|
||||
|
||||
def on_leave(self):
|
||||
if self.ctx.current_tooltip:
|
||||
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
|
||||
|
||||
self.ctx.current_tooltip = None
|
||||
|
||||
@property
|
||||
def ctx(self) -> CommonContext:
|
||||
return App.get_running_app().ctx
|
||||
|
||||
class MissionLayout(GridLayout):
|
||||
pass
|
||||
|
||||
class MissionCategory(GridLayout):
|
||||
pass
|
||||
|
||||
class SC2Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("Starcraft2", "Starcraft2"),
|
||||
]
|
||||
base_title = "Archipelago Starcraft 2 Client"
|
||||
|
||||
mission_panel = None
|
||||
last_checked_locations = {}
|
||||
mission_id_to_button = {}
|
||||
launching = False
|
||||
refresh_from_launching = True
|
||||
first_check = True
|
||||
|
||||
def __init__(self, ctx):
|
||||
super().__init__(ctx)
|
||||
|
||||
def build(self):
|
||||
container = super().build()
|
||||
|
||||
panel = TabbedPanelItem(text="Starcraft 2 Launcher")
|
||||
self.mission_panel = panel.content = MissionLayout()
|
||||
|
||||
self.tabs.add_widget(panel)
|
||||
|
||||
Clock.schedule_interval(self.build_mission_table, 0.5)
|
||||
|
||||
return container
|
||||
|
||||
def build_mission_table(self, dt):
|
||||
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
|
||||
not self.refresh_from_launching)) or self.first_check:
|
||||
self.refresh_from_launching = True
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
|
||||
if self.ctx.mission_req_table:
|
||||
self.last_checked_locations = self.ctx.checked_locations.copy()
|
||||
self.first_check = False
|
||||
|
||||
self.mission_id_to_button = {}
|
||||
categories = {}
|
||||
available_missions = []
|
||||
unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
|
||||
unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
|
||||
self.ctx.mission_req_table,
|
||||
self.ctx, available_missions=available_missions,
|
||||
unfinished_locations=unfinished_locations)
|
||||
|
||||
# separate missions into categories
|
||||
for mission in self.ctx.mission_req_table:
|
||||
if not self.ctx.mission_req_table[mission].category in categories:
|
||||
categories[self.ctx.mission_req_table[mission].category] = []
|
||||
|
||||
categories[self.ctx.mission_req_table[mission].category].append(mission)
|
||||
|
||||
for category in categories:
|
||||
category_panel = MissionCategory()
|
||||
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
|
||||
|
||||
# Map is completed
|
||||
for mission in categories[category]:
|
||||
text = mission
|
||||
tooltip = ""
|
||||
|
||||
# Map has uncollected locations
|
||||
if mission in unfinished_missions:
|
||||
text = f"[color=6495ED]{text}[/color]"
|
||||
|
||||
tooltip = f"Uncollected locations:\n"
|
||||
tooltip += "\n".join(location for location in unfinished_locations[mission])
|
||||
elif mission in available_missions:
|
||||
text = f"[color=FFFFFF]{text}[/color]"
|
||||
# Map requirements not met
|
||||
else:
|
||||
text = f"[color=a9a9a9]{text}[/color]"
|
||||
tooltip = f"Requires: "
|
||||
if len(self.ctx.mission_req_table[mission].required_world) > 0:
|
||||
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
|
||||
req_mission in
|
||||
self.ctx.mission_req_table[mission].required_world)
|
||||
|
||||
if self.ctx.mission_req_table[mission].number > 0:
|
||||
tooltip += " and "
|
||||
if self.ctx.mission_req_table[mission].number > 0:
|
||||
tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed"
|
||||
|
||||
mission_button = MissionButton(text=text, size_hint_y=None, height=50)
|
||||
mission_button.tooltip_text = tooltip
|
||||
mission_button.bind(on_press=self.mission_callback)
|
||||
self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button
|
||||
category_panel.add_widget(mission_button)
|
||||
|
||||
category_panel.add_widget(Label(text=""))
|
||||
self.mission_panel.add_widget(category_panel)
|
||||
|
||||
elif self.launching:
|
||||
self.refresh_from_launching = False
|
||||
|
||||
self.mission_panel.clear_widgets()
|
||||
self.mission_panel.add_widget(Label(text="Launching Mission"))
|
||||
|
||||
def mission_callback(self, button):
|
||||
if not self.launching:
|
||||
self.ctx.play_mission(list(self.mission_id_to_button.keys())
|
||||
[list(self.mission_id_to_button.values()).index(button)])
|
||||
self.launching = True
|
||||
Clock.schedule_once(self.finish_launching, 10)
|
||||
|
||||
def finish_launching(self, dt):
|
||||
self.launching = False
|
||||
|
||||
self.ui = SC2Manager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
Builder.load_file(Utils.local_path(os.path.dirname(SC2WoLWorld.__file__), "Starcraft2.kv"))
|
||||
|
||||
async def shutdown(self):
|
||||
await super(SC2Context, self).shutdown()
|
||||
if self.sc2_run_task:
|
||||
self.sc2_run_task.cancel()
|
||||
|
||||
def play_mission(self, mission_id):
|
||||
if self.missions_unlocked or \
|
||||
is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
|
||||
if self.sc2_run_task:
|
||||
if not self.sc2_run_task.done():
|
||||
sc2_logger.warning("Starcraft 2 Client is still running!")
|
||||
self.sc2_run_task.cancel() # doesn't actually close the game, just stops the python task
|
||||
if self.slot is None:
|
||||
sc2_logger.warning("Launching Mission without Archipelago authentication, "
|
||||
"checks will not be registered to server.")
|
||||
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
|
||||
name="Starcraft 2 Launch")
|
||||
else:
|
||||
sc2_logger.info(
|
||||
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
|
||||
f"Use /unfinished or /available to see what is available.")
|
||||
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('--name', default=None, help="Slot Name to connect as.")
|
||||
args = parser.parse_args()
|
||||
|
||||
ctx = SC2Context(args.connect, args.password)
|
||||
ctx.auth = args.name
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
maps_table = [
|
||||
"ap_traynor01", "ap_traynor02", "ap_traynor03",
|
||||
"ap_thanson01", "ap_thanson02", "ap_thanson03a", "ap_thanson03b",
|
||||
"ap_ttychus01", "ap_ttychus02", "ap_ttychus03", "ap_ttychus04", "ap_ttychus05",
|
||||
"ap_ttosh01", "ap_ttosh02", "ap_ttosh03a", "ap_ttosh03b",
|
||||
"ap_thorner01", "ap_thorner02", "ap_thorner03", "ap_thorner04", "ap_thorner05s",
|
||||
"ap_tzeratul01", "ap_tzeratul02", "ap_tzeratul03", "ap_tzeratul04",
|
||||
"ap_tvalerian01", "ap_tvalerian02a", "ap_tvalerian02b", "ap_tvalerian03"
|
||||
]
|
||||
|
||||
wol_default_categories = [
|
||||
"Mar Sara", "Mar Sara", "Mar Sara", "Colonist", "Colonist", "Colonist", "Colonist",
|
||||
"Artifact", "Artifact", "Artifact", "Artifact", "Artifact", "Covert", "Covert", "Covert", "Covert",
|
||||
"Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy",
|
||||
"Char", "Char", "Char", "Char"
|
||||
]
|
||||
|
||||
|
||||
def calculate_items(items):
|
||||
unit_unlocks = 0
|
||||
armory1_unlocks = 0
|
||||
armory2_unlocks = 0
|
||||
upgrade_unlocks = 0
|
||||
building_unlocks = 0
|
||||
merc_unlocks = 0
|
||||
lab_unlocks = 0
|
||||
protoss_unlock = 0
|
||||
minerals = 0
|
||||
vespene = 0
|
||||
supply = 0
|
||||
|
||||
for item in items:
|
||||
data = lookup_id_to_name[item.item]
|
||||
|
||||
if item_table[data].type == "Unit":
|
||||
unit_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Upgrade":
|
||||
upgrade_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Armory 1":
|
||||
armory1_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Armory 2":
|
||||
armory2_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Building":
|
||||
building_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Mercenary":
|
||||
merc_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Laboratory":
|
||||
lab_unlocks += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Protoss":
|
||||
protoss_unlock += (1 << item_table[data].number)
|
||||
elif item_table[data].type == "Minerals":
|
||||
minerals += item_table[data].number
|
||||
elif item_table[data].type == "Vespene":
|
||||
vespene += item_table[data].number
|
||||
elif item_table[data].type == "Supply":
|
||||
supply += item_table[data].number
|
||||
|
||||
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
|
||||
lab_unlocks, protoss_unlock, minerals, vespene, supply]
|
||||
|
||||
|
||||
def calc_difficulty(difficulty):
|
||||
if difficulty == 0:
|
||||
return 'C'
|
||||
elif difficulty == 1:
|
||||
return 'N'
|
||||
elif difficulty == 2:
|
||||
return 'H'
|
||||
elif difficulty == 3:
|
||||
return 'B'
|
||||
|
||||
return 'X'
|
||||
|
||||
|
||||
async def starcraft_launch(ctx: SC2Context, mission_id):
|
||||
ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
|
||||
ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
|
||||
ctx.announcements_pos = len(ctx.announcements)
|
||||
|
||||
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
|
||||
|
||||
run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id),
|
||||
name="Archipelago", fullscreen=True)], realtime=True)
|
||||
|
||||
|
||||
class ArchipelagoBot(sc2.bot_ai.BotAI):
|
||||
game_running = False
|
||||
mission_completed = False
|
||||
first_bonus = False
|
||||
second_bonus = False
|
||||
third_bonus = False
|
||||
fourth_bonus = False
|
||||
fifth_bonus = False
|
||||
sixth_bonus = False
|
||||
seventh_bonus = False
|
||||
eight_bonus = False
|
||||
ctx: SC2Context = None
|
||||
mission_id = 0
|
||||
|
||||
can_read_game = False
|
||||
|
||||
last_received_update = 0
|
||||
|
||||
def __init__(self, ctx: SC2Context, mission_id):
|
||||
self.ctx = ctx
|
||||
self.mission_id = mission_id
|
||||
|
||||
super(ArchipelagoBot, self).__init__()
|
||||
|
||||
async def on_step(self, iteration: int):
|
||||
game_state = 0
|
||||
if iteration == 0:
|
||||
start_items = calculate_items(self.ctx.items_received)
|
||||
difficulty = calc_difficulty(self.ctx.difficulty)
|
||||
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
|
||||
difficulty,
|
||||
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
|
||||
start_items[5], start_items[6], start_items[7], start_items[8], start_items[9],
|
||||
self.ctx.all_in_choice, start_items[10]))
|
||||
self.last_received_update = len(self.ctx.items_received)
|
||||
|
||||
else:
|
||||
if self.ctx.announcement_pos < len(self.ctx.announcements):
|
||||
index = 0
|
||||
message = ""
|
||||
while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
|
||||
message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
|
||||
index += 1
|
||||
|
||||
index = 0
|
||||
start_rem_pos = -1
|
||||
# Remove unneeded [Color] tags
|
||||
while index < len(message):
|
||||
if message[index] == '[':
|
||||
start_rem_pos = index
|
||||
index += 1
|
||||
elif message[index] == ']' and start_rem_pos > -1:
|
||||
temp_msg = ""
|
||||
|
||||
if start_rem_pos > 0:
|
||||
temp_msg = message[:start_rem_pos]
|
||||
if index < len(message) - 1:
|
||||
temp_msg += message[index + 1:]
|
||||
|
||||
message = temp_msg
|
||||
index += start_rem_pos - index
|
||||
start_rem_pos = -1
|
||||
else:
|
||||
index += 1
|
||||
|
||||
await self.chat_send("SendMessage " + message)
|
||||
self.ctx.announcement_pos += 1
|
||||
|
||||
# Archipelago reads the health
|
||||
for unit in self.all_own_units():
|
||||
if unit.health_max == 38281:
|
||||
game_state = int(38281 - unit.health)
|
||||
self.can_read_game = True
|
||||
|
||||
if iteration == 160 and not game_state & 1:
|
||||
await self.chat_send("SendMessage Warning: Archipelago unable to connect or has lost connection to " +
|
||||
"Starcraft 2 (This is likely a map issue)")
|
||||
|
||||
if self.last_received_update < len(self.ctx.items_received):
|
||||
current_items = calculate_items(self.ctx.items_received)
|
||||
await self.chat_send("UpdateTech {} {} {} {} {} {} {} {}".format(
|
||||
current_items[0], current_items[1], current_items[2], current_items[3], current_items[4],
|
||||
current_items[5], current_items[6], current_items[7]))
|
||||
self.last_received_update = len(self.ctx.items_received)
|
||||
|
||||
if game_state & 1:
|
||||
if not self.game_running:
|
||||
print("Archipelago Connected")
|
||||
self.game_running = True
|
||||
|
||||
if self.can_read_game:
|
||||
if game_state & (1 << 1) and not self.mission_completed:
|
||||
if self.mission_id != 29:
|
||||
print("Mission Completed")
|
||||
await self.ctx.send_msgs([
|
||||
{"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
|
||||
self.mission_completed = True
|
||||
else:
|
||||
print("Game Complete")
|
||||
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
|
||||
self.mission_completed = True
|
||||
|
||||
if game_state & (1 << 2) and not self.first_bonus:
|
||||
print("1st Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
|
||||
self.first_bonus = True
|
||||
|
||||
if not self.second_bonus and game_state & (1 << 3):
|
||||
print("2nd Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
|
||||
self.second_bonus = True
|
||||
|
||||
if not self.third_bonus and game_state & (1 << 4):
|
||||
print("3rd Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
|
||||
self.third_bonus = True
|
||||
|
||||
if not self.fourth_bonus and game_state & (1 << 5):
|
||||
print("4th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
|
||||
self.fourth_bonus = True
|
||||
|
||||
if not self.fifth_bonus and game_state & (1 << 6):
|
||||
print("5th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
|
||||
self.fifth_bonus = True
|
||||
|
||||
if not self.sixth_bonus and game_state & (1 << 7):
|
||||
print("6th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
|
||||
self.sixth_bonus = True
|
||||
|
||||
if not self.seventh_bonus and game_state & (1 << 8):
|
||||
print("6th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
|
||||
self.seventh_bonus = True
|
||||
|
||||
if not self.eight_bonus and game_state & (1 << 9):
|
||||
print("6th Bonus Collected")
|
||||
await self.ctx.send_msgs(
|
||||
[{"cmd": 'LocationChecks',
|
||||
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
|
||||
self.eight_bonus = True
|
||||
|
||||
else:
|
||||
await self.chat_send("LostConnection - Lost connection to game.")
|
||||
|
||||
|
||||
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
|
||||
objectives_complete = 0
|
||||
|
||||
if missions_info[mission].extra_locations > 0:
|
||||
for i in range(missions_info[mission].extra_locations):
|
||||
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
|
||||
objectives_complete += 1
|
||||
else:
|
||||
unfinished_locations[mission].append(ctx.location_names[
|
||||
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
|
||||
|
||||
return objectives_complete
|
||||
|
||||
else:
|
||||
return -1
|
||||
|
||||
|
||||
def request_unfinished_missions(locations_done, location_table, ui, ctx):
|
||||
if location_table:
|
||||
message = "Unfinished Missions: "
|
||||
unlocks = initialize_blank_mission_dict(location_table)
|
||||
unfinished_locations = initialize_blank_mission_dict(location_table)
|
||||
|
||||
unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
|
||||
unfinished_locations=unfinished_locations)
|
||||
|
||||
message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
|
||||
mark_up_objectives(
|
||||
f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
|
||||
ctx, unfinished_locations, mission)
|
||||
for mission in unfinished_missions)
|
||||
|
||||
if ui:
|
||||
ui.log_panels['All'].on_message_markup(message)
|
||||
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||
|
||||
|
||||
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
|
||||
available_missions=[]):
|
||||
unfinished_missions = []
|
||||
locations_completed = []
|
||||
|
||||
if not unlocks:
|
||||
unlocks = initialize_blank_mission_dict(locations)
|
||||
|
||||
if not unfinished_locations:
|
||||
unfinished_locations = initialize_blank_mission_dict(locations)
|
||||
|
||||
if len(available_missions) > 0:
|
||||
available_missions = []
|
||||
|
||||
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
|
||||
|
||||
for name in available_missions:
|
||||
if not locations[name].extra_locations == -1:
|
||||
objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
|
||||
|
||||
if objectives_completed < locations[name].extra_locations:
|
||||
unfinished_missions.append(name)
|
||||
locations_completed.append(objectives_completed)
|
||||
|
||||
else:
|
||||
unfinished_missions.append(name)
|
||||
locations_completed.append(-1)
|
||||
|
||||
return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
|
||||
|
||||
|
||||
def is_mission_available(mission_id_to_check, locations_done, locations):
|
||||
unfinished_missions = calc_available_missions(locations_done, locations)
|
||||
|
||||
return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
|
||||
|
||||
|
||||
def mark_up_mission_name(mission, location_table, ui, unlock_table):
|
||||
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
|
||||
|
||||
if location_table[mission].completion_critical:
|
||||
if ui:
|
||||
message = "[color=AF99EF]" + mission + "[/color]"
|
||||
else:
|
||||
message = "*" + mission + "*"
|
||||
else:
|
||||
message = mission
|
||||
|
||||
if ui:
|
||||
unlocks = unlock_table[mission]
|
||||
|
||||
if len(unlocks) > 0:
|
||||
pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
|
||||
pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
|
||||
pre_message += f"]"
|
||||
message = pre_message + message + "[/ref]"
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def mark_up_objectives(message, ctx, unfinished_locations, mission):
|
||||
formatted_message = message
|
||||
|
||||
if ctx.ui:
|
||||
locations = unfinished_locations[mission]
|
||||
|
||||
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
|
||||
pre_message += "<br>".join(location for location in locations)
|
||||
pre_message += f"]"
|
||||
formatted_message = pre_message + message + "[/ref]"
|
||||
|
||||
return formatted_message
|
||||
|
||||
|
||||
def request_available_missions(locations_done, location_table, ui):
|
||||
if location_table:
|
||||
message = "Available Missions: "
|
||||
|
||||
# Initialize mission unlock table
|
||||
unlocks = initialize_blank_mission_dict(location_table)
|
||||
|
||||
missions = calc_available_missions(locations_done, location_table, unlocks)
|
||||
message += \
|
||||
", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
|
||||
for mission in missions)
|
||||
|
||||
if ui:
|
||||
ui.log_panels['All'].on_message_markup(message)
|
||||
ui.log_panels['Starcraft2'].on_message_markup(message)
|
||||
else:
|
||||
sc2_logger.info(message)
|
||||
else:
|
||||
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
|
||||
|
||||
|
||||
def calc_available_missions(locations_done, locations, unlocks=None):
|
||||
available_missions = []
|
||||
missions_complete = 0
|
||||
|
||||
# Get number of missions completed
|
||||
for loc in locations_done:
|
||||
if loc % 100 == 0:
|
||||
missions_complete += 1
|
||||
|
||||
for name in locations:
|
||||
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
|
||||
if unlocks:
|
||||
for unlock in locations[name].required_world:
|
||||
unlocks[list(locations)[unlock-1]].append(name)
|
||||
|
||||
if mission_reqs_completed(name, missions_complete, locations_done, locations):
|
||||
available_missions.append(name)
|
||||
|
||||
return available_missions
|
||||
|
||||
|
||||
def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
|
||||
"""Returns a bool signifying if the mission has all requirements complete and can be done
|
||||
|
||||
Keyword arguments:
|
||||
locations_to_check -- the mission string name to check
|
||||
missions_complete -- an int of how many missions have been completed
|
||||
locations_done -- a list of the location ids that have been complete
|
||||
locations -- a dict of MissionInfo for mission requirements for this world"""
|
||||
if len(locations[location_to_check].required_world) >= 1:
|
||||
# A check for when the requirements are being or'd
|
||||
or_success = False
|
||||
|
||||
# Loop through required missions
|
||||
for req_mission in locations[location_to_check].required_world:
|
||||
req_success = True
|
||||
|
||||
# Check if required mission has been completed
|
||||
if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
|
||||
if not locations[location_to_check].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
|
||||
if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
|
||||
locations):
|
||||
if not locations[location_to_check].or_requirements:
|
||||
return False
|
||||
else:
|
||||
req_success = False
|
||||
|
||||
# If requirement check succeeded mark or as satisfied
|
||||
if locations[location_to_check].or_requirements and req_success:
|
||||
or_success = True
|
||||
|
||||
if locations[location_to_check].or_requirements:
|
||||
# Return false if or requirements not met
|
||||
if not or_success:
|
||||
return False
|
||||
|
||||
# Check number of missions
|
||||
if missions_complete >= locations[location_to_check].number:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def initialize_blank_mission_dict(location_table):
|
||||
unlocks = {}
|
||||
|
||||
for mission in list(location_table):
|
||||
unlocks[mission] = []
|
||||
|
||||
return unlocks
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
163
Utils.py
@@ -12,7 +12,12 @@ import io
|
||||
import collections
|
||||
import importlib
|
||||
import logging
|
||||
from tkinter import Tk
|
||||
import decimal
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tkinter import Tk
|
||||
else:
|
||||
Tk = typing.Any
|
||||
|
||||
|
||||
def tuplize_version(version: str) -> Version:
|
||||
@@ -25,10 +30,15 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.3.3"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
from yaml import load, dump, SafeLoader
|
||||
is_linux = sys.platform.startswith('linux')
|
||||
is_macos = sys.platform == 'darwin'
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
|
||||
import jellyfish
|
||||
from yaml import load, load_all, dump, SafeLoader
|
||||
|
||||
try:
|
||||
from yaml import CLoader as Loader
|
||||
@@ -36,41 +46,44 @@ except ImportError:
|
||||
from yaml import Loader
|
||||
|
||||
|
||||
def int16_as_bytes(value):
|
||||
def int16_as_bytes(value: int) -> typing.List[int]:
|
||||
value = value & 0xFFFF
|
||||
return [value & 0xFF, (value >> 8) & 0xFF]
|
||||
|
||||
|
||||
def int32_as_bytes(value):
|
||||
def int32_as_bytes(value: int) -> typing.List[int]:
|
||||
value = value & 0xFFFFFFFF
|
||||
return [value & 0xFF, (value >> 8) & 0xFF, (value >> 16) & 0xFF, (value >> 24) & 0xFF]
|
||||
|
||||
|
||||
def pc_to_snes(value):
|
||||
def pc_to_snes(value: int) -> int:
|
||||
return ((value << 1) & 0x7F0000) | (value & 0x7FFF) | 0x8000
|
||||
|
||||
|
||||
def snes_to_pc(value):
|
||||
def snes_to_pc(value: int) -> int:
|
||||
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
|
||||
|
||||
|
||||
def cache_argsless(function):
|
||||
if function.__code__.co_argcount:
|
||||
raise Exception("Can only cache 0 argument functions with this cache.")
|
||||
RetType = typing.TypeVar("RetType")
|
||||
|
||||
result = sentinel = object()
|
||||
|
||||
def _wrap():
|
||||
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
|
||||
assert not function.__code__.co_argcount, "Can only cache 0 argument functions with this cache."
|
||||
|
||||
sentinel = object()
|
||||
result: typing.Union[object, RetType] = sentinel
|
||||
|
||||
def _wrap() -> RetType:
|
||||
nonlocal result
|
||||
if result is sentinel:
|
||||
result = function()
|
||||
return result
|
||||
return typing.cast(RetType, result)
|
||||
|
||||
return _wrap
|
||||
|
||||
|
||||
def is_frozen() -> bool:
|
||||
return getattr(sys, 'frozen', False)
|
||||
return typing.cast(bool, getattr(sys, 'frozen', False))
|
||||
|
||||
|
||||
def local_path(*path: str) -> str:
|
||||
@@ -159,6 +172,7 @@ class UniqueKeyLoader(SafeLoader):
|
||||
|
||||
|
||||
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
|
||||
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
|
||||
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
|
||||
|
||||
|
||||
@@ -246,7 +260,7 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core.exe"),
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
@@ -258,7 +272,8 @@ def get_default_options() -> dict:
|
||||
},
|
||||
"minecraft_options": {
|
||||
"forge_directory": "Minecraft Forge server",
|
||||
"max_heap_size": "2G"
|
||||
"max_heap_size": "2G",
|
||||
"release_channel": "release"
|
||||
},
|
||||
"oot_options": {
|
||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||
@@ -416,7 +431,8 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
|
||||
|
||||
|
||||
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 = ""):
|
||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
exception_logger: typing.Optional[str] = None):
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = user_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
@@ -452,6 +468,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
logging.info(f"Archipelago ({__version__}) logging initialized.")
|
||||
|
||||
|
||||
def stream_input(stream, queue):
|
||||
def queuer():
|
||||
@@ -477,13 +495,112 @@ class VersionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")):
|
||||
n = 0
|
||||
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
|
||||
text = ""
|
||||
max_label = len(labels) - 1
|
||||
while index > max_label:
|
||||
text += labels[-1]
|
||||
index -= max_label
|
||||
return labels[index] + text
|
||||
|
||||
while value > power:
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
|
||||
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
|
||||
n = 0
|
||||
value = decimal.Decimal(value)
|
||||
while value >= power:
|
||||
value /= power
|
||||
n += 1
|
||||
if type(value) == int:
|
||||
return f"{value} {power_labels[n]}"
|
||||
|
||||
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
|
||||
|
||||
|
||||
def get_fuzzy_ratio(word1: str, word2: str) -> float:
|
||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||
/ max(len(word1), len(word2)))
|
||||
|
||||
|
||||
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
|
||||
-> typing.List[typing.Tuple[str, int]]:
|
||||
limit: int = limit if limit else len(wordlist)
|
||||
return list(
|
||||
map(
|
||||
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
|
||||
sorted(
|
||||
map(lambda candidate:
|
||||
(candidate, get_fuzzy_ratio(input_word, candidate)),
|
||||
wordlist),
|
||||
key=lambda element: element[1],
|
||||
reverse=True)[0:limit]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
|
||||
-> typing.Optional[str]:
|
||||
def run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
|
||||
|
||||
if is_linux:
|
||||
# prefer native dialog
|
||||
kdialog = shutil.which('kdialog')
|
||||
if kdialog:
|
||||
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
|
||||
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
|
||||
zenity = shutil.which('zenity')
|
||||
if zenity:
|
||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||
return run(zenity, f'--title={title}', '--file-selection', *z_filters)
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
import tkinter.filedialog
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because open_filename was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
return f"{value:0.3f} {power_labels[n]}"
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes))
|
||||
|
||||
|
||||
def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
def run(*args: str):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
|
||||
|
||||
def is_kivy_running():
|
||||
if 'kivy' in sys.modules:
|
||||
from kivy.app import App
|
||||
return App.get_running_app() is not None
|
||||
return False
|
||||
|
||||
if is_kivy_running():
|
||||
from kvui import MessageBox
|
||||
MessageBox(title, text, error).open()
|
||||
return
|
||||
|
||||
if is_linux and not 'tkinter' in sys.modules:
|
||||
# prefer native dialog
|
||||
kdialog = shutil.which('kdialog')
|
||||
if kdialog:
|
||||
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
|
||||
zenity = shutil.which('zenity')
|
||||
if zenity:
|
||||
return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
|
||||
|
||||
# fall back to tk
|
||||
try:
|
||||
import tkinter
|
||||
from tkinter.messagebox import showerror, showinfo
|
||||
except Exception as e:
|
||||
logging.error('Could not load tkinter, which is likely not installed. '
|
||||
f'This attempt was made because messagebox was used for "{title}".')
|
||||
raise e
|
||||
else:
|
||||
root = tkinter.Tk()
|
||||
root.withdraw()
|
||||
showerror(title, text) if error else showinfo(title, text)
|
||||
root.update()
|
||||
|
||||
58
WebHost.py
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
import sys
|
||||
import multiprocessing
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -20,6 +22,8 @@ from WebHostLib.autolauncher import autohost, autogen
|
||||
from WebHostLib.lttpsprites import update_sprites_lttp
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
configpath = os.path.abspath("config.yaml")
|
||||
if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
configpath = os.path.abspath(Utils.user_path('config.yaml'))
|
||||
@@ -36,13 +40,54 @@ def get_app():
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file():
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
with open(os.path.join("WebHostLib", "static", "assets", "tutorial", "tutorials.json")) as source:
|
||||
data = json.load(source)
|
||||
data = sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
||||
with open(os.path.join("WebHostLib", "static", "generated", "tutorials.json"), "w") as target:
|
||||
json.dump(data, target)
|
||||
import shutil
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
|
||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
|
||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, 'tutorials': []}
|
||||
for tutorial in world.web.tutorials:
|
||||
# build dict for the json file
|
||||
current_tutorial = {
|
||||
'name': tutorial.tutorial_name,
|
||||
'description': tutorial.description,
|
||||
'files': [{
|
||||
'language': tutorial.language,
|
||||
'filename': game + '/' + tutorial.file_name,
|
||||
'link': f'{game}/{tutorial.link}',
|
||||
'authors': tutorial.authors
|
||||
}]
|
||||
}
|
||||
|
||||
# check if the name of the current guide exists already
|
||||
for guide in game_data['tutorials']:
|
||||
if guide and tutorial.tutorial_name == guide['name']:
|
||||
guide['files'].append(current_tutorial['files'][0])
|
||||
break
|
||||
else:
|
||||
game_data['tutorials'].append(current_tutorial)
|
||||
|
||||
data.append(game_data)
|
||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||
generic_data = {}
|
||||
for games in data:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
generic_data = data.pop(data.index(games))
|
||||
sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower())
|
||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||
return sorted_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -63,7 +108,6 @@ if __name__ == "__main__":
|
||||
autogen(app.config)
|
||||
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()
|
||||
if app.config["DEBUG"]:
|
||||
autohost(app.config)
|
||||
app.run(debug=True, port=app.config["PORT"])
|
||||
else:
|
||||
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
|
||||
|
||||
@@ -46,7 +46,7 @@ app.config["PONY"] = {
|
||||
'create_db': True
|
||||
}
|
||||
app.config["MAX_ROLL"] = 20
|
||||
app.config["CACHE_TYPE"] = "simple"
|
||||
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.config["PATCH_TARGET"] = "archipelago.gg"
|
||||
|
||||
@@ -129,6 +129,10 @@ def tutorial(game, file, lang):
|
||||
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
@@ -137,6 +141,11 @@ def faq(lang):
|
||||
return render_template("faq.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
def terms(lang):
|
||||
return render_template("glossary.html", lang=lang)
|
||||
|
||||
|
||||
@app.route('/seed/<suuid:seed>')
|
||||
def view_seed(seed: UUID):
|
||||
seed = Seed.get(id=seed)
|
||||
@@ -166,7 +175,12 @@ def _read_log(path: str):
|
||||
|
||||
@app.route('/log/<suuid:room>')
|
||||
def display_log(room: UUID):
|
||||
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
room = Room.get(id=room)
|
||||
if room is None:
|
||||
return abort(404)
|
||||
if room.owner == session["_id"]:
|
||||
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
|
||||
return "Access Denied", 403
|
||||
|
||||
|
||||
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
|
||||
@@ -207,7 +221,17 @@ def get_datapackge():
|
||||
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
|
||||
|
||||
|
||||
@app.route('/index')
|
||||
@app.route('/sitemap')
|
||||
def get_sitemap():
|
||||
available_games = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
available_games.append(game)
|
||||
return render_template("siteMap.html", games=available_games)
|
||||
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -45,7 +45,7 @@ def generate_api():
|
||||
"detail": app.config["MAX_ROLL"]}, 409
|
||||
meta = get_meta(meta_options_source)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options)
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return {"text": str(results),
|
||||
"detail": results}, 400
|
||||
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
import logging
|
||||
import json
|
||||
import multiprocessing
|
||||
import threading
|
||||
from datetime import timedelta, datetime
|
||||
import concurrent.futures
|
||||
|
||||
import sys
|
||||
import typing
|
||||
import time
|
||||
@@ -17,6 +18,7 @@ from Utils import restricted_loads
|
||||
class CommonLocker():
|
||||
"""Uses a file lock to signal that something is already running"""
|
||||
lock_folder = "file_locks"
|
||||
|
||||
def __init__(self, lockname: str, folder=None):
|
||||
if folder:
|
||||
self.lock_folder = folder
|
||||
@@ -53,7 +55,7 @@ else: # unix
|
||||
def __enter__(self):
|
||||
try:
|
||||
self.fp = open(self.lockfile, "wb")
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX)
|
||||
fcntl.flock(self.fp.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
raise AlreadyRunningException() from e
|
||||
|
||||
@@ -110,6 +112,7 @@ def autohost(config: dict):
|
||||
def keep_running():
|
||||
try:
|
||||
with Locker("autohost"):
|
||||
run_guardian()
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
with db_session:
|
||||
@@ -162,16 +165,15 @@ def autogen(config: dict):
|
||||
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
||||
|
||||
|
||||
multiworlds = {}
|
||||
|
||||
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
|
||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
self.process: typing.Optional[multiprocessing.Process] = None
|
||||
multiworlds[self.room_id] = self
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
|
||||
def start(self):
|
||||
@@ -179,21 +181,58 @@ class MultiworldInstance():
|
||||
return False
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
self.process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig),
|
||||
name="MultiHost")
|
||||
self.process.start()
|
||||
self.guardian = guardians.submit(self._collect)
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
self.process = process
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
self.process = None
|
||||
|
||||
def _collect(self):
|
||||
def done(self):
|
||||
return self.process and not self.process.is_alive()
|
||||
|
||||
def collect(self):
|
||||
self.process.join() # wait for process to finish
|
||||
self.process = None
|
||||
self.guardian = None
|
||||
|
||||
|
||||
guardian = None
|
||||
guardian_lock = threading.Lock()
|
||||
|
||||
|
||||
def run_guardian():
|
||||
global guardian
|
||||
global multiworlds
|
||||
with guardian_lock:
|
||||
if not guardian:
|
||||
try:
|
||||
import resource
|
||||
except ModuleNotFoundError:
|
||||
pass # unix only module
|
||||
else:
|
||||
# Each Server is another file handle, so request as many as we can from the system
|
||||
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
|
||||
# set soft limit to hard limit
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
|
||||
|
||||
def guard():
|
||||
while 1:
|
||||
time.sleep(1)
|
||||
done = []
|
||||
with guardian_lock:
|
||||
for key, instance in multiworlds.items():
|
||||
if instance.done():
|
||||
instance.collect()
|
||||
done.append(key)
|
||||
for key in done:
|
||||
del (multiworlds[key])
|
||||
|
||||
guardian = threading.Thread(name="Guardian", target=guard)
|
||||
|
||||
|
||||
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
|
||||
|
||||
@@ -13,11 +13,11 @@ def allowed_file(filename):
|
||||
|
||||
|
||||
from Generate import roll_settings
|
||||
from Utils import parse_yaml
|
||||
from Utils import parse_yamls
|
||||
|
||||
|
||||
@app.route('/mysterycheck', methods=['GET', 'POST'])
|
||||
def mysterycheck():
|
||||
@app.route('/check', methods=['GET', 'POST'])
|
||||
def check():
|
||||
if request.method == 'POST':
|
||||
# check if the post request has the file part
|
||||
if 'file' not in request.files:
|
||||
@@ -30,10 +30,14 @@ def mysterycheck():
|
||||
else:
|
||||
results, _ = roll_options(options)
|
||||
return render_template("checkResult.html", results=results)
|
||||
|
||||
return render_template("check.html")
|
||||
|
||||
|
||||
@app.route('/mysterycheck')
|
||||
def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
options = {}
|
||||
# if user does not select file, browser also
|
||||
@@ -58,21 +62,29 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
return options
|
||||
|
||||
|
||||
def roll_options(options: Dict[str, Union[dict, str]]) -> Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = set(plando_options)
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
try:
|
||||
if type(text) is dict:
|
||||
yaml_data = text
|
||||
yaml_datas = (text, )
|
||||
else:
|
||||
yaml_data = parse_yaml(text)
|
||||
yaml_datas = tuple(parse_yamls(text))
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to parse YAML data in {filename}: {e}"
|
||||
else:
|
||||
try:
|
||||
rolled_results[filename] = roll_settings(yaml_data,
|
||||
plando_options={"bosses", "items", "connections", "texts"})
|
||||
if len(yaml_datas) == 1:
|
||||
rolled_results[filename] = roll_settings(yaml_datas[0],
|
||||
plando_options=plando_options)
|
||||
else:
|
||||
for i, yaml_data in enumerate(yaml_datas):
|
||||
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
|
||||
plando_options=plando_options)
|
||||
except Exception as e:
|
||||
results[filename] = f"Failed to generate mystery in {filename}: {e}"
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import websockets
|
||||
import asyncio
|
||||
import socket
|
||||
@@ -9,6 +8,7 @@ import threading
|
||||
import time
|
||||
import random
|
||||
import pickle
|
||||
import logging
|
||||
|
||||
import Utils
|
||||
from .models import *
|
||||
@@ -128,15 +128,21 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
ping_interval=None)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
for wssocket in ctx.server.ws_server.sockets:
|
||||
socketname = wssocket.getsockname()
|
||||
if wssocket.family == socket.AF_INET6:
|
||||
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = socketname[1]
|
||||
# Prefer IPv4, as most users seem to not have working ipv6 support
|
||||
if not port:
|
||||
port = socketname[1]
|
||||
elif wssocket.family == socket.AF_INET:
|
||||
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
|
||||
port = socketname[1]
|
||||
if port:
|
||||
with db_session:
|
||||
room = Room.get(id=ctx.room_id)
|
||||
room.last_port = port
|
||||
with db_session:
|
||||
ctx.auto_shutdown = Room.get(id=room_id).timeout
|
||||
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
|
||||
@@ -146,6 +152,3 @@ def run_server_process(room_id, ponyconfig: dict):
|
||||
from .autolauncher import Locker
|
||||
with Locker(room_id):
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
from WebHostLib import LOGS_FOLDER
|
||||
|
||||
@@ -25,7 +25,7 @@ def download_patch(room_id, patch_id):
|
||||
with zipfile.ZipFile(filelike, "a") as zf:
|
||||
with zf.open("archipelago.json", "r") as f:
|
||||
manifest = json.load(f)
|
||||
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}"
|
||||
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
|
||||
with zipfile.ZipFile(new_file, "w") as new_zip:
|
||||
for file in zf.infolist():
|
||||
if file.filename == "archipelago.json":
|
||||
@@ -55,7 +55,7 @@ def download_spoiler(seed_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()
|
||||
patch.player_id == player_id).first()
|
||||
|
||||
if not slot_data:
|
||||
return "Slot Data not found"
|
||||
@@ -71,7 +71,7 @@ def download_slot_file(room_id, player_id: int):
|
||||
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"
|
||||
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"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
@@ -82,6 +82,7 @@ def download_slot_file(room_id, player_id: int):
|
||||
return "Game download not supported."
|
||||
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
|
||||
|
||||
|
||||
@app.route("/templates")
|
||||
@cache.cached()
|
||||
def list_yaml_templates():
|
||||
@@ -90,4 +91,4 @@ def list_yaml_templates():
|
||||
for world_name, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
files.append(world_name)
|
||||
return render_template("templates.html", files=files)
|
||||
return render_template("templates.html", files=files)
|
||||
|
||||
@@ -22,11 +22,22 @@ from .upload import upload_zip_to_db
|
||||
|
||||
|
||||
def get_meta(options_source: dict) -> dict:
|
||||
plando_options = {
|
||||
options_source.get("plando_bosses", ""),
|
||||
options_source.get("plando_items", ""),
|
||||
options_source.get("plando_connections", ""),
|
||||
options_source.get("plando_texts", "")
|
||||
}
|
||||
plando_options -= {""}
|
||||
|
||||
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"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
"server_password": options_source.get("server_password", None),
|
||||
"plando_options": list(plando_options)
|
||||
}
|
||||
return meta
|
||||
|
||||
@@ -44,14 +55,13 @@ def generate(race=False):
|
||||
if type(options) == str:
|
||||
flash(options)
|
||||
else:
|
||||
results, gen_options = roll_options(options)
|
||||
# get form data -> server settings
|
||||
meta = get_meta(request.form)
|
||||
meta["race"] = race
|
||||
results, gen_options = roll_options(options, meta["plando_options"])
|
||||
|
||||
if race:
|
||||
meta["item_cheat"] = False
|
||||
meta["remaining"] = False
|
||||
meta["remaining_mode"] = "disabled"
|
||||
|
||||
if any(type(result) == str for result in results.values()):
|
||||
return render_template("checkResult.html", results=results)
|
||||
@@ -89,6 +99,8 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
||||
meta.setdefault("hint_cost", 10)
|
||||
race = meta.get("race", False)
|
||||
del (meta["race"])
|
||||
plando_options = meta.get("plando", {"bosses", "items", "connections", "texts"})
|
||||
del (meta["plando_options"])
|
||||
try:
|
||||
target = tempfile.TemporaryDirectory()
|
||||
playercount = len(gen_options)
|
||||
@@ -108,6 +120,7 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = ", ".join(plando_options)
|
||||
|
||||
name_counter = Counter()
|
||||
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
|
||||
|
||||
@@ -4,6 +4,7 @@ from Utils import __version__
|
||||
from jinja2 import Template
|
||||
import yaml
|
||||
import json
|
||||
import typing
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import Options
|
||||
@@ -17,13 +18,30 @@ handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hin
|
||||
def create():
|
||||
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
|
||||
|
||||
def dictify_range(option):
|
||||
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50}
|
||||
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
|
||||
data = {}
|
||||
special = getattr(option, "special_range_cutoff", None)
|
||||
if special is not None:
|
||||
data[special] = 0
|
||||
data.update({
|
||||
option.range_start: 0,
|
||||
option.range_end: 0,
|
||||
"random": 0, "random-low": 0, "random-high": 0,
|
||||
option.default: 50
|
||||
})
|
||||
notes = {
|
||||
special: "minimum value without special meaning",
|
||||
option.range_start: "minimum value",
|
||||
option.range_end: "maximum value"
|
||||
}
|
||||
|
||||
for name, number in getattr(option, "special_range_names", {}).items():
|
||||
if number in data:
|
||||
data[name] = data[number]
|
||||
del data[number]
|
||||
else:
|
||||
data[name] = 0
|
||||
|
||||
return data, notes
|
||||
|
||||
def default_converter(default_value):
|
||||
@@ -89,16 +107,26 @@ def create():
|
||||
"value": "random",
|
||||
})
|
||||
|
||||
if option.default == "random":
|
||||
this_option["defaultValue"] = "random"
|
||||
|
||||
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
|
||||
game_options[option_name] = {
|
||||
"type": "range",
|
||||
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
|
||||
"description": option.__doc__ if option.__doc__ else "Please document me!",
|
||||
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
|
||||
"defaultValue": option.default if hasattr(
|
||||
option, "default") and option.default != "random" else option.range_start,
|
||||
"min": option.range_start,
|
||||
"max": option.range_end,
|
||||
}
|
||||
|
||||
if hasattr(option, "special_range_names"):
|
||||
game_options[option_name]["type"] = 'special_range'
|
||||
game_options[option_name]["value_names"] = {}
|
||||
for key, val in option.special_range_names.items():
|
||||
game_options[option_name]["value_names"][key] = val
|
||||
|
||||
elif getattr(option, "verify_item_name", False):
|
||||
game_options[option_name] = {
|
||||
"type": "items-list",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
flask>=2.0.3
|
||||
flask>=2.1.2
|
||||
pony>=0.7.16
|
||||
waitress>=2.1.0
|
||||
flask-caching>=1.10.1
|
||||
Flask-Compress>=1.11
|
||||
Flask-Limiter>=2.2.0
|
||||
waitress>=2.1.1
|
||||
flask-caching>=1.11.1
|
||||
Flask-Compress>=1.12
|
||||
Flask-Limiter>=2.4.6
|
||||
bokeh>=2.4.3
|
||||
@@ -49,6 +49,12 @@ If you are ready to start randomizing games, or want to start playing your favor
|
||||
our discord server at the [Archipelago Discord](https://discord.gg/archipelago). There are always people ready to answer
|
||||
any questions you might have.
|
||||
|
||||
## What are some common terms I should know?
|
||||
|
||||
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
|
||||
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
|
||||
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
|
||||
|
||||
## 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
|
||||
|
||||
94
WebHostLib/static/assets/faq/glossary_en.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Multiworld Glossary
|
||||
|
||||
There are a lot of common terms used when playing in different game randomizer communities and in multiworld as well.
|
||||
This document serves as a lookup for common terms that may be used by users in the community or in various other
|
||||
documentation.
|
||||
|
||||
## Item
|
||||
Items are what get shuffled around in your world or other worlds that you then receive. This could be a sword, a stat
|
||||
upgrade, a spell, or any other potential receivable for your game.
|
||||
|
||||
## Location
|
||||
Locations are where items are placed in your game. Whenever you interact with a location, you or another player will
|
||||
then receive an item. A location could be a chest, an enemy drop, a shop purchase, or any other interactable that can
|
||||
contain items in your game.
|
||||
|
||||
## Check
|
||||
A check is a common term for when you "check", or pick up, a location. In terms of Archipelago this is usually used for
|
||||
when a player goes to a location and sends its item, or "checks" the location. Players will often reference their now
|
||||
randomized locations as checks.
|
||||
|
||||
## Slot
|
||||
A slot is the player name and number assigned during generation. The number of slots is equal to the number of players,
|
||||
or "worlds", created. Each name must be unique as these are used to identify the slot user.
|
||||
|
||||
## World
|
||||
World in terms of Archipelago can mean multiple things and is used interchangeably in many situations.
|
||||
* During gameplay, a world is a single instance of a game, occupying one player "slot". However,
|
||||
Archipelago allows multiple players to connect to the same slot; then those players can share a world
|
||||
and complete it cooperatively. For games with native cooperative play, you can also play together and
|
||||
share a world that way, usually with only one player connected to the multiworld.
|
||||
* On the programming side, a world typically represents the package that integrates Archipelago with a
|
||||
particular game. For example this could be the entire `worlds/factorio` directory.
|
||||
|
||||
## RNG
|
||||
Acronym for "Random Number Generator." Archipelago uses its own custom Random object with a unique seed per generation,
|
||||
or, if running from source, a seed can be supplied and this seed will control all randomization during generation as all
|
||||
game worlds will have access to it.
|
||||
|
||||
## Seed
|
||||
A "seed" is a number used to initialize a pseudorandom number generator. Whenever you generate a new game on Archipelago
|
||||
this is a new "seed" as it has unique item placement, and you can create multiple "rooms" on the Archipelago site from a
|
||||
single seed. Using the same seed results in the random placement being the same.
|
||||
|
||||
## Room
|
||||
Whenever you generate a seed on the Archipelago website you will be put on a seed page that contains all the seed info
|
||||
with a link to the spoiler if one exists and will show how many unique rooms exist per seed. Each room has its own
|
||||
unique identifier that is separate from the seed. The room page is where you can find information to connect to the
|
||||
multiworld and download any patches if necessary. If you have a particularly fun or interesting seed, and you want to
|
||||
share it with somebody you can link them to this seed page, where they can generate a new room to play it! For seeds
|
||||
generated with race mode enabled, the seed page will only show rooms created by the unique user so the seed page is
|
||||
perfectly safe to share for racing purposes.
|
||||
|
||||
## Logic
|
||||
Base behavior of all seeds generated by Archipelago is they are expected to be completable based on the requirements of
|
||||
the settings. This is done by using "logic" in order to determine valid locations to place items while still being able
|
||||
to reach said location without this item. For the purposes of the randomizer a location is considered "in logic" if you
|
||||
can reach it with your current toolset of items or skills based on settings. Some players are able to obtain locations
|
||||
"out of logic" by performing various glitches or tricks that the settings may not account for and tend to mention this
|
||||
when sending out an item they obtained this way.
|
||||
|
||||
## Progression
|
||||
Certain items will allow access to more locations and are considered progression items as they "progress" the seed.
|
||||
|
||||
## Trash
|
||||
A term used for "filler" items that have no bearing on the generation and are either marginally useful for the player
|
||||
or useless. These items can be very useful depending on the player but are never very important and as such are usually
|
||||
termed trash.
|
||||
|
||||
## Burger King / BK Mode
|
||||
A term used in multiworlds when a player is unable to continue to progress and is awaiting an item. The term came to be
|
||||
after a player, allegedly, was unable to progress during a multiworld and went to Burger King while waiting to receive
|
||||
items from other players.
|
||||
|
||||
* "Logical BK" is when the player is unable to progress according to the settings of their game but may still be able to do
|
||||
things that would be "out of logic" by the generation.
|
||||
|
||||
* "Hard / full BK" is when the player is completely unable to progress even with tricks they may know and are unable to
|
||||
continue to play, aside from doing something like killing enemies for experience or money.
|
||||
|
||||
## Sphere
|
||||
Archipelago calculates the game playthrough by using a "sphere" system where it has a state for each player and checks
|
||||
to see what the players are able to reach with their current items. Any location that is reachable with the current
|
||||
state of items is a "sphere." For the purposes of Archipelago it starts playthrough calculation by distributing sphere 0
|
||||
items which are items that are either forced in the player's inventory by the game or placed in the `start_inventory` in
|
||||
their settings. Sphere 1 is then all accessible locations the players can reach with all the items they received from
|
||||
sphere 0, or their starting inventory. The playthrough continues in this fashion calculating a number of spheres until
|
||||
all players have completed their goal.
|
||||
|
||||
## Scouts / Scouting
|
||||
In some games there are locations that have visible items even if the item itself is unobtainable at the current time.
|
||||
Some games utilize a scouting feature where when the player "sees" the item it will give a free hint for the item in the
|
||||
client letting the players know what the exact item is, since if the item was for that game it would know but the item
|
||||
being foreign is a lot harder to represent visually.
|
||||
|
||||
@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/gameInfo/` +
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
|
||||
53
WebHostLib/static/assets/glossary.js
Normal file
@@ -0,0 +1,53 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('glossary-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the glossary page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the glossary.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
|
||||
`glossary_${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>`;
|
||||
});
|
||||
});
|
||||
@@ -6,24 +6,24 @@ window.addEventListener('load', () => {
|
||||
// Update game name on page
|
||||
document.getElementById('game-name').innerText = gameName;
|
||||
|
||||
Promise.all([fetchSettingData()]).then((results) => {
|
||||
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]));
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem(`${gameName}-hash`, settingHash);
|
||||
localStorage.removeItem(gameName);
|
||||
settingHash = md5(results[0]);
|
||||
}
|
||||
|
||||
if (settingHash !== md5(results[0])) {
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
|
||||
"them all to default.");
|
||||
document.getElementById('user-message').addEventListener('click', resetSettings);
|
||||
}
|
||||
|
||||
// Page setup
|
||||
createDefaultSettings(results[0]);
|
||||
buildUI(results[0]);
|
||||
createDefaultSettings(results);
|
||||
buildUI(results);
|
||||
adjustHeaderWidth();
|
||||
|
||||
// Event listeners
|
||||
@@ -36,7 +36,8 @@ window.addEventListener('load', () => {
|
||||
const nameInput = document.getElementById('player-name');
|
||||
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
|
||||
nameInput.value = playerSettings.name;
|
||||
}).catch((error) => {
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
const url = new URL(window.location.href);
|
||||
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
|
||||
})
|
||||
@@ -158,9 +159,72 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
element.appendChild(rangeVal);
|
||||
break;
|
||||
|
||||
case 'special_range':
|
||||
element = document.createElement('div');
|
||||
element.classList.add('special-range-container');
|
||||
|
||||
// Build the select element
|
||||
let specialRangeSelect = document.createElement('select');
|
||||
specialRangeSelect.setAttribute('data-key', setting);
|
||||
Object.keys(settings[setting].value_names).forEach((presetName) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = settings[setting].value_names[presetName];
|
||||
specialRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
customOption.innerText = 'Custom';
|
||||
customOption.value = 'custom';
|
||||
customOption.selected = true;
|
||||
specialRangeSelect.appendChild(customOption);
|
||||
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
|
||||
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
|
||||
}
|
||||
|
||||
// Build range element
|
||||
let specialRangeWrapper = document.createElement('div');
|
||||
specialRangeWrapper.classList.add('special-range-wrapper');
|
||||
let specialRange = document.createElement('input');
|
||||
specialRange.setAttribute('type', 'range');
|
||||
specialRange.setAttribute('data-key', setting);
|
||||
specialRange.setAttribute('min', settings[setting].min);
|
||||
specialRange.setAttribute('max', settings[setting].max);
|
||||
specialRange.value = currentSettings[gameName][setting];
|
||||
|
||||
// Build rage value element
|
||||
let specialRangeVal = document.createElement('span');
|
||||
specialRangeVal.classList.add('range-value');
|
||||
specialRangeVal.setAttribute('id', `${setting}-value`);
|
||||
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
|
||||
|
||||
// Configure select event listener
|
||||
specialRangeSelect.addEventListener('change', (event) => {
|
||||
if (event.target.value === 'custom') { return; }
|
||||
|
||||
// Update range slider
|
||||
specialRange.value = event.target.value;
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
|
||||
// Configure range event handler
|
||||
specialRange.addEventListener('change', (event) => {
|
||||
// Update select element
|
||||
specialRangeSelect.value =
|
||||
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
|
||||
parseInt(event.target.value) : 'custom';
|
||||
document.getElementById(`${setting}-value`).innerText = event.target.value;
|
||||
updateGameSetting(event);
|
||||
});
|
||||
|
||||
element.appendChild(specialRangeSelect);
|
||||
specialRangeWrapper.appendChild(specialRange);
|
||||
specialRangeWrapper.appendChild(specialRangeVal);
|
||||
element.appendChild(specialRangeWrapper);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown setting type: ${settings[setting].type}`);
|
||||
console.error(setting);
|
||||
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ window.addEventListener('load', () => {
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/` +
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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.
|
||||
|
||||
[r2modman Page](https://thunderstore.io/package/ebkr/r2modman/)
|
||||
|
||||
### Install Archipelago Mod using r2modman
|
||||
|
||||
You can install the Archipelago mod using r2modman in one of two ways.
|
||||
|
||||
[Archipelago Mod Download Page](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)
|
||||
@@ -1,43 +0,0 @@
|
||||
# VVVVVV MultiWorld Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- VVVVVV (Bought from the [Steam Store](https://store.steampowered.com/app/70300/VVVVVV/) or [GOG Store](https://www.gog.com/game/vvvvvv) Page, NOT Make and Play Edition!)
|
||||
- [V6AP](https://github.com/N00byKing/VVVVVV/releases)
|
||||
|
||||
## Installation and Game Start Procedures
|
||||
|
||||
1. Install VVVVVV through either Steam or GOG
|
||||
2. Go to the page linked for V6AP, and download the latest release
|
||||
3. Unpack the zip file where you have VVVVVV installed.
|
||||
|
||||
# Joining a MultiWorld Game
|
||||
|
||||
To join, set the following launch options: `-v6ap_name YourName -v6ap_ip ServerIP:Port`.
|
||||
Optionally, add `-v6ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
|
||||
The Name in this case is the one specified in your generated .yaml file.
|
||||
In case you are using the Archipelago Website, the IP should be `archipelago.gg`.
|
||||
|
||||
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
|
||||
|
||||
# Playing offline
|
||||
|
||||
To play offline, first generate a seed on the game's settings page.
|
||||
Create a room and download the `.apv6` file, and start the game with the `-v6ap_file FileName` launch argument.
|
||||
|
||||
## Installation Troubleshooting
|
||||
|
||||
Start the game from the command line to view helpful messages regarding V6AP. These will look something like "V6AP: Message"
|
||||
|
||||
### Game no longer starts after copying the .exe
|
||||
|
||||
Most likely you forgot to set the launch options. `-v6ap_name YourName` and `-v6ap_ip ServerIP:Port` are required for startup for Multiworlds, and
|
||||
`-v6ap_file FileName` is required for (offline) singleplayer.
|
||||
If your Name or Password have spaces in them, surround them in quotes.
|
||||
|
||||
## Game Troubleshooting
|
||||
|
||||
### What happens if I lose connection?
|
||||
|
||||
V6AP tries to reconnect a few times, so be patient.
|
||||
Should the problem still be there after about a minute or two, just save and restart the game.
|
||||
@@ -1,559 +0,0 @@
|
||||
[
|
||||
{
|
||||
"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": "Archipelago Website User Guide",
|
||||
"description": "A guide to using the Archipelago website to generate multiworlds or host pre-generated multiworlds.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Archipelago/using_website.md",
|
||||
"link": "Archipelago/using_website/en",
|
||||
"authors": [
|
||||
"alwaysintreble"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Archipelago Server and Client Commands",
|
||||
"description": "A guide detailing the commands available to the user when participating in an Archipelago session.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Archipelago/commands_en.md",
|
||||
"link": "Archipelago/commands/en",
|
||||
"authors": [
|
||||
"jat2980",
|
||||
"Ijwu"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Advanced YAML Guide",
|
||||
"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",
|
||||
"Alchav"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Alchav"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "The Legend of Zelda: A Link to the Past",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago ALttP software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "A Link to the Past/multiworld_en.md",
|
||||
"link": "A Link to the Past/multiworld/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Deutsch",
|
||||
"filename": "A Link to the Past/multiworld_de.md",
|
||||
"link": "A Link to the Past/multiworld/de",
|
||||
"authors": [
|
||||
"Fischfilet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "A Link to the Past/multiworld_es.md",
|
||||
"link": "A Link to the Past/multiworld/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "A Link to the Past/multiworld_fr.md",
|
||||
"link": "A Link to the Past/multiworld/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MSU-1 Setup Tutorial",
|
||||
"description": "A guide to setting up MSU-1, which allows for custom in-game music.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "A Link to the Past/msu1_en.md",
|
||||
"link": "A Link to the Past/msu1/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Español",
|
||||
"filename": "A Link to the Past/msu1_es.md",
|
||||
"link": "A Link to the Past/msu1/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Français",
|
||||
"filename": "A Link to the Past/msu1_fr.md",
|
||||
"link": "A Link to the Past/msu1/fr",
|
||||
"authors": [
|
||||
"Coxla"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Plando Tutorial",
|
||||
"description": "A guide to creating Multiworld Plandos",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "A Link to the Past/plando_en.md",
|
||||
"link": "A Link to the Past/plando/en",
|
||||
"authors": [
|
||||
"Berserker"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "Ocarina of Time/setup_en.md",
|
||||
"link": "Ocarina of Time/setup/en",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "Ocarina of Time/setup_es.md",
|
||||
"link": "Ocarina of Time/setup/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Factorio",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Factorio software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Factorio/setup_en.md",
|
||||
"link": "Factorio/setup/en",
|
||||
"authors": [
|
||||
"Berserker",
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Meritous",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Meritous Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Meritous software on your computer.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Meritous/setup_en.md",
|
||||
"link": "Meritous/setup/en",
|
||||
"authors": [
|
||||
"KewlioMZX"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Minecraft",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Minecraft/minecraft_en.md",
|
||||
"link": "Minecraft/minecraft/en",
|
||||
"authors": [
|
||||
"Kono Tyran"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Spanish",
|
||||
"filename": "Minecraft/minecraft_es.md",
|
||||
"link": "Minecraft/minecraft/es",
|
||||
"authors": [
|
||||
"Edos"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "Swedish",
|
||||
"filename": "Minecraft/minecraft_sv.md",
|
||||
"link": "Minecraft/minecraft/sv",
|
||||
"authors": [
|
||||
"Albinum"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "Risk of Rain 2/setup_en.md",
|
||||
"link": "Risk of Rain 2/setup/en",
|
||||
"authors": [
|
||||
"Ijwu"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Raft",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up Raft integration for Archipelago multiworld games.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Raft/setup_en.md",
|
||||
"link": "Raft/setup/en",
|
||||
"authors": [
|
||||
"SunnyBat",
|
||||
"Awareqwx"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "German",
|
||||
"filename": "Timespinner/setup_de.md",
|
||||
"link": "Timespinner/setup/de",
|
||||
"authors": [
|
||||
"Grrmo",
|
||||
"Fynxes",
|
||||
"Blaze0168"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "SMZ3",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Archipelago Super Metroid and A Link to the Past Crossover randomizer on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "SMZ3/multiworld_en.md",
|
||||
"link": "SMZ3/multiworld/en",
|
||||
"authors": [
|
||||
"lordlou"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "Final Fantasy/multiworld_en.md",
|
||||
"link": "Final Fantasy/multiworld/en",
|
||||
"authors": [
|
||||
"jat2980"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Rogue Legacy",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up the Rogue Legacy Randomizer software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Rogue Legacy/rogue-legacy_en.md",
|
||||
"link": "Rogue Legacy/rogue-legacy/en",
|
||||
"authors": [
|
||||
"Phar"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Slay the Spire",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up Slay the Spire for Archipelago. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Slay the Spire/slay-the-spire_en.md",
|
||||
"link": "Slay the Spire/slay-the-spire/en",
|
||||
"authors": [
|
||||
"Phar"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "Super Mario 64 EX",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up SM64EX for MultiWorld.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "Super Mario 64/setup_en.md",
|
||||
"link": "Super Mario 64/setup/en",
|
||||
"authors": [
|
||||
"N00byKing"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "VVVVVV",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Guide",
|
||||
"description": "A guide to setting up VVVVVV for MultiWorld.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "VVVVVV/setup_en.md",
|
||||
"link": "VVVVVV/setup/en",
|
||||
"authors": [
|
||||
"N00byKing"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "ChecksFinder",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Multiworld Setup Tutorial",
|
||||
"description": "A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers single-player, multiworld, and related software.",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ChecksFinder/checksfinder_en.md",
|
||||
"link": "ChecksFinder/checksfinder/en",
|
||||
"authors": [
|
||||
"Mewlif"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"gameTitle": "ArchipIDLE",
|
||||
"tutorials": [
|
||||
{
|
||||
"name": "Setup Guide",
|
||||
"description": "A guide to playing ArchipIDLE",
|
||||
"files": [
|
||||
{
|
||||
"language": "English",
|
||||
"filename": "ArchipIDLE/guide_en.md",
|
||||
"link": "ArchipIDLE/guide/en",
|
||||
"authors": [
|
||||
"Farrak Kilhn"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -3,9 +3,9 @@ window.addEventListener('load', () => {
|
||||
let settingHash = localStorage.getItem('weighted-settings-hash');
|
||||
if (!settingHash) {
|
||||
// If no hash data has been set before, set it now
|
||||
localStorage.setItem('weighted-settings-hash', md5(JSON.stringify(results)));
|
||||
localStorage.removeItem('weighted-settings');
|
||||
settingHash = md5(JSON.stringify(results));
|
||||
localStorage.setItem('weighted-settings-hash', settingHash);
|
||||
localStorage.removeItem('weighted-settings');
|
||||
}
|
||||
|
||||
if (settingHash !== md5(JSON.stringify(results))) {
|
||||
@@ -77,6 +77,7 @@ const createDefaultSettings = (settingData) => {
|
||||
});
|
||||
break;
|
||||
case 'range':
|
||||
case 'special_range':
|
||||
for (let i = setting.min; i <= setting.max; ++i){
|
||||
newSettings[game][gameSetting][i] =
|
||||
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
|
||||
@@ -285,6 +286,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
case 'special_range':
|
||||
const rangeTable = document.createElement('table');
|
||||
const rangeTbody = document.createElement('tbody');
|
||||
|
||||
@@ -325,6 +327,14 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
|
||||
`below, then press the "Add" button to add a weight for it.<br />Minimum value: ${setting.min}<br />` +
|
||||
`Maximum value: ${setting.max}`;
|
||||
|
||||
if (setting.hasOwnProperty('value_names')) {
|
||||
hintText.innerHTML += '<br /><br />Certain values have special meaning:';
|
||||
Object.keys(setting.value_names).forEach((specialName) => {
|
||||
hintText.innerHTML += `<br />${specialName}: ${setting.value_names[specialName]}`;
|
||||
});
|
||||
}
|
||||
|
||||
settingWrapper.appendChild(hintText);
|
||||
|
||||
const addOptionDiv = document.createElement('div');
|
||||
@@ -487,7 +497,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown setting type for ${game} setting ${setting}: ${settings[setting].type}`);
|
||||
console.error(`Unknown setting type for ${game} setting ${settingName}: ${setting.type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
Copyright 2022 Berserker66 (Fabian Dill)
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
BIN
WebHostLib/static/static/backgrounds/header/stone-header.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
WebHostLib/static/static/backgrounds/stone.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
3
WebHostLib/static/static/branding/LICENSE
Normal file
@@ -0,0 +1,3 @@
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||
@@ -1,4 +1,3 @@
|
||||
Copyright 2022 Berserker66 (Fabian Dill)
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
Copyright 2022 Berserker66 (Fabian Dill)
|
||||
Copyright 2022 LegendaryLinux (Chris Wilson)
|
||||
|
||||
All rights reserved.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
|
||||
#generate-game{
|
||||
width: 700px;
|
||||
width: 990px;
|
||||
min-height: 360px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -25,9 +25,26 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#generate-game-tables-container{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.table-wrapper select {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.table-wrapper input:not([type]){
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
#generate-game-form-wrapper table td{
|
||||
text-align: left;
|
||||
padding-right: 0.5rem;
|
||||
vertical-align: top;
|
||||
width: 230px;
|
||||
}
|
||||
|
||||
#generate-form-button-row{
|
||||
|
||||
@@ -49,7 +49,6 @@ html{
|
||||
font-weight: normal;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 4px #000000;
|
||||
}
|
||||
|
||||
@@ -58,20 +57,14 @@ html{
|
||||
font-weight: normal;
|
||||
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;
|
||||
@@ -137,6 +130,20 @@ html{
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
#player-settings table .special-range-container{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#player-settings table .special-range-wrapper{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
#player-settings table .special-range-wrapper input[type=range]{
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
#player-settings table label{
|
||||
display: block;
|
||||
min-width: 200px;
|
||||
@@ -148,7 +155,7 @@ html{
|
||||
border: none;
|
||||
padding: 3px;
|
||||
font-size: 17px;
|
||||
vertical-align: middle;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1000px), all and (orientation: portrait){
|
||||
|
||||
15
WebHostLib/static/styles/stats.css
Normal file
@@ -0,0 +1,15 @@
|
||||
#charts-wrapper{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chart-container{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(50% - 15px);
|
||||
min-width: 400px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
65
WebHostLib/static/styles/themes/stone.css
Normal file
@@ -0,0 +1,65 @@
|
||||
html{
|
||||
background-image: url('../../static/backgrounds/stone.png');
|
||||
background-repeat: repeat;
|
||||
background-size: 275px 275px;
|
||||
}
|
||||
|
||||
body{
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#base-header {
|
||||
background: url('../../static/backgrounds/header/stone-header.png') repeat-x;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
background-color: rgba(0, 0, 0, 0.66) !important;
|
||||
}
|
||||
|
||||
h1{
|
||||
color: #cccbc3;
|
||||
}
|
||||
|
||||
h2{
|
||||
color: #aad79c;
|
||||
}
|
||||
|
||||
h3, h4, h5,h6{
|
||||
color: #ffffff;
|
||||
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
table th{
|
||||
|
||||
}
|
||||
|
||||
table td{
|
||||
|
||||
}
|
||||
|
||||
a{
|
||||
color: #96e2ff;
|
||||
}
|
||||
|
||||
pre{
|
||||
margin-top: 0;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 6px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
pre code{
|
||||
border: none;
|
||||
}
|
||||
|
||||
code{
|
||||
border-radius: 4px;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
pre, code{
|
||||
background-color: #e4ffdb;
|
||||
border: 1px solid #2d3435;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
|
||||
/** Directional arrow styles */
|
||||
.tooltip:before, [data-tooltip]:before {
|
||||
z-index: 1001;
|
||||
z-index: 10000;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
@@ -55,7 +55,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
|
||||
|
||||
/** Content styles */
|
||||
.tooltip:after, [data-tooltip]:after {
|
||||
z-index: 1000;
|
||||
z-index: 10000;
|
||||
padding: 8px;
|
||||
width: 160px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -44,11 +44,3 @@
|
||||
#user-content table.dataTable{
|
||||
width: unset;
|
||||
}
|
||||
|
||||
table.dataTable thead th{
|
||||
padding: 0 20px 0 0;
|
||||
}
|
||||
|
||||
table.dataTable tbody td{
|
||||
padding: 6px 20px 0 0;
|
||||
}
|
||||
|
||||
76
WebHostLib/stats.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from collections import Counter, defaultdict
|
||||
from itertools import cycle
|
||||
from datetime import datetime, timedelta, date
|
||||
from math import tau
|
||||
|
||||
from bokeh.embed import components
|
||||
from bokeh.palettes import Dark2_8 as palette
|
||||
from bokeh.plotting import figure, ColumnDataSource
|
||||
from bokeh.resources import INLINE
|
||||
from flask import render_template
|
||||
from pony.orm import select
|
||||
|
||||
from . import app, cache
|
||||
from .models import Room
|
||||
|
||||
|
||||
def get_db_data():
|
||||
games_played = defaultdict(Counter)
|
||||
total_games = Counter()
|
||||
cutoff = date.today()-timedelta(days=30000)
|
||||
room: Room
|
||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||
for slot in room.seed.slots:
|
||||
total_games[slot.game] += 1
|
||||
games_played[room.creation_time.date()][slot.game] += 1
|
||||
return total_games, games_played
|
||||
|
||||
|
||||
@app.route('/stats')
|
||||
@cache.memoize(timeout=60*60) # regen once per hour should be plenty
|
||||
def stats():
|
||||
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
|
||||
y_axis_label="Games Played", sizing_mode="scale_both", width=500, height=500)
|
||||
|
||||
total_games, games_played = get_db_data()
|
||||
days = sorted(games_played)
|
||||
|
||||
cyc_palette = cycle(palette)
|
||||
|
||||
for game in sorted(total_games):
|
||||
occurences = []
|
||||
for day in days:
|
||||
occurences.append(games_played[day][game])
|
||||
plot.line([datetime.combine(day, datetime.min.time()) for day in days],
|
||||
occurences, legend_label=game, line_width=2, color=next(cyc_palette))
|
||||
|
||||
total = sum(total_games.values())
|
||||
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
|
||||
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
|
||||
sizing_mode="scale_both", width=500, height=500)
|
||||
pie.axis.visible = False
|
||||
|
||||
data = {
|
||||
"games": [],
|
||||
"count": [],
|
||||
"start_angles": [],
|
||||
"end_angles": [],
|
||||
}
|
||||
current_angle = 0
|
||||
for i, (game, count) in enumerate(total_games.most_common()):
|
||||
data["games"].append(game)
|
||||
data["count"].append(count)
|
||||
data["start_angles"].append(current_angle)
|
||||
angle = count / total * tau
|
||||
current_angle += angle
|
||||
data["end_angles"].append(current_angle)
|
||||
|
||||
data["colors"] = [element[1] for element in sorted((game, color) for game, color in
|
||||
zip(data["games"], cycle(palette)))]
|
||||
pie.wedge(x=0.5, y=0.5, radius=0.5,
|
||||
start_angle="start_angles", end_angle="end_angles", fill_color="colors",
|
||||
source=ColumnDataSource(data=data), legend_field="games")
|
||||
|
||||
script, charts = components((plot, pie))
|
||||
return render_template("stats.html", js_resources=INLINE.render_js(), css_resources=INLINE.render_css(),
|
||||
chart_data=script, charts=charts)
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/oceanHeader.html' %}
|
||||
{% include 'header/oceanIslandHeader.html' %}
|
||||
<div id="check-wrapper">
|
||||
<div id="check" class="grass-island">
|
||||
<h3>Upload Yaml</h3>
|
||||
@@ -24,5 +24,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'islandFooter.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/oceanHeader.html' %}
|
||||
{% include 'header/oceanIslandHeader.html' %}
|
||||
<div id="check-result-wrapper">
|
||||
<div id="check-result" class="grass-island">
|
||||
<h1>Verification Results</h1>
|
||||
@@ -30,5 +30,4 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'islandFooter.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -35,86 +35,157 @@
|
||||
</p>
|
||||
<div id="generate-game-form-wrapper">
|
||||
<form id="generate-game-form" method="post" enctype="multipart/form-data">
|
||||
<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 %}
|
||||
<div id="generate-game-tables-container">
|
||||
<div class="table-wrapper">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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">
|
||||
{% if race -%}
|
||||
<option value="disabled">Disabled in Race mode</option>
|
||||
{%- else -%}
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="goal">Allow !remaining after goal completion</option>
|
||||
<option value="enabled">Manual !remaining</option>
|
||||
{%- endif -%}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="item_cheat">Item Cheat:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="Allows players to use the !getitem command.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<select name="item_cheat" id="item_cheat">
|
||||
{% if race -%}
|
||||
<option value="0">Disabled in Race mode</option>
|
||||
{%- else -%}
|
||||
<option value="1">Enabled</option>
|
||||
<option value="0">Disabled</option>
|
||||
{%- endif -%}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table>
|
||||
<tbody>
|
||||
<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>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="server_password">Server Password:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input id="server_password" name="server_password">
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="plando_options">Plando Options:</label>
|
||||
<span
|
||||
class="interactive"
|
||||
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" name="plando_bosses" value="bosses" checked>
|
||||
<label for="plando_bosses">Bosses</label><br>
|
||||
|
||||
<input type="checkbox" name="plando_items" value="items" checked>
|
||||
<label for="plando_items">Items</label><br>
|
||||
|
||||
<input type="checkbox" name="plando_connections" value="connections" checked>
|
||||
<label for="plando_connections">Connections</label><br>
|
||||
|
||||
<input type="checkbox" name="plando_texts" value="texts" checked>
|
||||
<label for="plando_texts">Text</label>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div id="generate-form-button-row">
|
||||
<input id="file-input" type="file" name="file">
|
||||
</div>
|
||||
|
||||
@@ -25,11 +25,11 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
{% for name, count in inventory.items() %}
|
||||
{% for id, count in inventory.items() %}
|
||||
<tr>
|
||||
<td>{{ name | item_name }}</td>
|
||||
<td>{{ id | item_name }}</td>
|
||||
<td>{{ count }}</td>
|
||||
<td>{{received_items[name]}}</td>
|
||||
<td>{{received_items[id]}}</td>
|
||||
</tr>
|
||||
{%- endfor -%}
|
||||
|
||||
|
||||
17
WebHostLib/templates/glossary.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<title>Glossary</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/glossary.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="glossary-wrapper" data-lang="{{ lang }}" class="markdown">
|
||||
<!-- Content generated by JavaScript -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
5
WebHostLib/templates/header/stoneHeader.html
Normal file
@@ -0,0 +1,5 @@
|
||||
{% block head %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/themes/stone.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% include 'header/baseHeader.html' %}
|
||||
@@ -20,7 +20,10 @@
|
||||
later,
|
||||
you can simply refresh this page and the server will be started again.<br>
|
||||
{% if room.last_port %}
|
||||
You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
||||
You can connect to this room by using <span class="interactive"
|
||||
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
|
||||
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
|
||||
</span>
|
||||
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
|
||||
{{ macros.list_patches_room(room) }}
|
||||
{% if room.owner == session["_id"] %}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{% block footer %}
|
||||
<footer id="island-footer">
|
||||
<div id="copyright-notice">Copyright 2021 Archipelago</div>
|
||||
<div id="copyright-notice">Copyright 2022 Archipelago</div>
|
||||
<div id="links">
|
||||
<a href="/sitemap">Site Map</a>
|
||||
-
|
||||
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
|
||||
-
|
||||
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>MultiWorld</title>
|
||||
<title>Archipelago</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/landing.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
|
||||
<tr>
|
||||
<td>{{ patch.player_id }}</td>
|
||||
<td>{{ patch.player_name }}</td>
|
||||
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
|
||||
<td>{{ patch.game }}</td>
|
||||
<td>
|
||||
{% if patch.game == "Minecraft" %}
|
||||
|
||||
@@ -46,6 +46,9 @@ requires:
|
||||
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
|
||||
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
|
||||
{%- endfor -%}
|
||||
{% if option.default == "random" %}
|
||||
random: 50
|
||||
{%- endif -%}
|
||||
{%- else %}
|
||||
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }}
|
||||
{%- endif -%}
|
||||
|
||||
46
WebHostLib/templates/siteMap.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>Site Map</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/grassFlowersHeader.html' %}
|
||||
<div id="site-map" class="markdown">
|
||||
<h1>Site Map</h1>
|
||||
<h2>Base Pages</h2>
|
||||
<ul>
|
||||
<li><a href="/discord">Discord Link</a></li>
|
||||
<li><a href="/faq/en">F.A.Q. Page</a></li>
|
||||
<li><a href="/favicon.ico">Favicon</a></li>
|
||||
<li><a href="/generate">Generate Game Page</a></li>
|
||||
<li><a href="/">Homepage</a></li>
|
||||
<li><a href="/uploads">Host Game Page</a></li>
|
||||
<li><a href="/datapackage">Raw Data Package</a></li>
|
||||
<li><a href="{{ url_for('check')}}">Settings Validator</a></li>
|
||||
<li><a href="/sitemap">Site Map</a></li>
|
||||
<li><a href="/start-playing">Start Playing</a></li>
|
||||
<li><a href="/games">Supported Games Page</a></li>
|
||||
<li><a href="/tutorial">Tutorials Page</a></li>
|
||||
<li><a href="/user-content">User Content</a></li>
|
||||
<li><a href="/weighted-settings">Weighted Settings Page</a></li>
|
||||
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
|
||||
<li><a href="/glossary/en">Glossary</a></li>
|
||||
</ul>
|
||||
|
||||
<h2>Game Info Pages</h2>
|
||||
<ul>
|
||||
{% for game in games %}
|
||||
<li><a href="{{ url_for('game_info', game=game, lang='en') }}">{{ game }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Game Settings Pages</h2>
|
||||
<ul>
|
||||
{% for game in games %}
|
||||
<li><a href="{{ url_for('player_settings', game=game) }}">{{ game }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
WebHostLib/templates/stats.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>Archipelago Game Statistics</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/stats.css") }}" />
|
||||
{{ css_resources|indent(4)|safe }}
|
||||
{{ js_resources|indent(4)|safe }}
|
||||
{{ chart_data|indent(4)|safe }}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/grassFlowersHeader.html' %}
|
||||
<div id="stats" class="markdown">
|
||||
<h1>Archipelago Game Statistics</h1>
|
||||
<h5>
|
||||
The data on this page is updated hourly.
|
||||
</h5>
|
||||
|
||||
<div id="charts-wrapper">
|
||||
{% for chart in charts %}
|
||||
<div class="chart-container">
|
||||
{{ chart|indent(16)|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -21,6 +21,10 @@
|
||||
{% elif world.web.settings_page %}
|
||||
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
|
||||
{% endif %}
|
||||
{% if world.web.bug_report_page %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ world.web.bug_report_page }}">Report a Bug</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
{{ super() }}
|
||||
<title>User Content</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/userContent.css") }}" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/userContent.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/oceanHeader.html' %}
|
||||
<div id="user-content-wrapper">
|
||||
<div id="user-content-wrapper" class="markdown">
|
||||
<div id="user-content" class="grass-island">
|
||||
<h1>User Content</h1>
|
||||
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
|
||||
@@ -68,5 +69,4 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'islandFooter.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -316,6 +316,11 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
||||
else:
|
||||
multisave: Dict[str, Any] = {}
|
||||
|
||||
slots_aimed_at_player = {tracked_player}
|
||||
for group_id, group_members in groups.items():
|
||||
if tracked_player in group_members:
|
||||
slots_aimed_at_player.add(group_id)
|
||||
|
||||
# Add items to player inventory
|
||||
for (ms_team, ms_player), locations_checked in multisave.get("location_checks", {}).items():
|
||||
# Skip teams and players not matching the request
|
||||
@@ -324,11 +329,8 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
||||
# If the player does not have the item, do nothing
|
||||
for location in locations_checked:
|
||||
if location in player_locations:
|
||||
if len(player_locations[location]) == 3:
|
||||
item, recipient, flags = player_locations[location]
|
||||
else: # TODO: remove around version 0.2.5
|
||||
item, recipient = player_locations[location]
|
||||
if recipient == tracked_player: # a check done for the tracked player
|
||||
item, recipient, flags = player_locations[location]
|
||||
if recipient in slots_aimed_at_player: # a check done for the tracked player
|
||||
attribute_item_solo(inventory, item)
|
||||
if ms_player == tracked_player: # a check done by the tracked player
|
||||
checks_done[location_to_area[location]] += 1
|
||||
@@ -392,10 +394,7 @@ def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[
|
||||
player_small_key_locations = set()
|
||||
for loc_data in locations.values():
|
||||
for values in loc_data.values():
|
||||
if len(values) == 3:
|
||||
item_id, item_player, flags = values
|
||||
else: # TODO: remove around version 0.2.5
|
||||
item_id, item_player = values
|
||||
item_id, item_player, flags = values
|
||||
if item_player == player:
|
||||
if item_id in ids_big_key:
|
||||
player_big_key_locations.add(ids_big_key[item_id])
|
||||
@@ -430,7 +429,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
||||
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
|
||||
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
|
||||
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
|
||||
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fa/Brewing_Stand.png",
|
||||
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
|
||||
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
|
||||
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
|
||||
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
|
||||
@@ -640,7 +639,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
"Shadow Temple": (67485, 67532),
|
||||
"Spirit Temple": (67533, 67582),
|
||||
"Ice Cavern": (67583, 67596),
|
||||
"Gerudo Training Grounds": (67597, 67635),
|
||||
"Gerudo Training Ground": (67597, 67635),
|
||||
"Thieves' Hideout": (67259, 67263),
|
||||
"Ganon's Castle": (67636, 67673),
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
full_name = lookup_any_location_id_to_name[id]
|
||||
if id == 67673:
|
||||
return full_name[13:] # Ganons Tower Boss Key Chest
|
||||
if area != 'Overworld':
|
||||
if area not in ["Overworld", "Thieves' Hideout"]:
|
||||
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
|
||||
return full_name[len(area):]
|
||||
return full_name
|
||||
@@ -659,6 +659,13 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
|
||||
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
|
||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
||||
|
||||
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
|
||||
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
|
||||
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
|
||||
for loc in location_info["Thieves' Hideout"]:
|
||||
del location_info["Overworld"][loc]
|
||||
|
||||
checks_done['Total'] = sum(checks_done.values())
|
||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
||||
|
||||
@@ -676,7 +683,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
"Spirit Temple": inventory[66178],
|
||||
"Shadow Temple": inventory[66179],
|
||||
"Bottom of the Well": inventory[66180],
|
||||
"Gerudo Training Grounds": inventory[66181],
|
||||
"Gerudo Training Ground": inventory[66181],
|
||||
"Thieves' Hideout": inventory[66182],
|
||||
"Ganon's Castle": inventory[66183],
|
||||
}
|
||||
boss_key_counts = {
|
||||
@@ -881,7 +889,6 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
||||
|
||||
for item_name, item_id in multi_items.items():
|
||||
base_name = item_name.split()[0].lower()
|
||||
count = inventory[item_id]
|
||||
display_data[base_name+"_count"] = inventory[item_id]
|
||||
|
||||
# Victory condition
|
||||
@@ -967,12 +974,10 @@ def getTracker(tracker: UUID):
|
||||
if location not in player_locations or location not in player_location_to_area[player]:
|
||||
continue
|
||||
|
||||
if len(player_locations[location]) == 3:
|
||||
item, recipient, flags = player_locations[location]
|
||||
else: # TODO: remove around version 0.2.5
|
||||
item, recipient = player_locations[location]
|
||||
item, recipient, flags = player_locations[location]
|
||||
|
||||
attribute_item(inventory, team, recipient, item)
|
||||
if recipient in names:
|
||||
attribute_item(inventory, team, recipient, item)
|
||||
checks_done[team][player][player_location_to_area[player][location]] += 1
|
||||
checks_done[team][player]["Total"] += 1
|
||||
|
||||
@@ -986,10 +991,7 @@ def getTracker(tracker: UUID):
|
||||
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
|
||||
for loc_data in locations.values():
|
||||
for values in loc_data.values():
|
||||
if len(values) == 3:
|
||||
item_id, item_player, flags = values
|
||||
else: # TODO: remove around version 0.2.5
|
||||
item_id, item_player = values
|
||||
item_id, item_player, flags = values
|
||||
|
||||
if item_id in ids_big_key:
|
||||
player_big_key_locations[item_player].add(ids_big_key[item_id])
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
size_hint_x: 1
|
||||
size_hint_y: 1
|
||||
pos: (0, 0)
|
||||
<ServerToolTip>:
|
||||
<ToolTip>:
|
||||
size: self.texture_size
|
||||
size_hint: None, None
|
||||
font_size: dp(18)
|
||||
@@ -51,4 +51,6 @@
|
||||
rgba: 0.235, 0.678, 0.843, 1
|
||||
Line:
|
||||
width: 1
|
||||
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
||||
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
|
||||
<ServerToolTip>:
|
||||
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
|
||||
|
||||
@@ -2,14 +2,14 @@ local socket = require("socket")
|
||||
local json = require('json')
|
||||
local math = require('math')
|
||||
|
||||
local script_version = '2022-03-22' -- Should be the last modified date
|
||||
local last_modified_date = '2022-05-25' -- Should be the last modified date
|
||||
local script_version = 1
|
||||
|
||||
--------------------------------------------------
|
||||
-- Heavily modified form of RiptideSage's tracker
|
||||
--------------------------------------------------
|
||||
|
||||
-- TODO: read this from the ROM
|
||||
local NUM_BIG_POES_REQUIRED = 1
|
||||
local NUM_BIG_POES_REQUIRED = 10
|
||||
|
||||
-- The offset constants are all from N64 RAM start. Offsets in the check statements are relative.
|
||||
local save_context_offset = 0x11A5D0
|
||||
@@ -464,7 +464,7 @@ local read_graveyard_checks = function()
|
||||
local checks = {}
|
||||
checks["Graveyard Shield Grave Chest"] = chest_check(0x40, 0x00)
|
||||
checks["Graveyard Heart Piece Grave Chest"] = chest_check(0x3F, 0x00)
|
||||
checks["Graveyard Composers Grave Chest"] = chest_check(0x41, 0x00)
|
||||
checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00)
|
||||
checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4)
|
||||
checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8)
|
||||
checks["Graveyard Hookshot Chest"] = chest_check(0x48, 0x00)
|
||||
@@ -899,11 +899,11 @@ end
|
||||
|
||||
local read_gerudo_fortress_checks = function()
|
||||
local checks = {}
|
||||
checks["GF North F1 Carpenter"] = on_the_ground_check(0xC, 0xC)
|
||||
checks["GF North F2 Carpenter"] = on_the_ground_check(0xC, 0xA)
|
||||
checks["GF South F1 Carpenter"] = on_the_ground_check(0xC, 0xE)
|
||||
checks["GF South F2 Carpenter"] = on_the_ground_check(0xC, 0xF)
|
||||
checks["GF Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
|
||||
checks["Hideout Jail Guard (1 Torch)"] = on_the_ground_check(0xC, 0xC)
|
||||
checks["Hideout Jail Guard (2 Torches)"] = on_the_ground_check(0xC, 0xF)
|
||||
checks["Hideout Jail Guard (3 Torches)"] = on_the_ground_check(0xC, 0xA)
|
||||
checks["Hideout Jail Guard (4 Torches)"] = on_the_ground_check(0xC, 0xE)
|
||||
checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
|
||||
checks["GF Chest"] = chest_check(0x5D, 0x0)
|
||||
checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0)
|
||||
checks["GF HBA 1500 Points"] = item_get_info_check(0x0, 0x7)
|
||||
@@ -915,46 +915,46 @@ end
|
||||
local read_gerudo_training_ground_checks = function(mq_table_address)
|
||||
local checks = {}
|
||||
if not is_master_quest_dungeon(mq_table_address, 0xB) then
|
||||
checks["Gerudo Training Grounds Lobby Left Chest"] = chest_check(0x0B, 0x13)
|
||||
checks["Gerudo Training Grounds Lobby Right Chest"] = chest_check(0x0B, 0x07)
|
||||
checks["Gerudo Training Grounds Stalfos Chest"] = chest_check(0x0B, 0x00)
|
||||
checks["Gerudo Training Grounds Before Heavy Block Chest"] = chest_check(0x0B, 0x11)
|
||||
checks["Gerudo Training Grounds Heavy Block First Chest"] = chest_check(0x0B, 0x0F)
|
||||
checks["Gerudo Training Grounds Heavy Block Second Chest"] = chest_check(0x0B, 0x0E)
|
||||
checks["Gerudo Training Grounds Heavy Block Third Chest"] = chest_check(0x0B, 0x14)
|
||||
checks["Gerudo Training Grounds Heavy Block Fourth Chest"] = chest_check(0x0B, 0x02)
|
||||
checks["Gerudo Training Grounds Eye Statue Chest"] = chest_check(0x0B, 0x03)
|
||||
checks["Gerudo Training Grounds Near Scarecrow Chest"] = chest_check(0x0B, 0x04)
|
||||
checks["Gerudo Training Grounds Hammer Room Clear Chest"] = chest_check(0x0B, 0x12)
|
||||
checks["Gerudo Training Grounds Hammer Room Switch Chest"] = chest_check(0x0B, 0x10)
|
||||
checks["Gerudo Training Grounds Freestanding Key"] = on_the_ground_check(0x0B, 0x1)
|
||||
checks["Gerudo Training Grounds Maze Right Central Chest"] = chest_check(0x0B, 0x05)
|
||||
checks["Gerudo Training Grounds Maze Right Side Chest"] = chest_check(0x0B, 0x08)
|
||||
checks["Gerudo Training Grounds Underwater Silver Rupee Chest"] = chest_check(0x0B, 0x0D)
|
||||
checks["Gerudo Training Grounds Beamos Chest"] = chest_check(0x0B, 0x01)
|
||||
checks["Gerudo Training Grounds Hidden Ceiling Chest"] = chest_check(0x0B, 0x0B)
|
||||
checks["Gerudo Training Grounds Maze Path First Chest"] = chest_check(0x0B, 0x06)
|
||||
checks["Gerudo Training Grounds Maze Path Second Chest"] = chest_check(0x0B, 0x0A)
|
||||
checks["Gerudo Training Grounds Maze Path Third Chest"] = chest_check(0x0B, 0x09)
|
||||
checks["Gerudo Training Grounds Maze Path Final Chest"] = chest_check(0x0B, 0x0C)
|
||||
checks["Gerudo Training Ground Lobby Left Chest"] = chest_check(0x0B, 0x13)
|
||||
checks["Gerudo Training Ground Lobby Right Chest"] = chest_check(0x0B, 0x07)
|
||||
checks["Gerudo Training Ground Stalfos Chest"] = chest_check(0x0B, 0x00)
|
||||
checks["Gerudo Training Ground Before Heavy Block Chest"] = chest_check(0x0B, 0x11)
|
||||
checks["Gerudo Training Ground Heavy Block First Chest"] = chest_check(0x0B, 0x0F)
|
||||
checks["Gerudo Training Ground Heavy Block Second Chest"] = chest_check(0x0B, 0x0E)
|
||||
checks["Gerudo Training Ground Heavy Block Third Chest"] = chest_check(0x0B, 0x14)
|
||||
checks["Gerudo Training Ground Heavy Block Fourth Chest"] = chest_check(0x0B, 0x02)
|
||||
checks["Gerudo Training Ground Eye Statue Chest"] = chest_check(0x0B, 0x03)
|
||||
checks["Gerudo Training Ground Near Scarecrow Chest"] = chest_check(0x0B, 0x04)
|
||||
checks["Gerudo Training Ground Hammer Room Clear Chest"] = chest_check(0x0B, 0x12)
|
||||
checks["Gerudo Training Ground Hammer Room Switch Chest"] = chest_check(0x0B, 0x10)
|
||||
checks["Gerudo Training Ground Freestanding Key"] = on_the_ground_check(0x0B, 0x1)
|
||||
checks["Gerudo Training Ground Maze Right Central Chest"] = chest_check(0x0B, 0x05)
|
||||
checks["Gerudo Training Ground Maze Right Side Chest"] = chest_check(0x0B, 0x08)
|
||||
checks["Gerudo Training Ground Underwater Silver Rupee Chest"] = chest_check(0x0B, 0x0D)
|
||||
checks["Gerudo Training Ground Beamos Chest"] = chest_check(0x0B, 0x01)
|
||||
checks["Gerudo Training Ground Hidden Ceiling Chest"] = chest_check(0x0B, 0x0B)
|
||||
checks["Gerudo Training Ground Maze Path First Chest"] = chest_check(0x0B, 0x06)
|
||||
checks["Gerudo Training Ground Maze Path Second Chest"] = chest_check(0x0B, 0x0A)
|
||||
checks["Gerudo Training Ground Maze Path Third Chest"] = chest_check(0x0B, 0x09)
|
||||
checks["Gerudo Training Ground Maze Path Final Chest"] = chest_check(0x0B, 0x0C)
|
||||
else
|
||||
checks["Gerudo Training Grounds MQ Lobby Left Chest"] = chest_check(0xB, 0x13)
|
||||
checks["Gerudo Training Grounds MQ Lobby Right Chest"] = chest_check(0xB, 0x7)
|
||||
checks["Gerudo Training Grounds MQ First Iron Knuckle Chest"] = chest_check(0xB, 0x0)
|
||||
checks["Gerudo Training Grounds MQ Before Heavy Block Chest"] = chest_check(0xB, 0x11)
|
||||
checks["Gerudo Training Grounds MQ Heavy Block Chest"] = chest_check(0xB, 0x2)
|
||||
checks["Gerudo Training Grounds MQ Eye Statue Chest"] = chest_check(0xB, 0x3)
|
||||
checks["Gerudo Training Grounds MQ Ice Arrows Chest"] = chest_check(0xB, 0x4)
|
||||
checks["Gerudo Training Grounds MQ Second Iron Knuckle Chest"] = chest_check(0xB, 0x12)
|
||||
checks["Gerudo Training Grounds MQ Flame Circle Chest"] = chest_check(0xB, 0xE)
|
||||
checks["Gerudo Training Grounds MQ Maze Right Central Chest"] = chest_check(0xB, 0x5)
|
||||
checks["Gerudo Training Grounds MQ Maze Right Side Chest"] = chest_check(0xB, 0x8)
|
||||
checks["Gerudo Training Grounds MQ Underwater Silver Rupee Chest"] = chest_check(0xB, 0xD)
|
||||
checks["Gerudo Training Grounds MQ Dinolfos Chest"] = chest_check(0xB, 0x1)
|
||||
checks["Gerudo Training Grounds MQ Hidden Ceiling Chest"] = chest_check(0xB, 0xB)
|
||||
checks["Gerudo Training Grounds MQ Maze Path First Chest"] = chest_check(0xB, 0x6)
|
||||
checks["Gerudo Training Grounds MQ Maze Path Third Chest"] = chest_check(0xB, 0x9)
|
||||
checks["Gerudo Training Grounds MQ Maze Path Second Chest"] = chest_check(0xB, 0xA)
|
||||
checks["Gerudo Training Ground MQ Lobby Left Chest"] = chest_check(0xB, 0x13)
|
||||
checks["Gerudo Training Ground MQ Lobby Right Chest"] = chest_check(0xB, 0x7)
|
||||
checks["Gerudo Training Ground MQ First Iron Knuckle Chest"] = chest_check(0xB, 0x0)
|
||||
checks["Gerudo Training Ground MQ Before Heavy Block Chest"] = chest_check(0xB, 0x11)
|
||||
checks["Gerudo Training Ground MQ Heavy Block Chest"] = chest_check(0xB, 0x2)
|
||||
checks["Gerudo Training Ground MQ Eye Statue Chest"] = chest_check(0xB, 0x3)
|
||||
checks["Gerudo Training Ground MQ Ice Arrows Chest"] = chest_check(0xB, 0x4)
|
||||
checks["Gerudo Training Ground MQ Second Iron Knuckle Chest"] = chest_check(0xB, 0x12)
|
||||
checks["Gerudo Training Ground MQ Flame Circle Chest"] = chest_check(0xB, 0xE)
|
||||
checks["Gerudo Training Ground MQ Maze Right Central Chest"] = chest_check(0xB, 0x5)
|
||||
checks["Gerudo Training Ground MQ Maze Right Side Chest"] = chest_check(0xB, 0x8)
|
||||
checks["Gerudo Training Ground MQ Underwater Silver Rupee Chest"] = chest_check(0xB, 0xD)
|
||||
checks["Gerudo Training Ground MQ Dinolfos Chest"] = chest_check(0xB, 0x1)
|
||||
checks["Gerudo Training Ground MQ Hidden Ceiling Chest"] = chest_check(0xB, 0xB)
|
||||
checks["Gerudo Training Ground MQ Maze Path First Chest"] = chest_check(0xB, 0x6)
|
||||
checks["Gerudo Training Ground MQ Maze Path Third Chest"] = chest_check(0xB, 0x9)
|
||||
checks["Gerudo Training Ground MQ Maze Path Second Chest"] = chest_check(0xB, 0xA)
|
||||
end
|
||||
return checks
|
||||
end
|
||||
@@ -1107,7 +1107,7 @@ local read_song_checks = function()
|
||||
checks["Song from Impa"] = event_check(0x5, 0x9) -- Zelda's Lullaby
|
||||
checks["Song from Malon"] = event_check(0x5, 0x8) -- Epona's Song
|
||||
checks["Song from Saria"] = event_check(0x5, 0x7) -- Saria's Song
|
||||
checks["Song from Composers Grave"] = event_check(0x5, 0xA) -- Sun's Song
|
||||
checks["Song from Royal Familys Tomb"] = event_check(0x5, 0xA) -- Sun's Song
|
||||
checks["Song from Ocarina of Time"] = event_check(0xA, 0x9) -- Song of Time
|
||||
checks["Song from Windmill"] = event_check(0x5, 0xB) -- Song of Storms
|
||||
checks["Sheik in Forest"] = event_check(0x5, 0x0) -- Minuet of Forest
|
||||
@@ -1546,7 +1546,7 @@ local player_names_address = coop_context + 20
|
||||
local player_name_length = 8 -- 8 bytes
|
||||
local rom_name_location = player_names_address + 0x800
|
||||
|
||||
local master_quest_table_address = rando_context + 0xB220
|
||||
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0CE0) - 0x03480000)
|
||||
|
||||
local save_context_addr = 0x11A5D0
|
||||
local internal_count_addr = save_context_addr + 0x90
|
||||
@@ -1555,6 +1555,8 @@ local item_queue = {}
|
||||
local first_connect = true
|
||||
local game_complete = false
|
||||
|
||||
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0CEE)
|
||||
|
||||
local bytes_to_string = function(bytes)
|
||||
local string = ''
|
||||
for i=0,#(bytes) do
|
||||
@@ -1781,6 +1783,7 @@ function receive()
|
||||
-- Determine message to send back
|
||||
local retTable = {}
|
||||
retTable["playerName"] = get_player_name()
|
||||
retTable["scriptVersion"] = script_version
|
||||
retTable["deathlinkActive"] = deathlink_enabled()
|
||||
if InSafeState() then
|
||||
retTable["locations"] = check_all_locations(master_quest_table_address)
|
||||
|
||||
@@ -8,7 +8,7 @@ There are two key steps to incorporating a game into Archipelago:
|
||||
|
||||
Refer to the following documents as well:
|
||||
- [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md) for network communication between client and server.
|
||||
- [api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/api.md) for documentation on server side code and creating a world package.
|
||||
- [world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md) for documentation on server side code and creating a world package.
|
||||
|
||||
|
||||
# Game Modification
|
||||
@@ -337,6 +337,7 @@ fields in the class being extended.
|
||||
This is also a good place to put game-specific quirky behavior that needs to be managed, as it tends to make things a bit
|
||||
cluttered if you put these things elsewhere.
|
||||
|
||||
The various methods and attributes are documented in `/worlds/AutoWorld.py[World]`,
|
||||
The various methods and attributes are documented in `/worlds/AutoWorld.py[World]` and
|
||||
[world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md),
|
||||
though it is also recommended to look at existing implementations to see how all this works first-hand.
|
||||
Once you get all that, all that remains to do is test the game and publish your work.
|
||||
|
||||
BIN
docs/img/theme_dirt.JPG
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
docs/img/theme_grass.JPG
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/img/theme_grassFlowers.JPG
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
docs/img/theme_ice.JPG
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
docs/img/theme_jungle.JPG
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/img/theme_ocean.JPG
Normal file
|
After Width: | Height: | Size: 221 KiB |
BIN
docs/img/theme_partyTime.JPG
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
docs/img/theme_stone.JPG
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
docs/network diagram.jpg
Normal file
|
After Width: | Height: | Size: 374 KiB |
157
docs/network diagram.md
Normal file
@@ -0,0 +1,157 @@
|
||||
```mermaid
|
||||
flowchart LR
|
||||
%% Diagram arranged specifically so output generates no terrible crossing lines.
|
||||
%% AP Server
|
||||
AS{Archipelago Server}
|
||||
|
||||
%% CommonClient.py
|
||||
CC[CommonClient.py]
|
||||
AS <-- WebSockets --> CC
|
||||
|
||||
%% ChecksFinder
|
||||
subgraph ChecksFinder
|
||||
CFC[ChecksFinderClient]
|
||||
CF[ChecksFinder]
|
||||
CFC <--> CF
|
||||
end
|
||||
CC <-- Integrated --> CFC
|
||||
|
||||
%% A Link to the Past
|
||||
subgraph A Link to the Past
|
||||
LTTP[SNES]
|
||||
end
|
||||
SNI <-- Various, depending on SNES device --> LTTP
|
||||
|
||||
%% Final Fantasy
|
||||
subgraph Final Fantasy 1
|
||||
FF1[FF1Client]
|
||||
FFLUA[Lua Connector]
|
||||
BZFF[BizHawk with Final Fantasy Loaded]
|
||||
FF1 <-- LuaSockets --> FFLUA
|
||||
FFLUA <--> BZFF
|
||||
end
|
||||
CC <-- Integrated --> FF1
|
||||
|
||||
%% Ocarina of Time
|
||||
subgraph Ocarina of Time
|
||||
OC[OoTClient]
|
||||
LC[Lua Connector]
|
||||
OCB[BizHawk with Ocarina of Time Loaded]
|
||||
OC <-- LuaSockets --> LC
|
||||
LC <--> OCB
|
||||
end
|
||||
CC <-- Integrated --> OC
|
||||
|
||||
%% SNI Connectors
|
||||
SC[SNIClient]
|
||||
SNI["Super Nintendo Interface (SNI)"]
|
||||
CC <-- Integrated --> SC
|
||||
SC <-- WebSockets --> SNI
|
||||
|
||||
%% Super Metroid
|
||||
subgraph Super Metroid
|
||||
SM[SNES]
|
||||
end
|
||||
SNI <-- Various, depending on SNES device --> SM
|
||||
|
||||
%% Super Metroid/A Link to the Past Combo Randomizer
|
||||
subgraph "SMZ3"
|
||||
SMZ[SNES]
|
||||
end
|
||||
SNI <-- Various, depending on SNES device --> SMZ
|
||||
|
||||
%% Native Clients or Games
|
||||
%% Games or clients which compile to native or which the client is integrated in the game.
|
||||
subgraph "Native"
|
||||
APCLIENTPP[Game using apclientpp Client Library]
|
||||
APCPP[Game using Apcpp Client Library]
|
||||
subgraph Secret of Evermore
|
||||
SOE[ap-soeclient]
|
||||
end
|
||||
SM64[Super Mario 64 Ex]
|
||||
V6[VVVVVV]
|
||||
MT[Meritous]
|
||||
TW[The Witness]
|
||||
|
||||
APCLIENTPP <--> SOE
|
||||
APCLIENTPP <--> MT
|
||||
APCLIENTPP <-- The Witness Randomizer --> TW
|
||||
APCPP <--> SM64
|
||||
APCPP <--> V6
|
||||
end
|
||||
SOE <--> SNI <-- Various, depending on SNES device --> SOESNES
|
||||
AS <-- WebSockets --> APCLIENTPP
|
||||
AS <-- WebSockets --> APCPP
|
||||
|
||||
%% Java Based Games
|
||||
subgraph Java
|
||||
JM[Mod with Archipelago.MultiClient.Java]
|
||||
STS[Slay the Spire]
|
||||
JM <-- Mod the Spire --> STS
|
||||
subgraph Minecraft
|
||||
MCS[Minecraft Forge Server]
|
||||
JMC[Any Java Minecraft Clients]
|
||||
MCS <-- TCP --> JMC
|
||||
end
|
||||
JM <-- Forge Mod Loader --> MCS
|
||||
end
|
||||
AS <-- WebSockets --> JM
|
||||
|
||||
%% .NET Based Games
|
||||
subgraph .NET
|
||||
NM[Mod with Archipelago.MultiClient.Net]
|
||||
subgraph FNA/XNA
|
||||
TS[Timespinner]
|
||||
RL[Rogue Legacy]
|
||||
end
|
||||
NM <-- TsRandomizer --> TS
|
||||
NM <-- RogueLegacyRandomizer --> RL
|
||||
subgraph Unity
|
||||
ROR[Risk of Rain 2]
|
||||
SN[Subnautica]
|
||||
HK[Hollow Knight]
|
||||
R[Raft]
|
||||
end
|
||||
NM <-- BepInEx --> ROR
|
||||
NM <-- "QModLoader (BepInEx)" --> SN
|
||||
NM <-- HK Modding API --> HK
|
||||
NM <--> R
|
||||
end
|
||||
AS <-- WebSockets --> NM
|
||||
|
||||
%% Archipelago WebHost
|
||||
subgraph "WebHost (archipelago.gg)"
|
||||
WHNOTE(["Configurable (waitress, gunicorn, flask)"])
|
||||
AH[AutoHoster]
|
||||
PDB[(PonyORM DB)]
|
||||
WH[WebHost]
|
||||
FWC[Flask WebContent]
|
||||
AG[AutoGenerator]
|
||||
|
||||
AH <-- SQL --> PDB
|
||||
WH -- Subprocesses --> AH
|
||||
FWC <-- SQL --> PDB
|
||||
WH --> FWC
|
||||
AG -- Deposit Generated Worlds --> PDB
|
||||
PDB -- Provide Generation Instructions --> AG
|
||||
WH -- Subprocesses --> AG
|
||||
end
|
||||
AH -- Subprocesses --> AS
|
||||
|
||||
%% Special subgraph for SoE for its SNES connection
|
||||
subgraph Secret of Evermore
|
||||
SOESNES[SNES]
|
||||
end
|
||||
|
||||
%% Factorio
|
||||
subgraph Factorio
|
||||
FC[FactorioClient] <-- RCON --> FS[Factorio Server]
|
||||
FS <-- UDP --> FG[Factorio Games]
|
||||
FMOD[Factorio Mod Generated by AP]
|
||||
FMAPI[Factorio Modding API]
|
||||
FMAPI <--> FS
|
||||
FMAPI <--> FG
|
||||
FMOD <--> FMAPI
|
||||
end
|
||||
CC <-- Integrated --> FC
|
||||
```
|
||||
1
docs/network diagram.svg
Normal file
|
After Width: | Height: | Size: 82 KiB |
@@ -63,10 +63,9 @@ Sent to clients when they connect to an Archipelago server.
|
||||
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit", "collect" and "remaining". |
|
||||
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
|
||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
|
||||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Sent only if the client is properly authenticated (see [Archipelago Connection Handshake](#Archipelago-Connection-Handshake)). Information on the players currently connected to the server. |
|
||||
| games | list\[str\] | sorted list of game names for the players, so first player's game will be games\[0\]. Matches game names in datapackage. |
|
||||
| datapackage_version | int | Data version of the [data package](#Data-Package-Contents) the server will send. Used to update the client's (optional) local cache. |
|
||||
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. |
|
||||
| games | list\[str\] | List of games present in this multiworld. |
|
||||
| datapackage_version | int | Sum of individual games' datapackage version. Deprecated. Use `datapackage_versions` instead. |
|
||||
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). |
|
||||
| seed_name | str | uniquely identifying name of this generation |
|
||||
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
|
||||
|
||||
@@ -101,11 +100,10 @@ Sent to clients when the server refuses connection. This is sent during the init
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| errors | list\[str\] | Optional. When provided, should contain any one of: `InvalidSlot`, `InvalidGame`, `SlotAlreadyTaken`, `IncompatibleVersion`, `InvalidPassword`, or `InvalidItemsHandling`. |
|
||||
| errors | list\[str\] | Optional. When provided, should contain any one of: `InvalidSlot`, `InvalidGame`, `IncompatibleVersion`, `InvalidPassword`, or `InvalidItemsHandling`. |
|
||||
|
||||
InvalidSlot indicates that the sent 'name' field did not match any auth entry on the server.
|
||||
InvalidGame indicates that a correctly named slot was found, but the game for it mismatched.
|
||||
SlotAlreadyTaken indicates a connection with a different uuid is already established.
|
||||
IncompatibleVersion indicates a version mismatch.
|
||||
InvalidPassword indicates the wrong, or no password when it was required, was sent.
|
||||
InvalidItemsHandling indicates a wrong value type or flag combination was sent.
|
||||
@@ -121,7 +119,7 @@ Sent to clients when the connection handshake is successfully completed.
|
||||
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
|
||||
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
|
||||
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. |
|
||||
| slot_info | dict\[int, NetworkSlot\] | maps each slot to a NetworkSlot information |
|
||||
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
|
||||
|
||||
### ReceivedItems
|
||||
Sent to clients when they receive an item.
|
||||
@@ -147,7 +145,7 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| hint_points | int | New argument. The client's current hint points. |
|
||||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Changed argument. Always sends all players, whether connected or not. |
|
||||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | Send in the event of an alias rename. Always sends all players, whether connected or not. |
|
||||
| checked_locations | list\[int\] | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. |
|
||||
| missing_locations | list\[int\] | Should never be sent as an update, if needed is the inverse of checked_locations. |
|
||||
|
||||
@@ -239,7 +237,7 @@ Sent by the client to initiate a connection to an Archipelago game session.
|
||||
| name | str | The player name for this client. |
|
||||
| uuid | str | Unique identifier for player client. |
|
||||
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags.
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||
|
||||
#### items_handling flags
|
||||
@@ -260,7 +258,7 @@ Update arguments from the Connect package, currently only updating tags and item
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| items_handling | int | Flags configuring which items should be sent by the server.
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||
|
||||
### Sync
|
||||
@@ -283,7 +281,7 @@ Sent to the server to inform it of locations the client has seen, but not checke
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
|
||||
| create_as_hint | bool | If True, the scouted locations get created and broadcasted as a player-visible hint. |
|
||||
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
|
||||
|
||||
### StatusUpdate
|
||||
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)
|
||||
@@ -305,9 +303,9 @@ Basic chat command which sends text to the server to be distributed to other cli
|
||||
Requests the data package from the server. Does not require client authentication.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ------ | ----- | ------ |
|
||||
| exclusions | list\[str\] | Optional. If specified, will not send back the specified data. Such as, \["Factorio"\] -> Datapackage without Factorio data.|
|
||||
| Name | Type | Notes |
|
||||
|-------| ----- |---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| games | list\[str\] | Optional. If specified, will only send back the specified data. Such as, \["Factorio"\] -> Datapackage with only Factorio data. |
|
||||
|
||||
### Bounce
|
||||
Send this message to the server, tell it which clients should receive the message and
|
||||
@@ -345,7 +343,7 @@ Additional arguments sent in this package will also be added to the [SetReply](#
|
||||
#### DataStorageOperation
|
||||
A DataStorageOperation manipulates or alters the value of a key in the data storage. If the operation transforms the value from one state to another then the current value of the key is used as the starting point otherwise the [Set](#Set)'s package `default` is used if the key does not exist on the server already.
|
||||
DataStorageOperations consist of an object containing both the operation to be applied, provided in the form of a string, as well as the value to be used for that operation, Example:
|
||||
```js
|
||||
```json
|
||||
{"operation": "add", "value": 12}
|
||||
```
|
||||
|
||||
@@ -400,7 +398,7 @@ class NetworkPlayer(NamedTuple):
|
||||
```
|
||||
|
||||
Example:
|
||||
```js
|
||||
```json
|
||||
[
|
||||
{"team": 0, "slot": 1, "alias": "Lord MeowsiePuss", "name": "Meow"},
|
||||
{"team": 0, "slot": 2, "alias": "Doggo", "name": "Bork"},
|
||||
@@ -420,7 +418,7 @@ class NetworkItem(NamedTuple):
|
||||
flags: int
|
||||
```
|
||||
In JSON this may look like:
|
||||
```js
|
||||
```json
|
||||
[
|
||||
{"item": 1, "location": 1, "player": 1, "flags": 1},
|
||||
{"item": 2, "location": 2, "player": 2, "flags": 2},
|
||||
@@ -569,7 +567,6 @@ Note:
|
||||
| Name | Type | Notes |
|
||||
| ------ | ----- | ------ |
|
||||
| games | dict[str, GameData] | Mapping of all Games and their respective data |
|
||||
| version | int | Sum of all per-game version numbers, for clients that don't bother with per-game caching/updating. |
|
||||
|
||||
#### GameData
|
||||
GameData is a **dict** but contains these keys and values. It's broken out into another "type" for ease of documentation.
|
||||
@@ -583,13 +580,13 @@ GameData is a **dict** but contains these keys and values. It's broken out into
|
||||
### Tags
|
||||
Tags are represented as a list of strings, the common Client tags follow:
|
||||
|
||||
| Name | Notes |
|
||||
| ----- | ---- |
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| IgnoreGame | Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client is actually a Tracker, will refuse new locations from this client and send all items as if they were remote items. |
|
||||
| TextOnly | Tells the server that this client will not send locations and does not want to receive items. |
|
||||
| Name | Notes |
|
||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| IgnoreGame | Deprecated. See Tracker and TextOnly. Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
|
||||
### DeathLink
|
||||
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
|
||||
|
||||
1004
docs/network.graphml
BIN
docs/network.png
|
Before Width: | Height: | Size: 110 KiB |
@@ -56,9 +56,21 @@ game.
|
||||
### WebWorld Class
|
||||
|
||||
A `WebWorld` class contains specific attributes and methods that can be modified
|
||||
for your world specifically on the webhost. At the moment this comprises of `settings_page`
|
||||
which can be changed to a link instead of an AP generated settings page; such is the case
|
||||
for Final Fantasy.
|
||||
for your world specifically on the webhost.
|
||||
|
||||
`settings_page` which can be changed to a link instead of an AP generated settings page.
|
||||
|
||||
`theme` to be used for your game specific AP pages. Available themes:
|
||||
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| <img src="img/theme_dirt.JPG" width="100"> | <img src="img/theme_grass.JPG" width="100"> | <img src="img/theme_grassFlowers.JPG" width="100"> | <img src="img/theme_ice.JPG" width="100"> | <img src="img/theme_jungle.JPG" width="100"> | <img src="img/theme_ocean.JPG" width="100"> | <img src="img/theme_partyTime.JPG" width="100"> | <img src="img/theme_stone.JPG" width="100"> |
|
||||
|
||||
`bug_report_page` (optional) can be a link to a bug reporting page, most likely a GitHub issue page, that will be placed by the site to help direct users to report bugs.
|
||||
|
||||
`tutorials` list of `Tutorial` classes where each class represents a guide to be generated on the webhost.
|
||||
|
||||
`game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be
|
||||
prefixed with the same string as defined here. Default already has 'en'.
|
||||
|
||||
### MultiWorld Object
|
||||
|
||||
@@ -102,14 +114,21 @@ Special locations with ID `None` can hold events.
|
||||
Items are all things that can "drop" for your game. This may be RPG items like
|
||||
weapons, could as well be technologies you normally research in a research tree.
|
||||
|
||||
Each item has a `name`, an `id` (can be known as "code"), and an `advancement`
|
||||
flag. An advancement item is an item which a player may require to advance in
|
||||
their world. Advancement items will be assigned to locations with higher
|
||||
Each item has a `name`, an `id` (can be known as "code"), and a classification.
|
||||
The most important classification is `progression` (formerly advancement).
|
||||
Progression items are items which a player may require to progress in
|
||||
their world. Progression items will be assigned to locations with higher
|
||||
priority and moved around to meet defined rules and accomplish progression
|
||||
balancing.
|
||||
|
||||
Special items with ID `None` can mark events (read below).
|
||||
|
||||
Other classifications include
|
||||
* filler: a regular item or trash item
|
||||
* useful: generally quite useful, but not required for anything logical
|
||||
* trap: negative impact on the player
|
||||
* skip_balancing: add to progression to skip balancing; e.g. currency or tokens
|
||||
|
||||
### Events
|
||||
|
||||
Events will mark some progress. You define an event location, an
|
||||
@@ -334,7 +353,7 @@ from .Options import mygame_options # the options we defined earlier
|
||||
from .Items import mygame_items # data used below to add items to the World
|
||||
from .Locations import mygame_locations # same as above
|
||||
from ..AutoWorld import World
|
||||
from BaseClasses import Region, Location, Entrance, Item
|
||||
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
||||
from Utils import get_options, output_path
|
||||
|
||||
class MyGameItem(Item): # or from Items import MyGameItem
|
||||
@@ -396,7 +415,7 @@ The world has to provide the following things for generation
|
||||
`self.world.get_filled_locations(self.player)` will filter for this world.
|
||||
`item.player` can be used to see if it's a local item.
|
||||
|
||||
In addition the following methods can be implemented
|
||||
In addition, the following methods can be implemented and attributes can be set
|
||||
|
||||
* `def generate_early(self)`
|
||||
called per player before any items or locations are created. You can set
|
||||
@@ -416,16 +435,17 @@ In addition the following methods can be implemented
|
||||
before, during and after the regular fill process, before `generate_output`.
|
||||
* `fill_slot_data` and `modify_multidata` can be used to modify the data that
|
||||
will be used by the server to host the MultiWorld.
|
||||
* `def get_required_client_version(self)`
|
||||
can return a tuple of 3 ints to make sure the client is compatible to this
|
||||
world (e.g. item IDs) when connecting.
|
||||
Always use `return max((x,y,z), super().get_required_client_version())`
|
||||
to catch updates in the lower layers that break compatibility.
|
||||
* `required_client_version: Tuple(int, int, int)`
|
||||
Client version as tuple of 3 ints to make sure the client is compatible to
|
||||
this world (e.g. implements all required features) when connecting.
|
||||
* `assert_generate(cls, world)` is a class method called at the start of
|
||||
generation to check the existence of prerequisite files, usually a ROM for
|
||||
games which require one.
|
||||
|
||||
#### generate_early
|
||||
|
||||
```python
|
||||
def generate_early(self):
|
||||
def generate_early(self) -> None:
|
||||
# read player settings to world instance
|
||||
self.final_boss_hp = self.world.final_boss_hp[self.player].value
|
||||
```
|
||||
@@ -440,7 +460,9 @@ from .Items import is_progression # this is just a dummy
|
||||
def create_item(self, item: str):
|
||||
# This is called when AP wants to create an item by name (for plando) or
|
||||
# when you call it from your own code.
|
||||
return MyGameItem(item, is_progression(item), self.item_name_to_id[item],
|
||||
classification = ItemClassification.progression if is_progression(item) else \
|
||||
ItemClassification.filler
|
||||
return MyGameItem(item, classification, self.item_name_to_id[item],
|
||||
self.player)
|
||||
|
||||
def create_event(self, event: str):
|
||||
@@ -451,7 +473,7 @@ def create_event(self, event: str):
|
||||
#### create_items
|
||||
|
||||
```python
|
||||
def create_items(self):
|
||||
def create_items(self) -> None:
|
||||
# Add items to the Multiworld.
|
||||
# If there are two of the same item, the item has to be twice in the pool.
|
||||
# Which items are added to the pool may depend on player settings,
|
||||
@@ -478,23 +500,23 @@ def create_items(self):
|
||||
#### create_regions
|
||||
|
||||
```python
|
||||
def create_regions(self):
|
||||
def create_regions(self) -> None:
|
||||
# Add regions to the multiworld. "Menu" is the required starting point.
|
||||
# Arguments to Region() are name, type, human_readable_name, player, world
|
||||
r = Region("Menu", None, "Menu", self.player, self.world)
|
||||
r = Region("Menu", RegionType.Generic, "Menu", self.player, self.world)
|
||||
# Set Region.exits to a list of entrances that are reachable from region
|
||||
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
|
||||
# Append region to MultiWorld's regions
|
||||
self.world.regions.append(r) # or use += [r...]
|
||||
|
||||
r = Region("Main Area", None, "Main Area", self.player, self.world)
|
||||
r = Region("Main Area", RegionType.Generic, "Main Area", self.player, self.world)
|
||||
# Add main area's locations to main area (all but final boss)
|
||||
r.locations = [MyGameLocation(self.player, location.name,
|
||||
self.location_name_to_id[location.name], r)]
|
||||
r.exits = [Entrance(self.player, "Boss Door", r)]
|
||||
self.world.regions.append(r)
|
||||
|
||||
r = Region("Boss Room", None, "Boss Room", self.player, self.world)
|
||||
r = Region("Boss Room", RegionType.Generic, "Boss Room", self.player, self.world)
|
||||
# add event to Boss Room
|
||||
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
|
||||
self.world.regions.append(r)
|
||||
@@ -513,7 +535,7 @@ def create_regions(self):
|
||||
#### generate_basic
|
||||
|
||||
```python
|
||||
def generate_basic(self):
|
||||
def generate_basic(self) -> None:
|
||||
# place "Victory" at "Final Boss" and set collection as win condition
|
||||
self.world.get_location("Final Boss", self.player)\
|
||||
.place_locked_item(self.create_event("Victory"))
|
||||
@@ -534,7 +556,7 @@ def generate_basic(self):
|
||||
from ..generic.Rules import add_rule, set_rule, forbid_item
|
||||
from Items import get_item_type
|
||||
|
||||
def set_rules(self):
|
||||
def set_rules(self) -> None:
|
||||
# For some worlds this step can be omitted if either a Logic mixin
|
||||
# (see below) is used, it's easier to apply the rules from data during
|
||||
# location generation or everything is in generate_basic
|
||||
@@ -618,7 +640,7 @@ class MyGameWorld(World):
|
||||
# ...
|
||||
def set_rules(self):
|
||||
set_rule(self.world.get_location("A Door", self.player),
|
||||
lamda state: state._myworld_has_key(self.world, self.player))
|
||||
lamda state: state._mygame_has_key(self.world, self.player))
|
||||
```
|
||||
|
||||
### Generate Output
|
||||
@@ -56,7 +56,7 @@ server_options:
|
||||
# Options for Generation
|
||||
generator:
|
||||
# Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases
|
||||
enemizer_path: "EnemizerCLI/EnemizerCLI.Core.exe"
|
||||
enemizer_path: "EnemizerCLI/EnemizerCLI.Core" # + ".exe" is implied on Windows
|
||||
# Folder from which the player yaml files are pulled from
|
||||
player_files_path: "Players"
|
||||
#amount of players, 0 to infer from player files
|
||||
@@ -105,7 +105,10 @@ factorio_options:
|
||||
minecraft_options:
|
||||
forge_directory: "Minecraft Forge server"
|
||||
max_heap_size: "2G"
|
||||
oot_options:
|
||||
# release channel, currently "release", or "beta"
|
||||
# any games played on the "beta" channel have a high likelihood of no longer working on the "release" channel.
|
||||
release_channel: "release"
|
||||
oot_options:
|
||||
# File name of the OoT v1.0 ROM
|
||||
rom_file: "The Legend of Zelda - Ocarina of Time.z64"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
@@ -123,4 +126,4 @@ smz3_options:
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
rom_start: true
|
||||
rom_start: true
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#define MyAppExeName "ArchipelagoServer.exe"
|
||||
#define MyAppIcon "data/icon.ico"
|
||||
#dim VersionTuple[4]
|
||||
#define MyAppVersion ParseVersion(source_path + '\ArchipelagoServer.exe', VersionTuple[0], VersionTuple[1], VersionTuple[2], VersionTuple[3])
|
||||
#define MyAppVersion GetVersionComponents(source_path + '\ArchipelagoServer.exe', VersionTuple[0], VersionTuple[1], VersionTuple[2], VersionTuple[3])
|
||||
#define MyAppVersionText Str(VersionTuple[0])+"."+Str(VersionTuple[1])+"."+Str(VersionTuple[2])
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
|
||||
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
|
||||
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
|
||||
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
|
||||
|
||||
[Dirs]
|
||||
@@ -90,6 +92,8 @@ Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags
|
||||
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
|
||||
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
|
||||
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
|
||||
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
|
||||
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
|
||||
|
||||
[Icons]
|
||||
@@ -101,6 +105,8 @@ Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactor
|
||||
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
|
||||
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
|
||||
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
|
||||
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
|
||||
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
|
||||
|
||||
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
|
||||
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
|
||||
@@ -109,6 +115,8 @@ Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\Archipela
|
||||
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
|
||||
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
|
||||
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
|
||||
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
|
||||
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
|
||||
|
||||
[Run]
|
||||
|
||||
@@ -124,20 +132,25 @@ Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||
|
||||
[Registry]
|
||||
|
||||
Root: HKCR; Subkey: ".apbp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: ".aplttp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apm3"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: ".apsm"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1""";
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
@@ -154,6 +167,10 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
|
||||
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text
|
||||
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text
|
||||
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text
|
||||
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text
|
||||
|
||||
[Code]
|
||||
const
|
||||
@@ -377,5 +394,5 @@ begin
|
||||
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/oot'));
|
||||
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
|
||||
end;
|
||||
221
kvui.py
@@ -8,7 +8,11 @@ os.environ["KIVY_NO_FILELOG"] = "1"
|
||||
os.environ["KIVY_NO_ARGS"] = "1"
|
||||
os.environ["KIVY_LOG_ENABLE"] = "0"
|
||||
|
||||
from kivy.base import Config
|
||||
import Utils
|
||||
if Utils.is_frozen():
|
||||
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
|
||||
|
||||
from kivy.config import Config
|
||||
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
Config.set('kivy', 'exit_on_escape', '0')
|
||||
@@ -18,7 +22,8 @@ from kivy.app import App
|
||||
from kivy.core.window import Window
|
||||
from kivy.core.clipboard import Clipboard
|
||||
from kivy.core.text.markup import MarkupLabel
|
||||
from kivy.base import ExceptionHandler, ExceptionManager, Clock
|
||||
from kivy.base import ExceptionHandler, ExceptionManager
|
||||
from kivy.clock import Clock
|
||||
from kivy.factory import Factory
|
||||
from kivy.properties import BooleanProperty, ObjectProperty
|
||||
from kivy.uix.button import Button
|
||||
@@ -36,9 +41,13 @@ from kivy.uix.recycleview.views import RecycleDataViewBehavior
|
||||
from kivy.uix.behaviors import FocusBehavior
|
||||
from kivy.uix.recycleboxlayout import RecycleBoxLayout
|
||||
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
|
||||
from kivy.animation import Animation
|
||||
from kivy.uix.popup import Popup
|
||||
|
||||
import Utils
|
||||
from NetUtils import JSONtoTextParser, JSONMessagePart
|
||||
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
|
||||
|
||||
|
||||
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import CommonClient
|
||||
@@ -50,7 +59,7 @@ else:
|
||||
|
||||
# I was surprised to find this didn't already exist in kivy :(
|
||||
class HoverBehavior(object):
|
||||
"""from https://stackoverflow.com/a/605348110"""
|
||||
"""originally from https://stackoverflow.com/a/605348110"""
|
||||
hovered = BooleanProperty(False)
|
||||
border_point = ObjectProperty(None)
|
||||
|
||||
@@ -61,11 +70,11 @@ class HoverBehavior(object):
|
||||
Window.bind(on_cursor_leave=self.on_cursor_leave)
|
||||
super(HoverBehavior, self).__init__(**kwargs)
|
||||
|
||||
def on_mouse_pos(self, *args):
|
||||
def on_mouse_pos(self, window, pos):
|
||||
if not self.get_root_window():
|
||||
return # do proceed if I'm not displayed <=> If have no parent
|
||||
pos = args[1]
|
||||
# Next line to_widget allow to compensate for relative layout
|
||||
return # Abort if not displayed
|
||||
|
||||
# to_widget translates window pos to within widget pos
|
||||
inside = self.collide_point(*self.to_widget(*pos))
|
||||
if self.hovered == inside:
|
||||
return # We have already done what was needed
|
||||
@@ -87,11 +96,19 @@ class HoverBehavior(object):
|
||||
Factory.register('HoverBehavior', HoverBehavior)
|
||||
|
||||
|
||||
class ServerToolTip(Label):
|
||||
class ToolTip(Label):
|
||||
pass
|
||||
|
||||
|
||||
class ServerToolTip(ToolTip):
|
||||
pass
|
||||
|
||||
|
||||
class HovererableLabel(HoverBehavior, Label):
|
||||
pass
|
||||
|
||||
|
||||
class ServerLabel(HovererableLabel):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HovererableLabel, self).__init__(*args, **kwargs)
|
||||
self.layout = FloatLayout()
|
||||
@@ -101,6 +118,7 @@ class HovererableLabel(HoverBehavior, Label):
|
||||
def on_enter(self):
|
||||
self.popuplabel.text = self.get_text()
|
||||
App.get_running_app().root.add_widget(self.layout)
|
||||
fade_in_animation.start(self.layout)
|
||||
|
||||
def on_leave(self):
|
||||
App.get_running_app().root.remove_widget(self.layout)
|
||||
@@ -109,8 +127,6 @@ class HovererableLabel(HoverBehavior, Label):
|
||||
def ctx(self) -> context_type:
|
||||
return App.get_running_app().ctx
|
||||
|
||||
|
||||
class ServerLabel(HovererableLabel):
|
||||
def get_text(self):
|
||||
if self.ctx.server:
|
||||
ctx = self.ctx
|
||||
@@ -158,10 +174,11 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
|
||||
""" Adds selection and focus behaviour to the view. """
|
||||
|
||||
|
||||
class SelectableLabel(RecycleDataViewBehavior, Label):
|
||||
class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
|
||||
""" Add selection support to the Label """
|
||||
index = None
|
||||
selected = BooleanProperty(False)
|
||||
tooltip = None
|
||||
|
||||
def refresh_view_attrs(self, rv, index, data):
|
||||
""" Catch and handle the view changes """
|
||||
@@ -169,6 +186,56 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
|
||||
return super(SelectableLabel, self).refresh_view_attrs(
|
||||
rv, index, data)
|
||||
|
||||
def create_tooltip(self, text, x, y):
|
||||
text = text.replace("<br>", "\n").replace('&', '&').replace('&bl;', '[').replace('&br;', ']')
|
||||
if self.tooltip:
|
||||
# update
|
||||
self.tooltip.children[0].text = text
|
||||
else:
|
||||
self.tooltip = FloatLayout()
|
||||
tooltip_label = ToolTip(text=text)
|
||||
self.tooltip.add_widget(tooltip_label)
|
||||
fade_in_animation.start(self.tooltip)
|
||||
App.get_running_app().root.add_widget(self.tooltip)
|
||||
|
||||
# handle left-side boundary to not render off-screen
|
||||
x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2)
|
||||
|
||||
# position float layout
|
||||
self.tooltip.x = x - self.tooltip.width / 2
|
||||
self.tooltip.y = y - self.tooltip.height / 2 + 48
|
||||
|
||||
def remove_tooltip(self):
|
||||
if self.tooltip:
|
||||
App.get_running_app().root.remove_widget(self.tooltip)
|
||||
self.tooltip = None
|
||||
|
||||
def on_mouse_pos(self, window, pos):
|
||||
if not self.get_root_window():
|
||||
return # Abort if not displayed
|
||||
super().on_mouse_pos(window, pos)
|
||||
if self.refs and self.hovered:
|
||||
|
||||
tx, ty = self.to_widget(*pos, relative=True)
|
||||
# Why TF is Y flipped *within* the texture?
|
||||
ty = self.texture_size[1] - ty
|
||||
hit = False
|
||||
for uid, zones in self.refs.items():
|
||||
for zone in zones:
|
||||
x, y, w, h = zone
|
||||
if x <= tx <= w and y <= ty <= h:
|
||||
self.create_tooltip(uid.split("|", 1)[1], *pos)
|
||||
hit = True
|
||||
break
|
||||
if not hit:
|
||||
self.remove_tooltip()
|
||||
|
||||
def on_enter(self):
|
||||
pass
|
||||
|
||||
def on_leave(self):
|
||||
self.remove_tooltip()
|
||||
|
||||
def on_touch_down(self, touch):
|
||||
""" Add selection on touch down """
|
||||
if super(SelectableLabel, self).on_touch_down(touch):
|
||||
@@ -179,7 +246,7 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
|
||||
else:
|
||||
# Not a fan of the following few lines, but they work.
|
||||
temp = MarkupLabel(text=self.text).markup
|
||||
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]")))
|
||||
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
|
||||
cmdinput = App.get_running_app().textinput
|
||||
if not cmdinput.text and " did you mean " in text:
|
||||
for question in ("Didn't find something that closely matches, did you mean ",
|
||||
@@ -192,7 +259,7 @@ class SelectableLabel(RecycleDataViewBehavior, Label):
|
||||
elif not cmdinput.text and text.startswith("Missing: "):
|
||||
cmdinput.text = text.replace("Missing: ", "!hint_location ")
|
||||
|
||||
Clipboard.copy(text)
|
||||
Clipboard.copy(text.replace('&', '&').replace('&bl;', '[').replace('&br;', ']'))
|
||||
return self.parent.select_with_touch(self.index, touch)
|
||||
|
||||
def apply_selection(self, rv, index, is_selected):
|
||||
@@ -206,6 +273,25 @@ class ConnectBarTextInput(TextInput):
|
||||
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
|
||||
|
||||
|
||||
class MessageBox(Popup):
|
||||
class MessageBoxLabel(Label):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._label.refresh()
|
||||
self.size = self._label.texture.size
|
||||
if self.width + 50 > Window.width:
|
||||
self.text_size[0] = Window.width - 50
|
||||
self._label.refresh()
|
||||
self.size = self._label.texture.size
|
||||
|
||||
def __init__(self, title, text, error=False, **kwargs):
|
||||
label = MessageBox.MessageBoxLabel(text=text)
|
||||
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
|
||||
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width)+40),
|
||||
separator_color=separator_color, **kwargs)
|
||||
self.height += max(0, label.height - 18)
|
||||
|
||||
|
||||
class GameManager(App):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
@@ -248,8 +334,8 @@ class GameManager(App):
|
||||
# top part
|
||||
server_label = ServerLabel()
|
||||
self.connect_layout.add_widget(server_label)
|
||||
self.server_connect_bar = ConnectBarTextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False,
|
||||
write_tab=False)
|
||||
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.server_address or "archipelago.gg", size_hint_y=None,
|
||||
height=30, multiline=False, write_tab=False)
|
||||
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
|
||||
self.connect_layout.add_widget(self.server_connect_bar)
|
||||
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
|
||||
@@ -302,7 +388,8 @@ class GameManager(App):
|
||||
return self.container
|
||||
|
||||
def update_texts(self, dt):
|
||||
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||
if hasattr(self.tabs.content.children[0], 'fix_heights'):
|
||||
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||
if self.ctx.server:
|
||||
self.title = self.base_title + " " + Utils.__version__ + \
|
||||
f" | Connected to: {self.ctx.server_address} " \
|
||||
@@ -325,6 +412,7 @@ class GameManager(App):
|
||||
def connect_button_action(self, button):
|
||||
if self.ctx.server:
|
||||
self.ctx.server_address = None
|
||||
self.ctx.username = None
|
||||
asyncio.create_task(self.ctx.disconnect())
|
||||
else:
|
||||
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
|
||||
@@ -358,6 +446,12 @@ class GameManager(App):
|
||||
self.log_panels["Archipelago"].on_message_markup(text)
|
||||
self.log_panels["All"].on_message_markup(text)
|
||||
|
||||
def update_address_bar(self, text: str):
|
||||
if hasattr(self, "server_connect_bar"):
|
||||
self.server_connect_bar.text = text
|
||||
else:
|
||||
logging.getLogger("Client").info("Could not update address bar as the GUI is not yet initialized.")
|
||||
|
||||
def enable_energy_link(self):
|
||||
if not hasattr(self, "energy_link_label"):
|
||||
self.energy_link_label = Label(text="Energy Link: Standby",
|
||||
@@ -369,57 +463,24 @@ class GameManager(App):
|
||||
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
|
||||
|
||||
|
||||
class FactorioManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("FactorioServer", "Factorio Server Log"),
|
||||
("FactorioWatcher", "Bridge Data Log"),
|
||||
]
|
||||
base_title = "Archipelago Factorio Client"
|
||||
|
||||
|
||||
class ChecksFinderManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago ChecksFinder Client"
|
||||
|
||||
|
||||
class SNIManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago"),
|
||||
("SNES", "SNES"),
|
||||
]
|
||||
base_title = "Archipelago SNI Client"
|
||||
|
||||
|
||||
class TextManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Text Client"
|
||||
|
||||
|
||||
class FF1Manager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Final Fantasy 1 Client"
|
||||
|
||||
class OoTManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Ocarina of Time Client"
|
||||
|
||||
|
||||
class LogtoUI(logging.Handler):
|
||||
def __init__(self, on_log):
|
||||
super(LogtoUI, self).__init__(logging.INFO)
|
||||
self.on_log = on_log
|
||||
|
||||
@staticmethod
|
||||
def format_compact(record: logging.LogRecord) -> str:
|
||||
if isinstance(record.msg, Exception):
|
||||
return str(record.msg)
|
||||
return (f'{record.exc_info[1]}\n' if record.exc_info else '') + str(record.msg).split("\n")[0]
|
||||
|
||||
def handle(self, record: logging.LogRecord) -> None:
|
||||
self.on_log(self.format(record))
|
||||
if getattr(record, 'skip_gui', False):
|
||||
pass # skip output
|
||||
elif getattr(record, 'compact_gui', False):
|
||||
self.on_log(self.format_compact(record))
|
||||
else:
|
||||
self.on_log(self.format(record))
|
||||
|
||||
|
||||
class UILog(RecycleView):
|
||||
@@ -453,6 +514,34 @@ class E(ExceptionHandler):
|
||||
|
||||
|
||||
class KivyJSONtoTextParser(JSONtoTextParser):
|
||||
def __call__(self, *args, **kwargs):
|
||||
self.ref_count = 0
|
||||
return super(KivyJSONtoTextParser, self).__call__(*args, **kwargs)
|
||||
|
||||
def _handle_item_name(self, node: JSONMessagePart):
|
||||
flags = node.get("flags", 0)
|
||||
if flags & 0b001: # advancement
|
||||
itemtype = "progression"
|
||||
elif flags & 0b010: # useful
|
||||
itemtype = "useful"
|
||||
elif flags & 0b100: # trap
|
||||
itemtype = "trap"
|
||||
else:
|
||||
itemtype = "normal"
|
||||
node.setdefault("refs", []).append("Item Class: " + itemtype)
|
||||
return super(KivyJSONtoTextParser, self)._handle_item_name(node)
|
||||
|
||||
def _handle_player_id(self, node: JSONMessagePart):
|
||||
player = int(node["text"])
|
||||
slot_info = self.ctx.slot_info.get(player, None)
|
||||
if slot_info:
|
||||
text = f"Game: {slot_info.game}<br>" \
|
||||
f"Type: {SlotType(slot_info.type).name}"
|
||||
if slot_info.group_members:
|
||||
text += f"<br>Members:<br> " + \
|
||||
'<br> '.join(self.ctx.player_names[player] for player in slot_info.group_members)
|
||||
node.setdefault("refs", []).append(text)
|
||||
return super(KivyJSONtoTextParser, self)._handle_player_id(node)
|
||||
|
||||
def _handle_color(self, node: JSONMessagePart):
|
||||
colors = node["color"].split(";")
|
||||
@@ -464,6 +553,12 @@ class KivyJSONtoTextParser(JSONtoTextParser):
|
||||
return self._handle_text(node)
|
||||
return self._handle_text(node)
|
||||
|
||||
def _handle_text(self, node: JSONMessagePart):
|
||||
for ref in node.get("refs", []):
|
||||
node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]"
|
||||
self.ref_count += 1
|
||||
return super(KivyJSONtoTextParser, self)._handle_text(node)
|
||||
|
||||
|
||||
ExceptionManager.add_handler(E())
|
||||
|
||||
|
||||
@@ -26,15 +26,17 @@ name: YourName{number} # Your name in-game. Spaces will be replaced with undersc
|
||||
game: # Pick a game to play
|
||||
A Link to the Past: 1
|
||||
requires:
|
||||
version: 0.2.3 # Version of Archipelago required for this yaml to work as expected.
|
||||
version: 0.3.3 # 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.
|
||||
progression_balancing: # A system to reduce BK, as in times during which you can't do anything, by moving your items into an earlier access sphere
|
||||
0: 0 # Choose a lower number if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
|
||||
25: 0
|
||||
50: 50 # Make it likely you have stuff to do.
|
||||
99: 0 # Get important items early, and stay at the front of the progression.
|
||||
A Link to the Past:
|
||||
### Logic Section ###
|
||||
glitches_required: # Determine the logic required to complete the seed
|
||||
@@ -167,8 +169,11 @@ A Link to the Past:
|
||||
standard: 0 # 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
|
||||
retro_bow:
|
||||
on: 0 # Zelda-1 like mode. You have to purchase a quiver to shoot arrows using rupees.
|
||||
off: 50
|
||||
retro_caves:
|
||||
on: 0 # Zelda-1 like mode. There are randomly placed take-any caves that contain one Sword and choices of Heart Container/Blue Potion.
|
||||
off: 50
|
||||
hints: # Vendors: King Zora and Bottle Merchant say what they're selling.
|
||||
# On/Full: Put item and entrance placement hints on telepathic tiles and some NPCs, Full removes joke hints.
|
||||
@@ -444,6 +449,11 @@ A Link to the Past:
|
||||
death_link:
|
||||
false: 50
|
||||
true: 0
|
||||
|
||||
allow_collect: # Allows for !collect / co-op to auto-open chests containing items for other players.
|
||||
# Off by default, because it currently crashes on real hardware.
|
||||
false: 50
|
||||
true: 0
|
||||
|
||||
linked_options:
|
||||
- name: crosskeys
|
||||
@@ -526,4 +536,4 @@ triggers:
|
||||
percentage: 0 # AND has a 0 percent chance (meaning this is default disabled, just to show how it works)
|
||||
options: # then inserts these options
|
||||
A Link to the Past:
|
||||
swordless: off
|
||||
swordless: off
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
colorama>=0.4.4
|
||||
websockets>=10.2
|
||||
websockets>=10.3
|
||||
PyYAML>=6.0
|
||||
thefuzz[speedup]>=0.19.0
|
||||
jinja2>=3.1.1
|
||||
jellyfish>=0.9.0
|
||||
jinja2>=3.1.2
|
||||
schema>=0.7.4
|
||||
kivy>=2.1.0
|
||||
bsdiff4>=1.2.2
|
||||
105
setup.py
@@ -2,11 +2,12 @@ import os
|
||||
import shutil
|
||||
import sys
|
||||
import sysconfig
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from hashlib import sha3_512
|
||||
import base64
|
||||
import datetime
|
||||
from Utils import version_tuple
|
||||
from Utils import version_tuple, is_windows, is_linux
|
||||
from collections.abc import Iterable
|
||||
import typing
|
||||
import setuptools
|
||||
@@ -16,7 +17,7 @@ from Launcher import components, icon_paths
|
||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
requirement = 'cx-Freeze>=6.10'
|
||||
requirement = 'cx-Freeze>=6.11'
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
import cx_Freeze
|
||||
@@ -29,17 +30,18 @@ except pkg_resources.ResolutionError:
|
||||
|
||||
if os.path.exists("X:/pw.txt"):
|
||||
print("Using signtool")
|
||||
with open("X:/pw.txt") as f:
|
||||
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
||||
pw = f.read()
|
||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p ' + pw + r' /fd sha256 /tr http://timestamp.digicert.com/ '
|
||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + r'" /fd sha256 /tr http://timestamp.digicert.com/ '
|
||||
else:
|
||||
signtool = None
|
||||
|
||||
|
||||
arch_folder = "exe.{platform}-{version}".format(platform=sysconfig.get_platform(),
|
||||
build_platform = sysconfig.get_platform()
|
||||
arch_folder = "exe.{platform}-{version}".format(platform=build_platform,
|
||||
version=sysconfig.get_python_version())
|
||||
buildfolder = Path("build", arch_folder)
|
||||
is_windows = sys.platform in ("win32", "cygwin", "msys")
|
||||
build_arch = build_platform.split('-')[-1] if '-' in build_platform else platform.machine()
|
||||
|
||||
|
||||
# see Launcher.py on how to add scripts to setup.py
|
||||
@@ -68,7 +70,7 @@ def _threaded_hash(filepath):
|
||||
|
||||
|
||||
# cx_Freeze's build command runs other commands. Override to accept --yes and store that.
|
||||
class BuildCommand(cx_Freeze.dist.build):
|
||||
class BuildCommand(cx_Freeze.command.build.Build):
|
||||
user_options = [
|
||||
('yes', 'y', 'Answer "yes" to all questions.'),
|
||||
]
|
||||
@@ -85,8 +87,8 @@ class BuildCommand(cx_Freeze.dist.build):
|
||||
|
||||
|
||||
# Override cx_Freeze's build_exe command for pre and post build steps
|
||||
class BuildExeCommand(cx_Freeze.dist.build_exe):
|
||||
user_options = cx_Freeze.dist.build_exe.user_options + [
|
||||
class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
user_options = cx_Freeze.command.build_exe.BuildEXE.user_options + [
|
||||
('yes', 'y', 'Answer "yes" to all questions.'),
|
||||
('extra-data=', None, 'Additional files to add.'),
|
||||
]
|
||||
@@ -109,8 +111,10 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
|
||||
self.libfolder = Path(self.buildfolder, "lib")
|
||||
self.library = Path(self.libfolder, "library.zip")
|
||||
|
||||
def installfile(self, path, keep_content=False):
|
||||
def installfile(self, path, subpath=None, keep_content: bool = False):
|
||||
folder = self.buildfolder
|
||||
if subpath:
|
||||
folder /= subpath
|
||||
print('copying', path, '->', folder)
|
||||
if path.is_dir():
|
||||
folder /= path.name
|
||||
@@ -156,6 +160,11 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
|
||||
self.buildtime = datetime.datetime.utcnow()
|
||||
super().run()
|
||||
|
||||
# include_files seems to be broken with this setup. implement here
|
||||
for src, dst in self.include_files:
|
||||
print('copying', src, '->', self.buildfolder / dst)
|
||||
shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False)
|
||||
|
||||
# post build steps
|
||||
if sys.platform == "win32": # kivy_deps is win32 only, linux picks them up automatically
|
||||
from kivy_deps import sdl2, glew
|
||||
@@ -166,6 +175,12 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
|
||||
for data in self.extra_data:
|
||||
self.installfile(Path(data))
|
||||
|
||||
# kivi data files
|
||||
import kivy
|
||||
shutil.copytree(os.path.join(os.path.dirname(kivy.__file__), "data"),
|
||||
self.buildfolder / "data",
|
||||
dirs_exist_ok=True)
|
||||
|
||||
os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True)
|
||||
from WebHostLib.options import create
|
||||
create()
|
||||
@@ -176,12 +191,12 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
|
||||
shutil.copyfile(os.path.join("WebHostLib", "static", "generated", "configs", file_name),
|
||||
self.buildfolder / "Players" / "Templates" / file_name)
|
||||
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
||||
|
||||
# TODO: fix LttP options one day
|
||||
shutil.copyfile("playerSettings.yaml", self.buildfolder / "Players" / "Templates" / "A Link to the Past.yaml")
|
||||
try:
|
||||
from maseya import z3pr
|
||||
except ImportError:
|
||||
print("Maseya Palette Shuffle not found, skipping data files.")
|
||||
z3pr = None
|
||||
else:
|
||||
# maseya Palette Shuffle exists and needs its data files
|
||||
print("Maseya Palette Shuffle found, including data files...")
|
||||
@@ -218,7 +233,6 @@ class BuildExeCommand(cx_Freeze.dist.build_exe):
|
||||
host_yaml = self.buildfolder / 'host.yaml'
|
||||
with host_yaml.open('r+b') as f:
|
||||
data = f.read()
|
||||
data = data.replace(b'EnemizerCLI.Core.exe', b'EnemizerCLI.Core')
|
||||
data = data.replace(b'factorio\\\\bin\\\\x64\\\\factorio', b'factorio/bin/x64/factorio')
|
||||
f.seek(0, os.SEEK_SET)
|
||||
f.write(data)
|
||||
@@ -267,7 +281,7 @@ match="${{1#--executable=}}"
|
||||
if [ "${{#match}}" -lt "${{#1}}" ]; then
|
||||
exe="$match"
|
||||
shift
|
||||
elif [ "$1" == "-executable" ] || [ "$1" == "--executable" ]; then
|
||||
elif [ "$1" = "-executable" ] || [ "$1" = "--executable" ]; then
|
||||
exe="$2"
|
||||
shift; shift
|
||||
fi
|
||||
@@ -332,7 +346,61 @@ $APPDIR/$exe "$@"
|
||||
self.write_desktop()
|
||||
self.write_launcher(self.app_exec)
|
||||
print(f'{self.app_dir} -> {self.dist_file}')
|
||||
subprocess.call(f'./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
|
||||
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
|
||||
|
||||
|
||||
def find_libs(*args: str) -> typing.Sequence[typing.Tuple[str, str]]:
|
||||
"""Try to find system libraries to be included."""
|
||||
arch = build_arch.replace('_', '-')
|
||||
libc = 'libc6' # we currently don't support musl
|
||||
|
||||
def parse(line):
|
||||
lib, path = line.strip().split(' => ')
|
||||
lib, typ = lib.split(' ', 1)
|
||||
for test_arch in ('x86-64', 'i386', 'aarch64'):
|
||||
if test_arch in typ:
|
||||
lib_arch = test_arch
|
||||
break
|
||||
else:
|
||||
lib_arch = ''
|
||||
for test_libc in ('libc6',):
|
||||
if test_libc in typ:
|
||||
lib_libc = test_libc
|
||||
break
|
||||
else:
|
||||
lib_libc = ''
|
||||
return (lib, lib_arch, lib_libc), path
|
||||
|
||||
if not hasattr(find_libs, "cache"):
|
||||
data = subprocess.run([shutil.which('ldconfig'), '-p'], capture_output=True, text=True).stdout.split('\n')[1:]
|
||||
find_libs.cache = {k: v for k, v in (parse(line) for line in data if '=>' in line)}
|
||||
|
||||
def find_lib(lib, arch, libc):
|
||||
for k, v in find_libs.cache.items():
|
||||
if k == (lib, arch, libc):
|
||||
return v
|
||||
for k, v, in find_libs.cache.items():
|
||||
if k[0].startswith(lib) and k[1] == arch and k[2] == libc:
|
||||
return v
|
||||
return None
|
||||
|
||||
res = []
|
||||
for arg in args:
|
||||
# try exact match, empty libc, empty arch, empty arch and libc
|
||||
file = find_lib(arg, arch, libc)
|
||||
file = file or find_lib(arg, arch, '')
|
||||
file = file or find_lib(arg, '', libc)
|
||||
file = file or find_lib(arg, '', '')
|
||||
# resolve symlinks
|
||||
for n in range(0, 5):
|
||||
res.append((file, os.path.join('lib', os.path.basename(file))))
|
||||
if not os.path.islink(file):
|
||||
break
|
||||
dirname = os.path.dirname(file)
|
||||
file = os.readlink(file)
|
||||
if not os.path.isabs(file):
|
||||
file = os.path.join(dirname, file)
|
||||
return res
|
||||
|
||||
|
||||
cx_Freeze.setup(
|
||||
@@ -340,6 +408,7 @@ cx_Freeze.setup(
|
||||
version=f"{version_tuple.major}.{version_tuple.minor}.{version_tuple.build}",
|
||||
description="Archipelago",
|
||||
executables=exes,
|
||||
ext_modules=[], # required to disable auto-discovery with setuptools>=61
|
||||
options={
|
||||
"build_exe": {
|
||||
"packages": ["websockets", "worlds", "kivy"],
|
||||
@@ -347,14 +416,14 @@ cx_Freeze.setup(
|
||||
"excludes": ["numpy", "Cython", "PySide2", "PIL",
|
||||
"pandas"],
|
||||
"zip_include_packages": ["*"],
|
||||
"zip_exclude_packages": ["worlds", "kivy"],
|
||||
"include_files": [],
|
||||
"zip_exclude_packages": ["worlds", "sc2"],
|
||||
"include_files": find_libs("libssl.so", "libcrypto.so") if is_linux else [],
|
||||
"include_msvcr": False,
|
||||
"replace_paths": [("*", "")],
|
||||
"optimize": 1,
|
||||
"build_exe": buildfolder,
|
||||
"extra_data": extra_data,
|
||||
"bin_includes": [] if is_windows else ["libffi.so"]
|
||||
"bin_includes": ["libffi.so", "libcrypt.so"] if is_linux else []
|
||||
},
|
||||
"bdist_appimage": {
|
||||
"build_folder": buildfolder,
|
||||
|
||||
@@ -6,7 +6,7 @@ import Utils
|
||||
file_path = pathlib.Path(__file__).parent.parent
|
||||
Utils.local_path.cached_path = file_path
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState
|
||||
from BaseClasses import MultiWorld, CollectionState, ItemClassification
|
||||
from worlds.alttp.Items import ItemFactory
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestBase(unittest.TestCase):
|
||||
return self._state_cache[self.world, tuple(items)]
|
||||
state = CollectionState(self.world)
|
||||
for item in items:
|
||||
item.advancement = True
|
||||
item.classification = ItemClassification.progression
|
||||
state.collect(item)
|
||||
state.sweep_for_events()
|
||||
self._state_cache[self.world, tuple(items)] = state
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import warnings
|
||||
warnings.simplefilter("always")
|
||||
|
||||