Merge remote-tracking branch 'remotes/upstream/main'

This commit is contained in:
massimilianodelliubaldini
2025-04-18 12:40:45 -04:00
127 changed files with 2363 additions and 1513 deletions

View File

@@ -65,7 +65,7 @@ jobs:
continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files"
continue-on-error: true

View File

@@ -99,8 +99,8 @@ jobs:
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004:
runs-on: ubuntu-20.04
build-ubuntu2204:
runs-on: ubuntu-22.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4

View File

@@ -29,8 +29,8 @@ jobs:
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-ubuntu2004:
runs-on: ubuntu-20.04
build-release-ubuntu2204:
runs-on: ubuntu-22.04
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV

View File

@@ -616,7 +616,7 @@ class MultiWorld():
locations: Set[Location] = set()
events: Set[Location] = set()
for location in self.get_filled_locations():
if type(location.item.code) is int:
if type(location.item.code) is int and type(location.address) is int:
locations.add(location)
else:
events.add(location)

View File

@@ -75,9 +75,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
items_to_place.append(reachable_items[next_player].pop())
for item in items_to_place:
for p, pool_item in enumerate(item_pool):
# The items added into `reachable_items` are placed starting from the end of each deque in
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
for p, pool_item in enumerate(reversed(item_pool), start=1):
if pool_item is item:
item_pool.pop(p)
del item_pool[-p]
break
maximum_exploration_state = sweep_from_pool(

View File

@@ -8,9 +8,7 @@ Archipelago Launcher
Scroll down to components= to add components to the launcher as well as setup.py
"""
import os
import argparse
import itertools
import logging
import multiprocessing
import shlex
@@ -132,7 +130,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
from kivymd.uix.divider import MDDivider
if client_component is None:
if not client_component:
run_component(text_client_component, *launch_args)
return
else:
@@ -228,14 +226,13 @@ refresh_components: Optional[Callable[[], None]] = None
def run_gui(path: str, args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, MDButton, MDLabel, MDButtonText, ScrollBox, ApAsyncImage)
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
from kivy.core.window import Window
from kivy.metrics import dp
from kivymd.uix.button import MDIconButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.relativelayout import MDRelativeLayout
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivy.lang.builder import Builder
@@ -250,7 +247,6 @@ def run_gui(path: str, args: Any) -> None:
self.image = image_path
super().__init__(args, kwargs)
class Launcher(ThemedApp):
base_title: str = "Archipelago Launcher"
top_screen: MDFloatLayout = ObjectProperty(None)
@@ -337,6 +333,11 @@ def run_gui(path: str, args: Any) -> None:
for card in cards:
self.button_layout.layout.add_widget(card)
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
- self.button_layout.height
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
def filter_clients(self, caller):
self._refresh_components(caller.type)
@@ -358,6 +359,11 @@ def run_gui(path: str, args: Any) -> None:
self._refresh_components(self.current_filter)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
return self.top_screen
def on_start(self):

View File

@@ -26,6 +26,7 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
from NetUtils import ClientStatus
from worlds.ladx import LinksAwakeningWorld
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
@@ -741,8 +742,8 @@ class LinksAwakeningContext(CommonContext):
await asyncio.sleep(1.0)
def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["ladx_options"].get("rom_start", True))
auto_start = LinksAwakeningWorld.settings.rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -28,6 +28,6 @@ def get_seeds():
response.append({
"seed_id": seed.id,
"creation_time": seed.creation_time,
"players": get_players(seed.slots),
"players": get_players(seed),
})
return jsonify(response)

View File

@@ -9,7 +9,7 @@ from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit
from pony.orm import db_session, select, commit, PrimaryKey
from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
@@ -36,12 +36,21 @@ def handle_generation_failure(result: BaseException):
logging.exception(e)
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
from setproctitle import setproctitle
setproctitle(f"Generator ({sid})")
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
setproctitle(f"Generator (idle)")
return res
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(gen_game, (options,),
pool.apply_async(_mp_gen_game, (options,),
{"meta": meta,
"sid": generation.id,
"owner": generation.owner},
@@ -55,6 +64,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
def init_generator(config: dict[str, Any]) -> None:
from setproctitle import setproctitle
setproctitle("Generator (idle)")
try:
import resource
except ModuleNotFoundError:

View File

@@ -227,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger:
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
from setproctitle import setproctitle
setproctitle(name)
Utils.init_logging(name)
try:
import resource
@@ -247,8 +250,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
del cert_file, cert_key_file, ponyconfig
if not cert_file:
def get_ssl_context():
return None
else:
load_date = None
ssl_context = load_server_cert(cert_file, cert_key_file)
def get_ssl_context():
nonlocal load_date, ssl_context
today = datetime.date.today()
if load_date != today:
ssl_context = load_server_cert(cert_file, cert_key_file)
load_date = today
return ssl_context
del ponyconfig
gc.collect() # free intermediate objects used during setup
loop = asyncio.get_event_loop()
@@ -263,12 +281,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
await ctx.server
port = 0

View File

@@ -9,3 +9,4 @@ bokeh>=3.6.3
markupsafe>=3.0.2
Markdown>=3.7
mdx-breakless-lists>=1.0.1
setproctitle>=1.3.5

View File

@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {

View File

@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit();
});
adjustFooterHeight();
});

View File

@@ -1,47 +0,0 @@
const adjustFooterHeight = () => {
// If there is no footer on this page, do nothing
const footer = document.getElementById('island-footer');
if (!footer) { return; }
// If the body is taller than the window, also do nothing
if (document.body.offsetHeight > window.innerHeight) {
footer.style.marginTop = '0';
return;
}
// Add a margin-top to the footer to position it at the bottom of the screen
const sibling = footer.previousElementSibling;
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
if (margin < 1) {
footer.style.marginTop = '0';
return;
}
footer.style.marginTop = `${margin}px`;
};
const adjustHeaderWidth = () => {
// If there is no header, do nothing
const header = document.getElementById('base-header');
if (!header) { return; }
const tempDiv = document.createElement('div');
tempDiv.style.width = '100px';
tempDiv.style.height = '100px';
tempDiv.style.overflow = 'scroll';
tempDiv.style.position = 'absolute';
tempDiv.style.top = '-500px';
document.body.appendChild(tempDiv);
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
document.body.removeChild(tempDiv);
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
};
window.addEventListener('load', () => {
window.addEventListener('resize', adjustFooterHeight);
window.addEventListener('resize', adjustHeaderWidth);
adjustFooterHeight();
adjustHeaderWidth();
});

View File

@@ -25,7 +25,6 @@ window.addEventListener('load', () => {
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {

View File

@@ -36,6 +36,13 @@ html{
body{
margin: 0;
display: flex;
flex-direction: column;
min-height: calc(100vh - 110px);
}
main {
flex-grow: 1;
}
a{

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Page Not Found (404)</title>
@@ -13,5 +14,4 @@
The page you're looking for doesn&apos;t exist.<br />
<a href="/">Click here to return to safety.</a>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Upload Multidata</title>
@@ -27,6 +28,4 @@
</div>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Archipelago</title>
@@ -57,5 +58,4 @@
</div>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -5,26 +5,29 @@
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
{% block head %}
<title>Archipelago</title>
{% endblock %}
</head>
<body>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% block body %}
{% endblock %}
</main>
{% if show_footer %}
{% include "islandFooter.html" %}
{% endif %}
{% endwith %}
{% block body %}
{% endblock %}
</body>
</html>

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Generation failed, please retry.</title>
@@ -15,5 +16,4 @@
{{ seed_error }}
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Start Playing</title>
@@ -26,6 +27,4 @@
</p>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -29,7 +29,8 @@
<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.
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
<h2>Your Rooms</h2>
{% if rooms %}

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>View Seed {{ seed.id|suuid }}</title>
@@ -50,5 +51,4 @@
</table>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,9 +1,12 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Generation in Progress</title>
<meta http-equiv="refresh" content="1">
<noscript>
<meta http-equiv="refresh" content="1">
</noscript>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
@@ -15,5 +18,34 @@
Waiting for game to generate, this page auto-refreshes to check.
</div>
</div>
{% include 'islandFooter.html' %}
<script>
const waitSeedDiv = document.getElementById("wait-seed");
async function checkStatus() {
try {
const response = await fetch("{{ url_for('api.wait_seed_api', seed=seed_id) }}");
if (response.status !== 202) {
// Seed is ready; reload page to load seed page.
location.reload();
return;
}
const data = await response.json();
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
`;
setTimeout(checkStatus, 1000); // Continue polling.
} catch (error) {
waitSeedDiv.innerHTML = `
<h1>Progress Unknown</h1>
<p>${error.message}<br />(Last checked: ${new Date().toLocaleTimeString()})</p>
`;
setTimeout(checkStatus, 1000);
}
}
setTimeout(checkStatus, 1000);
</script>
{% endblock %}

View File

@@ -16,20 +16,30 @@
orange: "FF7700" # Used for command echo
# KivyMD theming parameters
theme_style: "Dark" # Light/Dark
primary_palette: "Green" # Many options
dynamic_scheme_name: "TONAL_SPOT"
primary_palette: "Lightsteelblue" # Many options
dynamic_scheme_name: "VIBRANT"
dynamic_scheme_contrast: 0.0
<MDLabel>:
color: self.theme_cls.primaryColor
<BaseButton>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<MDTabsItemBase>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<TooltipLabel>:
adaptive_height: True
font_size: dp(20)
theme_font_size: "Custom"
font_size: "20dp"
markup: True
halign: "left"
<SelectableLabel>:
size_hint: 1, None
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerLowColor
rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
Rectangle:
size: self.size
pos: self.pos
@@ -153,9 +163,12 @@
<ToolTip>:
size: self.texture_size
size_hint: None, None
theme_font_size: "Custom"
font_size: dp(18)
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
halign: "left"
theme_text_color: "Custom"
text_color: (1, 1, 1, 1)
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
@@ -174,16 +187,34 @@
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
<AutocompleteHintInput>
<AutocompleteHintInput>:
size_hint_y: None
height: dp(30)
height: "30dp"
multiline: False
write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<ConnectBarTextInput>:
height: "30dp"
multiline: False
write_tab: False
role: "medium"
size_hint_y: None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<CommandPromptTextInput>:
size_hint_y: None
height: "30dp"
multiline: False
write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<MessageBoxLabel>:
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
<ScrollBox>:
layout: layout
bar_width: "12dp"
scroll_wheel_distance: 40
do_scroll_x: False
scroll_type: ['bars', 'content']
MDBoxLayout:
id: layout

View File

@@ -5,17 +5,18 @@
size_hint: 1, None
height: "75dp"
context_button: context
focus_behavior: False
MDRelativeLayout:
ApAsyncImage:
source: main.image
size: (40, 40)
size_hint_y: None
size: (48, 48)
size_hint: None, None
pos_hint: {"center_x": 0.1, "center_y": 0.5}
MDLabel:
text: main.component.display_name
pos_hint:{"center_x": 0.5, "center_y": 0.85 if main.component.description else 0.65}
pos_hint:{"center_x": 0.5, "center_y": 0.75 if main.component.description else 0.65}
halign: "center"
font_style: "Title"
role: "medium"
@@ -37,6 +38,7 @@
pos_hint:{"center_x": 0.85, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
on_release: app.set_favorite(self)
MDIconButton:
@@ -46,6 +48,7 @@
pos_hint:{"center_x": 0.95, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
MDButton:
pos_hint:{"center_x": 0.9, "center_y": 0.25}
@@ -53,7 +56,7 @@
height: "25dp"
component: main.component
on_release: app.component_action(self)
detect_visible: False
MDButtonText:
text: "Open"

View File

@@ -60,7 +60,7 @@ These are "nice to have" features for a client, but they are not strictly requir
if possible.
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
other clients. The icon size is 38x38 pixels, but it will accept larger images with downscaling.
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
## World
@@ -109,6 +109,10 @@ subclass for webhost documentation and behaviors
* A non-zero number of locations, added to your regions
* A non-zero number of items **equal** to the number of locations, added to the multiworld itempool
* In rare cases, there may be 0-location-0-item games, but this is extremely atypical.
* A set
[completion condition](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#L77) (aka "goal") for
the player.
* Use your player as the index (`multiworld.completion_condition[player]`) for your world's completion goal.
### Encouraged Features
@@ -142,11 +146,11 @@ workarounds or preferred methods which should be used instead:
* If you need to place specific items, there are multiple ways to do so, but they should not be added to the
multiworld itempool.
* It is not allowed to use `eval` for most reasons, chiefly due to security concerns.
* It is discouraged to use `yaml.load` directly due to security concerns.
* When possible, use `Utils.yaml_load` instead, as this defaults to the safe loader.
* It is discouraged to use PyYAML (i.e. `yaml.load`) directly due to security concerns.
* When possible, use `Utils.parse_yaml` instead, as this defaults to the safe loader and the faster C parser.
* When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively),
Do **not** use `=` as this will overwrite all elements for all games in the seed.
* Instead, use `append`, `extend`, or `+=`.
do **not** use `=` as this will overwrite all elements for all games in the seed.
* Instead, use `append`, `extend`, or `+=`.
### Notable Caveats

View File

@@ -66,3 +66,22 @@ The reason entrance access rules using `location.can_reach` and `entrance.can_re
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules.
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are much faster.
---
### I uploaded the generated output of my world to the webhost and webhost is erroring on corrupted multidata
The error `Could not load multidata. File may be corrupted or incompatible.` occurs when uploading a locally generated
file where there is an issue with the multidata contained within it. It may come with a description like
`(No module named 'worlds.myworld')` or `(global 'worlds.myworld.names.ItemNames' is forbidden)`
Pickling is a way to compress python objects such that they can be decompressed and be used to rebuild the
python objects. This means that if one of your custom class instances ends up in the multidata, the server would not
be able to load that custom class to decompress the data, which can fail either because the custom class is unknown
(because it cannot load your world module) or the class it's attempting to import to decompress is deemed unsafe.
Common situations where this can happen include:
* Using Option instances directly in slot_data. Ex: using `options.option_name` instead of `options.option_name.value`.
Also, consider using the `options.as_dict("option_name", "option_two")` helper.
* Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`,
make sure that you are not using your enum class for either the names or ids in these mappings.

View File

@@ -606,8 +606,8 @@ from .items import get_item_type
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
# (see below) is used or it's easier to apply the rules from data during
# location generation
# set a simple rule for an region
set_rule(self.multiworld.get_entrance("Boss Door", self.player),

204
kvui.py
View File

@@ -43,8 +43,8 @@ from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData
from kivy.base import ExceptionHandler, ExceptionManager
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty
from kivy.metrics import dp
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty, StringProperty
from kivy.metrics import dp, sp
from kivy.uix.widget import Widget
from kivy.uix.layout import Layout
from kivy.utils import escape_markup
@@ -60,7 +60,7 @@ from kivymd.app import MDApp
from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.floatlayout import MDFloatLayout
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.tab.tab import MDTabsPrimary, MDTabsItem, MDTabsItemText, MDTabsCarousel
from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.menu.menu import MDDropdownTextItem
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
@@ -90,16 +90,16 @@ remove_between_brackets = re.compile(r"\[.*?]")
class ThemedApp(MDApp):
def set_colors(self):
text_colors = KivyJSONtoTextParser.TextColors()
self.theme_cls.theme_style = getattr(text_colors, "theme_style", "Dark")
self.theme_cls.primary_palette = getattr(text_colors, "primary_palette", "Green")
self.theme_cls.dynamic_scheme_name = getattr(text_colors, "dynamic_scheme_name", "TONAL_SPOT")
self.theme_cls.dynamic_scheme_contrast = 0.0
self.theme_cls.theme_style = text_colors.theme_style
self.theme_cls.primary_palette = text_colors.primary_palette
self.theme_cls.dynamic_scheme_name = text_colors.dynamic_scheme_name
self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast
class ImageIcon(MDButtonIcon, AsyncImage):
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.image = AsyncImage(**kwargs)
self.image = ApAsyncImage(**kwargs)
self.add_widget(self.image)
def add_widget(self, widget, index=0, canvas=None):
@@ -114,7 +114,7 @@ class ImageButton(MDIconButton):
if val != "None":
image_args[kwarg.replace("image_", "")] = val
super().__init__()
self.image = AsyncImage(**image_args)
self.image = ApAsyncImage(**image_args)
def set_center(button, center):
self.image.center_x = self.center_x
@@ -166,6 +166,32 @@ class ToggleButton(MDButton, ToggleButtonBehavior):
child.icon_color = self.theme_cls.primaryColor
# thanks kivymd
class ResizableTextField(MDTextField):
"""
Resizable MDTextField that manually overrides the builtin sizing.
Note that in order to use this, the sizing must be specified from within a .kv rule.
"""
def __init__(self, *args, **kwargs):
# cursed rules override
rules = Builder.match(self)
textfield = next((rule for rule in rules if rule.name == f"<MDTextField>"), None)
if textfield:
subclasses = rules[rules.index(textfield) + 1:]
for subclass in subclasses:
height_rule = subclass.properties.get("height", None)
if height_rule:
height_rule.ignore_prev = True
super().__init__(args, kwargs)
def on_release(self: MDButton, *args):
super(MDButton, self).on_release(args)
self.on_leave()
MDButton.on_release = on_release
# I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object):
"""originally from https://stackoverflow.com/a/605348110"""
@@ -266,11 +292,15 @@ class TooltipLabel(HovererableLabel, MDTooltip):
self._tooltip = None
class ServerLabel(HovererableLabel, MDTooltip):
class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout):
tooltip_display_delay = 0.1
text: str = StringProperty("Server:")
def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
self.add_widget(MDIcon(icon="information", font_size=sp(15)))
self.add_widget(TooltipLabel(text=self.text, pos_hint={"center_x": 0.5, "center_y": 0.5},
font_size=sp(15)))
self._tooltip = ServerToolTip(text="Test")
def on_enter(self):
@@ -383,7 +413,6 @@ class MarkupDropdownTextItem(MDDropdownTextItem):
for child in self.children:
if child.__class__ == MDLabel:
child.markup = True
print(self.text)
# Currently, this only lets us do markup on text that does not have any icons
# Create new TextItems as needed
@@ -461,14 +490,13 @@ class MarkupDropdown(MDDropdownMenu):
self.menu.data = self._items
class AutocompleteHintInput(MDTextField):
class AutocompleteHintInput(ResizableTextField):
min_chars = NumericProperty(3)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(24), width=self.width)
self.dropdown.bind(on_select=lambda instance, x: setattr(self, 'text', x))
self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(2), width=self.width)
self.bind(on_text_validate=self.on_message)
self.bind(width=lambda instance, x: setattr(self.dropdown, "width", x))
@@ -485,8 +513,11 @@ class AutocompleteHintInput(MDTextField):
def on_press(text):
split_text = MarkupLabel(text=text).markup
return self.dropdown.select("".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
self.set_text(self, "".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
self.dropdown.dismiss()
self.focus = True
lowered = value.lower()
for item_name in item_names:
try:
@@ -498,7 +529,7 @@ class AutocompleteHintInput(MDTextField):
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
self.dropdown.items.append({
"text": text,
"on_release": lambda: on_press(text),
"on_release": lambda txt=text: on_press(txt),
"markup": True
})
if not self.dropdown.parent:
@@ -589,8 +620,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout):
if self.entrance_text != "Vanilla"
else "", ". (", self.status_text.lower(), ")"))
temp = MarkupLabel(text).markup
text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
text = "".join(part for part in temp if not part.startswith("["))
Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
else:
@@ -621,7 +651,7 @@ class HintLabel(RecycleDataViewBehavior, MDBoxLayout):
self.selected = is_selected
class ConnectBarTextInput(MDTextField):
class ConnectBarTextInput(ResizableTextField):
def insert_text(self, substring, from_undo=False):
s = substring.replace("\n", "").replace("\r", "")
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
@@ -631,14 +661,14 @@ def is_command_input(string: str) -> bool:
return len(string) > 0 and string[0] in "/!"
class CommandPromptTextInput(MDTextField):
class CommandPromptTextInput(ResizableTextField):
MAXIMUM_HISTORY_MESSAGES = 50
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._command_history_index = -1
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
def update_history(self, new_entry: str) -> None:
self._command_history_index = -1
if is_command_input(new_entry):
@@ -665,7 +695,7 @@ class CommandPromptTextInput(MDTextField):
self._change_to_history_text_if_available(self._command_history_index - 1)
return True
return super().keyboard_on_key_down(window, keycode, text, modifiers)
def _change_to_history_text_if_available(self, new_index: int) -> None:
if new_index < -1:
return
@@ -683,29 +713,61 @@ class MessageBox(Popup):
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),
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class ClientTabs(MDTabsPrimary):
class ClientTabs(MDTabsSecondary):
carousel: MDTabsCarousel
lock_swiping = True
def __init__(self, *args, **kwargs):
self.carousel = MDTabsCarousel(lock_swiping=True)
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(4)), self.carousel, **kwargs)
self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2)
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs)
self.size_hint_y = 1
def _check_panel_height(self, *args):
self.ids.tab_scroll.height = dp(38)
def update_indicator(
self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
) -> None:
def update_indicator(*args):
indicator_pos = (0, 0)
indicator_size = (0, 0)
item_text_object = self._get_tab_item_text_icon_object()
if item_text_object:
indicator_pos = (
instance.x + dp(12),
self.indicator.pos[1]
if not self._tabs_carousel
else self._tabs_carousel.height,
)
indicator_size = (
instance.width - dp(24),
self.indicator_height,
)
Animation(
pos=indicator_pos,
size=indicator_size,
d=0 if not self.indicator_anim else self.indicator_duration,
t=self.indicator_transition,
).start(self.indicator)
if not instance:
self.indicator.pos = (x, self.indicator.pos[1])
self.indicator.size = (w, self.indicator_height)
else:
Clock.schedule_once(update_indicator)
def remove_tab(self, tab, content=None):
if content is None:
content = tab.content
@@ -714,6 +776,21 @@ class ClientTabs(MDTabsPrimary):
self.on_size(self, self.size)
class CommandButton(MDButton, MDTooltip):
def __init__(self, *args, manager: "GameManager", **kwargs):
super().__init__(*args, **kwargs)
self.manager = manager
self._tooltip = ToolTip(text="Test")
def on_enter(self):
self._tooltip.text = self.manager.commandprocessor.get_help_text()
self._tooltip.font_size = dp(20 - (len(self._tooltip.text) // 400)) # mostly guessing on the numbers here
self.display_tooltip()
def on_leave(self):
self.animation_tooltip_dismiss()
class GameManager(ThemedApp):
logging_pairs = [
("Client", "Archipelago"),
@@ -768,19 +845,19 @@ class GameManager(ThemedApp):
self.grid = MainLayout()
self.grid.cols = 1
self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(70),
self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40),
spacing=5, padding=(5, 10))
# top part
server_label = ServerLabel(halign="center")
server_label = ServerLabel(width=dp(75))
self.connect_layout.add_widget(server_label)
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:",
size_hint_y=None, role="medium",
height=dp(70), multiline=False, write_tab=False)
pos_hint={"center_x": 0.5, "center_y": 0.5})
def connect_bar_validate(sender):
if not self.ctx.server:
self.connect_button_action(sender)
self.server_connect_bar.height = dp(30)
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
self.connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = MDButton(MDButtonText(text="Connect"), style="filled", size=(dp(100), dp(70)),
@@ -793,7 +870,7 @@ class GameManager(ThemedApp):
self.grid.add_widget(self.progressbar)
# middle part
self.tabs = ClientTabs()
self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5})
self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago")))
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
for logger_name, name in
@@ -821,9 +898,10 @@ class GameManager(ThemedApp):
self.grid.add_widget(self.main_area_container)
# bottom part
bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(70), spacing=5, padding=(5, 10))
info_button = MDButton(MDButtonText(text="Command:"), radius=5, style="filled", size=(dp(100), dp(70)),
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.575})
bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10))
info_button = CommandButton(MDButtonText(text="Command:", halign="left"), manager=self, radius=5,
style="filled", size=(dp(100), dp(70)), size_hint_x=None, size_hint_y=None,
pos_hint={"center_y": 0.575})
info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button)
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
@@ -844,15 +922,27 @@ class GameManager(ThemedApp):
self.server_connect_bar.focus = True
self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s))
# Uncomment to enable the kivy live editor console
# Press Ctrl-E (with numlock/capslock) disabled to open
# from kivy.core.window import Window
# from kivy.modules import console
# console.create_console(Window, self.container)
return self.container
def add_client_tab(self, title: str, content: Widget) -> Widget:
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget:
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
Returns the new tab widget, with the provided content being placed on the tab as content."""
new_tab = MDTabsItem(MDTabsItemText(text=title))
new_tab.content = content
self.tabs.add_widget(new_tab)
self.tabs.carousel.add_widget(new_tab.content)
if -1 < index <= len(self.tabs.carousel.slides):
new_tab.bind(on_release=self.tabs.set_active_item)
new_tab._tabs = self.tabs
self.tabs.ids.container.add_widget(new_tab, index=index)
self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index)
else:
self.tabs.add_widget(new_tab)
self.tabs.carousel.add_widget(new_tab.content)
return new_tab
def update_texts(self, dt):
@@ -1002,8 +1092,9 @@ class HintLayout(MDBoxLayout):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(55))
boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(55)))
boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40))
boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None,
height=dp(40), width=dp(75), halign="center", valign="center"))
boxlayout.add_widget(AutocompleteHintInput())
self.add_widget(boxlayout)
@@ -1013,7 +1104,7 @@ class HintLayout(MDBoxLayout):
if fix_func:
fix_func()
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "Found",
HintStatus.HINT_UNSPECIFIED: "Unspecified",
@@ -1110,6 +1201,7 @@ class HintLog(MDRecycleView):
class ApAsyncImage(AsyncImage):
def is_uri(self, filename: str) -> bool:
if filename.startswith("ap:"):
return True
@@ -1155,7 +1247,23 @@ class E(ExceptionHandler):
class KivyJSONtoTextParser(JSONtoTextParser):
# dummy class to absorb kvlang definitions
class TextColors(Widget):
pass
white: str = StringProperty("FFFFFF")
black: str = StringProperty("000000")
red: str = StringProperty("EE0000")
green: str = StringProperty("00FF7F")
yellow: str = StringProperty("FAFAD2")
blue: str = StringProperty("6495ED")
magenta: str = StringProperty("EE00EE")
cyan: str = StringProperty("00EEEE")
slateblue: str = StringProperty("6D8BE8")
plum: str = StringProperty("AF99EF")
salmon: str = StringProperty("FA8072")
orange: str = StringProperty("FF7700")
# KivyMD parameters
theme_style: str = StringProperty("Dark")
primary_palette: str = StringProperty("Lightsteelblue")
dynamic_scheme_name: str = StringProperty("VIBRANT")
dynamic_scheme_contrast: int = NumericProperty(0)
def __init__(self, *args, **kwargs):
# we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries

View File

@@ -2,7 +2,7 @@ from typing import List, TYPE_CHECKING, Dict, Any
from schema import Schema, Optional
from dataclasses import dataclass
from worlds.AutoWorld import PerGameCommonOptions
from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup
from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup, StartInventoryPool
if TYPE_CHECKING:
from . import HatInTimeWorld
@@ -625,6 +625,8 @@ class ParadeTrapWeight(Range):
@dataclass
class AHITOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
EndGoal: EndGoal
ActRandomizer: ActRandomizer
ActPlando: ActPlando

View File

@@ -1,6 +1,6 @@
from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld
from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \
calculate_yarn_costs, alps_hooks
calculate_yarn_costs, alps_hooks, junk_weights
from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region
from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \
get_total_locations
@@ -78,6 +78,9 @@ class HatInTimeWorld(World):
self.nyakuza_thug_items: Dict[str, int] = {}
self.badge_seller_count: int = 0
def get_filler_item_name(self) -> str:
return self.random.choices(list(junk_weights.keys()), weights=junk_weights.values(), k=1)[0]
def generate_early(self):
adjust_options(self)

View File

@@ -51,7 +51,7 @@ Boosts have logic associated with them in order to verify you can always reach t
- I need to kill a unit with a slinger/archer/musketman or some other obsolete unit I can't build anymore, how can I do this?
- Don't forget you can go into the Tech Tree and click on a Vanilla tech you've received in order to toggle it on/off. This is necessary in order to pursue some of the boosts if you receive techs in certain orders.
- Something happened, and I'm not able to unlock the boost due to game rules!
- A few scenarios you may worry about: "Found a religion", "Make an alliance with another player", "Develop an alliance to level 2", "Build a wonder from X Era", to name a few. Any boost that is "miss-able" has been flagged as an "Excluded" location and will not ever receive a progression item. For a list of how each boost is flagged, take a look [here](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/boosts.json).
- A few scenarios you may worry about: "Found a religion", "Make an alliance with another player", "Develop an alliance to level 2", "Build a wonder from X Era", to name a few. Any boost that is "miss-able" has been flagged as an "Excluded" location and will not ever receive a progression item. For a list of how each boost is flagged, take a look [here](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/boosts.py).
- I'm worried that my `PROGRESSIVE_ERA` item is going to be stuck in a boost I won't have time to complete before my maximum unlocked era ends!
- The unpredictable timing of boosts and unlocking them can occasionally lead to scenarios where you'll have to first encounter a locked era defeat and then load a previous save. To help reduce the frequency of this, local `PROGRESSIVE_ERA` items will never be located at a boost check.
- There's too many boosts, how will I know which one's I should focus on?!

View File

@@ -14,22 +14,17 @@ The following are required in order to play Civ VI in Archipelago:
## Enabling the tuner
Depending on how you installed Civ 6 you will have to navigate to one of the following:
- `YOUR_USER/Documents/My Games/Sid Meier's Civilization VI/AppOptions.txt`
- `YOUR_USER/AppData/Local/Firaxis Games/Sid Meier's Civilization VI/AppOptions.txt`
Once you have located your `AppOptions.txt`, do a search for `Enable FireTuner`. Set `EnableTuner` to `1` instead of `0`. **NOTE**: While this is active, achievements will be disabled.
In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled.
## Mod Installation
1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest).
2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`.
2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure.
3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it.
4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder.
4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can just rename it to a file ending with `.zip` and extract its contents to a new folder. To do this, right click the `.apcivvi` file and click "Rename", make sure it ends in `.zip`, then right click it again and select "Extract All".
5. Your finished mod folder should look something like this:

View File

@@ -930,7 +930,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
"Great Swamp Ring", miniboss=True), # Giant Crab drop
DS3LocationData("RS: Blue Sentinels - Horace", "Blue Sentinels",
missable=True, npc=True), # Horace quest
DS3LocationData("RS: Crystal Gem - stronghold, lizard", "Crystal Gem"),
DS3LocationData("RS: Crystal Gem - stronghold, lizard", "Crystal Gem", lizard=True),
DS3LocationData("RS: Fading Soul - woods by Crucifixion Woods bonfire", "Fading Soul",
static='03,0:53300210::'),

View File

@@ -1,10 +1,11 @@
import unittest
from typing import Dict
from BaseClasses import MultiWorld
from Options import NamedRange
from .option_names import options_to_include
from .checks.world_checks import assert_can_win, assert_same_number_items_locations
from . import DLCQuestTestBase, setup_dlc_quest_solo_multiworld
from .checks.world_checks import assert_can_win, assert_same_number_items_locations
from .option_names import options_to_include
def basic_checks(tester: DLCQuestTestBase, multiworld: MultiWorld):
@@ -38,6 +39,8 @@ class TestGenerateDynamicOptions(DLCQuestTestBase):
basic_checks(self, multiworld)
def test_given_option_truple_when_generate_then_basic_checks(self):
if self.skip_long_tests:
raise unittest.SkipTest("Long tests disabled")
num_options = len(options_to_include)
for option1_index in range(0, num_options):
for option2_index in range(option1_index + 1, num_options):
@@ -59,6 +62,8 @@ class TestGenerateDynamicOptions(DLCQuestTestBase):
basic_checks(self, multiworld)
def test_given_option_quartet_when_generate_then_basic_checks(self):
if self.skip_long_tests:
raise unittest.SkipTest("Long tests disabled")
num_options = len(options_to_include)
for option1_index in range(0, num_options):
for option2_index in range(option1_index + 1, num_options):

View File

@@ -1,19 +1,26 @@
from typing import ClassVar
from typing import Dict, FrozenSet, Tuple, Any
import os
from argparse import Namespace
from typing import ClassVar
from typing import Dict, FrozenSet, Tuple, Any
from BaseClasses import MultiWorld
from test.bases import WorldTestBase
from .. import DLCqworld
from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_multiworld
from worlds.AutoWorld import call_all
from .. import DLCqworld
class DLCQuestTestBase(WorldTestBase):
game = "DLCQuest"
world: DLCqworld
player: ClassVar[int] = 1
# Set False to run tests that take long
skip_long_tests: bool = True
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.skip_long_tests = not bool(os.environ.get("long"))
def world_setup(self, *args, **kwargs):
super().world_setup(*args, **kwargs)

View File

@@ -8,17 +8,20 @@ from schema import Schema, Optional, And, Or, SchemaError
from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \
StartInventoryPool, PerGameCommonOptions, OptionGroup
# schema helpers
class FloatRange:
def __init__(self, low, high):
self._low = low
self._high = high
def validate(self, value):
def validate(self, value) -> float:
if not isinstance(value, (float, int)):
raise SchemaError(f"should be instance of float or int, but was {value!r}")
if not self._low <= value <= self._high:
raise SchemaError(f"{value} is not between {self._low} and {self._high}")
return float(value)
LuaBool = Or(bool, And(int, lambda n: n in (0, 1)))

View File

@@ -450,7 +450,7 @@ class GrubHuntGoal(NamedRange):
display_name = "Grub Hunt Goal"
range_start = 1
range_end = 46
special_range_names = {"all": -1}
special_range_names = {"all": -1, "forty_six": 46}
default = 46

View File

@@ -3,34 +3,34 @@
## Required Software
* Download and unzip the Lumafly Mod Manager from the [Lumafly website](https://themulhima.github.io/Lumafly/).
* A legal copy of Hollow Knight.
* Steam, Gog, and Xbox Game Pass versions of the game are supported.
* Windows, Mac, and Linux (including Steam Deck) are supported.
* Steam, Gog, and Xbox Game Pass versions of the game are supported.
* Windows, Mac, and Linux (including Steam Deck) are supported.
## Installing the Archipelago Mod using Lumafly
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
2. Install the Archipelago mods by doing either of the following:
* Click one of the links below to allow Lumafly to install the mods. Lumafly will prompt for confirmation.
* [Archipelago and dependencies only](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago)
* [Archipelago with rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/)
(includes Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn,
and AdditionalMaps).
* Click the "Install" button near the "Archipelago" mod entry. If desired, also install "Archipelago Map Mod"
to use as an in-game tracker.
* Click one of the links below to allow Lumafly to install the mods. Lumafly will prompt for confirmation.
* [Archipelago and dependencies only](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago)
* [Archipelago with rando essentials](https://themulhima.github.io/Lumafly/commands/download/?mods=Archipelago/Archipelago%20Map%20Mod/RecentItemsDisplay/DebugMod/RandoStats/Additional%20Timelines/CompassAlwaysOn/AdditionalMaps/)
(includes Archipelago Map Mod, RecentItemsDisplay, DebugMod, RandoStats, AdditionalTimelines, CompassAlwaysOn,
and AdditionalMaps).
* Click the "Install" button near the "Archipelago" mod entry. If desired, also install "Archipelago Map Mod"
to use as an in-game tracker.
3. Launch the game, you're all set!
### What to do if Lumafly fails to find your installation directory
1. Find the directory manually.
* Xbox Game Pass:
1. Enter the Xbox app and move your mouse over "Hollow Knight" on the left sidebar.
2. Click the three points then click "Manage".
3. Go to the "Files" tab and select "Browse...".
4. Click "Hollow Knight", then "Content", then click the path bar and copy it.
* Steam:
1. You likely put your Steam library in a non-standard place. If this is the case, you probably know where
it is. Find your steam library and then find the Hollow Knight folder and copy the path.
* Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* Linux/Steam Deck - ~/.local/share/Steam/steamapps/common/Hollow Knight
* Mac - ~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app
* Xbox Game Pass:
1. Enter the Xbox app and move your mouse over "Hollow Knight" on the left sidebar.
2. Click the three points then click "Manage".
3. Go to the "Files" tab and select "Browse...".
4. Click "Hollow Knight", then "Content", then click the path bar and copy it.
* Steam:
1. You likely put your Steam library in a non-standard place. If this is the case, you probably know where
it is. Find your steam library and then find the Hollow Knight folder and copy the path.
* Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* Linux/Steam Deck - ~/.local/share/Steam/steamapps/common/Hollow Knight
* Mac - ~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app
2. Run Lumafly as an administrator and, when it asks you for the path, paste the path you copied.
## Configuring your YAML File
@@ -49,9 +49,9 @@ website to generate a YAML using a graphical interface.
4. Enter the correct settings for your Archipelago server.
5. Hit **Start** to begin the game. The game will stall for a few seconds while it does all item placements.
6. The game will immediately drop you into the randomized game.
* If you are waiting for a countdown then wait for it to lapse before hitting Start.
* Or hit Start then pause the game once you're in it.
* If you are waiting for a countdown then wait for it to lapse before hitting Start.
* Or hit Start then pause the game once you're in it.
## Hints and other commands
While playing in a multiworld, you can interact with the server using various commands listed in the
[commands guide](/tutorial/Archipelago/commands/en). You can use the Archipelago Text Client to do this,

View File

@@ -3,28 +3,28 @@
## Programas obrigatórios
* Baixe e extraia o Lumafly Mod Manager (gerenciador de mods Lumafly) do [Site Lumafly](https://themulhima.github.io/Lumafly/).
* Uma cópia legal de Hollow Knight.
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
## Instalando o mod Archipelago Mod usando Lumafly
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
2. Clique em "Install (instalar)" perto da opção "Archipelago" mod.
* Se quiser, instale também o "Archipelago Map Mod (mod do mapa do archipelago)" para usá-lo como rastreador dentro do jogo.
* Se quiser, instale também o "Archipelago Map Mod (mod do mapa do archipelago)" para usá-lo como rastreador dentro do jogo.
3. Abra o jogo, tudo preparado!
### O que fazer se o Lumafly falha em encontrar a sua pasta de instalação
1. Encontre a pasta manualmente.
* Xbox Game Pass:
1. Entre no seu aplicativo Xbox e mova seu mouse em cima de "Hollow Knight" na sua barra da esquerda.
2. Clique nos 3 pontos depois clique gerenciar.
3. Vá nos arquivos e selecione procurar.
4. Clique em "Hollow Knight", depois em "Content (Conteúdo)", depois clique na barra com o endereço e a copie.
* Steam:
1. Você provavelmente colocou sua biblioteca Steam num local não padrão. Se esse for o caso você provavelmente sabe onde está.
. Encontre sua biblioteca Steam, depois encontre a pasta do Hollow Knight e copie seu endereço.
* Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* Linux/Steam Deck - `~/.local/share/Steam/steamapps/common/Hollow Knight`
* Mac - `~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app`
* Xbox Game Pass:
1. Entre no seu aplicativo Xbox e mova seu mouse em cima de "Hollow Knight" na sua barra da esquerda.
2. Clique nos 3 pontos depois clique gerenciar.
3. Vá nos arquivos e selecione procurar.
4. Clique em "Hollow Knight", depois em "Content (Conteúdo)", depois clique na barra com o endereço e a copie.
* Steam:
1. Você provavelmente colocou sua biblioteca Steam num local não padrão. Se esse for o caso você provavelmente sabe onde está.
Encontre sua biblioteca Steam, depois encontre a pasta do Hollow Knight e copie seu endereço.
* Windows - `C:\Program Files (x86)\Steam\steamapps\common\Hollow Knight`
* Linux/Steam Deck - `~/.local/share/Steam/steamapps/common/Hollow Knight`
* Mac - `~/Library/Application Support/Steam/steamapps/common/Hollow Knight/hollow_knight.app`
2. Rode o Lumafly como administrador e, quando ele perguntar pelo endereço do arquivo, cole o endereço do arquivo que você copiou.
## Configurando seu arquivo YAML
@@ -43,9 +43,9 @@ para gerar o YAML usando a interface gráfica.
4. Coloque as configurações corretas do seu servidor Archipelago.
5. Aperte em **Começar**. O jogo vai travar por uns segundos enquanto ele coloca todos itens.
6. O jogo vai te colocar imediatamente numa partida randomizada.
* Se você está esperando uma contagem então espere ele cair antes de apertar começar.
* Ou clique em começar e pause o jogo enquanto estiver nele.
* Se você está esperando uma contagem então espere ele cair antes de apertar começar.
* Ou clique em começar e pause o jogo enquanto estiver nele.
## Dicas e outros comandos
Enquanto jogar um multiworld, você pode interagir com o servidor usando vários comandos listados no
[Guia de comandos](/tutorial/Archipelago/commands/en). Você pode usar o cliente de texto do Archipelago para isso,

View File

@@ -174,7 +174,7 @@ class LingoWorld(World):
"death_link", "victory_condition", "shuffle_colors", "shuffle_doors", "shuffle_paintings", "shuffle_panels",
"enable_pilgrimage", "sunwarp_access", "mastery_achievements", "level_2_requirement", "location_checks",
"early_color_hallways", "pilgrimage_allows_roof_access", "pilgrimage_allows_paintings", "shuffle_sunwarps",
"group_doors", "speed_boost_mode"
"group_doors", "speed_boost_mode", "shuffle_postgame"
]
slot_data = {

View File

@@ -34,12 +34,32 @@ ITEMS_BY_GROUP: Dict[str, List[str]] = {}
TRAP_ITEMS: List[str] = ["Slowness Trap", "Iceland Trap", "Atbash Trap"]
PROGUSEFUL_ITEMS: List[str] = [
"Crossroads - Roof Access",
"Black",
"Red",
"Blue",
"Yellow",
"Purple",
"Sunwarps",
"Tenacious Entrance Panels",
"The Tenacious - Black Palindromes (Panels)",
"Hub Room - RAT (Panel)",
"Outside The Wanderer - WANDERLUST (Panel)",
"Orange Tower Panels"
]
def get_prog_item_classification(item_name: str):
if item_name in PROGUSEFUL_ITEMS:
return ItemClassification.progression | ItemClassification.useful
else:
return ItemClassification.progression
def load_item_data():
global ALL_ITEM_TABLE, ITEMS_BY_GROUP
for color in ["Black", "Red", "Blue", "Yellow", "Green", "Orange", "Gray", "Brown", "Purple"]:
ALL_ITEM_TABLE[color] = ItemData(get_special_item_id(color), ItemClassification.progression,
ALL_ITEM_TABLE[color] = ItemData(get_special_item_id(color), get_prog_item_classification(color),
ItemType.COLOR, False, [])
ITEMS_BY_GROUP.setdefault("Colors", []).append(color)
@@ -53,16 +73,16 @@ def load_item_data():
door_groups.add(door.door_group)
ALL_ITEM_TABLE[door.item_name] = \
ItemData(get_door_item_id(room_name, door_name), ItemClassification.progression, ItemType.NORMAL,
door.has_doors, door.painting_ids)
ItemData(get_door_item_id(room_name, door_name), get_prog_item_classification(door.item_name),
ItemType.NORMAL, door.has_doors, door.painting_ids)
ITEMS_BY_GROUP.setdefault("Doors", []).append(door.item_name)
if door.item_group is not None:
ITEMS_BY_GROUP.setdefault(door.item_group, []).append(door.item_name)
for group in door_groups:
ALL_ITEM_TABLE[group] = ItemData(get_door_group_item_id(group),
ItemClassification.progression, ItemType.NORMAL, True, [])
ALL_ITEM_TABLE[group] = ItemData(get_door_group_item_id(group), get_prog_item_classification(group),
ItemType.NORMAL, True, [])
ITEMS_BY_GROUP.setdefault("Doors", []).append(group)
panel_groups: Set[str] = set()
@@ -72,11 +92,12 @@ def load_item_data():
panel_groups.add(panel_door.panel_group)
ALL_ITEM_TABLE[panel_door.item_name] = ItemData(get_panel_door_item_id(room_name, panel_door_name),
ItemClassification.progression, ItemType.NORMAL, False, [])
get_prog_item_classification(panel_door.item_name),
ItemType.NORMAL, False, [])
ITEMS_BY_GROUP.setdefault("Panels", []).append(panel_door.item_name)
for group in panel_groups:
ALL_ITEM_TABLE[group] = ItemData(get_panel_group_item_id(group), ItemClassification.progression,
ALL_ITEM_TABLE[group] = ItemData(get_panel_group_item_id(group), get_prog_item_classification(group),
ItemType.NORMAL, False, [])
ITEMS_BY_GROUP.setdefault("Panels", []).append(group)
@@ -101,7 +122,7 @@ def load_item_data():
for item_name in PROGRESSIVE_ITEMS:
ALL_ITEM_TABLE[item_name] = ItemData(get_progressive_item_id(item_name),
ItemClassification.progression, ItemType.NORMAL, False, [])
get_prog_item_classification(item_name), ItemType.NORMAL, False, [])
# Initialize the item data at module scope.

View File

@@ -35,8 +35,6 @@ LOCATIONS_BY_GROUP: Dict[str, List[str]] = {}
def load_location_data():
global ALL_LOCATION_TABLE, LOCATIONS_BY_GROUP
for room_name, panels in PANELS_BY_ROOM.items():
for panel_name, panel in panels.items():
location_name = f"{room_name} - {panel_name}" if panel.location_name is None else panel.location_name

View File

@@ -58,8 +58,7 @@ def hash_file(path):
def load_static_data(ll1_path, ids_path):
global PAINTING_EXITS, SPECIAL_ITEM_IDS, PANEL_LOCATION_IDS, DOOR_LOCATION_IDS, DOOR_ITEM_IDS, \
DOOR_GROUP_ITEM_IDS, PROGRESSIVE_ITEM_IDS, PANEL_DOOR_ITEM_IDS, PANEL_GROUP_ITEM_IDS
global PAINTING_EXITS
# Load in all item and location IDs. These are broken up into groups based on the type of item/location.
with open(ids_path, "r") as file:
@@ -128,7 +127,7 @@ def load_static_data(ll1_path, ids_path):
def process_single_entrance(source_room: str, room_name: str, door_obj) -> RoomEntrance:
global PAINTING_ENTRANCES, PAINTING_EXIT_ROOMS
global PAINTING_ENTRANCES
entrance_type = EntranceType.NORMAL
if "painting" in door_obj and door_obj["painting"]:
@@ -175,8 +174,6 @@ def process_entrance(source_room, doors, room_obj):
def process_panel_door(room_name, panel_door_name, panel_door_data):
global PANEL_DOORS_BY_ROOM, PANEL_DOOR_BY_PANEL_BY_ROOM
panels: List[RoomAndPanel] = list()
for panel in panel_door_data["panels"]:
if isinstance(panel, dict):
@@ -215,8 +212,6 @@ def process_panel_door(room_name, panel_door_name, panel_door_data):
def process_panel(room_name, panel_name, panel_data):
global PANELS_BY_ROOM
# required_room can either be a single room or a list of rooms.
if "required_room" in panel_data:
if isinstance(panel_data["required_room"], list):
@@ -310,8 +305,6 @@ def process_panel(room_name, panel_name, panel_data):
def process_door(room_name, door_name, door_data):
global DOORS_BY_ROOM
# The item name associated with a door can be explicitly specified in the configuration. If it is not, it is
# generated from the room and door name.
if "item_name" in door_data:
@@ -409,8 +402,6 @@ def process_door(room_name, door_name, door_data):
def process_painting(room_name, painting_data):
global PAINTINGS, REQUIRED_PAINTING_ROOMS, REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS
# Read in information about this painting and store it in an object.
painting_id = painting_data["id"]
@@ -468,8 +459,6 @@ def process_painting(room_name, painting_data):
def process_sunwarp(room_name, sunwarp_data):
global SUNWARP_ENTRANCES, SUNWARP_EXITS
if sunwarp_data["direction"] == "enter":
SUNWARP_ENTRANCES[sunwarp_data["dots"] - 1] = room_name
else:
@@ -477,8 +466,6 @@ def process_sunwarp(room_name, sunwarp_data):
def process_progressive_door(room_name, progression_name, progression_doors):
global PROGRESSIVE_ITEMS, PROGRESSIVE_DOORS_BY_ROOM
# Progressive items are configured as a list of doors.
PROGRESSIVE_ITEMS.add(progression_name)
@@ -497,8 +484,6 @@ def process_progressive_door(room_name, progression_name, progression_doors):
def process_progressive_panel(room_name, progression_name, progression_panel_doors):
global PROGRESSIVE_ITEMS, PROGRESSIVE_PANELS_BY_ROOM
# Progressive items are configured as a list of panel doors.
PROGRESSIVE_ITEMS.add(progression_name)
@@ -517,8 +502,6 @@ def process_progressive_panel(room_name, progression_name, progression_panel_doo
def process_room(room_name, room_data):
global ALL_ROOMS
room_obj = Room(room_name, [])
if "entrances" in room_data:

View File

@@ -46,8 +46,16 @@ class MessengerWeb(WebWorld):
"setup/en",
["alwaysintreble"],
)
plando_en = Tutorial(
"The Messenger Plando Guide",
"A guide detailing The Messenger's various supported plando options.",
"English",
"plando_en.md",
"plando/en",
["alwaysintreble"],
)
tutorials = [tut_en]
tutorials = [tut_en, plando_en]
class MessengerWorld(World):

View File

@@ -1,6 +1,7 @@
# The Messenger
## Quick Links
- [Setup](/tutorial/The%20Messenger/setup/en)
- [Options Page](/games/The%20Messenger/player-options)
- [Courier Github](https://github.com/Brokemia/Courier)
@@ -26,6 +27,7 @@ obtained. You'll be forced to do sections of the game in different ways with you
## Where can I find items?
You can find items wherever items can be picked up in the original game. This includes:
* Shopkeeper dialog where the player originally gains movement items
* Quest Item pickups
* Music Box notes
@@ -42,6 +44,7 @@ group of items. Hinting for a group will choose a random item from the group tha
for it.
The groups you can use for The Messenger are:
* Notes - This covers the music notes
* Keys - An alternative name for the music notes
* Crest - The Sun and Moon Crests
@@ -64,16 +67,29 @@ The groups you can use for The Messenger are:
be entered in game.
## Known issues
* Ruxxtin Coffin cutscene will sometimes not play correctly, but will still reward the item
* If you receive the Magic Firefly while in Quillshroom Marsh, The De-curse Queen cutscene will not play. You can exit
to Searing Crags and re-enter to get it to play correctly.
* Teleporting back to HQ, then returning to the same level you just left through a Portal can cause Ninja to run left
and enter a different portal than the one entered by the player or lead to other incorrect inputs, causing a soft lock
* Text entry menus don't accept controller input
* In power seal hunt mode, the chest must be opened by entering the shop from a level. Teleporting to HQ and opening the
chest will not work.
## What do I do if I have a problem?
If you believe something happened that isn't intended, please get the `log.txt` from the folder of your game
installation and send a bug report either on GitHub or the [Archipelago Discord Server](http://archipelago.gg/discord)
## FAQ
* The tracker says I can get some checks in Howling Grotto, but I can't defeat the Emerald Golem. How do I get there?
* Due to the way the vanilla game handles bosses and level transitions, if you die to him, the room will be unlocked,
and you can leave.
* I have the money wrench. Why won't the shopkeeper let me enter the sink?
* The money wrench is both an item you must find or receive from another player and a location check, which you must
purchase from the Artificer, as in vanilla.
* How do I unfreeze Manfred? Where is the monk?
* The monk will only appear near Manfred after you cleanse the Queen of Quills with the fairy (magic firefly).
* I have all the power seals I need to win, but nothing is happening when I open the chest.
* Due to how the level loading code works, I am currently unable to teleport you out of HQ at will; you must enter the
shop from within a level.

View File

@@ -0,0 +1,101 @@
# The Messenger Plando Guide
This guide details the usage of the game-specific plando options that The Messenger has. The Messenger also supports the
generic item plando. For more information on what plando is and for information covering item plando, refer to the
[generic Archipelago plando guide](/tutorial/Archipelago/plando/en). The Messenger also uses the generic connection
plando system, but with specific behaviors that will be covered in this guide along with the other options.
## Shop Price Plando
This option allows you to specify prices for items in both shops. This also supports weighting, allowing you to choose
from multiple different prices for any given item.
### Example
```yaml
The Messenger:
shop_price_plan:
Karuta Plates: 50
Devil's Due: 1
Barmath'azel Figurine:
# left side is price, right side is weight
500: 10
700: 5
1000: 20
```
This block will make the item at the `Karuta Plates` node cost 50 shards, `Devil's Due` will cost 1 shard, and
`Barmath'azel Figurine` will cost either 500, 700, or 1000, with 1000 being the most likely with a 20/35 chance.
## Portal Plando
This option allows you to specify certain outputs for the portals. This option will only be checked if portal shuffle
and the `connections` plando host setting are enabled.
A portal connection is plandoed by specifying an `entrance` and an `exit`. This option also supports `percentage`, which
is the percent chance that that connection occurs. The `entrance` is which portal is going to be entered, whereas the
`exit` is where the portal will lead and can include a shop location, a checkpoint, or any portal. However, the
portal exit must also be in the available pool for the selected portal shuffle option. For example, if portal shuffle is
set to `shops`, then the valid exits will only be portals and shops; any exit that is a checkpoint will not be valid. If
portal shuffle is set to `checkpoints`, you may not have multiple portals lead to the same area, e.g. `Seashell` and
`Spike Wave` may not both be used since they are both in Quillshroom Marsh. If the option is set to `anywhere`, then all
exits are valid.
All valid connections for portal shuffle can be found by scrolling through the [portals module](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/portals.py#L12).
The entrance and exit should be written exactly as they appear within that file, except for when the **exit** point is a
portal. In that case, it should have "Portal" included.
### Example
```yaml
The Messenger:
portal_plando:
- entrance: Riviere Turquoise
exit: Wingsuit
- entrance: Sunken Shrine
exit: Sunny Day
- entrance: Searing Crags
exit: Glacial Peak Portal
```
This block will make it so that the Riviere Turquoise Portal will exit to the Wingsuit Shop, the Sunken Shrine Portal
will exit to the Sunny Day checkpoint, and the Searing Crags Portal will exit to the Glacial Peak Portal.
## Transition Plando
This option allows you to specify certain connections when using transition shuffle. This will only work if
transition shuffle and the `connections` plando host setting are enabled.
Each transition connection is plandoed by specifying its attributes:
* `entrance` is where you will enter this transition from.
* `exit` is where the transition will lead.
* `percentage` is the chance this connection will happen at all.
* `direction` is used to specify whether this connection will also go in reverse. This entry will be ignored if the
transition shuffle is set to `coupled` or if the specified connection can only occur in one direction, such as exiting
to Riviere Turquoise. The default direction is "both", which will make it so that returning through the exit
transition will return you to where you entered it from. "entrance" and "exit" are treated the same, with them both
making this transition only one-way.
Valid connections can be found in the [`RANDOMIZED_CONNECTIONS` dictionary](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L640).
The keys (left) are entrances, and values (right) are exits. Whether you want the connection to go both ways or not,
both sides must either be two-way or one-way; E.g. connecting Artificer (Corrupted Future Portal) to one of the
Quillshroom Marsh entrances is not a valid pairing. A pairing can be determined to be two-way if both the entrance and
exit of that pair are an exit and entrance of another pairing, respectively.
### Example
```yaml
The Messenger:
plando_connections:
- entrance: Searing Crags - Top
exit: Dark Cave - Right
- entrance: Glacial Peak - Left
exit: Corrupted Future
```
This block will create the following connections:
1. Leaving Searing Crags towards Glacial Peak will take you to the beginning of Dark Cave, and leaving the Dark Cave
door will return you to the top of Searing Crags.
2. Taking Manfred to leave Glacial Peak, will take you to Corrupted Future. There is no reverse connection here so it
will always be one-way.

View File

@@ -16,17 +16,8 @@ class MessengerAccessibility(ItemsAccessibility):
class PortalPlando(PlandoConnections):
"""
Plando connections to be used with portal shuffle. Direction is ignored.
List of valid connections can be found here: https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/portals.py#L12.
The entering Portal should *not* have "Portal" appended.
For the exits, those in checkpoints and shops should just be the name of the spot, while portals should have " Portal" at the end.
Example:
- entrance: Riviere Turquoise
exit: Wingsuit
- entrance: Sunken Shrine
exit: Sunny Day
- entrance: Searing Crags
exit: Glacial Peak Portal
Plando connections to be used with portal shuffle.
Documentation on using this can be found in The Messenger plando guide.
"""
display_name = "Portal Plando Connections"
portals = [f"{portal} Portal" for portal in PORTALS]
@@ -40,14 +31,7 @@ class PortalPlando(PlandoConnections):
class TransitionPlando(PlandoConnections):
"""
Plando connections to be used with transition shuffle.
List of valid connections can be found at https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/messenger/connections.py#L641.
Dictionary keys (left) are entrances and values (right) are exits. If transition shuffle is on coupled all plando
connections will be coupled. If on decoupled, "entrance" and "exit" will be treated the same, simply making the
plando connection one-way from entrance to exit.
Example:
- entrance: Searing Crags - Top
exit: Dark Cave - Right
direction: both
Documentation on using this can be found in The Messenger plando guide.
"""
display_name = "Transition Plando Connections"
entrances = frozenset(RANDOMIZED_CONNECTIONS.keys())
@@ -147,7 +131,9 @@ class MusicBox(DefaultOnToggle):
class NotesNeeded(Range):
"""How many notes are needed to access the Music Box."""
"""
How many notes need to be found in order to access the Music Box. 6 are always needed to enter, so this places the others in your start inventory.
"""
display_name = "Notes Needed"
range_start = 1
range_end = 6

View File

@@ -148,12 +148,13 @@ def set_rules(world: "MLSSWorld", excluded):
and StateLogic.canDash(state, world.player)
and StateLogic.canCrash(state, world.player)
)
add_rule(
world.get_location(LocationName.BowsersCastleWendyLarryHallwayDigspot),
lambda state: StateLogic.ultra(state, world.player)
and StateLogic.fire(state, world.player)
and StateLogic.canCrash(state, world.player)
)
if world.options.chuckle_beans != 0:
add_rule(
world.get_location(LocationName.BowsersCastleWendyLarryHallwayDigspot),
lambda state: StateLogic.ultra(state, world.player)
and StateLogic.fire(state, world.player)
and StateLogic.canCrash(state, world.player)
)
add_rule(
world.get_location(LocationName.BowsersCastleBeforeFawfulFightBlock1),
lambda state: StateLogic.canDig(state, world.player)

View File

@@ -1580,16 +1580,22 @@ def create_regions(world):
world.random.shuffle(world.item_pool)
if not world.options.key_items_only:
if "Player's House 2F - Player's PC" in world.options.exclude_locations:
acceptable_item = lambda item: item.excludable
elif "Player's House 2F - Player's PC" in world.options.priority_locations:
acceptable_item = lambda item: item.advancement
else:
acceptable_item = lambda item: True
def acceptable_item(item):
return ("Badge" not in item.name and "Trap" not in item.name and item.name != "Pokedex"
and "Coins" not in item.name and "Progressive" not in item.name
and ("Player's House 2F - Player's PC" not in world.options.exclude_locations or item.excludable)
and ("Player's House 2F - Player's PC" in world.options.exclude_locations or
"Player's House 2F - Player's PC" not in world.options.priority_locations or item.advancement))
for i, item in enumerate(world.item_pool):
if acceptable_item(item):
if acceptable_item(item) and (item.name not in world.options.non_local_items.value):
world.pc_item = world.item_pool.pop(i)
break
else:
for i, item in enumerate(world.item_pool):
if acceptable_item(item):
world.pc_item = world.item_pool.pop(i)
break
advancement_items = [item.name for item in world.item_pool if item.advancement] \
+ [item.name for item in world.multiworld.precollected_items[world.player] if

View File

@@ -5,12 +5,11 @@ from NetUtils import JSONMessagePart
from kvui import GameManager, HoverBehavior, ServerToolTip, KivyJSONtoTextParser
from kivy.app import App
from kivy.clock import Clock
from kivymd.uix.tab import MDTabsItem, MDTabsItemText
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 kivymd.uix.tooltip import MDTooltip
from kivy.uix.scrollview import ScrollView
from kivy.properties import StringProperty
@@ -26,30 +25,22 @@ class HoverableButton(HoverBehavior, Button):
pass
class MissionButton(HoverableButton):
class MissionButton(HoverableButton, MDTooltip):
tooltip_text = StringProperty("Test")
def __init__(self, *args, **kwargs):
super(HoverableButton, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
self.popuplabel = ServerToolTip(text=self.text, markup=True)
self.popuplabel.padding = [5, 2, 5, 2]
self.layout.add_widget(self.popuplabel)
super(HoverableButton, self).__init__(**kwargs)
self._tooltip = ServerToolTip(text=self.text, markup=True)
self._tooltip.padding = [5, 2, 5, 2]
def on_enter(self):
self.popuplabel.text = self.tooltip_text
self._tooltip.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
if self.tooltip_text != "":
self.display_tooltip()
def on_leave(self):
self.ctx.ui.clear_tooltip()
self.remove_tooltip()
@property
def ctx(self) -> SC2Context:

View File

@@ -4,7 +4,6 @@ from typing import BinaryIO, Optional
import Utils
from worlds.Files import APDeltaPatch
USHASH = '6e9c94511d04fac6e0a1e582c170be3a'
@@ -20,9 +19,9 @@ class SoEDeltaPatch(APDeltaPatch):
def get_base_rom_path(file_name: Optional[str] = None) -> str:
options = Utils.get_options()
if not file_name:
file_name = options["soe_options"]["rom_file"]
from . import SoEWorld
file_name = SoEWorld.settings.rom_file
if not file_name:
raise ValueError("Missing soe_options -> rom_file from host.yaml")
if not os.path.exists(file_name):

View File

@@ -145,7 +145,7 @@ class StardewValleyWorld(World):
def create_items(self):
self.precollect_starting_season()
self.precollect_farm_type_items()
self.precollect_building_items()
items_to_exclude = [excluded_items
for excluded_items in self.multiworld.precollected_items[self.player]
if not item_table[excluded_items.name].has_any_group(Group.RESOURCE_PACK,
@@ -200,9 +200,16 @@ class StardewValleyWorld(World):
starting_season = self.create_item(self.random.choice(season_pool))
self.multiworld.push_precollected(starting_season)
def precollect_farm_type_items(self):
if self.options.farm_type == FarmType.option_meadowlands and self.options.building_progression & BuildingProgression.option_progressive:
self.multiworld.push_precollected(self.create_item("Progressive Coop"))
def precollect_building_items(self):
building_progression = self.content.features.building_progression
# Not adding items when building are vanilla because the buildings are already placed in the world.
if not building_progression.is_progressive:
return
for building in building_progression.starting_buildings:
item, quantity = building_progression.to_progressive_item(building)
for _ in range(quantity):
self.multiworld.push_precollected(self.create_item(item))
def setup_logic_events(self):
def register_event(name: str, region: str, rule: StardewRule):

View File

@@ -1,8 +1,9 @@
from . import content_packs
from .feature import cropsanity, friendsanity, fishsanity, booksanity, skill_progression, tool_progression
from .feature import cropsanity, friendsanity, fishsanity, booksanity, building_progression, skill_progression, tool_progression
from .game_content import ContentPack, StardewContent, StardewFeatures
from .unpacking import unpack_content
from .. import options
from ..strings.building_names import Building
def create_content(player_options: options.StardewValleyOptions) -> StardewContent:
@@ -20,7 +21,7 @@ def choose_content_packs(player_options: options.StardewValleyOptions):
if player_options.special_order_locations & options.SpecialOrderLocations.value_qi:
active_packs.append(content_packs.qi_board_content_pack)
for mod in player_options.mods.value:
for mod in sorted(player_options.mods.value):
active_packs.append(content_packs.by_mod[mod])
return active_packs
@@ -29,6 +30,7 @@ def choose_content_packs(player_options: options.StardewValleyOptions):
def choose_features(player_options: options.StardewValleyOptions) -> StardewFeatures:
return StardewFeatures(
choose_booksanity(player_options.booksanity),
choose_building_progression(player_options.building_progression, player_options.farm_type),
choose_cropsanity(player_options.cropsanity),
choose_fishsanity(player_options.fishsanity),
choose_friendsanity(player_options.friendsanity, player_options.friendsanity_heart_size),
@@ -109,6 +111,32 @@ def choose_friendsanity(friendsanity_option: options.Friendsanity, heart_size: o
raise ValueError(f"No friendsanity feature mapped to {str(friendsanity_option.value)}")
def choose_building_progression(building_option: options.BuildingProgression,
farm_type_option: options.FarmType) -> building_progression.BuildingProgressionFeature:
starting_buildings = {Building.farm_house, Building.pet_bowl, Building.shipping_bin}
if farm_type_option == options.FarmType.option_meadowlands:
starting_buildings.add(Building.coop)
if (building_option == options.BuildingProgression.option_vanilla
or building_option == options.BuildingProgression.option_vanilla_cheap
or building_option == options.BuildingProgression.option_vanilla_very_cheap):
return building_progression.BuildingProgressionVanilla(
starting_buildings=starting_buildings,
)
starting_buildings.remove(Building.shipping_bin)
if (building_option == options.BuildingProgression.option_progressive
or building_option == options.BuildingProgression.option_progressive_cheap
or building_option == options.BuildingProgression.option_progressive_very_cheap):
return building_progression.BuildingProgressionProgressive(
starting_buildings=starting_buildings,
)
raise ValueError(f"No building progression feature mapped to {str(building_option.value)}")
skill_progression_by_option = {
options.SkillProgression.option_vanilla: skill_progression.SkillProgressionVanilla(),
options.SkillProgression.option_progressive: skill_progression.SkillProgressionProgressive(),

View File

@@ -1,4 +1,5 @@
from . import booksanity
from . import building_progression
from . import cropsanity
from . import fishsanity
from . import friendsanity

View File

@@ -0,0 +1,53 @@
from abc import ABC
from dataclasses import dataclass
from typing import ClassVar, Set, Tuple
from ...strings.building_names import Building
progressive_house = "Progressive House"
# This assumes that the farm house is always available, which might not be true forever...
progressive_house_by_upgrade_name = {
Building.farm_house: 0,
Building.kitchen: 1,
Building.kids_room: 2,
Building.cellar: 3
}
def to_progressive_item(building: str) -> Tuple[str, int]:
"""Return the name of the progressive item and its quantity required to unlock the building.
"""
if building in [Building.coop, Building.barn, Building.shed]:
return f"Progressive {building}", 1
elif building.startswith("Big"):
return f"Progressive {building[building.index(' ') + 1:]}", 2
elif building.startswith("Deluxe"):
return f"Progressive {building[building.index(' ') + 1:]}", 3
elif building in progressive_house_by_upgrade_name:
return progressive_house, progressive_house_by_upgrade_name[building]
return building, 1
def to_location_name(building: str) -> str:
return f"{building} Blueprint"
@dataclass(frozen=True)
class BuildingProgressionFeature(ABC):
is_progressive: ClassVar[bool]
starting_buildings: Set[str]
to_progressive_item = staticmethod(to_progressive_item)
progressive_house = progressive_house
to_location_name = staticmethod(to_location_name)
class BuildingProgressionVanilla(BuildingProgressionFeature):
is_progressive = False
class BuildingProgressionProgressive(BuildingProgressionFeature):
is_progressive = True

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Iterable, Set, Any, Mapping, Type, Tuple, Union
from .feature import booksanity, cropsanity, fishsanity, friendsanity, skill_progression, tool_progression
from .feature import booksanity, cropsanity, fishsanity, friendsanity, skill_progression, building_progression, tool_progression
from ..data.building import Building
from ..data.fish_data import FishItem
from ..data.game_item import GameItem, ItemSource, ItemTag
from ..data.game_item import GameItem, Source, ItemTag
from ..data.skill import Skill
from ..data.villagers_data import Villager
@@ -20,16 +21,17 @@ class StardewContent:
game_items: Dict[str, GameItem] = field(default_factory=dict)
fishes: Dict[str, FishItem] = field(default_factory=dict)
villagers: Dict[str, Villager] = field(default_factory=dict)
farm_buildings: Dict[str, Building] = field(default_factory=dict)
skills: Dict[str, Skill] = field(default_factory=dict)
quests: Dict[str, Any] = field(default_factory=dict)
def find_sources_of_type(self, types: Union[Type[ItemSource], Tuple[Type[ItemSource]]]) -> Iterable[ItemSource]:
def find_sources_of_type(self, types: Union[Type[Source], Tuple[Type[Source]]]) -> Iterable[Source]:
for item in self.game_items.values():
for source in item.sources:
if isinstance(source, types):
yield source
def source_item(self, item_name: str, *sources: ItemSource):
def source_item(self, item_name: str, *sources: Source):
item = self.game_items.setdefault(item_name, GameItem(item_name))
item.add_sources(sources)
@@ -50,6 +52,7 @@ class StardewContent:
@dataclass(frozen=True)
class StardewFeatures:
booksanity: booksanity.BooksanityFeature
building_progression: building_progression.BuildingProgressionFeature
cropsanity: cropsanity.CropsanityFeature
fishsanity: fishsanity.FishsanityFeature
friendsanity: friendsanity.FriendsanityFeature
@@ -70,13 +73,13 @@ class ContentPack:
# def item_hook
# ...
harvest_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict)
harvest_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict)
"""Harvest sources contains both crops and forageables, but also fruits from trees, the cave farm and stuff harvested from tapping like maple syrup."""
def harvest_source_hook(self, content: StardewContent):
...
shop_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict)
shop_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict)
def shop_source_hook(self, content: StardewContent):
...
@@ -86,12 +89,12 @@ class ContentPack:
def fish_hook(self, content: StardewContent):
...
crafting_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict)
crafting_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict)
def crafting_hook(self, content: StardewContent):
...
artisan_good_sources: Mapping[str, Iterable[ItemSource]] = field(default_factory=dict)
artisan_good_sources: Mapping[str, Iterable[Source]] = field(default_factory=dict)
def artisan_good_hook(self, content: StardewContent):
...
@@ -101,6 +104,11 @@ class ContentPack:
def villager_hook(self, content: StardewContent):
...
farm_buildings: Iterable[Building] = ()
def farm_building_hook(self, content: StardewContent):
...
skills: Iterable[Skill] = ()
def skill_hook(self, content: StardewContent):

View File

@@ -1,7 +1,25 @@
from ..game_content import ContentPack
from ..mod_registry import register_mod_content_pack
from ...data.building import Building
from ...data.shop import ShopSource
from ...mods.mod_data import ModNames
from ...strings.artisan_good_names import ArtisanGood
from ...strings.building_names import ModBuilding
from ...strings.metal_names import MetalBar
from ...strings.region_names import Region
register_mod_content_pack(ContentPack(
ModNames.tractor,
farm_buildings=(
Building(
ModBuilding.tractor_garage,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=150_000,
items_price=((20, MetalBar.iron), (5, MetalBar.iridium), (1, ArtisanGood.battery_pack)),
),
),
),
),
))

View File

@@ -5,7 +5,7 @@ from typing import Iterable, Mapping, Callable
from .game_content import StardewContent, ContentPack, StardewFeatures
from .vanilla.base import base_game as base_game_content_pack
from ..data.game_item import GameItem, ItemSource
from ..data.game_item import GameItem, Source
def unpack_content(features: StardewFeatures, packs: Iterable[ContentPack]) -> StardewContent:
@@ -61,6 +61,10 @@ def register_pack(content: StardewContent, pack: ContentPack):
content.villagers[villager.name] = villager
pack.villager_hook(content)
for building in pack.farm_buildings:
content.farm_buildings[building.name] = building
pack.farm_building_hook(content)
for skill in pack.skills:
content.skills[skill.name] = skill
pack.skill_hook(content)
@@ -73,7 +77,7 @@ def register_pack(content: StardewContent, pack: ContentPack):
def register_sources_and_call_hook(content: StardewContent,
sources_by_item_name: Mapping[str, Iterable[ItemSource]],
sources_by_item_name: Mapping[str, Iterable[Source]],
hook: Callable[[StardewContent], None]):
for item_name, sources in sources_by_item_name.items():
item = content.game_items.setdefault(item_name, GameItem(item_name))

View File

@@ -1,10 +1,13 @@
from ..game_content import ContentPack
from ...data import villagers_data, fish_data
from ...data.game_item import GenericSource, ItemTag, Tag, CustomRuleSource, CompoundSource
from ...data.building import Building
from ...data.game_item import GenericSource, ItemTag, Tag, CustomRuleSource
from ...data.harvest import ForagingSource, SeasonalForagingSource, ArtifactSpotSource
from ...data.requirement import ToolRequirement, BookRequirement, SkillRequirement
from ...data.shop import ShopSource, MysteryBoxSource, ArtifactTroveSource, PrizeMachineSource, FishingTreasureChestSource
from ...strings.artisan_good_names import ArtisanGood
from ...strings.book_names import Book
from ...strings.building_names import Building as BuildingNames
from ...strings.crop_names import Fruit
from ...strings.fish_names import WaterItem
from ...strings.food_names import Beverage, Meal
@@ -12,6 +15,7 @@ from ...strings.forageable_names import Forageable, Mushroom
from ...strings.fruit_tree_names import Sapling
from ...strings.generic_names import Generic
from ...strings.material_names import Material
from ...strings.metal_names import MetalBar
from ...strings.region_names import Region, LogicRegion
from ...strings.season_names import Season
from ...strings.seed_names import Seed, TreeSeed
@@ -229,10 +233,10 @@ pelican_town = ContentPack(
ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),),
Book.mapping_cave_systems: (
Tag(ItemTag.BOOK, ItemTag.BOOK_POWER),
CompoundSource(sources=(
GenericSource(regions=(Region.adventurer_guild_bedroom,)),
ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),
))),
GenericSource(regions=(Region.adventurer_guild_bedroom,)),
# Disabling the shop source for better game design.
# ShopSource(money_price=20000, shop_region=LogicRegion.bookseller_3),
),
Book.monster_compendium: (
Tag(ItemTag.BOOK, ItemTag.BOOK_POWER),
CustomRuleSource(create_rule=lambda logic: logic.monster.can_kill_many(Generic.any)),
@@ -385,5 +389,204 @@ pelican_town = ContentPack(
villagers_data.vincent,
villagers_data.willy,
villagers_data.wizard,
),
farm_buildings=(
Building(
BuildingNames.barn,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=6000,
items_price=((350, Material.wood), (150, Material.stone))
),
),
),
Building(
BuildingNames.big_barn,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=12_000,
items_price=((450, Material.wood), (200, Material.stone))
),
),
upgrade_from=BuildingNames.barn,
),
Building(
BuildingNames.deluxe_barn,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=25_000,
items_price=((550, Material.wood), (300, Material.stone))
),
),
upgrade_from=BuildingNames.big_barn,
),
Building(
BuildingNames.coop,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=4000,
items_price=((300, Material.wood), (100, Material.stone))
),
),
),
Building(
BuildingNames.big_coop,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=10_000,
items_price=((400, Material.wood), (150, Material.stone))
),
),
upgrade_from=BuildingNames.coop,
),
Building(
BuildingNames.deluxe_coop,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=20_000,
items_price=((500, Material.wood), (200, Material.stone))
),
),
upgrade_from=BuildingNames.big_coop,
),
Building(
BuildingNames.fish_pond,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=5000,
items_price=((200, Material.stone), (5, WaterItem.seaweed), (5, WaterItem.green_algae))
),
),
),
Building(
BuildingNames.mill,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=2500,
items_price=((50, Material.stone), (150, Material.wood), (4, ArtisanGood.cloth))
),
),
),
Building(
BuildingNames.shed,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=15_000,
items_price=((300, Material.wood),)
),
),
),
Building(
BuildingNames.big_shed,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=20_000,
items_price=((550, Material.wood), (300, Material.stone))
),
),
upgrade_from=BuildingNames.shed,
),
Building(
BuildingNames.silo,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=100,
items_price=((100, Material.stone), (10, Material.clay), (5, MetalBar.copper))
),
),
),
Building(
BuildingNames.slime_hutch,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=10_000,
items_price=((500, Material.stone), (10, MetalBar.quartz), (1, MetalBar.iridium))
),
),
),
Building(
BuildingNames.stable,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=10_000,
items_price=((100, Material.hardwood), (5, MetalBar.iron))
),
),
),
Building(
BuildingNames.well,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=1000,
items_price=((75, Material.stone),)
),
),
),
Building(
BuildingNames.shipping_bin,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=250,
items_price=((150, Material.wood),)
),
),
),
Building(
BuildingNames.pet_bowl,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=5000,
items_price=((25, Material.hardwood),)
),
),
),
Building(
BuildingNames.kitchen,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=10_000,
items_price=((450, Material.wood),)
),
),
upgrade_from=BuildingNames.farm_house,
),
Building(
BuildingNames.kids_room,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=65_000,
items_price=((100, Material.hardwood),)
),
),
upgrade_from=BuildingNames.kitchen,
),
Building(
BuildingNames.cellar,
sources=(
ShopSource(
shop_region=Region.carpenter,
money_price=100_000,
),
),
upgrade_from=BuildingNames.kids_room,
),
)
)

View File

@@ -1,10 +1,10 @@
from dataclasses import dataclass
from .game_item import ItemSource
from .game_item import Source
@dataclass(frozen=True, kw_only=True)
class MachineSource(ItemSource):
class MachineSource(Source):
item: str # this should be optional (worm bin)
machine: str
# seasons

View File

@@ -0,0 +1,16 @@
from dataclasses import dataclass, field
from functools import cached_property
from typing import Optional, Tuple
from .game_item import Source
@dataclass(frozen=True)
class Building:
name: str
sources: Tuple[Source, ...] = field(kw_only=True)
upgrade_from: Optional[str] = field(default=None, kw_only=True)
@cached_property
def is_upgrade(self) -> bool:
return self.upgrade_from is not None

View File

@@ -27,7 +27,7 @@ class ItemTag(enum.Enum):
@dataclass(frozen=True)
class ItemSource(ABC):
class Source(ABC):
add_tags: ClassVar[Tuple[ItemTag]] = ()
other_requirements: Tuple[Requirement, ...] = field(kw_only=True, default_factory=tuple)
@@ -38,23 +38,18 @@ class ItemSource(ABC):
@dataclass(frozen=True, kw_only=True)
class GenericSource(ItemSource):
class GenericSource(Source):
regions: Tuple[str, ...] = ()
"""No region means it's available everywhere."""
@dataclass(frozen=True)
class CustomRuleSource(ItemSource):
class CustomRuleSource(Source):
"""Hopefully once everything is migrated to sources, we won't need these custom logic anymore."""
create_rule: Callable[[Any], StardewRule]
@dataclass(frozen=True, kw_only=True)
class CompoundSource(ItemSource):
sources: Tuple[ItemSource, ...] = ()
class Tag(ItemSource):
class Tag(Source):
"""Not a real source, just a way to add tags to an item. Will be removed from the item sources during unpacking."""
tag: Tuple[ItemTag, ...]
@@ -69,10 +64,10 @@ class Tag(ItemSource):
@dataclass(frozen=True)
class GameItem:
name: str
sources: List[ItemSource] = field(default_factory=list)
sources: List[Source] = field(default_factory=list)
tags: Set[ItemTag] = field(default_factory=set)
def add_sources(self, sources: Iterable[ItemSource]):
def add_sources(self, sources: Iterable[Source]):
self.sources.extend(source for source in sources if type(source) is not Tag)
for source in sources:
self.add_tags(source.add_tags)

View File

@@ -1,18 +1,18 @@
from dataclasses import dataclass
from typing import Tuple, Sequence, Mapping
from .game_item import ItemSource, ItemTag
from .game_item import Source, ItemTag
from ..strings.season_names import Season
@dataclass(frozen=True, kw_only=True)
class ForagingSource(ItemSource):
class ForagingSource(Source):
regions: Tuple[str, ...]
seasons: Tuple[str, ...] = Season.all
@dataclass(frozen=True, kw_only=True)
class SeasonalForagingSource(ItemSource):
class SeasonalForagingSource(Source):
season: str
days: Sequence[int]
regions: Tuple[str, ...]
@@ -22,17 +22,17 @@ class SeasonalForagingSource(ItemSource):
@dataclass(frozen=True, kw_only=True)
class FruitBatsSource(ItemSource):
class FruitBatsSource(Source):
...
@dataclass(frozen=True, kw_only=True)
class MushroomCaveSource(ItemSource):
class MushroomCaveSource(Source):
...
@dataclass(frozen=True, kw_only=True)
class HarvestFruitTreeSource(ItemSource):
class HarvestFruitTreeSource(Source):
add_tags = (ItemTag.CROPSANITY,)
sapling: str
@@ -46,7 +46,7 @@ class HarvestFruitTreeSource(ItemSource):
@dataclass(frozen=True, kw_only=True)
class HarvestCropSource(ItemSource):
class HarvestCropSource(Source):
add_tags = (ItemTag.CROPSANITY,)
seed: str
@@ -61,5 +61,5 @@ class HarvestCropSource(ItemSource):
@dataclass(frozen=True, kw_only=True)
class ArtifactSpotSource(ItemSource):
class ArtifactSpotSource(Source):
amount: int

View File

@@ -509,6 +509,7 @@ id,name,classification,groups,mod_name
561,Fishing Bar Size Bonus,filler,PLAYER_BUFF,
562,Quality Bonus,filler,PLAYER_BUFF,
563,Glow Bonus,filler,PLAYER_BUFF,
564,Pet Bowl,progression,BUILDING,
4001,Burnt Trap,trap,TRAP,
4002,Darkness Trap,trap,TRAP,
4003,Frozen Trap,trap,TRAP,
1 id name classification groups mod_name
509 561 Fishing Bar Size Bonus filler PLAYER_BUFF
510 562 Quality Bonus filler PLAYER_BUFF
511 563 Glow Bonus filler PLAYER_BUFF
512 564 Pet Bowl progression BUILDING
513 4001 Burnt Trap trap TRAP
514 4002 Darkness Trap trap TRAP
515 4003 Frozen Trap trap TRAP

View File

@@ -21,6 +21,11 @@ class SkillRequirement(Requirement):
level: int
@dataclass(frozen=True)
class RegionRequirement(Requirement):
region: str
@dataclass(frozen=True)
class SeasonRequirement(Requirement):
season: str

View File

@@ -1,14 +1,14 @@
from dataclasses import dataclass
from typing import Tuple, Optional
from .game_item import ItemSource
from .game_item import Source
from ..strings.season_names import Season
ItemPrice = Tuple[int, str]
@dataclass(frozen=True, kw_only=True)
class ShopSource(ItemSource):
class ShopSource(Source):
shop_region: str
money_price: Optional[int] = None
items_price: Optional[Tuple[ItemPrice, ...]] = None
@@ -20,20 +20,20 @@ class ShopSource(ItemSource):
@dataclass(frozen=True, kw_only=True)
class MysteryBoxSource(ItemSource):
class MysteryBoxSource(Source):
amount: int
@dataclass(frozen=True, kw_only=True)
class ArtifactTroveSource(ItemSource):
class ArtifactTroveSource(Source):
amount: int
@dataclass(frozen=True, kw_only=True)
class PrizeMachineSource(ItemSource):
class PrizeMachineSource(Source):
amount: int
@dataclass(frozen=True, kw_only=True)
class FishingTreasureChestSource(ItemSource):
class FishingTreasureChestSource(Source):
amount: int

View File

@@ -23,9 +23,9 @@ def setup_early_items(multiworld, options: stardew_options.StardewValleyOptions,
add_seasonal_candidates(early_candidates, options)
if options.building_progression & stardew_options.BuildingProgression.option_progressive:
if content.features.building_progression.is_progressive:
early_forced.append(Building.shipping_bin)
if options.farm_type != stardew_options.FarmType.option_meadowlands:
if Building.coop not in content.features.building_progression.starting_buildings:
early_candidates.append("Progressive Coop")
early_candidates.append("Progressive Barn")

View File

@@ -15,7 +15,7 @@ from .data.game_item import ItemTag
from .logic.logic_event import all_events
from .mods.mod_data import ModNames
from .options import StardewValleyOptions, TrapItems, FestivalLocations, ExcludeGingerIsland, SpecialOrderLocations, SeasonRandomization, Museumsanity, \
BuildingProgression, ElevatorProgression, BackpackProgression, ArcadeMachineLocations, Monstersanity, Goal, \
ElevatorProgression, BackpackProgression, ArcadeMachineLocations, Monstersanity, Goal, \
Chefsanity, Craftsanity, BundleRandomization, EntranceRandomization, Shipsanity, Walnutsanity, EnabledFillerBuffs
from .strings.ap_names.ap_option_names import BuffOptionName, WalnutsanityOptionName
from .strings.ap_names.ap_weapon_names import APWeapon
@@ -225,7 +225,7 @@ def create_unique_items(item_factory: StardewItemFactory, options: StardewValley
create_tools(item_factory, content, items)
create_skills(item_factory, content, items)
create_wizard_buildings(item_factory, options, items)
create_carpenter_buildings(item_factory, options, items)
create_carpenter_buildings(item_factory, content, items)
items.append(item_factory("Railroad Boulder Removed"))
items.append(item_factory(CommunityUpgrade.fruit_bats))
items.append(item_factory(CommunityUpgrade.mushroom_boxes))
@@ -353,30 +353,14 @@ def create_wizard_buildings(item_factory: StardewItemFactory, options: StardewVa
items.append(item_factory("Woods Obelisk"))
def create_carpenter_buildings(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]):
building_option = options.building_progression
if not building_option & BuildingProgression.option_progressive:
def create_carpenter_buildings(item_factory: StardewItemFactory, content: StardewContent, items: List[Item]):
building_progression = content.features.building_progression
if not building_progression.is_progressive:
return
items.append(item_factory("Progressive Coop"))
items.append(item_factory("Progressive Coop"))
items.append(item_factory("Progressive Coop"))
items.append(item_factory("Progressive Barn"))
items.append(item_factory("Progressive Barn"))
items.append(item_factory("Progressive Barn"))
items.append(item_factory("Well"))
items.append(item_factory("Silo"))
items.append(item_factory("Mill"))
items.append(item_factory("Progressive Shed"))
items.append(item_factory("Progressive Shed", ItemClassification.useful))
items.append(item_factory("Fish Pond"))
items.append(item_factory("Stable"))
items.append(item_factory("Slime Hutch"))
items.append(item_factory("Shipping Bin"))
items.append(item_factory("Progressive House"))
items.append(item_factory("Progressive House"))
items.append(item_factory("Progressive House"))
if ModNames.tractor in options.mods:
items.append(item_factory("Tractor Garage"))
for building in content.farm_buildings.values():
item_name, _ = building_progression.to_progressive_item(building.name)
items.append(item_factory(item_name))
def create_quest_rewards(item_factory: StardewItemFactory, options: StardewValleyOptions, items: List[Item]):

View File

@@ -11,7 +11,7 @@ from .data.game_item import ItemTag
from .data.museum_data import all_museum_items
from .mods.mod_data import ModNames
from .options import ExcludeGingerIsland, ArcadeMachineLocations, SpecialOrderLocations, Museumsanity, \
FestivalLocations, BuildingProgression, ElevatorProgression, BackpackProgression, FarmType
FestivalLocations, ElevatorProgression, BackpackProgression, FarmType
from .options import StardewValleyOptions, Craftsanity, Chefsanity, Cooksanity, Shipsanity, Monstersanity
from .strings.goal_names import Goal
from .strings.quest_names import ModQuest, Quest
@@ -261,6 +261,19 @@ def extend_baby_locations(randomized_locations: List[LocationData]):
randomized_locations.extend(baby_locations)
def extend_building_locations(randomized_locations: List[LocationData], content: StardewContent):
building_progression = content.features.building_progression
if not building_progression.is_progressive:
return
for building in content.farm_buildings.values():
if building.name in building_progression.starting_buildings:
continue
location_name = building_progression.to_location_name(building.name)
randomized_locations.append(location_table[location_name])
def extend_festival_locations(randomized_locations: List[LocationData], options: StardewValleyOptions, random: Random):
if options.festival_locations == FestivalLocations.option_disabled:
return
@@ -485,10 +498,7 @@ def create_locations(location_collector: StardewLocationCollector,
if skill_progression.is_mastery_randomized(skill):
randomized_locations.append(location_table[skill.mastery_name])
if options.building_progression & BuildingProgression.option_progressive:
for location in locations_by_tag[LocationTags.BUILDING_BLUEPRINT]:
if location.mod_name is None or location.mod_name in options.mods:
randomized_locations.append(location_table[location.name])
extend_building_locations(randomized_locations, content)
if options.arcade_machine_locations != ArcadeMachineLocations.option_disabled:
randomized_locations.extend(locations_by_tag[LocationTags.ARCADE_MACHINE_VICTORY])

View File

@@ -20,7 +20,6 @@ class LogicRegistry:
self.museum_rules: Dict[str, StardewRule] = {}
self.festival_rules: Dict[str, StardewRule] = {}
self.quest_rules: Dict[str, StardewRule] = {}
self.building_rules: Dict[str, StardewRule] = {}
self.special_order_rules: Dict[str, StardewRule] = {}
self.sve_location_rules: Dict[str, StardewRule] = {}

View File

@@ -1,22 +1,22 @@
import typing
from functools import cached_property
from typing import Dict, Union
from typing import Union
from Utils import cache_self1
from .base_logic import BaseLogic, BaseLogicMixin
from .has_logic import HasLogicMixin
from .money_logic import MoneyLogicMixin
from .received_logic import ReceivedLogicMixin
from .region_logic import RegionLogicMixin
from ..options import BuildingProgression
from ..stardew_rule import StardewRule, True_, False_, Has
from ..strings.artisan_good_names import ArtisanGood
from ..stardew_rule import StardewRule, true_
from ..strings.building_names import Building
from ..strings.fish_names import WaterItem
from ..strings.material_names import Material
from ..strings.metal_names import MetalBar
from ..strings.region_names import Region
has_group = "building"
if typing.TYPE_CHECKING:
from .source_logic import SourceLogicMixin
else:
SourceLogicMixin = object
AUTO_BUILDING_BUILDINGS = {Building.shipping_bin, Building.pet_bowl, Building.farm_house}
class BuildingLogicMixin(BaseLogicMixin):
@@ -25,78 +25,38 @@ class BuildingLogicMixin(BaseLogicMixin):
self.building = BuildingLogic(*args, **kwargs)
class BuildingLogic(BaseLogic[Union[BuildingLogicMixin, MoneyLogicMixin, RegionLogicMixin, ReceivedLogicMixin, HasLogicMixin]]):
def initialize_rules(self):
self.registry.building_rules.update({
# @formatter:off
Building.barn: self.logic.money.can_spend(6000) & self.logic.has_all(Material.wood, Material.stone),
Building.big_barn: self.logic.money.can_spend(12000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.barn),
Building.deluxe_barn: self.logic.money.can_spend(25000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.big_barn),
Building.coop: self.logic.money.can_spend(4000) & self.logic.has_all(Material.wood, Material.stone),
Building.big_coop: self.logic.money.can_spend(10000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.coop),
Building.deluxe_coop: self.logic.money.can_spend(20000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.big_coop),
Building.fish_pond: self.logic.money.can_spend(5000) & self.logic.has_all(Material.stone, WaterItem.seaweed, WaterItem.green_algae),
Building.mill: self.logic.money.can_spend(2500) & self.logic.has_all(Material.stone, Material.wood, ArtisanGood.cloth),
Building.shed: self.logic.money.can_spend(15000) & self.logic.has(Material.wood),
Building.big_shed: self.logic.money.can_spend(20000) & self.logic.has_all(Material.wood, Material.stone) & self.logic.building.has_building(Building.shed),
Building.silo: self.logic.money.can_spend(100) & self.logic.has_all(Material.stone, Material.clay, MetalBar.copper),
Building.slime_hutch: self.logic.money.can_spend(10000) & self.logic.has_all(Material.stone, MetalBar.quartz, MetalBar.iridium),
Building.stable: self.logic.money.can_spend(10000) & self.logic.has_all(Material.hardwood, MetalBar.iron),
Building.well: self.logic.money.can_spend(1000) & self.logic.has(Material.stone),
Building.shipping_bin: self.logic.money.can_spend(250) & self.logic.has(Material.wood),
Building.kitchen: self.logic.money.can_spend(10000) & self.logic.has(Material.wood) & self.logic.building.has_house(0),
Building.kids_room: self.logic.money.can_spend(65000) & self.logic.has(Material.hardwood) & self.logic.building.has_house(1),
Building.cellar: self.logic.money.can_spend(100000) & self.logic.building.has_house(2),
# @formatter:on
})
def update_rules(self, new_rules: Dict[str, StardewRule]):
self.registry.building_rules.update(new_rules)
class BuildingLogic(BaseLogic[Union[BuildingLogicMixin, RegionLogicMixin, ReceivedLogicMixin, HasLogicMixin, SourceLogicMixin]]):
@cache_self1
def has_building(self, building: str) -> StardewRule:
# Shipping bin is special. The mod auto-builds it when received, no need to go to Robin.
if building is Building.shipping_bin:
if not self.options.building_progression & BuildingProgression.option_progressive:
return True_()
return self.logic.received(building)
def can_build(self, building_name: str) -> StardewRule:
building = self.content.farm_buildings.get(building_name)
assert building is not None, f"Building {building_name} not found."
source_rule = self.logic.source.has_access_to_any(building.sources)
if not building.is_upgrade:
return source_rule
upgrade_rule = self.logic.building.has_building(building.upgrade_from)
return self.logic.and_(upgrade_rule, source_rule)
@cache_self1
def has_building(self, building_name: str) -> StardewRule:
building_progression = self.content.features.building_progression
if building_name in building_progression.starting_buildings:
return true_
if not building_progression.is_progressive:
return self.logic.building.can_build(building_name)
# Those buildings are special. The mod auto-builds them when received, no need to go to Robin.
if building_name in AUTO_BUILDING_BUILDINGS:
return self.logic.received(Building.shipping_bin)
carpenter_rule = self.logic.building.can_construct_buildings
if not self.options.building_progression & BuildingProgression.option_progressive:
return Has(building, self.registry.building_rules, has_group) & carpenter_rule
count = 1
if building in [Building.coop, Building.barn, Building.shed]:
building = f"Progressive {building}"
elif building.startswith("Big"):
count = 2
building = " ".join(["Progressive", *building.split(" ")[1:]])
elif building.startswith("Deluxe"):
count = 3
building = " ".join(["Progressive", *building.split(" ")[1:]])
return self.logic.received(building, count) & carpenter_rule
item, count = building_progression.to_progressive_item(building_name)
return self.logic.received(item, count) & carpenter_rule
@cached_property
def can_construct_buildings(self) -> StardewRule:
return self.logic.region.can_reach(Region.carpenter)
@cache_self1
def has_house(self, upgrade_level: int) -> StardewRule:
if upgrade_level < 1:
return True_()
if upgrade_level > 3:
return False_()
carpenter_rule = self.logic.building.can_construct_buildings
if self.options.building_progression & BuildingProgression.option_progressive:
return carpenter_rule & self.logic.received(f"Progressive House", upgrade_level)
if upgrade_level == 1:
return carpenter_rule & Has(Building.kitchen, self.registry.building_rules, has_group)
if upgrade_level == 2:
return carpenter_rule & Has(Building.kids_room, self.registry.building_rules, has_group)
# if upgrade_level == 3:
return carpenter_rule & Has(Building.cellar, self.registry.building_rules, has_group)

View File

@@ -17,6 +17,7 @@ from ..data.recipe_data import RecipeSource, StarterSource, ShopSource, SkillSou
from ..data.recipe_source import CutsceneSource, ShopTradeSource
from ..options import Chefsanity
from ..stardew_rule import StardewRule, True_, False_
from ..strings.building_names import Building
from ..strings.region_names import LogicRegion
from ..strings.skill_names import Skill
from ..strings.tv_channel_names import Channel
@@ -32,7 +33,7 @@ class CookingLogic(BaseLogic[Union[HasLogicMixin, ReceivedLogicMixin, RegionLogi
BuildingLogicMixin, RelationshipLogicMixin, SkillLogicMixin, CookingLogicMixin]]):
@cached_property
def can_cook_in_kitchen(self) -> StardewRule:
return self.logic.building.has_house(1) | self.logic.skill.has_level(Skill.foraging, 9)
return self.logic.building.has_building(Building.kitchen) | self.logic.skill.has_level(Skill.foraging, 9)
# Should be cached
def can_cook(self, recipe: CookingRecipe = None) -> StardewRule:

View File

@@ -44,7 +44,7 @@ class GoalLogic(BaseLogic[StardewLogic]):
self.logic.museum.can_complete_museum(),
# Catching every fish not expected
# Shipping every item not expected
self.logic.relationship.can_get_married() & self.logic.building.has_house(2),
self.logic.relationship.can_get_married() & self.logic.building.has_building(Building.kids_room),
self.logic.relationship.has_hearts_with_n(5, 8), # 5 Friends
self.logic.relationship.has_hearts_with_n(10, 8), # 10 friends
self.logic.pet.has_pet_hearts(5), # Max Pet

View File

@@ -13,6 +13,7 @@ from ..strings.craftable_names import Consumable
from ..strings.currency_names import Currency
from ..strings.fish_names import WaterChest
from ..strings.geode_names import Geode
from ..strings.material_names import Material
from ..strings.region_names import Region
from ..strings.tool_names import Tool
@@ -21,9 +22,14 @@ if TYPE_CHECKING:
else:
ToolLogicMixin = object
MIN_ITEMS = 10
MAX_ITEMS = 999
PERCENT_REQUIRED_FOR_MAX_ITEM = 24
MIN_MEDIUM_ITEMS = 10
MAX_MEDIUM_ITEMS = 999
PERCENT_REQUIRED_FOR_MAX_MEDIUM_ITEM = 24
EASY_ITEMS = {Material.wood, Material.stone, Material.fiber, Material.sap}
MIN_EASY_ITEMS = 300
MAX_EASY_ITEMS = 2997
PERCENT_REQUIRED_FOR_MAX_EASY_ITEM = 6
class GrindLogicMixin(BaseLogicMixin):
@@ -43,7 +49,7 @@ class GrindLogic(BaseLogic[Union[GrindLogicMixin, HasLogicMixin, ReceivedLogicMi
# Assuming one box per day, but halved because we don't know how many months have passed before Mr. Qi's Plane Ride.
time_rule = self.logic.time.has_lived_months(quantity // 14)
return self.logic.and_(opening_rule, mystery_box_rule,
book_of_mysteries_rule, time_rule,)
book_of_mysteries_rule, time_rule, )
def can_grind_artifact_troves(self, quantity: int) -> StardewRule:
opening_rule = self.logic.region.can_reach(Region.blacksmith)
@@ -67,11 +73,26 @@ class GrindLogic(BaseLogic[Union[GrindLogicMixin, HasLogicMixin, ReceivedLogicMi
# Assuming twelve per month if the player does not grind it.
self.logic.time.has_lived_months(quantity // 12))
def can_grind_item(self, quantity: int, item: str | None = None) -> StardewRule:
if item in EASY_ITEMS:
return self.logic.grind.can_grind_easy_item(quantity)
else:
return self.logic.grind.can_grind_medium_item(quantity)
@cache_self1
def can_grind_item(self, quantity: int) -> StardewRule:
if quantity <= MIN_ITEMS:
def can_grind_medium_item(self, quantity: int) -> StardewRule:
if quantity <= MIN_MEDIUM_ITEMS:
return self.logic.true_
quantity = min(quantity, MAX_ITEMS)
price = max(1, quantity * PERCENT_REQUIRED_FOR_MAX_ITEM // MAX_ITEMS)
quantity = min(quantity, MAX_MEDIUM_ITEMS)
price = max(1, quantity * PERCENT_REQUIRED_FOR_MAX_MEDIUM_ITEM // MAX_MEDIUM_ITEMS)
return HasProgressionPercent(self.player, price)
@cache_self1
def can_grind_easy_item(self, quantity: int) -> StardewRule:
if quantity <= MIN_EASY_ITEMS:
return self.logic.true_
quantity = min(quantity, MAX_EASY_ITEMS)
price = max(1, quantity * PERCENT_REQUIRED_FOR_MAX_EASY_ITEM // MAX_EASY_ITEMS)
return HasProgressionPercent(self.player, price)

View File

@@ -254,7 +254,7 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
Geode.omni: self.mine.can_mine_in_the_mines_floor_41_80() | self.region.can_reach(Region.desert) | self.tool.has_tool(Tool.pan, ToolMaterial.iron) | self.received(Wallet.rusty_key) | (self.has(Fish.octopus) & self.building.has_building(Building.fish_pond)) | self.region.can_reach(Region.volcano_floor_10),
Gift.bouquet: self.relationship.has_hearts_with_any_bachelor(8) & self.money.can_spend_at(Region.pierre_store, 100),
Gift.golden_pumpkin: self.season.has(Season.fall) | self.action.can_open_geode(Geode.artifact_trove),
Gift.mermaid_pendant: self.region.can_reach(Region.tide_pools) & self.relationship.has_hearts_with_any_bachelor(10) & self.building.has_house(1) & self.has(Consumable.rain_totem),
Gift.mermaid_pendant: self.region.can_reach(Region.tide_pools) & self.relationship.has_hearts_with_any_bachelor(10) & self.building.has_building(Building.kitchen) & self.has(Consumable.rain_totem),
Gift.movie_ticket: self.money.can_spend_at(Region.movie_ticket_stand, 1000),
Gift.pearl: (self.has(Fish.blobfish) & self.building.has_building(Building.fish_pond)) | self.action.can_open_geode(Geode.artifact_trove),
Gift.tea_set: self.season.has(Season.winter) & self.time.has_lived_max_months,
@@ -355,9 +355,6 @@ class StardewLogic(ReceivedLogicMixin, HasLogicMixin, RegionLogicMixin, Travelin
obtention_rule = self.registry.item_rules[recipe] if recipe in self.registry.item_rules else False_()
self.registry.item_rules[recipe] = obtention_rule | crafting_rule
self.building.initialize_rules()
self.building.update_rules(self.mod.building.get_modded_building_rules())
self.quest.initialize_rules()
self.quest.update_rules(self.mod.quest.get_modded_quest_rules())

View File

@@ -17,8 +17,8 @@ from ..strings.region_names import Region, LogicRegion
if typing.TYPE_CHECKING:
from .shipping_logic import ShippingLogicMixin
assert ShippingLogicMixin
else:
ShippingLogicMixin = object
qi_gem_rewards = ("100 Qi Gems", "50 Qi Gems", "40 Qi Gems", "35 Qi Gems", "25 Qi Gems",
"20 Qi Gems", "15 Qi Gems", "10 Qi Gems")
@@ -31,7 +31,7 @@ class MoneyLogicMixin(BaseLogicMixin):
class MoneyLogic(BaseLogic[Union[RegionLogicMixin, MoneyLogicMixin, TimeLogicMixin, RegionLogicMixin, ReceivedLogicMixin, HasLogicMixin, SeasonLogicMixin,
GrindLogicMixin, 'ShippingLogicMixin']]):
GrindLogicMixin, ShippingLogicMixin]]):
@cache_self1
def can_have_earned_total(self, amount: int) -> StardewRule:
@@ -80,7 +80,7 @@ GrindLogicMixin, 'ShippingLogicMixin']]):
item_rules = []
if source.items_price is not None:
for price, item in source.items_price:
item_rules.append(self.logic.has(item) & self.logic.grind.can_grind_item(price))
item_rules.append(self.logic.has(item) & self.logic.grind.can_grind_item(price, item))
region_rule = self.logic.region.can_reach(source.shop_region)

View File

@@ -15,9 +15,9 @@ from ..content.feature import friendsanity
from ..data.villagers_data import Villager
from ..stardew_rule import StardewRule, True_, false_, true_
from ..strings.ap_names.mods.mod_items import SVEQuestItem
from ..strings.building_names import Building
from ..strings.generic_names import Generic
from ..strings.gift_names import Gift
from ..strings.quest_names import ModQuest
from ..strings.region_names import Region
from ..strings.season_names import Season
from ..strings.villager_names import NPC, ModNPC
@@ -63,7 +63,7 @@ ReceivedLogicMixin, HasLogicMixin, ModLogicMixin]]):
if not self.content.features.friendsanity.is_enabled:
return self.logic.relationship.can_reproduce(number_children)
return self.logic.received_n(*possible_kids, count=number_children) & self.logic.building.has_house(2)
return self.logic.received_n(*possible_kids, count=number_children) & self.logic.building.has_building(Building.kids_room)
def can_reproduce(self, number_children: int = 1) -> StardewRule:
assert number_children >= 0, "Can't have a negative amount of children."
@@ -71,7 +71,7 @@ ReceivedLogicMixin, HasLogicMixin, ModLogicMixin]]):
return True_()
baby_rules = [self.logic.relationship.can_get_married(),
self.logic.building.has_house(2),
self.logic.building.has_building(Building.kids_room),
self.logic.relationship.has_hearts_with_any_bachelor(12),
self.logic.relationship.has_children(number_children - 1)]

View File

@@ -8,6 +8,7 @@ from .fishing_logic import FishingLogicMixin
from .has_logic import HasLogicMixin
from .quest_logic import QuestLogicMixin
from .received_logic import ReceivedLogicMixin
from .region_logic import RegionLogicMixin
from .relationship_logic import RelationshipLogicMixin
from .season_logic import SeasonLogicMixin
from .skill_logic import SkillLogicMixin
@@ -16,7 +17,7 @@ from .tool_logic import ToolLogicMixin
from .walnut_logic import WalnutLogicMixin
from ..data.game_item import Requirement
from ..data.requirement import ToolRequirement, BookRequirement, SkillRequirement, SeasonRequirement, YearRequirement, CombatRequirement, QuestRequirement, \
RelationshipRequirement, FishingRequirement, WalnutRequirement
RelationshipRequirement, FishingRequirement, WalnutRequirement, RegionRequirement
class RequirementLogicMixin(BaseLogicMixin):
@@ -26,7 +27,7 @@ class RequirementLogicMixin(BaseLogicMixin):
class RequirementLogic(BaseLogic[Union[RequirementLogicMixin, HasLogicMixin, ReceivedLogicMixin, ToolLogicMixin, SkillLogicMixin, BookLogicMixin,
SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, RelationshipLogicMixin, FishingLogicMixin, WalnutLogicMixin]]):
SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, RelationshipLogicMixin, FishingLogicMixin, WalnutLogicMixin, RegionLogicMixin]]):
def meet_all_requirements(self, requirements: Iterable[Requirement]):
if not requirements:
@@ -45,6 +46,10 @@ SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, Relationshi
def _(self, requirement: SkillRequirement):
return self.logic.skill.has_level(requirement.skill, requirement.level)
@meet_requirement.register
def _(self, requirement: RegionRequirement):
return self.logic.region.can_reach(requirement.region)
@meet_requirement.register
def _(self, requirement: BookRequirement):
return self.logic.book.has_book_power(requirement.book)
@@ -76,5 +81,3 @@ SeasonLogicMixin, TimeLogicMixin, CombatLogicMixin, QuestLogicMixin, Relationshi
@meet_requirement.register
def _(self, requirement: FishingRequirement):
return self.logic.fishing.can_fish_at(requirement.region)

View File

@@ -12,7 +12,7 @@ from .region_logic import RegionLogicMixin
from .requirement_logic import RequirementLogicMixin
from .tool_logic import ToolLogicMixin
from ..data.artisan import MachineSource
from ..data.game_item import GenericSource, ItemSource, GameItem, CustomRuleSource, CompoundSource
from ..data.game_item import GenericSource, Source, GameItem, CustomRuleSource
from ..data.harvest import ForagingSource, FruitBatsSource, MushroomCaveSource, SeasonalForagingSource, \
HarvestCropSource, HarvestFruitTreeSource, ArtifactSpotSource
from ..data.shop import ShopSource, MysteryBoxSource, ArtifactTroveSource, PrizeMachineSource, FishingTreasureChestSource
@@ -25,7 +25,7 @@ class SourceLogicMixin(BaseLogicMixin):
class SourceLogic(BaseLogic[Union[SourceLogicMixin, HasLogicMixin, ReceivedLogicMixin, HarvestingLogicMixin, MoneyLogicMixin, RegionLogicMixin,
ArtisanLogicMixin, ToolLogicMixin, RequirementLogicMixin, GrindLogicMixin]]):
ArtisanLogicMixin, ToolLogicMixin, RequirementLogicMixin, GrindLogicMixin]]):
def has_access_to_item(self, item: GameItem):
rules = []
@@ -36,14 +36,10 @@ class SourceLogic(BaseLogic[Union[SourceLogicMixin, HasLogicMixin, ReceivedLogic
rules.append(self.logic.source.has_access_to_any(item.sources))
return self.logic.and_(*rules)
def has_access_to_any(self, sources: Iterable[ItemSource]):
def has_access_to_any(self, sources: Iterable[Source]):
return self.logic.or_(*(self.logic.source.has_access_to(source) & self.logic.requirement.meet_all_requirements(source.other_requirements)
for source in sources))
def has_access_to_all(self, sources: Iterable[ItemSource]):
return self.logic.and_(*(self.logic.source.has_access_to(source) & self.logic.requirement.meet_all_requirements(source.other_requirements)
for source in sources))
@functools.singledispatchmethod
def has_access_to(self, source: Any):
raise ValueError(f"Sources of type{type(source)} have no rule registered.")
@@ -56,10 +52,6 @@ class SourceLogic(BaseLogic[Union[SourceLogicMixin, HasLogicMixin, ReceivedLogic
def _(self, source: CustomRuleSource):
return source.create_rule(self.logic)
@has_access_to.register
def _(self, source: CompoundSource):
return self.logic.source.has_access_to_all(source.sources)
@has_access_to.register
def _(self, source: ForagingSource):
return self.logic.harvesting.can_forage_from(source)

View File

@@ -1,28 +0,0 @@
from typing import Dict, Union
from ..mod_data import ModNames
from ...logic.base_logic import BaseLogicMixin, BaseLogic
from ...logic.has_logic import HasLogicMixin
from ...logic.money_logic import MoneyLogicMixin
from ...stardew_rule import StardewRule
from ...strings.artisan_good_names import ArtisanGood
from ...strings.building_names import ModBuilding
from ...strings.metal_names import MetalBar
from ...strings.region_names import Region
class ModBuildingLogicMixin(BaseLogicMixin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.building = ModBuildingLogic(*args, **kwargs)
class ModBuildingLogic(BaseLogic[Union[MoneyLogicMixin, HasLogicMixin]]):
def get_modded_building_rules(self) -> Dict[str, StardewRule]:
buildings = dict()
if ModNames.tractor in self.options.mods:
tractor_rule = (self.logic.money.can_spend_at(Region.carpenter, 150000) &
self.logic.has_all(MetalBar.iron, MetalBar.iridium, ArtisanGood.battery_pack))
buildings.update({ModBuilding.tractor_garage: tractor_rule})
return buildings

View File

@@ -1,4 +1,3 @@
from .buildings_logic import ModBuildingLogicMixin
from .deepwoods_logic import DeepWoodsLogicMixin
from .elevator_logic import ModElevatorLogicMixin
from .item_logic import ModItemLogicMixin
@@ -16,6 +15,6 @@ class ModLogicMixin(BaseLogicMixin):
self.mod = ModLogic(*args, **kwargs)
class ModLogic(ModElevatorLogicMixin, MagicLogicMixin, ModSkillLogicMixin, ModItemLogicMixin, ModQuestLogicMixin, ModBuildingLogicMixin,
class ModLogic(ModElevatorLogicMixin, MagicLogicMixin, ModSkillLogicMixin, ModItemLogicMixin, ModQuestLogicMixin,
ModSpecialOrderLogicMixin, DeepWoodsLogicMixin, SVELogicMixin):
pass

View File

@@ -34,7 +34,7 @@ class RegionData:
merged_exits.extend(self.exits)
if exits is not None:
merged_exits.extend(exits)
merged_exits = list(set(merged_exits))
merged_exits = sorted(set(merged_exits))
return RegionData(self.name, merged_exits, is_ginger_island=self.is_ginger_island)
def get_without_exits(self, exits_to_remove: Set[str]):

View File

@@ -521,7 +521,7 @@ def create_final_regions(world_options) -> List[RegionData]:
final_regions.extend(vanilla_regions)
if world_options.mods is None:
return final_regions
for mod in world_options.mods.value:
for mod in sorted(world_options.mods.value):
if mod not in ModDataList:
continue
for mod_region in ModDataList[mod].regions:
@@ -747,8 +747,7 @@ def swap_one_random_connection(regions_by_name, connections_by_name, randomized_
randomized_connections_already_shuffled = {connection: randomized_connections[connection]
for connection in randomized_connections
if connection != randomized_connections[connection]}
unreachable_regions_names_leading_somewhere = tuple([region for region in unreachable_regions
if len(regions_by_name[region].exits) > 0])
unreachable_regions_names_leading_somewhere = [region for region in sorted(unreachable_regions) if len(regions_by_name[region].exits) > 0]
unreachable_regions_leading_somewhere = [regions_by_name[region_name] for region_name in unreachable_regions_names_leading_somewhere]
unreachable_regions_exits_names = [exit_name for region in unreachable_regions_leading_somewhere for exit_name in region.exits]
unreachable_connections = [connections_by_name[exit_name] for exit_name in unreachable_regions_exits_names]

View File

@@ -19,7 +19,7 @@ from .logic.logic import StardewLogic
from .logic.time_logic import MAX_MONTHS
from .logic.tool_logic import tool_upgrade_prices
from .mods.mod_data import ModNames
from .options import BuildingProgression, ExcludeGingerIsland, SpecialOrderLocations, Museumsanity, BackpackProgression, Shipsanity, \
from .options import ExcludeGingerIsland, SpecialOrderLocations, Museumsanity, BackpackProgression, Shipsanity, \
Monstersanity, Chefsanity, Craftsanity, ArcadeMachineLocations, Cooksanity, StardewValleyOptions, Walnutsanity
from .stardew_rule import And, StardewRule, true_
from .stardew_rule.indirect_connection import look_for_indirect_connection
@@ -71,7 +71,7 @@ def set_rules(world):
set_tool_rules(logic, multiworld, player, world_content)
set_skills_rules(logic, multiworld, player, world_content)
set_bundle_rules(bundle_rooms, logic, multiworld, player, world_options)
set_building_rules(logic, multiworld, player, world_options)
set_building_rules(logic, multiworld, player, world_content)
set_cropsanity_rules(logic, multiworld, player, world_content)
set_story_quests_rules(all_location_names, logic, multiworld, player, world_options)
set_special_order_rules(all_location_names, logic, multiworld, player, world_options)
@@ -130,15 +130,19 @@ def set_tool_rules(logic: StardewLogic, multiworld, player, content: StardewCont
MultiWorldRules.set_rule(tool_upgrade_location, logic.tool.has_tool(tool, previous))
def set_building_rules(logic: StardewLogic, multiworld, player, world_options: StardewValleyOptions):
if not world_options.building_progression & BuildingProgression.option_progressive:
def set_building_rules(logic: StardewLogic, multiworld, player, content: StardewContent):
building_progression = content.features.building_progression
if not building_progression.is_progressive:
return
for building in locations.locations_by_tag[LocationTags.BUILDING_BLUEPRINT]:
if building.mod_name is not None and building.mod_name not in world_options.mods:
for building in content.farm_buildings.values():
if building.name in building_progression.starting_buildings:
continue
MultiWorldRules.set_rule(multiworld.get_location(building.name, player),
logic.registry.building_rules[building.name.replace(" Blueprint", "")])
location_name = building_progression.to_location_name(building.name)
MultiWorldRules.set_rule(multiworld.get_location(location_name, player),
logic.building.can_build(building.name))
def set_bundle_rules(bundle_rooms: List[BundleRoom], logic: StardewLogic, multiworld, player, world_options: StardewValleyOptions):
@@ -241,7 +245,7 @@ def set_dangerous_mine_rules(logic, multiworld, player, world_options: StardewVa
def set_farm_buildings_entrance_rules(logic, multiworld, player):
set_entrance_rule(multiworld, player, Entrance.downstairs_to_cellar, logic.building.has_house(3))
set_entrance_rule(multiworld, player, Entrance.downstairs_to_cellar, logic.building.has_building(Building.cellar))
set_entrance_rule(multiworld, player, Entrance.use_desert_obelisk, logic.can_use_obelisk(Transportation.desert_obelisk))
set_entrance_rule(multiworld, player, Entrance.enter_greenhouse, logic.received("Greenhouse"))
set_entrance_rule(multiworld, player, Entrance.enter_coop, logic.building.has_building(Building.coop))

View File

@@ -14,9 +14,11 @@ class Building:
stable = "Stable"
well = "Well"
shipping_bin = "Shipping Bin"
farm_house = "Farm House"
kitchen = "Kitchen"
kids_room = "Kids Room"
cellar = "Cellar"
pet_bowl = "Pet Bowl"
class ModBuilding:

View File

@@ -61,11 +61,13 @@ class TestBooksanityNone(SVTestBase):
for location in self.multiworld.get_locations():
if not location.name.startswith(shipsanity_prefix):
continue
item_to_ship = location.name[len(shipsanity_prefix):]
if item_to_ship not in power_books and item_to_ship not in skill_books:
continue
with self.subTest(location.name):
self.assert_can_reach_location(location, self.multiworld.state)
self.assert_can_reach_location(location)
class TestBooksanityPowers(SVTestBase):
@@ -107,11 +109,13 @@ class TestBooksanityPowers(SVTestBase):
for location in self.multiworld.get_locations():
if not location.name.startswith(shipsanity_prefix):
continue
item_to_ship = location.name[len(shipsanity_prefix):]
if item_to_ship not in power_books and item_to_ship not in skill_books:
continue
with self.subTest(location.name):
self.assert_can_reach_location(location, self.multiworld.state)
self.assert_can_reach_location(location)
class TestBooksanityPowersAndSkills(SVTestBase):
@@ -153,11 +157,13 @@ class TestBooksanityPowersAndSkills(SVTestBase):
for location in self.multiworld.get_locations():
if not location.name.startswith(shipsanity_prefix):
continue
item_to_ship = location.name[len(shipsanity_prefix):]
if item_to_ship not in power_books and item_to_ship not in skill_books:
continue
with self.subTest(location.name):
self.assert_can_reach_location(location, self.multiworld.state)
self.assert_can_reach_location(location)
class TestBooksanityAll(SVTestBase):
@@ -199,8 +205,10 @@ class TestBooksanityAll(SVTestBase):
for location in self.multiworld.get_locations():
if not location.name.startswith(shipsanity_prefix):
continue
item_to_ship = location.name[len(shipsanity_prefix):]
if item_to_ship not in power_books and item_to_ship not in skill_books:
continue
with self.subTest(location.name):
self.assert_can_reach_location(location, self.multiworld.state)
self.assert_can_reach_location(location)

View File

@@ -1,5 +1,9 @@
from . import SVTestBase
from .. import options
from ..strings.ap_names.transport_names import Transportation
from ..strings.building_names import Building
from ..strings.region_names import Region
from ..strings.seed_names import Seed
class TestCropsanityRules(SVTestBase):
@@ -8,13 +12,13 @@ class TestCropsanityRules(SVTestBase):
}
def test_need_greenhouse_for_cactus(self):
harvest_cactus = self.world.logic.region.can_reach_location("Harvest Cactus Fruit")
self.assert_rule_false(harvest_cactus, self.multiworld.state)
harvest_cactus_fruit = "Harvest Cactus Fruit"
self.assert_cannot_reach_location(harvest_cactus_fruit)
self.multiworld.state.collect(self.create_item("Cactus Seeds"))
self.multiworld.state.collect(self.create_item("Shipping Bin"))
self.multiworld.state.collect(self.create_item("Desert Obelisk"))
self.assert_rule_false(harvest_cactus, self.multiworld.state)
self.multiworld.state.collect(self.create_item(Seed.cactus))
self.multiworld.state.collect(self.create_item(Building.shipping_bin))
self.multiworld.state.collect(self.create_item(Transportation.desert_obelisk))
self.assert_cannot_reach_location(harvest_cactus_fruit)
self.multiworld.state.collect(self.create_item("Greenhouse"))
self.assert_rule_true(harvest_cactus, self.multiworld.state)
self.multiworld.state.collect(self.create_item(Region.greenhouse))
self.assert_can_reach_location(harvest_cactus_fruit)

View File

@@ -1,3 +1,5 @@
from collections import Counter
from . import SVTestBase
from .assertion import WorldAssertMixin
from .. import options
@@ -5,27 +7,49 @@ from .. import options
class TestStartInventoryStandardFarm(WorldAssertMixin, SVTestBase):
options = {
options.FarmType.internal_name: options.FarmType.option_standard,
options.FarmType: options.FarmType.option_standard,
}
def test_start_inventory_progressive_coops(self):
start_items = dict(map(lambda x: (x.name, self.multiworld.precollected_items[self.player].count(x)), self.multiworld.precollected_items[self.player]))
items = dict(map(lambda x: (x.name, self.multiworld.itempool.count(x)), self.multiworld.itempool))
start_items = Counter((i.name for i in self.multiworld.precollected_items[self.player]))
items = Counter((i.name for i in self.multiworld.itempool))
self.assertIn("Progressive Coop", items)
self.assertEqual(items["Progressive Coop"], 3)
self.assertNotIn("Progressive Coop", start_items)
def test_coop_is_not_logically_available(self):
self.assert_rule_false(self.world.logic.building.has_building("Coop"))
class TestStartInventoryMeadowLands(WorldAssertMixin, SVTestBase):
class TestStartInventoryMeadowLandsProgressiveBuilding(WorldAssertMixin, SVTestBase):
options = {
options.FarmType.internal_name: options.FarmType.option_meadowlands,
options.BuildingProgression.internal_name: options.BuildingProgression.option_progressive,
options.FarmType: options.FarmType.option_meadowlands,
options.BuildingProgression: options.BuildingProgression.option_progressive,
}
def test_start_inventory_progressive_coops(self):
start_items = dict(map(lambda x: (x.name, self.multiworld.precollected_items[self.player].count(x)), self.multiworld.precollected_items[self.player]))
items = dict(map(lambda x: (x.name, self.multiworld.itempool.count(x)), self.multiworld.itempool))
start_items = Counter((i.name for i in self.multiworld.precollected_items[self.player]))
items = Counter((i.name for i in self.multiworld.itempool))
self.assertIn("Progressive Coop", items)
self.assertEqual(items["Progressive Coop"], 2)
self.assertIn("Progressive Coop", start_items)
self.assertEqual(start_items["Progressive Coop"], 1)
def test_coop_is_logically_available(self):
self.assert_rule_true(self.world.logic.building.has_building("Coop"))
class TestStartInventoryMeadowLandsVanillaBuildings(WorldAssertMixin, SVTestBase):
options = {
options.FarmType: options.FarmType.option_meadowlands,
options.BuildingProgression: options.BuildingProgression.option_vanilla,
}
def test_start_inventory_has_no_coop(self):
start_items = Counter((i.name for i in self.multiworld.precollected_items[self.player]))
self.assertNotIn("Progressive Coop", start_items)
def test_coop_is_logically_available(self):
self.assert_rule_true(self.world.logic.building.has_building("Coop"))

View File

@@ -3,13 +3,30 @@ from typing import List
from BaseClasses import ItemClassification, Item
from . import SVTestBase
from .. import items, location_table, options
from ..items import Group
from ..items import Group, ItemData
from ..locations import LocationTags
from ..options import Friendsanity, SpecialOrderLocations, Shipsanity, Chefsanity, SeasonRandomization, Craftsanity, ExcludeGingerIsland, SkillProgression, \
Booksanity, Walnutsanity
from ..strings.region_names import Region
def get_all_permanent_progression_items() -> List[ItemData]:
"""Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression.
"""
return [
item
for item in items.all_items
if ItemClassification.progression in item.classification
if item.mod_name is None
if item.name not in {event.name for event in items.events}
if item.name not in {deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED]}
if item.name not in {season.name for season in items.items_by_group[Group.SEASON]}
if item.name not in {weapon.name for weapon in items.items_by_group[Group.WEAPON]}
if item.name not in {baby.name for baby in items.items_by_group[Group.BABY]}
if item.name != "The Gateway Gazette"
]
class TestBaseItemGeneration(SVTestBase):
options = {
SeasonRandomization.internal_name: SeasonRandomization.option_progressive,
@@ -25,17 +42,8 @@ class TestBaseItemGeneration(SVTestBase):
}
def test_all_progression_items_are_added_to_the_pool(self):
all_created_items = [item.name for item in self.multiworld.itempool]
# Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression
items_to_ignore = [event.name for event in items.events]
items_to_ignore.extend(item.name for item in items.all_items if item.mod_name is not None)
items_to_ignore.extend(deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED])
items_to_ignore.extend(season.name for season in items.items_by_group[Group.SEASON])
items_to_ignore.extend(weapon.name for weapon in items.items_by_group[Group.WEAPON])
items_to_ignore.extend(baby.name for baby in items.items_by_group[Group.BABY])
items_to_ignore.extend(resource_pack.name for resource_pack in items.items_by_group[Group.RESOURCE_PACK])
items_to_ignore.append("The Gateway Gazette")
progression_items = [item for item in items.all_items if item.classification & ItemClassification.progression and item.name not in items_to_ignore]
all_created_items = set(self.get_all_created_items())
progression_items = get_all_permanent_progression_items()
for progression_item in progression_items:
with self.subTest(f"{progression_item.name}"):
self.assertIn(progression_item.name, all_created_items)
@@ -45,19 +53,19 @@ class TestBaseItemGeneration(SVTestBase):
self.assertEqual(len(non_event_locations), len(self.multiworld.itempool))
def test_does_not_create_deprecated_items(self):
all_created_items = [item.name for item in self.multiworld.itempool]
all_created_items = set(self.get_all_created_items())
for deprecated_item in items.items_by_group[items.Group.DEPRECATED]:
with self.subTest(f"{deprecated_item.name}"):
self.assertNotIn(deprecated_item.name, all_created_items)
def test_does_not_create_more_than_one_maximum_one_items(self):
all_created_items = [item.name for item in self.multiworld.itempool]
all_created_items = self.get_all_created_items()
for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]:
with self.subTest(f"{maximum_one_item.name}"):
self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1)
def test_does_not_create_exactly_two_items(self):
all_created_items = [item.name for item in self.multiworld.itempool]
def test_does_not_create_or_create_two_of_exactly_two_items(self):
all_created_items = self.get_all_created_items()
for exactly_two_item in items.items_by_group[items.Group.EXACTLY_TWO]:
with self.subTest(f"{exactly_two_item.name}"):
count = all_created_items.count(exactly_two_item.name)
@@ -77,17 +85,10 @@ class TestNoGingerIslandItemGeneration(SVTestBase):
}
def test_all_progression_items_except_island_are_added_to_the_pool(self):
all_created_items = [item.name for item in self.multiworld.itempool]
# Ignore all the stuff that the algorithm chooses one of, instead of all, to fulfill logical progression
items_to_ignore = [event.name for event in items.events]
items_to_ignore.extend(item.name for item in items.all_items if item.mod_name is not None)
items_to_ignore.extend(deprecated.name for deprecated in items.items_by_group[Group.DEPRECATED])
items_to_ignore.extend(season.name for season in items.items_by_group[Group.SEASON])
items_to_ignore.extend(season.name for season in items.items_by_group[Group.WEAPON])
items_to_ignore.extend(baby.name for baby in items.items_by_group[Group.BABY])
items_to_ignore.append("The Gateway Gazette")
progression_items = [item for item in items.all_items if item.classification & ItemClassification.progression and item.name not in items_to_ignore]
all_created_items = set(self.get_all_created_items())
progression_items = get_all_permanent_progression_items()
for progression_item in progression_items:
with self.subTest(f"{progression_item.name}"):
if Group.GINGER_ISLAND in progression_item.groups:
self.assertNotIn(progression_item.name, all_created_items)
@@ -100,19 +101,19 @@ class TestNoGingerIslandItemGeneration(SVTestBase):
self.assertEqual(len(non_event_locations), len(self.multiworld.itempool))
def test_does_not_create_deprecated_items(self):
all_created_items = [item.name for item in self.multiworld.itempool]
all_created_items = self.get_all_created_items()
for deprecated_item in items.items_by_group[items.Group.DEPRECATED]:
with self.subTest(f"Deprecated item: {deprecated_item.name}"):
self.assertNotIn(deprecated_item.name, all_created_items)
def test_does_not_create_more_than_one_maximum_one_items(self):
all_created_items = [item.name for item in self.multiworld.itempool]
all_created_items = self.get_all_created_items()
for maximum_one_item in items.items_by_group[items.Group.MAXIMUM_ONE]:
with self.subTest(f"{maximum_one_item.name}"):
self.assertLessEqual(all_created_items.count(maximum_one_item.name), 1)
def test_does_not_create_exactly_two_items(self):
all_created_items = [item.name for item in self.multiworld.itempool]
all_created_items = self.get_all_created_items()
for exactly_two_item in items.items_by_group[items.Group.EXACTLY_TWO]:
with self.subTest(f"{exactly_two_item.name}"):
count = all_created_items.count(exactly_two_item.name)

View File

@@ -49,9 +49,9 @@ class LogicTestBase(RuleAssertMixin, TestCase):
self.assert_rule_can_be_resolved(rule, self.multiworld.state)
def test_given_building_rule_then_can_be_resolved(self):
for building in self.logic.registry.building_rules.keys():
for building in self.world.content.farm_buildings:
with self.subTest(msg=building):
rule = self.logic.registry.building_rules[building]
rule = self.logic.building.can_build(building)
self.assert_rule_can_be_resolved(rule, self.multiworld.state)
def test_given_quest_rule_then_can_be_resolved(self):

View File

@@ -8,9 +8,8 @@ class TestBitFlagsVanilla(SVTestBase):
BuildingProgression.internal_name: BuildingProgression.option_vanilla}
def test_options_are_not_detected_as_progressive(self):
world_options = self.world.options
tool_progressive = self.world.content.features.tool_progression.is_progressive
building_progressive = world_options.building_progression & BuildingProgression.option_progressive
building_progressive = self.world.content.features.building_progression.is_progressive
self.assertFalse(tool_progressive)
self.assertFalse(building_progressive)
@@ -25,9 +24,8 @@ class TestBitFlagsVanillaCheap(SVTestBase):
BuildingProgression.internal_name: BuildingProgression.option_vanilla_cheap}
def test_options_are_not_detected_as_progressive(self):
world_options = self.world.options
tool_progressive = self.world.content.features.tool_progression.is_progressive
building_progressive = world_options.building_progression & BuildingProgression.option_progressive
building_progressive = self.world.content.features.building_progression.is_progressive
self.assertFalse(tool_progressive)
self.assertFalse(building_progressive)
@@ -42,9 +40,8 @@ class TestBitFlagsVanillaVeryCheap(SVTestBase):
BuildingProgression.internal_name: BuildingProgression.option_vanilla_very_cheap}
def test_options_are_not_detected_as_progressive(self):
world_options = self.world.options
tool_progressive = self.world.content.features.tool_progression.is_progressive
building_progressive = world_options.building_progression & BuildingProgression.option_progressive
building_progressive = self.world.content.features.building_progression.is_progressive
self.assertFalse(tool_progressive)
self.assertFalse(building_progressive)
@@ -59,9 +56,8 @@ class TestBitFlagsProgressive(SVTestBase):
BuildingProgression.internal_name: BuildingProgression.option_progressive}
def test_options_are_detected_as_progressive(self):
world_options = self.world.options
tool_progressive = self.world.content.features.tool_progression.is_progressive
building_progressive = world_options.building_progression & BuildingProgression.option_progressive
building_progressive = self.world.content.features.building_progression.is_progressive
self.assertTrue(tool_progressive)
self.assertTrue(building_progressive)
@@ -76,9 +72,8 @@ class TestBitFlagsProgressiveCheap(SVTestBase):
BuildingProgression.internal_name: BuildingProgression.option_progressive_cheap}
def test_options_are_detected_as_progressive(self):
world_options = self.world.options
tool_progressive = self.world.content.features.tool_progression.is_progressive
building_progressive = world_options.building_progression & BuildingProgression.option_progressive
building_progressive = self.world.content.features.building_progression.is_progressive
self.assertTrue(tool_progressive)
self.assertTrue(building_progressive)
@@ -93,9 +88,8 @@ class TestBitFlagsProgressiveVeryCheap(SVTestBase):
BuildingProgression.internal_name: BuildingProgression.option_progressive_very_cheap}
def test_options_are_detected_as_progressive(self):
world_options = self.world.options
tool_progressive = self.world.content.features.tool_progression.is_progressive
building_progressive = world_options.building_progression & BuildingProgression.option_progressive
building_progressive = self.world.content.features.building_progression.is_progressive
self.assertTrue(tool_progressive)
self.assertTrue(building_progressive)

View File

@@ -1,12 +1,13 @@
import itertools
from typing import ClassVar
from BaseClasses import ItemClassification
from Options import NamedRange
from test.param import classvar_matrix
from . import SVTestCase, solo_multiworld, SVTestBase
from .assertion import WorldAssertMixin
from .long.option_names import all_option_choices
from .options.option_names import all_option_choices
from .options.presets import allsanity_no_mods_6_x_x, allsanity_mods_6_x_x
from .. import items_by_group, Group, StardewValleyWorld
from .. import items_by_group, Group
from ..locations import locations_by_tag, LocationTags, location_table
from ..options import ExcludeGingerIsland, ToolProgression, Goal, SeasonRandomization, TrapItems, SpecialOrderLocations, ArcadeMachineLocations
from ..strings.goal_names import Goal as GoalName
@@ -18,42 +19,36 @@ SEASONS = {Season.spring, Season.summer, Season.fall, Season.winter}
TOOLS = {"Hoe", "Pickaxe", "Axe", "Watering Can", "Trash Can", "Fishing Rod"}
@classvar_matrix(option_and_choice=all_option_choices)
class TestGenerateDynamicOptions(WorldAssertMixin, SVTestCase):
def test_given_special_range_when_generate_then_basic_checks(self):
options = StardewValleyWorld.options_dataclass.type_hints
for option_name, option in options.items():
if not issubclass(option, NamedRange):
continue
for value in option.special_range_names:
world_options = {option_name: option.special_range_names[value]}
with self.solo_world_sub_test(f"{option_name}: {value}", world_options) as (multiworld, _):
self.assert_basic_checks(multiworld)
option_and_choice: ClassVar[tuple[str, str]]
def test_given_choice_when_generate_then_basic_checks(self):
options = StardewValleyWorld.options_dataclass.type_hints
for option_name, option in options.items():
if not option.options:
continue
for value in option.options:
world_options = {option_name: option.options[value]}
with self.solo_world_sub_test(f"{option_name}: {value}", world_options) as (multiworld, _):
self.assert_basic_checks(multiworld)
def test_given_option_and_choice_when_generate_then_basic_checks(self):
option, choice = self.option_and_choice
world_options = {option: choice}
with solo_multiworld(world_options) as (multiworld, stardew_world):
self.assert_basic_checks(multiworld)
@classvar_matrix(goal_and_location=[
("community_center", GoalName.community_center),
("grandpa_evaluation", GoalName.grandpa_evaluation),
("bottom_of_the_mines", GoalName.bottom_of_the_mines),
("cryptic_note", GoalName.cryptic_note),
("master_angler", GoalName.master_angler),
("complete_collection", GoalName.complete_museum),
("full_house", GoalName.full_house),
("perfection", GoalName.perfection),
])
class TestGoal(SVTestCase):
goal_and_location: ClassVar[tuple[str, str]]
def test_given_goal_when_generate_then_victory_is_in_correct_location(self):
for goal, location in [("community_center", GoalName.community_center),
("grandpa_evaluation", GoalName.grandpa_evaluation),
("bottom_of_the_mines", GoalName.bottom_of_the_mines),
("cryptic_note", GoalName.cryptic_note),
("master_angler", GoalName.master_angler),
("complete_collection", GoalName.complete_museum),
("full_house", GoalName.full_house),
("perfection", GoalName.perfection)]:
world_options = {Goal.internal_name: Goal.options[goal]}
with self.solo_world_sub_test(f"Goal: {goal}, Location: {location}", world_options) as (multi_world, _):
victory = multi_world.find_item("Victory", 1)
self.assertEqual(victory.name, location)
goal, location = self.goal_and_location
world_options = {Goal.internal_name: goal}
with solo_multiworld(world_options) as (multi_world, _):
victory = multi_world.find_item("Victory", 1)
self.assertEqual(victory.name, location)
class TestSeasonRandomization(SVTestCase):
@@ -104,26 +99,28 @@ class TestToolProgression(SVTestBase):
self.assertEqual(useful_count, 1)
@classvar_matrix(option_and_choice=all_option_choices)
class TestGenerateAllOptionsWithExcludeGingerIsland(WorldAssertMixin, SVTestCase):
option_and_choice: ClassVar[tuple[str, str]]
def test_given_choice_when_generate_exclude_ginger_island_then_ginger_island_is_properly_excluded(self):
for option, option_choice in all_option_choices:
if option is ExcludeGingerIsland:
continue
option, option_choice = self.option_and_choice
world_options = {
ExcludeGingerIsland: ExcludeGingerIsland.option_true,
option: option_choice
}
if option == ExcludeGingerIsland.internal_name:
self.skipTest("ExcludeGingerIsland is forced to true")
with self.solo_world_sub_test(f"{option.internal_name}: {option_choice}", world_options) as (multiworld, stardew_world):
world_options = {
ExcludeGingerIsland.internal_name: ExcludeGingerIsland.option_true,
option: option_choice
}
# Some options, like goals, will force Ginger island back in the game. We want to skip testing those.
if stardew_world.options.exclude_ginger_island != ExcludeGingerIsland.option_true:
continue
with solo_multiworld(world_options) as (multiworld, stardew_world):
self.assert_basic_checks(multiworld)
self.assert_no_ginger_island_content(multiworld)
if stardew_world.options.exclude_ginger_island != ExcludeGingerIsland.option_true:
self.skipTest("Some options, like goals, will force Ginger island back in the game. We want to skip testing those.")
self.assert_basic_checks(multiworld)
self.assert_no_ginger_island_content(multiworld)
class TestTraps(SVTestCase):

View File

@@ -0,0 +1,29 @@
from typing import ClassVar
from BaseClasses import MultiWorld, get_seed
from test.param import classvar_matrix
from . import SVTestCase, skip_long_tests, solo_multiworld
from .assertion import GoalAssertMixin, OptionAssertMixin, WorldAssertMixin
from .options.option_names import generate_random_world_options
@classvar_matrix(n=range(10 if skip_long_tests() else 1000))
class TestGenerateManyWorlds(GoalAssertMixin, OptionAssertMixin, WorldAssertMixin, SVTestCase):
n: ClassVar[int]
def test_generate_many_worlds_then_check_results(self):
seed = get_seed()
world_options = generate_random_world_options(seed + self.n)
print(f"Generating solo multiworld with seed {seed} for Stardew Valley...")
with solo_multiworld(world_options, seed=seed, world_caching=False) as (multiworld, _):
self.assert_multiworld_is_valid(multiworld)
def assert_multiworld_is_valid(self, multiworld: MultiWorld):
self.assert_victory_exists(multiworld)
self.assert_same_number_items_locations(multiworld)
self.assert_goal_world_is_valid(multiworld)
self.assert_can_reach_island_if_should(multiworld)
self.assert_cropsanity_same_number_items_and_locations(multiworld)
self.assert_festivals_give_access_to_deluxe_scarecrow(multiworld)
self.assert_has_festival_recipes(multiworld)

View File

@@ -70,7 +70,6 @@ class TestWalnutsanityPuzzles(SVTestBase):
def test_field_office_locations_require_professor_snail(self):
location_names = ["Complete Large Animal Collection", "Complete Snake Collection", "Complete Mummified Frog Collection",
"Complete Mummified Bat Collection", "Purple Flowers Island Survey", "Purple Starfish Island Survey", ]
locations = [location for location in self.multiworld.get_locations() if location.name in location_names]
self.collect("Island Obelisk")
self.collect("Island North Turtle")
self.collect("Island West Turtle")
@@ -84,11 +83,11 @@ class TestWalnutsanityPuzzles(SVTestBase):
self.collect("Progressive Sword", 5)
self.collect("Combat Level", 10)
self.collect("Mining Level", 10)
for location in locations:
self.assert_cannot_reach_location(location, self.multiworld.state)
for location in location_names:
self.assert_cannot_reach_location(location)
self.collect("Open Professor Snail Cave")
for location in locations:
self.assert_can_reach_location(location, self.multiworld.state)
for location in location_names:
self.assert_can_reach_location(location)
class TestWalnutsanityBushes(SVTestBase):

View File

@@ -1,3 +1,4 @@
import itertools
import logging
import os
import threading
@@ -11,7 +12,8 @@ from test.general import gen_steps, setup_solo_multiworld as setup_base_solo_mul
from worlds.AutoWorld import call_all
from .assertion import RuleAssertMixin
from .options.utils import fill_namespace_with_default, parse_class_option_keys, fill_dataclass_with_default
from .. import StardewValleyWorld, StardewItem
from .. import StardewValleyWorld, StardewItem, StardewRule
from ..logic.time_logic import MONTH_COEFFICIENT
from ..options import StardewValleyOption
logger = logging.getLogger(__name__)
@@ -20,21 +22,19 @@ DEFAULT_TEST_SEED = get_seed()
logger.info(f"Default Test Seed: {DEFAULT_TEST_SEED}")
class SVTestCase(unittest.TestCase):
# Set False to not skip some 'extra' tests
skip_base_tests: bool = True
# Set False to run tests that take long
skip_long_tests: bool = True
def skip_default_tests() -> bool:
return not bool(os.environ.get("base", False))
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
base_tests_key = "base"
if base_tests_key in os.environ:
cls.skip_base_tests = not bool(os.environ[base_tests_key])
long_tests_key = "long"
if long_tests_key in os.environ:
cls.skip_long_tests = not bool(os.environ[long_tests_key])
def skip_long_tests() -> bool:
return not bool(os.environ.get("long", False))
class SVTestCase(unittest.TestCase):
skip_default_tests: bool = skip_default_tests()
"""Set False to not skip the base fill tests"""
skip_long_tests: bool = skip_long_tests()
"""Set False to run tests that take long"""
@contextmanager
def solo_world_sub_test(self, msg: Optional[str] = None,
@@ -92,10 +92,16 @@ class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase):
@property
def run_default_tests(self) -> bool:
if self.skip_base_tests:
if self.skip_default_tests:
return False
return super().run_default_tests
def collect_months(self, months: int) -> None:
real_total_prog_items = self.world.total_progression_items
percent = months * MONTH_COEFFICIENT
self.collect("Stardrop", real_total_prog_items * 100 // percent)
self.world.total_progression_items = real_total_prog_items
def collect_lots_of_money(self, percent: float = 0.25):
self.collect("Shipping Bin")
real_total_prog_items = self.world.total_progression_items
@@ -145,12 +151,35 @@ class SVTestBase(RuleAssertMixin, WorldTestBase, SVTestCase):
def create_item(self, item: str) -> StardewItem:
return self.world.create_item(item)
def get_all_created_items(self) -> list[str]:
return [item.name for item in itertools.chain(self.multiworld.get_items(), self.multiworld.precollected_items[self.player])]
def remove_one_by_name(self, item: str) -> None:
self.remove(self.create_item(item))
def reset_collection_state(self):
def reset_collection_state(self) -> None:
self.multiworld.state = self.original_state.copy()
def assert_rule_true(self, rule: StardewRule, state: CollectionState | None = None) -> None:
if state is None:
state = self.multiworld.state
super().assert_rule_true(rule, state)
def assert_rule_false(self, rule: StardewRule, state: CollectionState | None = None) -> None:
if state is None:
state = self.multiworld.state
super().assert_rule_false(rule, state)
def assert_can_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None:
if state is None:
state = self.multiworld.state
super().assert_can_reach_location(location, state)
def assert_cannot_reach_location(self, location: Location | str, state: CollectionState | None = None) -> None:
if state is None:
state = self.multiworld.state
super().assert_cannot_reach_location(location, state)
pre_generated_worlds = {}
@@ -165,21 +194,22 @@ def solo_multiworld(world_options: Optional[Dict[Union[str, StardewValleyOption]
yield multiworld, multiworld.worlds[1]
else:
multiworld = setup_solo_multiworld(world_options, seed)
multiworld.lock.acquire()
world = multiworld.worlds[1]
try:
multiworld.lock.acquire()
world = multiworld.worlds[1]
original_state = multiworld.state.copy()
original_itempool = multiworld.itempool.copy()
unfilled_locations = multiworld.get_unfilled_locations(1)
original_state = multiworld.state.copy()
original_itempool = multiworld.itempool.copy()
unfilled_locations = multiworld.get_unfilled_locations(1)
yield multiworld, world
yield multiworld, world
multiworld.state = original_state
multiworld.itempool = original_itempool
for location in unfilled_locations:
location.item = None
multiworld.lock.release()
multiworld.state = original_state
multiworld.itempool = original_itempool
for location in unfilled_locations:
location.item = None
finally:
multiworld.lock.release()
# Mostly a copy of test.general.setup_solo_multiworld, I just don't want to change the core.

View File

@@ -2,9 +2,11 @@ import unittest
from typing import ClassVar, Tuple
from ...content import content_packs, ContentPack, StardewContent, unpack_content, StardewFeatures, feature
from ...strings.building_names import Building
default_features = StardewFeatures(
feature.booksanity.BooksanityDisabled(),
feature.building_progression.BuildingProgressionVanilla(starting_buildings={Building.farm_house}),
feature.cropsanity.CropsanityDisabled(),
feature.fishsanity.FishsanityNone(),
feature.friendsanity.FriendsanityNone(),

View File

@@ -1,61 +1,19 @@
import unittest
from itertools import combinations, product
from itertools import combinations
from typing import ClassVar
from BaseClasses import get_seed
from .option_names import all_option_choices, get_option_choices
from .. import SVTestCase
from test.param import classvar_matrix
from .. import SVTestCase, solo_multiworld, skip_long_tests
from ..assertion import WorldAssertMixin, ModAssertMixin
from ..options.option_names import all_option_choices
from ... import options
from ...mods.mod_data import ModNames
assert unittest
from ...options.options import all_mods
class TestGenerateModsOptions(WorldAssertMixin, ModAssertMixin, SVTestCase):
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
if cls.skip_long_tests:
raise unittest.SkipTest("Long tests disabled")
def test_given_mod_pairs_when_generate_then_basic_checks(self):
for mod_pair in combinations(options.Mods.valid_keys, 2):
world_options = {
options.Mods: frozenset(mod_pair)
}
with self.solo_world_sub_test(f"Mods: {mod_pair}", world_options, world_caching=False) as (multiworld, _):
self.assert_basic_checks(multiworld)
self.assert_stray_mod_items(list(mod_pair), multiworld)
def test_given_mod_names_when_generate_paired_with_other_options_then_basic_checks(self):
for mod, (option, value) in product(options.Mods.valid_keys, all_option_choices):
world_options = {
option: value,
options.Mods: mod
}
with self.solo_world_sub_test(f"{option.internal_name}: {value}, Mod: {mod}", world_options, world_caching=False) as (multiworld, _):
self.assert_basic_checks(multiworld)
self.assert_stray_mod_items(mod, multiworld)
def test_given_no_quest_all_mods_when_generate_with_all_goals_then_basic_checks(self):
for goal, (option, value) in product(get_option_choices(options.Goal), all_option_choices):
if option is options.QuestLocations:
continue
world_options = {
options.Goal: goal,
option: value,
options.QuestLocations: -1,
options.Mods: frozenset(options.Mods.valid_keys),
}
with self.solo_world_sub_test(f"Goal: {goal}, {option.internal_name}: {value}", world_options, world_caching=False) as (multiworld, _):
self.assert_basic_checks(multiworld)
@unittest.skip
@unittest.skip
class TestTroubleshootMods(WorldAssertMixin, ModAssertMixin, SVTestCase):
def test_troubleshoot_option(self):
seed = get_seed(78709133382876990000)
@@ -67,3 +25,60 @@ class TestGenerateModsOptions(WorldAssertMixin, ModAssertMixin, SVTestCase):
with self.solo_world_sub_test(world_options=world_options, seed=seed, world_caching=False) as (multiworld, _):
self.assert_basic_checks(multiworld)
self.assert_stray_mod_items(world_options[options.Mods], multiworld)
if skip_long_tests():
raise unittest.SkipTest("Long tests disabled")
@classvar_matrix(mod_pair=combinations(sorted(all_mods), 2))
class TestGenerateModsPairs(WorldAssertMixin, ModAssertMixin, SVTestCase):
mod_pair: ClassVar[tuple[str, str]]
def test_given_mod_pairs_when_generate_then_basic_checks(self):
world_options = {
options.Mods.internal_name: frozenset(self.mod_pair)
}
with solo_multiworld(world_options, world_caching=False) as (multiworld, _):
self.assert_basic_checks(multiworld)
self.assert_stray_mod_items(list(self.mod_pair), multiworld)
@classvar_matrix(mod=all_mods, option_and_choice=all_option_choices)
class TestGenerateModAndOptionChoice(WorldAssertMixin, ModAssertMixin, SVTestCase):
mod: ClassVar[str]
option_and_choice: ClassVar[tuple[str, str]]
def test_given_mod_names_when_generate_paired_with_other_options_then_basic_checks(self):
option, choice = self.option_and_choice
world_options = {
option: choice,
options.Mods.internal_name: self.mod
}
with solo_multiworld(world_options, world_caching=False) as (multiworld, _):
self.assert_basic_checks(multiworld)
self.assert_stray_mod_items(self.mod, multiworld)
@classvar_matrix(goal=options.Goal.options.keys(), option_and_choice=all_option_choices)
class TestGenerateAllGoalAndAllOptionWithAllModsWithoutQuest(WorldAssertMixin, ModAssertMixin, SVTestCase):
goal = ClassVar[str]
option_and_choice = ClassVar[tuple[str, str]]
def test_given_no_quest_all_mods_when_generate_with_all_goals_then_basic_checks(self):
option, choice = self.option_and_choice
if option == options.QuestLocations.internal_name:
self.skipTest("QuestLocations are disabled")
world_options = {
options.Goal.internal_name: self.goal,
option: choice,
options.QuestLocations.internal_name: -1,
options.Mods.internal_name: frozenset(options.Mods.valid_keys),
}
with solo_multiworld(world_options, world_caching=False) as (multiworld, _):
self.assert_basic_checks(multiworld)

Some files were not shown because too many files have changed in this diff Show More