Files
dockipelago/worlds/k64/__init__.py
Jonathan Tinney 7971961166
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
add schedule I, sonic 1/frontiers/heroes, spirit island
2026-04-02 23:46:36 -07:00

233 lines
10 KiB
Python

import logging
from BaseClasses import Tutorial, ItemClassification, MultiWorld, CollectionState, Item
from worlds.AutoWorld import World, WebWorld
from .items import item_table, item_names, copy_ability_table, filler_item_weights, K64Item, copy_ability_access_table,\
power_combo_table, friend_table, star_filler_item_weights
from .locations import location_table, K64Location
from .names import LocationName, ItemName
from .regions import create_levels, default_levels
from .rom import K64ProcedurePatch, get_base_rom_path, RomData, patch_rom, K64UHASH
from .client import K64Client
from .options import K64Options
from .rules import set_rules
from typing import TextIO, Any, Mapping, ClassVar
from io import BytesIO
import os
import math
import threading
import base64
import settings
logger = logging.getLogger("Kirby 64: The Crystal Shards")
class K64Settings(settings.Group):
class RomFile(settings.UserFilePath):
"""File name of the K64 EN rom"""
description = "Kirby 64 - The Crystal Shards ROM File"
copy_to = "Kirby 64 - The Crystal Shards (USA).z64"
md5s = [K64UHASH]
# another day, another UserFilePath reimplementation
@classmethod
def validate(cls, path: str) -> None:
"""Try to open and validate file against hashes"""
with open(path, "rb", buffering=0) as f:
if path.endswith(".n64"):
# little endian, byteswap on the half
byte_data = bytearray(f.read())
for i in range(0, len(byte_data), 2):
temp = byte_data[i]
byte_data[i] = byte_data[i + 1]
byte_data[i + 1] = temp
f = BytesIO(byte_data)
elif path.endswith(".v64"):
# byteswapped, byteswap on the word
byte_data = bytearray(f.read())
for i in range(0, len(byte_data), 4):
temp = byte_data[i]
byte_data[i] = byte_data[i + 3]
byte_data[i + 1] = byte_data[i + 2]
byte_data[i + 2] = byte_data[i + 1]
byte_data[i + 3] = temp
f = BytesIO(byte_data)
try:
cls._validate_stream_hashes(f)
except ValueError:
raise ValueError(f"File hash does not match for {path}")
rom_file: RomFile = RomFile(RomFile.copy_to)
class K64WebWorld(WebWorld):
theme = "partyTime"
tutorials = [
Tutorial(
"Multiworld Setup Guide",
"A guide to setting up the Kirby 64 - The Crystal Shards randomizer connected to an Archipelago Multiworld.",
"English",
"setup_en.md",
"setup/en",
["Silvris"]
)
]
class K64World(World):
"""
After Dark Matter attacks the distant Ripple Star's crystal, the young Ribbon attempts to flee with the crystal to save it.
The crystal shattered, stranding Ribbon in Dream Land. Now it's up to Kirby and friends to travel the galaxy in order to
restore the shattered crystal, and bring peace to the world.
"""
game = "Kirby 64 - The Crystal Shards"
options_dataclass = K64Options
options: K64Options
item_name_to_id = {item: item_table[item].code for item in item_table}
location_name_to_id = {location_table[location]: location for location in location_table}
item_name_groups = item_names
web = K64WebWorld()
settings: ClassVar[K64Settings]
def __init__(self, multiworld: MultiWorld, player: int):
self.stage_shuffle_enabled: bool = False
self.rom_name = None
self.rom_name_available_event = threading.Event()
super().__init__(multiworld, player)
self.required_crystals: int = 0 # we fill this during create_items
self.boss_requirements: list[int] = []
self.player_levels = default_levels.copy()
def generate_early(self) -> None:
if self.multiworld.players == 1 and self.options.required_crystals.value > 90:
logger.warning(f"Kirby 64 ({self.player_name}): Required Crystal percentage is too high "
f"for a single player game, setting to 90.")
self.options.required_crystals.value = 90
create_regions = create_levels
def create_item(self, name: str, force_non_progression=False) -> K64Item:
item = item_table[name]
classification = ItemClassification.filler
if item.progression and not force_non_progression:
classification = ItemClassification.progression_skip_balancing \
if item.skip_balancing else ItemClassification.progression
elif item.trap:
classification = ItemClassification.trap
return K64Item(name, classification, item.code, self.player)
def get_filler_item_name(self) -> str:
return self.random.choices(list(filler_item_weights.keys()),
weights=list(filler_item_weights.values()))[0]
def create_items(self) -> None:
itempool = []
itempool.extend([self.create_item(name) for name in copy_ability_table])
itempool.extend([self.create_item(name) for name in friend_table])
if self.options.split_power_combos:
itempool.extend([self.create_item(name) for name in power_combo_table])
required_percentage = self.options.required_crystals / 100.0
remaining_items = len([location for location in self.get_locations() if not location.item]) - len(itempool)
total_crystals = min(remaining_items, self.options.max_crystals.value)
required_crystals = max(math.floor(total_crystals * required_percentage), 5)
# ensure at least 1 crystal shard required
filler_items = total_crystals - required_crystals
filler_amount = math.floor(filler_items * (self.options.filler_percentage / 100.0))
non_required_crystals = filler_items - filler_amount
self.required_crystals = required_crystals
# handle boss requirements here
requirements = [required_crystals]
quotient = required_crystals // 6 # since we set the last manually, we can afford imperfect rounding
if self.options.boss_requirement_random:
for i in range(1, 6):
max_stars = quotient * i
requirements.insert(i, self.random.randint(
min(1, max_stars), max_stars))
requirements.sort()
else:
for i in range(1, 6):
requirements.insert(i - 1, quotient * i)
self.boss_requirements = requirements
filler_weights = star_filler_item_weights if "Stars" in self.options.consumables else filler_item_weights
itempool.extend([self.create_item(ItemName.crystal_shard) for _ in range(required_crystals)])
itempool.extend([self.create_item(name)
for name in self.random.choices(list(filler_weights.keys()), weights=list(filler_weights.values()),
k=filler_amount + (remaining_items - total_crystals))])
itempool.extend([self.create_item(ItemName.crystal_shard, True) for _ in range(non_required_crystals)])
self.multiworld.itempool += itempool
set_rules = set_rules
def generate_basic(self) -> None:
self.stage_shuffle_enabled = self.options.stage_shuffle.value > 0
def fill_slot_data(self) -> Mapping[str, Any]:
slot_data = self.options.as_dict("goal_speed", "split_power_combos", "kirby_flavor_preset", "kirby_flavor")
slot_data.update({
"player_levels": self.player_levels,
"required_crystals": self.required_crystals,
"boss_requirements": self.boss_requirements
})
return slot_data
@staticmethod
def interpret_slot_data(slot_data: Mapping[str, Any]):
local_levels = {int(key): value for key, value in slot_data["player_levels"].items()}
return {"player_levels": local_levels}
def generate_output(self, output_directory: str):
try:
rom_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}"
f"{K64ProcedurePatch.patch_file_ending}")
patch = K64ProcedurePatch(player=self.player, player_name=self.multiworld.player_name[self.player])
patch_rom(self, self.player, patch)
self.rom_name = patch.name
patch.write(rom_path)
except Exception:
raise
finally:
self.rom_name_available_event.set() # make sure threading continues and errors are collected
def modify_multidata(self, multidata: dict):
# wait for self.rom_name to be available.
self.rom_name_available_event.wait()
rom_name = getattr(self, "rom_name", None)
# we skip in case of error, so that the original error in the output thread is the one that gets raised
if rom_name:
new_name = base64.b64encode(bytes(self.rom_name)).decode()
multidata["connect_names"][new_name] = multidata["connect_names"][self.multiworld.player_name[self.player]]
def write_spoiler(self, spoiler_handle: TextIO) -> None:
if self.stage_shuffle_enabled:
spoiler_handle.write(f"\nLevel Layout ({self.multiworld.get_player_name(self.player)}):\n")
for level in LocationName.level_names_inverse:
for stage, i in zip(self.player_levels[LocationName.level_names_inverse[level]], range(1, 7)):
spoiler_handle.write(f"{level} {i}: {location_table[stage].replace(' - Complete', '')}\n")
def collect(self, state: "CollectionState", item: "Item") -> bool:
value = super().collect(state, item)
if not self.boss_requirements:
return value
crystals = state.prog_items[self.player][ItemName.crystal_shard]
level_state = [crystals >= requirement for requirement in self.boss_requirements]
if state.k64_level_state[self.player] != level_state:
state.k64_stale[self.player] = True
return value
def remove(self, state: "CollectionState", item: "Item") -> bool:
value = super().remove(state, item)
if not self.boss_requirements:
return value
crystals = state.prog_items[self.player][ItemName.crystal_shard]
level_state = [crystals >= requirement for requirement in self.boss_requirements]
if state.k64_level_state[self.player] != level_state:
state.k64_stale[self.player] = True
return value