├── .gitignore ├── mysql.ini ├── gamemodes ├── modules │ ├── utils │ │ ├── dialogs.pwn │ │ └── colors.pwn │ └── core │ │ ├── server │ │ └── database.pwn │ │ └── player │ │ └── account │ │ ├── utils.pwn │ │ └── core.pwn └── main.pwn ├── database_structure.sql └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore the compiled code (AMX) 2 | *.amx -------------------------------------------------------------------------------- /mysql.ini: -------------------------------------------------------------------------------- 1 | hostname = localhost 2 | username = root 3 | database = lsmp 4 | auto_reconnect = true -------------------------------------------------------------------------------- /gamemodes/modules/utils/dialogs.pwn: -------------------------------------------------------------------------------- 1 | // Defines dialog IDs using an enum. Enums are preferred since they automatically 2 | // assign unique values (IDs), eliminating the need to manually track them. 3 | // 4 | // Avoid using magic numbers for dialog IDs - it quickly becomes unclear what 5 | // each value represents. 6 | enum 7 | { 8 | DIALOG_NO_RESPONSE, 9 | 10 | DIALOG_REGISTRATION, 11 | DIALOG_LOGIN 12 | }; -------------------------------------------------------------------------------- /gamemodes/modules/utils/colors.pwn: -------------------------------------------------------------------------------- 1 | //----------------------------------------------------------------------------- 2 | // Normal Colors 3 | //----------------------------------------------------------------------------- 4 | 5 | #define COLOR_WHITE (0xFFFFFFFF) 6 | #define COLOR_RED (0xFFDE97FF) 7 | 8 | //----------------------------------------------------------------------------- 9 | // Embedded Colors 10 | //----------------------------------------------------------------------------- 11 | 12 | #define EMBED_WHITE "{FFFFFF}" 13 | #define EMBED_RED "{FFDE97}" -------------------------------------------------------------------------------- /database_structure.sql: -------------------------------------------------------------------------------- 1 | -- phpMyAdmin SQL Dump 2 | -- version 5.2.1 3 | -- https://www.phpmyadmin.net/ 4 | -- 5 | -- Host: 127.0.0.1 6 | -- Generation Time: Jun 11, 2025 at 08:44 PM 7 | -- Server version: 10.4.32-MariaDB 8 | -- PHP Version: 8.0.30 9 | 10 | SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; 11 | START TRANSACTION; 12 | SET time_zone = "+00:00"; 13 | 14 | 15 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 16 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; 17 | /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; 18 | /*!40101 SET NAMES utf8mb4 */; 19 | 20 | -- 21 | -- Database: `mydatabase` 22 | -- 23 | 24 | -- -------------------------------------------------------- 25 | 26 | -- 27 | -- Table structure for table `player_accounts` 28 | -- 29 | 30 | CREATE TABLE `player_accounts` ( 31 | `account_id` int(10) UNSIGNED NOT NULL, 32 | `username` varchar(24) NOT NULL, 33 | `password_hash` CHAR(60) BINARY NOT NULL, 34 | `register_date` datetime DEFAULT current_timestamp() 35 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 36 | 37 | 38 | -- 39 | -- Indexes for dumped tables 40 | -- 41 | 42 | -- 43 | -- Indexes for table `player_accounts` 44 | -- 45 | ALTER TABLE `player_accounts` 46 | ADD PRIMARY KEY (`account_id`), 47 | ADD UNIQUE KEY `username` (`username`); 48 | 49 | -- 50 | -- AUTO_INCREMENT for dumped tables 51 | -- 52 | 53 | -- 54 | -- AUTO_INCREMENT for table `player_accounts` 55 | -- 56 | ALTER TABLE `player_accounts` 57 | MODIFY `account_id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=1; 58 | COMMIT; 59 | 60 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 61 | /*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; 62 | /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; 63 | -------------------------------------------------------------------------------- /gamemodes/modules/core/server/database.pwn: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | //----------------------------------------------------------------------------- 4 | // Definitions 5 | //----------------------------------------------------------------------------- 6 | 7 | // Stores the current database connection handle. This handle is used for 8 | // executing all subsequent MySQL operations throughout the gamemode. 9 | new MySQL:g_DatabaseHandle; 10 | 11 | // ----------------------------------------------------------------------------- 12 | // Hooked Callbacks 13 | // ----------------------------------------------------------------------------- 14 | 15 | hook OnGameModeInit() 16 | { 17 | // Attempt to connect to the MySQL server using `mysql_connect_file`. 18 | // This reads credentials from a plaintext INI file named `mysql.ini` 19 | // located in the open.mp server root directory (no subfolders allowed). 20 | // 21 | // This method is ideal for separating database secrets from the codebase. 22 | g_DatabaseHandle = mysql_connect_file("mysql.ini"); 23 | 24 | // `mysql_errno` returns 0 on success and -1 on failure. 25 | if (mysql_errno(g_DatabaseHandle) == 0) 26 | { 27 | print("-----------------------------------------------"); 28 | print("Successfully connected to database!"); 29 | print("-----------------------------------------------"); 30 | } 31 | else 32 | { 33 | print("-----------------------------------------------"); 34 | print("Failed to connect to database!"); 35 | print("Please verify your mysql.ini settings"); 36 | print("Server will be locked for maintenance..."); 37 | print("-----------------------------------------------"); 38 | 39 | // Lock down the server to prevent players from joining during DB failure. 40 | // You can comment these lines if you prefer the server to stay open. 41 | SendRconCommand("password TvHRY2FmQjXEsCq"); 42 | SendRconCommand("name Server is under maintenance!"); 43 | } 44 | 45 | return 1; 46 | } 47 | 48 | hook OnGameModeExit() 49 | { 50 | // Safely close the database connection when the gamemode unloads. 51 | mysql_close(g_DatabaseHandle); 52 | 53 | return 1; 54 | } 55 | -------------------------------------------------------------------------------- /gamemodes/main.pwn: -------------------------------------------------------------------------------- 1 | /*=============================================================================== 2 | Project : A simple open.mp base gamemode 3 | Author : Mido 4 | Date : June 11, 2025 5 | Target Server : open multiplayer (open.mp) 6 | 7 | Description : 8 | -------------------------------------------------------------------- 9 | This is a simple yet useful base for an open.mp gamemode. Its purpose 10 | is to assist new scripters, whether they're just getting started or 11 | returning to the SA:MP scene after a break. 12 | 13 | For additional details, refer to the README.md file. 14 | 15 | Dependencies : 16 | -------------------------------------------------------------------- 17 | - open.mp server (latest version) 18 | - Plugin: MySQL by maddinat0r and blueG (R41-4) 19 | - Plugin: BCrypt by Sreyas-Sreelal (0.4.1) 20 | - Library: YSI by Y_Less (v5.10.0006) 21 | 22 | Thanks to : 23 | -------------------------------------------------------------------- 24 | - SA:MP and open.mp Teams past, present and future. 25 | - Mido - Writing this gamemode. 26 | - Kevin - Highly constructive suggestions and insights. 27 | - itsneufox - Testing the script. 28 | 29 | Repository : 30 | -------------------------------------------------------------------- 31 | - GitHub: https://github.com/midosvt/omp-base-script 32 | 33 | ===============================================================================*/ 34 | 35 | //----------------------------------------------------------------------------- 36 | // Predefinitions 37 | //----------------------------------------------------------------------------- 38 | 39 | // Redefine `MAX_PLAYERS` to match our player slot. This value must align with 40 | // the `max_players` setting in the `config.json` file to prevent any issues. 41 | #define MAX_PLAYERS (20) 42 | 43 | // Allows both American and British English spellings. It is required to 44 | // preserve compatibility with how it was in SA:MP. 45 | #define MIXED_SPELLINGS 46 | 47 | //----------------------------------------------------------------------------- 48 | // Script Dependencies 49 | //----------------------------------------------------------------------------- 50 | 51 | // Core 52 | #include 53 | 54 | // Plugins 55 | #include 56 | #include 57 | 58 | // YSI 59 | #include 60 | 61 | //----------------------------------------------------------------------------- 62 | // Script Modules 63 | //----------------------------------------------------------------------------- 64 | 65 | // Definitions and Utilities 66 | #include "modules/utils/colors.pwn" 67 | #include "modules/utils/dialogs.pwn" 68 | 69 | // Server 70 | #include "modules/core/server/database.pwn" 71 | 72 | // Player Account 73 | #include "modules/core/player/account/utils.pwn" 74 | #include "modules/core/player/account/core.pwn" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This is a simple yet practical base script designed for anyone looking to develop gamemodes for [open.mp](https://open.mp/) or SA:MP. Whether you're a beginner starting from scratch or a returning developer familiar with the platform, this script offers a clean foundation to build on. 4 | 5 | It uses [MySQL](https://github.com/pBlueG/SA-MP-MySQL) for handling data and [BCrypt](https://github.com/Sreyas-Sreelal/samp-bcrypt) for secure password hashing. While the script itself is straightforward, it does assume you have at least a basic understanding of Pawn scripting and MySQL/SQL. If you're willing to learn and explore how each part works, this base will be a solid starting point for your own projects. 6 | 7 | # Installation 8 | 9 | This section provides a step-by-step guide to get the gamemode running properly on your setup. 10 | 11 | ## Downloads 12 | 13 | Download the following files before proceeding: 14 | * **[open.mp server (latest version)](https://github.com/openmultiplayer/open.mp/releases/latest)** 15 | * **[YSI includes (latest version)](https://github.com/pawn-lang/YSI-Includes/releases/latest)** 16 | * **[MySQL plugin (R41-4)](https://github.com/pBlueG/SA-MP-MySQL/releases/tag/R41-4)** 17 | * **[BCrypt plugin (v0.4.1)](https://github.com/Sreyas-Sreelal/samp-bcrypt/releases/tag/0.4.1)** 18 | 19 | ## Setup Instructions 20 | 21 | **1. open.mp server** 22 | - Extract the open.mp server files into the **root folder** of your server. 23 | 24 | **2. YSI Library** 25 | - Extract the contents of the archive into `qawno/include/`. 26 | 27 | **3. MySQL and BCrypt Plugins** 28 | - Place the plugin files (`.dll` for Windows or `.so` for Linux) inside the `plugins/` directory. 29 | - Move the corresponding `.inc` include files to `qawno/include/`. 30 | 31 | **4. MySQL Dependencies** 32 | - Files like `libmariadb.dll`, `log-core.dll` (`log-core.so` for Linux), or any additional libraries should be placed directly in the server's **root folder**. 33 | 34 | > [!NOTE] 35 | > Ensure that all plugin names are correctly listed in your `config.json` file under the `legacy_plugins` setting. 36 | 37 | ## Database 38 | 39 | This gamemode uses [`mysql_connect_file`](https://github.com/pBlueG/SA-MP-MySQL/wiki#mysql_connect_file) to connect to the database through an INI-style configuration file. This file contains all the necessary credentials and settings needed to establish a connection. 40 | 41 | Example configuration file: 42 | **[mysql.ini](https://github.com/midosvt/omp-base-script/blob/master/mysql.ini)** 43 | 44 | Please refer to the [MySQL plugin documentation](https://github.com/pBlueG/SA-MP-MySQL/wiki#mysql_connect_file) for details on all available fields and options. 45 | 46 | Download the database structure: 47 | **[database_structure.sql](https://github.com/midosvt/omp-base-script/blob/master/database_structure.sql)** 48 | 49 | > [!NOTE] 50 | > Make sure to import the `.sql` structure into your database before launching the server, otherwise account registration and login features will not function properly. 51 | 52 | # Structure 53 | 54 | This gamemode follows a modular structure to keep the codebase clean, organized, and easy to maintain. If you're not familiar with modular programming, check out [this tutorial](https://sampforum.blast.hk/showthread.php?tid=597338&highlight=Modular+programming) before making any changes. It's highly recommended to stick with this approach, as it makes development, debugging, and future updates much easier. 55 | 56 | # Contributing 57 | 58 | Everyone is welcome to contribute - whether it's advice, pull requests, or suggestions. 59 | 60 | If you notice any bugs, issues, or have ideas to make things better, feel free to open an issue or join the discussion on [our Discord](https://discord.gg/samp). 61 | 62 | # Special Thanks to 63 | - SA:MP and open.mp teams (past, present, and future). 64 | - Mido for developing this gamemode. 65 | - BlueG and maddinat0r for the MySQL plugin. 66 | - Sys for the BCrypt plugin. 67 | - Y_Less for the YSI library. 68 | - Kevin for his highly constructive suggestions and insights. 69 | - itsneufox for testing this script. 70 | -------------------------------------------------------------------------------- /gamemodes/modules/core/player/account/utils.pwn: -------------------------------------------------------------------------------- 1 | //----------------------------------------------------------------------------- 2 | // Definitions 3 | //----------------------------------------------------------------------------- 4 | 5 | // Password length limits for account creation. 6 | #define ACCOUNT_MIN_PASSWORD_LENGTH (6) 7 | 8 | // Invalid database account ID. 9 | #define INVALID_ACCOUNT_ID (0) 10 | 11 | // Player account data variables. 12 | static 13 | s_PlayerAccountID[MAX_PLAYERS] = { INVALID_ACCOUNT_ID, ... }, 14 | bool:s_IsPlayerLoggedIn[MAX_PLAYERS] = { false, ... }; 15 | 16 | //----------------------------------------------------------------------------- 17 | // Functions 18 | //----------------------------------------------------------------------------- 19 | 20 | // Checks whether an account with the player's name already exists in the database. 21 | // This function is used to determine if the player should register or log in. 22 | Account_Check(playerid) 23 | { 24 | // Initialize the variables here. 25 | s_PlayerAccountID[playerid] = INVALID_ACCOUNT_ID; 26 | SetPlayerLoggedIn(playerid, false); 27 | 28 | new 29 | query[128]; 30 | 31 | mysql_format(g_DatabaseHandle, query, sizeof (query), "SELECT `password_hash` FROM `player_accounts` WHERE `username` = '%e' LIMIT 1;", ReturnPlayerName(playerid)); 32 | mysql_tquery(g_DatabaseHandle, query, "OnPlayerAccountCheck", "d", playerid); 33 | 34 | return 1; 35 | } 36 | 37 | // Shows the registration dialog to the player. 38 | Account_ShowRegistrationDialog(playerid, bool:badpass = false) 39 | { 40 | // Show the dialog. 41 | ShowPlayerDialog( 42 | playerid, 43 | DIALOG_REGISTRATION, 44 | DIALOG_STYLE_PASSWORD, 45 | "Registration", 46 | "Create a password for your new account.\n\n\ 47 | The password must be longer than %d characters:", 48 | "Register", 49 | "Quit", 50 | ACCOUNT_MIN_PASSWORD_LENGTH 51 | ); 52 | 53 | // If `badpass` is true, it means the player's password didn't meet the length 54 | // requirements, so we show a warning explaining what went wrong. 55 | if (badpass) 56 | { 57 | SendClientMessage(playerid, COLOR_RED, "Sorry, something went wrong. The password you chose is too short."); 58 | SendClientMessage(playerid, COLOR_RED, "Please choose a stronger password and try again."); 59 | } 60 | 61 | return 1; 62 | } 63 | 64 | // Shows the login dialog to the player. 65 | Account_ShowLoginDialog(playerid) 66 | { 67 | // Show the dialog. 68 | ShowPlayerDialog( 69 | playerid, 70 | DIALOG_LOGIN, 71 | DIALOG_STYLE_PASSWORD, 72 | "Login", 73 | "Type your password below to login.", 74 | "Login", 75 | "Quit" 76 | ); 77 | 78 | return 1; 79 | } 80 | 81 | // Validates the player's password length and format. 82 | bool:IsValidPassword(const password[]) 83 | { 84 | // Check if password length is within allowed limits. 85 | if (strlen(password) < ACCOUNT_MIN_PASSWORD_LENGTH) 86 | { 87 | // Password length invalid. 88 | return false; 89 | } 90 | 91 | // Additional validations can be added here in the future, such as checking 92 | // for symbols, uppercase, lowercase letters, etc. 93 | 94 | // Password is valid. 95 | return true; 96 | } 97 | 98 | // Hashes the given password for the specified player. 99 | HashPassword(playerid, const password[]) 100 | { 101 | bcrypt_hash(playerid, "OnPasswordHash", password, BCRYPT_COST); 102 | } 103 | 104 | // Creates a new player account in the database. 105 | Account_Create(playerid, const hash[]) 106 | { 107 | new 108 | query[256]; 109 | 110 | mysql_format(g_DatabaseHandle, query, sizeof(query), 111 | "INSERT INTO `player_accounts` (\ 112 | `username`, \ 113 | `password_hash` \ 114 | ) VALUES ('%e', '%e');", 115 | ReturnPlayerName(playerid), hash 116 | ); 117 | mysql_tquery(g_DatabaseHandle, query, "OnPlayerRegister", "d", playerid); 118 | 119 | return 1; 120 | } 121 | 122 | // Sets the account ID for the specified player. 123 | SetPlayerAccountID(playerid, accountid) 124 | { 125 | s_PlayerAccountID[playerid] = accountid; 126 | 127 | return 1; 128 | } 129 | 130 | // Returns the player's account ID. 131 | stock GetPlayerAccountID(playerid) 132 | { 133 | return IsPlayerConnected(playerid) 134 | ? s_PlayerAccountID[playerid] 135 | : INVALID_ACCOUNT_ID 136 | ; 137 | } 138 | 139 | // Sets the player's logged-in state. 140 | SetPlayerLoggedIn(playerid, bool:set) 141 | { 142 | // Ensure the player is connected before modifying state. 143 | if (!IsPlayerConnected(playerid)) 144 | { 145 | return 0; 146 | } 147 | 148 | // Update the player's login state. 149 | s_IsPlayerLoggedIn[playerid] = set; 150 | 151 | return 1; 152 | } -------------------------------------------------------------------------------- /gamemodes/modules/core/player/account/core.pwn: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | //----------------------------------------------------------------------------- 4 | // Definitions 5 | //----------------------------------------------------------------------------- 6 | 7 | // Called after `Account_Check` completes. Handle login or registration here. 8 | forward public OnPlayerAccountCheck(playerid); 9 | 10 | // Called after a password is hashed during registration. 11 | forward public OnPasswordHash(playerid); 12 | 13 | // Called after a new account has been successfully inserted into the database. 14 | forward public OnPlayerRegister(playerid); 15 | 16 | // Called after a password check during login. 17 | forward public OnPasswordCheck(playerid, bool:match); 18 | 19 | // Called when a player sucessfully logs in. 20 | forward public OnPlayerLogin(playerid); 21 | 22 | // Maximum allowed login attempts before a player is kicked. 23 | #define MAX_LOGIN_ATTEMPTS (3) 24 | 25 | // Stores the number of login attempts made by each player. 26 | static s_PlayerLoginAttempts[MAX_PLAYERS] = { 0, ... }; 27 | 28 | //----------------------------------------------------------------------------- 29 | // Hooks 30 | //----------------------------------------------------------------------------- 31 | 32 | hook OnPlayerConnect(playerid) 33 | { 34 | // Checks if an account exists for the player in the database. 35 | Account_Check(playerid); 36 | 37 | return 1; 38 | } 39 | 40 | // Called after `Account_Check` completes. Handle login or registration here. 41 | hook OnPlayerAccountCheck(playerid) 42 | { 43 | // No account found. Prompt registration. 44 | if (cache_num_rows() == 0) 45 | { 46 | Account_ShowRegistrationDialog(playerid); 47 | return 1; 48 | } 49 | 50 | // Account found. 51 | 52 | // Fetch the player's password hash for some checking later. 53 | new tempPassword[BCRYPT_HASH_LENGTH]; 54 | cache_get_value_name(0, "password_hash", tempPassword); 55 | SetPVarString(playerid, "tempPassword", tempPassword); 56 | 57 | // Show login dialog. 58 | Account_ShowLoginDialog(playerid); 59 | 60 | return 1; 61 | } 62 | 63 | hook OnDialogResponse(playerid, dialogid, response, listitem, inputtext[]) 64 | { 65 | // Fun fact, `switch` is way faster in open.mp. 66 | switch (dialogid) 67 | { 68 | case DIALOG_REGISTRATION: 69 | { 70 | if (!response) 71 | { 72 | Kick(playerid); 73 | } 74 | else 75 | { 76 | // Check if the password meets the requirements. 77 | if (!IsValidPassword(inputtext)) 78 | { 79 | Account_ShowRegistrationDialog(playerid, .badpass = true); 80 | } 81 | else 82 | { 83 | // Password looks good. Now hash the password. 84 | HashPassword(playerid, inputtext); 85 | } 86 | } 87 | 88 | // Return 1 to indicate the dialog was handled. 89 | return 1; 90 | } 91 | 92 | case DIALOG_LOGIN: 93 | { 94 | if (!response) 95 | { 96 | Kick(playerid); 97 | } 98 | else 99 | { 100 | // Get the temporarily stored hash. 101 | new tempHash[BCRYPT_HASH_LENGTH]; 102 | GetPVarString(playerid, "tempPassword", tempHash); 103 | 104 | // Compare hashes. 105 | bcrypt_verify(playerid, "OnPasswordCheck", inputtext, tempHash); 106 | } 107 | 108 | // Return 1 to indicate the dialog was handled. 109 | return 1; 110 | } 111 | } 112 | 113 | // Return 0 for unhandled dialogs, just like in `OnPlayerCommandText`. 114 | return 0; 115 | } 116 | 117 | // Called after a password is hashed. 118 | hook OnPasswordHash(playerid) 119 | { 120 | // Retrieve the password hash generated by BCrypt. 121 | new hash[BCRYPT_HASH_LENGTH]; 122 | bcrypt_get_hash(hash); 123 | 124 | // Create an account for the player and insert it into the database. 125 | Account_Create(playerid, hash); 126 | 127 | return 1; 128 | } 129 | 130 | // Called after a new account has been successfully inserted into the database. 131 | hook OnPlayerRegister(playerid) 132 | { 133 | // Cache the last inserted ID from the database for this player (account ID). 134 | new const accountID = cache_insert_id(); 135 | SetPlayerAccountID(playerid, accountID); 136 | 137 | // Mark the player as logged in. 138 | SetPlayerLoggedIn(playerid, true); 139 | 140 | return 1; 141 | } 142 | 143 | // Called after a password check during login. 144 | hook OnPasswordCheck(playerid, bool:match) 145 | { 146 | // Password is correct. 147 | if (match) 148 | { 149 | // Mark the player as logged in. 150 | SetPlayerLoggedIn(playerid, true); 151 | 152 | // Password hash should not be kept in memory. 153 | DeletePVar(playerid, "tempPassword"); 154 | 155 | // Reset attempts. 156 | s_PlayerLoginAttempts[playerid] = 0; 157 | 158 | // You can load the player's data here, etc. 159 | CallLocalFunction("OnPlayerLogin", "d", playerid); 160 | } 161 | 162 | // Password is not correct. 163 | else 164 | { 165 | s_PlayerLoginAttempts[playerid]++; 166 | 167 | // If maximum attempts exceeded, kick the player. 168 | if (s_PlayerLoginAttempts[playerid] >= MAX_LOGIN_ATTEMPTS) 169 | { 170 | SendClientMessage(playerid, COLOR_RED, "You've been kicked due to too many failed login attempts."); 171 | Kick(playerid); 172 | } 173 | else 174 | { 175 | // Otherwise, let the player try again. 176 | Account_ShowLoginDialog(playerid); 177 | 178 | // Attempts remaining. 179 | new attemptsLeft = MAX_LOGIN_ATTEMPTS - s_PlayerLoginAttempts[playerid]; 180 | SendClientMessage(playerid, COLOR_RED, "Wrong password. You still have %d %s left.", attemptsLeft, attemptsLeft == 1 ? "attempt" : "attempts"); 181 | } 182 | } 183 | 184 | return 1; 185 | } 186 | 187 | // Called when a player sucessfully logs in. 188 | hook OnPlayerLogin(playerid) 189 | { 190 | // You can load the player's data here, etc. 191 | 192 | return 1; 193 | } --------------------------------------------------------------------------------