Compare commits

...

3 Commits

Author SHA1 Message Date
Duck
e49ba2ff6f Undertale: Use check_locations helper to avoid redundant sends (#5993) 2026-03-01 01:30:26 +01:00
Doug Hoskisson
61d5120f66 Core: use typing_extensions deprecated (#5989) 2026-03-01 00:14:33 +01:00
Chris W
ff5402c410 Fix(undertale): prevent massive bounce msg spam for position updates (#5990)
* fix(undertale): prevent massive bounce msg spam for position updates

* make sure player is removed on leaving / timing out

* do not check for tags: online, as bounce evaluation is or'd
2026-02-28 22:56:28 +00:00
2 changed files with 92 additions and 28 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
import time
import asyncio import asyncio
import typing import typing
import bsdiff4 import bsdiff4
@@ -15,6 +16,9 @@ from CommonClient import CommonContext, server_loop, \
gui_enabled, ClientCommandProcessor, logger, get_base_parser gui_enabled, ClientCommandProcessor, logger, get_base_parser
from Utils import async_start from Utils import async_start
# Heartbeat for position sharing via bounces, in seconds
UNDERTALE_STATUS_INTERVAL = 30.0
UNDERTALE_ONLINE_TIMEOUT = 60.0
class UndertaleCommandProcessor(ClientCommandProcessor): class UndertaleCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx): def __init__(self, ctx):
@@ -109,6 +113,11 @@ class UndertaleContext(CommonContext):
self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0}
# self.save_game_folder: files go in this path to pass data between us and the actual game # self.save_game_folder: files go in this path to pass data between us and the actual game
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
self.last_sent_position: typing.Optional[tuple] = None
self.last_room: typing.Optional[str] = None
self.last_status_write: float = 0.0
self.other_undertale_status: dict[int, dict] = {}
def patch_game(self): def patch_game(self):
with open(Utils.user_path("Undertale", "data.win"), "rb") as f: with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
@@ -219,6 +228,9 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral", await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral",
str(ctx.slot)+" RoutesDone pacifist", str(ctx.slot)+" RoutesDone pacifist",
str(ctx.slot)+" RoutesDone genocide"]}]) str(ctx.slot)+" RoutesDone genocide"]}])
if any(info.game == "Undertale" and slot != ctx.slot
for slot, info in ctx.slot_info.items()):
ctx.set_notify("undertale_room_status")
if args["slot_data"]["only_flakes"]: if args["slot_data"]["only_flakes"]:
with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f: with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f:
f.close() f.close()
@@ -263,6 +275,12 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]: if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]:
if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None: if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None:
ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"] ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"]
if "undertale_room_status" in args["keys"] and args["keys"]["undertale_room_status"]:
status = args["keys"]["undertale_room_status"]
ctx.other_undertale_status = {
int(key): val for key, val in status.items()
if int(key) != ctx.slot
}
elif cmd == "SetReply": elif cmd == "SetReply":
if args["value"] is not None: if args["value"] is not None:
if str(ctx.slot)+" RoutesDone pacifist" == args["key"]: if str(ctx.slot)+" RoutesDone pacifist" == args["key"]:
@@ -271,17 +289,19 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
ctx.completed_routes["genocide"] = args["value"] ctx.completed_routes["genocide"] = args["value"]
elif str(ctx.slot)+" RoutesDone neutral" == args["key"]: elif str(ctx.slot)+" RoutesDone neutral" == args["key"]:
ctx.completed_routes["neutral"] = args["value"] ctx.completed_routes["neutral"] = args["value"]
if args.get("key") == "undertale_room_status" and args.get("value"):
ctx.other_undertale_status = {
int(key): val for key, val in args["value"].items()
if int(key) != ctx.slot
}
elif cmd == "ReceivedItems": elif cmd == "ReceivedItems":
start_index = args["index"] start_index = args["index"]
if start_index == 0: if start_index == 0:
ctx.items_received = [] ctx.items_received = []
elif start_index != len(ctx.items_received): elif start_index != len(ctx.items_received):
sync_msg = [{"cmd": "Sync"}] await ctx.check_locations(ctx.locations_checked)
if ctx.locations_checked: await ctx.send_msgs([{"cmd": "Sync"}])
sync_msg.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received): if start_index == len(ctx.items_received):
counter = -1 counter = -1
placedWeapon = 0 placedWeapon = 0
@@ -368,9 +388,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
f.close() f.close()
elif cmd == "Bounced": elif cmd == "Bounced":
tags = args.get("tags", []) data = args.get("data", {})
if "Online" in tags: if "x" in data and "room" in data:
data = args.get("data", {})
if data["player"] != ctx.slot and data["player"] is not None: if data["player"] != ctx.slot and data["player"] is not None:
filename = f"FRISK" + str(data["player"]) + ".playerspot" filename = f"FRISK" + str(data["player"]) + ".playerspot"
with open(os.path.join(ctx.save_game_folder, filename), "w") as f: with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
@@ -381,21 +400,63 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
async def multi_watcher(ctx: UndertaleContext): async def multi_watcher(ctx: UndertaleContext):
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
path = ctx.save_game_folder if "Online" in ctx.tags and any(
for root, dirs, files in os.walk(path): info.game == "Undertale" and slot != ctx.slot
for file in files: for slot, info in ctx.slot_info.items()):
if "spots.mine" in file and "Online" in ctx.tags: now = time.time()
with open(os.path.join(root, file), "r") as mine: path = ctx.save_game_folder
this_x = mine.readline() for root, dirs, files in os.walk(path):
this_y = mine.readline() for file in files:
this_room = mine.readline() if "spots.mine" in file:
this_sprite = mine.readline() with open(os.path.join(root, file), "r") as mine:
this_frame = mine.readline() this_x = mine.readline()
mine.close() this_y = mine.readline()
message = [{"cmd": "Bounce", "tags": ["Online"], this_room = mine.readline()
"data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room, this_sprite = mine.readline()
"spr": this_sprite, "frm": this_frame}}] this_frame = mine.readline()
await ctx.send_msgs(message)
if this_room != ctx.last_room or \
now - ctx.last_status_write >= UNDERTALE_STATUS_INTERVAL:
ctx.last_room = this_room
ctx.last_status_write = now
await ctx.send_msgs([{
"cmd": "Set",
"key": "undertale_room_status",
"default": {},
"want_reply": False,
"operations": [{"operation": "update",
"value": {str(ctx.slot): {"room": this_room,
"time": now}}}]
}])
# If player was visible but timed out (heartbeat) or left the room, remove them.
for slot, entry in ctx.other_undertale_status.items():
if entry.get("room") != this_room or \
now - entry.get("time", now) > UNDERTALE_ONLINE_TIMEOUT:
playerspot = os.path.join(ctx.save_game_folder,
f"FRISK{slot}.playerspot")
if os.path.exists(playerspot):
os.remove(playerspot)
current_position = (this_x, this_y, this_room, this_sprite, this_frame)
if current_position == ctx.last_sent_position:
continue
# Empty status dict = no data yet → send to bootstrap.
online_in_room = any(
entry.get("room") == this_room and
now - entry.get("time", now) <= UNDERTALE_ONLINE_TIMEOUT
for entry in ctx.other_undertale_status.values()
)
if ctx.other_undertale_status and not online_in_room:
continue
message = [{"cmd": "Bounce", "games": ["Undertale"],
"data": {"player": ctx.slot, "x": this_x, "y": this_y,
"room": this_room, "spr": this_sprite,
"frm": this_frame}}]
await ctx.send_msgs(message)
ctx.last_sent_position = current_position
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
@@ -409,10 +470,9 @@ async def game_watcher(ctx: UndertaleContext):
for file in files: for file in files:
if ".item" in file: if ".item" in file:
os.remove(os.path.join(root, file)) os.remove(os.path.join(root, file))
sync_msg = [{"cmd": "Sync"}] await ctx.check_locations(ctx.locations_checked)
if ctx.locations_checked: await ctx.send_msgs([{"cmd": "Sync"}])
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False ctx.syncing = False
if ctx.got_deathlink: if ctx.got_deathlink:
ctx.got_deathlink = False ctx.got_deathlink = False
@@ -447,7 +507,7 @@ async def game_watcher(ctx: UndertaleContext):
for l in lines: for l in lines:
sending = sending+[(int(l.rstrip('\n')))+12000] sending = sending+[(int(l.rstrip('\n')))+12000]
finally: finally:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}]) await ctx.check_locations(sending)
if "victory" in file and str(ctx.route) in file: if "victory" in file and str(ctx.route) in file:
victory = True victory = True
if ".playerspot" in file and "Online" not in ctx.tags: if ".playerspot" in file and "Online" not in ctx.tags:

View File

@@ -23,6 +23,7 @@ from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump from yaml import load, load_all, dump
from pathspec import PathSpec, GitIgnoreSpec from pathspec import PathSpec, GitIgnoreSpec
from typing_extensions import deprecated
try: try:
from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper
@@ -315,6 +316,7 @@ def get_public_ipv6() -> str:
return ip return ip
@deprecated("Utils.get_options() is deprecated. Use the settings API instead.")
def get_options() -> Settings: def get_options() -> Settings:
deprecate("Utils.get_options() is deprecated. Use the settings API instead.") deprecate("Utils.get_options() is deprecated. Use the settings API instead.")
return get_settings() return get_settings()
@@ -1003,6 +1005,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
def deprecate(message: str, add_stacklevels: int = 0): def deprecate(message: str, add_stacklevels: int = 0):
"""also use typing_extensions.deprecated wherever you use this"""
if __debug__: if __debug__:
raise Exception(message) raise Exception(message)
warnings.warn(message, stacklevel=2 + add_stacklevels) warnings.warn(message, stacklevel=2 + add_stacklevels)
@@ -1067,6 +1070,7 @@ def _extend_freeze_support() -> None:
multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop
@deprecated("Use multiprocessing.freeze_support() instead")
def freeze_support() -> None: def freeze_support() -> None:
"""This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load.""" """This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load."""
import multiprocessing import multiprocessing