Skip to content

Commit

Permalink
Merge branch 'main' into tunc-logic-rules-redux
Browse files Browse the repository at this point in the history
  • Loading branch information
ScipioWright authored Jul 5, 2024
2 parents e29313a + 315e0c8 commit 9b2377e
Show file tree
Hide file tree
Showing 21 changed files with 457 additions and 316 deletions.
4 changes: 2 additions & 2 deletions MultiServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1352,7 +1352,7 @@ def _cmd_remaining(self) -> bool:
if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
Expand All @@ -1365,7 +1365,7 @@ def _cmd_remaining(self) -> bool:
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id]
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
Expand Down
53 changes: 40 additions & 13 deletions WebHostLib/misc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import datetime
import os
from typing import List, Dict, Union
from typing import Any, IO, Dict, Iterator, List, Tuple, Union

import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
Expand Down Expand Up @@ -97,25 +97,37 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id))


def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
marker = log.read(3) # skip optional BOM
if marker != b'\xEF\xBB\xBF':
log.seek(0, os.SEEK_SET)
log.seek(offset, os.SEEK_CUR)
yield from log
log.close() # free file handle as soon as possible


@app.route('/log/<suuid:room>')
def display_log(room: UUID):
def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt")
if os.path.exists(file_path):
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
return "Log File does not exist."
try:
log = open(file_path, "rb")
range_header = request.headers.get("Range")
if range_header:
range_type, range_values = range_header.split('=')
start, end = map(str.strip, range_values.split('-', 1))
if range_type != "bytes" or end != "":
return "Unsupported range", 500
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
return Response(_read_log(log), mimetype="text/plain")
except FileNotFoundError:
return Response(f"Logfile {file_path} does not exist. "
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
mimetype="text/plain")

return "Access Denied", 403

Expand All @@ -139,7 +151,22 @@ def host_room(room: UUID):
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running

return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
def get_log(max_size: int = 1024000) -> str:
try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0
fragments: List[str] = []
for block in _read_log(log):
if raw_size + len(block) > max_size:
fragments.append("…")
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments)
except FileNotFoundError:
return ""

return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)


@app.route('/favicon.ico')
Expand Down
93 changes: 79 additions & 14 deletions WebHostLib/templates/hostRoom.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}
<div style="display: flex; align-items: center;">
<form method=post style="flex-grow: 1; margin-right: 1em;">
<form method="post" id="command-form" style="flex-grow: 1; margin-right: 1em;">
<div class="form-group">
<label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd"
Expand All @@ -55,24 +55,89 @@
Open Log File...
</a>
</div>
<div id="logger"></div>
<script type="application/ecmascript">
let xmlhttp = new XMLHttpRequest();
let url = '{{ url_for('display_log', room = room.id) }}';
{% set log = get_log() -%}
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
<div id="logger" style="white-space: pre">{{ log }}</div>
<script>
let url = '{{ url_for('display_log', room = room.id) }}';
let bytesReceived = {{ log_len }};
let updateLogTimeout;
let awaitingCommandResponse = false;
let logger = document.getElementById("logger");

xmlhttp.onreadystatechange = function () {
if (this.readyState === 4 && this.status === 200) {
document.getElementById("logger").innerText = this.responseText;
function scrollToBottom(el) {
let bot = el.scrollHeight - el.clientHeight;
el.scrollTop += Math.ceil((bot - el.scrollTop)/10);
if (bot - el.scrollTop >= 1) {
window.clearTimeout(el.scrollTimer);
el.scrollTimer = window.setTimeout(() => {
scrollToBottom(el)
}, 16);
}
}

async function updateLog() {
try {
let res = await fetch(url, {
headers: {
'Range': `bytes=${bytesReceived}-`,
}
});
if (res.ok) {
let text = await res.text();
if (text.length > 0) {
awaitingCommandResponse = false;
if (bytesReceived === 0 || res.status !== 206) {
logger.innerHTML = '';
}
if (res.status !== 206) {
bytesReceived = 0;
} else {
bytesReceived += new Blob([text]).size;
}
if (logger.innerHTML.endsWith('…')) {
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
}
logger.appendChild(document.createTextNode(text));
scrollToBottom(logger);
}
};
}
}
finally {
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, awaitingCommandResponse ? 500 : 10000);
}
}

function request_new() {
xmlhttp.open("GET", url, true);
xmlhttp.send();
async function postForm(ev) {
/** @type {HTMLInputElement} */
let cmd = document.getElementById("cmd");
if (cmd.value === "") {
ev.preventDefault();
return;
}
/** @type {HTMLFormElement} */
let form = document.getElementById("command-form");
let req = fetch(form.action || window.location.href, {
method: form.method,
body: new FormData(form),
redirect: "manual",
});
ev.preventDefault(); // has to happen before first await
form.reset();
let res = await req;
if (res.ok || res.type === 'opaqueredirect') {
awaitingCommandResponse = true;
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, 100);
} else {
window.alert(res.statusText);
}
}

window.setTimeout(request_new, 1000);
window.setInterval(request_new, 10000);
document.getElementById("command-form").addEventListener("submit", postForm);
updateLogTimeout = window.setTimeout(updateLog, 1000);
logger.scrollTop = logger.scrollHeight;
</script>
{% endif %}
</div>
Expand Down
5 changes: 3 additions & 2 deletions docs/world api.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,9 @@ In addition, the following methods can be implemented and are called in this ord
called to place player's regions and their locations into the MultiWorld's regions list.
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
* `create_items(self)`
called to place player's items into the MultiWorld's itempool. After this step all regions
and items have to be in the MultiWorld's regions and itempool, and these lists should not be modified afterward.
called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
after this step. Locations cannot be moved to different regions after this step.
* `set_rules(self)`
called to set access and item rules on locations and entrances.
* `generate_basic(self)`
Expand Down
6 changes: 3 additions & 3 deletions test/general/test_reachability.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ def test_default_all_state_can_reach_everything(self):
state = multiworld.get_all_state(False)
for location in multiworld.get_locations():
if location.name not in excluded:
with self.subTest("Location should be reached", location=location):
with self.subTest("Location should be reached", location=location.name):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")

for region in multiworld.get_regions():
if region.name in unreachable_regions:
with self.subTest("Region should be unreachable", region=region):
with self.subTest("Region should be unreachable", region=region.name):
self.assertFalse(region.can_reach(state))
else:
with self.subTest("Region should be reached", region=region):
with self.subTest("Region should be reached", region=region.name):
self.assertTrue(region.can_reach(state))

with self.subTest("Completion Condition"):
Expand Down
2 changes: 1 addition & 1 deletion worlds/generic/docs/setup_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Some steps also assume use of Windows, so may vary with your OS.
## Installing the Archipelago software

The most recent public release of Archipelago can be found on GitHub:
[Archipelago Lastest Release](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
[Archipelago Latest Release](https://github.com/ArchipelagoMW/Archipelago/releases/latest).

Run the exe file, and after accepting the license agreement you will be asked which components you would like to
install.
Expand Down
28 changes: 15 additions & 13 deletions worlds/witness/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
from worlds.AutoWorld import WebWorld, World

from .data import static_items as static_witness_items
from .data import static_locations as static_witness_locations
from .data import static_logic as static_witness_logic
from .data.item_definition_classes import DoorItemDefinition, ItemData
from .data.utils import get_audio_logs
from .hints import CompactItemData, create_all_hints, make_compact_hint_data, make_laser_hints
from .locations import WitnessPlayerLocations, static_witness_locations
from .locations import WitnessPlayerLocations
from .options import TheWitnessOptions, witness_option_groups
from .player_items import WitnessItem, WitnessPlayerItems
from .player_logic import WitnessPlayerLogic
Expand Down Expand Up @@ -53,7 +54,8 @@ class WitnessWorld(World):
options: TheWitnessOptions

item_name_to_id = {
name: data.ap_code for name, data in static_witness_items.ITEM_DATA.items()
# ITEM_DATA doesn't have any event items in it
name: cast(int, data.ap_code) for name, data in static_witness_items.ITEM_DATA.items()
}
location_name_to_id = static_witness_locations.ALL_LOCATIONS_TO_ID
item_name_groups = static_witness_items.ITEM_GROUPS
Expand Down Expand Up @@ -142,7 +144,7 @@ def generate_early(self) -> None:
)
self.player_regions: WitnessPlayerRegions = WitnessPlayerRegions(self.player_locations, self)

self.log_ids_to_hints = dict()
self.log_ids_to_hints = {}

self.determine_sufficient_progression()

Expand Down Expand Up @@ -279,7 +281,7 @@ def create_items(self) -> None:
remaining_item_slots = pool_size - sum(item_pool.values())

# Add puzzle skips.
num_puzzle_skips = self.options.puzzle_skip_amount
num_puzzle_skips = self.options.puzzle_skip_amount.value

if num_puzzle_skips > remaining_item_slots:
warning(f"{self.multiworld.get_player_name(self.player)}'s Witness world has insufficient locations"
Expand All @@ -301,21 +303,21 @@ def create_items(self) -> None:
if self.player_items.item_data[item_name].local_only:
self.options.local_items.value.add(item_name)

def fill_slot_data(self) -> dict:
self.log_ids_to_hints: Dict[int, CompactItemData] = dict()
self.laser_ids_to_hints: Dict[int, CompactItemData] = dict()
def fill_slot_data(self) -> Dict[str, Any]:
self.log_ids_to_hints: Dict[int, CompactItemData] = {}
self.laser_ids_to_hints: Dict[int, CompactItemData] = {}

already_hinted_locations = set()

# Laser hints

if self.options.laser_hints:
laser_hints = make_laser_hints(self, static_witness_items.ITEM_GROUPS["Lasers"])
laser_hints = make_laser_hints(self, sorted(static_witness_items.ITEM_GROUPS["Lasers"]))

for item_name, hint in laser_hints.items():
item_def = cast(DoorItemDefinition, static_witness_logic.ALL_ITEMS[item_name])
self.laser_ids_to_hints[int(item_def.panel_id_hexes[0], 16)] = make_compact_hint_data(hint, self.player)
already_hinted_locations.add(hint.location)
already_hinted_locations.add(cast(Location, hint.location))

# Audio Log Hints

Expand Down Expand Up @@ -378,13 +380,13 @@ class WitnessLocation(Location):
game: str = "The Witness"
entity_hex: int = -1

def __init__(self, player: int, name: str, address: Optional[int], parent, ch_hex: int = -1) -> None:
def __init__(self, player: int, name: str, address: Optional[int], parent: Region, ch_hex: int = -1) -> None:
super().__init__(player, name, address, parent)
self.entity_hex = ch_hex


def create_region(world: WitnessWorld, name: str, player_locations: WitnessPlayerLocations,
region_locations=None, exits=None) -> Region:
region_locations: Optional[List[str]] = None, exits: Optional[List[str]] = None) -> Region:
"""
Create an Archipelago Region for The Witness
"""
Expand All @@ -399,11 +401,11 @@ def create_region(world: WitnessWorld, name: str, player_locations: WitnessPlaye
entity_hex = int(
static_witness_logic.ENTITIES_BY_NAME[location]["entity_hex"], 0
)
location = WitnessLocation(
location_obj = WitnessLocation(
world.player, location, loc_id, ret, entity_hex
)

ret.locations.append(location)
ret.locations.append(location_obj)
if exits:
for single_exit in exits:
ret.exits.append(Entrance(world.player, single_exit, ret))
Expand Down
12 changes: 6 additions & 6 deletions worlds/witness/data/static_items.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Dict, List
from typing import Dict, List, Set

from BaseClasses import ItemClassification

Expand All @@ -7,7 +7,7 @@
from .static_locations import ID_START

ITEM_DATA: Dict[str, ItemData] = {}
ITEM_GROUPS: Dict[str, List[str]] = {}
ITEM_GROUPS: Dict[str, Set[str]] = {}

# Useful items that are treated specially at generation time and should not be automatically added to the player's
# item list during get_progression_items.
Expand All @@ -22,13 +22,13 @@ def populate_items() -> None:

if definition.category is ItemCategory.SYMBOL:
classification = ItemClassification.progression
ITEM_GROUPS.setdefault("Symbols", []).append(item_name)
ITEM_GROUPS.setdefault("Symbols", set()).add(item_name)
elif definition.category is ItemCategory.DOOR:
classification = ItemClassification.progression
ITEM_GROUPS.setdefault("Doors", []).append(item_name)
ITEM_GROUPS.setdefault("Doors", set()).add(item_name)
elif definition.category is ItemCategory.LASER:
classification = ItemClassification.progression_skip_balancing
ITEM_GROUPS.setdefault("Lasers", []).append(item_name)
ITEM_GROUPS.setdefault("Lasers", set()).add(item_name)
elif definition.category is ItemCategory.USEFUL:
classification = ItemClassification.useful
elif definition.category is ItemCategory.FILLER:
Expand All @@ -47,7 +47,7 @@ def populate_items() -> None:
def get_item_to_door_mappings() -> Dict[int, List[int]]:
output: Dict[int, List[int]] = {}
for item_name, item_data in ITEM_DATA.items():
if not isinstance(item_data.definition, DoorItemDefinition):
if not isinstance(item_data.definition, DoorItemDefinition) or item_data.ap_code is None:
continue
output[item_data.ap_code] = [int(hex_string, 16) for hex_string in item_data.definition.panel_id_hexes]
return output
Expand Down
Loading

0 comments on commit 9b2377e

Please sign in to comment.