Created
February 8, 2025 08:16
-
-
Save Alexrns/5faf4030e9aca89e6bb331e0c8c22e88 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
''' | |
Name: sc_map.py | |
Version: b1.4 | |
Author: Alexrns | |
Date: 2/6/2025 | |
Source and Config: https://gist.github.com/Alexrns/5faf4030e9aca89e6bb331e0c8c22e88 | |
Description: STILL IN DEVELOPMENT | |
Changelog: | |
b1.0 - First public release. | |
b1.1 - Added more config options and made a config section. | |
- Should reset in boss. | |
- Map shouldnt overlap now. | |
b1.2 - Added color functionality for main room, normal room, current room, and end room. | |
- Rooms that lead back to main are indicated. | |
- Should reset when entering sc boss now. | |
b1.3 - Will reset when entering sc boss now. | |
- Added temporary(?) recursion error handling in all recursive loops. | |
Graphic Y is dependant on next room door location when current room door is middle | |
b1.4 - Turned door codes into (int) enum. | |
- Added fixes for dist and graphic loops. | |
TODO: | |
Verify that dist_from_main update loop is stable. | |
Draw connection grapics. | |
Verify that room graphics are accurate. | |
Offset left / right rooms based on `next_code`. | |
Reconsider storage method | |
Binary Search Tree esque approach | |
Reset plugin when homing out of incomplete sc. | |
Connection class is ugly, make edges a part of the dict? | |
''' | |
import util | |
import logging | |
import math | |
from _remote import ffi, lib | |
from manager import PluginBase | |
from enum import IntEnum | |
# door code key: left right top_left bottom_left top_right bottom_right | |
class DoorCodes(IntEnum): | |
DOOR_NONE = 0b000000 | |
DOOR_LEFT = 0b100000 | |
DOOR_RIGHT = 0b010000 | |
DOOR_TOP_LEFT = 0b001000 | |
DOOR_BOTTOM_LEFT = 0b000100 | |
DOOR_TOP_RIGHT = 0b000010 | |
DOOR_BOTTOM_RIGHT = 0b000001 | |
NUM_DOOR = 6 | |
class Connection: | |
def __init__(self, this_code=DoorCodes.DOOR_NONE, next_code=DoorCodes.DOOR_NONE, next_room=None): | |
self.this_code = this_code | |
self.next_code = next_code | |
self.next_room = next_room | |
# self.x1 = 0 | |
# self.y1 = 0 | |
# self.x2 = 0 | |
# self.y2 = 0 | |
def __eq__(self, other): | |
return self.this_code == other.this_code and self.next_code == other.next_code and self.next_room == other.next_room | |
def __repr__(self): | |
return f'{self.this_code:06b}-{self.next_code:06b}-{self.next_room.id}' | |
# return f'{self.next_room.id}' | |
def __str__(self): | |
return f'{self.this_code:06b}-{self.next_code:06b}-{self.next_room.id}' | |
# return f'{self.next_room.id}' | |
class Room: | |
def __init__(self, id=-1): | |
self.id = id | |
# door code key: left right top_left bottom_left top_right bottom_right | |
self.x = 0 | |
self.y = 0 | |
self.color = 0x80808080 | |
self.dirty = False | |
self.door_codes = DoorCodes.DOOR_NONE | |
self.num_doors = 0 | |
self.dist_from_main = -1 | |
self.path_back = False | |
self.connections = [] | |
# door_code: orderd pair / tuple - coords | |
self.door_locations = { | |
DoorCodes.DOOR_LEFT: (0, 0), | |
DoorCodes.DOOR_RIGHT: (0, 0), | |
DoorCodes.DOOR_TOP_LEFT: (0, 0), | |
DoorCodes.DOOR_BOTTOM_LEFT: (0, 0), | |
DoorCodes.DOOR_TOP_RIGHT: (0, 0), | |
DoorCodes.DOOR_BOTTOM_RIGHT: (0, 0), | |
} | |
def __eq__(self, other): | |
return self.id == other.id | |
# def __int__(self): | |
# return self.id | |
def __str__(self): | |
# return f'id: {self.id}, door_codes: {self.door_codes:06b}, ({self.x}, {self.y})({self.w}x{self.h}), connections: {self.connections[0] if self.connections else "[]"}, door_locations: {self.door_locations}' | |
# return f'id: {self.id}, door_codes: {self.door_codes:06b}, ({self.x}, {self.y}) {self.w}x{self.h}, connections: {self.connections}' | |
return f'id: {self.id}, dist: {self.dist_from_main}, path: {self.path_back}, door_codes: {self.door_codes:06b}, connections: {self.connections}' | |
def dist(x, y, d): | |
return math.floor(math.sqrt(pow(x - d[0], 2) + pow(y - d[1], 2))) | |
class Plugin(PluginBase): | |
def onInit(self, inputs=None): | |
self.config.option('show_map', False, 'bool') | |
self.config.option('show_debug', False, 'bool') | |
self.config.option('x', 350, 'int') | |
self.config.option('y', 250, 'int') | |
self.config.option('w', 50, 'int') | |
self.config.option('h', 50, 'int') | |
self.config.option('horizontal_spacing', 25, 'int') | |
self.config.option('vertical_spacing', 25, 'int') | |
self.config.option('current_room_color', 0x80ff8080, 'color') | |
self.config.option('main_room_color', 0x800080ff, 'color') | |
self.config.option('normal_room_color', 0x80ff80ff, 'color') | |
self.config.option('end_room_color', 0x8080ff00, 'color') | |
self.debug_txt = util.MultilineText() | |
# floor: Room | |
self.rooms = {} | |
# for the graphic | |
self.connections = [] | |
self.main = None | |
self.first_enter = True | |
self.update_graphic = True | |
self.prev_floor = 0 | |
self.floor = 0 | |
self.prev_room = None | |
self.room = None | |
self.prev_closest_door = DoorCodes.DOOR_NONE | |
self.closest_door = DoorCodes.DOOR_NONE | |
# self.prev_decoration = b'' | |
self.decoration = b'' | |
self.d = 0 | |
def initVals(self): | |
self.rooms.clear() | |
self.connections.clear() | |
self.main = None | |
self.first_enter = True | |
self.update_graphic = True | |
self.prev_floor = 0 | |
self.floor = 0 | |
self.prev_room = None | |
self.room = None | |
self.prev_closest_door = DoorCodes.DOOR_NONE | |
self.closest_door = DoorCodes.DOOR_NONE | |
self.decoration = b'' | |
self.d = 0 | |
def makeConnection(self): | |
test = 0 | |
# Make no connections with core rooms | |
# if self.prev_decoration == b'sccore' or self.decoration == b'sccore': return | |
# logging.info('\n/ makeConnection -----------------------------------\\' +\ | |
# f'\n{self.prev_room} - {self.prev_closest_door:06b}\n{self.room} - {self.closest_door:06b}') | |
# create connection | |
con = Connection(self.closest_door, self.prev_closest_door, self.prev_room) | |
logging.info(f'{con}') | |
if con not in self.room.connections: | |
test = 1 | |
self.update_graphic = True | |
self.room.connections.append(con) | |
self.connections.append(con) | |
# create connection in other room | |
con = Connection(self.prev_closest_door, self.closest_door, self.room) | |
# logging.info(f'{con}' +\ | |
# '\n\\---------------------------------------------------/') | |
if con not in self.prev_room.connections: | |
if not test: | |
logging.warn('room connection already existed but prev_room connection didnt!') | |
self.update_graphic = True | |
self.prev_room.connections.append(con) | |
def identifyDoors(self, room): | |
# FF / SC: facing 0 = right, facing 180 = left | |
# door code key: left, right, top_left, bottom_left, top_right, bottom_right | |
code = DoorCodes.DOOR_NONE | |
left_doors = 0 | |
right_doors = 0 | |
door_objs = [] | |
for obj in util.worldobjects(self.refs.ClientWorld.serverSubWorld): # here I go looping again... | |
# look for closed doors | |
if ffi.string(obj.props.vid.s) in {b'sc.door', b'sc.cdoor'}: | |
# if ffi.string(obj.props.vid.s) in {b'forest.nextdoor', b'forest.prevdoor', b'forest.unextdoor', b'forest.uprevdoor'}: | |
door_objs.append(obj) | |
if obj.props.facing == 0: | |
right_doors += 1 | |
elif obj.props.facing == 180: | |
left_doors += 1 | |
if left_doors == 1: | |
code = code | DoorCodes.DOOR_LEFT | |
for obj in door_objs: | |
if obj.props.facing == 180: | |
room.door_locations[DoorCodes.DOOR_LEFT] = (obj.props.xmp, obj.props.ymp) | |
break | |
elif left_doors == 2: | |
code = code | DoorCodes.DOOR_TOP_LEFT | DoorCodes.DOOR_BOTTOM_LEFT | |
# find top / bot | |
pos = [] | |
for obj in door_objs: | |
if obj.props.facing == 180: | |
if not pos: | |
pos.append(obj.props.xmp) | |
pos.append(obj.props.ymp) | |
else: | |
if obj.props.ymp > pos[1]: | |
room.door_locations[DoorCodes.DOOR_TOP_LEFT] = (pos[0], pos[1]) # tuple(pos) | |
room.door_locations[DoorCodes.DOOR_BOTTOM_LEFT] = (obj.props.xmp, obj.props.ymp) | |
else: | |
room.door_locations[DoorCodes.DOOR_TOP_LEFT] = (obj.props.xmp, obj.props.ymp) | |
room.door_locations[DoorCodes.DOOR_BOTTOM_LEFT] = (pos[0], pos[1]) # tuple(pos) | |
break | |
if right_doors == 1: | |
code = code | DoorCodes.DOOR_RIGHT | |
for obj in door_objs: | |
if obj.props.facing == 0: | |
room.door_locations[DoorCodes.DOOR_RIGHT] = (obj.props.xmp, obj.props.ymp) | |
break | |
elif right_doors == 2: | |
code = code | DoorCodes.DOOR_TOP_RIGHT | DoorCodes.DOOR_BOTTOM_RIGHT | |
# find top / bot | |
pos = [] | |
for obj in door_objs: | |
if obj.props.facing == 0: | |
if not pos: | |
pos.append(obj.props.xmp) | |
pos.append(obj.props.ymp) | |
else: | |
if obj.props.ymp > pos[1]: | |
room.door_locations[DoorCodes.DOOR_TOP_RIGHT] = (pos[0], pos[1]) # tuple(pos) | |
room.door_locations[DoorCodes.DOOR_BOTTOM_RIGHT] = (obj.props.xmp, obj.props.ymp) | |
else: | |
room.door_locations[DoorCodes.DOOR_TOP_RIGHT] = (obj.props.xmp, obj.props.ymp) | |
room.door_locations[DoorCodes.DOOR_BOTTOM_RIGHT] = (pos[0], pos[1]) # tuple(pos) | |
break | |
room.door_codes = code | |
room.num_doors = len(door_objs) | |
def distUpdateLoop(self, room, prev_room): # recursively loops through room connections to update dist from main when a 'loop' is discovered | |
if room.dist_from_main > 20: # Should prevent recursion errors, at least temporarily | |
logging.info(f'Unusually high dist ({room.dist_from_main}): {room}') | |
return | |
for con in room.connections: | |
# "base case" is when all rooms are the dist +/- 1 or prev room | |
if con.next_room.id == prev_room.id: continue | |
if abs(con.next_room.dist_from_main - room.dist_from_main) > 1: | |
# bad dist detected, increase recursion level | |
con.next_room.dist_from_main = room.dist_from_main + 1 | |
self.distUpdateLoop(con.next_room, room) | |
# def floydWarshall(self): | |
# rm_list = list(self.rooms) | |
# edges = [[1 << 32] * len(rm_list)] * len(rm_list) | |
# for i in range(len(rm_list)): | |
# for con in self.rooms[rm_list[i]].connections: | |
# con.next_room.id | |
def pathBackUpdateLoop(self, room): | |
# logging.info('---') | |
for con in room.connections: | |
# might not need prev id | |
# logging.info(f'room {room.id} dist: {room.dist_from_main}, next {con.next_room.id} dist: {con.next_room.dist_from_main}, {(con.next_room.dist_from_main == room.dist_from_main - 1)}') | |
if (con.next_room.id != 0) and (con.next_room.dist_from_main == room.dist_from_main - 1): | |
con.next_room.path_back = True | |
self.pathBackUpdateLoop(con.next_room) | |
def afterUpdate(self, inputs=None): | |
# return | |
cw = self.refs.ClientWorld | |
if cw == ffi.NULL: return | |
# Determine if the player is in the right map | |
zone = ffi.string(cw.asWorld.props.zone.s) or ffi.string(cw.asWorld.props.music.s) | |
if zone == b'sc_boss': self.initVals() # TODO: run once? | |
if zone != b'sc': return | |
# if zone != b'forest': return | |
if ffi.string(cw.asWorld.props.decoration.name.s) == b'sccore': return # we hate core rooms we hate core rooms, we hate core rooms we hate core rooms | |
# Get or create current room class | |
self.floor = cw.asWorld.props.floor | |
if self.floor not in self.rooms: | |
self.update_graphic = True | |
self.rooms[self.floor] = Room(self.floor) | |
self.identifyDoors(self.rooms[self.floor]) | |
logging.info('\n/ new floor ----------------------------------------\\' +\ | |
f'\n{self.prev_room} | {self.rooms[self.floor]}\n{self.prev_closest_door:06b} | {self.closest_door:06b}' +\ | |
'\n\\---------------------------------------------------/') | |
self.room = self.rooms[self.floor] | |
self.decoration = ffi.string(cw.asWorld.props.decoration.name.s) | |
# Make sure the player exists | |
plr = cw.player | |
if plr == ffi.NULL: return # Player neds to load in before room changes and connections happen, takes a couple frames | |
pprops = ffi.cast('struct WorldObject *', plr).props | |
# Find the closest door | |
player_dist = 1 << 32 # very large int value, probably always larger than the player will be far | |
self.closest_door = DoorCodes.DOOR_NONE | |
for i in range(DoorCodes.NUM_DOOR): | |
door_code = 1 << i | |
if door_code & self.room.door_codes: | |
new_dist = dist(pprops.xmp, pprops.ymp, self.room.door_locations[door_code]) | |
if new_dist < player_dist: | |
player_dist = new_dist | |
self.closest_door = door_code | |
# First time inits / connections | |
if not self.first_enter: | |
# Core rooms have an id of 0 but are distinguished by their decoration: b'sccore' | |
# main is b'scstart01' | |
# b'sctask01' is pat, b'sctask02' is hive, b'sctask03' is bones, b'sctask04' is jump | |
if self.prev_floor != self.floor: # make "handler" functions like `self.onRoomChange()`? | |
# logging.info('\n/ changed floor id ---------------------------------\\' +\ | |
# f'\nEntered room #{self.floor} from #{self.prev_floor}' +\ | |
# f'\ndecoration: {ffi.string(cw.asWorld.props.decoration.name.s)}' +\ | |
# '\n\\---------------------------------------------------/') | |
# (next_room, this_room, next_code, this_code) | |
self.makeConnection() | |
self.room.color = self.config.current_room_color | |
if self.prev_room == self.main: | |
self.prev_room.color = self.config.main_room_color | |
elif self.prev_room.num_doors == 1: | |
self.prev_room.color = self.config.end_room_color | |
else: | |
self.prev_room.color = self.config.normal_room_color | |
if self.room.dist_from_main == -1: | |
self.room.dist_from_main = self.prev_room.dist_from_main + 1 | |
# ''' | |
elif not ((self.prev_room.dist_from_main == self.room.dist_from_main + 1) or (self.prev_room.dist_from_main == self.room.dist_from_main - 1)): # nor, can also use != + and (faster?) | |
# update back propagation triggered since previous room isnt current distance +1 or -1 | |
# if self.room.dist_from_main > 5: | |
# return | |
if self.prev_room.dist_from_main < self.room.dist_from_main: | |
self.room.dist_from_main = self.prev_room.dist_from_main + 1 | |
try: | |
self.distUpdateLoop(self.room, self.prev_room) | |
except RecursionError: | |
logging.error(f'Recursion Error distUpdateLoop\n{self.rooms}') | |
else: | |
self.prev_room.dist_from_main = self.room.dist_from_main + 1 | |
try: | |
self.distUpdateLoop(self.prev_room, self.room) | |
except RecursionError: | |
logging.error(f'Recursion Error distUpdateLoop\n{self.rooms}') | |
# logging.info(f'2: {self.room.dist_from_main}, {self.prev_room.dist_from_main}') | |
# ''' | |
for room in self.rooms: # maybe find a different way | |
self.rooms[room].path_back = False | |
try: | |
self.pathBackUpdateLoop(self.room) | |
except RecursionError: | |
logging.error(f'Recursion Error pathBackUpdateLoop\n{self.rooms}') | |
else: | |
self.first_enter = False | |
self.room.dist_from_main = 0 | |
self.main = self.room | |
self.main.color = self.config.main_room_color | |
logging.info(f'First Enter, main: {self.main}') | |
# Set prevs | |
self.prev_floor = self.floor | |
self.prev_room = self.room | |
self.prev_closest_door = self.closest_door | |
# self.prev_decoration = self.decoration | |
self.d = player_dist | |
def graphicUpdateLoop(self, room, prev_room): # could also make a list of updated rooms instead of prev_room | |
if not room.dirty: | |
return | |
room.dirty = False | |
# logging.info(f'inside gUL room: {room.x}') | |
# if room.x > 5000: | |
# logging.error(f'recursion error; {room.x}') | |
# raise RecursionError | |
for con in room.connections: | |
if con.next_room.id != prev_room.id: | |
# logging.info('########################################################') | |
if con.this_code == DoorCodes.DOOR_LEFT: | |
con.next_room.x = room.x - (self.config.w + self.config.horizontal_spacing) | |
if con.next_code == DoorCodes.DOOR_TOP_RIGHT: | |
con.next_room.y = room.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.next_code == DoorCodes.DOOR_BOTTOM_RIGHT: | |
con.next_room.y = room.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
else: | |
con.next_room.y = room.y | |
elif con.this_code == DoorCodes.DOOR_RIGHT: | |
con.next_room.x = room.x + (self.config.w + self.config.horizontal_spacing) | |
if con.next_code == DoorCodes.DOOR_TOP_LEFT: | |
con.next_room.y = room.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.next_code == DoorCodes.DOOR_BOTTOM_LEFT: | |
con.next_room.y = room.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
else: | |
con.next_room.y = room.y | |
elif con.this_code == DoorCodes.DOOR_TOP_LEFT: | |
con.next_room.x = room.x - (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = room.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.this_code == DoorCodes.DOOR_BOTTOM_LEFT: | |
con.next_room.x = room.x - (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = room.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.this_code == DoorCodes.DOOR_TOP_RIGHT: | |
con.next_room.x = room.x + (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = room.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.this_code == DoorCodes.DOOR_BOTTOM_RIGHT: | |
con.next_room.x = room.x + (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = room.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
self.graphicUpdateLoop(con.next_room, room) | |
def draw(self, room): | |
self.refs.XDL_FillRect(room.x + self.config.x, room.y + self.config.y, self.config.w, self.config.h, room.color, lib.BLENDMODE_BLEND) | |
if room.path_back: | |
self.refs.XDL_FillRect(room.x + self.config.x + self.config.w // 4, room.y + self.config.y + self.config.h // 4, self.config.w // 2, self.config.h // 2, self.config.current_room_color, lib.BLENDMODE_BLEND) | |
def onPresent(self, inputs=None): | |
if not self.rooms: return | |
# door code key: left, right, top_left, bottom_left, top_right, bottom_right | |
while self.update_graphic and not self.first_enter: | |
self.update_graphic = False | |
# logging.info(f'before gUL main: {self.main.x}') | |
for floor_id in self.rooms: | |
self.rooms[floor_id].dirty = True | |
self.main.dirty = False | |
for con in self.main.connections: | |
# TODO: set room pos, set connection pos, create guess connections | |
if con.this_code == DoorCodes.DOOR_LEFT: | |
con.next_room.x = self.main.x - (self.config.w + self.config.horizontal_spacing) | |
if con.next_code == DoorCodes.DOOR_TOP_RIGHT: | |
con.next_room.y = self.main.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.next_code == DoorCodes.DOOR_BOTTOM_RIGHT: | |
con.next_room.y = self.main.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
else: | |
con.next_room.y = self.main.y | |
elif con.this_code == DoorCodes.DOOR_RIGHT: | |
con.next_room.x = self.main.x + (self.config.w + self.config.horizontal_spacing) | |
if con.next_code == DoorCodes.DOOR_TOP_LEFT: | |
con.next_room.y = self.main.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.next_code == DoorCodes.DOOR_BOTTOM_LEFT: | |
con.next_room.y = self.main.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
else: | |
con.next_room.y = self.main.y | |
elif con.this_code == DoorCodes.DOOR_TOP_LEFT: | |
con.next_room.x = self.main.x - (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = self.main.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.this_code == DoorCodes.DOOR_BOTTOM_LEFT: | |
con.next_room.x = self.main.x - (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = self.main.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.this_code == DoorCodes.DOOR_TOP_RIGHT: | |
con.next_room.x = self.main.x + (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = self.main.y - (self.config.w // 2 + self.config.vertical_spacing // 2) | |
elif con.this_code == DoorCodes.DOOR_BOTTOM_RIGHT: | |
con.next_room.x = self.main.x + (self.config.w + self.config.horizontal_spacing) | |
con.next_room.y = self.main.y + (self.config.w // 2 + self.config.vertical_spacing // 2) | |
try: | |
self.graphicUpdateLoop(con.next_room, self.main) | |
except RecursionError: | |
logging.error(f'Recursion Error graphicUpdateLoop\n{self.rooms}') | |
if self.config.show_map: # put map update here? | |
for floor in self.rooms: | |
cw = self.refs.canvasW_[0] | |
ch = self.refs.canvasH_[0] | |
self.refs.canvasW_[0] = self.refs.windowW | |
self.refs.canvasH_[0] = self.refs.windowH | |
self.draw(self.rooms[floor]) | |
self.refs.canvasW_[0] = cw | |
self.refs.canvasH_[0] = ch | |
if self.config.show_debug: | |
text = '' | |
for room in self.rooms: | |
text += str(self.rooms[room]) + '\n' | |
self.debug_txt.text = text + f'\n{self.closest_door:06b}: {self.d}\n{self.decoration}, {self.prev_room.dist_from_main}' | |
# self.debug_txt.text = f'{self.room}\n{self.prev_room}' | |
self.debug_txt.draw(300, 300) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[plugin_sc_map] | |
show_map = yes | |
show_debug = no | |
x = 350 | |
y = 250 | |
w = 50 | |
h = 50 | |
horizontal_spacing = 25 | |
vertical_spacing = 25 | |
current_room_color = 80ff8080 | |
main_room_color = 800080ff | |
normal_room_color = 80ff80ff | |
end_room_color = 8080ff00 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment