forked from mirror/Archipelago
Some checks failed
Analyze modified files / flake8 (push) Failing after 2m28s
Build / build-win (push) Has been cancelled
Build / build-ubuntu2204 (push) Has been cancelled
ctest / Test C++ ubuntu-latest (push) Has been cancelled
ctest / Test C++ windows-latest (push) Has been cancelled
Analyze modified files / mypy (push) Has been cancelled
Build and Publish Docker Images / Push Docker image to Docker Hub (push) Successful in 5m4s
Native Code Static Analysis / scan-build (push) Failing after 5m2s
type check / pyright (push) Successful in 1m7s
unittests / Test Python 3.11.2 ubuntu-latest (push) Failing after 16m23s
unittests / Test Python 3.12 ubuntu-latest (push) Failing after 28m19s
unittests / Test Python 3.13 ubuntu-latest (push) Failing after 14m49s
unittests / Test hosting with 3.13 on ubuntu-latest (push) Successful in 5m0s
unittests / Test Python 3.13 macos-latest (push) Has been cancelled
unittests / Test Python 3.11 windows-latest (push) Has been cancelled
unittests / Test Python 3.13 windows-latest (push) Has been cancelled
525 lines
18 KiB
Python
525 lines
18 KiB
Python
import os
|
|
import typing
|
|
import threading
|
|
import pkgutil
|
|
|
|
|
|
from typing import List, Set, Dict, TextIO
|
|
from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification
|
|
from worlds.AutoWorld import World, WebWorld
|
|
import settings
|
|
from .Items import get_item_names_per_category, soul_filler_table, item_table, consumable_table, money_table
|
|
from .Locations import get_locations
|
|
from .Regions import init_areas
|
|
from .Options import DoSOptions, dos_option_groups, SoulsanityLevel, SoulRandomizer
|
|
from .Rules import set_location_rules
|
|
from .Client import DoSClient
|
|
from .Rom import DoSProcPatch, patch_rom
|
|
from .static_location_data import location_ids
|
|
from .setup_game import place_static_items, setup_game, place_static_souls
|
|
|
|
|
|
class DoSWeb(WebWorld):
|
|
theme = "ocean"
|
|
|
|
setup_en = Tutorial(
|
|
"Multiworld Setup Guide",
|
|
"A guide to setting up the Dawn of Sorrow randomizer"
|
|
"and connecting to an Archipelago server.",
|
|
"English",
|
|
"setup_en.md",
|
|
"setup/en",
|
|
["Pink Switch"]
|
|
)
|
|
|
|
option_groups = dos_option_groups
|
|
tutorials = [setup_en]
|
|
|
|
|
|
class CVDoSItem(Item):
|
|
game: str = "Castlevania: Dawn of Sorrow"
|
|
|
|
|
|
class DoSSettings(settings.Group):
|
|
class RomFile(settings.UserFilePath):
|
|
"""File name of the Castlevania: Dawn of Sorrow ROM file."""
|
|
description = "Dawn of Sorrow ROM File"
|
|
copy_to = "CASTLEVANIA1_ACVEA4_00.nds"
|
|
md5 = "cc0f25b8783fb83cb4588d1c111bdc18"
|
|
|
|
rom_file: RomFile = RomFile(RomFile.copy_to)
|
|
|
|
|
|
class DoSWorld(World):
|
|
"""One year after the events of Aria, Soma is targetted by a recently emerged cult.
|
|
Having rejected his fate, the cult seeks to create a new Dark Lord in his stead.
|
|
Explore a new castle and defeat the Dark Lord Candidates!"""
|
|
|
|
game = "Castlevania: Dawn of Sorrow"
|
|
option_definitions = DoSOptions
|
|
data_version = 1
|
|
required_client_version = (0, 6, 0)
|
|
origin_region_name = "Lost Village Upper"
|
|
|
|
item_name_to_id = {item: item_table[item].code for item in item_table}
|
|
location_name_to_id = location_ids
|
|
item_name_groups = get_item_names_per_category()
|
|
|
|
web = DoSWeb()
|
|
settings: typing.ClassVar[DoSSettings]
|
|
# topology_present = True
|
|
ut_can_gen_without_yaml = True
|
|
|
|
options_dataclass = DoSOptions
|
|
options: DoSOptions
|
|
|
|
locked_locations: List[str]
|
|
location_cache: List[Location]
|
|
|
|
def __init__(self, multiworld: MultiWorld, player: int):
|
|
self.rom_name_available_event = threading.Event()
|
|
super().__init__(multiworld, player)
|
|
|
|
self.locked_locations = []
|
|
self.location_cache = []
|
|
self.extra_item_count = 0
|
|
self.has_tried_chaos_ring = False
|
|
self.starting_warp_room = None
|
|
|
|
self.armor_table = [
|
|
"Casual Clothes",
|
|
"Cloth Tunic",
|
|
"Gym Clothes",
|
|
"Kung Fu Suit",
|
|
"Biker Jacket",
|
|
"War Fatigues",
|
|
"Ninja Suit",
|
|
"Three 7s",
|
|
"Justaucorps",
|
|
"Army Jacket",
|
|
"Pitch Black Suit",
|
|
"Olrox's Suit",
|
|
"Dracula's Tunic",
|
|
"Leather Armor",
|
|
"Breastplate",
|
|
"Ring Mail",
|
|
"Scale Mail",
|
|
"Chain Mail",
|
|
"Hauberk",
|
|
"Cuirass",
|
|
"Blocking Mail",
|
|
"Eversing",
|
|
"Demon's Mail",
|
|
"Silk Robe",
|
|
"Mage Robe",
|
|
"Elfin Robe",
|
|
"Wyrm Robe",
|
|
"Aquarius",
|
|
"Serenity Robe",
|
|
"Death's Robe",
|
|
"Cape",
|
|
"Traveler Cape",
|
|
"Crimson Cloak",
|
|
"Black Cloak",
|
|
"Pendant",
|
|
"Heart Pendant",
|
|
"Skull Necklace",
|
|
"Flame Necklace",
|
|
"Rosary",
|
|
"Scarf",
|
|
"Red Scarf",
|
|
"Neck Warmer",
|
|
"Power Belt",
|
|
"Black Belt",
|
|
"Megingiord",
|
|
"Hoop Earring",
|
|
"Turquoise Stud",
|
|
"Silver Stud",
|
|
"Gold Stud",
|
|
"Bloody Stud",
|
|
"Platinum Stud",
|
|
"Tear Of Blood",
|
|
"Lucky Charm",
|
|
"Satan's Ring",
|
|
"Rare Ring",
|
|
"Soul Eater Ring",
|
|
"Rune Ring",
|
|
"Shaman Ring",
|
|
"Gold Ring"
|
|
]
|
|
|
|
self.weapon_table = [
|
|
"Knife",
|
|
"Combat Knife",
|
|
"Baselard",
|
|
"Cutall",
|
|
"Cinquedia",
|
|
"Rapier",
|
|
"Fleuret",
|
|
"Main Gauche",
|
|
"Small Sword",
|
|
"Estoc",
|
|
"Whip Sword",
|
|
"Garian Sword",
|
|
"Kris Naga",
|
|
"Nebula",
|
|
"Short Sword",
|
|
"Cutlass",
|
|
"Long Sword",
|
|
"Fragarach",
|
|
"Hrunting",
|
|
"Mystletain",
|
|
"Joyeuse",
|
|
"Milican's Sword",
|
|
"Ice Brand",
|
|
"Laevatain",
|
|
"Burtgang",
|
|
"Kaladbolg",
|
|
"Valmanway",
|
|
"Claymore",
|
|
"Falchion",
|
|
"Great Sword",
|
|
"Durandal",
|
|
"Dainslef",
|
|
"Ascalon",
|
|
"Balmung",
|
|
"Final Sword",
|
|
"Claimh Solais",
|
|
"Spear",
|
|
"Partizan",
|
|
"Halberd",
|
|
"Lance",
|
|
"Trident",
|
|
"Brionac",
|
|
"Geiborg",
|
|
"Longinus",
|
|
"Gungner",
|
|
"Mace",
|
|
"Morgenstern",
|
|
"Mjollnjr",
|
|
"Axe",
|
|
"Battle Axe",
|
|
"Bhuj",
|
|
"Great Axe",
|
|
"Golden Axe",
|
|
"Death Scythe",
|
|
"Blunt Sword",
|
|
"Katana",
|
|
"Kotetsu",
|
|
"Masamune",
|
|
"Osafune",
|
|
"Kunitsuna",
|
|
"Yasutsuna",
|
|
"Muramasa",
|
|
"Brass Knuckles",
|
|
"Cestus",
|
|
"Whip Knuckle",
|
|
"Mach Punch",
|
|
"Kaiser Knuckle",
|
|
"Handgun",
|
|
"Silver Gun",
|
|
"Boomerang",
|
|
"Chakram",
|
|
"Tomahawk",
|
|
"Throwing Sickle",
|
|
"RPG",
|
|
"Terror Bear",
|
|
"Nunchakus"
|
|
]
|
|
|
|
self.common_souls = {
|
|
"Axe Armor Soul",
|
|
"Warg Soul",
|
|
"Spin Devil Soul",
|
|
"Slime Soul",
|
|
"Corpseweed Soul",
|
|
"Yeti Soul",
|
|
"Flying Humanoid Soul",
|
|
"Buer Soul",
|
|
"Guillotiner Soul",
|
|
"Cave Troll Soul",
|
|
"Merman Soul",
|
|
"Homunculus Soul",
|
|
"Decarabia Soul",
|
|
"Dead Mate Soul",
|
|
"Mothman Soul"
|
|
|
|
}
|
|
|
|
self.uncommon_souls = {
|
|
"Zombie Soul",
|
|
"Bat Soul",
|
|
"Skeleton Soul",
|
|
"Skull Archer Soul",
|
|
"Armor Knight Soul",
|
|
"Student Witch Soul",
|
|
"Slaughterer Soul",
|
|
"Bomber Armor Soul",
|
|
"Golem Soul",
|
|
"Une Soul",
|
|
"Manticore Soul",
|
|
"Mollusca Soul",
|
|
"Rycuda Soul",
|
|
"Mandragora Soul",
|
|
"Yorick Soul",
|
|
"Catoblepas Soul",
|
|
"Ghost Dancer Soul",
|
|
"Mini Devil Soul",
|
|
"Quetzalcoatl Soul",
|
|
"Amalaric Sniper Soul",
|
|
"Great Armor Soul",
|
|
"Waiter Skeleton Soul",
|
|
"Persephone Soul",
|
|
"Witch Soul",
|
|
"Lilith Soul",
|
|
"Killer Clown Soul",
|
|
"Skelerang Soul",
|
|
"Fleaman Soul",
|
|
"Devil Soul",
|
|
"Needles Soul",
|
|
"Hell Boar Soul",
|
|
"White Dragon Soul",
|
|
"Wakwak Tree Soul",
|
|
"Imp Soul",
|
|
"Harpy Soul",
|
|
"Malachi Soul",
|
|
"Larva Soul",
|
|
"Fish Head Soul",
|
|
"Ukoback Soul",
|
|
"Killer Fish Soul",
|
|
"Dead Pirate Soul",
|
|
"Frozen Shade Soul",
|
|
"Disc Armor Soul",
|
|
"Alura Une Soul",
|
|
"Mushussu Soul",
|
|
"Succubus Soul",
|
|
"Werewolf Soul",
|
|
"Flame Demon Soul",
|
|
"Alastor Soul"
|
|
|
|
}
|
|
|
|
self.rare_souls = {
|
|
"Ghost Soul",
|
|
"Ouija Table Soul",
|
|
"Peeping Eye Soul",
|
|
"Skeleton Ape Soul",
|
|
"Skeleton Farmer Soul",
|
|
"The Creature Soul",
|
|
"Ghoul Soul",
|
|
"Tombstone Soul",
|
|
"Treant Soul",
|
|
"Valkyrie Soul",
|
|
"Killer Doll Soul",
|
|
"Draghignazzo Soul",
|
|
"Bone Pillar Soul",
|
|
"Barbariccia Soul",
|
|
"Heart Eater Soul",
|
|
"Medusa Head Soul",
|
|
"Mimic Soul",
|
|
"Bugbear Soul",
|
|
"Procel Soul",
|
|
"Bone Ark Soul",
|
|
"Gorgon Soul",
|
|
"Great Axe Armor Soul",
|
|
"Dead Crusader Soul",
|
|
"Dead Warrior Soul",
|
|
"Erinys Soul",
|
|
"Tanjelly Soul",
|
|
"Final Guard Soul",
|
|
"Iron Golem Soul"
|
|
}
|
|
|
|
self.red_soul_walls = []
|
|
self.magic_seal_table = []
|
|
|
|
self.important_souls = {
|
|
"Bone Ark Soul",
|
|
"Skeleton Ape Soul",
|
|
"Mandragora Soul",
|
|
"Rycuda Soul",
|
|
"Waiter Skeleton Soul"
|
|
}
|
|
# These souls are always required for movment logic
|
|
|
|
self.excluded_static_souls = {
|
|
"Aguni Soul",
|
|
"Abaddon Soul"
|
|
}
|
|
|
|
def generate_early(self) -> None:
|
|
if hasattr(self.multiworld, "re_gen_passthrough"): # If UT
|
|
if "Castlevania: Dawn of Sorrow" not in self.multiworld.re_gen_passthrough: return
|
|
passthrough = self.multiworld.re_gen_passthrough["Castlevania: Dawn of Sorrow"]
|
|
self.options.goal = passthrough["goal"]
|
|
self.options.soul_randomizer = passthrough["soul_randomizer"]
|
|
self.options.soulsanity_level = passthrough["soulsanity_level"]
|
|
self.starting_warp_room = passthrough["starting_warp"]
|
|
self.options.open_drawbridge = passthrough["open_drawbridge"]
|
|
self.options.boost_speed = passthrough["speed_boost"]
|
|
self.red_soul_walls = passthrough["soul_walls"]
|
|
self.options.gate_items = passthrough["buttonsanity"]
|
|
self.magic_seal_table = passthrough["seals"]
|
|
self.options.menace_condition.value = passthrough["menace_condition"]
|
|
self.options.mine_condition.value = passthrough["mine_condition"]
|
|
self.options.garden_condition.value = passthrough["garden_condition"]
|
|
setup_game(self)
|
|
|
|
self.auth_id = self.random.getrandbits(32)
|
|
|
|
def create_regions(self) -> None:
|
|
init_areas(self, get_locations(self))
|
|
place_static_items(self)
|
|
if self.options.soul_randomizer != SoulRandomizer.option_soulsanity:
|
|
place_static_souls(self)
|
|
if self.options.soul_randomizer != SoulRandomizer.option_soulsanity or self.options.soulsanity_level < SoulsanityLevel.option_medium:
|
|
self.get_location("Imp Soul").place_locked_item(self.create_static_soul("Imp Soul"))
|
|
|
|
def create_items(self) -> None:
|
|
pool = self.get_item_pool(self.get_excluded_items())
|
|
self.fill_pool(pool)
|
|
|
|
self.multiworld.itempool += pool
|
|
|
|
def set_rules(self) -> None:
|
|
set_location_rules(self)
|
|
self.multiworld.completion_condition[self.player] = lambda state: state.has("Menace Defeated", self.player)
|
|
|
|
def generate_output(self, output_directory: str) -> None:
|
|
self.has_generated_output = True # Make sure data defined in generate output doesn't get added to spoiler only mode
|
|
try:
|
|
code_patch = pkgutil.get_data(__name__, "src/overlay_41.bin")
|
|
patch = DoSProcPatch(player=self.player, player_name=self.multiworld.player_name[self.player])
|
|
patch.write_file("dos_base.bsdiff4", pkgutil.get_data(__name__, "src/dos_base.bsdiff4"))
|
|
patch_rom(self, patch, self.player, code_patch)
|
|
|
|
self.rom_name = patch.name
|
|
|
|
patch.write(os.path.join(output_directory,
|
|
f"{self.multiworld.get_out_file_name_base(self.player)}{patch.patch_file_ending}"))
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
self.rom_name_available_event.set() # make sure threading continues and errors are collected
|
|
|
|
def fill_slot_data(self) -> Dict[str, typing.Any]:
|
|
return {
|
|
"goal": self.options.goal.value,
|
|
"starting_warp": self.starting_warp_room,
|
|
"soul_randomizer": self.options.soul_randomizer.value,
|
|
"soulsanity_level": self.options.soulsanity_level.value,
|
|
"open_drawbridge": self.options.open_drawbridge.value,
|
|
"speed_boost": self.options.boost_speed.value,
|
|
"soul_walls": self.red_soul_walls,
|
|
"buttonsanity": self.options.gate_items.value,
|
|
"seals": self.magic_seal_table,
|
|
"menace_condition": self.options.menace_condition.value,
|
|
"garden_condition": self.options.garden_condition.value,
|
|
"mine_condition": self.options.mine_condition.value
|
|
}
|
|
|
|
def modify_multidata(self, multidata: dict) -> None:
|
|
# wait for self.rom_name to be available.
|
|
self.rom_name_available_event.wait()
|
|
rom_name = getattr(self, "rom_name", None)
|
|
if rom_name:
|
|
multidata["connect_names"][self.rom_name] = multidata["connect_names"][self.multiworld.player_name[self.player]]
|
|
|
|
def write_spoiler_header(self, spoiler_handle: TextIO) -> None:
|
|
if self.options.shuffle_starting_warp_room:
|
|
spoiler_handle.write(f"Default Warp Room: {self.starting_warp_room}\n")
|
|
|
|
if self.options.randomize_red_soul_walls:
|
|
spoiler_handle.write(f"\nSoul Barriers:\n")
|
|
spoiler_handle.write(f" Paranoia 1: {self.red_soul_walls[1]}\n")
|
|
spoiler_handle.write(f" Paranoia 2: {self.red_soul_walls[0]}\n")
|
|
spoiler_handle.write(f" Paranoia 3: {self.red_soul_walls[3]}\n")
|
|
spoiler_handle.write(f" Dark Chapel Catacombs: {self.red_soul_walls[2]}\n")
|
|
|
|
if self.options.boss_shuffle:
|
|
spoiler_handle.write(f"\nBosses:\n")
|
|
for boss in self.boss_slots:
|
|
spoiler_handle.write(f" {boss}: {self.boss_slots[boss].new_boss}\n")
|
|
|
|
if self.options.seal_shuffle:
|
|
spoiler_handle.write(f"\nMagic Seals:\n")
|
|
for seal in self.magic_seal_table:
|
|
if seal in ["Mine of Judgment", "The Abyss"] and self.mine_status == "Disabled": # Ignore Magic Seals that are past the endgame trigger
|
|
continue
|
|
else:
|
|
spoiler_handle.write(f" {seal}: {self.magic_seal_table[seal]}\n")
|
|
|
|
def create_item(self, name: str) -> CVDoSItem:
|
|
data = self.set_classifications(name)
|
|
|
|
return CVDoSItem(name, data.classification, data.code, self.player)
|
|
|
|
def get_filler_item_name(self) -> str:
|
|
weights = {"soul": 10, "money": 20, "weapon": 30, "armor": 40, "consumable": 60}
|
|
|
|
# If these pools have been exhausted, set their weights to 0
|
|
if not self.weapon_table:
|
|
weights["weapon"] = 0
|
|
|
|
if not self.armor_table:
|
|
weights["armor"] = 0
|
|
|
|
filler_type = self.random.choices(list(weights), weights=list(weights.values()), k=1)[0]
|
|
weight_table = {
|
|
"soul": soul_filler_table,
|
|
"weapon": self.weapon_table,
|
|
"armor": self.armor_table,
|
|
"money": money_table,
|
|
"consumable": consumable_table,
|
|
}
|
|
|
|
filler_item = self.random.choice(weight_table[filler_type])
|
|
|
|
if filler_item in self.weapon_table:
|
|
self.weapon_table.remove(filler_item)
|
|
elif filler_item in self.armor_table:
|
|
self.armor_table.remove(filler_item)
|
|
|
|
if not self.has_tried_chaos_ring:
|
|
self.has_tried_chaos_ring = True
|
|
if self.random.randint(0, 101) <= 10: # Chaos ring should have a single 10/100 chance to be placed
|
|
filler_item = "Chaos Ring"
|
|
|
|
return filler_item
|
|
|
|
def get_excluded_items(self) -> Set[str]:
|
|
excluded_items: Set[str] = set()
|
|
return excluded_items
|
|
|
|
def set_classifications(self, name: str) -> Item:
|
|
data = item_table[name]
|
|
item = CVDoSItem(name, data.classification, data.code, self.player)
|
|
if name in self.important_souls:
|
|
item.classification = ItemClassification.progression
|
|
|
|
if self.options.soul_randomizer == SoulRandomizer.option_soulsanity:
|
|
if name == "Soul Eater Ring" and self.options.soulsanity_level == SoulsanityLevel.option_rare:
|
|
item.classification = ItemClassification.progression
|
|
|
|
return item
|
|
|
|
def fill_pool(self, pool: List[Item]) -> None:
|
|
for _ in range(len(self.multiworld.get_unfilled_locations(self.player)) - len(pool) - self.extra_item_count): # Change to fix event count
|
|
item = self.set_classifications(self.get_filler_item_name())
|
|
pool.append(item)
|
|
|
|
def get_item_pool(self, excluded_items: Set[str]) -> List[Item]:
|
|
pool: List[Item] = []
|
|
|
|
for name, data in item_table.items():
|
|
if name not in excluded_items:
|
|
for _ in range(data.amount):
|
|
item = self.set_classifications(name)
|
|
pool.append(item)
|
|
|
|
return pool
|
|
|
|
def create_static_soul(self, soul):
|
|
data = item_table[soul]
|
|
item = Item(soul, ItemClassification.progression, None, self.player) # Create an event item of the soul
|
|
return item |