mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Improve leaderboard score handling (#5481)
* Leaderboards will now display the score of the player, if a score was just sent before displaying the leaderboard. * If the score is being sent, the leaderboard will wait for it to be sent before showing the leaderboard. * Important change: a player name can now be empty when a score is sent. In this case, the leaderboard will auto-generate a name. * Leaderboard administration was improved to allow to customise the prefix used to generate automatically player names (when no name is entered by the player). You can also now choose to disallow any custom player name to prevent abuse (in this case, all non-connected player names will be auto-generated) * Allow to use custom CSS to fully customize the leaderboard - only for business users (reach out if you need this). Co-authored-by: AlexandreS <32449369+AlexandreSi@users.noreply.github.com>
This commit is contained in:
@@ -59,11 +59,17 @@ module.exports = {
|
||||
false
|
||||
)
|
||||
.addParameter('string', _('Name to register for the player'), '', false)
|
||||
.setParameterLongDescription(
|
||||
_(
|
||||
'Let this empty to let the leaderboard auto-generate a player name (e.g: "Player23464"). You can configure this in the leaderboard administration.'
|
||||
)
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/sha256.js')
|
||||
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore');
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.savePlayerScore')
|
||||
.setAsyncFunctionName('gdjs.evtTools.leaderboards.savePlayerScore');
|
||||
|
||||
extension
|
||||
.addAction(
|
||||
@@ -89,7 +95,8 @@ module.exports = {
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/sha256.js')
|
||||
.addIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.saveConnectedPlayerScore');
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.saveConnectedPlayerScore')
|
||||
.setAsyncFunctionName('gdjs.evtTools.leaderboards.saveConnectedPlayerScore');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
@@ -154,20 +161,22 @@ module.exports = {
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.isSaving');
|
||||
|
||||
extension
|
||||
.addCondition(
|
||||
'HasPlayerJustClosedLeaderboardView',
|
||||
_('Closed by player'),
|
||||
_('Check if the player has just closed the leaderboard view.'),
|
||||
_('Player has just closed the leaderboard view'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName('gdjs.evtTools.leaderboards.hasPlayerJustClosedLeaderboardView');
|
||||
extension
|
||||
.addCondition(
|
||||
'HasPlayerJustClosedLeaderboardView',
|
||||
_('Closed by player'),
|
||||
_('Check if the player has just closed the leaderboard view.'),
|
||||
_('Player has just closed the leaderboard view'),
|
||||
_('Display leaderboard'),
|
||||
'JsPlatform/Extensions/leaderboard.svg',
|
||||
'JsPlatform/Extensions/leaderboard.svg'
|
||||
)
|
||||
.setHelpPath('/all-features/leaderboards')
|
||||
.getCodeExtraInformation()
|
||||
.setIncludeFile('Extensions/Leaderboards/leaderboardstools.js')
|
||||
.setFunctionName(
|
||||
'gdjs.evtTools.leaderboards.hasPlayerJustClosedLeaderboardView'
|
||||
);
|
||||
|
||||
extension
|
||||
.addStrExpression(
|
||||
|
@@ -24,37 +24,47 @@ namespace gdjs {
|
||||
return shaObj.getHash('B64');
|
||||
};
|
||||
|
||||
// Score saving
|
||||
/**
|
||||
* Hold the state of the save of a score for a leaderboard.
|
||||
*/
|
||||
class ScoreSavingState {
|
||||
lastScoreSavingStartedAt: number | null;
|
||||
lastScoreSavingSucceededAt: number | null;
|
||||
currentlySavingScore: number | null;
|
||||
currentlySavingPlayerName: string | null;
|
||||
currentlySavingPlayerId: string | null;
|
||||
lastSavedScore: number | null;
|
||||
lastSavedPlayerName: string | null;
|
||||
lastSavedPlayerId: string | null;
|
||||
lastSaveError: string | null;
|
||||
isScoreSaving: boolean;
|
||||
hasScoreBeenSaved: boolean;
|
||||
hasScoreSavingErrored: boolean;
|
||||
lastScoreSavingStartedAt: number | null = null;
|
||||
lastScoreSavingSucceededAt: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.lastScoreSavingStartedAt = null;
|
||||
this.lastScoreSavingSucceededAt = null;
|
||||
this.currentlySavingScore = null;
|
||||
this.currentlySavingPlayerName = null;
|
||||
this.currentlySavingPlayerId = null;
|
||||
this.lastSavedScore = null;
|
||||
this.lastSavedPlayerName = null;
|
||||
this.lastSavedPlayerId = null;
|
||||
this.lastSaveError = null;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
/** The promise that will be resolved when the score saving is done (successfully or not). */
|
||||
lastSavingPromise: Promise<void> | null = null;
|
||||
|
||||
// Score that is being saved:
|
||||
private _currentlySavingScore: number | null = null;
|
||||
private _currentlySavingPlayerName: string | null = null;
|
||||
private _currentlySavingPlayerId: string | null = null;
|
||||
|
||||
// Last score saved with success:
|
||||
private _lastSavedScore: number | null = null;
|
||||
private _lastSavedPlayerName: string | null = null;
|
||||
private _lastSavedPlayerId: string | null = null;
|
||||
|
||||
/** The id of the entry in the leaderboard, for the last score saved with success. */
|
||||
lastSavedLeaderboardEntryId: string | null = null;
|
||||
|
||||
/** Last error that happened when saving the score (useful if `hasScoreSavingErrored` is true). */
|
||||
lastSaveError: string | null = null;
|
||||
|
||||
/** `true` if the last save has finished and succeeded. */
|
||||
hasScoreBeenSaved: boolean = false;
|
||||
|
||||
/** `true` if the last save has finished and failed (check `lastSaveError` then). */
|
||||
hasScoreSavingErrored: boolean = false;
|
||||
|
||||
isSaving(): boolean {
|
||||
return (
|
||||
!!this.lastSavingPromise &&
|
||||
!this.hasScoreBeenSaved &&
|
||||
!this.hasScoreSavingErrored
|
||||
);
|
||||
}
|
||||
|
||||
isSameAsLastScore({
|
||||
private _isSameAsLastScore({
|
||||
playerName,
|
||||
playerId,
|
||||
score,
|
||||
@@ -64,13 +74,13 @@ namespace gdjs {
|
||||
score: number;
|
||||
}): boolean {
|
||||
return (
|
||||
((!!playerName && this.lastSavedPlayerName === playerName) ||
|
||||
(!!playerId && this.lastSavedPlayerId === playerId)) &&
|
||||
this.lastSavedScore === score
|
||||
((!!playerName && this._lastSavedPlayerName === playerName) ||
|
||||
(!!playerId && this._lastSavedPlayerId === playerId)) &&
|
||||
this._lastSavedScore === score
|
||||
);
|
||||
}
|
||||
|
||||
isAlreadySavingThisScore({
|
||||
private _isAlreadySavingThisScore({
|
||||
playerName,
|
||||
playerId,
|
||||
score,
|
||||
@@ -79,15 +89,16 @@ namespace gdjs {
|
||||
playerId?: string;
|
||||
score: number;
|
||||
}): boolean {
|
||||
if (!this.isSaving()) return false;
|
||||
|
||||
return (
|
||||
((!!playerName && this.currentlySavingPlayerName === playerName) ||
|
||||
(!!playerId && this.currentlySavingPlayerId === playerId)) &&
|
||||
this.isScoreSaving &&
|
||||
this.currentlySavingScore === score
|
||||
((!!playerName && this._currentlySavingPlayerName === playerName) ||
|
||||
(!!playerId && this._currentlySavingPlayerId === playerId)) &&
|
||||
this._currentlySavingScore === score
|
||||
);
|
||||
}
|
||||
|
||||
isTooSoonToSaveAnotherScore(): boolean {
|
||||
private _isTooSoonToSaveAnotherScore(): boolean {
|
||||
return (
|
||||
!!this.lastScoreSavingStartedAt &&
|
||||
Date.now() - this.lastScoreSavingStartedAt < 500
|
||||
@@ -102,28 +113,91 @@ namespace gdjs {
|
||||
playerName?: string;
|
||||
playerId?: string;
|
||||
score: number;
|
||||
}): void {
|
||||
}): {
|
||||
closeSaving: (leaderboardEntryId: string | null) => void;
|
||||
closeSavingWithError(errorCode: string);
|
||||
} {
|
||||
if (this._isAlreadySavingThisScore({ playerName, playerId, score })) {
|
||||
logger.warn(
|
||||
'There is already a request to save with this player name and this score. Ignoring this one.'
|
||||
);
|
||||
throw new Error('Ignoring this saving request.');
|
||||
}
|
||||
|
||||
if (this._isSameAsLastScore({ playerName, playerId, score })) {
|
||||
logger.warn(
|
||||
'The player and score to be sent are the same as previous one. Ignoring this one.'
|
||||
);
|
||||
this._setError('SAME_AS_PREVIOUS');
|
||||
throw new Error('Ignoring this saving request.');
|
||||
}
|
||||
|
||||
if (this._isTooSoonToSaveAnotherScore()) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
this._setError('TOO_FAST');
|
||||
|
||||
// Set the starting time to cancel all the following attempts that
|
||||
// are started too early after this one.
|
||||
this.lastScoreSavingStartedAt = Date.now();
|
||||
|
||||
throw new Error('Ignoring this saving request.');
|
||||
}
|
||||
|
||||
let resolveSavingPromise: () => void;
|
||||
const savingPromise = new Promise<void>((resolve) => {
|
||||
resolveSavingPromise = resolve;
|
||||
});
|
||||
|
||||
this.lastScoreSavingStartedAt = Date.now();
|
||||
this.isScoreSaving = true;
|
||||
this.lastSavingPromise = savingPromise;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = false;
|
||||
this.currentlySavingScore = score;
|
||||
if (playerName) this.currentlySavingPlayerName = playerName;
|
||||
if (playerId) this.currentlySavingPlayerId = playerId;
|
||||
this._currentlySavingScore = score;
|
||||
if (playerName) this._currentlySavingPlayerName = playerName;
|
||||
if (playerId) this._currentlySavingPlayerId = playerId;
|
||||
|
||||
return {
|
||||
closeSaving: (leaderboardEntryId) => {
|
||||
if (savingPromise !== this.lastSavingPromise) {
|
||||
logger.info(
|
||||
'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.'
|
||||
);
|
||||
|
||||
// Still finish the promise that can be waited upon:
|
||||
resolveSavingPromise();
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastScoreSavingSucceededAt = Date.now();
|
||||
this._lastSavedScore = this._currentlySavingScore;
|
||||
this._lastSavedPlayerName = this._currentlySavingPlayerName;
|
||||
this._lastSavedPlayerId = this._currentlySavingPlayerId;
|
||||
this.lastSavedLeaderboardEntryId = leaderboardEntryId;
|
||||
this.hasScoreBeenSaved = true;
|
||||
|
||||
resolveSavingPromise();
|
||||
},
|
||||
closeSavingWithError: (errorCode) => {
|
||||
if (savingPromise !== this.lastSavingPromise) {
|
||||
logger.info(
|
||||
'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.'
|
||||
);
|
||||
|
||||
// Still finish the promise that can be waited upon:
|
||||
resolveSavingPromise();
|
||||
return;
|
||||
}
|
||||
|
||||
this._setError(errorCode);
|
||||
resolveSavingPromise();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
closeSaving(): void {
|
||||
this.lastScoreSavingSucceededAt = Date.now();
|
||||
this.lastSavedScore = this.currentlySavingScore;
|
||||
this.lastSavedPlayerName = this.currentlySavingPlayerName;
|
||||
this.lastSavedPlayerId = this.currentlySavingPlayerId;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = true;
|
||||
}
|
||||
|
||||
setError(errorCode: string): void {
|
||||
private _setError(errorCode: string): void {
|
||||
this.lastSaveError = errorCode;
|
||||
this.isScoreSaving = false;
|
||||
this.hasScoreBeenSaved = false;
|
||||
this.hasScoreSavingErrored = true;
|
||||
}
|
||||
@@ -172,6 +246,7 @@ namespace gdjs {
|
||||
}
|
||||
_loaderContainer.appendChild(_loader);
|
||||
|
||||
/** Get the saving state of the leaderboard who had the last update (successful or started). */
|
||||
const getLastScoreSavingState = function ({
|
||||
hasSucceeded,
|
||||
}: {
|
||||
@@ -201,19 +276,17 @@ namespace gdjs {
|
||||
return lastScoreSavingState;
|
||||
};
|
||||
|
||||
const saveScore = function ({
|
||||
const saveScore = async function ({
|
||||
leaderboardId,
|
||||
playerName,
|
||||
authenticatedPlayerData,
|
||||
score,
|
||||
scoreSavingState,
|
||||
runtimeScene,
|
||||
}: {
|
||||
leaderboardId: string;
|
||||
playerName?: string | null;
|
||||
authenticatedPlayerData?: { playerId: string; playerToken: string };
|
||||
score: number;
|
||||
scoreSavingState: ScoreSavingState;
|
||||
runtimeScene: gdjs.RuntimeScene;
|
||||
}) {
|
||||
const rootApi = runtimeScene
|
||||
@@ -242,195 +315,143 @@ namespace gdjs {
|
||||
] = `player-game-token ${authenticatedPlayerData.playerToken}`;
|
||||
leaderboardEntryCreationUrl += `?playerId=${authenticatedPlayerData.playerId}`;
|
||||
} else {
|
||||
// In case playerName is empty or undefined, the formatting will generate a random name.
|
||||
// In case playerName is empty, the backend will generate a random name.
|
||||
payloadObject['playerName'] = formatPlayerName(playerName);
|
||||
}
|
||||
const payload = JSON.stringify(payloadObject);
|
||||
headers['Digest'] = computeDigest(payload);
|
||||
|
||||
fetch(leaderboardEntryCreationUrl, {
|
||||
body: payload,
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
}).then(
|
||||
(response) => {
|
||||
if (!response.ok) {
|
||||
const errorCode = response.status.toString();
|
||||
logger.error(
|
||||
'Server responded with an error:',
|
||||
errorCode,
|
||||
response.statusText
|
||||
);
|
||||
scoreSavingState.setError(errorCode);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(leaderboardEntryCreationUrl, {
|
||||
body: payload,
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
});
|
||||
|
||||
scoreSavingState.closeSaving();
|
||||
|
||||
return response.text().then(
|
||||
(text) => {},
|
||||
(error) => {
|
||||
logger.warn(
|
||||
'An error occurred when reading response but score has been saved:',
|
||||
error
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorCode = response.status.toString();
|
||||
logger.error(
|
||||
'Server responded with an error:',
|
||||
errorCode,
|
||||
response.statusText
|
||||
);
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Error while submitting a leaderboard score:', error);
|
||||
const errorCode = 'REQUEST_NOT_SENT';
|
||||
scoreSavingState.setError(errorCode);
|
||||
|
||||
throw errorCode;
|
||||
}
|
||||
);
|
||||
|
||||
let leaderboardEntryId: string | null = null;
|
||||
try {
|
||||
const leaderboardEntry = await response.json();
|
||||
leaderboardEntryId = leaderboardEntry.id;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
'An error occurred when reading response but score has been saved:',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
return leaderboardEntryId;
|
||||
} catch (error) {
|
||||
logger.error('Error while submitting a leaderboard score:', error);
|
||||
const errorCode = 'REQUEST_NOT_SENT';
|
||||
|
||||
throw errorCode;
|
||||
}
|
||||
};
|
||||
|
||||
export const savePlayerScore = function (
|
||||
export const savePlayerScore = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
score: float,
|
||||
playerName: string
|
||||
) {
|
||||
let scoreSavingState: ScoreSavingState;
|
||||
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
|
||||
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
|
||||
let shouldStartSaving = true;
|
||||
if (
|
||||
shouldStartSaving &&
|
||||
scoreSavingState.isAlreadySavingThisScore({ playerName, score })
|
||||
) {
|
||||
logger.warn(
|
||||
'There is already a request to save with this player name and this score. Ignoring this one.'
|
||||
);
|
||||
shouldStartSaving = false;
|
||||
}
|
||||
) =>
|
||||
new gdjs.PromiseTask(
|
||||
(async () => {
|
||||
const scoreSavingState = (_scoreSavingStateByLeaderboard[
|
||||
leaderboardId
|
||||
] =
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] ||
|
||||
new ScoreSavingState());
|
||||
|
||||
if (
|
||||
shouldStartSaving &&
|
||||
scoreSavingState.isSameAsLastScore({ playerName, score })
|
||||
) {
|
||||
logger.warn(
|
||||
'The player and score to be sent are the same as previous one. Ignoring this one.'
|
||||
);
|
||||
scoreSavingState.setError('SAME_AS_PREVIOUS');
|
||||
shouldStartSaving = false;
|
||||
}
|
||||
try {
|
||||
const {
|
||||
closeSaving,
|
||||
closeSavingWithError,
|
||||
} = scoreSavingState.startSaving({ playerName, score });
|
||||
|
||||
if (
|
||||
shouldStartSaving &&
|
||||
scoreSavingState.isTooSoonToSaveAnotherScore()
|
||||
) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
scoreSavingState.setError('TOO_FAST');
|
||||
shouldStartSaving = false;
|
||||
// Set the starting time to cancel all the following attempts that
|
||||
// are started too early after this one.
|
||||
scoreSavingState.lastScoreSavingStartedAt = Date.now();
|
||||
}
|
||||
if (!shouldStartSaving) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
scoreSavingState = new ScoreSavingState();
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
|
||||
}
|
||||
try {
|
||||
const leaderboardEntryId = await saveScore({
|
||||
leaderboardId,
|
||||
playerName,
|
||||
score,
|
||||
runtimeScene,
|
||||
});
|
||||
closeSaving(leaderboardEntryId);
|
||||
} catch (errorCode) {
|
||||
closeSavingWithError(errorCode);
|
||||
}
|
||||
} catch {
|
||||
// Do nothing: saving was rejected for a reason already logged.
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
scoreSavingState.startSaving({ playerName, score });
|
||||
|
||||
saveScore({
|
||||
leaderboardId,
|
||||
playerName,
|
||||
score,
|
||||
scoreSavingState,
|
||||
runtimeScene,
|
||||
});
|
||||
};
|
||||
|
||||
export const saveConnectedPlayerScore = function (
|
||||
export const saveConnectedPlayerScore = (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
score: float
|
||||
) {
|
||||
let scoreSavingState: ScoreSavingState;
|
||||
const playerId = gdjs.playerAuthentication.getUserId();
|
||||
const playerToken = gdjs.playerAuthentication.getUserToken();
|
||||
if (!playerId || !playerToken) {
|
||||
logger.warn(
|
||||
'Cannot save a score for a connected player if the player is not connected.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_scoreSavingStateByLeaderboard[leaderboardId]) {
|
||||
scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
|
||||
let shouldStartSaving = true;
|
||||
if (
|
||||
shouldStartSaving &&
|
||||
scoreSavingState.isAlreadySavingThisScore({ playerId, score })
|
||||
) {
|
||||
logger.warn(
|
||||
'There is already a request to save with this player ID and this score. Ignoring this one.'
|
||||
);
|
||||
shouldStartSaving = false;
|
||||
}
|
||||
) =>
|
||||
new gdjs.PromiseTask(
|
||||
(async () => {
|
||||
const playerId = gdjs.playerAuthentication.getUserId();
|
||||
const playerToken = gdjs.playerAuthentication.getUserToken();
|
||||
if (!playerId || !playerToken) {
|
||||
logger.warn(
|
||||
'Cannot save a score for a connected player if the player is not connected.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
shouldStartSaving &&
|
||||
scoreSavingState.isSameAsLastScore({ playerId, score })
|
||||
) {
|
||||
logger.warn(
|
||||
'The player and score to be sent are the same as previous one. Ignoring this one.'
|
||||
);
|
||||
scoreSavingState.setError('SAME_AS_PREVIOUS');
|
||||
shouldStartSaving = false;
|
||||
}
|
||||
const scoreSavingState = (_scoreSavingStateByLeaderboard[
|
||||
leaderboardId
|
||||
] =
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] ||
|
||||
new ScoreSavingState());
|
||||
|
||||
if (
|
||||
shouldStartSaving &&
|
||||
scoreSavingState.isTooSoonToSaveAnotherScore()
|
||||
) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
scoreSavingState.setError('TOO_FAST');
|
||||
shouldStartSaving = false;
|
||||
// Set the starting time to cancel all the following attempts that
|
||||
// are started too early after this one.
|
||||
scoreSavingState.lastScoreSavingStartedAt = Date.now();
|
||||
}
|
||||
if (!shouldStartSaving) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
scoreSavingState = new ScoreSavingState();
|
||||
_scoreSavingStateByLeaderboard[leaderboardId] = scoreSavingState;
|
||||
}
|
||||
try {
|
||||
const {
|
||||
closeSaving,
|
||||
closeSavingWithError,
|
||||
} = scoreSavingState.startSaving({ playerId, score });
|
||||
|
||||
scoreSavingState.startSaving({ playerId, score });
|
||||
|
||||
saveScore({
|
||||
leaderboardId,
|
||||
authenticatedPlayerData: { playerId, playerToken },
|
||||
score,
|
||||
scoreSavingState,
|
||||
runtimeScene,
|
||||
});
|
||||
};
|
||||
try {
|
||||
const leaderboardEntryId = await saveScore({
|
||||
leaderboardId,
|
||||
authenticatedPlayerData: { playerId, playerToken },
|
||||
score,
|
||||
runtimeScene,
|
||||
});
|
||||
closeSaving(leaderboardEntryId);
|
||||
} catch (errorCode) {
|
||||
closeSavingWithError(errorCode);
|
||||
}
|
||||
} catch {
|
||||
// Do nothing: saving was rejected for a reason already logged.
|
||||
}
|
||||
})()
|
||||
);
|
||||
|
||||
export const isSaving = function (leaderboardId?: string): boolean {
|
||||
if (leaderboardId) {
|
||||
return _scoreSavingStateByLeaderboard[leaderboardId]
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].isScoreSaving
|
||||
? _scoreSavingStateByLeaderboard[leaderboardId].isSaving()
|
||||
: false;
|
||||
}
|
||||
|
||||
const lastScoreSavingState = getLastScoreSavingState({
|
||||
hasSucceeded: false,
|
||||
});
|
||||
return lastScoreSavingState
|
||||
? lastScoreSavingState.isScoreSaving
|
||||
: false;
|
||||
return lastScoreSavingState ? lastScoreSavingState.isSaving() : false;
|
||||
};
|
||||
|
||||
export const hasBeenSaved = function (leaderboardId?: string): boolean {
|
||||
@@ -491,9 +512,7 @@ namespace gdjs {
|
||||
typeof rawName !== 'string' ||
|
||||
(typeof rawName === 'string' && rawName.length === 0)
|
||||
) {
|
||||
return `Player${Math.round(
|
||||
(Math.random() * 9 + 1) * 10000 // Number between 10,000 and 99,999
|
||||
)}`;
|
||||
return '';
|
||||
}
|
||||
return rawName
|
||||
.trim()
|
||||
@@ -648,7 +667,7 @@ namespace gdjs {
|
||||
return iframe;
|
||||
};
|
||||
|
||||
export const displayLeaderboard = function (
|
||||
export const displayLeaderboard = async function (
|
||||
runtimeScene: gdjs.RuntimeScene,
|
||||
leaderboardId: string,
|
||||
displayLoader: boolean
|
||||
@@ -682,81 +701,99 @@ namespace gdjs {
|
||||
});
|
||||
}
|
||||
|
||||
// If a save is being done for this leaderboard, wait for it to end so that the `lastSavedLeaderboardEntryId`
|
||||
// can be saved and then used to show the player score.
|
||||
const scoreSavingState = _scoreSavingStateByLeaderboard[leaderboardId];
|
||||
if (scoreSavingState && scoreSavingState.lastSavingPromise) {
|
||||
await scoreSavingState.lastSavingPromise;
|
||||
}
|
||||
|
||||
const lastSavedLeaderboardEntryId = scoreSavingState
|
||||
? scoreSavingState.lastSavedLeaderboardEntryId
|
||||
: null;
|
||||
|
||||
const gameId = gdjs.projectData.properties.projectUuid;
|
||||
const isDev = runtimeScene
|
||||
.getGame()
|
||||
.isUsingGDevelopDevelopmentEnvironment();
|
||||
const targetUrl = `https://gd.games/games/${gameId}/leaderboard/${leaderboardId}?inGameEmbedded=true${
|
||||
isDev ? '&dev=true' : ''
|
||||
}`;
|
||||
checkLeaderboardAvailability(targetUrl).then(
|
||||
(isAvailable) => {
|
||||
if (leaderboardId !== _requestedLeaderboardId) {
|
||||
logger.warn(
|
||||
`Received a response for leaderboard ${leaderboardId} though the last leaderboard requested is ${_requestedLeaderboardId}, ignoring this response.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isAvailable) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'Leaderboard data could not be fetched. Closing leaderboard view if there is one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_leaderboardViewIframe) {
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
if (displayLoader) {
|
||||
displayLoaderInLeaderboardView(true, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
}
|
||||
_leaderboardViewIframe.src = targetUrl;
|
||||
} else {
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('inGameEmbedded', 'true');
|
||||
if (isDev) searchParams.set('dev', 'true');
|
||||
if (lastSavedLeaderboardEntryId)
|
||||
searchParams.set(
|
||||
'playerLeaderboardEntryId',
|
||||
lastSavedLeaderboardEntryId
|
||||
);
|
||||
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
const targetUrl = `https://gd.games/games/${gameId}/leaderboard/${leaderboardId}?${searchParams}`;
|
||||
|
||||
_leaderboardViewIframe = computeLeaderboardDisplayingIframe(
|
||||
targetUrl
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
_leaderboardViewClosingCallback = (event: MessageEvent) => {
|
||||
receiveMessageFromLeaderboardView(
|
||||
runtimeScene,
|
||||
displayLoader,
|
||||
event
|
||||
);
|
||||
};
|
||||
(window as any).addEventListener(
|
||||
'message',
|
||||
_leaderboardViewClosingCallback,
|
||||
true
|
||||
);
|
||||
}
|
||||
domElementContainer.appendChild(_leaderboardViewIframe);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
logger.error(err);
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'An error occurred when fetching leaderboard data. Closing leaderboard view if there is one.'
|
||||
try {
|
||||
const isAvailable = await checkLeaderboardAvailability(targetUrl);
|
||||
|
||||
if (leaderboardId !== _requestedLeaderboardId) {
|
||||
logger.warn(
|
||||
`Received a response for leaderboard ${leaderboardId} though the last leaderboard requested is ${_requestedLeaderboardId}, ignoring this response.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
);
|
||||
if (!isAvailable) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'Leaderboard data could not be fetched. Closing leaderboard view if there is one.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_leaderboardViewIframe) {
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
if (displayLoader) {
|
||||
displayLoaderInLeaderboardView(true, runtimeScene, {
|
||||
callOnErrorIfDomElementContainerMissing: false,
|
||||
});
|
||||
}
|
||||
_leaderboardViewIframe.src = targetUrl;
|
||||
} else {
|
||||
const domElementContainer = runtimeScene
|
||||
.getGame()
|
||||
.getRenderer()
|
||||
.getDomElementContainer();
|
||||
if (!domElementContainer) {
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
"The div element covering the game couldn't be found, the leaderboard cannot be displayed."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resetLeaderboardDisplayErrorTimeout(runtimeScene);
|
||||
|
||||
_leaderboardViewIframe = computeLeaderboardDisplayingIframe(
|
||||
targetUrl
|
||||
);
|
||||
if (typeof window !== 'undefined') {
|
||||
_leaderboardViewClosingCallback = (event: MessageEvent) => {
|
||||
receiveMessageFromLeaderboardView(
|
||||
runtimeScene,
|
||||
displayLoader,
|
||||
event
|
||||
);
|
||||
};
|
||||
(window as any).addEventListener(
|
||||
'message',
|
||||
_leaderboardViewClosingCallback,
|
||||
true
|
||||
);
|
||||
}
|
||||
domElementContainer.appendChild(_leaderboardViewIframe);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
handleErrorDisplayingLeaderboard(
|
||||
runtimeScene,
|
||||
'An error occurred when fetching leaderboard data. Closing leaderboard view if there is one.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const isLeaderboardViewErrored = function (): boolean {
|
||||
|
@@ -29,26 +29,14 @@ describe('Leaderboards', () => {
|
||||
});
|
||||
|
||||
it('it generates a predefined player name with a random number if input is void/wrong type/empty', () => {
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(null)).to.be('');
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(undefined)).to.be('');
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('')).to.be('');
|
||||
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(null)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(5)).to.be('');
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(5)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(undefined)).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(() => {})).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
// @ts-ignore
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName('')).to.match(
|
||||
/^Player\d{5}/
|
||||
);
|
||||
expect(gdjs.evtTools.leaderboards.formatPlayerName(() => {})).to.be('');
|
||||
});
|
||||
|
||||
it('it removes accents from latin letters', () => {
|
||||
|
@@ -105,7 +105,7 @@ Use [`addAction`](https://docs.gdevelop.io/GDCore%20Documentation/classgd_1_1_pl
|
||||
- Call `getCodeExtraInformation()` and then functions like [`setFunctionName` and `setIncludeFile`](https://docs.gdevelop.io/GDCore%20Documentation/classgd_1_1_instruction_metadata_1_1_extra_information.html) to declare the JavaScript function to be called and the file to be included.
|
||||
- If your function is asynchronous, call `setAsyncFunctionName` instead to tell GDevelop to wait for the returned `Task` to resolve before executing subsequent actions and subevents.
|
||||
- If you want to be able to toggle the awaiting on and off, make one implementation of the function that returns a `Task` and one that doesn't, and use both `setFunctionName` and `setAsyncFunctionName` respectively to declare both to GDevelop, making the action optionally async.
|
||||
- If you just want to return a promise to be awaited, you do not need to create your own task. Simply return `new gd.PromiseTask(yourPromise)`.
|
||||
- If you just want to return a promise to be awaited, you do not need to create your own task. Simply return `new gdjs.PromiseTask(yourPromise)`.
|
||||
- Note that, as of now, _only actions can be asynchronous_. Giving other functions an async function name will not have any effect.
|
||||
|
||||
> You can call these functions on the `extension` object, or on the objects returned by `extension.addObject` (for objects) or `extension.addBehavior` (for behaviors). See below.
|
||||
|
@@ -33,6 +33,8 @@ import LeaderboardPlaygroundCard from './LeaderboardPlaygroundCard';
|
||||
import { rgbStringToHexString } from '../../Utils/ColorTransformer';
|
||||
import Link from '../../UI/Link';
|
||||
import Window from '../../Utils/Window';
|
||||
import Checkbox from '../../UI/Checkbox';
|
||||
import SemiControlledTextField from '../../UI/SemiControlledTextField';
|
||||
|
||||
const unitToAbbreviation = {
|
||||
hour: 'HH',
|
||||
@@ -99,7 +101,7 @@ function LeaderboardAppearanceDialog({
|
||||
}: Props) {
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false);
|
||||
const authenticatedUser = React.useContext(AuthenticatedUserContext);
|
||||
const canUserCustomizeTheme = canUserCustomizeLeaderboardTheme(
|
||||
const { canUseTheme, canUseCustomCss } = canUserCustomizeLeaderboardTheme(
|
||||
authenticatedUser
|
||||
);
|
||||
const rgbLeaderboardTheme = getRGBLeaderboardTheme(
|
||||
@@ -131,6 +133,17 @@ function LeaderboardAppearanceDialog({
|
||||
defaultDisplayedEntriesNumberError,
|
||||
setDefaultDisplayedEntriesNumberError,
|
||||
] = React.useState<?string>(null);
|
||||
const [customCss, setCustomCss] = React.useState<string>(
|
||||
(leaderboardCustomizationSettings &&
|
||||
leaderboardCustomizationSettings.customCss) ||
|
||||
''
|
||||
);
|
||||
const [useCustomCss, setUseCustomCss] = React.useState<boolean>(
|
||||
!!(
|
||||
leaderboardCustomizationSettings &&
|
||||
leaderboardCustomizationSettings.useCustomCss
|
||||
)
|
||||
);
|
||||
const [scoreTitle, setScoreTitle] = React.useState<string>(
|
||||
leaderboardCustomizationSettings
|
||||
? leaderboardCustomizationSettings.scoreTitle
|
||||
@@ -210,7 +223,7 @@ function LeaderboardAppearanceDialog({
|
||||
precision,
|
||||
}
|
||||
: { type: scoreType, ...unitSelectOptions[timeUnits] },
|
||||
theme: canUserCustomizeTheme
|
||||
theme: canUseTheme
|
||||
? {
|
||||
backgroundColor: rgbStringToHexString(backgroundColor),
|
||||
textColor: rgbStringToHexString(textColor),
|
||||
@@ -220,6 +233,8 @@ function LeaderboardAppearanceDialog({
|
||||
highlightTextColor: rgbStringToHexString(highlightTextColor),
|
||||
}
|
||||
: undefined,
|
||||
useCustomCss,
|
||||
customCss,
|
||||
};
|
||||
await onSave(customizationSettings);
|
||||
};
|
||||
@@ -289,6 +304,10 @@ function LeaderboardAppearanceDialog({
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Spacer />
|
||||
<Text size="sub-title" noMargin>
|
||||
<Trans>Visual appearance</Trans>
|
||||
</Text>
|
||||
<ResponsiveLineStackLayout noMargin>
|
||||
<ColorField
|
||||
floatingLabelText={<Trans>Background color</Trans>}
|
||||
@@ -296,7 +315,7 @@ function LeaderboardAppearanceDialog({
|
||||
fullWidth
|
||||
color={backgroundColor}
|
||||
onChange={setBackgroundColor}
|
||||
disabled={!canUserCustomizeTheme || isLoading}
|
||||
disabled={!canUseTheme || isLoading}
|
||||
/>
|
||||
<ColorField
|
||||
floatingLabelText={<Trans>Text color</Trans>}
|
||||
@@ -304,7 +323,7 @@ function LeaderboardAppearanceDialog({
|
||||
fullWidth
|
||||
color={textColor}
|
||||
onChange={setTextColor}
|
||||
disabled={!canUserCustomizeTheme || isLoading}
|
||||
disabled={!canUseTheme || isLoading}
|
||||
/>
|
||||
</ResponsiveLineStackLayout>
|
||||
<ResponsiveLineStackLayout noMargin>
|
||||
@@ -314,7 +333,7 @@ function LeaderboardAppearanceDialog({
|
||||
fullWidth
|
||||
color={highlightBackgroundColor}
|
||||
onChange={setHighlightBackgroundColor}
|
||||
disabled={!canUserCustomizeTheme || isLoading}
|
||||
disabled={!canUseTheme || isLoading}
|
||||
/>
|
||||
<ColorField
|
||||
floatingLabelText={<Trans>Highlight text color</Trans>}
|
||||
@@ -322,10 +341,10 @@ function LeaderboardAppearanceDialog({
|
||||
fullWidth
|
||||
color={highlightTextColor}
|
||||
onChange={setHighlightTextColor}
|
||||
disabled={!canUserCustomizeTheme || isLoading}
|
||||
disabled={!canUseTheme || isLoading}
|
||||
/>
|
||||
</ResponsiveLineStackLayout>
|
||||
{!canUserCustomizeTheme ? (
|
||||
{!canUseTheme ? (
|
||||
<GetSubscriptionCard subscriptionDialogOpeningReason="Leaderboard customization">
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
@@ -354,6 +373,59 @@ function LeaderboardAppearanceDialog({
|
||||
<LeaderboardPlaygroundCard />
|
||||
)}
|
||||
<Spacer />
|
||||
<Text size="sub-title" noMargin>
|
||||
<Trans>Visual appearance (advanced)</Trans>
|
||||
</Text>
|
||||
<Checkbox
|
||||
label={<Trans>Use custom CSS for the leaderboard</Trans>}
|
||||
disabled={
|
||||
// Disable the checkbox if it's loading,
|
||||
// or if custom css is not allowed - unless it's already checked,
|
||||
// in which case we allow to uncheck it.
|
||||
(!canUseCustomCss && !useCustomCss) || isLoading
|
||||
}
|
||||
checked={useCustomCss}
|
||||
onCheck={(e, checked) => setUseCustomCss(checked)}
|
||||
/>
|
||||
<SemiControlledTextField
|
||||
fullWidth
|
||||
floatingLabelText={<Trans>Custom CSS</Trans>}
|
||||
multiline
|
||||
rows={4}
|
||||
rowsMax={15}
|
||||
value={customCss}
|
||||
onChange={setCustomCss}
|
||||
disabled={!useCustomCss || isLoading}
|
||||
/>
|
||||
{!canUseCustomCss ? (
|
||||
<GetSubscriptionCard subscriptionDialogOpeningReason="Leaderboard customization">
|
||||
<Line>
|
||||
<Column noMargin>
|
||||
<Text noMargin>
|
||||
<Trans>
|
||||
Get a business subscription to unlock custom CSS or
|
||||
contact us.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Link
|
||||
href="https://gd.games/playground/test-leaderboard"
|
||||
onClick={() =>
|
||||
Window.openExternalURL(
|
||||
'https://gd.games/playground/test-leaderboard'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text noMargin color="inherit">
|
||||
<Trans>Test it out!</Trans>
|
||||
</Text>
|
||||
</Link>
|
||||
</Column>
|
||||
</Line>
|
||||
</GetSubscriptionCard>
|
||||
) : (
|
||||
<LeaderboardPlaygroundCard />
|
||||
)}
|
||||
<Spacer />
|
||||
<Text size="sub-title" noMargin>
|
||||
<Trans>Score column settings</Trans>
|
||||
</Text>
|
||||
|
@@ -28,6 +28,8 @@ import Refresh from '../../UI/CustomSvgIcons/Refresh';
|
||||
import Trash from '../../UI/CustomSvgIcons/Trash';
|
||||
import Visibility from '../../UI/CustomSvgIcons/Visibility';
|
||||
import VisibilityOff from '../../UI/CustomSvgIcons/VisibilityOff';
|
||||
import Lock from '../../UI/CustomSvgIcons/Lock';
|
||||
import LockOpen from '../../UI/CustomSvgIcons/LockOpen';
|
||||
import Copy from '../../UI/CustomSvgIcons/Copy';
|
||||
|
||||
import PlaceholderLoader from '../../UI/PlaceholderLoader';
|
||||
@@ -87,6 +89,8 @@ type ApiError = {|
|
||||
| 'leaderboardNameUpdate'
|
||||
| 'leaderboardSortUpdate'
|
||||
| 'leaderboardVisibilityUpdate'
|
||||
| 'leaderboardAutoPlayerNamePrefixUpdate'
|
||||
| 'leaderboardIgnoreCustomPlayerNamesUpdate'
|
||||
| 'leaderboardPrimaryUpdate'
|
||||
| 'leaderboardAppearanceUpdate'
|
||||
| 'leaderboardPlayerUnicityDisplayChoiceUpdate'
|
||||
@@ -127,6 +131,10 @@ const getApiError = (payload: LeaderboardUpdatePayload): ApiError => ({
|
||||
? 'leaderboardSortUpdate'
|
||||
: payload.visibility
|
||||
? 'leaderboardVisibilityUpdate'
|
||||
: payload.ignoreCustomPlayerNames !== undefined
|
||||
? 'leaderboardIgnoreCustomPlayerNamesUpdate'
|
||||
: payload.autoPlayerNamePrefix !== undefined
|
||||
? 'leaderboardAutoPlayerNamePrefixUpdate'
|
||||
: payload.primary
|
||||
? 'leaderboardPrimaryUpdate'
|
||||
: payload.customizationSettings
|
||||
@@ -147,6 +155,16 @@ const getApiError = (payload: LeaderboardUpdatePayload): ApiError => ({
|
||||
An error occurred when updating the visibility of the leaderboard, please
|
||||
close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : payload.ignoreCustomPlayerNames !== undefined ? (
|
||||
<Trans>
|
||||
An error occurred when updating the handling of player names of the
|
||||
leaderboard, please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : payload.autoPlayerNamePrefix !== undefined ? (
|
||||
<Trans>
|
||||
An error occurred when updating the handling of player names of the
|
||||
leaderboard, please close the dialog, come back and try again.
|
||||
</Trans>
|
||||
) : payload.primary ? (
|
||||
<Trans>
|
||||
An error occurred when setting the leaderboard as default, please close
|
||||
@@ -208,12 +226,23 @@ export const LeaderboardAdmin = ({
|
||||
setIsEditingSortOptions,
|
||||
] = React.useState<boolean>(false);
|
||||
const [isEditingName, setIsEditingName] = React.useState<boolean>(false);
|
||||
const [
|
||||
isEditingAutoPlayerNamePrefix,
|
||||
setIsEditingAutoPlayerNamePrefix,
|
||||
] = React.useState<boolean>(false);
|
||||
const [isRequestPending, setIsRequestPending] = React.useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [newName, setNewName] = React.useState<string>('');
|
||||
const [newNameError, setNewNameError] = React.useState<?string>(null);
|
||||
const [
|
||||
newAutoPlayerNamePrefix,
|
||||
setNewAutoPlayerNamePrefix,
|
||||
] = React.useState<string>('');
|
||||
const newNameTextFieldRef = React.useRef<?TextFieldInterface>(null);
|
||||
const newAutoPlayerNamePrefixTextFieldRef = React.useRef<?TextFieldInterface>(
|
||||
null
|
||||
);
|
||||
const [apiError, setApiError] = React.useState<?ApiError>(null);
|
||||
const [
|
||||
displayGameRegistration,
|
||||
@@ -265,6 +294,8 @@ export const LeaderboardAdmin = ({
|
||||
try {
|
||||
await updateLeaderboard(payload);
|
||||
if (payload.name) setIsEditingName(false);
|
||||
if (payload.autoPlayerNamePrefix !== undefined)
|
||||
setIsEditingAutoPlayerNamePrefix(false);
|
||||
} catch (err) {
|
||||
console.error('An error occurred when updating leaderboard', err);
|
||||
setApiError(getApiError(payload));
|
||||
@@ -794,6 +825,152 @@ export const LeaderboardAdmin = ({
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ignoreCustomPlayerNames',
|
||||
avatar: currentLeaderboard.ignoreCustomPlayerNames ? (
|
||||
<Lock />
|
||||
) : (
|
||||
<LockOpen />
|
||||
),
|
||||
text: (
|
||||
<Tooltip
|
||||
title={i18n._(
|
||||
currentLeaderboard.ignoreCustomPlayerNames
|
||||
? t`Even if the action is used to send a score with a custom player username, this name will be ignored by the leaderboard.`
|
||||
: t`The player name sent in the action to send a score will be used.`
|
||||
)}
|
||||
>
|
||||
<Text size="body2">
|
||||
{currentLeaderboard.ignoreCustomPlayerNames ? (
|
||||
<Trans>Ignore unauthenticated player usernames</Trans>
|
||||
) : (
|
||||
<Trans>Allow unauthenticated player usernames</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError &&
|
||||
apiError.action === 'leaderboardIgnoreCustomPlayerNamesUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
await onUpdateLeaderboard(i18n, {
|
||||
ignoreCustomPlayerNames: !currentLeaderboard.ignoreCustomPlayerNames,
|
||||
});
|
||||
}}
|
||||
tooltip={
|
||||
currentLeaderboard.ignoreCustomPlayerNames
|
||||
? t`Change to allow custom player usernames`
|
||||
: t`Change to ignore custom player usernames`
|
||||
}
|
||||
edge="end"
|
||||
disabled={isRequestPending || isEditingName}
|
||||
>
|
||||
<SwitchHorizontal />
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'autoPlayerNamePrefix',
|
||||
avatar: <Tag />,
|
||||
text: isEditingAutoPlayerNamePrefix ? (
|
||||
<Line alignItems="center" expand noMargin>
|
||||
<TextField
|
||||
id="edit-autoPlayerNamePrefix-field"
|
||||
ref={newAutoPlayerNamePrefixTextFieldRef}
|
||||
margin="none"
|
||||
style={styles.leaderboardNameTextField}
|
||||
maxLength={40}
|
||||
value={newAutoPlayerNamePrefix}
|
||||
onChange={(e, text) => setNewAutoPlayerNamePrefix(text)}
|
||||
onKeyPress={event => {
|
||||
if (shouldValidate(event) && !isRequestPending) {
|
||||
onUpdateLeaderboard(i18n, {
|
||||
autoPlayerNamePrefix: newAutoPlayerNamePrefix,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={isRequestPending}
|
||||
/>
|
||||
{!isRequestPending && (
|
||||
<>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
tooltip={t`Cancel`}
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => {
|
||||
setIsEditingAutoPlayerNamePrefix(false);
|
||||
}}
|
||||
>
|
||||
<Cross />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Line>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
currentLeaderboard.autoPlayerNamePrefix ||
|
||||
i18n._('No custom prefix for auto-generated player names')
|
||||
}
|
||||
>
|
||||
<Text size="body2" style={styles.leaderboardNameText}>
|
||||
{currentLeaderboard.autoPlayerNamePrefix ||
|
||||
i18n._('No custom prefix for auto-generated player names')}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
),
|
||||
secondaryText:
|
||||
apiError &&
|
||||
apiError.action === 'leaderboardAutoPlayerNamePrefixUpdate' ? (
|
||||
<Text color="error" size="body2">
|
||||
{apiError.message}
|
||||
</Text>
|
||||
) : null,
|
||||
secondaryAction: (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (isEditingAutoPlayerNamePrefix) {
|
||||
onUpdateLeaderboard(i18n, {
|
||||
autoPlayerNamePrefix: newAutoPlayerNamePrefix,
|
||||
});
|
||||
} else {
|
||||
setNewAutoPlayerNamePrefix(
|
||||
currentLeaderboard.autoPlayerNamePrefix || ''
|
||||
);
|
||||
setIsEditingAutoPlayerNamePrefix(true);
|
||||
}
|
||||
}}
|
||||
tooltip={
|
||||
isEditingAutoPlayerNamePrefix
|
||||
? t`Save`
|
||||
: t`Change the default prefix for player names`
|
||||
}
|
||||
disabled={isRequestPending}
|
||||
edge="end"
|
||||
id={
|
||||
isEditingAutoPlayerNamePrefix
|
||||
? 'save-autoPlayerNamePrefix-button'
|
||||
: 'edit-autoPlayerNamePrefix-button'
|
||||
}
|
||||
>
|
||||
{isEditingAutoPlayerNamePrefix ? (
|
||||
isRequestPending ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<Save />
|
||||
)
|
||||
) : (
|
||||
<Edit />
|
||||
)}
|
||||
</IconButton>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'appearance',
|
||||
avatar: <TextFormat />,
|
||||
|
@@ -47,6 +47,8 @@ export type LeaderboardCustomizationSettings = {|
|
||||
scoreTitle: string,
|
||||
scoreFormatting: LeaderboardScoreFormatting,
|
||||
theme?: LeaderboardTheme,
|
||||
useCustomCss?: boolean,
|
||||
customCss?: string,
|
||||
|};
|
||||
|
||||
export type Leaderboard = {|
|
||||
@@ -62,6 +64,8 @@ export type Leaderboard = {|
|
||||
primary?: boolean,
|
||||
resetLaunchedAt?: string,
|
||||
extremeAllowedScore?: number,
|
||||
ignoreCustomPlayerNames?: boolean,
|
||||
autoPlayerNamePrefix?: string,
|
||||
|};
|
||||
|
||||
export type LeaderboardUpdatePayload = {|
|
||||
@@ -72,6 +76,8 @@ export type LeaderboardUpdatePayload = {|
|
||||
customizationSettings?: LeaderboardCustomizationSettings,
|
||||
primary?: boolean,
|
||||
extremeAllowedScore?: number | null,
|
||||
ignoreCustomPlayerNames?: boolean,
|
||||
autoPlayerNamePrefix?: string,
|
||||
|};
|
||||
|
||||
export type LeaderboardEntry = {|
|
||||
@@ -414,16 +420,24 @@ export const updateComment = async (
|
||||
|
||||
export const canUserCustomizeLeaderboardTheme = (
|
||||
authenticatedUser: AuthenticatedUser
|
||||
): boolean => {
|
||||
): {|
|
||||
canUseTheme: boolean,
|
||||
canUseCustomCss: boolean,
|
||||
|} => {
|
||||
const { limits } = authenticatedUser;
|
||||
return (
|
||||
!!limits &&
|
||||
!!limits.capabilities.leaderboards &&
|
||||
(limits.capabilities.leaderboards.themeCustomizationCapabilities ===
|
||||
'BASIC' ||
|
||||
limits.capabilities.leaderboards.themeCustomizationCapabilities ===
|
||||
'FULL')
|
||||
);
|
||||
return {
|
||||
canUseTheme:
|
||||
!!limits &&
|
||||
!!limits.capabilities.leaderboards &&
|
||||
(limits.capabilities.leaderboards.themeCustomizationCapabilities ===
|
||||
'BASIC' ||
|
||||
limits.capabilities.leaderboards.themeCustomizationCapabilities ===
|
||||
'FULL'),
|
||||
canUseCustomCss:
|
||||
!!limits &&
|
||||
!!limits.capabilities.leaderboards &&
|
||||
!!limits.capabilities.leaderboards.canUseCustomCss,
|
||||
};
|
||||
};
|
||||
|
||||
export const getRGBLeaderboardTheme = (
|
||||
|
@@ -63,6 +63,7 @@ export type Capabilities = {
|
||||
maximumCountPerGame: number,
|
||||
canMaximumCountPerGameBeIncreased: boolean,
|
||||
themeCustomizationCapabilities: 'NONE' | 'BASIC' | 'FULL',
|
||||
canUseCustomCss: boolean,
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -197,14 +197,21 @@ export const subscriptionForGoldUser: Subscription = {
|
||||
planId: 'gdevelop_gold',
|
||||
createdAt: 1515084011000,
|
||||
updatedAt: 1515084011000,
|
||||
userId: 'silver-user',
|
||||
userId: 'gold-user',
|
||||
};
|
||||
|
||||
export const subscriptionForStartupUser: Subscription = {
|
||||
planId: 'gdevelop_startup',
|
||||
createdAt: 1515084011000,
|
||||
updatedAt: 1515084011000,
|
||||
userId: 'silver-user',
|
||||
userId: 'startup-user',
|
||||
};
|
||||
|
||||
export const subscriptionForBusinessUser: Subscription = {
|
||||
planId: 'gdevelop_business',
|
||||
createdAt: 1515084011000,
|
||||
updatedAt: 1515084011000,
|
||||
userId: 'business-user',
|
||||
};
|
||||
|
||||
export const subscriptionForEducationPlan: Subscription = {
|
||||
@@ -272,6 +279,7 @@ export const limitsForNoSubscriptionUser: Limits = {
|
||||
maximumCountPerGame: 3,
|
||||
canMaximumCountPerGameBeIncreased: true,
|
||||
themeCustomizationCapabilities: 'NONE',
|
||||
canUseCustomCss: false,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
@@ -306,6 +314,7 @@ export const limitsForSilverUser: Limits = {
|
||||
maximumCountPerGame: -1,
|
||||
canMaximumCountPerGameBeIncreased: false,
|
||||
themeCustomizationCapabilities: 'BASIC',
|
||||
canUseCustomCss: false,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
@@ -340,6 +349,7 @@ export const limitsForGoldUser: Limits = {
|
||||
maximumCountPerGame: -1,
|
||||
canMaximumCountPerGameBeIncreased: false,
|
||||
themeCustomizationCapabilities: 'FULL',
|
||||
canUseCustomCss: false,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
@@ -374,6 +384,42 @@ export const limitsForStartupUser: Limits = {
|
||||
maximumCountPerGame: -1,
|
||||
canMaximumCountPerGameBeIncreased: false,
|
||||
themeCustomizationCapabilities: 'FULL',
|
||||
canUseCustomCss: false,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
'cordova-build': {
|
||||
current: 2,
|
||||
max: 1000,
|
||||
limitReached: false,
|
||||
},
|
||||
'ai-project-generation': {
|
||||
current: 3,
|
||||
max: 1000,
|
||||
limitReached: false,
|
||||
},
|
||||
},
|
||||
message: undefined,
|
||||
};
|
||||
|
||||
export const limitsForBusinessUser: Limits = {
|
||||
capabilities: {
|
||||
analytics: {
|
||||
sessions: true,
|
||||
players: true,
|
||||
retention: true,
|
||||
sessionsTimeStats: true,
|
||||
platforms: true,
|
||||
},
|
||||
cloudProjects: {
|
||||
maximumCount: 500,
|
||||
canMaximumCountBeIncreased: false,
|
||||
},
|
||||
leaderboards: {
|
||||
maximumCountPerGame: -1,
|
||||
canMaximumCountPerGameBeIncreased: false,
|
||||
themeCustomizationCapabilities: 'FULL',
|
||||
canUseCustomCss: true,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
@@ -408,6 +454,7 @@ export const limitsReached: Limits = {
|
||||
maximumCountPerGame: 3,
|
||||
canMaximumCountPerGameBeIncreased: true,
|
||||
themeCustomizationCapabilities: 'BASIC',
|
||||
canUseCustomCss: false,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
@@ -499,7 +546,12 @@ export const fakeGoldAuthenticatedUser: AuthenticatedUser = {
|
||||
export const fakeStartupAuthenticatedUser: AuthenticatedUser = {
|
||||
...fakeSilverAuthenticatedUser,
|
||||
subscription: subscriptionForStartupUser,
|
||||
limits: limitsForGoldUser,
|
||||
limits: limitsForStartupUser,
|
||||
};
|
||||
export const fakeBusinessAuthenticatedUser: AuthenticatedUser = {
|
||||
...fakeSilverAuthenticatedUser,
|
||||
subscription: subscriptionForBusinessUser,
|
||||
limits: limitsForBusinessUser,
|
||||
};
|
||||
export const fakeAuthenticatedUserWithEducationPlan: AuthenticatedUser = {
|
||||
...fakeSilverAuthenticatedUser,
|
||||
|
@@ -8,6 +8,7 @@ import LeaderboardAppearanceDialog from '../../../GameDashboard/LeaderboardAdmin
|
||||
import AuthenticatedUserContext from '../../../Profile/AuthenticatedUserContext';
|
||||
import {
|
||||
fakeSilverAuthenticatedUser,
|
||||
fakeBusinessAuthenticatedUser,
|
||||
fakeAuthenticatedUserWithNoSubscription,
|
||||
} from '../../../fixtures/GDevelopServicesTestData';
|
||||
|
||||
@@ -38,7 +39,7 @@ export const WithoutSubscription = () => (
|
||||
</AuthenticatedUserContext.Provider>
|
||||
);
|
||||
|
||||
export const WithSubscription = () => (
|
||||
export const WithSilverSubscription = () => (
|
||||
<AuthenticatedUserContext.Provider value={fakeSilverAuthenticatedUser}>
|
||||
<LeaderboardAppearanceDialog
|
||||
open
|
||||
@@ -56,3 +57,22 @@ export const WithSubscription = () => (
|
||||
/>
|
||||
</AuthenticatedUserContext.Provider>
|
||||
);
|
||||
|
||||
export const WithBusinessSubscription = () => (
|
||||
<AuthenticatedUserContext.Provider value={fakeBusinessAuthenticatedUser}>
|
||||
<LeaderboardAppearanceDialog
|
||||
open
|
||||
onClose={() => action('onClose')()}
|
||||
onSave={() => action('onSave')()}
|
||||
leaderboardCustomizationSettings={{
|
||||
scoreTitle: 'Coins collected',
|
||||
scoreFormatting: {
|
||||
type: 'custom',
|
||||
prefix: '',
|
||||
suffix: ' coins',
|
||||
precision: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</AuthenticatedUserContext.Provider>
|
||||
);
|
||||
|
Reference in New Issue
Block a user