The Messenger: implement new game (#1494)

* initial commit of messenger integration

* setup no_logic and needed slot_data

* fix some typos and determinism

* make all of it deterministic

* add documentation

* swapped to non local items so change the fed data

* ~~deathlink~~

* satisfy the docs test

* update doc test to show expected name

* split custom classes into a separate file and fix an errant rule

* make access dependency test give more useful errors

* implement tests

* remove some unneccessary back entrances and make names clearer

* fix some big dumbs

* successful unit tests are good also some slight reorganizing

* add astral tea quest line, and potentially power seals as items

* if TYPE_CHECKING... aahhhhhh

* oop forgot to remove legacy code

* having the seed and leaves as actual items doesn't seem to do anything so remove them. locations still work though

* update setup guide with some changes

* Tower HQ was creating duplicate locations

* allow self locking items

* cleanup

* move self_locking_items function to core

* docstring

* implement choice of notes needed for music box

* test the default value

* don't create any starting inventory items

* make item creation faster

* change default accessibility and power seals options

* improve documentation

* precollected_items is a dict of Items...

* implement shop chest goal

* tests

* always assign total and required seals

* add new goals and set music box as requiring shop chest on shop chest goals instead of just setting it as the completion

* fix dumb test quirk

* implement music box skip as an option

* world rewrite/cleanup

* default to apworld and add game to readme

* revert bleeding commits from other PRs

* more bleeds

* fix some errors in options docstrings

* ???

* make my set rules method not have an awful name

* test cleanup

* add a test for item accessibility

* fix issues with tests

* make the self locking item behavior work correctly

* misc cleanup

* more general cleanup to be a good example

* quick rules rewrite

* more general cleanup and typing

* more speed, more clean

* bump data version

* make sure the locked item belongs to current player

* fix bad name and indent. call MessengerItem directly for events

* add poptracker pack to docs

* doc cleanup and "known issues" section that I probably won't be able to fix any time soon.

* missed some spots

* add another bug i forgot about

* be consistently wrong
This commit is contained in:
alwaysintreble 2023-03-12 09:05:50 -05:00 committed by GitHub
parent 39563cc347
commit 070a92e76c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1005 additions and 0 deletions

View File

@ -38,6 +38,7 @@ Currently, the following games are supported:
* Wargroove
* Stardew Valley
* The Legend of Zelda
* The Messenger
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

View File

@ -54,6 +54,7 @@ apworlds: set = {
"Stardew Valley",
"Timespinner",
"Minecraft",
"The Messenger",
}

View File

@ -0,0 +1,153 @@
# items
# listing individual groups first for easy lookup
NOTES = [
"Key of Hope",
"Key of Chaos",
"Key of Courage",
"Key of Love",
"Key of Strength",
"Key of Symbiosis"
]
PROG_ITEMS = [
"Wingsuit",
"Rope Dart",
"Ninja Tabi",
"Power Thistle",
"Demon King Crown",
"Ruxxtin's Amulet",
"Fairy Bottle",
"Sun Crest",
"Moon Crest",
# "Astral Seed",
# "Astral Tea Leaves"
]
PHOBEKINS = [
"Necro",
"Pyro",
"Claustro",
"Acro"
]
USEFUL_ITEMS = [
"Windmill Shuriken"
]
# item_name_to_id needs to be deterministic and match upstream
ALL_ITEMS = [
*NOTES,
"Windmill Shuriken",
"Wingsuit",
"Rope Dart",
"Ninja Tabi",
# "Astral Seed",
# "Astral Tea Leaves",
"Candle",
"Seashell",
"Power Thistle",
"Demon King Crown",
"Ruxxtin's Amulet",
"Fairy Bottle",
"Sun Crest",
"Moon Crest",
*PHOBEKINS,
"Power Seal",
"Time Shard" # there's 45 separate instances of this in the client lookup, but hopefully we don't care?
]
# locations
# the names of these don't actually matter, but using the upstream's names for now
# order must be exactly the same as upstream
ALWAYS_LOCATIONS = [
# notes
"Key of Love",
"Key of Courage",
"Key of Chaos",
"Key of Symbiosis",
"Key of Strength",
"Key of Hope",
# upgrades
"Wingsuit",
"Rope Dart",
"Ninja Tabi",
"Climbing Claws",
# quest items
"Astral Seed",
"Astral Tea Leaves",
"Candle",
"Seashell",
"Power Thistle",
"Demon King Crown",
"Ruxxtin's Amulet",
"Fairy Bottle",
"Sun Crest",
"Moon Crest",
# phobekins
"Necro",
"Pyro",
"Claustro",
"Acro"
]
SEALS = [
"Ninja Village Seal - Tree House",
"Autumn Hills Seal - Trip Saws",
"Autumn Hills Seal - Double Swing Saws",
"Autumn Hills Seal - Spike Ball Swing",
"Autumn Hills Seal - Spike Ball Darts",
"Catacombs Seal - Triple Spike Crushers",
"Catacombs Seal - Crusher Gauntlet",
"Catacombs Seal - Dirty Pond",
"Bamboo Creek Seal - Spike Crushers and Doors",
"Bamboo Creek Seal - Spike Ball Pits",
"Bamboo Creek Seal - Spike Crushers and Doors v2",
"Howling Grotto Seal - Windy Saws and Balls",
"Howling Grotto Seal - Crushing Pits",
"Howling Grotto Seal - Breezy Crushers",
"Quillshroom Marsh Seal - Spikey Window",
"Quillshroom Marsh Seal - Sand Trap",
"Quillshroom Marsh Seal - Do the Spike Wave",
"Searing Crags Seal - Triple Ball Spinner",
"Searing Crags Seal - Raining Rocks",
"Searing Crags Seal - Rhythm Rocks",
"Glacial Peak Seal - Ice Climbers",
"Glacial Peak Seal - Projectile Spike Pit",
"Glacial Peak Seal - Glacial Air Swag",
"Tower of Time Seal - Time Waster Seal",
"Tower of Time Seal - Lantern Climb",
"Tower of Time Seal - Arcane Orbs",
"Cloud Ruins Seal - Ghost Pit",
"Cloud Ruins Seal - Toothbrush Alley",
"Cloud Ruins Seal - Saw Pit",
"Cloud Ruins Seal - Money Farm Room",
"Underworld Seal - Sharp and Windy Climb",
"Underworld Seal - Spike Wall",
"Underworld Seal - Fireball Wave",
"Underworld Seal - Rising Fanta",
"Forlorn Temple Seal - Rocket Maze",
"Forlorn Temple Seal - Rocket Sunset",
"Sunken Shrine Seal - Ultra Lifeguard",
"Sunken Shrine Seal - Waterfall Paradise",
"Sunken Shrine Seal - Tabi Gauntlet",
"Riviere Turquoise Seal - Bounces and Balls",
"Riviere Turquoise Seal - Launch of Faith",
"Riviere Turquoise Seal - Flower Power",
"Elemental Skylands Seal - Air",
"Elemental Skylands Seal - Water",
"Elemental Skylands Seal - Fire"
]

View File

@ -0,0 +1,66 @@
from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice
class MessengerAccessibility(Accessibility):
default = Accessibility.option_locations
# defaulting to locations accessibility since items makes certain items self-locking
__doc__ = Accessibility.__doc__.replace(f"default {Accessibility.default}", f"default {default}")
class Logic(DefaultOnToggle):
"""Whether the seed should be guaranteed completable."""
display_name = "Use Logic"
class PowerSeals(DefaultOnToggle):
"""Whether power seal locations should be randomized."""
display_name = "Shuffle Seals"
class Goal(Choice):
"""Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled."""
display_name = "Goal"
option_open_music_box = 0
option_power_seal_hunt = 1
class MusicBox(DefaultOnToggle):
"""Whether the music box gauntlet needs to be done."""
display_name = "Music Box Gauntlet"
class NotesNeeded(Range):
"""How many notes are needed to access the Music Box."""
display_name = "Notes Needed"
range_start = 1
range_end = 6
default = range_end
class AmountSeals(Range):
"""Number of power seals that exist in the item pool when power seal hunt is the goal."""
display_name = "Total Power Seals"
range_start = 1
range_end = 45
default = range_end
class RequiredSeals(Range):
"""Percentage of total seals required to open the shop chest."""
display_name = "Percent Seals Required"
range_start = 10
range_end = 100
default = range_end
messenger_options = {
"accessibility": MessengerAccessibility,
"enable_logic": Logic,
"shuffle_seals": PowerSeals,
"goal": Goal,
"music_box": MusicBox,
"notes_needed": NotesNeeded,
"total_seals": AmountSeals,
"percent_seals_required": RequiredSeals,
"death_link": DeathLink,
}

View File

@ -0,0 +1,52 @@
from typing import Dict, Set, List
REGIONS: Dict[str, List[str]] = {
"Menu": [],
"Tower HQ": [],
"The Shop": [],
"Tower of Time": [],
"Ninja Village": ["Candle", "Astral Seed"],
"Autumn Hills": ["Climbing Claws", "Key of Hope"],
"Forlorn Temple": ["Demon King Crown"],
"Catacombs": ["Necro", "Ruxxtin's Amulet"],
"Bamboo Creek": ["Claustro"],
"Howling Grotto": ["Wingsuit"],
"Quillshroom Marsh": ["Seashell"],
"Searing Crags": ["Rope Dart"],
"Searing Crags Upper": ["Power Thistle", "Key of Strength", "Astral Tea Leaves"],
"Glacial Peak": [],
"Cloud Ruins": ["Acro"],
"Underworld": ["Pyro", "Key of Chaos"],
"Dark Cave": [],
"Riviere Turquoise": ["Fairy Bottle"],
"Sunken Shrine": ["Ninja Tabi", "Sun Crest", "Moon Crest", "Key of Love"],
"Elemental Skylands": ["Key of Symbiosis"],
"Corrupted Future": ["Key of Courage"],
"Music Box": ["Rescue Phantom"]
}
"""seal locations have the region in their name and may not need to be created so skip them here"""
REGION_CONNECTIONS: Dict[str, Set[str]] = {
"Menu": {"Tower HQ"},
"Tower HQ": {"Autumn Hills", "Howling Grotto", "Searing Crags", "Glacial Peak", "Tower of Time", "Riviere Turquoise",
"Sunken Shrine", "Corrupted Future", "The Shop", "Music Box"},
"Tower of Time": set(),
"Ninja Village": set(),
"Autumn Hills": {"Ninja Village", "Forlorn Temple", "Catacombs"},
"Forlorn Temple": {"Catacombs", "Bamboo Creek"},
"Catacombs": {"Autumn Hills", "Bamboo Creek", "Dark Cave"},
"Bamboo Creek": {"Catacombs", "Howling Grotto"},
"Howling Grotto": {"Bamboo Creek", "Quillshroom Marsh", "Sunken Shrine"},
"Quillshroom Marsh": {"Howling Grotto", "Searing Crags"},
"Searing Crags": {"Searing Crags Upper", "Quillshroom Marsh", "Underworld"},
"Searing Crags Upper": {"Searing Crags", "Glacial Peak"},
"Glacial Peak": {"Searing Crags Upper", "Tower HQ", "Cloud Ruins", "Elemental Skylands"},
"Cloud Ruins": {"Underworld"},
"Underworld": set(),
"Dark Cave": {"Catacombs", "Riviere Turquoise"},
"Riviere Turquoise": set(),
"Sunken Shrine": {"Howling Grotto"},
"Elemental Skylands": set()
}
"""Vanilla layout mapping with all Tower HQ portals open. from -> to"""

158
worlds/messenger/Rules.py Normal file
View File

@ -0,0 +1,158 @@
from typing import Dict, Callable, Optional, Tuple, Union, TYPE_CHECKING, List, Iterable
from BaseClasses import CollectionState, MultiWorld, Location, Region, Entrance, Item
from .Options import MessengerAccessibility, Goal
from .Constants import NOTES, PHOBEKINS
from ..generic.Rules import add_rule, set_rule
if TYPE_CHECKING:
from . import MessengerWorld
else:
MessengerWorld = object
class MessengerRules:
player: int
world: MessengerWorld
def __init__(self, world: MessengerWorld):
self.player = world.player
self.world = world
self.region_rules: Dict[str, Callable[[CollectionState], bool]] = {
"Ninja Village": self.has_wingsuit,
"Autumn Hills": self.has_wingsuit,
"Catacombs": self.has_wingsuit,
"Bamboo Creek": self.has_wingsuit,
"Searing Crags Upper": self.has_vertical,
"Cloud Ruins": lambda state: self.has_wingsuit(state) and state.has("Ruxxtin's Amulet", self.player),
"Underworld": self.has_tabi,
"Forlorn Temple": lambda state: state.has_all(PHOBEKINS, self.player) and self.has_wingsuit(state),
"Glacial Peak": self.has_vertical,
"Elemental Skylands": lambda state: state.has("Fairy Bottle", self.player),
"Music Box": lambda state: state.has_all(NOTES, self.player)
}
self.location_rules: Dict[str, Callable[[CollectionState], bool]] = {
# ninja village
"Ninja Village Seal - Tree House": self.has_dart,
# autumn hills
"Key of Hope": self.has_dart,
# howling grotto
"Howling Grotto Seal - Windy Saws and Balls": self.has_wingsuit,
"Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state),
# searing crags
"Key of Strength": lambda state: state.has("Power Thistle", self.player),
# glacial peak
"Glacial Peak Seal - Ice Climbers": self.has_dart,
"Glacial Peak Seal - Projectile Spike Pit": self.has_vertical,
"Glacial Peak Seal - Glacial Air Swag": self.has_vertical,
# tower of time
"Tower of Time Seal - Time Waster Seal": self.has_dart,
"Tower of Time Seal - Lantern Climb": self.has_wingsuit,
"Tower of Time Seal - Arcane Orbs": lambda state: self.has_wingsuit(state) and self.has_dart(state),
# underworld
"Underworld Seal - Sharp and Windy Climb": self.has_wingsuit,
"Underworld Seal - Fireball Wave": self.has_wingsuit,
"Underworld Seal - Rising Fanta": self.has_dart,
# sunken shrine
"Sun Crest": self.has_tabi,
"Moon Crest": self.has_tabi,
"Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player),
"Sunken Shrine Seal - Waterfall Paradise": self.has_tabi,
"Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi,
# riviere turquoise
"Fairy Bottle": self.has_vertical,
"Riviere Turquoise Seal - Flower Power": self.has_vertical,
# elemental skylands
"Key of Symbiosis": self.has_dart,
"Elemental Skylands Seal - Air": self.has_wingsuit,
"Elemental Skylands Seal - Water": self.has_dart,
"Elemental Skylands Seal - Fire": self.has_dart,
# corrupted future
"Key of Courage": lambda state: state.has_all({"Demon King Crown", "Fairy Bottle"}, self.player),
# the shop
"Shop Chest": self.has_enough_seals
}
def has_wingsuit(self, state: CollectionState) -> bool:
return state.has("Wingsuit", self.player)
def has_dart(self, state: CollectionState) -> bool:
return state.has("Rope Dart", self.player)
def has_tabi(self, state: CollectionState) -> bool:
return state.has("Ninja Tabi", self.player)
def has_vertical(self, state: CollectionState) -> bool:
return self.has_wingsuit(state) or self.has_dart(state)
def has_enough_seals(self, state: CollectionState) -> bool:
required_seals = state.multiworld.worlds[self.player].required_seals
return state.has("Power Seal", self.player, required_seals)
def set_messenger_rules(self) -> None:
multiworld = self.world.multiworld
for region in multiworld.get_regions(self.player):
if region.name in self.region_rules:
for entrance in region.entrances:
entrance.access_rule = self.region_rules[region.name]
for loc in region.locations:
if loc.name in self.location_rules:
loc.access_rule = self.location_rules[loc.name]
if multiworld.goal[self.player] == Goal.option_power_seal_hunt:
set_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player),
lambda state: state.has("Shop Chest", self.player))
if multiworld.enable_logic[self.player]:
multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player)
else:
multiworld.accessibility[self.player].value = MessengerAccessibility.option_minimal
if multiworld.accessibility[self.player] > MessengerAccessibility.option_locations:
set_self_locking_items(multiworld, self.player)
def location_item_name(state: CollectionState, location_name: str, player: int) -> Optional[Tuple[str, int]]:
location = state.multiworld.get_location(location_name, player)
if location.item is None:
return None
return location.item.name, location.item.player
def allow_self_locking_items(spot: Union[Location, Region], *item_names: str) -> None:
"""
Sets rules on the supplied spot, such that the supplied item_name(s) can possibly be placed there.
:param spot: Location or Region that the item(s) are allowed to be placed in
:param item_names: item name or names that are allowed to be placed in the Location or Region
"""
player = spot.player
def set_always_allow(location: Location, rule: Callable[[CollectionState, Item], bool]) -> None:
location.always_allow = rule
def add_allowed_rules(area: Union[Location, Entrance], location: Location) -> None:
for item_name in item_names:
add_rule(area, lambda state, item_name=item_name:
location_item_name(state, location.name, player) == (item_name, player), "or")
set_always_allow(location, lambda state, item:
item.player == player and item.name in [item_name for item_name in item_names])
if isinstance(spot, Region):
for entrance in spot.entrances:
for location in spot.locations:
add_allowed_rules(entrance, location)
else:
add_allowed_rules(spot, spot)
def set_self_locking_items(multiworld: MultiWorld, player: int) -> None:
# do the ones for seal shuffle on and off first
allow_self_locking_items(multiworld.get_location("Key of Strength", player), "Power Thistle")
allow_self_locking_items(multiworld.get_location("Key of Love", player), "Sun Crest", "Moon Crest")
allow_self_locking_items(multiworld.get_location("Key of Courage", player), "Demon King Crown")
# add these locations when seals aren't shuffled
if not multiworld.shuffle_seals[player]:
allow_self_locking_items(multiworld.get_region("Cloud Ruins", player), "Ruxxtin's Amulet")
allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS)

View File

@ -0,0 +1,58 @@
from typing import Set, TYPE_CHECKING, Optional, Dict
from BaseClasses import Region, Location, Item, ItemClassification, Entrance
from .Constants import SEALS, NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS
from .Options import Goal
from .Regions import REGIONS
if TYPE_CHECKING:
from . import MessengerWorld
else:
MessengerWorld = object
class MessengerRegion(Region):
def __init__(self, name: str, world: MessengerWorld):
super().__init__(name, world.player, world.multiworld)
self.add_locations(self.multiworld.worlds[self.player].location_name_to_id)
world.multiworld.regions.append(self)
def add_locations(self, name_to_id: Dict[str, int]) -> None:
for loc in REGIONS[self.name]:
self.locations.append(MessengerLocation(loc, self, name_to_id.get(loc, None)))
if self.name == "The Shop" and self.multiworld.goal[self.player] > Goal.option_open_music_box:
self.locations.append(MessengerLocation("Shop Chest", self, name_to_id.get("Shop Chest", None)))
# putting some dumb special case for searing crags and ToT so i can split them into 2 regions
if self.multiworld.shuffle_seals[self.player] and self.name not in {"Searing Crags", "Tower HQ"}:
for seal_loc in SEALS:
if seal_loc.startswith(self.name.split(" ")[0]):
self.locations.append(MessengerLocation(seal_loc, self, name_to_id.get(seal_loc, None)))
def add_exits(self, exits: Set[str]) -> None:
for exit in exits:
ret = Entrance(self.player, f"{self.name} -> {exit}", self)
self.exits.append(ret)
ret.connect(self.multiworld.get_region(exit, self.player))
class MessengerLocation(Location):
game = "The Messenger"
def __init__(self, name: str, parent: MessengerRegion, loc_id: Optional[int]):
super().__init__(parent.player, name, loc_id, parent)
if loc_id is None:
self.place_locked_item(MessengerItem(name, parent.player, None))
class MessengerItem(Item):
game = "The Messenger"
def __init__(self, name: str, player: int, item_id: Optional[int] = None):
if name in {*NOTES, *PROG_ITEMS, *PHOBEKINS} or item_id is None:
item_class = ItemClassification.progression
elif name in USEFUL_ITEMS:
item_class = ItemClassification.useful
else:
item_class = ItemClassification.filler
super().__init__(name, item_class, item_id, player)

View File

@ -0,0 +1,125 @@
from typing import Dict, Any, List, Optional
from BaseClasses import Tutorial, ItemClassification
from worlds.AutoWorld import World, WebWorld
from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS, ALWAYS_LOCATIONS, SEALS, ALL_ITEMS
from .Options import messenger_options, NotesNeeded, Goal, PowerSeals
from .Regions import REGIONS, REGION_CONNECTIONS
from .Rules import MessengerRules
from .SubClasses import MessengerRegion, MessengerItem
class MessengerWeb(WebWorld):
theme = "ocean"
bug_report_page = "https://github.com/minous27/TheMessengerRandomizerMod/issues"
tut_en = Tutorial(
"Multiworld Setup Tutorial",
"A guide to setting up The Messenger randomizer on your computer.",
"English",
"setup_en.md",
"setup/en",
["alwaysintreble"]
)
tutorials = [tut_en]
class MessengerWorld(World):
"""
As a demon army besieges his village, a young ninja ventures through a cursed world, to deliver a scroll paramount
to his clans survival. What begins as a classic action platformer soon unravels into an expansive time-traveling
adventure full of thrills, surprises, and humor.
"""
game = "The Messenger"
item_name_groups = {
"Notes": set(NOTES),
"Keys": set(NOTES),
"Crest": {"Sun Crest", "Moon Crest"},
"Phobe": set(PHOBEKINS),
"Phobekin": set(PHOBEKINS),
"Shuriken": {"Windmill Shuriken"},
}
option_definitions = messenger_options
base_offset = 0xADD_000
item_name_to_id = {item: item_id
for item_id, item in enumerate(ALL_ITEMS, base_offset)}
location_name_to_id = {location: location_id
for location_id, location in enumerate([*ALWAYS_LOCATIONS, *SEALS], base_offset)}
data_version = 1
web = MessengerWeb()
total_seals: Optional[int] = None
required_seals: Optional[int] = None
def generate_early(self) -> None:
if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt:
self.multiworld.shuffle_seals[self.player].value = PowerSeals.option_true
self.total_seals = self.multiworld.total_seals[self.player].value
self.required_seals = int(self.multiworld.percent_seals_required[self.player].value / 100 * self.total_seals)
def create_regions(self) -> None:
for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]:
if region.name in REGION_CONNECTIONS:
region.add_exits(REGION_CONNECTIONS[region.name])
def create_items(self) -> None:
itempool: List[MessengerItem] = []
if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt:
seals = [self.create_item("Power Seal") for _ in range(self.total_seals)]
for i in range(self.required_seals):
seals[i].classification = ItemClassification.progression_skip_balancing
itempool += seals
else:
notes = self.multiworld.random.sample(NOTES, k=len(NOTES))
precollected_notes_amount = NotesNeeded.range_end - self.multiworld.notes_needed[self.player]
if precollected_notes_amount:
for note in notes[:precollected_notes_amount]:
self.multiworld.push_precollected(self.create_item(note))
itempool += [self.create_item(note) for note in notes[precollected_notes_amount:]]
itempool += [self.create_item(item)
for item in self.item_name_to_id
if item not in
{
"Power Seal", "Time Shard", *NOTES,
*{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}
# this is a set and currently won't create items for anything that appears in here at all
# if we get in a position where this can have duplicates of items that aren't Power Seals
# or Time shards, this will need to be redone.
}]
itempool += [self.create_filler()
for _ in range(len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool))]
self.multiworld.itempool += itempool
def set_rules(self) -> None:
MessengerRules(self).set_messenger_rules()
def fill_slot_data(self) -> Dict[str, Any]:
locations: Dict[int, List[str]] = {}
for loc in self.multiworld.get_filled_locations(self.player):
if loc.item.code:
locations[loc.address] = [loc.item.name, self.multiworld.player_name[loc.item.player]]
return {
"deathlink": self.multiworld.death_link[self.player].value,
"goal": self.multiworld.goal[self.player].current_key,
"music_box": self.multiworld.music_box[self.player].value,
"required_seals": self.required_seals,
"locations": locations,
"settings": {"Difficulty": "Basic" if not self.multiworld.shuffle_seals[self.player] else "Advanced"}
}
def get_filler_item_name(self) -> str:
return "Time Shard"
def create_item(self, name: str) -> MessengerItem:
item_id: Optional[int] = self.item_name_to_id.get(name, None)
return MessengerItem(name, self.player, item_id)

View File

@ -0,0 +1,75 @@
# The Messenger
## Quick Links
- [Setup](../../../../games/The%20Messenger/setup/en)
- [Settings Page](../../../../games/The%20Messenger/player-settings)
- [Courier Github](https://github.com/Brokemia/Courier)
- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod)
- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker)
- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack)
## What does randomization do in this game?
All items and upgrades that can be picked up by the player in the game are randomized. The player starts in the Tower of
Time HQ with the past section finished, all area portals open, and with the cloud step, and climbing claws already
obtained. You'll be forced to do sections of the game in different ways with your current abilities. Currently, logic
assumes you already have all shop upgrades.
## What items can appear in other players' worlds?
* The player's movement items
* Quest and pedestal items
* Music Box notes
* The Phobekins
* Time shards
* Power Seals
## 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
* Phobekins
* Power seals
## What are the item name groups?
When you attempt to hint for items in Archipelago you can use either the name for the specific item, or the name of a
group of items. Hinting for a group will choose a random item from the group that you do not currently have and hint
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
* Phobekin - Any of the Phobekins
* Phobe - An alternative name for the Phobekins
* Shuriken - The windmill shuriken
## Other changes
* The player can return to the Tower of Time HQ at any point by selecting the button from the options menu
* This can cause issues if used at specific times. Current known:
* During Boss fights
* After Courage Note collection (Corrupted Future chase)
* This is currently an expected action in logic. If you do need to teleport during this chase sequence, it
is recommended to quit to title and reload the save
* After reaching ninja village a teleport option is added to the menu to reach it quickly
* Toggle Windmill Shuriken button is added to option menu once the item is received
## Currently known issues
* Necro cutscene will sometimes not play correctly, but will still reward the item
* Ruxxtin Coffin cutscene will sometimes not play correctly, but will still reward the item
* If you receive the Fairy Bottle while in Quillshroom Marsh, The Decurse Queen cutscene will not play
* If you defeat Barma'thazël, the cutscene afterward will not play correctly since that is what normally transitions
you to 2nd quest. The game will not kill you if you fall here, so you can teleport to HQ at any point after defeating him.
* Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the
player.
* If playing the game in non-english, sometimes the text entry menus will say "What is your name?" in local language
instead of the correct text. This can be fixed by going into the game options and selecting your language in the menu.
It does not need to be changed to something else and back.
* Text entry menus don't accept controller input
## 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)

View File

@ -0,0 +1,52 @@
# The Messenger Randomizer Setup Guide
## Quick Links
- [Main Page](../../../../games/The%20Messenger/info/en)
- [Settings Page](../../../../games/The%20Messenger/player-settings)
- [Courier Github](https://github.com/Brokemia/Courier)
- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod)
- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker)
- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack)
## Required Software
- [The Messenger](https://store.steampowered.com/app/764790/The_Messenger/)
- Only Steam version is currently supported.
- [Courier Mod Loader](https://github.com/Brokemia/Courier/releases)
- [The Messenger Randomizer Mod](https://github.com/minous27/TheMessengerRandomizerMod/releases)
## Installation
1. Download and install Courier Mod Loader using the instructions on the release page
2. Download and install the randomizer mod
* Download the latest `TheMessengerRandomizer.zip`
* Extract the zip file to `TheMessenger/Mods/` of your game's install location
* Optionally, Backup your save game
1. Press `Windows Key + R` to open run
2. Type `%appdata%` to access AppData
3. Navigate to `AppData/locallow/SabotageStudios/The Messenger`
4. Rename `SaveGame.txt` to any name of your choice
## Joining a MultiWorld Game
1. Launch the game
2. Navigate to `Options > Third Party Mod Options`
3. Select `Reset Randomizer File Slots`
* This will set up all of your save slots with new randomizer save files. You can have up to 3 randomizer files at a
time, but must do this step again to start new runs afterwards.
4. Enter connection info using the relevant option buttons
* **The game is limited to alphanumerical characters and `-` so when entering the host name replace `.` with ` ` and
ensure that your player name when generating a settings file follows these constrictions**
* This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the
website.
5. Select the `Connect to Archipelago` button
6. Navigate to save file selection
7. Select a new valid randomizer save
## Troubleshooting
If you launch the game, and it hangs on the splash screen for more than 30 seconds try these steps:
1. Close the game and remove `TheMessengerRandomizer` from the `Mods` folder.
2. Launch The Messenger
3. Delete any save slot
4. Reinstall the randomizer mod following step 2 of the installation.

View File

@ -0,0 +1,149 @@
from . import MessengerTestBase
from ..Constants import NOTES, PHOBEKINS
from ..Options import MessengerAccessibility
class AccessTest(MessengerTestBase):
def testTabi(self) -> None:
"""locations that hard require the Ninja Tabi"""
locations = ["Pyro", "Key of Chaos", "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Spike Wall",
"Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta", "Sun Crest", "Moon Crest",
"Sunken Shrine Seal - Waterfall Paradise", "Sunken Shrine Seal - Tabi Gauntlet"]
items = [["Ninja Tabi"]]
self.assertAccessDependency(locations, items)
def testDart(self) -> None:
"""locations that hard require the Rope Dart"""
locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits",
"Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal",
"Tower of Time Seal - Arcane Orbs", "Underworld Seal - Rising Fanta", "Key of Symbiosis",
"Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire"]
items = [["Rope Dart"]]
self.assertAccessDependency(locations, items)
def testWingsuit(self) -> None:
"""locations that hard require the Wingsuit"""
locations = ["Candle", "Ninja Village Seal - Tree House", "Climbing Claws", "Key of Hope",
"Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws",
"Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Necro",
"Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet",
"Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors",
"Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2",
"Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls",
"Tower of Time Seal - Lantern Climb", "Demon King Crown", "Cloud Ruins Seal - Ghost Pit",
"Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room",
"Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs",
"Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave",
"Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze",
"Forlorn Temple Seal - Rocket Sunset", "Astral Seed"]
items = [["Wingsuit"]]
self.assertAccessDependency(locations, items)
def testVertical(self) -> None:
"""locations that require either the Rope Dart or the Wingsuit"""
locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits",
"Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal",
"Underworld Seal - Rising Fanta", "Key of Symbiosis",
"Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire", "Candle",
"Ninja Village Seal - Tree House", "Climbing Claws", "Key of Hope",
"Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws",
"Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Necro",
"Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet",
"Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors",
"Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2",
"Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls",
"Tower of Time Seal - Lantern Climb", "Demon King Crown", "Cloud Ruins Seal - Ghost Pit",
"Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room",
"Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs",
"Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave",
"Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset",
"Power Thistle", "Key of Strength", "Glacial Peak Seal - Projectile Spike Pit",
"Glacial Peak Seal - Glacial Air Swag", "Fairy Bottle", "Riviere Turquoise Seal - Flower Power",
"Searing Crags Seal - Triple Ball Spinner", "Searing Crags Seal - Raining Rocks",
"Searing Crags Seal - Rhythm Rocks", "Astral Seed", "Astral Tea Leaves"]
items = [["Wingsuit", "Rope Dart"]]
self.assertAccessDependency(locations, items)
def testAmulet(self) -> None:
"""Locations that require Ruxxtin's Amulet"""
locations = ["Acro", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley",
"Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room"]
# Cloud Ruins requires Ruxxtin's Amulet
items = [["Ruxxtin's Amulet"]]
self.assertAccessDependency(locations, items)
def testBottle(self) -> None:
"""Elemental Skylands and Corrupted Future require the Fairy Bottle"""
locations = ["Key of Symbiosis", "Elemental Skylands Seal - Air", "Elemental Skylands Seal - Fire",
"Elemental Skylands Seal - Water", "Key of Courage"]
items = [["Fairy Bottle"]]
self.assertAccessDependency(locations, items)
def testCrests(self) -> None:
"""Test Key of Love nonsense"""
locations = ["Key of Love"]
items = [["Sun Crest", "Moon Crest"]]
self.assertAccessDependency(locations, items)
self.collect_all_but("Sun Crest")
self.assertEqual(self.can_reach_location("Key of Love"), False)
self.remove(self.get_item_by_name("Moon Crest"))
self.collect_by_name("Sun Crest")
self.assertEqual(self.can_reach_location("Key of Love"), False)
def testThistle(self) -> None:
"""I'm a chuckster!"""
locations = ["Key of Strength"]
items = [["Power Thistle"]]
self.assertAccessDependency(locations, items)
def testCrown(self) -> None:
"""Crocomire but not"""
locations = ["Key of Courage"]
items = [["Demon King Crown"]]
self.assertAccessDependency(locations, items)
def testGoal(self) -> None:
"""Test some different states to verify goal requires the correct items"""
self.collect_all_but([*NOTES, "Rescue Phantom"])
self.assertEqual(self.can_reach_location("Rescue Phantom"), False)
self.collect_all_but(["Key of Love", "Rescue Phantom"])
self.assertBeatable(False)
self.collect_by_name(["Key of Love"])
self.assertEqual(self.can_reach_location("Rescue Phantom"), True)
self.assertBeatable(True)
class ItemsAccessTest(MessengerTestBase):
options = {
"shuffle_seals": False,
"accessibility": MessengerAccessibility.option_items
}
def testSelfLockingItems(self) -> None:
"""Force items that can be self locked to ensure it's valid placement."""
location_lock_pairs = {
"Key of Strength": ["Power Thistle"],
"Key of Love": ["Sun Crest", "Moon Crest"],
"Key of Courage": ["Demon King Crown"],
"Acro": ["Ruxxtin's Amulet"],
"Demon King Crown": PHOBEKINS
}
for loc in location_lock_pairs:
for item_name in location_lock_pairs[loc]:
item = self.get_item_by_name(item_name)
with self.subTest("Fulfills Accessibility", location=loc, item=item_name):
self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, True))
class NoLogicTest(MessengerTestBase):
options = {
"enable_logic": "false"
}
def testNoLogic(self) -> None:
"""Test some funny locations to make sure they aren't reachable but we can still win"""
self.assertEqual(self.can_reach_location("Pyro"), False)
self.assertEqual(self.can_reach_location("Rescue Phantom"), False)
self.assertBeatable(True)

View File

@ -0,0 +1,30 @@
from . import MessengerTestBase
from ..Constants import NOTES
class TwoNoteGoalTest(MessengerTestBase):
options = {
"notes_needed": 2,
}
def testPrecollectedNotes(self) -> None:
self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 4)
class FourNoteGoalTest(MessengerTestBase):
options = {
"notes_needed": 4,
}
def testPrecollectedNotes(self) -> None:
self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 2)
class DefaultGoalTest(MessengerTestBase):
def testPrecollectedNotes(self) -> None:
self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 0)
def testGoal(self) -> None:
self.assertBeatable(False)
self.collect_by_name(NOTES)
self.assertBeatable(True)

View File

@ -0,0 +1,79 @@
from BaseClasses import ItemClassification, CollectionState
from . import MessengerTestBase
class NoLogicTest(MessengerTestBase):
options = {
"enable_logic": "false",
"goal": "power_seal_hunt",
}
def testChestAccess(self):
"""Test to make sure we can win even though we can't reach the chest."""
self.assertEqual(self.can_reach_location("Shop Chest"), False)
self.assertBeatable(True)
class AllSealsRequired(MessengerTestBase):
options = {
"shuffle_seals": "false",
"goal": "power_seal_hunt",
}
def testSealsShuffled(self) -> None:
"""Shuffle seals should be forced on when shop chest is the goal so test it."""
self.assertTrue(self.multiworld.shuffle_seals[self.player])
def testChestAccess(self) -> None:
"""Defaults to a total of 45 power seals in the pool and required."""
with self.subTest("Access Dependency"):
self.assertEqual(len([seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]),
self.multiworld.total_seals[self.player])
locations = ["Shop Chest"]
items = [["Power Seal"]]
self.assertAccessDependency(locations, items)
self.multiworld.state = CollectionState(self.multiworld)
self.assertEqual(self.can_reach_location("Shop Chest"), False)
self.assertBeatable(False)
self.collect_all_but(["Power Seal", "Shop Chest", "Rescue Phantom"])
self.assertEqual(self.can_reach_location("Shop Chest"), False)
self.assertBeatable(False)
self.collect_by_name("Power Seal")
self.assertEqual(self.can_reach_location("Shop Chest"), True)
self.assertBeatable(True)
class HalfSealsRequired(MessengerTestBase):
options = {
"goal": "power_seal_hunt",
"percent_seals_required": 50,
}
def testSealsAmount(self) -> None:
"""Should have 45 power seals in the item pool and half that required"""
self.assertEqual(self.multiworld.total_seals[self.player], 45)
self.assertEqual(self.multiworld.worlds[self.player].total_seals, 45)
self.assertEqual(self.multiworld.worlds[self.player].required_seals, 22)
total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]
required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing]
self.assertEqual(len(total_seals), 45)
self.assertEqual(len(required_seals), 22)
class ThirtyThirtySeals(MessengerTestBase):
options = {
"goal": "power_seal_hunt",
"total_seals": 30,
"percent_seals_required": 34,
}
def testSealsAmount(self) -> None:
"""Should have 30 power seals in the pool and 33 percent of that required."""
self.assertEqual(self.multiworld.total_seals[self.player], 30)
self.assertEqual(self.multiworld.worlds[self.player].total_seals, 30)
self.assertEqual(self.multiworld.worlds[self.player].required_seals, 10)
total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]
required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing]
self.assertEqual(len(total_seals), 30)
self.assertEqual(len(required_seals), 10)

View File

@ -0,0 +1,6 @@
from test.TestBase import WorldTestBase
class MessengerTestBase(WorldTestBase):
game = "The Messenger"
player: int = 1