Bläddra i källkod

Initial plugin commit

This has been sitting outside of version control for a long while now.
Probably should commit and store it somewhere.
nosoop 7 år sedan
förälder
incheckning
dc31f2de48

+ 29 - 0
configs/sql-init-scripts/sqlite/custom_achievements.sql

@@ -0,0 +1,29 @@
+/* run with sqlite3 -init "configs/sql-init-scripts/sqlite/custom_achievements.sql" "data/sqlite3/custom-achievements.sq3" */
+
+/* list of achievements */
+CREATE TABLE achievements (
+	achievement_id INTEGER PRIMARY KEY AUTOINCREMENT,
+	name TEXT NOT NULL,
+	displaytype INTEGER,
+	visible BOOLEAN DEFAULT TRUE, /* determines whether or not it's rendered on an achievement page */
+	CONSTRAINT achievement_name UNIQUE(name)
+);
+
+/* per-account achievement status */
+CREATE TABLE achievement_status (
+	achievement_id INTEGER,
+	steamid3 INTEGER NOT NULL,
+	achieved INTEGER DEFAULT 0, /* should be time achieved */
+	metadata TEXT,
+	CONSTRAINT achievement_user UNIQUE(achievement_id, steamid3)
+);
+
+/* localized achievement name and descriptions for display */
+/* also to be used in CallOnCustomAchievementAwarded() to print to chat */
+CREATE TABLE achievement_languages (
+	achievement_name TEXT NOT NULL,
+	language_shortcode TEXT NOT NULL,
+	achievement_local_name TEXT NOT NULL,
+	achievement_local_description TEXT,
+	CONSTRAINT achievement_in_language UNIQUE(achievement_name, language_shortcode)
+);

+ 511 - 0
scripting/custom_achievements.sp

@@ -0,0 +1,511 @@
+/**
+ * [CSRD] Custom Achievements
+ * 
+ * The custom achievement implementation for Pikachu's Canadian Server of Romance and Drama.
+ */
+#pragma semicolon 1
+#include <sourcemod>
+
+#include <tf2_stocks>
+#include <sdktools_sound>
+
+#pragma newdecls required
+#include <stocksoup/log_server>
+#include <stocksoup/tf/tempents_stocks>
+
+#define PLUGIN_VERSION "0.1.0"
+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);
+	
+	g_QueryRegisterAchievement.BindString(0, internalName, false);
+	g_QueryRegisterAchievement.BindInt(1, achievementStyle, false);
+	
+	SQL_Execute(g_QueryRegisterAchievement);
+	
+	g_QueryGetAchievementIdentifier.BindString(0, internalName, false);
+	SQL_Execute(g_QueryGetAchievementIdentifier);
+	
+	int iAchievement;
+	if (SQL_FetchRow(g_QueryGetAchievementIdentifier)) {
+		iAchievement = SQL_FetchInt(g_QueryGetAchievementIdentifier, 0);
+	}
+	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<DBResultSet>(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<DBResultSet>(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<DBResultSet>(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);
+			
+			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) {
+	for (int i = 1; i <= MaxClients; i++) {
+		if (!IsFakeClient(i) && IsClientAuthorized(i)) {
+			if (steamid3 == GetSteamAccountID(i)) {
+				return i;
+			}
+		}
+	}
+	return 0;
+}
+
+/**
+ * Sends the "<recipient> has earned the achievement <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<BfWrite>(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);
+	}
+}

+ 129 - 0
scripting/include/custom_achievements.inc

@@ -0,0 +1,129 @@
+#if defined __custom_achievements_included
+	#endinput
+#endif
+
+#define __custom_achievements_included
+
+/**
+ * Controls type of achievement.  Intended to be used when displaying the achievements in some
+ * fashion (e.g., on a webpage).
+ */
+enum AchievementStyle {
+	AchievementStyle_Undefined = 0, // Style is undefined (may need to be custom-made).
+	AchievementStyle_Single, // Either it's achieved or it isn't; no renderable progress.
+	AchievementStyle_Progress, // Requires a progress bar.
+};
+
+/**
+ * Called when achievement metadata is fetched for an account.  If the account has no metadata
+ * for the specified achievement, an empty string is returned.
+ * 
+ * There is no defined metadata format other than it being a string; the plugin is free to store
+ * any relevant persistent information pertaining to that achievement for an account, if any.
+ */
+typedef AchievementMetadataCallback = function void(CustomAchievement achievement,
+		const char[] metadata, any data);
+
+methodmap CustomAchievement {
+	/**
+	 * Instantiates a custom achievement with the specified internal name, returning an
+	 * identifier.
+	 */
+	public native CustomAchievement(const char[] internalName, AchievementStyle style);
+	
+	/**
+	 * Flags the achievement as completed for this client.  If the achievement hasn't been
+	 * awarded to the client before, `notify` is true, and the player is in the server,
+	 * `OnCustomAchievementAwarded` is fired.
+	 */
+	public native void AwardToAccountID(int steamid3, bool notify = true);
+	
+	/**
+	 * Flags the achievement as completed for this client.
+	 */
+	public void Award(int client, bool notify = true) {
+		if (IsClientAuthorized(client)) {
+			this.AwardToAccountID(GetSteamAccountID(client), notify);
+		}
+	}
+	
+	/**
+	 * Performs a threaded query to get the metadata for this achievement for the specified
+	 * Steam account.
+	 */
+	public native void FetchMetadataByAccountID(int steamid3,
+			AchievementMetadataCallback callback, any data);
+	
+	/**
+	 * Performs a threaded query to get the metadata for this achievement for the specified
+	 * user.
+	 * 
+	 * (You'll probably want to pass the client in under the `data` argument in some way.)
+	 */
+	public void FetchMetadata(int client, AchievementMetadataCallback callback,
+			any data) {
+		if (IsClientAuthorized(client)) {
+			this.FetchMetadataByAccountID(GetSteamAccountID(client), callback, data);
+		}
+	}
+	
+	/**
+	 * Store the account-specific metadata for this achievement.
+	 */
+	public native bool StoreMetadataByAccountID(int steamid3, const char[] metadata);
+	
+	/**
+	 * Performs a fast query to store the metadata for this achievement for the specified
+	 * client.
+	 */
+	public bool StoreMetadata(int client, const char[] metadata) {
+		if (IsClientAuthorized(client)) {
+			return this.StoreMetadataByAccountID(GetSteamAccountID(client), metadata);
+		}
+		return false;
+	}
+	
+	/**
+	 * Resets this achievement, removing stored metadata and removing the "achieved" state for
+	 * the specified account.
+	 * 
+	 * There is no `CustomAchievement.Reset(int client)` by design as achievements are not meant
+	 * to be reset so often.
+	 */
+	public native void ResetByAccountID(int steamid3);
+}
+
+/**
+ * Called when an achievement is awarded to an in-game player.
+ * 
+ * TODO implement a function to test if achievement(s) were already awarded, to check if a
+ * milestone is reached, or something.
+ */
+forward void OnCustomAchievementAwarded(int client, CustomAchievement achievement);
+
+public SharedPlugin __pl_custom_achievements = {
+	name = "custom-achievements",
+	file = "custom_achievements.smx",
+#if defined REQUIRE_PLUGIN
+	required = 1,
+#else
+	required = 0,
+#endif
+};
+
+#if !defined REQUIRE_PLUGIN
+public __pl_custom_achievements_SetNTVOptional() {
+	MarkNativeAsOptional("CustomAchievement.CustomAchievement");
+	
+	MarkNativeAsOptional("CustomAchievement.AwardToAccountID");
+	MarkNativeAsOptional("CustomAchievement.Award");
+	
+	MarkNativeAsOptional("CustomAchievement.FetchMetadataByAccountID");
+	MarkNativeAsOptional("CustomAchievement.FetchMetadata");
+	
+	MarkNativeAsOptional("CustomAchievement.StoreMetadataByAccountID");
+	MarkNativeAsOptional("CustomAchievement.StoreMetadata");
+	
+	MarkNativeAsOptional("CustomAchievement.ResetByAccountID");
+}
+#endif