diff --git a/README.md b/README.md index 608af1313c2f..25d7611ccf4f 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Currently, the following games are supported: * Celeste (Open World) * Choo-Choo Charles * APQuest +* BK Simulator For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/docs/CODEOWNERS b/docs/CODEOWNERS index e4ef3fe73e28..34c8e0565fcd 100644 --- a/docs/CODEOWNERS +++ b/docs/CODEOWNERS @@ -25,6 +25,9 @@ # Aquaria /worlds/aquaria/ @tioui +# BK Simulator +/worlds/bksim @EmilyV99 + # Blasphemous /worlds/blasphemous/ @TRPG0 diff --git a/worlds/bksim/.gitignore b/worlds/bksim/.gitignore new file mode 100644 index 000000000000..ed8ebf583f77 --- /dev/null +++ b/worlds/bksim/.gitignore @@ -0,0 +1 @@ +__pycache__ \ No newline at end of file diff --git a/worlds/bksim/__init__.py b/worlds/bksim/__init__.py new file mode 100644 index 000000000000..c9b71f86a7b4 --- /dev/null +++ b/worlds/bksim/__init__.py @@ -0,0 +1 @@ +from .world import BKSimWorld as BKSimWorld diff --git a/worlds/bksim/archipelago.json b/worlds/bksim/archipelago.json new file mode 100644 index 000000000000..2b9d9038bd87 --- /dev/null +++ b/worlds/bksim/archipelago.json @@ -0,0 +1,5 @@ +{ + "game": "BKSimulator", + "authors": ["EmilyV99"], + "world_version": "0.1.2" +} diff --git a/worlds/bksim/common.py b/worlds/bksim/common.py new file mode 100644 index 000000000000..a00c5a41fad1 --- /dev/null +++ b/worlds/bksim/common.py @@ -0,0 +1,17 @@ +from enum import StrEnum + +game_name = 'BKSimulator' + + +class RID(StrEnum): + HOME = 'Home' + SUNNY = 'Sunny' + RAINY = 'Rainy' + SNOWY = 'Snowy' + + +class ITEM(StrEnum): + SHOES = 'Better Shoes' + BOOTS = 'Snow Boots' + NEWLOC = 'New Location' + TOY = 'Plastic Toy' diff --git a/worlds/bksim/docs/en_BKSimulator.md b/worlds/bksim/docs/en_BKSimulator.md new file mode 100644 index 000000000000..46e353187922 --- /dev/null +++ b/worlds/bksim/docs/en_BKSimulator.md @@ -0,0 +1,27 @@ +# BK Simulator + +## What is BK Simulator? +You have to walk to BK, in various weather conditions. Each time you reach BK is a check. Collect every check (and return home!) to goal. + + + +## Settings +[Options Page](../player-options) +- You choose how many locations are in the game. Collecting ALL of them is required as the goal. +- You can adjust the distance to the nearest BK at the start. This will heavily control the speed / length of the game. +- You can also adjust the speed gained for each speed upgrade. +- You can set a percentage of your Shoe/Boot upgrades to be replaced with filler. (Higher values will be fulfilled as much as is possible without breaking logic requirements) + +## Items / Logic +Items: +- Shoe upgrades allow you to walk faster +- Snow Boots allow you to walk in snow (and walk faster in snow) +- Opening a new BK location cuts the distance you need to walk in half + +Logic: +- Sunny weather is the standard, and where you should start +- Rainy weather is slower to walk in, and expects that you have more upgrades than sunny weather expects +- Snowy weather requires snow boots to walk in +- Higher-numbered checks will expect gradually more items to become in-logic. +- "Sphere 1" includes the first 2 Sunny checks only. + diff --git a/worlds/bksim/docs/setup_en.md b/worlds/bksim/docs/setup_en.md new file mode 100644 index 000000000000..ec4e92bd6b50 --- /dev/null +++ b/worlds/bksim/docs/setup_en.md @@ -0,0 +1,32 @@ +# BK Simulator Setup Guide + +## Required Software + +- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest), to generate or use text client. + +## How to play + +- Open the game, connect to your slot. +- Choose which weather to walk to BK in. +- Each time you reach BK, you send a check. You do still need to walk home after, before you can start your next trip! + + + +## Settings +- You choose how many locations are in the game. Collecting ALL of them is required as the goal. +- You can adjust the distance to the nearest BK at the start. This will heavily control the speed / length of the game. +- You can also adjust the speed gained for each speed upgrade. +- You can set a percentage of your Shoe/Boot upgrades to be replaced with filler. (Higher values will be fulfilled as much as is possible without breaking logic requirements) + +## Items / Logic +Items: +- Shoe upgrades allow you to walk faster +- Snow Boots allow you to walk in snow (and walk faster in snow) +- Opening a new BK location cuts the distance you need to walk in half + +Logic: +- Sunny weather is the standard, and where you should start +- Rainy weather is slower to walk in, and expects that you have more upgrades than sunny weather expects +- Snowy weather requires snow boots to walk in +- Higher-numbered checks will expect gradually more items to become in-logic. +- "Sphere 1" includes the first 2 Sunny checks only. diff --git a/worlds/bksim/items.py b/worlds/bksim/items.py new file mode 100644 index 000000000000..914bfc5cf9ab --- /dev/null +++ b/worlds/bksim/items.py @@ -0,0 +1,87 @@ +from __future__ import annotations +from math import floor, ceil +from BaseClasses import Item, ItemClassification +from .common import * +import typing +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .world import BKSimWorld + + +class BKSim_Item(Item): + game = game_name + + +class ItemInfo(typing.NamedTuple): + name: ITEM + flag: ItemClassification + + +item_table = [ + ItemInfo(ITEM.SHOES, ItemClassification.progression), + ItemInfo(ITEM.BOOTS, ItemClassification.progression), + ItemInfo(ITEM.NEWLOC, ItemClassification.progression), + ItemInfo(ITEM.TOY, ItemClassification.filler), +] +item_name_to_id = {str(name): num for num, (name, _) in enumerate(item_table, 1)} + + +def get_item_counts(world: BKSimWorld) -> list[int]: + multiworld = world.multiworld + player = world.player + options = world.options + + newloc_count: int = 0 + shoe_count: int = 0 + boot_count: int = 0 + + for item in multiworld.precollected_items[player]: + if item.name == ITEM.NEWLOC: + newloc_count += 1 + elif item.name == ITEM.SHOES: + shoe_count += 1 + elif item.name == ITEM.BOOTS: + boot_count += 1 + + extra_filler_rate: float = max(0, min(100, options.extra_filler_rate.value)) / 100.0 + per_weather_locs: int = int(options.locs_per_weather.value) + total_locs: int = per_weather_locs * 3 + newloc_items: int = max(0, min(2, per_weather_locs) - newloc_count) + snow_items: int = per_weather_locs + shoe_items: int = per_weather_locs + toy_items: int = per_weather_locs - newloc_items + if extra_filler_rate > 0.0: # replace shoes/boots with filler at the specified rate, but don't break logic + shoe_items = max(0, (int(floor(per_weather_locs - 1) / 2)) + 1 - shoe_count, + ceil(shoe_items * (1.0 - extra_filler_rate))) + snow_items = max(0, (int(floor(per_weather_locs - 1) / 2)) + 1 - boot_count, + ceil(snow_items * (1.0 - extra_filler_rate))) + toy_items += (per_weather_locs - shoe_items) + (per_weather_locs - snow_items) + + # ensure proper item count + toy_items += total_locs - (newloc_items + snow_items + shoe_items + toy_items) + + return [shoe_items, snow_items, newloc_items, toy_items] + + +def create_items(world: BKSimWorld) -> None: + multiworld = world.multiworld + player = world.player + + counts: list[int] = get_item_counts(world) + itempool = [] + for q in range(len(item_table)): + data: ItemInfo = item_table[q] + count: int = counts[q] + itempool += [BKSim_Item(str(data.name), data.flag, q + 1, player) for _ in range(count)] + multiworld.itempool += itempool + + +def create_item(name: str, player: int) -> BKSim_Item: + itemid = item_name_to_id[name] + _, flag = item_table[itemid - 1] + return BKSim_Item(name, flag, itemid, player) + + +def create_event_item(event: str, player: int) -> BKSim_Item: + return BKSim_Item(event, ItemClassification.progression, None, player) diff --git a/worlds/bksim/locations.py b/worlds/bksim/locations.py new file mode 100644 index 000000000000..62efbe19330e --- /dev/null +++ b/worlds/bksim/locations.py @@ -0,0 +1,27 @@ +from typing import NamedTuple, Optional +from BaseClasses import Location, Region +from .common import * + + +class LocInfo(NamedTuple): + name: str + region_id: RID + index: int + + +class BKSim_Location(Location): + game = game_name + info: Optional[LocInfo] + + # override constructor to automatically mark event locations as such, and handle LocInfo + def __init__(self, player: int, name: str, code: Optional[int], parent: Region, info: Optional[LocInfo] = None) -> None: + super(BKSim_Location, self).__init__(player, name, code, parent) + self.event = code is None + self.info = info + + +location_table = [LocInfo("Sunny %d" % (idx + 1), RID.SUNNY, idx) for idx in range(0,100)] +location_table += [LocInfo("Rainy %d" % (idx + 1), RID.RAINY, idx) for idx in range(0,100)] +location_table += [LocInfo("Snowy %d" % (idx + 1), RID.SNOWY, idx) for idx in range(0,100)] + +location_name_to_id = {loc.name: num for num,loc in enumerate(location_table,1)} diff --git a/worlds/bksim/options.py b/worlds/bksim/options.py new file mode 100644 index 000000000000..a045e7519a55 --- /dev/null +++ b/worlds/bksim/options.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +from Options import Range, PerGameCommonOptions + + +class LocationsPerWeather(Range): + """The total number of locations per type of weather. (There are 3 weather types)""" + display_name = "Locations Per Weather" + range_start = 1 + range_end = 100 + default = 3 + + +class StartDistance(Range): + """The distance to the closest BK at the start. + Each 'New Location' upgrade opens a location halfway between your house and the current closest location.""" + display_name = "Start Distance" + range_start = 50 + range_end = 5000 + default = 300 + + +class SpeedPerUpgrade(Range): + """The amount of speed gained for each shoe upgrade. + Snow boots give half this speed in the snow.""" + display_name = "Speed Per Upgrade" + range_start = 1 + range_end = 100 + default = 2 + + +class ExtraFillerRate(Range): + """This percent of shoe/boot upgrades will attempt to be turned into filler. + The number of upgrades will not be reduced below the amount logically required.""" + display_name = "Extra Filler Rate" + range_start = 0 + range_end = 100 + default = 0 + + +@dataclass +class BKSim_Options(PerGameCommonOptions): + locs_per_weather: LocationsPerWeather + start_distance: StartDistance + speed_per_upgrade: SpeedPerUpgrade + extra_filler_rate: ExtraFillerRate + + +options_presets = { + "Quick": { + "locs_per_weather": 1, + "start_distance": 100, + "speed_per_upgrade": 5, + "extra_filler_rate": 0, + }, + "Marathon": { + "locs_per_weather": 1, + "start_distance": 5000, + "speed_per_upgrade": 1, + "extra_filler_rate": 0, + }, + "Masochist": { + "locs_per_weather": 100, + "start_distance": 5000, + "speed_per_upgrade": 1, + "extra_filler_rate": 100, + }, +} diff --git a/worlds/bksim/regions.py b/worlds/bksim/regions.py new file mode 100644 index 000000000000..22b2a2d2b2af --- /dev/null +++ b/worlds/bksim/regions.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from BaseClasses import Region +from .locations import location_table, BKSim_Location +from .common import * +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .world import BKSimWorld + + +def create_regions(world: BKSimWorld) -> None: + multiworld = world.multiworld + player = world.player + + for rid in RID: + multiworld.regions.append(Region(str(rid), player, multiworld)) + + locs_per_weather = world.options.locs_per_weather.value + for locid, locinfo in enumerate(location_table, 1): + if locinfo.index >= locs_per_weather: + continue + region = world.get_region(locinfo.region_id) + if region: + region.locations.append(BKSim_Location(player, locinfo.name, locid, region, locinfo)) diff --git a/worlds/bksim/rules.py b/worlds/bksim/rules.py new file mode 100644 index 000000000000..c9c5d86dfcdf --- /dev/null +++ b/worlds/bksim/rules.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from math import floor +from ..generic.Rules import set_rule +from .common import * +from .locations import BKSim_Location +import typing +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .world import BKSimWorld + + +def set_rules(world: BKSimWorld) -> None: + multiworld = world.multiworld + player = world.player + options = world.options + + world.get_region(RID.HOME).connect(connecting_region=world.get_region(RID.SUNNY)) + world.get_region(RID.HOME).connect(connecting_region=world.get_region(RID.RAINY)) + world.get_region(RID.HOME).connect(connecting_region=world.get_region(RID.SNOWY), rule=lambda state: state.has(ITEM.BOOTS, player)) + + locs_list: typing.Iterable[BKSim_Location] = typing.cast(typing.Iterable[BKSim_Location], multiworld.get_locations(player)) + loc_count = options.locs_per_weather.value + max_rules = [] + for loc in locs_list: + if loc.info.region_id == RID.SUNNY: + if loc.info.index == 0: + continue + tmp_rule = lambda state, idx=loc.info.index: state.has(ITEM.SHOES, player, floor(idx / 2)) + if loc.info.index == loc_count - 1: + max_rules.append(tmp_rule) + set_rule(loc, tmp_rule) + elif loc.info.region_id == RID.RAINY: + tmp_rule = lambda state, idx=loc.info.index: (state.has(ITEM.SHOES, player, floor(idx / 2) + 1) and state.has( + ITEM.NEWLOC, player)) or state.has(ITEM.SHOES, player, idx) + if loc.info.index == loc_count - 1: + max_rules.append(tmp_rule) + set_rule(loc, tmp_rule) + elif loc.info.region_id == RID.SNOWY: + tmp_rule = lambda state, idx=loc.info.index: state.has(ITEM.BOOTS, player, floor(idx / 2) + 1) + if loc.info.index == loc_count - 1: + max_rules.append(tmp_rule) + set_rule(loc, tmp_rule) + + multiworld.completion_condition[player] = lambda state: all(rule(state) for rule in max_rules) diff --git a/worlds/bksim/web_world.py b/worlds/bksim/web_world.py new file mode 100644 index 000000000000..fed7a6cda0c4 --- /dev/null +++ b/worlds/bksim/web_world.py @@ -0,0 +1,16 @@ +from worlds.AutoWorld import WebWorld +from . import options +from BaseClasses import Tutorial + + +class BKSimWebWorld(WebWorld): + options_presets = options.options_presets + setup_en = Tutorial( + "BKSim Setup", + "How to set up BK Simulator for Archipelago", + "English", + "setup_en.md", + "setup/en", + ["Emily"] + ) + tutorials = [setup_en] diff --git a/worlds/bksim/world.py b/worlds/bksim/world.py new file mode 100644 index 000000000000..605ed11cdf08 --- /dev/null +++ b/worlds/bksim/world.py @@ -0,0 +1,47 @@ +from typing import Any + +from BaseClasses import Region, MultiWorld +from worlds.AutoWorld import World +from . import locations, items, regions, rules, options, web_world +from .common import * + + +class BKSimWorld(World): + """ + BK Simulator is a simple game where you walk to BK and back. + """ + + game = game_name + topology_present = False + web = web_world.BKSimWebWorld() + options_dataclass = options.BKSim_Options + options: options.BKSim_Options + location_name_to_id = locations.location_name_to_id + item_name_to_id = items.item_name_to_id + + origin_region_name = str(RID.HOME) + + def __init__(self, multiworld: MultiWorld, player: int): + super().__init__(multiworld, player) + + def create_regions(self) -> None: + regions.create_regions(self) + + def create_items(self) -> None: + items.create_items(self) + + def create_item(self, name: str) -> items.BKSim_Item: + return items.create_item(name, self.player) + + def set_rules(self) -> None: + rules.set_rules(self) + + def fill_slot_data(self) -> dict[str, Any]: + return { + 'LocsPerWeather': self.options.locs_per_weather.value, + 'StartDistance': self.options.start_distance.value, + 'SpeedPerUpgrade': self.options.speed_per_upgrade.value, + } + + def get_region(self, region_name: str) -> Region: + return self.multiworld.get_region(region_name, self.player)