/**
* LittleJS Newgrounds Plugin
* - NewgroundsMedal extends Medal with Newgrounds API functionality
* - When logged in, Newgrounds holds the player's NewgroundsMedals: they unlock once the server confirms and the local save leaves them alone
* - A plain Medal is never touched, so a game can use the plugin for scoreboards alone
* - Without a session the medal and scoreboard lists still come in, so the medals get their names and icons; unlocking on the server and posting scores need a logged in player
* - Create the medals as NewgroundsMedals with their ids on Newgrounds, then new NewgroundsPlugin(app_id, cipher); medalsInit is still needed, before or after
* - Encrypts medal unlocks and posted scores, the calls Newgrounds secures, with the browser's own WebCrypto when the app has a cipher, no library needed
* - Logs a view when it starts, and provides functions to unlock medals and to post and read scoreboards
* - Tells the Newgrounds page around the game when a medal unlocks or a score posts, as the official client does
* - Checks the session every minute when logged in, which keeps it alive, and plays as not logged in once it is lost
* - Where the browser has AbortSignal.timeout, a request that takes longer than 15 seconds fails like one that could not reach the server
* - Every call is a fetch, so the functions return promises; await newgrounds.ready for the medals and scoreboards
* @namespace Newgrounds
*/
'use strict';
/** Global Newgrounds object
* @type {NewgroundsPlugin}
* @memberof Newgrounds */
let newgrounds;
// Engine internal variables not exposed to documentation
const newgroundsUnlocksToResend = new Set; // pending medals whose request did not reach the server
const newgroundsUnlocksRefused = new Set; // medals the server refused this visit, asked again they answer no unsent
const newgroundsSecureComponents = ['Medal.unlock', 'ScoreBoard.postScore']; // the calls encrypted with a cipher
const newgroundsSessionErrors = [104, 110, 111]; // expired session, login required, session cancelled
const newgroundsTimeoutMS = 15e3; // how long a request may take before it fails
// whether the server answered that the session is gone, as opposed to a request that failed on the way
function newgroundsSessionLost(response)
{ return newgroundsSessionErrors.includes(response?.result?.data?.error?.code ?? response?.error?.code); }
// tell the Newgrounds page around the game, with the message the official client sends
function newgroundsNotifyPage(component, id)
{ globalThis.top?.postMessage(JSON.stringify({'ngioComponent':component, 'id':id}), '*'); }
///////////////////////////////////////////////////////////////////////////////
/**
* NewgroundsMedal: its id is the medal's id on the Newgrounds API Tools page; when logged in it only unlocks once the server confirms
* @extends Medal
* @memberof Newgrounds
*/
class NewgroundsMedal extends Medal
{
/** Create a NewgroundsMedal and add it to the list of medals
* @param {number} id - The unique identifier of the medal
* @param {string} name - Name of the medal
* @param {string} [description] - Description of the medal
* @param {string} [icon] - Icon for the medal
* @param {string} [src] - Image location for the medal
*/
constructor(id, name, description, icon, src)
{
super(id, name, description, icon, src);
/** @property {number|undefined} - Difficulty from the server once ready, 1 easy to 5 brutal
* @type {number|undefined} */
this.difficulty = undefined;
/** @property {number|undefined} - Point value from the server once ready
* @type {number|undefined} */
this.value = undefined;
}
/** Whether the local save holds this medal, false while logged in when Newgrounds holds it
* @return {boolean} */
isLocal() { return !newgrounds?.session_id; }
/** Unlocks a medal if not already unlocked, once Newgrounds confirms it when logged in
* - The promise is optional, for when a game wants to know the outcome
* - A request that did not reach the server is sent again after the session check every minute, and calling
* unlock again while the medal is pending returns the same promise
* - One the server refused is not sent again this visit, calling unlock again resolves false without a request
* - An answer that the session is gone makes the game play as not logged in, and the pending medals, this one too,
* unlock locally unless unlocks are prevented; a refused one only unlocks if it is earned again
* @return {Promise<boolean>} - Whether the medal is unlocked, once the server has answered when logged in */
unlock()
{
if (medalsPreventUnlock || this.unlocked || this.isLocal())
return super.unlock(); // nothing to send, or not logged in and the local save holds the medal
// logged in, Newgrounds holds the medal: it unlocks once the server confirms, one request at a time
ASSERT(medalsSaveName, 'save name must be set');
if (newgroundsUnlocksRefused.has(this))
return Promise.resolve(false); // refused this visit, asking again will not change that
const pending = newgrounds.pendingUnlocks;
if (pending.has(this))
return pending.get(this);
const request = newgrounds.unlockMedal(this.id).then(response=>
{
if (newgroundsSessionLost(response))
newgrounds.dropSession(); // the pending unlocks, this one too, are local now
const serverMedal = response?.result?.data?.medal;
if (!serverMedal?.unlocked || medalsPreventUnlock)
{
debugMedals && LOG('Newgrounds did not unlock medal', this.id, response?.result?.data?.error || response?.error);
if (this.isLocal())
return this.unlocked; // the session dropped, the medal is local now
if (!response || serverMedal?.unlocked)
newgroundsUnlocksToResend.add(this); // did not reach the server, or confirmed while prevented: pending
else
{
// refused, which will not change: no longer pending, so a session drop does not unlock it
pending.delete(this);
newgroundsUnlocksRefused.add(this);
}
return false;
}
const listed = newgrounds.medals.find(m=> m['id'] == this.id);
listed && Object.assign(listed, serverMedal); // keep the fetched list in step
if (serverMedal['icon'] && serverMedal['icon'] != this.image?.src)
(this.image = new Image).src = serverMedal['icon']; // a secret medal shows its real icon once unlocked
pending.delete(this);
newgroundsNotifyPage('Medal.unlock', this.id);
return super.unlock();
});
pending.set(this, request);
return request;
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* Newgrounds API object
* @memberof Newgrounds
*/
class NewgroundsPlugin
{
/** Create the global newgrounds object
* - Logs a view right away, for a guest and a logged in player alike, so a game does not have to
* - Create the medals first: they take their name and icon from the server once it answers, and when logged in they are locked here until it does
* - Call medalsInit too, before or after: an unlock asserts without it, and it keeps the medals while not logged in
* @param {string} app_id - The Newgrounds App ID
* @param {string} [cipher] - The encryption key from the app's settings, AES-128 as Base64; medal unlocks and posted
* scores are encrypted with the browser's WebCrypto, which needs a secure page, https or localhost
* @example
* // create the newgrounds object, replace the app id with your own
* const app_id = 'your_app_id_here';
* new NewgroundsPlugin(app_id);
*/
constructor(app_id, cipher)
{
ASSERT(!newgrounds, 'there can only be one newgrounds object');
ASSERT(!cipher || typeof crypto != 'undefined' && crypto.subtle, 'a cipher needs WebCrypto, which the browser only has on a secure page');
ASSERT(!cipher || /^[A-Za-z0-9+/]{22}==$/.test(cipher), 'the cipher must be the Base64 AES-128 key from the app settings');
newgrounds = this; // set global newgrounds object
/** @property {string} - The Newgrounds App ID */
this.app_id = app_id;
/** @property {string|undefined} - AES-128/Base64 encryption key, if any
* @type {string|undefined} */
this.cipher = cipher;
/** @type {CryptoKey|undefined} */
this.cryptoKey = undefined; // the cipher imported for WebCrypto, on the first encrypted call
const hasLocation = typeof location != 'undefined';
/** @property {string} - Hostname sent with the view the plugin logs when it starts */
this.host = hasLocation ? location.hostname : '';
/** @property {Array} - Medals fetched from Newgrounds, empty until ready, with the unlocks only when logged in */
this.medals = [];
/** @property {Array} - Scoreboards fetched from Newgrounds, empty until ready */
this.scoreboards = [];
/** @property {{id: number, name: string, url: string, supporter: boolean}|null} - The logged in player once ready, null when not logged in
* @type {{id: number, name: string, url: string, supporter: boolean}|null} */
this.user = null;
/** @property {Map<NewgroundsMedal, Promise<boolean>>} - Medals whose unlock is in flight or waiting to be resent, with their request's promise; one the server refused leaves it
* @type {Map<NewgroundsMedal, Promise<boolean>>} */
this.pendingUnlocks = new Map;
// get session id from url search params
/** @property {string|null} - Newgrounds session id from the URL, null when not logged in or once the session is lost
* @type {string|null} */
this.session_id = hasLocation ? new URL(location.href).searchParams.get('ngio_session_id') : null;
// Newgrounds holds this player's NewgroundsMedals: locked until the server says otherwise, the local save leaves them alone
if (this.session_id)
medalsForEach(medal=> medal instanceof NewgroundsMedal && (medal.unlocked = false));
/** @property {Promise<NewgroundsPlugin>} - Resolves once the session is checked and the lists are in, empty if the server could not be reached */
this.ready = this.init();
}
/** @deprecated since 1.20, the view is logged when the plugin starts, so this does nothing */
logView() {}
/** Log the view, check the session, fetch the medals and scoreboards, then keep the session alive; the constructor runs it once
* @private */
async init()
{
this.call('App.logView', {'host':this.host}); // every view counts, guest or logged in
let medalList;
if (this.session_id)
{
// the player is logged in when the server knows the session, it has a user and the medals come in
const sessionResult = await this.call('App.checkSession');
const session = sessionResult?.result?.data?.['session'];
const user = session && !session['expired'] && session['user'];
medalList = user && (await this.call('Medal.getList'))?.result?.data?.['medals'];
if (medalList && this.session_id)
this.user = user;
else
{
this.dropSession(); // without the server (offline / bad session / server error), or lost meanwhile
medalList = undefined; // its unlocks belong to the lost session
}
}
// not logged in, the list comes too, without the unlocks
medalList = medalList || (await this.call('Medal.getList'))?.result?.data?.['medals'];
this.medals = medalList || [];
debugMedals && LOG(this.medals);
for (const newgroundsMedal of this.medals)
{
const medal = medals[newgroundsMedal['id']];
if (medal instanceof NewgroundsMedal) // a plain medal with the same id is left alone
{
// copy the server's medal data
medal.image = new Image;
medal.image.src = newgroundsMedal['icon'];
medal.name = newgroundsMedal['name'];
medal.description = newgroundsMedal['description'];
medal.unlocked = medal.unlocked || !!newgroundsMedal['unlocked']; // keeps a local unlock, or one that landed first
medal.difficulty = newgroundsMedal['difficulty'];
medal.value = newgroundsMedal['value'];
if (medal.value) // add value to description
medal.description += ` (${ medal.value })`;
if (this.session_id && medal.unlocked)
{
newgroundsMedal['unlocked'] = true; // the list says so too
this.pendingUnlocks.delete(medal); // and a request waiting to be resent, or refused, is done
newgroundsUnlocksToResend.delete(medal);
newgroundsUnlocksRefused.delete(medal);
}
}
}
const scoreboardResult = await this.call('ScoreBoard.getBoards');
this.scoreboards = scoreboardResult?.result?.data?.scoreboards || [];
debugMedals && LOG(this.scoreboards);
if (!this.session_id)
return this;
// logged in, check the session every minute, which keeps it alive, and resend the unlocks that did not reach the server
const keepAlive = setInterval(async ()=>
{
if (!this.session_id)
return clearInterval(keepAlive);
const response = await this.call('App.checkSession');
const session = response?.result?.data?.['session'];
if (newgroundsSessionLost(response) || session && (session['expired'] || !session['user']))
return this.dropSession();
this.resendUnlocks();
}, 60e3);
return this;
}
/** Play as not logged in from now on: the NewgroundsMedals come back from the local save, keeping the unlocks the
* server confirmed meanwhile, and the unlocks still pending unlock locally; a refused one only if it is earned again
* - internal, the medals call it too when the server says the session is gone
* @ignore */
dropSession()
{
if (!this.session_id) return;
debugMedals && LOG('Newgrounds session unavailable; medals are local');
const confirmed = Object.values(medals).filter(medal=> medal.unlocked);
this.session_id = null;
this.user = null;
medalsLoad(); // the NewgroundsMedals are local again, back from the save
confirmed.forEach(medal=> medal.unlocked = true);
medalsSave();
// the unlocks still out are local too, they unlock now, or are dropped like any local unlock while prevented
const pending = [...this.pendingUnlocks.keys()];
this.pendingUnlocks.clear();
newgroundsUnlocksToResend.clear();
newgroundsUnlocksRefused.clear();
for (const medal of pending)
medal.unlock();
}
/** Send the unlocks whose request did not reach the server again, which the session check does every minute
* - A request still out is left to answer, and while unlocks are prevented they wait */
resendUnlocks()
{
if (medalsPreventUnlock) return;
for (const medal of newgroundsUnlocksToResend)
{
this.pendingUnlocks.delete(medal);
medal.unlock(); // a failure is only added back once the new request answers
}
newgroundsUnlocksToResend.clear();
}
/** Send a request to unlock a medal by id, the local medal is not changed; NewgroundsMedal.unlock sends this and waits for the answer
* @param {number} id - The medal id
* @return {Promise<Object>} - The response JSON object, undefined when the call failed */
unlockMedal(id) { return this.call('Medal.unlock', {'id':id}); }
/** Send message to post score
* @param {number} id - The scoreboard id
* @param {number} value - The score value, a whole number
* @return {Promise<Object>} - The response JSON object, undefined when the call failed; result.data.success says whether
* it posted, which needs a logged in player; an answer that the session is gone makes the game play as not logged
* in, and one that timed out may still have posted */
postScore(id, value)
{
return this.call('ScoreBoard.postScore', {'id':id, 'value':value}).then(response=>
{
response?.result?.data?.success && newgroundsNotifyPage('ScoreBoard.postScore', id);
newgroundsSessionLost(response) && this.dropSession();
return response;
});
}
/** Get scores from a scoreboard
* @param {number} id - The scoreboard id
* @param {string|number} [user] - A user's id or name, to load only their scores
* @param {boolean} [social] - If true, only the scores of the user and their friends, the logged in player when user is left out
* @param {number} [skip] - Number of scores to skip over
* @param {number} [limit] - Number of scores to include in the list
* @param {string} [period] - 'D' today, which the server assumes when left out, 'W' this week, 'M' this month, 'Y' this year or 'A' all time
* @return {Promise<Object>} - The response JSON object, undefined when the call failed; the scores are in
* result.data.scores, each with user.name, value and formatted_value; without a user or social it is the whole board
*/
getScores(id, user, social=false, skip=0, limit=10, period)
{
// the whole board goes without the session, which the server would narrow down to the logged in player
const session_id = user || social ? this.session_id : null;
const parameters = {'id':id, 'user':user, 'social':social, 'skip':skip, 'limit':limit, 'period':period};
return this.call('ScoreBoard.getScores', parameters, session_id);
}
/** Encrypt text the way the Newgrounds gateway expects, AES-128 CBC with a random iv in front, as Base64
* @param {string} text
* @return {Promise<string>} */
async encrypt(text)
{
if (!this.cryptoKey)
{
const keyBytes = Uint8Array.from(atob(this.cipher), c=> c.charCodeAt(0));
this.cryptoKey = await crypto.subtle.importKey('raw', keyBytes, 'AES-CBC', false, ['encrypt']);
}
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = new Uint8Array(await crypto.subtle.encrypt({'name':'AES-CBC', iv}, this.cryptoKey, new TextEncoder().encode(text)));
const bytes = new Uint8Array(iv.length + encrypted.length);
bytes.set(iv);
bytes.set(encrypted, iv.length);
let binary = '';
for (const b of bytes)
binary += String.fromCharCode(b);
return btoa(binary);
}
/** Send a message to call a component of the Newgrounds API
* @param {string} component - Name of the component
* @param {Object} [parameters] - Parameters to use for call
* @param {string|null} [session_id] - The session to send, the player's by default
* @return {Promise<Object>} - The response JSON object, undefined when the call failed or took over 15 seconds;
* a component's own success and error are in result.data, and a cipher that is not a key gives error 201
*/
async call(component, parameters, session_id=this.session_id)
{
const url = 'https://www.newgrounds.io/gateway_v3.php';
try
{
/** @type {Object} */
let execute = {'component':component, 'parameters':parameters};
if (this.cipher && newgroundsSecureComponents.includes(component))
{
// only the encrypted call goes; a key that will not even import is refused here the way the server
// refuses a wrong one, since sending again cannot fix it
const secure = await this.encrypt(JSON.stringify(execute)).catch(e=> debugMedals && LOG('Newgrounds cipher failed', e));
if (!secure)
return {'success':false, 'error':{'code':201, 'message':'Invalid Encryption: the cipher is not a Base64 AES-128 key'}};
execute = {'secure': secure};
}
// build the request object, in the form the Newgrounds.io docs give
const request =
{
'app_id': this.app_id,
'session_id': session_id,
'execute': execute
};
// send it as post data
const formData = new FormData();
formData.append('request', JSON.stringify(request));
const signal = globalThis.AbortSignal?.timeout?.(newgroundsTimeoutMS); // a stalled request fails
const response = await fetch(url, {'method':'POST', 'body':formData, 'signal':signal});
const text = await response.text();
debugMedals && LOG(text);
return text ? JSON.parse(text) : undefined;
}
catch(e) { debugMedals && LOG('newgrounds call failed', e); }
}
}