Compare commits

..

3 Commits

Author SHA1 Message Date
NewSoupVi
f95f3430b9 Update CommonClient.py 2025-10-08 09:22:19 +02:00
NewSoupVi
0e5a0e490c Update CommonClient.py 2025-10-08 09:21:57 +02:00
NewSoupVi
4f0a67295e CommonClient: Fix manually connecting to a url when the username or password has a space in it 2025-10-08 09:21:08 +02:00
15 changed files with 104 additions and 215 deletions

View File

@@ -3,6 +3,9 @@ ModuleUpdate.update()
import Utils
if __name__ == "__main__":
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
import asyncio
import base64
import binascii
@@ -23,14 +26,16 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
from NetUtils import ClientStatus
from . import LinksAwakeningWorld
from .Common import BASE_ID as LABaseID
from .GpsTracker import GpsTracker
from .TrackerConsts import storage_key
from .ItemTracker import ItemTracker
from .LADXR.checkMetadata import checkMetadataTable
from .Locations import get_locations_to_id, meta_to_name
from .Tracker import LocationTracker, MagpieBridge, Check
from worlds.ladx import LinksAwakeningWorld
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
class GameboyException(Exception):
pass
@@ -755,44 +760,42 @@ def run_game(romfile: str) -> None:
except FileNotFoundError:
logger.error(f"Couldn't launch ROM, {args[0]} is missing")
def launch(*launch_args):
async def main():
parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
async def main():
parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args(launch_args)
args = parser.parse_args()
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
# TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
# TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
# Down below run_gui so that we get errors out of the process
if args.diff_file:
run_game(rom_file)
# Down below run_gui so that we get errors out of the process
if args.diff_file:
run_game(rom_file)
await ctx.exit_event.wait()
await ctx.shutdown()
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
await ctx.exit_event.wait()
await ctx.shutdown()
if __name__ == '__main__':
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

View File

@@ -135,7 +135,6 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint):
__slots__ = (
"__weakref__",
"version",
"auth",
"team",
@@ -217,7 +216,6 @@ class Context:
"release_mode": str,
"remaining_mode": str,
"collect_mode": str,
"countdown_mode": str,
"item_cheat": bool,
"compatibility": int}
# team -> slot id -> list of clients authenticated to slot.
@@ -247,8 +245,8 @@ class Context:
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger
super(Context, self).__init__()
self.slot_info = {}
@@ -281,7 +279,6 @@ class Context:
self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode
self.countdown_mode: str = countdown_mode
self.item_cheat = item_cheat
self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[
@@ -667,7 +664,6 @@ class Context:
"server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
"countdown_mode": self.countdown_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
}
@@ -702,7 +698,6 @@ class Context:
self.release_mode = savedata["game_options"]["release_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"]
@@ -1534,23 +1529,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
" You can ask the server admin for a /collect")
return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
if self.ctx.countdown_mode == "disabled" or \
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
return False
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
@@ -2511,11 +2489,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif option_name == "countdown_mode":
valid_values = {"enabled", "disabled", "auto"}
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
@@ -2603,13 +2576,6 @@ def parse_args() -> argparse.Namespace:
goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion
''')
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
choices=['enabled', 'disabled', "auto"], help='''\
Select !countdown Accessibility. (default: %(default)s)
enabled: !countdown is always available
disabled: !countdown is never available
auto: !countdown is available for rooms with less than 30 players
''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s)
@@ -2675,7 +2641,7 @@ async def main(args: argparse.Namespace):
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
args.countdown_mode, args.remaining_mode,
args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata

View File

@@ -1474,10 +1474,8 @@ class ItemLinks(OptionList):
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()
for link in self.value:
link["name"] = link["name"].strip()[:16].strip()
if link["name"] in existing_links:
raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
f"You have more than one link named '{link['name']}'.")
raise Exception(f"You cannot have more than one link named {link['name']}.")
existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)

View File

@@ -18,7 +18,7 @@ from json import loads, dumps
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils
import settings
from settings import Settings
from Utils import async_start
from MultiServer import mark_raw
if typing.TYPE_CHECKING:
@@ -286,7 +286,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None:
sni_path = settings.get_settings().sni_options.sni_path
sni_path = Settings.sni_options.sni_path
if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path)
@@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
async def run_game(romfile: str) -> None:
auto_start = settings.get_settings().sni_options.snes_rom_start
auto_start = Settings.sni_options.snes_rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -33,7 +33,6 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)),
}

View File

@@ -23,61 +23,18 @@ Metadata about the apworld is defined in an `archipelago.json` file inside the z
The current format version has at minimum:
```json
{
"version": 7,
"compatible_version": 7,
"version": 6,
"compatible_version": 5,
"game": "Game Name"
}
```
The `version` and `compatible_version` fields refer to Archipelago's internal file packaging scheme
and get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
["Build apworlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your .apworld as a world developer. Do not write these fields yourself.
On the other hand, the `game` field should be present in the world folder's manifest file before packaging.
There are also the following optional fields:
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded.
Archipelago version respectively to filter those files from being loaded
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An apworld without a world_version is always treated as older than one with a version.
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
package managers. Should always be a list of strings.
An apworld without a world_version is always treated as older than one with a version
### "Build apworlds" Launcher Component
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
So, a world folder with an `archipelago.json` that looks like this:
```json
{
"game": "Game Name",
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"]
}
```
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
```json
{
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"],
"version": 7,
"compatible_version": 7,
"game": "Game Name"
}
```
This is the recommended workflow for packaging your world to an `.apworld`.
## Extra Data

View File

@@ -180,8 +180,8 @@ Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{a
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";

View File

@@ -579,17 +579,6 @@ class ServerOptions(Group):
"goal" -> Client can ask for remaining items after goal completion
"""
class CountdownMode(str):
"""
Countdown modes
Determines whether or not a player can initiate a countdown with !countdown
Note that /countdown is always available to the host.
"enabled" -> Client can always initiate a countdown with !countdown.
"disabled" -> Client can never initiate a countdown with !countdown.
"auto" -> !countdown will be available for any room with less than 30 slots.
"""
class AutoShutdown(int):
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
@@ -624,7 +613,6 @@ class ServerOptions(Group):
release_mode: ReleaseMode = ReleaseMode("auto")
collect_mode: CollectMode = CollectMode("auto")
remaining_mode: RemainingMode = RemainingMode("goal")
countdown_mode: CountdownMode = CountdownMode("auto")
auto_shutdown: AutoShutdown = AutoShutdown(0)
compatibility: Compatibility = Compatibility(2)
log_network: LogNetwork = LogNetwork(0)

View File

@@ -1,12 +1,4 @@
def run_locations_benchmark(freeze_gc: bool = True) -> None:
"""
Run a benchmark of location access rule performance against an empty_state and an all_state.
:param freeze_gc: Whether to freeze gc before benchmarking and unfreeze gc afterward. Freezing gc moves all objects
tracked by the garbage collector to a permanent generation, ignoring them in all future collections. Freezing
greatly reduces the duration of running gc.collect() within benchmarks, which otherwise often takes much longer
than running all iterations for the location rule being benchmarked.
"""
def run_locations_benchmark():
import argparse
import logging
import gc
@@ -42,8 +34,6 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
if freeze_gc:
gc.freeze()
with TimeIt(f"{test_location.game} {self.rule_iterations} "
f"runs of {test_location}.access_rule({state_name})", logger) as t:
for _ in range(self.rule_iterations):
@@ -51,8 +41,6 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
# if time is taken to disentangle complex ref chains,
# this time should be attributed to the rule.
gc.collect()
if freeze_gc:
gc.unfreeze()
return t.dif
def main(self):
@@ -76,13 +64,9 @@ def run_locations_benchmark(freeze_gc: bool = True) -> None:
gc.collect()
for step in self.gen_steps:
if freeze_gc:
gc.freeze()
with TimeIt(f"{game} step {step}", logger):
call_all(multiworld, step)
gc.collect()
if freeze_gc:
gc.unfreeze()
locations = sorted(multiworld.get_unfilled_locations())
if not locations:

View File

@@ -217,6 +217,8 @@ components: List[Component] = [
description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Ocarina of Time
Component('OoT Client', 'OoTClient',

View File

@@ -3,7 +3,7 @@ from dataclasses import dataclass
import os.path
import typing
import logging
from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed, StartInventoryPool
from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed
from collections import defaultdict
import Utils
@@ -665,7 +665,6 @@ class LinksAwakeningOptions(PerGameCommonOptions):
tarins_gift: TarinsGift
overworld: Overworld
stabilize_item_pool: StabilizeItemPool
start_inventory_from_pool: StartInventoryPool
warp_improvements: Removed
additional_warp_points: Removed

View File

@@ -9,7 +9,6 @@ import settings
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial
from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World
from worlds.LauncherComponents import Component, components, SuffixIdentifier, Type, launch, icon_paths
from .Common import *
from . import ItemIconGuessing
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
@@ -30,19 +29,6 @@ from .Rom import LADXProcedurePatch, write_patch_data
DEVELOPER_MODE = False
def launch_client(*args):
from .LinksAwakeningClient import launch as ladx_launch
launch(ladx_launch, name=f"{LINKS_AWAKENING} Client", args=args)
components.append(Component(f"{LINKS_AWAKENING} Client",
func=launch_client,
component_type=Type.CLIENT,
icon=LINKS_AWAKENING,
file_identifier=SuffixIdentifier('.apladx')))
icon_paths[LINKS_AWAKENING] = "ap:worlds.ladx/assets/MarinV-3_small.png"
class LinksAwakeningSettings(settings.Group):
class RomFile(settings.UserFilePath):
"""File name of the Link's Awakening DX rom"""
@@ -225,6 +211,8 @@ class LinksAwakeningWorld(World):
def create_items(self) -> None:
itempool = []
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
self.prefill_own_dungeons = []
self.pre_fill_items = []
@@ -241,46 +229,50 @@ class LinksAwakeningWorld(World):
continue
item_name = ladxr_item_to_la_item_name[ladx_item_name]
for _ in range(count):
item = self.create_item(item_name)
if not self.options.tradequest and isinstance(item.item_data, TradeItemData):
location = self.multiworld.get_location(item.item_data.vanilla_location, self.player)
location.place_locked_item(item)
location.show_in_spoiler = False
continue
if isinstance(item.item_data, DungeonItemData):
item_type = item.item_data.ladxr_id[:-1]
shuffle_type = self.dungeon_item_types[item_type]
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
# Find instrument, lock
# TODO: we should be able to pinpoint the region we want, save a lookup table please
found = False
for r in self.multiworld.get_regions(self.player):
if r.dungeon_index != item.item_data.dungeon_index:
continue
for loc in r.locations:
if not isinstance(loc, LinksAwakeningLocation):
continue
if not isinstance(loc.ladxr_item, Instrument):
continue
loc.place_locked_item(item)
found = True
break
if found:
break
else:
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
self.pre_fill_items.append(item)
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
self.prefill_own_dungeons.append(item)
self.pre_fill_items.append(item)
else:
itempool.append(item)
if item_name in exclude:
exclude.remove(item_name) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
else:
itempool.append(item)
item = self.create_item(item_name)
if not self.options.tradequest and isinstance(item.item_data, TradeItemData):
location = self.multiworld.get_location(item.item_data.vanilla_location, self.player)
location.place_locked_item(item)
location.show_in_spoiler = False
continue
if isinstance(item.item_data, DungeonItemData):
item_type = item.item_data.ladxr_id[:-1]
shuffle_type = self.dungeon_item_types[item_type]
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
# Find instrument, lock
# TODO: we should be able to pinpoint the region we want, save a lookup table please
found = False
for r in self.multiworld.get_regions(self.player):
if r.dungeon_index != item.item_data.dungeon_index:
continue
for loc in r.locations:
if not isinstance(loc, LinksAwakeningLocation):
continue
if not isinstance(loc.ladxr_item, Instrument):
continue
loc.place_locked_item(item)
found = True
break
if found:
break
else:
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
self.pre_fill_items.append(item)
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
self.prefill_own_dungeons.append(item)
self.pre_fill_items.append(item)
else:
itempool.append(item)
else:
itempool.append(item)
self.multi_key = self.generate_multi_key()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -50,6 +50,7 @@ on the Archipelago website to generate a YAML using a graphical interface.
significantly more difficult with this mod, so it is recommended to choose a lower difficulty than you normally would
play on.
4. Open the world in single player or multiplayer.
5. When you're ready, open chat, and enter `/apstart` to start the game.
## Commands