/** * [CSRD] Custom Achievements * * The custom achievement implementation for Pikachu's Canadian Server of Romance and Drama. */ #pragma semicolon 1 #include #include #include #pragma newdecls required #include #include #define PLUGIN_VERSION "0.1.2" public Plugin myinfo = { name = "[CSRD] Custom Achievements", author = "nosoop", description = "Provides a lightweight API for custom achievements.", version = PLUGIN_VERSION, url = "https://git.csrd.science/" } // Maximum language shortcode length (2-3 characters usually, though 'pt_p' is a thing). #define MAX_LANG_SC_LENGTH 8 // Head attachment point index for player. #define ATTACHMENT_HEAD 1 public APLRes AskPluginLoad2(Handle hPlugin, bool late, char[] error, int err_max) { RegPluginLibrary("custom-achievements"); CreateNative("CustomAchievement.CustomAchievement", Native_RegisterAchievement); CreateNative("CustomAchievement.AwardToAccountID", Native_AwardAchievement); CreateNative("CustomAchievement.FetchMetadataByAccountID", Native_FetchAchievementMetadata); CreateNative("CustomAchievement.StoreMetadataByAccountID", Native_StoreAchievementMetadata); CreateNative("CustomAchievement.ResetByAccountID", Native_ResetAchievement); return APLRes_Success; } ConVar g_ConVarDebug; Database g_Database; DBStatement g_QueryRegisterAchievement; DBStatement g_QueryGetAchievementIdentifier; Handle g_ForwardOnCustomAchievementAwarded; float g_flLastEarnedAchievementTime[MAXPLAYERS+1]; public void OnPluginStart() { g_ConVarDebug = CreateConVar("custom_achievement_debug", "0", "Generate debug spew?"); char error[64]; if ((g_Database = SQLite_UseDatabase("custom-achievements", error, sizeof(error))) == INVALID_HANDLE) { SetFailState("Could not use database custom-achievements for custom achievements: %s", error); } g_QueryRegisterAchievement = SQL_PrepareQuery(g_Database, "INSERT OR IGNORE INTO achievements (name, displaytype) VALUES (?, ?)", error, sizeof(error)); if (!g_QueryRegisterAchievement) { SetFailState("Could not create achievement instantiation query: %s", error); } g_QueryGetAchievementIdentifier = SQL_PrepareQuery(g_Database, "SELECT achievement_id FROM achievements WHERE name = ?", error, sizeof(error)); if (!g_QueryGetAchievementIdentifier) { SetFailState("Could not create achievement identifier query: %s", error); } g_ForwardOnCustomAchievementAwarded = CreateGlobalForward("OnCustomAchievementAwarded", ET_Ignore, Param_Cell, Param_Cell); HookEvent("achievement_earned", OnAchievementEarned); } public void OnClientConnected(int client) { g_flLastEarnedAchievementTime[client] = 0.0; } public void OnAchievementEarned(Event event, const char[] name, bool dontBroadcast) { int recipient = event.GetInt("player"); if (recipient && recipient <= MaxClients) { g_flLastEarnedAchievementTime[recipient] = GetGameTime(); } } public int Native_RegisterAchievement(Handle hPlugin, int argc) { int internalNameLength; GetNativeStringLength(1, internalNameLength); internalNameLength++; char[] internalName = new char[internalNameLength]; GetNativeString(1, internalName, internalNameLength); int achievementStyle = GetNativeCell(2); // Attempt to fetch achievement first, creating it if it doesn't exist. g_QueryGetAchievementIdentifier.BindString(0, internalName, false); DBResultSet achievementResults = SQL_ExecuteStatement(g_QueryGetAchievementIdentifier); int iAchievement; if (achievementResults.FetchRow()) { iAchievement = achievementResults.FetchInt(0); } else { g_QueryRegisterAchievement.BindString(0, internalName, false); g_QueryRegisterAchievement.BindInt(1, achievementStyle, false); DBResultSet registerResults = SQL_ExecuteStatement(g_QueryRegisterAchievement); iAchievement = registerResults.InsertId; } LogDebug("Achievement '%s' registered under identifier %d", internalName, iAchievement); return iAchievement; } public int Native_AwardAchievement(Handle hPlugin, int argc) { int iAchievement = GetNativeCell(1); int steamid3 = GetNativeCell(2); bool notify = GetNativeCell(3) != 0; char query[1024]; Format(query, sizeof(query), "SELECT achieved FROM achievement_status " ... "WHERE steamid3 = '%d' AND achievement_id = '%d'", steamid3, iAchievement); DataPack recipientData = new DataPack(); recipientData.WriteCell(iAchievement); recipientData.WriteCell(steamid3); recipientData.WriteCell(notify); LogDebug("Awarding achievement %d to steamid %d", iAchievement, steamid3); SQL_TQuery(g_Database, SQLT_OnPreAchievementAwarded, query, recipientData); } public void SQLT_OnPreAchievementAwarded(Handle hOwner, Handle hChild, const char[] error, DataPack recipientData) { recipientData.Reset(); int iAchievement = recipientData.ReadCell(); int steamid3 = recipientData.ReadCell(); bool notify = recipientData.ReadCell() != 0; delete recipientData; DBResultSet resultSet = view_as(hChild); if (!resultSet || !resultSet.FetchRow() || resultSet.FetchInt(0) == 0) { LogDebug("Account %d does not have achievement yet. Granting.", steamid3); char query[1024]; Transaction transaction = new Transaction(); // update player row to indicate achievement Format(query, sizeof(query), "UPDATE OR IGNORE achievement_status SET achieved=%d " ... "WHERE steamid3 = '%d' AND achievement_id = '%d'", GetTime(), steamid3, iAchievement); transaction.AddQuery(query); // create new row for player achievement if it doesn't exist Format(query, sizeof(query), "INSERT OR IGNORE INTO achievement_status " ..."(achievement_id, steamid3, achieved) VALUES (%d, %d, %d)", iAchievement, steamid3, GetTime()); transaction.AddQuery(query); // transaction is autoclosed SQL_ExecuteTransaction(g_Database, transaction); if (notify) { // perform query to get all localized strings, then fetch each and send to clients as appropriate CallOnCustomAchievementAwarded(steamid3, iAchievement); } } } public int Native_FetchAchievementMetadata(Handle hPlugin, int argc) { int iAchievement = GetNativeCell(1); int steamid3 = GetNativeCell(2); DataPack fetchPack = new DataPack(); fetchPack.WriteCell(iAchievement); fetchPack.WriteCell(hPlugin); fetchPack.WriteFunction(GetNativeFunction(3)); fetchPack.WriteCell(GetNativeCell(4)); char query[1024]; Format(query, sizeof(query), "SELECT metadata FROM achievement_status " ... "WHERE achievement_id = %d AND steamid3 = %d", iAchievement, steamid3); SQL_TQuery(g_Database, SQLT_OnMetadataReceived, query, fetchPack); } public void SQLT_OnMetadataReceived(Handle owner, Handle child, const char[] error, DataPack fetchPack) { fetchPack.Reset(); int iAchievement = fetchPack.ReadCell(); Handle hPlugin = fetchPack.ReadCell(); Function callback = fetchPack.ReadFunction(); any data = fetchPack.ReadCell(); delete fetchPack; if (GetPluginStatus(hPlugin) != Plugin_Running) { ThrowError("Calling plugin was unloaded before a query callback"); return; } Call_StartFunction(hPlugin, callback); Call_PushCell(iAchievement); DBResultSet resultSet = view_as(child); if (resultSet && resultSet.FetchRow()) { int metadataLength = resultSet.FetchSize(0) + 1; char[] metadata = new char[metadataLength]; resultSet.FetchString(0, metadata, metadataLength); Call_PushString(metadata); /** * quirk: we have to finish the call before metadata goes out of scope, otherwise the * callback gets an empty string??? */ Call_PushCell(data); Call_Finish(); } else { Call_PushString(""); Call_PushCell(data); Call_Finish(); } } public int Native_StoreAchievementMetadata(Handle hPlugin, int argc) { int iAchievement = GetNativeCell(1); int steamid3 = GetNativeCell(2); int metadataLength; GetNativeStringLength(3, metadataLength); metadataLength++; metadataLength *= 2; // buffer must be at least 2*strlen(string)+1 [when SQL escaping] char[] metadata = new char[metadataLength]; GetNativeString(3, metadata, metadataLength); SQL_EscapeString(g_Database, metadata, metadata, metadataLength); int queryLength = metadataLength + 1024; char[] query = new char[queryLength]; // attempt to update an existing row Transaction transaction = new Transaction(); Format(query, queryLength, "UPDATE OR IGNORE achievement_status SET metadata='%s' " ... "WHERE steamid3 = '%d' AND achievement_id = '%d'", metadata, steamid3, iAchievement); transaction.AddQuery(query); // create row if it doesn't Format(query, queryLength, "INSERT OR IGNORE INTO achievement_status " ..."(achievement_id, steamid3, metadata) VALUES (%d, %d, '%s')", iAchievement, steamid3, metadata); transaction.AddQuery(query); // transaction is autoclosed SQL_ExecuteTransaction(g_Database, transaction); LogDebug("Wrote metadata for achievement %d under steamid3 %d (%s)", iAchievement, steamid3, metadata); } public int Native_ResetAchievement(Handle hPlugin, int argc) { int iAchievement = GetNativeCell(1); int steamid3 = GetNativeCell(2); char query[256]; // We update the entries instead of deleting the row in case the row is regenerated anyways Transaction transaction = new Transaction(); Format(query, sizeof(query), "UPDATE OR IGNORE achievement_status " ... "SET metadata='', achieved=0 " ... "WHERE steamid3 = '%d' AND achievement_id = '%d'", steamid3, iAchievement); transaction.AddQuery(query); SQL_ExecuteTransaction(g_Database, transaction); LogDebug("Reset achievement %d for steamid3 %d", iAchievement, steamid3); } void CallOnCustomAchievementAwarded(int steamid3, int iAchievement) { int recipient = FindClientByAccountID(steamid3); if (recipient) { char recipientName[MAX_NAME_LENGTH]; GetClientName(recipient, recipientName, sizeof(recipientName)); // int nClients; // int clients[MAXPLAYERS+1]; TransmitAchievementEvent(recipient, iAchievement); Call_StartForward(g_ForwardOnCustomAchievementAwarded); Call_PushCell(recipient); Call_PushCell(iAchievement); Call_Finish(); LogDebug("%N was awarded achievement %d", recipient, iAchievement); } } /** * Prepares the achievement event by pulling localization strings. * If no localization strings are available, the achievement effects (particles and sound) are * not played. */ void TransmitAchievementEvent(int recipient, int iAchievement) { char query[512]; Format(query, sizeof(query), "SELECT language_shortcode, achievement_local_name, achievement_local_description " ... "FROM achievement_languages JOIN achievements ON achievement_name = name " ... "WHERE achievement_id = '%d'", iAchievement); DataPack pack = new DataPack(); pack.WriteCell(GetClientUserId(recipient)); pack.WriteCell(iAchievement); SQL_TQuery(g_Database, SQLT_OnLocalizedAchievementStringsReceived, query, pack); } public void SQLT_OnLocalizedAchievementStringsReceived(Handle hOwner, Handle hChild, const char[] error, DataPack pack) { pack.Reset(); int recipient = GetClientOfUserId(pack.ReadCell()); int iAchievement = pack.ReadCell(); if (recipient) { DBResultSet resultSet = view_as(hChild); if (resultSet && resultSet.RowCount > 0) { KeyValues localizedStrings = new KeyValues("Achievement"); // Put all name / descriptions into sections corresponding to language shortcodes. while (resultSet.FetchRow()) { int nNameLength, nDescriptionLength; char languageShortCode[MAX_LANG_SC_LENGTH]; resultSet.FetchString(0, languageShortCode, sizeof(languageShortCode)); nNameLength = resultSet.FetchSize(1) + 1; char[] achievementName = new char[nNameLength]; resultSet.FetchString(1, achievementName, nNameLength); nDescriptionLength = resultSet.FetchSize(2) + 1; char[] achievementDescription = new char[nDescriptionLength]; resultSet.FetchString(2, achievementDescription, nDescriptionLength); if (localizedStrings.JumpToKey(languageShortCode, true)) { localizedStrings.SetString("name", achievementName); localizedStrings.SetString("description", achievementDescription); localizedStrings.GoBack(); } } localizedStrings.Rewind(); char serverLanguageShortCode[MAX_LANG_SC_LENGTH]; GetLanguageInfo(GetServerLanguage(), serverLanguageShortCode, sizeof(serverLanguageShortCode)); // Ensure the server's language is localized as a fallback measure. if (localizedStrings.JumpToKey(serverLanguageShortCode)) { localizedStrings.GoBack(); // Identify and send localized translations to each client, if possible. for (int i = 1; i <= MaxClients; i++) { if (IsClientInGame(i) && !IsFakeClient(i)) { char languageShortCode[MAX_LANG_SC_LENGTH]; GetLanguageInfo(GetClientLanguage(i), languageShortCode, sizeof(languageShortCode)); // Use server (default) language if client language isn't available. if (localizedStrings.JumpToKey(languageShortCode) || localizedStrings.JumpToKey(serverLanguageShortCode)) { char localName[64], localDescription[256]; localizedStrings.GetString("name", localName, sizeof(localName)); localizedStrings.GetString("description", localDescription, sizeof(localDescription)); SendAchievementMessageToOne(recipient, i, localName, localDescription); localizedStrings.GoBack(); } } } TransmitAchievementEffects(recipient); g_flLastEarnedAchievementTime[recipient] = GetGameTime(); } else { LogMessage("Achievement %d is missing server / fallback localization entries " ... "in server's language '%s'. Achievement will not be displayed.", iAchievement, serverLanguageShortCode); } delete localizedStrings; } else { LogMessage("Achievement %d does not have any localized strings.", iAchievement); } } delete pack; } /** * Plays the achievement sound and displays the related particle on the recipient. */ void TransmitAchievementEffects(int recipient) { /** * TODO add temporal filter if event `achievement_earned` or this function was called within * the last second so the effect is only used once. */ if (GetGameTime() - g_flLastEarnedAchievementTime[recipient] > 1.0) { TFTeam recipientTeam = TF2_GetClientTeam(recipient); if (recipientTeam != TFTeam_Unassigned && recipientTeam != TFTeam_Spectator) { EmitGameSoundToAll("Achievement.Earned", recipient); // TODO prevent custom models from allowing this? if (IsPlayerAlive(recipient)) { TE_SetupTFParticleEffect("achieved", NULL_VECTOR, NULL_VECTOR, NULL_VECTOR, recipient, PATTACH_POINT_FOLLOW, ATTACHMENT_HEAD); TE_SendToAll(); } } } } /** * Returns a client with the specified Steam account ID, or 0 if none. */ stock int FindClientByAccountID(int steamid3) { if (steamid3) { for (int i = 1; i <= MaxClients; i++) { if (IsClientConnected(i) && !IsFakeClient(i) && IsClientAuthorized(i)) { if (steamid3 == GetSteamAccountID(i)) { return i; } } } } return 0; } /** * Sends the " has earned the achievement " message to the specified * client. * * If a description is specified, the description is enclosed in parentheses. */ void SendAchievementMessageToOne(int recipient, int client, const char[] achievementName, const char[] achievementDescription = "") { char recipientName[MAX_NAME_LENGTH]; GetClientName(recipient, recipientName, sizeof(recipientName)); int clients[1]; clients[0] = client; if (!strlen(achievementDescription)) { // no description provided SendSayText2Message(recipient, clients, 1, "#Achievement_Earned", recipientName, achievementName); } else { int bufferLength = strlen(achievementName) + strlen(achievementDescription) + 16; char[] achievementBuffer = new char[bufferLength]; Format(achievementBuffer, bufferLength, "%s \x01(%s)", achievementName, achievementDescription); SendSayText2Message(recipient, clients, 1, "#Achievement_Earned", recipientName, achievementBuffer); } } void SendSayText2Message(int author, int[] clients, int nClients, const char[] localizationToken, const char[] name, const char[] message) { Handle buffer = StartMessage("SayText2", clients, nClients, USERMSG_RELIABLE | USERMSG_BLOCKHOOKS); BfWrite bitbuf = view_as(buffer); bitbuf.WriteByte(author); bitbuf.WriteByte(true); bitbuf.WriteString(localizationToken); bitbuf.WriteString(name); bitbuf.WriteString(message); EndMessage(); } void LogDebug(const char[] format, any ...) { if (g_ConVarDebug.BoolValue) { char message[256]; VFormat(message, sizeof(message), format, 2); LogServer(message); } } /** * Executes a prepared statement and returns it as a result set view. * The handle should not be closed. */ DBResultSet SQL_ExecuteStatement(DBStatement query) { SQL_Execute(query); return view_as(query); }