Saving Princess: implement new game (#3238)

* Saving Princess: initial commit

* settings -> options

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* settings -> options

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* replace RegionData class with List[str]

RegionData was only wrapping a List[str], so we can directly use List[str]

* world: MultiWorld -> multiworld: MultiWorld

* use world's random instead of multiworld's

* use state's has_any and has_all where applicable

* remove unused StartInventory import

* reorder PerGameCommonOptions

* fix relative AutoWorld import

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* clean up double spaces

* local commands -> Local Commands

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* remove redundant which items section

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* game info rework

* clean up item count redundancy

* add game to readme and codeowners

* fix get_region_entrance return type

* world.multiworld.get -> world.get

* add more events

added events for the boss kills that open the gate, as well as for system power being restored

these only apply if expanded pool is not selected

* add client/autoupdater to launcher

* reorder commands in game info

* update docs with automated installation info

* add quick links to doc

* Update setup_en.md

* remove standalone saving princess client

* doc fixes

* code improvements and redundant default removal

as suggested by @Exempt-Medic
this includes the removal of events from the item/location name to id, as well as checking for the player name being ASCII

* add option to change launch coammnd

the LaunchCommand option is filled to either the executable or wine with the necessary arguments based on Utils.is_windows

* simplify valid install check

* mod installer improvements

now deletes possible existing files before installing the mod

* add option groups and presets

* add required client version

* update docs about cheat items pop-ups

items sent directly by the server (such as with starting inventory) now have pop-ups just like any other item

* add Steam Input issue to faq

* Saving Princess: BRAINOS requires all weapons

* Saving Princess: Download dll and patch together

Previously, gm-apclientpp.dll was downloaded from its own repo
With this update, the dll is instead extracted from the same zip as the game's patch

* Saving Princess: Add URI launch support

* Saving Princess: goal also requires all weapons

given it's past brainos

* Saving Princess: update docs

automatic connection support was added, docs now reflect this

* Saving Princess: extend([item]) -> append(item)

* Saving Princess: automatic connection validation

also parses the slot, password and host:port into parameters for the game

* Saving Princess: change subprocess .run to .Popen

This keeps the game from freezing the launcher while it is running

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
This commit is contained in:
LeonarthCG 2024-12-07 11:29:27 +01:00 committed by GitHub
parent ced93022b6
commit c9625e1b35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1341 additions and 0 deletions

View File

@ -77,6 +77,7 @@ Currently, the following games are supported:
* Mega Man 2
* Yacht Dice
* Faxanadu
* Saving Princess
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

@ -142,6 +142,9 @@
# Risk of Rain 2
/worlds/ror2/ @kindasneaki
# Saving Princess
/worlds/saving_princess/ @LeonarthCG
# Shivers
/worlds/shivers/ @GodlFire

View File

@ -0,0 +1,258 @@
import argparse
import zipfile
from io import BytesIO
import bsdiff4
from datetime import datetime
import hashlib
import json
import logging
import os
import requests
import secrets
import shutil
import subprocess
from tkinter import messagebox
from typing import Any, Dict, Set
import urllib
import urllib.parse
import Utils
from .Constants import *
from . import SavingPrincessWorld
files_to_clean: Set[str] = {
"D3DX9_43.dll",
"data.win",
"m_boss.ogg",
"m_brainos.ogg",
"m_coldarea.ogg",
"m_escape.ogg",
"m_hotarea.ogg",
"m_hsis_dark.ogg",
"m_hsis_power.ogg",
"m_introarea.ogg",
"m_malakhov.ogg",
"m_miniboss.ogg",
"m_ninja.ogg",
"m_purple.ogg",
"m_space_idle.ogg",
"m_stonearea.ogg",
"m_swamp.ogg",
"m_zzz.ogg",
"options.ini",
"Saving Princess v0_8.exe",
"splash.png",
"gm-apclientpp.dll",
"LICENSE",
"original_data.win",
"versions.json",
}
file_hashes: Dict[str, str] = {
"D3DX9_43.dll": "86e39e9161c3d930d93822f1563c280d",
"Saving Princess v0_8.exe": "cc3ad10c782e115d93c5b9fbc5675eaf",
"original_data.win": "f97b80204bd9ae535faa5a8d1e5eb6ca",
}
class UrlResponse:
def __init__(self, response_code: int, data: Any):
self.response_code = response_code
self.data = data
def get_date(target_asset: str) -> str:
"""Provided the name of an asset, fetches its update date"""
try:
with open("versions.json", "r") as versions_json:
return json.load(versions_json)[target_asset]
except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError):
return "2000-01-01T00:00:00Z" # arbitrary old date
def set_date(target_asset: str, date: str) -> None:
"""Provided the name of an asset and a date, sets it update date"""
try:
with open("versions.json", "r") as versions_json:
versions = json.load(versions_json)
versions[target_asset] = date
except (FileNotFoundError, KeyError, json.decoder.JSONDecodeError):
versions = {target_asset: date}
with open("versions.json", "w") as versions_json:
json.dump(versions, versions_json)
def get_timestamp(date: str) -> float:
"""Parses a GitHub REST API date into a timestamp"""
return datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ").timestamp()
def send_request(request_url: str) -> UrlResponse:
"""Fetches status code and json response from given url"""
response = requests.get(request_url)
if response.status_code == 200: # success
try:
data = response.json()
except requests.exceptions.JSONDecodeError:
raise RuntimeError(f"Unable to fetch data. (status code {response.status_code}).")
else:
data = {}
return UrlResponse(response.status_code, data)
def update(target_asset: str, url: str) -> bool:
"""
Returns True if the data was fetched and installed
(or it was already on the latest version, or the user refused the update)
Returns False if rate limit was exceeded
"""
try:
logging.info(f"Checking for {target_asset} updates.")
response = send_request(url)
if response.response_code == 403: # rate limit exceeded
return False
assets = response.data[0]["assets"]
for asset in assets:
if target_asset in asset["name"]:
newest_date: str = asset["updated_at"]
release_url: str = asset["browser_download_url"]
break
else:
raise RuntimeError(f"Failed to locate {target_asset} amongst the assets.")
except (KeyError, IndexError, TypeError, RuntimeError):
update_error = f"Failed to fetch latest {target_asset}."
messagebox.showerror("Failure", update_error)
raise RuntimeError(update_error)
try:
update_available = get_timestamp(newest_date) > get_timestamp(get_date(target_asset))
if update_available and messagebox.askyesnocancel(f"New {target_asset}",
"Would you like to install the new version now?"):
# unzip and patch
with urllib.request.urlopen(release_url) as download:
with zipfile.ZipFile(BytesIO(download.read())) as zf:
zf.extractall()
patch_game()
set_date(target_asset, newest_date)
except (ValueError, RuntimeError, urllib.error.HTTPError):
update_error = f"Failed to apply update."
messagebox.showerror("Failure", update_error)
raise RuntimeError(update_error)
return True
def patch_game() -> None:
"""Applies the patch to data.win"""
logging.info("Proceeding to patch.")
with open(PATCH_NAME, "rb") as patch:
with open("original_data.win", "rb") as data:
patched_data = bsdiff4.patch(data.read(), patch.read())
with open("data.win", "wb") as data:
data.write(patched_data)
logging.info("Done!")
def is_install_valid() -> bool:
"""Checks that the mandatory files that we cannot replace do exist in the current folder"""
for file_name, expected_hash in file_hashes.items():
if not os.path.exists(file_name):
return False
with open(file_name, "rb") as clean:
current_hash = hashlib.md5(clean.read()).hexdigest()
if not secrets.compare_digest(current_hash, expected_hash):
return False
return True
def install() -> None:
"""Extracts all the game files into the mod installation folder"""
logging.info("Mod installation missing or corrupted, proceeding to reinstall.")
# get the cab file and extract it into the installation folder
with open(SavingPrincessWorld.settings.exe_path, "rb") as exe:
# find the cab header
logging.info("Looking for cab archive inside exe.")
cab_found: bool = False
while not cab_found:
cab_found = exe.read(1) == b'M' and exe.read(1) == b'S' and exe.read(1) == b'C' and exe.read(1) == b'F'
exe.read(4) # skip reserved1, always 0
cab_size: int = int.from_bytes(exe.read(4), "little") # read size in bytes
exe.seek(-12, 1) # move the cursor back to the start of the cab file
logging.info(f"Archive found at offset {hex(exe.seek(0, 1))}, size: {hex(cab_size)}.")
logging.info("Extracting cab archive from exe.")
with open("saving_princess.cab", "wb") as cab:
cab.write(exe.read(cab_size))
# clean up files from previous installations
for file_name in files_to_clean:
if os.path.exists(file_name):
os.remove(file_name)
logging.info("Extracting files from cab archive.")
if Utils.is_windows:
subprocess.run(["Extrac32", "/Y", "/E", "saving_princess.cab"])
else:
if shutil.which("wine") is not None:
subprocess.run(["wine", "Extrac32", "/Y", "/E", "saving_princess.cab"])
elif shutil.which("7z") is not None:
subprocess.run(["7z", "e", "saving_princess.cab"])
else:
error = "Could not find neither wine nor 7z.\n\nPlease install either the wine or the p7zip package."
messagebox.showerror("Missing package!", f"Error: {error}")
raise RuntimeError(error)
os.remove("saving_princess.cab") # delete the cab file
shutil.copyfile("data.win", "original_data.win") # and make a copy of data.win
logging.info("Done!")
def launch(*args: str) -> Any:
"""Check args, then the mod installation, then launch the game"""
name: str = ""
password: str = ""
server: str = ""
if args:
parser = argparse.ArgumentParser(description=f"{GAME_NAME} Client Launcher")
parser.add_argument("url", type=str, nargs="?", help="Archipelago Webhost uri to auto connect to.")
args = parser.parse_args(args)
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
if args.url:
url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago":
server = f'--server="{url.hostname}:{url.port}"'
if url.username:
name = f'--name="{urllib.parse.unquote(url.username)}"'
if url.password:
password = f'--password="{urllib.parse.unquote(url.password)}"'
else:
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
Utils.init_logging(CLIENT_NAME, exception_logger="Client")
os.chdir(SavingPrincessWorld.settings.install_folder)
# check that the mod installation is valid
if not is_install_valid():
if messagebox.askyesnocancel(f"Mod installation missing or corrupted!",
"Would you like to reinstall now?"):
install()
# if there is no mod installation, and we are not installing it, then there isn't much to do
else:
return
# check for updates
if not update(DOWNLOAD_NAME, DOWNLOAD_URL):
messagebox.showinfo("Rate limit exceeded",
"GitHub REST API limit exceeded, could not check for updates.\n\n"
"This will not prevent the game from being played if it was already playable.")
# and try to launch the game
if SavingPrincessWorld.settings.launch_game:
logging.info("Launching game.")
try:
subprocess.Popen(f"{SavingPrincessWorld.settings.launch_command} {name} {password} {server}")
except FileNotFoundError:
error = ("Could not run the game!\n\n"
"Please check that launch_command in options.yaml or host.yaml is set up correctly.")
messagebox.showerror("Command error!", f"Error: {error}")
raise RuntimeError(error)

View File

@ -0,0 +1,97 @@
GAME_NAME: str = "Saving Princess"
BASE_ID: int = 0x53565052494E # SVPRIN
# client installation data
CLIENT_NAME = f"{GAME_NAME.replace(' ', '')}Client"
GAME_HASH = "35a111d0149fae1f04b7b3fea42c5319"
PATCH_NAME = "saving_princess_basepatch.bsdiff4"
DOWNLOAD_NAME = "saving_princess_archipelago.zip"
DOWNLOAD_URL = "https://api.github.com/repos/LeonarthCG/saving-princess-archipelago/releases"
# item names
ITEM_WEAPON_CHARGE: str = "Powered Blaster"
ITEM_WEAPON_FIRE: str = "Flamethrower"
ITEM_WEAPON_ICE: str = "Ice Spreadshot"
ITEM_WEAPON_VOLT: str = "Volt Laser"
ITEM_MAX_HEALTH: str = "Life Extension"
ITEM_MAX_AMMO: str = "Clip Extension"
ITEM_RELOAD_SPEED: str = "Faster Reload"
ITEM_SPECIAL_AMMO: str = "Special Extension"
ITEM_JACKET: str = "Jacket"
EP_ITEM_GUARD_GONE: str = "Cave Key"
EP_ITEM_CLIFF_GONE: str = "Volcanic Key"
EP_ITEM_ACE_GONE: str = "Arctic Key"
EP_ITEM_SNAKE_GONE: str = "Swamp Key"
EP_ITEM_POWER_ON: str = "System Power"
FILLER_ITEM_HEAL: str = "Full Heal"
FILLER_ITEM_QUICK_FIRE: str = "Quick-fire Mode"
FILLER_ITEM_ACTIVE_CAMO: str = "Active Camouflage"
TRAP_ITEM_ICE: str = "Ice Trap"
TRAP_ITEM_SHAKES: str = "Shake Trap"
TRAP_ITEM_NINJA: str = "Ninja Trap"
EVENT_ITEM_GUARD_GONE: str = "Guard neutralized"
EVENT_ITEM_CLIFF_GONE: str = "Cliff neutralized"
EVENT_ITEM_ACE_GONE: str = "Ace neutralized"
EVENT_ITEM_SNAKE_GONE: str = "Snake neutralized"
EVENT_ITEM_POWER_ON: str = "Power restored"
EVENT_ITEM_VICTORY: str = "PRINCESS"
# location names, EP stands for Expanded Pool
LOCATION_CAVE_AMMO: str = "Cave: After Wallboss"
LOCATION_CAVE_RELOAD: str = "Cave: Balcony"
LOCATION_CAVE_HEALTH: str = "Cave: Spike pit"
LOCATION_CAVE_WEAPON: str = "Cave: Powered Blaster chest"
LOCATION_VOLCANIC_RELOAD: str = "Volcanic: Hot coals"
LOCATION_VOLCANIC_HEALTH: str = "Volcanic: Under bridge"
LOCATION_VOLCANIC_AMMO: str = "Volcanic: Behind wall"
LOCATION_VOLCANIC_WEAPON: str = "Volcanic: Flamethrower chest"
LOCATION_ARCTIC_AMMO: str = "Arctic: Before pipes"
LOCATION_ARCTIC_RELOAD: str = "Arctic: After Guard"
LOCATION_ARCTIC_HEALTH: str = "Arctic: Under snow"
LOCATION_ARCTIC_WEAPON: str = "Arctic: Ice Spreadshot chest"
LOCATION_JACKET: str = "Arctic: Jacket chest"
LOCATION_HUB_AMMO: str = "Hub: Hidden near Arctic"
LOCATION_HUB_HEALTH: str = "Hub: Hidden near Cave"
LOCATION_HUB_RELOAD: str = "Hub: Hidden near Swamp"
LOCATION_SWAMP_AMMO: str = "Swamp: Bramble room"
LOCATION_SWAMP_HEALTH: str = "Swamp: Down the chimney"
LOCATION_SWAMP_RELOAD: str = "Swamp: Wall maze"
LOCATION_SWAMP_SPECIAL: str = "Swamp: Special Extension chest"
LOCATION_ELECTRICAL_RELOAD: str = "Electrical: Near generator"
LOCATION_ELECTRICAL_HEALTH: str = "Electrical: Behind wall"
LOCATION_ELECTRICAL_AMMO: str = "Electrical: Before Malakhov"
LOCATION_ELECTRICAL_WEAPON: str = "Electrical: Volt Laser chest"
EP_LOCATION_CAVE_MINIBOSS: str = "Cave: Wallboss (Boss)"
EP_LOCATION_CAVE_BOSS: str = "Cave: Guard (Boss)"
EP_LOCATION_VOLCANIC_BOSS: str = "Volcanic: Cliff (Boss)"
EP_LOCATION_ARCTIC_BOSS: str = "Arctic: Ace (Boss)"
EP_LOCATION_HUB_CONSOLE: str = "Hub: Console login"
EP_LOCATION_HUB_NINJA_SCARE: str = "Hub: Ninja scare (Boss?)"
EP_LOCATION_SWAMP_BOSS: str = "Swamp: Snake (Boss)"
EP_LOCATION_ELEVATOR_NINJA_FIGHT: str = "Elevator: Ninja (Boss)"
EP_LOCATION_ELECTRICAL_EXTRA: str = "Electrical: Tesla orb"
EP_LOCATION_ELECTRICAL_MINIBOSS: str = "Electrical: Generator (Boss)"
EP_LOCATION_ELECTRICAL_BOSS: str = "Electrical: Malakhov (Boss)"
EP_LOCATION_ELECTRICAL_FINAL_BOSS: str = "Electrical: BRAINOS (Boss)"
EVENT_LOCATION_GUARD_GONE: str = "Cave status"
EVENT_LOCATION_CLIFF_GONE: str = "Volcanic status"
EVENT_LOCATION_ACE_GONE: str = "Arctic status"
EVENT_LOCATION_SNAKE_GONE: str = "Swamp status"
EVENT_LOCATION_POWER_ON: str = "Generator status"
EVENT_LOCATION_VICTORY: str = "Mission objective"
# region names
REGION_MENU: str = "Menu"
REGION_CAVE: str = "Cave"
REGION_VOLCANIC: str = "Volcanic"
REGION_ARCTIC: str = "Arctic"
REGION_HUB: str = "Hub"
REGION_SWAMP: str = "Swamp"
REGION_ELECTRICAL: str = "Electrical"
REGION_ELECTRICAL_POWERED: str = "Electrical (Power On)"

View File

@ -0,0 +1,98 @@
from typing import Optional, Dict, Tuple
from BaseClasses import Item, ItemClassification as ItemClass
from .Constants import *
class SavingPrincessItem(Item):
game: str = GAME_NAME
class ItemData:
item_class: ItemClass
code: Optional[int]
count: int # Number of copies for the item that will be made of class item_class
count_extra: int # Number of extra copies for the item that will be made as useful
def __init__(self, item_class: ItemClass, code: Optional[int] = None, count: int = 1, count_extra: int = 0):
self.item_class = item_class
self.code = code
if code is not None:
self.code += BASE_ID
# if this is filler, a trap or an event, ignore the count
if self.item_class == ItemClass.filler or self.item_class == ItemClass.trap or code is None:
self.count = 0
self.count_extra = 0
else:
self.count = count
self.count_extra = count_extra
def create_item(self, player: int):
return SavingPrincessItem(item_data_names[self], self.item_class, self.code, player)
item_dict_weapons: Dict[str, ItemData] = {
ITEM_WEAPON_CHARGE: ItemData(ItemClass.progression, 0),
ITEM_WEAPON_FIRE: ItemData(ItemClass.progression, 1),
ITEM_WEAPON_ICE: ItemData(ItemClass.progression, 2),
ITEM_WEAPON_VOLT: ItemData(ItemClass.progression, 3),
}
item_dict_upgrades: Dict[str, ItemData] = {
ITEM_MAX_HEALTH: ItemData(ItemClass.progression, 4, 2, 4),
ITEM_MAX_AMMO: ItemData(ItemClass.progression, 5, 2, 4),
ITEM_RELOAD_SPEED: ItemData(ItemClass.progression, 6, 4, 2),
ITEM_SPECIAL_AMMO: ItemData(ItemClass.useful, 7),
}
item_dict_base: Dict[str, ItemData] = {
**item_dict_weapons,
**item_dict_upgrades,
ITEM_JACKET: ItemData(ItemClass.useful, 8),
}
item_dict_keys: Dict[str, ItemData] = {
EP_ITEM_GUARD_GONE: ItemData(ItemClass.progression, 9),
EP_ITEM_CLIFF_GONE: ItemData(ItemClass.progression, 10),
EP_ITEM_ACE_GONE: ItemData(ItemClass.progression, 11),
EP_ITEM_SNAKE_GONE: ItemData(ItemClass.progression, 12),
}
item_dict_expanded: Dict[str, ItemData] = {
**item_dict_base,
**item_dict_keys,
EP_ITEM_POWER_ON: ItemData(ItemClass.progression, 13),
}
item_dict_filler: Dict[str, ItemData] = {
FILLER_ITEM_HEAL: ItemData(ItemClass.filler, 14),
FILLER_ITEM_QUICK_FIRE: ItemData(ItemClass.filler, 15),
FILLER_ITEM_ACTIVE_CAMO: ItemData(ItemClass.filler, 16),
}
item_dict_traps: Dict[str, ItemData] = {
TRAP_ITEM_ICE: ItemData(ItemClass.trap, 17),
TRAP_ITEM_SHAKES: ItemData(ItemClass.trap, 18),
TRAP_ITEM_NINJA: ItemData(ItemClass.trap, 19),
}
item_dict_events: Dict[str, ItemData] = {
EVENT_ITEM_GUARD_GONE: ItemData(ItemClass.progression),
EVENT_ITEM_CLIFF_GONE: ItemData(ItemClass.progression),
EVENT_ITEM_ACE_GONE: ItemData(ItemClass.progression),
EVENT_ITEM_SNAKE_GONE: ItemData(ItemClass.progression),
EVENT_ITEM_POWER_ON: ItemData(ItemClass.progression),
EVENT_ITEM_VICTORY: ItemData(ItemClass.progression),
}
item_dict: Dict[str, ItemData] = {
**item_dict_expanded,
**item_dict_filler,
**item_dict_traps,
**item_dict_events,
}
item_data_names: Dict[ItemData, str] = {value: key for key, value in item_dict.items()}

View File

@ -0,0 +1,82 @@
from typing import Optional, Dict
from BaseClasses import Location
from .Constants import *
class SavingPrincessLocation(Location):
game: str = GAME_NAME
class LocData:
code: Optional[int]
def __init__(self, code: Optional[int] = None):
if code is not None:
self.code = code + BASE_ID
else:
self.code = None
location_dict_base: Dict[str, LocData] = {
LOCATION_CAVE_AMMO: LocData(0),
LOCATION_CAVE_RELOAD: LocData(1),
LOCATION_CAVE_HEALTH: LocData(2),
LOCATION_CAVE_WEAPON: LocData(3),
LOCATION_VOLCANIC_RELOAD: LocData(4),
LOCATION_VOLCANIC_HEALTH: LocData(5),
LOCATION_VOLCANIC_AMMO: LocData(6),
LOCATION_VOLCANIC_WEAPON: LocData(7),
LOCATION_ARCTIC_AMMO: LocData(8),
LOCATION_ARCTIC_RELOAD: LocData(9),
LOCATION_ARCTIC_HEALTH: LocData(10),
LOCATION_ARCTIC_WEAPON: LocData(11),
LOCATION_JACKET: LocData(12),
LOCATION_HUB_AMMO: LocData(13),
LOCATION_HUB_HEALTH: LocData(14),
LOCATION_HUB_RELOAD: LocData(15),
LOCATION_SWAMP_AMMO: LocData(16),
LOCATION_SWAMP_HEALTH: LocData(17),
LOCATION_SWAMP_RELOAD: LocData(18),
LOCATION_SWAMP_SPECIAL: LocData(19),
LOCATION_ELECTRICAL_RELOAD: LocData(20),
LOCATION_ELECTRICAL_HEALTH: LocData(21),
LOCATION_ELECTRICAL_AMMO: LocData(22),
LOCATION_ELECTRICAL_WEAPON: LocData(23),
}
location_dict_expanded: Dict[str, LocData] = {
**location_dict_base,
EP_LOCATION_CAVE_MINIBOSS: LocData(24),
EP_LOCATION_CAVE_BOSS: LocData(25),
EP_LOCATION_VOLCANIC_BOSS: LocData(26),
EP_LOCATION_ARCTIC_BOSS: LocData(27),
EP_LOCATION_HUB_CONSOLE: LocData(28),
EP_LOCATION_HUB_NINJA_SCARE: LocData(29),
EP_LOCATION_SWAMP_BOSS: LocData(30),
EP_LOCATION_ELEVATOR_NINJA_FIGHT: LocData(31),
EP_LOCATION_ELECTRICAL_EXTRA: LocData(32),
EP_LOCATION_ELECTRICAL_MINIBOSS: LocData(33),
EP_LOCATION_ELECTRICAL_BOSS: LocData(34),
EP_LOCATION_ELECTRICAL_FINAL_BOSS: LocData(35),
}
location_dict_event_expanded: Dict[str, LocData] = {
EVENT_LOCATION_VICTORY: LocData(),
}
# most event locations are only relevant without expanded pool
location_dict_events: Dict[str, LocData] = {
EVENT_LOCATION_GUARD_GONE: LocData(),
EVENT_LOCATION_CLIFF_GONE: LocData(),
EVENT_LOCATION_ACE_GONE: LocData(),
EVENT_LOCATION_SNAKE_GONE: LocData(),
EVENT_LOCATION_POWER_ON: LocData(),
**location_dict_event_expanded,
}
location_dict: Dict[str, LocData] = {
**location_dict_expanded,
**location_dict_events,
}

View File

@ -0,0 +1,183 @@
from dataclasses import dataclass
from typing import Dict, Any
from Options import PerGameCommonOptions, DeathLink, StartInventoryPool, Choice, DefaultOnToggle, Range, Toggle, \
OptionGroup
class ExpandedPool(DefaultOnToggle):
"""
Determines if places other than chests and special weapons will be locations.
This includes boss fights as well as powering the tesla orb and completing the console login.
In Expanded Pool, system power is instead restored when receiving the System Power item.
Similarly, the final area door will open once the four Key items, one for each main area, have been found.
"""
display_name = "Expanded Item Pool"
class InstantSaving(DefaultOnToggle):
"""
When enabled, save points activate with no delay when touched.
This makes saving much faster, at the cost of being unable to pick and choose when to save in order to save warp.
"""
display_name = "Instant Saving"
class SprintAvailability(Choice):
"""
Determines under which conditions the debug sprint is made accessible to the player.
To sprint, hold down Ctrl if playing on keyboard, or Left Bumper if on gamepad (remappable).
With Jacket: you will not be able to sprint until after the Jacket item has been found.
"""
display_name = "Sprint Availability"
option_never_available = 0
option_always_available = 1
option_available_with_jacket = 2
default = option_available_with_jacket
class CliffWeaponUpgrade(Choice):
"""
Determines which weapon Cliff uses against you, base or upgraded.
This does not change the available strategies all that much.
Vanilla: Cliff adds fire to his grenades if Ace has been defeated.
If playing with the expanded pool, the Arctic Key will trigger the change instead.
"""
display_name = "Cliff Weapon Upgrade"
option_never_upgraded = 0
option_always_upgraded = 1
option_vanilla = 2
default = option_always_upgraded
class AceWeaponUpgrade(Choice):
"""
Determines which weapon Ace uses against you, base or upgraded.
Ace with his base weapon is very hard to dodge, the upgraded weapon offers a more balanced experience.
Vanilla: Ace uses ice attacks if Cliff has been defeated.
If playing with the expanded pool, the Volcanic Key will trigger the change instead.
"""
display_name = "Ace Weapon Upgrade"
option_never_upgraded = 0
option_always_upgraded = 1
option_vanilla = 2
default = option_always_upgraded
class ScreenShakeIntensity(Range):
"""
Percentage multiplier for screen shake effects.
0% means the screen will not shake at all.
100% means the screen shake will be the same as in vanilla.
"""
display_name = "Screen Shake Intensity %"
range_start = 0
range_end = 100
default = 50
class IFramesDuration(Range):
"""
Percentage multiplier for Portia's invincibility frames.
0% means you will have no invincibility frames.
100% means invincibility frames will be the same as vanilla.
"""
display_name = "IFrame Duration %"
range_start = 0
range_end = 400
default = 100
class TrapChance(Range):
"""
Likelihood of a filler item becoming a trap.
"""
display_name = "Trap Chance"
range_start = 0
range_end = 100
default = 50
class MusicShuffle(Toggle):
"""
Enables music shuffling.
The title screen song is not shuffled, as it plays before the client connects.
"""
display_name = "Music Shuffle"
@dataclass
class SavingPrincessOptions(PerGameCommonOptions):
# generation options
start_inventory_from_pool: StartInventoryPool
expanded_pool: ExpandedPool
trap_chance: TrapChance
# gameplay options
death_link: DeathLink
instant_saving: InstantSaving
sprint_availability: SprintAvailability
cliff_weapon_upgrade: CliffWeaponUpgrade
ace_weapon_upgrade: AceWeaponUpgrade
iframes_duration: IFramesDuration
# aesthetic options
shake_intensity: ScreenShakeIntensity
music_shuffle: MusicShuffle
groups = [
OptionGroup("Generation Options", [
ExpandedPool,
TrapChance,
]),
OptionGroup("Gameplay Options", [
DeathLink,
InstantSaving,
SprintAvailability,
CliffWeaponUpgrade,
AceWeaponUpgrade,
IFramesDuration,
]),
OptionGroup("Aesthetic Options", [
ScreenShakeIntensity,
MusicShuffle,
]),
]
presets = {
"Vanilla-like": {
"expanded_pool": False,
"trap_chance": 0,
"death_link": False,
"instant_saving": False,
"sprint_availability": SprintAvailability.option_never_available,
"cliff_weapon_upgrade": CliffWeaponUpgrade.option_vanilla,
"ace_weapon_upgrade": AceWeaponUpgrade.option_vanilla,
"iframes_duration": 100,
"shake_intensity": 100,
"music_shuffle": False,
},
"Easy": {
"expanded_pool": True,
"trap_chance": 0,
"death_link": False,
"instant_saving": True,
"sprint_availability": SprintAvailability.option_always_available,
"cliff_weapon_upgrade": CliffWeaponUpgrade.option_never_upgraded,
"ace_weapon_upgrade": AceWeaponUpgrade.option_always_upgraded,
"iframes_duration": 200,
"shake_intensity": 50,
"music_shuffle": False,
},
"Hard": {
"expanded_pool": True,
"trap_chance": 100,
"death_link": True,
"instant_saving": True,
"sprint_availability": SprintAvailability.option_never_available,
"cliff_weapon_upgrade": CliffWeaponUpgrade.option_always_upgraded,
"ace_weapon_upgrade": AceWeaponUpgrade.option_never_upgraded,
"iframes_duration": 50,
"shake_intensity": 100,
"music_shuffle": False,
}
}

View File

@ -0,0 +1,110 @@
from typing import List, Dict
from BaseClasses import MultiWorld, Region, Entrance
from . import Locations
from .Constants import *
region_dict: Dict[str, List[str]] = {
REGION_MENU: [],
REGION_CAVE: [
LOCATION_CAVE_AMMO,
LOCATION_CAVE_RELOAD,
LOCATION_CAVE_HEALTH,
LOCATION_CAVE_WEAPON,
EP_LOCATION_CAVE_MINIBOSS,
EP_LOCATION_CAVE_BOSS,
EVENT_LOCATION_GUARD_GONE,
],
REGION_VOLCANIC: [
LOCATION_VOLCANIC_RELOAD,
LOCATION_VOLCANIC_HEALTH,
LOCATION_VOLCANIC_AMMO,
LOCATION_VOLCANIC_WEAPON,
EP_LOCATION_VOLCANIC_BOSS,
EVENT_LOCATION_CLIFF_GONE,
],
REGION_ARCTIC: [
LOCATION_ARCTIC_AMMO,
LOCATION_ARCTIC_RELOAD,
LOCATION_ARCTIC_HEALTH,
LOCATION_ARCTIC_WEAPON,
LOCATION_JACKET,
EP_LOCATION_ARCTIC_BOSS,
EVENT_LOCATION_ACE_GONE,
],
REGION_HUB: [
LOCATION_HUB_AMMO,
LOCATION_HUB_HEALTH,
LOCATION_HUB_RELOAD,
EP_LOCATION_HUB_CONSOLE,
EP_LOCATION_HUB_NINJA_SCARE,
],
REGION_SWAMP: [
LOCATION_SWAMP_AMMO,
LOCATION_SWAMP_HEALTH,
LOCATION_SWAMP_RELOAD,
LOCATION_SWAMP_SPECIAL,
EP_LOCATION_SWAMP_BOSS,
EVENT_LOCATION_SNAKE_GONE,
],
REGION_ELECTRICAL: [
EP_LOCATION_ELEVATOR_NINJA_FIGHT,
LOCATION_ELECTRICAL_WEAPON,
EP_LOCATION_ELECTRICAL_MINIBOSS,
EP_LOCATION_ELECTRICAL_EXTRA,
EVENT_LOCATION_POWER_ON,
],
REGION_ELECTRICAL_POWERED: [
LOCATION_ELECTRICAL_RELOAD,
LOCATION_ELECTRICAL_HEALTH,
LOCATION_ELECTRICAL_AMMO,
EP_LOCATION_ELECTRICAL_BOSS,
EP_LOCATION_ELECTRICAL_FINAL_BOSS,
EVENT_LOCATION_VICTORY,
],
}
def set_region_locations(region: Region, location_names: List[str], is_pool_expanded: bool):
location_pool = {**Locations.location_dict_base, **Locations.location_dict_events}
if is_pool_expanded:
location_pool = {**Locations.location_dict_expanded, **Locations.location_dict_event_expanded}
region.locations = [
Locations.SavingPrincessLocation(
region.player,
name,
Locations.location_dict[name].code,
region
) for name in location_names if name in location_pool.keys()
]
def create_regions(multiworld: MultiWorld, player: int, is_pool_expanded: bool):
for region_name, location_names in region_dict.items():
region = Region(region_name, player, multiworld)
set_region_locations(region, location_names, is_pool_expanded)
multiworld.regions.append(region)
connect_regions(multiworld, player)
def connect_regions(multiworld: MultiWorld, player: int):
# and add a connection from the menu to the hub region
menu = multiworld.get_region(REGION_MENU, player)
hub = multiworld.get_region(REGION_HUB, player)
connection = Entrance(player, f"{REGION_HUB} entrance", menu)
menu.exits.append(connection)
connection.connect(hub)
# now add an entrance from every other region to hub
for region_name in [REGION_CAVE, REGION_VOLCANIC, REGION_ARCTIC, REGION_SWAMP, REGION_ELECTRICAL]:
connection = Entrance(player, f"{region_name} entrance", hub)
hub.exits.append(connection)
connection.connect(multiworld.get_region(region_name, player))
# and finally, the connection between the final region and its powered version
electrical = multiworld.get_region(REGION_ELECTRICAL, player)
connection = Entrance(player, f"{REGION_ELECTRICAL_POWERED} entrance", electrical)
electrical.exits.append(connection)
connection.connect(multiworld.get_region(REGION_ELECTRICAL_POWERED, player))

View File

@ -0,0 +1,132 @@
from typing import TYPE_CHECKING
from BaseClasses import CollectionState, Location, Entrance
from worlds.generic.Rules import set_rule
from .Constants import *
if TYPE_CHECKING:
from . import SavingPrincessWorld
def set_rules(world: "SavingPrincessWorld"):
def get_location(name: str) -> Location:
return world.get_location(name)
def get_region_entrance(name: str) -> Entrance:
return world.get_entrance(f"{name} entrance")
def can_hover(state: CollectionState) -> bool:
# portia can hover if she has a weapon other than the powered blaster and 4 reload speed upgrades
return (
state.has(ITEM_RELOAD_SPEED, world.player, 4)
and state.has_any({ITEM_WEAPON_FIRE, ITEM_WEAPON_ICE, ITEM_WEAPON_VOLT}, world.player)
)
# guarantees that the player will have some upgrades before having to face the area bosses, except for cave
def nice_check(state: CollectionState) -> bool:
return (
state.has(ITEM_MAX_HEALTH, world.player)
and state.has(ITEM_MAX_AMMO, world.player)
and state.has(ITEM_RELOAD_SPEED, world.player, 2)
)
# same as above, but for the final area
def super_nice_check(state: CollectionState) -> bool:
return (
state.has(ITEM_MAX_HEALTH, world.player, 2)
and state.has(ITEM_MAX_AMMO, world.player, 2)
and state.has(ITEM_RELOAD_SPEED, world.player, 4)
and state.has(ITEM_WEAPON_CHARGE, world.player)
# at least one special weapon, other than powered blaster
and state.has_any({ITEM_WEAPON_FIRE, ITEM_WEAPON_ICE, ITEM_WEAPON_VOLT}, world.player)
)
# all special weapons required so that the boss' weapons can be targeted
def all_weapons(state: CollectionState) -> bool:
return state.has_all({ITEM_WEAPON_FIRE, ITEM_WEAPON_ICE, ITEM_WEAPON_VOLT}, world.player)
def is_gate_unlocked(state: CollectionState) -> bool:
# the gate unlocks with all 4 boss keys, although this only applies to extended pool
if world.is_pool_expanded:
# in expanded, the final area requires all the boss keys
return (
state.has_all(
{EP_ITEM_GUARD_GONE, EP_ITEM_CLIFF_GONE, EP_ITEM_ACE_GONE, EP_ITEM_SNAKE_GONE},
world.player
) and super_nice_check(state)
)
else:
# in base pool, check that the main area bosses can be defeated
return state.has_all(
{EVENT_ITEM_GUARD_GONE, EVENT_ITEM_CLIFF_GONE, EVENT_ITEM_ACE_GONE, EVENT_ITEM_SNAKE_GONE},
world.player
) and super_nice_check(state)
def is_power_on(state: CollectionState) -> bool:
# in expanded pool, the power item is what determines this, else it happens when the generator is powered
return state.has(EP_ITEM_POWER_ON if world.is_pool_expanded else EVENT_ITEM_POWER_ON, world.player)
# set the location rules
# this is behind the blast door to arctic
set_rule(get_location(LOCATION_HUB_AMMO), lambda state: state.has(ITEM_WEAPON_CHARGE, world.player))
# these are behind frozen doors
for location_name in [LOCATION_ARCTIC_HEALTH, LOCATION_JACKET]:
set_rule(get_location(location_name), lambda state: state.has(ITEM_WEAPON_FIRE, world.player))
# these would require damage boosting otherwise
set_rule(get_location(LOCATION_VOLCANIC_RELOAD),
lambda state: state.has(ITEM_WEAPON_ICE, world.player) or can_hover(state))
set_rule(get_location(LOCATION_SWAMP_AMMO), lambda state: can_hover(state))
if world.is_pool_expanded:
# does not spawn until the guard has been defeated
set_rule(get_location(EP_LOCATION_HUB_NINJA_SCARE), lambda state: state.has(EP_ITEM_GUARD_GONE, world.player))
# generator cannot be turned on without the volt laser
set_rule(
get_location(EP_LOCATION_ELECTRICAL_EXTRA if world.is_pool_expanded else EVENT_LOCATION_POWER_ON),
lambda state: state.has(ITEM_WEAPON_VOLT, world.player)
)
# the roller is not very intuitive to get past without 4 ammo
set_rule(get_location(LOCATION_CAVE_WEAPON), lambda state: state.has(ITEM_MAX_AMMO, world.player))
set_rule(
get_location(EP_LOCATION_CAVE_BOSS if world.is_pool_expanded else EVENT_LOCATION_GUARD_GONE),
lambda state: state.has(ITEM_MAX_AMMO, world.player)
)
# guarantee some upgrades to be found before bosses
boss_locations = [LOCATION_VOLCANIC_WEAPON, LOCATION_ARCTIC_WEAPON, LOCATION_SWAMP_SPECIAL]
if world.is_pool_expanded:
boss_locations += [EP_LOCATION_VOLCANIC_BOSS, EP_LOCATION_ARCTIC_BOSS, EP_LOCATION_SWAMP_BOSS]
else:
boss_locations += [EVENT_LOCATION_CLIFF_GONE, EVENT_LOCATION_ACE_GONE, EVENT_LOCATION_SNAKE_GONE]
for location_name in boss_locations:
set_rule(get_location(location_name), lambda state: nice_check(state))
# set the basic access rules for the regions, these are all behind blast doors
for region_name in [REGION_VOLCANIC, REGION_ARCTIC, REGION_SWAMP]:
set_rule(get_region_entrance(region_name), lambda state: state.has(ITEM_WEAPON_CHARGE, world.player))
# now for the final area regions, which have different rules based on if ep is on
set_rule(get_region_entrance(REGION_ELECTRICAL), lambda state: is_gate_unlocked(state))
set_rule(get_region_entrance(REGION_ELECTRICAL_POWERED), lambda state: is_power_on(state))
# brainos requires all weapons, cannot destroy the cannons otherwise
if world.is_pool_expanded:
set_rule(get_location(EP_LOCATION_ELECTRICAL_FINAL_BOSS), lambda state: all_weapons(state))
# and we need to beat brainos to beat the game
set_rule(get_location(EVENT_LOCATION_VICTORY), lambda state: all_weapons(state))
# if not expanded pool, place the events for the boss kills and generator
if not world.is_pool_expanded:
# accessible with no items
cave_item = world.create_item(EVENT_ITEM_GUARD_GONE)
get_location(EVENT_LOCATION_GUARD_GONE).place_locked_item(cave_item)
volcanic_item = world.create_item(EVENT_ITEM_CLIFF_GONE)
get_location(EVENT_LOCATION_CLIFF_GONE).place_locked_item(volcanic_item)
arctic_item = world.create_item(EVENT_ITEM_ACE_GONE)
get_location(EVENT_LOCATION_ACE_GONE).place_locked_item(arctic_item)
swamp_item = world.create_item(EVENT_ITEM_SNAKE_GONE)
get_location(EVENT_LOCATION_SNAKE_GONE).place_locked_item(swamp_item)
power_item = world.create_item(EVENT_ITEM_POWER_ON)
get_location(EVENT_LOCATION_POWER_ON).place_locked_item(power_item)
# and, finally, set the victory event
victory_item = world.create_item(EVENT_ITEM_VICTORY)
get_location(EVENT_LOCATION_VICTORY).place_locked_item(victory_item)
world.multiworld.completion_condition[world.player] = lambda state: state.has(EVENT_ITEM_VICTORY, world.player)

View File

@ -0,0 +1,174 @@
from typing import ClassVar, Dict, Any, Type, List, Union
import Utils
from BaseClasses import Tutorial, ItemClassification as ItemClass
from Options import PerGameCommonOptions, OptionError
from settings import Group, UserFilePath, LocalFolderPath, Bool
from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import components, Component, launch_subprocess, Type as ComponentType
from . import Options, Items, Locations
from .Constants import *
def launch_client(*args: str):
from .Client import launch
launch_subprocess(launch(*args), name=CLIENT_NAME)
components.append(
Component(f"{GAME_NAME} Client", game_name=GAME_NAME, func=launch_client, component_type=ComponentType.CLIENT, supports_uri=True)
)
class SavingPrincessSettings(Group):
class GamePath(UserFilePath):
"""Path to the game executable from which files are extracted"""
description = "the Saving Princess game executable"
is_exe = True
md5s = [GAME_HASH]
class InstallFolder(LocalFolderPath):
"""Path to the mod installation folder"""
description = "the folder to install Saving Princess Archipelago to"
class LaunchGame(Bool):
"""Set this to false to never autostart the game"""
class LaunchCommand(str):
"""
The console command that will be used to launch the game
The command will be executed with the installation folder as the current directory
"""
exe_path: GamePath = GamePath("Saving Princess.exe")
install_folder: InstallFolder = InstallFolder("Saving Princess")
launch_game: Union[LaunchGame, bool] = True
launch_command: LaunchCommand = LaunchCommand('"Saving Princess v0_8.exe"' if Utils.is_windows
else 'wine "Saving Princess v0_8.exe"')
class SavingPrincessWeb(WebWorld):
theme = "partyTime"
bug_report_page = "https://github.com/LeonarthCG/saving-princess-archipelago/issues"
setup_en = Tutorial(
"Multiworld Setup Guide",
"A guide to setting up Saving Princess for Archipelago multiworld.",
"English",
"setup_en.md",
"setup/en",
["LeonarthCG"]
)
tutorials = [setup_en]
options_presets = Options.presets
option_groups = Options.groups
class SavingPrincessWorld(World):
"""
Explore a space station crawling with rogue machines and even rival bounty hunters
with the same objective as you - but with far, far different intentions!
Expand your arsenal as you collect upgrades to your trusty arm cannon and armor!
""" # Excerpt from itch
game = GAME_NAME
web = SavingPrincessWeb()
required_client_version = (0, 5, 0)
topology_present = False
item_name_to_id = {
key: value.code for key, value in (Items.item_dict.items() - Items.item_dict_events.items())
}
location_name_to_id = {
key: value.code for key, value in (Locations.location_dict.items() - Locations.location_dict_events.items())
}
item_name_groups = {
"Weapons": {key for key in Items.item_dict_weapons.keys()},
"Upgrades": {key for key in Items.item_dict_upgrades.keys()},
"Keys": {key for key in Items.item_dict_keys.keys()},
"Filler": {key for key in Items.item_dict_filler.keys()},
"Traps": {key for key in Items.item_dict_traps.keys()},
}
options_dataclass: ClassVar[Type[PerGameCommonOptions]] = Options.SavingPrincessOptions
options: Options.SavingPrincessOptions
settings_key = "saving_princess_settings"
settings: ClassVar[SavingPrincessSettings]
is_pool_expanded: bool = False
music_table: List[int] = list(range(16))
def generate_early(self) -> None:
if not self.player_name.isascii():
raise OptionError(f"{self.player_name}'s name must be only ASCII.")
self.is_pool_expanded = self.options.expanded_pool > 0
if self.options.music_shuffle:
self.random.shuffle(self.music_table)
# find zzz and purple and swap them back to their original positions
for song_id in [9, 13]:
song_index = self.music_table.index(song_id)
t = self.music_table[song_id]
self.music_table[song_id] = song_id
self.music_table[song_index] = t
def create_regions(self) -> None:
from .Regions import create_regions
create_regions(self.multiworld, self.player, self.is_pool_expanded)
def create_items(self) -> None:
items_made: int = 0
# now, for each item
item_dict = Items.item_dict_expanded if self.is_pool_expanded else Items.item_dict_base
for item_name, item_data in item_dict.items():
# create count copies of the item
for i in range(item_data.count):
self.multiworld.itempool.append(self.create_item(item_name))
items_made += item_data.count
# and create count_extra useful copies of the item
original_item_class: ItemClass = item_data.item_class
item_data.item_class = ItemClass.useful
for i in range(item_data.count_extra):
self.multiworld.itempool.append(self.create_item(item_name))
item_data.item_class = original_item_class
items_made += item_data.count_extra
# get the number of unfilled locations, that is, locations for items - items generated
location_count = len(Locations.location_dict_base)
if self.is_pool_expanded:
location_count = len(Locations.location_dict_expanded)
junk_count: int = location_count - items_made
# and generate as many junk items as unfilled locations
for i in range(junk_count):
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
def create_item(self, name: str) -> Items.SavingPrincessItem:
return Items.item_dict[name].create_item(self.player)
def get_filler_item_name(self) -> str:
filler_list = list(Items.item_dict_filler.keys())
# check if this is going to be a trap
if self.random.randint(0, 99) < self.options.trap_chance:
filler_list = list(Items.item_dict_traps.keys())
# and return one of the names at random
return self.random.choice(filler_list)
def set_rules(self):
from .Rules import set_rules
set_rules(self)
def fill_slot_data(self) -> Dict[str, Any]:
slot_data = self.options.as_dict(
"death_link",
"expanded_pool",
"instant_saving",
"sprint_availability",
"cliff_weapon_upgrade",
"ace_weapon_upgrade",
"shake_intensity",
"iframes_duration",
)
slot_data["music_table"] = self.music_table
return slot_data

View File

@ -0,0 +1,55 @@
# Saving Princess
## Quick Links
- [Setup Guide](/tutorial/Saving%20Princess/setup/en)
- [Options Page](/games/Saving%20Princess/player-options)
- [Saving Princess Archipelago GitHub](https://github.com/LeonarthCG/saving-princess-archipelago)
## What changes have been made?
The game has had several changes made to add new features and prevent issues. The most important changes are the following:
- There is an in-game connection settings menu, autotracker and client console.
- New save files are created and used automatically for each seed and slot played.
- The game window can now be dragged and a new integer scaling option has been added.
## What items and locations get shuffled?
The chest contents and special weapons are the items and locations that get shuffled.
Additionally, there are new items to work as filler and traps, ranging from a full health and ammo restore to spawning a Ninja on top of you.
The Expanded Pool option, which is enabled by default, adds a few more items and locations:
- Completing the intro sequence, powering the generator with the Volt Laser and defeating each boss become locations.
- 4 Keys will be shuffled, which serve to open the door to the final area in place of defeating the main area bosses.
- A System Power item will be shuffled, which restores power to the final area instead of this happening when the generator is powered.
## What does another world's item look like in Saving Princess?
Some locations, such as boss kills, have no visual representation, but those that do will have the Archipelago icon.
Once the item is picked up, a textbox will inform you of the item that was found as well as the player that will be receiving it.
These textboxes will have colored backgrounds and comments about the item category.
For example, progression items will have a purple background and say "Looks plenty important!".
## When the player receives an item, what happens?
When you receive an item, a textbox will show up.
This textbox shows both which item you got and which player sent it to you.
If you send an item to yourself, however, the sending player will be omitted.
## Unique Local Commands
The following commands are only available when using the in-game console in Saving Princess:
- `/help` Returns the help listing.
- `/options` Lists currently applied options.
- `/resync` Manually triggers a resync. This also resends all found locations.
- `/unstuck` Sets save point to the first save point. Portia is then killed.
- `/deathlink [on|off]` Toggles or sets death link mode.
- `/instantsaving [on|off]` Toggles or sets instant saving.
- `/sprint {never|always|jacket}` Sets sprint mode.
- `/cliff {never|always|vanilla}` Sets Cliff's weapon upgrade condition.
- `/ace {never|always|vanilla}` Sets Ace's weapon upgrade condition.
- `/iframes n` Sets the iframe duration % multiplier to n, where 0 <= n <= 400.
- `/shake n` Sets the shake intensity % multiplier to n, where 0 <= n <= 100.

View File

@ -0,0 +1,148 @@
# Saving Princess Setup Guide
## Quick Links
- [Game Info](/games/Saving%20Princess/info/en)
- [Options Page](/games/Saving%20Princess/player-options)
- [Saving Princess Archipelago GitHub](https://github.com/LeonarthCG/saving-princess-archipelago)
## Installation Procedures
### Automated Installation
*These instructions have only been tested on Windows and Ubuntu.*
Once everything is set up, it is recommended to continue launching the game through this method, as it will check for any updates to the mod and automatically apply them.
This is also the method used by the Automatic Connection described further below.
1. Purchase and download [Saving Princess](https://brainos.itch.io/savingprincess)
2. Download and install the latest [Archipelago release](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
3. Launch `ArchipelagoLauncher` and click on "Saving Princess Client"
* You will probably need to scroll down on the Clients column to see it
4. Follow the prompts
* On Linux, you will need one of either Wine or 7z for the automated installation
When launching the game, Windows machines will simply run the executable. For any other OS, the launcher defaults to trying to run the game through Wine. You can change this by modifying the `launch_command` in `options.yaml` or `host.yaml`, under the `saving_princess_settings` section.
### Manual Windows Installation
Required software:
- Saving Princess, found at its [itch.io Store Page](https://brainos.itch.io/savingprincess)
- `saving_princess_basepatch.bsdiff4` and `gm-apclientpp.dll`, from [saving_princess_archipelago.zip](https://github.com/LeonarthCG/saving-princess-archipelago/releases/latest)
- Software that can decompress the previous files, such as [7-zip](https://www.7-zip.org/download.html)
- A way to apply `.bsdiff4` patches, such as [bspatch](https://www.romhacking.net/utilities/929/)
Steps:
1. Extract all files from `Saving Princess.exe`, as if it were a `.7z` file
* Feel free to rename `Saving Princess.exe` to `Saving Princess.exe.7z` if needed
* If installed through the itch app, you can find the installation directory from the game's page, pressing the cog button, then "Manage" and finally "Open folder in explorer"
2. Extract all files from `saving_princess_archipelago.zip` into the same directory as the files extracted in the previous step
* This should include, at least, `saving_princess_basepatch.bsdiff4` and `gm-apclientpp.dll`
3. If you don't have `original_data.win`, copy `data.win` and rename its copy to `original_data.win`
* By keeping an unmodified copy of `data.win`, you will have an easier time updating in the future
4. Apply the `saving_princess_basepatch.bsdiff4` patch using your patching software
5. To launch the game, run `Saving Princess v0_8.exe`
### Manual Linux Installation
*These instructions have only been tested on Ubuntu.*
The game does run mostly well through Wine, so it is possible to play on Linux, although there are some minor sprite displacement and sound issues from time to time.
You can follow the instructions for Windows with very few changes:
* Using the `p7zip-full` package to decompress the file.
```
7z e 'Saving Princess.exe'
```
* And the `bsdiff` package for patching.
```
bspatch original_data.win data.win saving_princess_basepatch.bsdiff4
```
## Configuring your YAML file
### What is a YAML file and why do I need one?
See the guide on setting up a basic YAML at the Archipelago setup
guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en).
### Where do I get a YAML file?
You can customize your options by visiting the [Saving Princess Player Options Page](/games/Saving%20Princess/player-options).
### Verifying your YAML file
If you would like to validate your config file to make sure it works, you may do so on the YAML Validator page. YAML
validator page: [YAML Validation page](/check).
## Joining a MultiWorld Game
### Automatic Connection on archipelago.gg
1. Go to the room page of the MultiWorld you are going to join.
2. Click on your slot name on the left side.
3. Click the "Saving Princess Client" button in the prompt.
* This launches the same client described in the Automated Installation section.
4. Upon reaching the title screen, a connection attempt will automatically be started.
Note that this updates your Saving Princess saved connection details, which are described in the Manual Connection section.
### Manual Connection
After launching the game, enter the Archipelago options menu through the in-game button with the Archipelago icon.
From here, enter the different menus and type in the following details in their respective fields:
- **server:port** (e.g. `archipelago.gg:38281`)
* If hosting on the website, this detail will be shown in your created room.
- **slot name** (e.g. `Player`)
* This is your player name, which you chose along with your player options.
- **password** (e.g. `123456`)
* If the room does not have a password, it can be left empty.
This configuration persists through launches and even updates.
With your settings filled, start a connection attempt by pressing on the title screen's "CONNECT!" button.
Once connected, the button will become one of either "NEW GAME" or "CONTINUE".
The game automatically keeps a save file for each seed and slot combination, so you do not need to manually move or delete save files.
All that's left is pressing on the button again to start playing. If you are waiting for a countdown, press "NEW GAME" when the countdown finishes.
## Gameplay Questions
### Do I need to save the game before I stop playing?
It is safe to close the game at any point while playing, your progress will be kept.
### What happens if I lose connection?
If a disconnection occurs, you will see the HUD connection indicator go grey.
From here, the game will automatically try to reconnect.
You can tell it succeeded if the indicator regains its color.
If the game is unable to reconnect, save and restart.
Although you can keep playing while disconnected, you won't get any items until you reconnect, not even items found in your own game.
Once reconnected, however, all of your progress will sync up.
### I got an item, but it did not say who sent it to me
Items sent to you by yourself do not list the sender.
Additionally, if you get an item while already having the max for that item (for example, you have 9 ammo and get sent a Clip Extension), no message will be shown at all.
### I pressed the release/collect button, but nothing happened
It is likely that you do not have release or collect permissions, or that there is nothing to release or collect.
Another option is that your connection was interrupted.
If you would still like to use release or collect, refer to [this section of the server commands page](https://archipelago.gg/tutorial/Archipelago/commands/en#collect/release).
You may use the in-game console to execute the commands, if your slot has permissions to do so.
### I am trying to configure my controller, but the menu keeps closing itself
Steam Input will make your controller behave as a keyboard and mouse even while not playing any Steam games.
To fix this, simply close Steam while playing Saving Princess.
Another option is to disable Steam Input under `Steam -> Settings -> Controller -> External Gamepad Settings`