From a97c43e06ed9bafcbd11080e9ba6a6f8849bc59b Mon Sep 17 00:00:00 2001 From: Ixrec Date: Mon, 5 Feb 2024 01:57:56 +0000 Subject: [PATCH] randomly generate EotU coordinates, put them in slot data, and unit test the generation --- worlds/outer_wilds/Coordinates.py | 71 +++++++++++++++++++++ worlds/outer_wilds/Options.py | 8 ++- worlds/outer_wilds/__init__.py | 3 + worlds/outer_wilds/test/test_coordinates.py | 62 ++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 worlds/outer_wilds/Coordinates.py create mode 100644 worlds/outer_wilds/test/test_coordinates.py diff --git a/worlds/outer_wilds/Coordinates.py b/worlds/outer_wilds/Coordinates.py new file mode 100644 index 000000000000..9ee132f0e7f5 --- /dev/null +++ b/worlds/outer_wilds/Coordinates.py @@ -0,0 +1,71 @@ +from typing import List +from random import Random + + +two_point_coordinates = 6 * 5 +three_point_coordinates = 6 * 5 * 4 +four_point_coordinates = 6 * 5 * 4 * 3 +five_point_coordinates = 6 * 5 * 4 * 3 * 2 +six_point_coordinates = 6 * 5 * 4 * 3 * 2 * 1 + +total_possible_coordinates = (two_point_coordinates + three_point_coordinates + four_point_coordinates + + five_point_coordinates + six_point_coordinates) + + +def generate_random_coordinates(random: Random) -> List[List[int]]: + selections = [ + random.randint(0, total_possible_coordinates - 1), + random.randint(0, total_possible_coordinates - 1), + random.randint(0, total_possible_coordinates - 1), + ] + selected_coordinates = list(map(get_coordinate_for_number, selections)) + map(validate_coordinate, selected_coordinates) + return selected_coordinates + + +def validate_coordinate(coordinate: List[int]) -> None: + assert len(coordinate) >= 2 + assert len(coordinate) <= 6 + assert len(set(coordinate)) == len(coordinate) + + +# Here, the number represents an index into the list of all possible coordinates of any length +def get_coordinate_for_number(coord_index: int) -> List[int]: + assert coord_index < total_possible_coordinates + + if coord_index < two_point_coordinates: + return get_coordinate_points_for_number(2, two_point_coordinates, coord_index) + coord_index -= two_point_coordinates + + if coord_index < three_point_coordinates: + return get_coordinate_points_for_number(3, three_point_coordinates, coord_index) + coord_index -= three_point_coordinates + + if coord_index < four_point_coordinates: + return get_coordinate_points_for_number(4, four_point_coordinates, coord_index) + coord_index -= four_point_coordinates + + if coord_index < five_point_coordinates: + return get_coordinate_points_for_number(5, five_point_coordinates, coord_index) + coord_index -= five_point_coordinates + + return get_coordinate_points_for_number(6, six_point_coordinates, coord_index) + + +# Now the number represents an index into the list of all possible coordinates of one specific length +def get_coordinate_points_for_number(point_count: int, possible_coords: int, coord_index: int) -> List[int]: + points_not_taken = [0, 1, 2, 3, 4, 5] + coord_points = [] + bucket_size = possible_coords + + while len(coord_points) < point_count: + bucket_size //= len(points_not_taken) + point_choice = coord_index // bucket_size + + point_num = points_not_taken[point_choice] + points_not_taken.remove(point_num) + coord_points.append(point_num) + + coord_index %= bucket_size + + return coord_points diff --git a/worlds/outer_wilds/Options.py b/worlds/outer_wilds/Options.py index 7689ae92e691..0d7f11cdf527 100644 --- a/worlds/outer_wilds/Options.py +++ b/worlds/outer_wilds/Options.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from Options import Choice, Toggle, PerGameCommonOptions +from Options import Choice, DefaultOnToggle, Toggle, PerGameCommonOptions class Goal(Choice): @@ -12,6 +12,11 @@ class Goal(Choice): option_song_of_six = 1 +class RandomizeCoordinates(DefaultOnToggle): + """Randomize the Eye of the Universe coordinates needed to reach the end of the game.""" + display_name = "Randomize Coordinates" + + class DeathLink(Choice): """When you die, everyone dies. Of course the reverse is true too. The "default" option will not include deaths to meditation, the supernova or the time loop ending.""" @@ -30,5 +35,6 @@ class Logsanity(Toggle): @dataclass class OuterWildsGameOptions(PerGameCommonOptions): goal: Goal + randomize_coordinates: RandomizeCoordinates death_link: DeathLink logsanity: Logsanity diff --git a/worlds/outer_wilds/__init__.py b/worlds/outer_wilds/__init__.py index b3c56bda9441..2670ec8e6181 100644 --- a/worlds/outer_wilds/__init__.py +++ b/worlds/outer_wilds/__init__.py @@ -7,6 +7,7 @@ from .LocationsAndRegions import (all_non_event_locations_table, location_name_groups, create_regions, get_locations_to_create) from .Options import OuterWildsGameOptions +from .Coordinates import generate_random_coordinates class OuterWildsWebWorld(WebWorld): @@ -86,6 +87,8 @@ def set_rules(self) -> None: def fill_slot_data(self): slot_data = self.options.as_dict("goal", "death_link", "logsanity") + slot_data["eotu_coordinates"] = generate_random_coordinates(self.random) \ + if self.options.randomize_coordinates else "vanilla" # Archipelago does not yet have apworld versions (data_version is deprecated), # so we have to roll our own with slot_data for the time being slot_data["apworld_version"] = "0.2.0-dev" diff --git a/worlds/outer_wilds/test/test_coordinates.py b/worlds/outer_wilds/test/test_coordinates.py new file mode 100644 index 000000000000..7462c2fa45cf --- /dev/null +++ b/worlds/outer_wilds/test/test_coordinates.py @@ -0,0 +1,62 @@ +import json +import unittest + +from ..Coordinates import get_coordinate_for_number, total_possible_coordinates, validate_coordinate + + +class TestCoordinateGeneration(unittest.TestCase): + def test_generate_specific_coordinates(self): + self.assertListEqual(get_coordinate_for_number(0), [0, 1]) + self.assertListEqual(get_coordinate_for_number(1), [0, 2]) + self.assertListEqual(get_coordinate_for_number(2), [0, 3]) + self.assertListEqual(get_coordinate_for_number(3), [0, 4]) + self.assertListEqual(get_coordinate_for_number(4), [0, 5]) + self.assertListEqual(get_coordinate_for_number(5), [1, 0]) + self.assertListEqual(get_coordinate_for_number(6), [1, 2]) + + # jump to where we switch from 2-point to 3-point coords + self.assertListEqual(get_coordinate_for_number(29), [5, 4]) + self.assertListEqual(get_coordinate_for_number(30), [0, 1, 2]) + + # jump to the very end + self.assertListEqual(get_coordinate_for_number(1949), [5, 4, 3, 2, 1, 0]) + + def test_generate_every_coordinate(self): + all_possible_coordinates = set() + + for coordinate_number in range(0, total_possible_coordinates): + coordinate = get_coordinate_for_number(coordinate_number) + all_possible_coordinates.add(json.dumps(coordinate)) + # every coordinate is valid + validate_coordinate(coordinate) + + # every coordinate is unique + self.assertEqual(len(all_possible_coordinates), total_possible_coordinates) + + # an attempt was made to iterate through all 1950 coordinates and compare to the get_*() outputs, but + # it turns out it was far easier to make get_*() correct than this test correct so I abandoned that + # in case I change my mind, here's how far I got: + # + # coordinate = [0, 1] + # for coordinate_number in range(1, total_possible_coordinates): + # index = 1 + # value = coordinate[-index] + # while value in coordinate and index <= len(coordinate): + # value += 1 + # if value > 5: + # index += 1 + # if index > len(coordinate): + # break + # value = coordinate[-index] + # if index <= len(coordinate): + # coordinate[-index] = value + # for index in range(index - 1, 0, -1): + # coordinate[-index] = 0 + # while len(set(coordinate)) != len(coordinate): + # coordinate[-index] += 1 + # else: + # coordinate = list(range(0, index)) + # + # validate_coordinate(coordinate) + # self.assertListEqual(get_coordinate_for_number(coordinate_number), coordinate, + # f"coordinate number {coordinate_number} did not match get_coordinate_for_number")