1 /* 2 * Copyright (c) 2017-2018 sel-project 3 * 4 * Permission is hereby granted, free of charge, to any person obtaining a copy 5 * of this software and associated documentation files (the "Software"), to deal 6 * in the Software without restriction, including without limitation the rights 7 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 * copies of the Software, and to permit persons to whom the Software is 9 * furnished to do so, subject to the following conditions: 10 * 11 * The above copyright notice and this permission notice shall be included in all 12 * copies or substantial portions of the Software. 13 * 14 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 * SOFTWARE. 21 * 22 */ 23 /** 24 * Copyright: 2017-2018 sel-project 25 * License: MIT 26 * Authors: Kripth 27 * Source: $(HTTP github.com/sel-project/selery/source/selery/hub/handler/hncom.d, selery/hub/handler/hncom.d) 28 */ 29 module selery.hub.handler.hncom; 30 31 import core.atomic : atomicOp; 32 import core.thread : Thread; 33 34 import std.algorithm : canFind; 35 import std.concurrency : spawn; 36 import std.conv : to; 37 import std.datetime : dur; 38 import std.json : JSONValue; 39 import std.math : round; 40 import std.regex : ctRegex, matchFirst; 41 import std.socket; 42 import std.string; 43 import std.system : Endian; 44 import std.zlib; 45 46 import sel.hncom.about; 47 import sel.hncom.handler : Handler = HncomHandler; 48 import sel.net.modifiers : LengthPrefixedStream; 49 import sel.net.stream : TcpStream; 50 import sel.server.query : Query; 51 import sel.server.util; 52 53 import selery.about; 54 import selery.hub.player : WorldSession = World, PlayerSession, Skin; 55 import selery.hub.server : HubServer; 56 import selery.lang : translate; 57 import selery.util.thread : SafeThread; 58 import selery.util.util : microseconds; 59 60 import Util = sel.hncom.util; 61 import Login = sel.hncom.login; 62 import Status = sel.hncom.status; 63 import Player = sel.hncom.player; 64 65 alias HncomStream = LengthPrefixedStream!(uint, Endian.littleEndian); 66 67 class HncomHandler { 68 69 private shared HubServer server; 70 71 private shared JSONValue* additionalJson; 72 73 private shared Address address; 74 75 public shared this(shared HubServer server, shared JSONValue* additionalJson) { 76 this.server = server; 77 this.additionalJson = additionalJson; 78 } 79 80 public shared void start(inout(string)[] accepted, ushort port) { 81 bool v4, v6, public_; 82 foreach(address ; accepted) { 83 switch(address) { 84 case "127.0.0.1": 85 v4 = true; 86 break; 87 case "::1": 88 v6 = true; 89 break; 90 default: 91 if(address.canFind(":")) v6 = true; 92 else v4 = true; 93 public_ = true; 94 break; 95 } 96 } 97 Address address = getAddress(public_ ? (v4 ? "0.0.0.0" : "::") : (v4 ? "127.0.0.1" : "::1"), port)[0]; 98 Socket socket = new TcpSocket(v4 && v6 ? AddressFamily.INET | AddressFamily.INET6 : address.addressFamily); 99 socket.setOption(SocketOptionLevel.SOCKET, SocketOption.REUSEADDR, true); 100 socket.setOption(SocketOptionLevel.IPV6, SocketOption.IPV6_V6ONLY, !v4 || !v6); 101 socket.blocking = true; 102 socket.bind(address); 103 socket.listen(8); 104 this.address = cast(shared)address; 105 spawn(&this.acceptClients, cast(shared)socket); 106 } 107 108 private shared void acceptClients(shared Socket _socket) { 109 debug Thread.getThis().name = "hncom_server@" ~ (cast()_socket).localAddress.toString(); 110 Socket socket = cast()_socket; 111 while(true) { 112 Socket client = socket.accept(); 113 Address address; 114 try { 115 address = client.remoteAddress; 116 } catch(Exception) { 117 continue; 118 } 119 if(this.server.acceptNode(address)) { 120 new SafeThread(this.server.config.lang, { 121 shared ClassicNode node = new shared ClassicNode(this.server, client, this.additionalJson); 122 delete node; 123 }).start(); 124 } else { 125 client.close(); 126 } 127 } 128 } 129 130 public shared pure nothrow @property @safe @nogc shared(Address) localAddress() { 131 return this.address; 132 } 133 134 } 135 136 /** 137 * Session of a node. It's executed in a dedicated thread. 138 */ 139 abstract class AbstractNode : Handler!serverbound { 140 141 private static shared uint _id; 142 143 public immutable uint id; 144 145 private shared HubServer server; 146 private shared JSONValue* additionalJson; 147 148 protected HncomStream stream; 149 150 private shared bool n_main; 151 private shared string n_name; 152 153 private shared uint[][ubyte] accepted; 154 155 private shared uint n_max; 156 public shared Login.NodeInfo.Plugin[] plugins; 157 158 private shared PlayerSession[immutable(uint)] players; 159 private shared WorldSession[immutable(uint)] _worlds; 160 161 private uint n_latency; 162 163 private shared float n_tps; 164 private shared ulong n_ram; 165 private shared float n_cpu; 166 167 public shared this(shared HubServer server, shared JSONValue* additionalJson) { 168 this.id = atomicOp!"+="(_id, 1); 169 this.server = server; 170 this.additionalJson = additionalJson; 171 } 172 173 protected shared void exchageInfo(HncomStream stream) { 174 with(cast()server.config.hub) { 175 Login.HubInfo.GameInfo[ubyte] games; 176 if(bedrock) games[__BEDROCK__] = Login.HubInfo.GameInfo(bedrock.motd, bedrock.protocols, bedrock.onlineMode, ushort(0)); 177 if(java) games[__JAVA__] = Login.HubInfo.GameInfo(java.motd, java.protocols, java.onlineMode, ushort(0)); 178 this.sendHubInfo(stream, Login.HubInfo(server.id, server.nextPool, displayName, games, server.onlinePlayers, server.maxPlayers, server.config.lang.language, server.config.lang.acceptedLanguages.dup, webAdmin, cast()*this.additionalJson)); 179 } 180 auto info = this.receiveNodeInfo(stream); 181 this.n_max = info.max; 182 this.accepted = cast(shared uint[][ubyte])info.acceptedGames; 183 this.plugins = cast(shared)info.plugins; 184 foreach(node ; server.nodesList) stream.send(node.addPacket.encode()); 185 server.add(this); 186 this.loop(stream); 187 server.remove(this); 188 this.onClosed(); 189 } 190 191 protected abstract shared void sendHubInfo(HncomStream stream, Login.HubInfo packet); 192 193 protected abstract shared Login.NodeInfo receiveNodeInfo(HncomStream stream); 194 195 protected abstract shared void loop(HncomStream stream); 196 197 protected abstract void send(ubyte[] buffer); 198 199 protected shared void send(ubyte[] buffer) { 200 return (cast()this).send(buffer); 201 } 202 203 /** 204 * Gets the name of the node. The name is different for every node 205 * connected to hub and it should be used other nodes with 206 * the transfer function. 207 */ 208 public shared nothrow @property @safe @nogc const string name() { 209 return this.n_name; 210 } 211 212 /** 213 * Indicates whether or not this is a main node. 214 * A main node is able to receive players without the 215 * use of the transfer function. 216 * Every hub should have at least one main node, otherwise 217 * every player that tries to connect will be disconnected with 218 * the 'end of stream' message. 219 */ 220 public shared nothrow @property @safe @nogc const bool main() { 221 return this.n_main; 222 } 223 224 /** 225 * Gets the highest number of players that can connect to the node. 226 */ 227 public shared nothrow @property @safe @nogc const uint max() { 228 return this.n_max; 229 } 230 231 /** 232 * Gets the number of players connected to the node. 233 */ 234 public shared nothrow @property @safe @nogc const uint online() { 235 version(X86_64) { 236 return cast(uint)this.players.length; 237 } else { 238 return this.players.length; 239 } 240 } 241 242 /** 243 * Indicates whether the node is full. 244 */ 245 public shared nothrow @property @safe @nogc const bool full() { 246 return this.max != Login.NodeInfo.UNLIMITED && this.online >= this.max; 247 } 248 249 /** 250 * Gets the list of worlds loaded on the node. 251 */ 252 public shared nothrow @property shared(WorldSession)[] worlds() { 253 return this._worlds.values; 254 } 255 256 /** 257 * Gets the node's latency (it may not be precise). 258 */ 259 public shared nothrow @property @safe @nogc const uint latency() { 260 return this.n_latency; 261 } 262 263 /** 264 * Gets the node's usage, updated with the ResourcesUsage packet. 265 */ 266 public shared nothrow @property @safe @nogc const float tps() { 267 return this.n_tps; 268 } 269 270 /// ditto 271 public shared nothrow @property @safe @nogc const ulong ram() { 272 return this.n_ram; 273 } 274 275 /// ditto 276 public shared nothrow @property @safe @nogc const float cpu() { 277 return this.n_cpu; 278 } 279 280 public shared nothrow @property @safe bool accepts(ubyte game, uint protocol) { 281 auto p = game in this.accepted; 282 return p && (*p).canFind(protocol); 283 } 284 285 public shared @property Status.AddNode addPacket() { 286 return Status.AddNode(this.id, this.name, this.main, cast(uint[][ubyte])this.accepted); 287 } 288 289 protected override void handleUtilUncompressed(Util.Uncompressed packet) { 290 assert(packet.id == 0); //TODO 291 foreach(p ; packet.packets) { 292 if(p.length) this.handleHncom(p.dup); 293 } 294 } 295 296 protected override void handleUtilCompressed(Util.Compressed packet) { 297 this.handleUtilUncompressed(packet.uncompress()); 298 } 299 300 protected override void handleStatusLatency(Status.Latency packet) { 301 this.send(packet.encode()); 302 } 303 304 protected override void handleStatusLog(Status.Log packet) { 305 string name; 306 if(packet.worldId != -1) { 307 auto world = packet.worldId in this._worlds; 308 if(world) name = world.name; 309 } 310 this.server.handleLog((cast(shared)this).name, packet.message, packet.timestamp, packet.commandId, packet.worldId, name); 311 } 312 313 protected override void handleStatusSendMessage(Status.SendMessage packet) { 314 if(packet.addressees.length) { 315 foreach(addressee ; packet.addressees) { 316 auto node = this.server.nodeById(addressee); 317 if(node !is null) node.sendMessage(this.id, false, packet.payload); 318 } 319 } else { 320 foreach(node ; this.server.nodesList) { 321 if(node.id != this.id) node.sendMessage(this.id, true, packet.payload); 322 } 323 } 324 } 325 326 protected override void handleStatusUpdateMaxPlayers(Status.UpdateMaxPlayers packet) { 327 this.n_max = packet.max; 328 this.server.updateMaxPlayers(); 329 } 330 331 protected override void handleStatusUpdateUsage(Status.UpdateUsage packet) { 332 this.n_ram = (cast(ulong)packet.ram) * 1024Lu; 333 this.n_cpu = packet.cpu; 334 } 335 336 protected override void handleStatusUpdateLanguageFiles(Status.UpdateLanguageFiles packet) { 337 this.server.config.lang.add(packet.language, packet.messages); 338 } 339 340 protected override void handleStatusAddWorld(Status.AddWorld packet) { 341 auto world = new shared WorldSession(packet.worldId, packet.name, packet.dimension); 342 if(packet.parent != -1) { 343 auto parent = packet.parent in this._worlds; 344 if(parent) world.parent = *parent; 345 } 346 this._worlds[packet.worldId] = world; 347 } 348 349 protected override void handleStatusRemoveWorld(Status.RemoveWorld packet) { 350 this._worlds.remove(packet.worldId); 351 } 352 353 protected override void handlePlayerKick(Player.Kick packet) { 354 auto player = packet.hubId in this.players; 355 if(player) { 356 this.players.remove(packet.hubId); 357 (*player).kick(packet.reason, packet.translation, packet.parameters); 358 } 359 } 360 361 protected override void handlePlayerTransfer(Player.Transfer packet) { 362 auto player = packet.hubId in this.players; 363 if(player) { 364 this.players.remove(packet.hubId); 365 (*player).connect(Player.Add.TRANSFERRED, packet.node, packet.message, packet.onFail); 366 } 367 } 368 369 protected override void handlePlayerUpdateDisplayName(Player.UpdateDisplayName packet) { 370 auto player = packet.hubId in this.players; 371 if(player) { 372 (*player).displayName = packet.displayName; 373 } 374 } 375 376 protected override void handlePlayerUpdateWorld(Player.UpdateWorld packet) { 377 auto player = packet.hubId in this.players; 378 auto world = packet.worldId in this._worlds; 379 if(player && world) { 380 (*player).world = *world; 381 } 382 } 383 384 protected override void handlePlayerUpdatePermissionLevel(Player.UpdatePermissionLevel packet) { 385 auto player = packet.hubId in this.players; 386 if(player) { 387 (*player).permissionLevel = packet.permissionLevel; 388 } 389 } 390 391 protected override void handlePlayerGamePacket(Player.GamePacket packet) { 392 //TODO compress if needed and send 393 } 394 395 protected override void handlePlayerSerializedGamePacket(Player.SerializedGamePacket packet) { 396 auto player = packet.hubId in this.players; 397 if(player) { 398 (*player).sendFromNode(packet.payload); 399 } 400 } 401 402 protected override void handlePlayerOrderedGamePacket(Player.OrderedGamePacket packet) { 403 auto player = packet.hubId in this.players; 404 if(player) { 405 (*player).sendOrderedFromNode(packet.order, packet.payload); 406 } 407 } 408 409 /** 410 * Sends data to the node received from a player. 411 */ 412 public shared void sendTo(shared PlayerSession player, ubyte[] data) { 413 this.send(Player.GamePacket(player.id, data).encode()); 414 } 415 416 /** 417 * Executes a remote command. 418 */ 419 public shared void remoteCommand(string command, ubyte origin, Address address, int commandId) { 420 this.send(Status.RemoteCommand(origin, address, command, commandId).encode()); 421 } 422 423 /** 424 * Notifies the node that another node has connected 425 * to the hub. 426 */ 427 public shared void addNode(shared AbstractNode node) { 428 this.send(node.addPacket.encode()); 429 } 430 431 /** 432 * Notifies the node that another node has been 433 * disconnected from the hub. 434 */ 435 public shared void removeNode(shared AbstractNode node) { 436 this.send(Status.RemoveNode(node.id).encode()); 437 } 438 439 /** 440 * Sends a message to the node. 441 */ 442 public shared void sendMessage(uint sender, bool broadcasted, ubyte[] payload) { 443 this.send(Status.ReceiveMessage(sender, broadcasted, payload).encode()); 444 } 445 446 /** 447 * Sends the number of online players and maximum number of 448 * players to the node. 449 */ 450 public shared void updatePlayers(inout uint online, inout uint max) { 451 this.send(Status.UpdatePlayers(online, max).encode()); 452 } 453 454 /** 455 * Adds a player to the node. 456 */ 457 public shared void addPlayer(shared PlayerSession player, ubyte reason, ubyte[] transferMessage) { 458 this.players[player.id] = player; 459 this.send(Player.Add(player.id, reason, transferMessage, player.type, player.protocol, player.uuid, player.username, player.displayName, player.gameName, player.gameVersion, player.permissionLevel, player.dimension, player.viewDistance, player.address, Player.Add.ServerAddress(player.serverIp, player.serverPort), player.skin is null ? Player.Add.Skin.init : Player.Add.Skin(player.skin.name, player.skin.data.dup, player.skin.cape.dup, player.skin.geometryName, player.skin.geometryData.dup), player.language, cast(ubyte)player.inputMode, player.hncomAddData()).encode()); 460 } 461 462 /** 463 * Called when a player is transferred by the hub (not by the node) 464 * to another node. 465 */ 466 public shared void onPlayerTransferred(shared PlayerSession player) { 467 this.onPlayerGone(player, Player.Remove.TRANSFERRED); 468 } 469 470 /** 471 * Called when a player lefts the server using the disconnect 472 * button or closing the socket. 473 */ 474 public shared void onPlayerLeft(shared PlayerSession player) { 475 this.onPlayerGone(player, Player.Remove.LEFT); 476 } 477 478 /** 479 * Called when a player times out. 480 */ 481 public shared void onPlayerTimedOut(shared PlayerSession player) { 482 this.onPlayerGone(player, Player.Remove.TIMED_OUT); 483 } 484 485 /** 486 * Called when a player is kicked (not by the node). 487 */ 488 public shared void onPlayerKicked(shared PlayerSession player) { 489 this.onPlayerGone(player, Player.Remove.KICKED); 490 } 491 492 /** 493 * Generic function that removes a player from the 494 * node's list and sends a PlayerDisconnected packet to 495 * notify the node of the disconnection. 496 */ 497 protected shared void onPlayerGone(shared PlayerSession player, ubyte reason) { 498 if(this.players.remove(player.id)) { 499 this.send(Player.Remove(player.id, reason).encode()); 500 } 501 } 502 503 public shared void sendDisplayNameUpdate(shared PlayerSession player, string displayName) { 504 this.send(Player.UpdateDisplayName(player.id, displayName).encode()); 505 } 506 507 public shared void sendPermissionLevelUpdate(shared PlayerSession player, ubyte permissionLevel) { 508 this.send(Player.UpdatePermissionLevel(player.id, permissionLevel).encode()); 509 } 510 511 public shared void sendViewDistanceUpdate(shared PlayerSession player, uint viewDistance) { 512 this.send(Player.UpdateViewDistance(player.id, viewDistance).encode()); 513 } 514 515 public shared void sendLanguageUpdate(shared PlayerSession player, string language) { 516 this.send(Player.UpdateLanguage(player.id, language).encode()); 517 } 518 519 /** 520 * Updates a player's latency (usually sent every 30 seconds). 521 */ 522 public shared void sendLatencyUpdate(shared PlayerSession player) { 523 this.send(Player.UpdateLatency(player.id, player.latency).encode()); 524 } 525 526 /** 527 * Updates a player's packet loss (usually sent every 30 seconds). 528 */ 529 public shared void sendPacketLossUpdate(shared PlayerSession player) { 530 this.send(new Player.UpdatePacketLoss(player.id, player.packetLoss).encode()); 531 } 532 533 /** 534 * Called when the client closes the connection. 535 * Tries to transfer every connected player to the main node. 536 */ 537 public shared void onClosed(bool transfer=true) { 538 if(transfer) { 539 foreach(shared PlayerSession player ; this.players) { 540 player.connect(Player.Add.FORCIBLY_TRANSFERRED); 541 } 542 } else { 543 foreach(shared PlayerSession player ; this.players) { 544 player.kick("disconnect.close", true, []); 545 } 546 } 547 } 548 549 public abstract shared inout string toString(); 550 551 } 552 553 class ClassicNode : AbstractNode { 554 555 private shared Socket socket; 556 private immutable string remoteAddress; 557 558 public shared this(shared HubServer server, Socket socket, shared JSONValue* additionalJson) { 559 super(server, additionalJson); 560 this.socket = cast(shared)socket; 561 this.remoteAddress = socket.remoteAddress.toString(); 562 debug Thread.getThis().name = "hncom_client#" ~ to!string(this.id); 563 socket.setOption(SocketOptionLevel.SOCKET, SocketOption.RCVTIMEO, dur!"msecs"(2500)); 564 socket.blocking = true; 565 auto stream = new HncomStream(new TcpStream(socket, 4096)); 566 this.stream = cast(shared)stream; 567 auto payload = stream.receive(); 568 if(payload.length && payload[0] == Login.ConnectionRequest.ID) { 569 immutable password = server.config.hub.hncomPassword; 570 auto request = Login.ConnectionRequest.fromBuffer(payload[1..$]); 571 this.n_name = request.name.idup; 572 this.n_main = request.main; 573 Login.ConnectionResponse response; 574 if(request.protocol > __PROTOCOL__) response.status = Login.ConnectionResponse.OUTDATED_HUB; 575 else if(request.protocol < __PROTOCOL__) response.status = Login.ConnectionResponse.OUTDATED_NODE; 576 else if(password.length && !password.length) response.status = Login.ConnectionResponse.PASSWORD_REQUIRED; 577 else if(password.length && password != request.password) response.status = Login.ConnectionResponse.WRONG_PASSWORD; 578 else if(!this.n_name.length || this.n_name.length > 32) response.status = Login.ConnectionResponse.INVALID_NAME_LENGTH; 579 else if(!this.n_name.matchFirst(ctRegex!r"[^a-zA-Z0-9_+-.,!?:@#$%\/]").empty) response.status = Login.ConnectionResponse.INVALID_NAME_CHARACTERS; 580 else if(server.nodeNames.canFind(this.n_name)) response.status = Login.ConnectionResponse.NAME_ALREADY_USED; 581 else if(["reload", "stop"].canFind(this.n_name.toLower)) response.status = Login.ConnectionResponse.NAME_RESERVED; 582 stream.send(response.encode()); 583 if(response.status == Login.ConnectionResponse.OK) { 584 this.exchageInfo(stream); 585 } 586 } 587 socket.close(); 588 } 589 590 protected override shared void sendHubInfo(HncomStream stream, Login.HubInfo packet) { 591 stream.send(packet.encode()); 592 } 593 594 protected override shared Login.NodeInfo receiveNodeInfo(HncomStream stream) { 595 stream.stream.socket.setOption(SocketOptionLevel.SOCKET, SocketOption.RCVTIMEO, dur!"minutes"(5)); // giving it the time to load resorces and generate worlds 596 auto payload = stream.receive(); 597 if(payload.length && payload[0] == Login.NodeInfo.ID) return Login.NodeInfo.fromBuffer(payload[1..$]); 598 else return Login.NodeInfo.init; 599 } 600 601 protected override shared void loop(HncomStream stream) { 602 auto _this = cast()this; 603 stream.stream.socket.setOption(SocketOptionLevel.SOCKET, SocketOption.RCVTIMEO, dur!"msecs"(0)); // blocking without timeout 604 while(true) { 605 auto payload = stream.receive(); 606 if(payload.length) _this.handleHncom(payload); 607 else break; // connection closed or error 608 } 609 } 610 611 protected override void send(ubyte[] payload) { 612 this.stream.send(payload); 613 } 614 615 public override shared inout string toString() { 616 return "Node(" ~ to!string(this.id) ~ ", " ~ this.name ~ ", " ~ this.remoteAddress ~ ", " ~ to!string(this.n_main) ~ ")"; 617 } 618 619 } 620 621 class LiteNode : AbstractNode { 622 623 static import std.concurrency; 624 625 public shared static bool ready = false; 626 public shared static std.concurrency.Tid tid; 627 628 private std.concurrency.Tid node; 629 630 public shared this(shared HubServer server, shared JSONValue* additionalJson) { 631 super(server, additionalJson); 632 tid = cast(shared)std.concurrency.thisTid; 633 ready = true; 634 this.node = cast(shared)std.concurrency.receiveOnly!(std.concurrency.Tid)(); 635 this.n_main = true; 636 this.exchageInfo(null); 637 } 638 639 protected override shared void sendHubInfo(HncomStream stream, Login.HubInfo packet) { 640 std.concurrency.send(cast()this.node, cast(shared)packet); 641 } 642 643 protected override shared Login.NodeInfo receiveNodeInfo(HncomStream stream) { 644 return cast()std.concurrency.receiveOnly!(shared Login.NodeInfo)(); 645 } 646 647 protected override shared void loop(HncomStream stream) { 648 auto _this = cast()this; 649 while(true) { 650 ubyte[] payload = std.concurrency.receiveOnly!(immutable(ubyte)[])().dup; 651 if(payload.length) { 652 _this.handleHncom(payload); 653 } else { 654 break; 655 } 656 } 657 } 658 659 protected override void send(ubyte[] buffer) { 660 std.concurrency.send(this.node, buffer.idup); 661 } 662 663 protected override shared void send(ubyte[] buffer) { 664 std.concurrency.send(cast()this.node, buffer.idup); 665 } 666 667 public override shared inout string toString() { 668 return "LiteNode(" ~ to!string(this.id) ~ ")"; 669 } 670 671 }