diff --git a/README.md b/README.md index b1dbf0e..a74d4e6 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ see the [plugins repository](https://github.com/MinoMino/minqlx-plugins). - `qlx_owner`: The SteamID64 of the server owner. This is should be set, otherwise minqlx can't tell who the owner is and will refuse to execute admin commands. - `qlx_plugins`: A comma-separated list of plugins that should be loaded at launch. - - Default: `plugin_manager, essentials, motd, permission, ban, clan`. + - Default: `plugin_manager, essentials, motd, permission, ban, clan, names`. - `qlx_pluginsPath`: The path (either relative or absolute) to the directory with the plugins. - Default: `minqlx-plugins` - `qlx_database`: The default database to use. You should not change this unless you know what you're doing. @@ -122,6 +122,17 @@ for commands like `!ban` where the target player might not currently be connecte [See here for a full command list.](https://github.com/MinoMino/minqlx/wiki/Command-List) +Updating +======== +Since this and plugins use different repositories, they will also be updated separately. However, the latest master +branch of both repositories should always be compatible. If you want to try out the develop branch, make sure you use +the develop branch of both repositories too, otherwise you might run into issues. + +To update the core, just use `wget` to get the latest binary tarball and put it in your QLDS directory, then simply +extract it with `tar -xvf `. To update the plugins, use `cd` to change the working directory to `qlds/minqlx-plugins` +and do `git pull origin` and you should be good to go. Git should not remove any untracked files, so you can have your +own custom plugins there and still keep your local copy of the repo up to date. + Compiling ========= **NOTE**: This is *not* required if you are using binaries. diff --git a/commands.c b/commands.c index 35d4981..e48377a 100644 --- a/commands.c +++ b/commands.c @@ -108,5 +108,8 @@ void __cdecl RestartPython(void) { if (PyMinqlx_IsInitialized()) PyMinqlx_Finalize(); PyMinqlx_Initialize(); + // minqlx initializes after the first new game starts, but since the game already + // start, we manually trigger the event to make it initialize properly. + NewGameDispatcher(0); } #endif diff --git a/dllmain.c b/dllmain.c index 0c712ca..23eb73a 100644 --- a/dllmain.c +++ b/dllmain.c @@ -61,6 +61,7 @@ G_InitGame_ptr G_InitGame; CheckPrivileges_ptr CheckPrivileges; ClientConnect_ptr ClientConnect; ClientDisconnect_ptr ClientDisconnect; +ClientSpawn_ptr ClientSpawn; // VM global variables. gentity_t* g_entities; @@ -293,6 +294,14 @@ void SearchVmFunctions(void) { } else DebugPrint("ClientDisconnect: %p\n", ClientDisconnect); + ClientSpawn = (ClientSpawn_ptr)PatternSearch((void*)((pint)qagame + 0xB000), + 0xB0000, PTRN_CLIENTSPAWN, MASK_CLIENTSPAWN); + if (ClientSpawn == NULL) { + DebugPrint("ERROR: Unable to find ClientSpawn.\n"); + failed = 1; + } + else DebugPrint("ClientSpawn: %p\n", ClientSpawn); + if (failed) { DebugPrint("Exiting.\n"); exit(1); diff --git a/hooks.c b/hooks.c index c411293..37177ee 100644 --- a/hooks.c +++ b/hooks.c @@ -169,6 +169,15 @@ char* __cdecl My_ClientConnect(int clientNum, qboolean firstTime, qboolean isBot return ClientConnect(clientNum, firstTime, isBot); } + +void __cdecl My_ClientSpawn(gentity_t* ent) { + ClientSpawn(ent); + + // Since we won't ever stop the real function from being called, + // we trigger the event after calling the real one. This will allow + // us to set weapons and such without it getting overriden later. + ClientSpawnDispatcher(ent - g_entities); +} #endif // Hook static functions. Can be done before program even runs. @@ -268,6 +277,12 @@ void HookVm(void) { failed = 1; } + res = Hook((void*)ClientSpawn, My_ClientSpawn, (void*)&ClientSpawn); + if (res) { + DebugPrint("ERROR: Failed to hook ClientSpawn: %d\n", res); + failed = 1; + } + if (failed) { DebugPrint("Exiting.\n"); exit(1); diff --git a/patterns.h b/patterns.h index 46e77fe..09efc6a 100644 --- a/patterns.h +++ b/patterns.h @@ -54,6 +54,8 @@ #define MASK_SYS_SETMODULEOFFSET "XXXXXXXXXXXXXXXXX----XXX-X----X----X----XXXXXX-" #define PTRN_VA "\x53\x48\x81\xec\x00\x00\x00\x00\x84\xc0\x48\x89\x74\x24\x00\x48\x89\x54\x24\x00\x48\x89\x4c\x24\x00\x4c\x89\x44\x24\x00\x4c\x89\x4c\x24\x00\x74\x00\x0f\x29\x44\x24\x00\x0f\x29\x4c\x24\x00" #define MASK_VA "XXXX----XXXXXX-XXXX-XXXX-XXXX-XXXX-X-XXXX-XXXX-" +#define PTRN_CLIENTSPAWN "\x41\x57\x41\x56\x49\x89\xfe\x41\x55\x41\x54\x55\x53\x48\x81\xec\x00\x00\x00\x00\x4c\x8b\xbf\x00\x00\x00\x00\x64\x48\x8b\x04\x25\x00\x00\x00\x00\x48\x89\x84\x24\x00\x00\x00\x00\x31\xc0" +#define MASK_CLIENTSPAWN "XXXXXXXXXXXXXXXX----XXX----XXXXX----XXXX----XX" // Generated by minfuncfind64. qagame functions. #define PTRN_G_RUNFRAME "\x8b\x05\x00\x00\x00\x00\x85\xc0\x74\x00\xf3\xc3" diff --git a/pyminqlx.h b/pyminqlx.h index 49e95d3..6b7a58f 100644 --- a/pyminqlx.h +++ b/pyminqlx.h @@ -57,6 +57,7 @@ extern PyObject* new_game_handler; extern PyObject* set_configstring_handler; extern PyObject* rcon_handler; extern PyObject* console_print_handler; +extern PyObject* client_spawn_handler; // Custom console command handler. These are commands added through Python that can be used // from the console or using RCON. @@ -83,5 +84,6 @@ void NewGameDispatcher(int restart); char* SetConfigstringDispatcher(int index, char* value); void RconDispatcher(const char* cmd); char* ConsolePrintDispatcher(char* cmd); +void ClientSpawnDispatcher(int client_id); #endif /* PYMINQLX_H */ diff --git a/python/minqlx/_commands.py b/python/minqlx/_commands.py index b4fe1b5..54d2b29 100644 --- a/python/minqlx/_commands.py +++ b/python/minqlx/_commands.py @@ -19,6 +19,7 @@ import minqlx import re +MAX_MSG_LENGTH = 1000 re_color_tag = re.compile(r"\^[^\^]") # ==================================================================== @@ -237,28 +238,34 @@ def name(self): def reply(self, msg): raise NotImplementedError() - def split_long_msg(self, msg, limit=100, delimiter=" "): - """Split a message into several pieces for channels with limtations.""" - if len(msg) < limit: - return [msg] - out = [] - index = limit - for i in reversed(range(limit)): - if msg[i:i + len(delimiter)] == delimiter: - index = i - out.append(msg[0:index]) - # Keep going, but skip the delimiter. - rest = msg[index + len(delimiter):] - if rest: - out.extend(self.split_long_msg(rest, limit, delimiter)) - return out - - out.append(msg[0:index]) - # Keep going. - rest = msg[index:] - if rest: - out.extend(self.split_long_msg(rest, limit, delimiter)) - return out + def split_long_lines(self, msg, limit=100, delimiter=" "): + res = [] + + while msg: + i = msg.find("\n") + if 0 <= i <= limit: + res.append(msg[:i]) + msg = msg[i+1:] + continue + + if len(msg) < limit: + if msg: + res.append(msg) + break + + length = 0 + while True: + i = msg[length:].find(delimiter) + if i == -1 or i+length > limit: + if not length: + length = limit+1 + res.append(msg[:length-1]) + msg = msg[length+len(delimiter)-1:] + break + else: + length += i+1 + + return res class ChatChannel(AbstractChannel): """A channel for chat to and from the server.""" @@ -285,7 +292,21 @@ def reply(self, msg, limit=100, delimiter=" "): if p.team == self.team: targets.append(p.id) - for s in self.split_long_msg(msg, limit, delimiter): + split_msgs = self.split_long_lines(msg, limit, delimiter) + # We've split messages, but we can still just join them up to 1000-ish + # bytes before we need to send multiple server cmds. + joined_msgs = [] + for s in split_msgs: + if not len(joined_msgs): + joined_msgs.append(s) + else: + s_new = joined_msgs[-1] + "\n" + s + if len(s_new.encode(errors="replace")) > MAX_MSG_LENGTH: + joined_msgs.append(s) + else: + joined_msgs[-1] = s_new + + for s in joined_msgs: if not targets: minqlx.send_server_command(None, self.fmt.format(last_color + s)) else: diff --git a/python/minqlx/_core.py b/python/minqlx/_core.py index 3fc3cc3..8a73f30 100644 --- a/python/minqlx/_core.py +++ b/python/minqlx/_core.py @@ -33,24 +33,24 @@ import shlex import sys import os -import re from logging.handlers import RotatingFileHandler # Team number -> string -TEAMS = dict(enumerate(("free", "red", "blue", "spectator"))) +TEAMS = collections.OrderedDict(enumerate(("free", "red", "blue", "spectator"))) # Game type number -> string -GAMETYPES = dict(enumerate(("Free for All", "Duel", "Race", "Team Deathmatch", "Clan Arena", +GAMETYPES = collections.OrderedDict(enumerate(("Free for All", "Duel", "Race", "Team Deathmatch", "Clan Arena", "Capture the Flag", "Overload", "Harvester", "Freeze Tag", "Domination", "Attack and Defend", "Red Rover"))) # Game type number -> short string -GAMETYPES_SHORT = dict(enumerate(("ffa", "duel", "race", "tdm", "ca", "ctf", "ob", "har", "ft", "dom", "ad", "rr"))) +GAMETYPES_SHORT = collections.OrderedDict(enumerate(("ffa", "duel", "race", "tdm", "ca", "ctf", "ob", "har", "ft", "dom", "ad", "rr"))) # Connection states. -STATES = dict(enumerate(("free", "zombie", "connected", "primed", "active"))) +CONNECTION_STATES = collections.OrderedDict(enumerate(("free", "zombie", "connected", "primed", "active"))) -_re_varsplit = re.compile(r"\\*") +WEAPONS = collections.OrderedDict(enumerate(("_none", "g", "mg", "sg", "gl", "rl", "lg", "rg", + "pg", "bfg", "gh", "ng", "pl", "cg", "hmg", "hands"))) # ==================================================================== # HELPERS @@ -74,12 +74,14 @@ def parse_variables(varstr, ordered=False): if not varstr.strip(): return res - vars = _re_varsplit.split(varstr.lstrip("\\")) + vars = varstr.lstrip("\\").split("\\") try: for i in range(0, len(vars), 2): res[vars[i]] = vars[i + 1] - except: - raise ValueError("Uneven number of keys and values: {}".format(varstr)) + except IndexError: + # Log and return incomplete dict. + logger = minqlx.get_logger() + logger.warning("Uneven number of keys and values: {}".format(varstr)) return res @@ -210,7 +212,7 @@ def set_map_subtitles(): def next_frame(func): def f(*args, **kwargs): - minqlx.frame_tasks.enter(0, 0, func, args, kwargs) + minqlx.next_frame_tasks.append((func, args, kwargs)) return f diff --git a/python/minqlx/_events.py b/python/minqlx/_events.py index b2ba55b..85f7caf 100644 --- a/python/minqlx/_events.py +++ b/python/minqlx/_events.py @@ -19,7 +19,7 @@ import minqlx import re -_re_vote = re.compile(r"^(?P[^ ]+)(?: \"?(?P.+?)\"?)?$") +_re_vote = re.compile(r"^(?P[^ ]+)(?: \"?(?P.*?)\"?)?$") # ==================================================================== # EVENTS @@ -375,6 +375,13 @@ class PlayerDisonnectDispatcher(EventDispatcher): def dispatch(self, player, reason): return super().dispatch(player, reason) +class PlayerSpawnDispatcher(EventDispatcher): + """Event that triggers when a player spawns. Cannot be cancelled.""" + name = "player_spawn" + + def dispatch(self, player): + return super().dispatch(player) + class StatsDispatcher(EventDispatcher): """Event that triggers whenever the server sends stats over ZMQ.""" name = "stats" @@ -394,20 +401,17 @@ class VoteEndedDispatcher(EventDispatcher): name = "vote_ended" def dispatch(self, passed): - super().dispatch(passed) - - def cancel(self): # Check if there's a current vote in the first place. cs = minqlx.get_configstring(9) if not cs: + minqlx.get_logger().warning("vote_ended went off without configstring 9.") return res = _re_vote.match(cs) vote = res.group("cmd") args = res.group("args") if res.group("args") else "" votes = (int(minqlx.get_configstring(10)), int(minqlx.get_configstring(11))) - # Return None if the vote's cancelled (like if the round starts before vote's over). - super().trigger(votes, vote, args, None) + super().dispatch(votes, vote, args, passed) class VoteDispatcher(EventDispatcher): """Event that goes off whenever someone tries to vote either yes or no.""" @@ -525,6 +529,7 @@ def dispatch(self, victim, killer, data): EVENT_DISPATCHERS.add_dispatcher(PlayerConnectDispatcher) EVENT_DISPATCHERS.add_dispatcher(PlayerLoadedDispatcher) EVENT_DISPATCHERS.add_dispatcher(PlayerDisonnectDispatcher) +EVENT_DISPATCHERS.add_dispatcher(PlayerSpawnDispatcher) EVENT_DISPATCHERS.add_dispatcher(StatsDispatcher) EVENT_DISPATCHERS.add_dispatcher(VoteCalledDispatcher) EVENT_DISPATCHERS.add_dispatcher(VoteEndedDispatcher) diff --git a/python/minqlx/_handlers.py b/python/minqlx/_handlers.py index 3ce17c8..666190d 100644 --- a/python/minqlx/_handlers.py +++ b/python/minqlx/_handlers.py @@ -17,6 +17,7 @@ # along with minqlx. If not, see . import minqlx +import collections import sched import re @@ -89,7 +90,7 @@ def handle_client_command(client_id, cmd): return cmd res = _re_callvote.match(cmd) - if res: + if res and not minqlx.Plugin.is_vote_active(): vote = res.group("cmd") args = res.group("args") if res.group("args") else "" if minqlx.EVENT_DISPATCHERS["vote_called"].dispatch(player, vote, args) == False: @@ -165,6 +166,7 @@ def handle_server_command(client_id, cmd): # weird behavior if you were to use threading. This list will act as a task queue. # Tasks can be added by simply adding the @minqlx.next_frame decorator to functions. frame_tasks = sched.scheduler() +next_frame_tasks = collections.deque() def handle_frame(): """This will be called every frame. To allow threads to call stuff from the @@ -189,6 +191,14 @@ def handle_frame(): minqlx.log_exception() return True + try: + while True: + func, args, kwargs = next_frame_tasks.popleft() + frame_tasks.enter(0, 0, func, args, kwargs) + except IndexError: + pass + + _zmq_warning_issued = False _first_game = True @@ -248,12 +258,11 @@ def handle_set_configstring(index, value): new_state = new_cs["g_gameState"] if old_state != new_state: if old_state == "PRE_GAME" and new_state == "IN_PROGRESS": - minqlx.EVENT_DISPATCHERS["vote_ended"].cancel() # Cancel current vote if any. - #minqlx.EVENT_DISPATCHERS["game_start"].dispatch() + pass elif old_state == "PRE_GAME" and new_state == "COUNT_DOWN": minqlx.EVENT_DISPATCHERS["game_countdown"].dispatch() elif old_state == "COUNT_DOWN" and new_state == "IN_PROGRESS": - minqlx.EVENT_DISPATCHERS["vote_ended"].cancel() # Cancel current vote if any. + pass #minqlx.EVENT_DISPATCHERS["game_start"].dispatch() elif old_state == "IN_PROGRESS" and new_state == "PRE_GAME": pass @@ -333,6 +342,19 @@ def handle_player_disconnect(client_id, reason): minqlx.log_exception() return True +def handle_player_spawn(client_id): + """Called when a player spawns. Note that a spectator going in free spectate mode + makes the client spawn, so you'll want to check for that if you only want "actual" + spawns. + + """ + try: + player = minqlx.Player(client_id) + return minqlx.EVENT_DISPATCHERS["player_spawn"].dispatch(player) + except: + minqlx.log_exception() + return True + def handle_console_print(text): """Called whenever the server tries to set a configstring. Can return False to stop the event and can be modified along the handler chain. @@ -355,7 +377,6 @@ def handle_console_print(text): minqlx.log_exception() return True - def register_handlers(): minqlx.register_handler("rcon", handle_rcon) minqlx.register_handler("client_command", handle_client_command) @@ -366,4 +387,5 @@ def register_handlers(): minqlx.register_handler("player_connect", handle_player_connect) minqlx.register_handler("player_loaded", handle_player_loaded) minqlx.register_handler("player_disconnect", handle_player_disconnect) + minqlx.register_handler("player_spawn", handle_player_spawn) minqlx.register_handler("console_print", handle_console_print) diff --git a/python/minqlx/_player.py b/python/minqlx/_player.py index b92ad8b..9238408 100644 --- a/python/minqlx/_player.py +++ b/python/minqlx/_player.py @@ -24,37 +24,6 @@ "\\handicap\\100\\cl_anonymous\\0\\color1\\4\\color2\\23\\sex\\male" "\\teamtask\\0\\rate\\25000\\country\\NO") -def _player(client_id): - """A wrapper for minqlx.player_info() to make the output more usable.""" - info = minqlx.player_info(client_id) - if info == None: - return None - - d = minqlx.parse_variables(info["userinfo"]) - for key in info: - if key == "userinfo": - pass - else: - d[key] = info[key] - - return d - -def _players(): - """A wrapper for minqlx.players_info() to make the output more usable.""" - ret = {} - for i, player in enumerate(minqlx.players_info()): - if not player: - continue - - d = minqlx.parse_variables(player["userinfo"]) - for key in player: - if key == "userinfo": - pass - else: - d[key] = player[key] - ret[i] = d - return ret - class NonexistentPlayerError(Exception): """An exception that is raised when a player that disconnected is being used as if the player were still present. @@ -71,26 +40,34 @@ class Player(): and the player has disconnected, it will raise a :exc:`minqlx.NonexistentPlayerError` exception. - At the same time, that also means that if you access an attribute a lot, you should - probably assign it to a temporary variable first. - """ - def __init__(self, client_id, cvars_func=_player, player_dict=None): - self._cvars_func = cvars_func + def __init__(self, client_id, info=None): self._valid = True - # player_dict used for more efficient Plugin.players(). - if player_dict: + + # Can pass own info for efficiency when getting all players and to allow dummy players. + if info: self._id = client_id - self._cvars = player_dict + self._info = info else: self._id = client_id - self._cvars = cvars_func(client_id) - if not self._cvars: + self._info = minqlx.player_info(client_id) + if not self._info: self._invalidate("Tried to initialize a Player instance of nonexistant player {}." .format(client_id)) - self._steam_id = self._cvars["steam_id"] - self._name = self._cvars["name"] + self._userinfo = None + self._steam_id = self._info.steam_id + + # When a player connects, a the name field in the client struct has yet to be initialized, + # so we fall back to the userinfo and try parse it ourselves to get the name if needed. + if self._info.name: + self._name = self._info.name + else: + self._userinfo = minqlx.parse_variables(self._info.userinfo, ordered=True) + if "name" in self._userinfo: + self._name = self._userinfo["name"] + else: # No name at all. Weird userinfo during connection perhaps? + self._name = "" def __repr__(self): if not self._valid: @@ -127,12 +104,19 @@ def update(self): :raises: minqlx.NonexistentPlayerError """ - self._cvars = self._cvars_func(self._id) + self._info = minqlx.player_info(self._id) - if not self._cvars or self.steam_id != self._steam_id: + if not self._info or self._steam_id != self._info.steam_id: self._invalidate() - self._name = self._cvars["name"] + if self._info.name: + self._name = self._info.name + else: + self._userinfo = minqlx.parse_variables(self._info.userinfo, ordered=True) + if "name" in self._userinfo: + self._name = self._userinfo["name"] + else: + self._name = "" def _invalidate(self, e="The player does not exist anymore. Did the player disconnect?"): self._valid = False @@ -143,7 +127,10 @@ def cvars(self): if not self._valid: self._invalidate() - return self._cvars.copy() + if not self._userinfo: + self._userinfo = minqlx.parse_variables(self._info.userinfo) + + return self._userinfo.copy() @property def steam_id(self): @@ -173,9 +160,8 @@ def name(self): @name.setter def name(self, value): - info = minqlx.parse_variables(minqlx.get_userinfo(self.id), ordered=True) - info["name"] = value - new_info = "\\".join(["{}\\{}".format(key, info[key]) for key in info]) + self._userinfo["name"] = value + new_info = "\\".join(["{}\\{}".format(key, self._userinfo[key]) for key in self._userinfo]) minqlx.client_command(self.id, "userinfo \"{}\"".format(new_info)) self._name = value @@ -190,7 +176,7 @@ def qport(self): @property def team(self): - return minqlx.TEAMS[self["team"]] + return minqlx.TEAMS[self._info.team] @property def colors(self): @@ -206,7 +192,23 @@ def headmodel(self): return self["headmodel"] @property - def state(self): + def handicap(self): + return self["handicap"] + + @property + def autohop(self): + return bool(int(self["cg_autoHop"])) + + @property + def autoaction(self): + return bool(int(self["cg_autoAction"])) + + @property + def predictitems(self): + return bool(int(self["cg_predictItems"])) + + @property + def connection_state(self): """A string describing the connection state of a player. Possible values: @@ -216,22 +218,26 @@ def state(self): - *primed* -- The player was sent the necessary information to play, but has yet to send commands. - *active* -- The player finished loading and is actively sending commands to the server. - In other words, if you need to make sure a player is in-game, check if ``player.state == "active"``. + In other words, if you need to make sure a player is in-game, check if ``player.connection_state == "active"``. """ - return minqlx.STATES[self["state"]] + return minqlx.CONNECTION_STATES[self._info.connection_state] + + @property + def state(self): + return minqlx.player_state(self.id) @property def privileges(self): - if self["privileges"] == minqlx.PRIV_NONE: + if self._info.privileges == minqlx.PRIV_NONE: return None - elif self["privileges"] == minqlx.PRIV_MOD: + elif self._info.privileges == minqlx.PRIV_MOD: return "mod" - elif self["privileges"] == minqlx.PRIV_ADMIN: + elif self._info.privileges == minqlx.PRIV_ADMIN: return "admin" - elif self["privileges"] == minqlx.PRIV_ROOT: + elif self._info.privileges == minqlx.PRIV_ROOT: return "root" - elif self["privileges"] == minqlx.PRIV_BANNED: + elif self._info.privileges == minqlx.PRIV_BANNED: return "banned" @property @@ -240,11 +246,201 @@ def country(self): @property def valid(self): - try: - self["name"] - return True - except NonexistentPlayerError: - return False + return self._valid + + @property + def stats(self): + return minqlx.player_stats(self.id) + + def position(self, reset=False, **kwargs): + if reset: + pos = minqlx.Vector3((0, 0, 0)) + else: + pos = self.state.position + + if not kwargs: + return pos + + x = pos.x if "x" not in kwargs else kwargs["x"] + y = pos.y if "y" not in kwargs else kwargs["y"] + z = pos.z if "z" not in kwargs else kwargs["z"] + + return minqlx.set_position(self.id, minqlx.Vector3((x, y, z))) + + def velocity(self, reset=False, **kwargs): + if reset: + vel = minqlx.Vector3((0, 0, 0)) + else: + vel = self.state.velocity + + if not kwargs: + return vel + + x = vel.x if "x" not in kwargs else kwargs["x"] + y = vel.y if "y" not in kwargs else kwargs["y"] + z = vel.z if "z" not in kwargs else kwargs["z"] + + return minqlx.set_velocity(self.id, minqlx.Vector3((x, y, z))) + + def weapons(self, reset=False, **kwargs): + if reset: + weaps = minqlx.Weapons(((False,)*15)) + else: + weaps = self.state.weapons + + if not kwargs: + return weaps + + g = weaps.g if "g" not in kwargs else kwargs["g"] + mg = weaps.mg if "mg" not in kwargs else kwargs["mg"] + sg = weaps.sg if "sg" not in kwargs else kwargs["sg"] + gl = weaps.gl if "gl" not in kwargs else kwargs["gl"] + rl = weaps.rl if "rl" not in kwargs else kwargs["rl"] + lg = weaps.lg if "lg" not in kwargs else kwargs["lg"] + rg = weaps.rg if "rg" not in kwargs else kwargs["rg"] + pg = weaps.pg if "pg" not in kwargs else kwargs["pg"] + bfg = weaps.bfg if "bfg" not in kwargs else kwargs["bfg"] + gh = weaps.gh if "gh" not in kwargs else kwargs["gh"] + ng = weaps.ng if "ng" not in kwargs else kwargs["ng"] + pl = weaps.pl if "pl" not in kwargs else kwargs["pl"] + cg = weaps.cg if "cg" not in kwargs else kwargs["cg"] + hmg = weaps.hmg if "hmg" not in kwargs else kwargs["hmg"] + hands = weaps.hands if "hands" not in kwargs else kwargs["hands"] + + return minqlx.set_weapons(self.id, + minqlx.Weapons((g, mg, sg, gl, rl, lg, rg, pg, bfg, gh, ng, pl, cg, hmg, hands))) + + def weapon(self, new_weapon=None): + if new_weapon is None: + return self.state.weapon + elif new_weapon in minqlx.WEAPONS: + pass + elif new_weapon in minqlx.WEAPONS.values(): + new_weapon = tuple(minqlx.WEAPONS.values()).index(new_weapon) + + return minqlx.set_weapon(self.id, new_weapon) + + def ammo(self, reset=False, **kwargs): + if reset: + a = minqlx.Weapons(((0,)*15)) + else: + a = self.state.ammo + + if not kwargs: + return a + + g = a.g if "g" not in kwargs else kwargs["g"] + mg = a.mg if "mg" not in kwargs else kwargs["mg"] + sg = a.sg if "sg" not in kwargs else kwargs["sg"] + gl = a.gl if "gl" not in kwargs else kwargs["gl"] + rl = a.rl if "rl" not in kwargs else kwargs["rl"] + lg = a.lg if "lg" not in kwargs else kwargs["lg"] + rg = a.rg if "rg" not in kwargs else kwargs["rg"] + pg = a.pg if "pg" not in kwargs else kwargs["pg"] + bfg = a.bfg if "bfg" not in kwargs else kwargs["bfg"] + gh = a.gh if "gh" not in kwargs else kwargs["gh"] + ng = a.ng if "ng" not in kwargs else kwargs["ng"] + pl = a.pl if "pl" not in kwargs else kwargs["pl"] + cg = a.cg if "cg" not in kwargs else kwargs["cg"] + hmg = a.hmg if "hmg" not in kwargs else kwargs["hmg"] + hands = a.hands if "hands" not in kwargs else kwargs["hands"] + + return minqlx.set_ammo(self.id, + minqlx.Weapons((g, mg, sg, gl, rl, lg, rg, pg, bfg, gh, ng, pl, cg, hmg, hands))) + + def powerups(self, reset=False, **kwargs): + if reset: + pu = minqlx.Powerups(((0,)*6)) + else: + pu = self.state.powerups + + if not kwargs: + return pu + + quad = pu.quad if "quad" not in kwargs else kwargs["quad"] + bs = pu.battlesuit if "battlesuit" not in kwargs else kwargs["battlesuit"] + haste = pu.haste if "haste" not in kwargs else kwargs["haste"] + invis = pu.invisibility if "invisibility" not in kwargs else kwargs["invisibility"] + regen = pu.regeneration if "regeneration" not in kwargs else kwargs["regeneration"] + invul = pu.invulnerability if "invulnerability" not in kwargs else kwargs["invulnerability"] + + return minqlx.set_powerups(self.id, + minqlx.Powerups((quad, bs, haste, invis, regen, invul))) + + @property + def holdable(self): + return self.state.holdable + + @holdable.setter + def holdable(self, value): + if not value: + minqlx.set_holdable(self.id, 0) + elif value == "teleporter": + minqlx.set_holdable(self.id, 27) + elif value == "medkit": + minqlx.set_holdable(self.id, 28) + elif value == "flight": + minqlx.set_holdable(self.id, 34) + self.flight(reset=True) + elif value == "kamikaze": + minqlx.set_holdable(self.id, 37) + elif value == "portal": + minqlx.set_holdable(self.id, 38) + elif value == "invulnerability": + minqlx.set_holdable(self.id, 39) + else: + raise ValueError("Invalid holdable item.") + + def flight(self, reset=False, **kwargs): + state = self.state + if state.holdable != "flight": + self.holdable = "flight" + reset = True + + if reset: + # Set to defaults on reset. + fl = minqlx.Flight((16000, 16000, 1200, 0)) + else: + fl = state.flight + + fuel = fl.fuel if "fuel" not in kwargs else kwargs["fuel"] + max_fuel = fl.max_fuel if "max_fuel" not in kwargs else kwargs["max_fuel"] + thrust = fl.thrust if "thrust" not in kwargs else kwargs["thrust"] + refuel = fl.refuel if "refuel" not in kwargs else kwargs["refuel"] + + return minqlx.set_flight(self.id, minqlx.Flight((fuel, max_fuel, thrust, refuel))) + + @property + def noclip(self): + return self.state.noclip + + @noclip.setter + def noclip(self, value): + minqlx.noclip(self.id, bool(value)) + + @property + def health(self): + return self.state.health + + @health.setter + def health(self, value): + minqlx.set_health(self.id, value) + + @property + def armor(self): + return self.state.armor + + @armor.setter + def armor(self, value): + minqlx.set_armor(self.id, value) + + @property + def score(self): + return self.stats.score + + @score.setter + def score(self, value): + return minqlx.set_score(self.id, value) def tell(self, msg, **kwargs): return minqlx.Plugin.tell(msg, self, **kwargs) @@ -290,12 +486,13 @@ def slay(self): @classmethod def all_players(cls): - players = _players() - return [cls(cid, player_dict=players[cid]) for cid in players] + return [cls(i, info=info) for i, info in enumerate(minqlx.players_info()) if info] class AbstractDummyPlayer(Player): - def __init__(self): - self._cvars = minqlx.parse_variables(_DUMMY_USERINFO) + def __init__(self, name="DummyPlayer"): + info = minqlx.PlayerInfo((-1, name, minqlx.CS_CONNECTED, + _DUMMY_USERINFO, -1, minqlx.TEAM_SPECTATOR, minqlx.PRIV_NONE)) + super().__init__(-1, info=info) @property def id(self): @@ -305,6 +502,9 @@ def id(self): def steam_id(self): raise NotImplementedError("steam_id property needs to be implemented.") + def update(self): + pass + def tell(self, msg): raise NotImplementedError("tell() needs to be implemented.") diff --git a/python/minqlx/_plugin.py b/python/minqlx/_plugin.py index b2ea36d..3d5195e 100644 --- a/python/minqlx/_plugin.py +++ b/python/minqlx/_plugin.py @@ -431,9 +431,8 @@ def current_vote_count(cls): return None @classmethod - def callvote(cls, vote): - # TODO: Implement callvote. - pass + def callvote(cls, vote, display, time=30): + minqlx.callvote(vote, display, time) @classmethod def force_vote(cls, pass_it): diff --git a/python/minqlx/database.py b/python/minqlx/database.py index 7cf977b..73ba48a 100644 --- a/python/minqlx/database.py +++ b/python/minqlx/database.py @@ -138,15 +138,24 @@ def get_permission(self, player): """Gets the permission of a player. :param player: The player in question. - :type player: minqlx.Player + :type player: minqlx.Player, int :returns: int """ if isinstance(player, minqlx.Player): - key = "minqlx:players:{}:permission".format(player.steam_id) + steam_id = player.steam_id + elif isinstance(player, int): + steam_id = player + elif isinstance(player, str): + steam_id = int(player) else: - key = "minqlx:players:{}:permission".format(player) + raise ValueError("Invalid player. Use either a minqlx.Player instance or a SteamID64.") + + # If it's the owner, treat it like a 5. + if steam_id == minqlx.owner(): + return 5 + key = "minqlx:players:{}:permission".format(steam_id) perm = self[key] if perm == None: return 0 diff --git a/python_dispatchers.c b/python_dispatchers.c index 5c5cd19..1362433 100644 --- a/python_dispatchers.c +++ b/python_dispatchers.c @@ -225,3 +225,21 @@ char* ConsolePrintDispatcher(char* text) { PyGILState_Release(gstate); return ret; } + +void ClientSpawnDispatcher(int client_id) { + if (!client_spawn_handler) + return; // No registered handler. + + PyGILState_STATE gstate = PyGILState_Ensure(); + + PyObject* result = PyObject_CallFunction(client_spawn_handler, "i", client_id); + + // Only change to 0 if we got False returned to us. + if (result == NULL) { + DebugError("PyObject_CallFunction() returned NULL.\n", + __FILE__, __LINE__, __func__); + } + Py_XDECREF(result); + + PyGILState_Release(gstate); +} diff --git a/python_embed.c b/python_embed.c index 6b97f15..e649510 100644 --- a/python_embed.c +++ b/python_embed.c @@ -1,4 +1,6 @@ #include +#include +#include #include #include #include @@ -20,6 +22,7 @@ PyObject* new_game_handler = NULL; PyObject* set_configstring_handler = NULL; PyObject* rcon_handler = NULL; PyObject* console_print_handler = NULL; +PyObject* client_spawn_handler = NULL; static PyThreadState* mainstate; static int initialized = 0; @@ -60,95 +63,198 @@ static handler_t handlers[] = { {"set_configstring", &set_configstring_handler}, {"rcon", &rcon_handler}, {"console_print", &console_print_handler}, + {"player_spawn", &client_spawn_handler}, {NULL, NULL} }; +/* + * ================================================================ + * Struct Sequences + * ================================================================ +*/ + +// Players +static PyTypeObject player_info_type = {0}; + +static PyStructSequence_Field player_info_fields[] = { + {"client_id", "The player's client ID."}, + {"name", "The player's name."}, + {"connection_state", "The player's connection state."}, + {"userinfo", "The player's userinfo."}, + {"steam_id", "The player's 64-bit representation of the Steam ID."}, + {"team", "The player's team."}, + {"privileges", "The player's privileges."}, + {NULL} +}; + +static PyStructSequence_Desc player_info_desc = { + "PlayerInfo", + "Information about a player, such as Steam ID, name, client ID, and whatnot.", + player_info_fields, + (sizeof(player_info_fields)/sizeof(PyStructSequence_Field)) - 1 +}; + +// Player state +static PyTypeObject player_state_type = {0}; + +static PyStructSequence_Field player_state_fields[] = { + {"is_alive", "Whether the player's alive or not."}, + {"position", "The player's position."}, + {"velocity", "The player's velocity."}, + {"health", "The player's health."}, + {"armor", "The player's armor."}, + {"noclip", "Whether the player has noclip or not."}, + {"weapon", "The weapon the player is currently using."}, + {"weapons", "The player's weapons."}, + {"ammo", "The player's weapon ammo."}, + {"powerups", "The player's powerups."}, + {"holdable", "The player's holdable item."}, + {"flight", "A struct sequence with flight parameters."}, + {NULL} +}; + +static PyStructSequence_Desc player_state_desc = { + "PlayerState", + "Information about a player's state in the game.", + player_state_fields, + (sizeof(player_state_fields)/sizeof(PyStructSequence_Field)) - 1 +}; + +// Stats +static PyTypeObject player_stats_type = {0}; + +static PyStructSequence_Field player_stats_fields[] = { + {"score", "The player's primary score."}, + {"kills", "The player's number of kills."}, + {"deaths", "The player's number of deaths."}, + {"damage_dealt", "The player's total damage dealt."}, + {"damage_taken", "The player's total damage taken."}, + {"time", "The time in milliseconds the player has on a team since the game started."}, + {NULL} +}; + +static PyStructSequence_Desc player_stats_desc = { + "PlayerStats", + "A player's score and some basic stats.", + player_stats_fields, + (sizeof(player_stats_fields)/sizeof(PyStructSequence_Field)) - 1 +}; + +// Vectors +static PyTypeObject vector3_type = {0}; + +static PyStructSequence_Field vector3_fields[] = { + {"x", NULL}, + {"y", NULL}, + {"z", NULL}, + {NULL} +}; + +static PyStructSequence_Desc vector3_desc = { + "Vector3", + "A three-dimensional vector.", + vector3_fields, + (sizeof(vector3_fields)/sizeof(PyStructSequence_Field)) - 1 +}; + +// Weapons +static PyTypeObject weapons_type = {0}; + +static PyStructSequence_Field weapons_fields[] = { + {"g", NULL}, {"mg", NULL}, {"sg", NULL}, + {"gl", NULL}, {"rl", NULL}, {"lg", NULL}, + {"rg", NULL}, {"pg", NULL}, {"bfg", NULL}, + {"gh", NULL}, {"ng", NULL}, {"pl", NULL}, + {"cg", NULL}, {"hmg", NULL}, {"hands", NULL}, + {NULL} +}; + +static PyStructSequence_Desc weapons_desc = { + "Weapons", + "A struct sequence containing all the weapons in the game.", + weapons_fields, + (sizeof(weapons_fields)/sizeof(PyStructSequence_Field)) - 1 +}; + +// Powerups +static PyTypeObject powerups_type = {0}; + +static PyStructSequence_Field powerups_fields[] = { + {"quad", NULL}, {"battlesuit", NULL}, + {"haste", NULL}, {"invisibility", NULL}, + {"regeneration", NULL}, {"invulnerability", NULL}, + {NULL} +}; + +static PyStructSequence_Desc powerups_desc = { + "Powerups", + "A struct sequence containing all the powerups in the game.", + powerups_fields, + (sizeof(powerups_fields)/sizeof(PyStructSequence_Field)) - 1 +}; + +// Flight +static PyTypeObject flight_type = {0}; + +static PyStructSequence_Field flight_fields[] = { + {"fuel", NULL}, + {"max_fuel", NULL}, + {"thrust", NULL}, + {"refuel", NULL}, + {NULL} +}; + +static PyStructSequence_Desc flight_desc = { + "Flight", + "A struct sequence containing parameters for the flight holdable item.", + flight_fields, + (sizeof(flight_fields)/sizeof(PyStructSequence_Field)) - 1 +}; + /* * ================================================================ * player_info/players_info * ================================================================ */ -static PyObject* makePlayerDict(int client_id) { - PyObject* ret = PyDict_New(); - if (!ret) { - DebugError("Failed to create a new dictionary.\n", - __FILE__, __LINE__, __func__); - Py_RETURN_NONE; - } +static PyObject* makePlayerTuple(int client_id) { + PyObject *name, *team, *priv; + PyObject* cid = PyLong_FromLongLong(client_id); - // STATE - PyObject* state = PyLong_FromLongLong(svs->clients[client_id].state); - if (PyDict_SetItemString(ret, "state", state) == -1) { - DebugError("Failed to add 'state' to the dictionary.\n", - __FILE__, __LINE__, __func__); - Py_DECREF(ret); - Py_RETURN_NONE; - } - Py_DECREF(state); - - // USERINFO - PyObject* userinfo = PyUnicode_DecodeUTF8(svs->clients[client_id].userinfo, strlen(svs->clients[client_id].userinfo), "ignore"); - if (PyDict_SetItemString(ret, "userinfo", userinfo) == -1) { - DebugError("Failed to add 'userinfo' to the dictionary.\n", - __FILE__, __LINE__, __func__); - Py_DECREF(ret); - Py_RETURN_NONE; - } - Py_DECREF(userinfo); - - // STEAM ID - PyObject* steam_id = PyLong_FromLongLong(svs->clients[client_id].steam_id); - if (PyDict_SetItemString(ret, "steam_id", steam_id) == -1) { - DebugError("Failed to add 'steam_id' to the dictionary.\n", - __FILE__, __LINE__, __func__); - Py_DECREF(ret); - Py_RETURN_NONE; - } - Py_DECREF(steam_id); - - if (g_entities[client_id].client) { - // NAME - PyObject* name = PyUnicode_DecodeUTF8(g_entities[client_id].client->pers.netname, - strlen(g_entities[client_id].client->pers.netname), "ignore"); - if (PyDict_SetItemString(ret, "name", name) == -1) { - DebugError("Failed to add 'name' to the dictionary.\n", - __FILE__, __LINE__, __func__); - Py_DECREF(ret); - Py_RETURN_NONE; - } - Py_DECREF(name); + if (g_entities[client_id].client != NULL) { + if (g_entities[client_id].client->pers.connected == CON_DISCONNECTED) + name = PyUnicode_FromString(""); + else + name = PyUnicode_DecodeUTF8(g_entities[client_id].client->pers.netname, + strlen(g_entities[client_id].client->pers.netname), "ignore"); - // TEAM - PyObject* team; if (g_entities[client_id].client->pers.connected == CON_DISCONNECTED) team = PyLong_FromLongLong(TEAM_SPECTATOR); // Set team to spectator if not yet connected. else team = PyLong_FromLongLong(g_entities[client_id].client->sess.sessionTeam); - if (PyDict_SetItemString(ret, "team", team) == -1) { - DebugError("Failed to add 'team' to the dictionary.\n", - __FILE__, __LINE__, __func__); - Py_DECREF(ret); - Py_RETURN_NONE; - } - Py_DECREF(team); - - // PRIVILEGES - PyObject* priv = PyLong_FromLongLong(g_entities[client_id].client->sess.privileges); - if (PyDict_SetItemString(ret, "privileges", priv) == -1) { - DebugError("Failed to add 'privileges' to the dictionary.\n", - __FILE__, __LINE__, __func__); - Py_DECREF(ret); - Py_RETURN_NONE; - } + priv = PyLong_FromLongLong(g_entities[client_id].client->sess.privileges); } else { - DebugError("gclient %d was NULL.\n", - __FILE__, __LINE__, __func__, client_id); + name = PyUnicode_FromString(""); + team = PyLong_FromLongLong(TEAM_SPECTATOR); + priv = PyLong_FromLongLong(-1); } - return ret; + PyObject* state = PyLong_FromLongLong(svs->clients[client_id].state); + PyObject* userinfo = PyUnicode_DecodeUTF8(svs->clients[client_id].userinfo, strlen(svs->clients[client_id].userinfo), "ignore"); + PyObject* steam_id = PyLong_FromLongLong(svs->clients[client_id].steam_id); + + PyObject* info = PyStructSequence_New(&player_info_type); + PyStructSequence_SetItem(info, 0, cid); + PyStructSequence_SetItem(info, 1, name); + PyStructSequence_SetItem(info, 2, state); + PyStructSequence_SetItem(info, 3, userinfo); + PyStructSequence_SetItem(info, 4, steam_id); + PyStructSequence_SetItem(info, 5, team); + PyStructSequence_SetItem(info, 6, priv); + + return info; } static PyObject* PyMinqlx_PlayerInfo(PyObject* self, PyObject* args) { @@ -170,7 +276,7 @@ static PyObject* PyMinqlx_PlayerInfo(PyObject* self, PyObject* args) { Py_RETURN_NONE; } - return makePlayerDict(i); + return makePlayerTuple(i); } static PyObject* PyMinqlx_PlayersInfo(PyObject* self, PyObject* args) { @@ -184,7 +290,7 @@ static PyObject* PyMinqlx_PlayersInfo(PyObject* self, PyObject* args) { continue; } - if (PyList_SetItem(ret, i, makePlayerDict(i)) == -1) + if (PyList_SetItem(ret, i, makePlayerTuple(i)) == -1) return NULL; } @@ -529,6 +635,582 @@ static PyObject* PyMinqlx_RegisterHandler(PyObject* self, PyObject* args) { return NULL; } +/* + * ================================================================ + * player_state + * ================================================================ +*/ + +static PyObject* PyMinqlx_PlayerState(PyObject* self, PyObject* args) { + int client_id; + + if (!PyArg_ParseTuple(args, "i:player_state", &client_id)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_NONE; + + PyObject* state = PyStructSequence_New(&player_state_type); + PyStructSequence_SetItem(state, 0, PyBool_FromLong(g_entities[client_id].client->ps.pm_type == 0)); + + PyObject* pos = PyStructSequence_New(&vector3_type); + PyStructSequence_SetItem(pos, 0, + PyFloat_FromDouble(g_entities[client_id].client->ps.origin[0])); + PyStructSequence_SetItem(pos, 1, + PyFloat_FromDouble(g_entities[client_id].client->ps.origin[1])); + PyStructSequence_SetItem(pos, 2, + PyFloat_FromDouble(g_entities[client_id].client->ps.origin[2])); + PyStructSequence_SetItem(state, 1, pos); + + PyObject* vel = PyStructSequence_New(&vector3_type); + PyStructSequence_SetItem(vel, 0, + PyFloat_FromDouble(g_entities[client_id].client->ps.velocity[0])); + PyStructSequence_SetItem(vel, 1, + PyFloat_FromDouble(g_entities[client_id].client->ps.velocity[1])); + PyStructSequence_SetItem(vel, 2, + PyFloat_FromDouble(g_entities[client_id].client->ps.velocity[2])); + PyStructSequence_SetItem(state, 2, vel); + + PyStructSequence_SetItem(state, 3, PyLong_FromLongLong(g_entities[client_id].health)); + PyStructSequence_SetItem(state, 4, PyLong_FromLongLong(g_entities[client_id].client->ps.stats[STAT_ARMOR])); + PyStructSequence_SetItem(state, 5, PyBool_FromLong(g_entities[client_id].client->noclip)); + PyStructSequence_SetItem(state, 6, PyLong_FromLongLong(g_entities[client_id].client->ps.weapon)); + + // Get weapons and ammo count. + PyObject* weapons = PyStructSequence_New(&weapons_type); + PyObject* ammo = PyStructSequence_New(&weapons_type); + for (int i = 0; i < weapons_desc.n_in_sequence; i++) { + PyStructSequence_SetItem(weapons, i, PyBool_FromLong(g_entities[client_id].client->ps.stats[STAT_WEAPONS] & (1 << (i+1)))); + PyStructSequence_SetItem(ammo, i, PyLong_FromLongLong(g_entities[client_id].client->ps.ammo[i+1])); + } + PyStructSequence_SetItem(state, 7, weapons); + PyStructSequence_SetItem(state, 8, ammo); + + PyObject* powerups = PyStructSequence_New(&powerups_type); + int index; + for (int i = 0; i < powerups_desc.n_in_sequence; i++) { + index = i+PW_QUAD; + if (index == PW_FLIGHT) // Skip flight. + index = PW_INVULNERABILITY; + int remaining = g_entities[client_id].client->ps.powerups[index]; + if (remaining) // We don't want the time, but the remaining time. + remaining -= level->time; + PyStructSequence_SetItem(powerups, i, PyLong_FromLongLong(remaining)); + } + PyStructSequence_SetItem(state, 9, powerups); + + PyObject* holdable; + switch (g_entities[client_id].client->ps.stats[STAT_HOLDABLE_ITEM]) { + case 0: + holdable = Py_None; + Py_INCREF(Py_None); + break; + case 27: + holdable = PyUnicode_FromString("teleporter"); + break; + case 28: + holdable = PyUnicode_FromString("medkit"); + break; + case 34: + holdable = PyUnicode_FromString("flight"); + break; + case 37: + holdable = PyUnicode_FromString("kamikaze"); + break; + case 38: + holdable = PyUnicode_FromString("portal"); + break; + case 39: + holdable = PyUnicode_FromString("invulnerability"); + break; + default: + holdable = PyUnicode_FromString("unknown"); + } + PyStructSequence_SetItem(state, 10, holdable); + + PyObject* flight = PyStructSequence_New(&flight_type); + PyStructSequence_SetItem(flight, 0, + PyLong_FromLongLong(g_entities[client_id].client->ps.stats[STAT_CUR_FLIGHT_FUEL])); + PyStructSequence_SetItem(flight, 1, + PyLong_FromLongLong(g_entities[client_id].client->ps.stats[STAT_MAX_FLIGHT_FUEL])); + PyStructSequence_SetItem(flight, 2, + PyLong_FromLongLong(g_entities[client_id].client->ps.stats[STAT_FLIGHT_THRUST])); + PyStructSequence_SetItem(flight, 3, + PyLong_FromLongLong(g_entities[client_id].client->ps.stats[STAT_FLIGHT_REFUEL])); + PyStructSequence_SetItem(state, 11, flight); + + return state; +} + +/* + * ================================================================ + * player_stats + * ================================================================ +*/ + +static PyObject* PyMinqlx_PlayerStats(PyObject* self, PyObject* args) { + int client_id; + + if (!PyArg_ParseTuple(args, "i:player_stats", &client_id)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_NONE; + + PyObject* stats = PyStructSequence_New(&player_stats_type); + PyStructSequence_SetItem(stats, 0, PyLong_FromLongLong(g_entities[client_id].client->ps.persistant[PERS_ROUND_SCORE])); + PyStructSequence_SetItem(stats, 1, PyLong_FromLongLong(g_entities[client_id].client->expandedStats.numKills)); + PyStructSequence_SetItem(stats, 2, PyLong_FromLongLong(g_entities[client_id].client->expandedStats.numDeaths)); + PyStructSequence_SetItem(stats, 3, PyLong_FromLongLong(g_entities[client_id].client->expandedStats.totalDamageDealt)); + PyStructSequence_SetItem(stats, 4, PyLong_FromLongLong(g_entities[client_id].client->expandedStats.totalDamageTaken)); + PyStructSequence_SetItem(stats, 5, PyLong_FromLongLong(level->time - g_entities[client_id].client->pers.enterTime)); + + return stats; +} + +/* + * ================================================================ + * set_position + * ================================================================ +*/ + +static PyObject* PyMinqlx_SetPosition(PyObject* self, PyObject* args) { + int client_id; + PyObject* new_position; + + if (!PyArg_ParseTuple(args, "iO:set_position", &client_id, &new_position)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + else if (!PyObject_TypeCheck(new_position, &vector3_type)) { + PyErr_Format(PyExc_ValueError, "Argument must be of type minqlx.Vector3."); + return NULL; + } + + g_entities[client_id].client->ps.origin[0] = + (float)PyFloat_AsDouble(PyStructSequence_GetItem(new_position, 0)); + g_entities[client_id].client->ps.origin[1] = + (float)PyFloat_AsDouble(PyStructSequence_GetItem(new_position, 1)); + g_entities[client_id].client->ps.origin[2] = + (float)PyFloat_AsDouble(PyStructSequence_GetItem(new_position, 2)); + + Py_RETURN_TRUE; +} + +/* + * ================================================================ + * set_velocity + * ================================================================ +*/ + +static PyObject* PyMinqlx_SetVelocity(PyObject* self, PyObject* args) { + int client_id; + PyObject* new_velocity; + + if (!PyArg_ParseTuple(args, "iO:set_velocity", &client_id, &new_velocity)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + else if (!PyObject_TypeCheck(new_velocity, &vector3_type)) { + PyErr_Format(PyExc_ValueError, "Argument must be of type minqlx.Vector3."); + return NULL; + } + + g_entities[client_id].client->ps.velocity[0] = + (float)PyFloat_AsDouble(PyStructSequence_GetItem(new_velocity, 0)); + g_entities[client_id].client->ps.velocity[1] = + (float)PyFloat_AsDouble(PyStructSequence_GetItem(new_velocity, 1)); + g_entities[client_id].client->ps.velocity[2] = + (float)PyFloat_AsDouble(PyStructSequence_GetItem(new_velocity, 2)); + + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* noclip +* ================================================================ +*/ + +static PyObject* PyMinqlx_NoClip(PyObject* self, PyObject* args) { + int client_id, activate; + if (!PyArg_ParseTuple(args, "ip:noclip", &client_id, &activate)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + + if ((activate && g_entities[client_id].client->noclip) || (!activate && !g_entities[client_id].client->noclip)) { + // Change was made. + Py_RETURN_FALSE; + } + + g_entities[client_id].client->noclip = activate ? qtrue : qfalse; + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_health +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetHealth(PyObject* self, PyObject* args) { + int client_id, health; + if (!PyArg_ParseTuple(args, "ii:set_health", &client_id, &health)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + + g_entities[client_id].health = health; + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_armor +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetArmor(PyObject* self, PyObject* args) { + int client_id, armor; + if (!PyArg_ParseTuple(args, "ii:set_armor", &client_id, &armor)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + + g_entities[client_id].client->ps.stats[STAT_ARMOR] = armor; + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_weapons +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetWeapons(PyObject* self, PyObject* args) { + int client_id, weapon_flags = 0; + PyObject* weapons; + if (!PyArg_ParseTuple(args, "iO:set_weapons", &client_id, &weapons)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + else if (!PyObject_TypeCheck(weapons, &weapons_type)) { + PyErr_Format(PyExc_ValueError, "Argument must be of type minqlx.Weapons."); + return NULL; + } + + PyObject* w; + for (int i = 0; i < weapons_desc.n_in_sequence; i++) { + w = PyStructSequence_GetItem(weapons, i); + if (!PyBool_Check(w)) { + PyErr_Format(PyExc_ValueError, "Tuple argument %d is not a boolean.", i); + return NULL; + } + + weapon_flags |= w == Py_True ? (1 << (i+1)) : 0; + } + + g_entities[client_id].client->ps.stats[STAT_WEAPONS] = weapon_flags; + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_weapon +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetWeapon(PyObject* self, PyObject* args) { + int client_id, weapon; + if (!PyArg_ParseTuple(args, "ii:set_weapon", &client_id, &weapon)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + else if (weapon < 0 || weapon > 16) { + PyErr_Format(PyExc_ValueError, "Weapon must be a number from 0 to 15."); + return NULL; + } + + g_entities[client_id].client->ps.weapon = weapon; + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_ammo +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetAmmo(PyObject* self, PyObject* args) { + int client_id; + PyObject* ammos; + if (!PyArg_ParseTuple(args, "iO:set_ammo", &client_id, &ammos)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + else if (!PyObject_TypeCheck(ammos, &weapons_type)) { + PyErr_Format(PyExc_ValueError, "Argument must be of type minqlx.Weapons."); + return NULL; + } + + PyObject* a; + for (int i = 0; i < weapons_desc.n_in_sequence; i++) { + a = PyStructSequence_GetItem(ammos, i); + if (!PyLong_Check(a)) { + PyErr_Format(PyExc_ValueError, "Tuple argument %d is not an integer.", i); + return NULL; + } + + g_entities[client_id].client->ps.ammo[i+1] = PyLong_AsLong(a); + } + + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_powerups +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetPowerups(PyObject* self, PyObject* args) { + int client_id, t; + PyObject* powerups; + if (!PyArg_ParseTuple(args, "iO:set_powerups", &client_id, &powerups)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + else if (!PyObject_TypeCheck(powerups, &powerups_type)) { + PyErr_Format(PyExc_ValueError, "Argument must be of type minqlx.Powerups."); + return NULL; + } + + PyObject* powerup; + + // Quad -> Invulnerability, but skip flight. + for (int i = 0; i < powerups_desc.n_in_sequence; i++) { + powerup = PyStructSequence_GetItem(powerups, i); + if (!PyLong_Check(powerup)) { + PyErr_Format(PyExc_ValueError, "Tuple argument %d is not an integer.", i); + return NULL; + } + + // If i == 5, it'll modify flight, which isn't a real powerup. + // We bump it up and modify invulnerability instead. + if (i+PW_QUAD == PW_FLIGHT) + i = PW_INVULNERABILITY - PW_QUAD; + + t = PyLong_AsLong(powerup); + if (!t) { + g_entities[client_id].client->ps.powerups[i+PW_QUAD] = 0; + continue; + } + + g_entities[client_id].client->ps.powerups[i+PW_QUAD] = level->time - (level->time % 1000) + t; + } + + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_holdable +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetHoldable(PyObject* self, PyObject* args) { + int client_id, i; + if (!PyArg_ParseTuple(args, "ii:set_holdable", &client_id, &i)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + + g_entities[client_id].client->ps.stats[STAT_HOLDABLE_ITEM] = i; + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_flight +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetFlight(PyObject* self, PyObject* args) { + int client_id; + PyObject* flight; + if (!PyArg_ParseTuple(args, "iO:set_flight", &client_id, &flight)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + else if (!PyObject_TypeCheck(flight, &flight_type)) { + PyErr_Format(PyExc_ValueError, "Argument must be of type minqlx.Flight."); + return NULL; + } + + for (int i = 0; i < flight_desc.n_in_sequence; i++) + if (!PyLong_Check(PyStructSequence_GetItem(flight, i))) { + PyErr_Format(PyExc_ValueError, "Tuple argument %d is not an integer.", i); + return NULL; + } + + g_entities[client_id].client->ps.stats[STAT_CUR_FLIGHT_FUEL] = PyLong_AsLong(PyStructSequence_GetItem(flight, 0)); + g_entities[client_id].client->ps.stats[STAT_MAX_FLIGHT_FUEL] = PyLong_AsLong(PyStructSequence_GetItem(flight, 1)); + g_entities[client_id].client->ps.stats[STAT_FLIGHT_THRUST] = PyLong_AsLong(PyStructSequence_GetItem(flight, 2)); + g_entities[client_id].client->ps.stats[STAT_FLIGHT_REFUEL] = PyLong_AsLong(PyStructSequence_GetItem(flight, 3)); + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* set_score +* ================================================================ +*/ + +static PyObject* PyMinqlx_SetScore(PyObject* self, PyObject* args) { + int client_id, score; + if (!PyArg_ParseTuple(args, "ii:set_score", &client_id, &score)) + return NULL; + else if (client_id < 0 || client_id >= sv_maxclients->integer) { + PyErr_Format(PyExc_ValueError, + "client_id needs to be a number from 0 to %d.", + sv_maxclients->integer); + return NULL; + } + else if (!g_entities[client_id].client) + Py_RETURN_FALSE; + + g_entities[client_id].client->ps.persistant[PERS_ROUND_SCORE] = score; + Py_RETURN_TRUE; +} + +/* +* ================================================================ +* callvote +* ================================================================ +*/ + +static PyObject* PyMinqlx_Callvote(PyObject* self, PyObject* args) { + char *vote, *vote_disp; + int vote_time = 30; + char buf[64]; + if (!PyArg_ParseTuple(args, "ss|i:callvote", &vote, &vote_disp, &vote_time)) + return NULL; + + strncpy(level->voteString, vote, sizeof(level->voteString)); + strncpy(level->voteDisplayString, vote_disp, sizeof(level->voteDisplayString)); + level->voteTime = (level->time - 30000) + vote_time * 1000; + level->voteYes = 0; + level->voteNo = 0; + + for (int i = 0; i < sv_maxclients->integer; i++) + if (g_entities[i].client) + g_entities[i].client->pers.voteState = VOTE_PENDING; + + SV_SetConfigstring(CS_VOTE_STRING, level->voteDisplayString); + snprintf(buf, sizeof(buf), "%d", level->voteTime); + SV_SetConfigstring(CS_VOTE_TIME, buf); + SV_SetConfigstring(CS_VOTE_YES, "0"); + SV_SetConfigstring(CS_VOTE_NO, "0"); + + Py_RETURN_NONE; +} + +/* +* ================================================================ +* allow_single_player +* ================================================================ +*/ + +static PyObject* PyMinqlx_AllowSinglePlayer(PyObject* self, PyObject* args) { + int x; + if (!PyArg_ParseTuple(args, "p:allow_single_player", &x)) + return NULL; + + if (x) + level->mapIsTrainingMap = qtrue; + else + level->mapIsTrainingMap = qfalse; + + Py_RETURN_NONE; +} + /* * ================================================================ * Module definition and initialization @@ -568,6 +1250,38 @@ static PyMethodDef minqlxMethods[] = { "Adds a console command that will be handled by Python code."}, {"register_handler", PyMinqlx_RegisterHandler, METH_VARARGS, "Register an event handler. Can be called more than once per event, but only the last one will work."}, + {"player_state", PyMinqlx_PlayerState, METH_VARARGS, + "Get information about the player's state in the game."}, + {"player_stats", PyMinqlx_PlayerStats, METH_VARARGS, + "Get some player stats."}, + {"set_position", PyMinqlx_SetPosition, METH_VARARGS, + "Sets a player's position vector."}, + {"set_velocity", PyMinqlx_SetVelocity, METH_VARARGS, + "Sets a player's velocity vector."}, + {"noclip", PyMinqlx_NoClip, METH_VARARGS, + "Sets noclip for a player."}, + {"set_health", PyMinqlx_SetHealth, METH_VARARGS, + "Sets a player's health."}, + {"set_armor", PyMinqlx_SetArmor, METH_VARARGS, + "Sets a player's armor."}, + {"set_weapons", PyMinqlx_SetWeapons, METH_VARARGS, + "Sets a player's weapons."}, + {"set_weapon", PyMinqlx_SetWeapon, METH_VARARGS, + "Sets a player's current weapon."}, + {"set_ammo", PyMinqlx_SetAmmo, METH_VARARGS, + "Sets a player's ammo."}, + {"set_powerups", PyMinqlx_SetPowerups, METH_VARARGS, + "Sets a player's powerups."}, + {"set_holdable", PyMinqlx_SetHoldable, METH_VARARGS, + "Sets a player's holdable item."}, + {"set_flight", PyMinqlx_SetFlight, METH_VARARGS, + "Sets a player's flight parameters, such as current fuel, max fuel and, so on."}, + {"set_score", PyMinqlx_SetScore, METH_VARARGS, + "Sets a player's score."}, + {"callvote", PyMinqlx_Callvote, METH_VARARGS, + "Calls a vote as if started by the server and not a player."}, + {"allow_single_player", PyMinqlx_AllowSinglePlayer, METH_VARARGS, + "Allows or disallows a game with only a single player in it to go on without forfeiting. Useful for race."}, {NULL, NULL, 0, NULL} }; @@ -628,6 +1342,36 @@ static PyObject* PyMinqlx_InitModule(void) { PyModule_AddIntMacro(module, CS_CONNECTED); PyModule_AddIntMacro(module, CS_PRIMED); PyModule_AddIntMacro(module, CS_ACTIVE); + + // Teams. + PyModule_AddIntMacro(module, TEAM_FREE); + PyModule_AddIntMacro(module, TEAM_RED); + PyModule_AddIntMacro(module, TEAM_BLUE); + PyModule_AddIntMacro(module, TEAM_SPECTATOR); + + // Initialize struct sequence types. + PyStructSequence_InitType(&player_info_type, &player_info_desc); + PyStructSequence_InitType(&player_state_type, &player_state_desc); + PyStructSequence_InitType(&player_stats_type, &player_stats_desc); + PyStructSequence_InitType(&vector3_type, &vector3_desc); + PyStructSequence_InitType(&weapons_type, &weapons_desc); + PyStructSequence_InitType(&powerups_type, &powerups_desc); + PyStructSequence_InitType(&flight_type, &flight_desc); + Py_INCREF((PyObject*)&player_info_type); + Py_INCREF((PyObject*)&player_state_type); + Py_INCREF((PyObject*)&player_stats_type); + Py_INCREF((PyObject*)&vector3_type); + Py_INCREF((PyObject*)&weapons_type); + Py_INCREF((PyObject*)&powerups_type); + Py_INCREF((PyObject*)&flight_type); + // Add new types. + PyModule_AddObject(module, "PlayerInfo", (PyObject*)&player_info_type); + PyModule_AddObject(module, "PlayerState", (PyObject*)&player_state_type); + PyModule_AddObject(module, "PlayerStats", (PyObject*)&player_stats_type); + PyModule_AddObject(module, "Vector3", (PyObject*)&vector3_type); + PyModule_AddObject(module, "Weapons", (PyObject*)&weapons_type); + PyModule_AddObject(module, "Powerups", (PyObject*)&powerups_type); + PyModule_AddObject(module, "Flight", (PyObject*)&flight_type); return module; } diff --git a/quake_common.h b/quake_common.h index 0d80281..a5487a8 100644 --- a/quake_common.h +++ b/quake_common.h @@ -163,6 +163,27 @@ typedef enum { POSTGAME = 0x5, } roundStateState_t; +typedef enum { + STAT_HEALTH, + STAT_HOLDABLE_ITEM, + STAT_RUNE, + STAT_WEAPONS, + STAT_ARMOR, + STAT_BSKILL, + STAT_CLIENTS_READY, + STAT_MAX_HEALTH, + STAT_SPINUP, + STAT_FLIGHT_THRUST, + STAT_MAX_FLIGHT_FUEL, + STAT_CUR_FLIGHT_FUEL, + STAT_FLIGHT_REFUEL, + STAT_QUADKILLS, + STAT_ARMORTYPE, + STAT_KEY, + STAT_OTHER_HEALTH, + STAT_OTHER_ARMOR, +} statIndex_t; + typedef enum { GAME_INIT, // ( int levelTime, int randomSeed, int restart ); // init and shutdown will be called every single level @@ -375,6 +396,13 @@ typedef enum { WP_NUM_WEAPONS = 0x10, } weapon_t; +typedef enum { + WEAPON_READY = 0x0, + WEAPON_RAISING = 0x1, + WEAPON_DROPPING = 0x2, + WEAPON_FIRING = 0x3, +} weaponstate_t; + typedef enum { RUNE_NONE = 0x0, RUNE_SCOUT = 0x1, @@ -419,6 +447,20 @@ typedef enum { MOVER_2TO1 } moverState_t; +enum { + PERS_ROUND_SCORE = 0x0, + PERS_COMBOKILL_COUNT = 0x1, + PERS_RAMPAGE_COUNT = 0x2, + PERS_MIDAIR_COUNT = 0x3, + PERS_REVENGE_COUNT = 0x4, + PERS_PERFORATED_COUNT = 0x5, + PERS_HEADSHOT_COUNT = 0x6, + PERS_ACCURACY_COUNT = 0x7, + PERS_QUADGOD_COUNT = 0x8, + PERS_FIRSTFRAG_COUNT = 0x9, + PERS_PERFECT_COUNT = 0xA, +}; + enum cvar_flags { CVAR_ARCHIVE = 1, CVAR_USERINFO = 2, @@ -1383,6 +1425,7 @@ typedef void (__cdecl *G_InitGame_ptr)(int levelTime, int randomSeed, int restar typedef int (__cdecl *CheckPrivileges_ptr)(gentity_t* ent, char* cmd); typedef char* (__cdecl *ClientConnect_ptr)(int clientNum, qboolean firstTime, qboolean isBot); typedef void (__cdecl *ClientDisconnect_ptr)(int clientNum); +typedef void (__cdecl *ClientSpawn_ptr)(gentity_t* ent); // Some of them are initialized by Initialize(), but not all of them necessarily. extern Com_Printf_ptr Com_Printf; @@ -1413,6 +1456,7 @@ extern G_InitGame_ptr G_InitGame; extern CheckPrivileges_ptr CheckPrivileges; extern ClientConnect_ptr ClientConnect; extern ClientDisconnect_ptr ClientDisconnect; +extern ClientSpawn_ptr ClientSpawn; // Server replacement functions for hooks. void __cdecl My_Cmd_AddCommand(char* cmd, void* func); @@ -1428,6 +1472,7 @@ void __cdecl My_Com_Printf(char* fmt, ...); void __cdecl My_G_RunFrame(int time); void __cdecl My_G_InitGame(int levelTime, int randomSeed, int restart); char* __cdecl My_ClientConnect(int clientNum, qboolean firstTime, qboolean isBot); +void __cdecl My_ClientSpawn(gentity_t* ent); #endif // Custom commands added using Cmd_AddCommand during initialization.