Skip to content

Commit ba7b0ed

Browse files
committed
custom achievment support
1 parent 0022756 commit ba7b0ed

File tree

4 files changed

+347
-13
lines changed

4 files changed

+347
-13
lines changed

src/engine/runtime.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,6 +1140,22 @@ class Runtime extends EventEmitter {
11401140
}
11411141
}
11421142

1143+
if (extensionInfo.id === 'customAchievements') {
1144+
const xml = '<button ' +
1145+
`text="${xmlEscape(maybeFormatMessage({
1146+
id: 'tw.blocks.modifyAchievements',
1147+
default: 'Modify Achievements',
1148+
description: 'Button that opens popup to modify achievements'
1149+
}))}" ` +
1150+
'callbackKey="OPEN_ACHIEVEMENT_POPUP" ' +
1151+
`callbackData="OPEN_ACHIEVEMENT_POPUP"></button>`;
1152+
const block = {
1153+
info: {},
1154+
xml
1155+
};
1156+
categoryInfo.blocks.push(block);
1157+
}
1158+
11431159
if (extensionInfo.docsURI) {
11441160
const xml = '<button ' +
11451161
`text="${xmlEscape(maybeFormatMessage({

src/extension-support/extension-manager.js

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ const defaultBuiltinExtensions = {
2626
boost: () => require('../extensions/scratch3_boost'),
2727
gdxfor: () => require('../extensions/scratch3_gdx_for'),
2828
// tw: core extension
29-
tw: () => require('../extensions/tw')
29+
tw: () => require('../extensions/tw'),
30+
// Custom Achievements extension
31+
customAchievements: () => require('../extensions/custom_achievements')
3032
};
3133

3234
/**
@@ -157,8 +159,9 @@ class ExtensionManager {
157159
* Synchronously load an internal extension (core or non-core) by ID. This call will
158160
* fail if the provided id is not does not match an internal extension.
159161
* @param {string} extensionId - the ID of an internal extension
162+
* @param {object} additionalInfo data to be passed on to very special extensions (EG: customAchievements)
160163
*/
161-
loadExtensionIdSync (extensionId) {
164+
loadExtensionIdSync (extensionId, additionalInfo = {}) {
162165
if (!this.isBuiltinExtension(extensionId)) {
163166
log.warn(`Could not find extension ${extensionId} in the built in extensions.`);
164167
return;
@@ -172,7 +175,12 @@ class ExtensionManager {
172175
}
173176

174177
const extension = this.builtinExtensions[extensionId]();
175-
const extensionInstance = new extension(this.runtime);
178+
const passedDownRuntime = this.runtime;
179+
// this is hardcoded to prevent security vulnerabilities where an extension could read JWT access token
180+
if (extensionId === 'customAchievements'){
181+
passedDownRuntime.additionalInfo = additionalInfo;
182+
}
183+
const extensionInstance = new extension(passedDownRuntime);
176184
const serviceName = this._registerInternalExtension(extensionInstance);
177185
this._loadedExtensions.set(extensionId, serviceName);
178186
this.runtime.compilerRegisterExtension(extensionId, extensionInstance);
@@ -211,10 +219,10 @@ class ExtensionManager {
211219
* @param {string} extensionURL - the URL for the extension to load OR the ID of an internal extension
212220
* @returns {Promise} resolved once the extension is loaded and initialized or rejected on failure
213221
*/
214-
async loadExtensionURL (extensionURL, emit = true) {
222+
async loadExtensionURL (extensionURL, emit = true, additionalInfo = {}) {
215223
if (this.isBuiltinExtension(extensionURL)) {
216224
if (emit) this._CollaborationEmitTrigger(extensionURL);
217-
this.loadExtensionIdSync(extensionURL, false);
225+
this.loadExtensionIdSync(extensionURL, additionalInfo);
218226
return;
219227
}
220228

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
const ArgumentType = require('../../extension-support/argument-type');
2+
const BlockType = require('../../extension-support/block-type');
3+
const Cast = require('../../util/cast');
4+
const formatMessage = require('format-message');
5+
6+
// eslint-disable-next-line max-len
7+
const iconURI = 'data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22UTF-8%22%3F%3E%3Csvg%20version%3D%221.1%22%20viewBox%3D%220%200%2040%2040%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3Epen-icon%3C%2Ftitle%3E%3Cpath%20d%3D%22m18.856%203.5079c-2.8134%200.073968-5.6394%200.19931-8.4179%200.65808-0.98006%200.31109-1.5087%201.4362-1.3483%202.4131-1.4539%200.22647-2.9269%200.42012-4.335%200.85896-1.0245%200.48274-1.3331%201.8195-0.9367%202.817%200.95204%204.2684%204.7266%207.7466%209.0733%208.298%200.39534%200.0029%200.71%200.21858%200.99738%200.4614%200.87788%200.59337%201.8418%201.0611%202.8545%201.3739-0.1804%201.1024-0.57129%202.1705-1.1464%203.1283-1.2352-0.06861-2.5702%200.2825-3.3792%201.2757-0.80576%200.90538-1.0073%202.1763-0.9066%203.3477v2.0458c-1.438-0.01872-2.8993%200.63653-3.6987%201.8588-0.66916%200.97085-1.0139%202.3179-0.47937%203.4211%200.48366%200.85326%201.5539%201.1318%202.4708%201.0034%207.2788-0.0087%2014.56%200.02376%2021.837-0.02542%201.2214-0.14458%201.9861-1.53%201.6613-2.6728-0.29414-2.0686-2.3293-3.7122-4.4088-3.574-0.0034-1.2892%200.09129-2.593-0.09619-3.8754-0.41986-1.8119-2.3485-3.0535-4.1706-2.786-0.57308-0.96694-0.97216-2.0385-1.1542-3.1484%201.2328-0.37582%202.3891-0.99097%203.409-1.7772%204.3188-0.38642%208.2218-3.6259%209.3736-7.8111%200.26533-0.89799%200.55174-2.0166-0.12302-2.8084-0.47316-0.61175-1.2544-0.80991-1.9845-0.89221-0.99623-0.2049-2.0007-0.36557-3.0037-0.53379%200.05692-0.87944-0.23831-1.9091-1.1176-2.2827-0.85466-0.38685-1.8223-0.28763-2.7296-0.44008-2.7379-0.2645-5.4904-0.37002-8.2408-0.33386zm-9.7281%207.3884c0.04627%200.49226%200.11749%200.98242%200.22592%201.4652-0.4286-0.46649-0.78756-0.99758-1.0692-1.5647%200.25247%200.02308%200.74777-0.27851%200.83386-0.02783%200.00286%200.04243%200.0063%200.08491%200.00945%200.12737zm22.21-0.17224c0.25396%200.05773%200.52227%200.03527%200.26216%200.33032-0.25942%200.47178-0.575%200.91185-0.93545%201.3115%200.12645-0.5624%200.20659-1.135%200.2427-1.7101%200.14353%200.02273%200.28706%200.04547%200.43059%200.06822z%22%20fill%3D%22%23515151%22%2F%3E%3Cpath%20d%3D%22m9.9972%206.0731v1.2727c-1.5353%200.21976-3.0541%200.48942-4.5552%200.80544-0.58372%200.1239-0.96589%200.68586-0.86621%201.2742%200.77079%204.5291%204.5238%207.9532%209.1045%208.3064%201.1852%200.96863%202.5773%201.6518%204.0687%201.9965-0.14183%201.6836-0.70991%203.3033-1.6509%204.7066h-1.0962c-1.5367%200-2.7812%201.246-2.7812%202.7812v3.8937h-1.1125c-1.8432%200-3.3374%201.4942-3.3374%203.3374%200%200.6141%200.49839%201.1125%201.1125%201.1125h22.25c0.61438%200%201.1124-0.49783%201.1124-1.1125%200-1.8432-1.4942-3.3374-3.3374-3.3374h-1.1125v-3.8937c0-1.5367-1.246-2.7812-2.7812-2.7812h-1.0962c-0.94055-1.4033-1.5082-3.0231-1.6494-4.7066%201.4915-0.34534%202.8836-1.0288%204.0687-1.998%204.5812-0.35262%208.335-3.7769%209.106-8.3064%200.098671-0.58825-0.28423-1.1497-0.86778-1.2727-1.5086-0.31843-3.0273-0.5873-4.5537-0.80544v-1.2741c-1.51e-4%20-0.56145-0.41878-1.0347-0.97598-1.1036-2.9981-0.37337-6.0165-0.56006-9.0378-0.5595-3.06%200-6.0756%200.19005-9.0377%200.5595-0.5567%200.069518-0.97441%200.54268-0.97458%201.1036zm0%203.8996c0%201.774%200.46251%203.4413%201.2712%204.886-2.0279-0.90927-3.5723-2.6393-4.2467-4.7569%200.98798-0.19173%201.98-0.36104%202.9755-0.50848zm20.025%200v-0.37954c0.99975%200.14856%201.9921%200.31731%202.9755%200.50848-0.67425%202.1177-2.2188%203.8478-4.2467%204.7569%200.83599-1.4925%201.2737-3.1752%201.2711-4.8859z%22%20clip-rule%3D%22evenodd%22%20fill%3D%22%23fff%22%20fill-rule%3D%22evenodd%22%2F%3E%3Cpath%20d%3D%22m18.877%205.0234c-2.6766%200.058632-5.3328%200.2069-7.9789%200.58494-0.50053%200.29756-0.22912%200.96773-0.29527%201.4383-0.08381%200.2776%200.20493%200.87534-0.22644%200.85427-1.6627%200.25914-3.3356%200.49682-4.9687%200.9039-0.46425%200.3529-0.10684%200.97684-0.034243%201.4327%200.76252%202.8075%202.8702%205.1611%205.5731%206.2456%200.94313%200.3795%201.945%200.58073%202.9701%200.66408%201.2772%201.0866%202.8489%201.7804%204.4827%202.1228-0.10263%202.0592-0.78064%204.0886-1.9763%205.7722-0.99895%200.01403-2.1988-0.18081-2.9585%200.63214-0.65515%200.61875-0.68676%201.5782-0.63839%202.4169v3.6268c-1.1536%200.0311-2.4928-0.21956-3.4397%200.61138-0.67416%200.56097-1.1587%201.503-0.93898%202.3827%200.35791%200.43592%200.99829%200.17381%201.4808%200.24164%207.1108-0.0034%2014.223%200.01185%2021.333-0.0146%200.52589-0.17134%200.38836-0.83024%200.27382-1.2344-0.33687-1.2742-1.6816-2.1383-2.98-1.9889h-1.3649c-0.03625-1.6683%200.06949-3.3443-0.06112-5.0075-0.23304-1.0718-1.3614-1.7979-2.4331-1.6703-0.46074-0.0323-1.1305%200.17055-1.353-0.37268-1.0516-1.5992-1.6247-3.4891-1.7259-5.3954%201.6345-0.34575%203.2105-1.0363%204.486-2.1284%202.821-0.15055%205.4652-1.6263%207.1137-3.9116%200.8467-1.2059%201.4628-2.6118%201.6302-4.0818-0.17048-0.57267-0.9091-0.44266-1.3614-0.59693-1.3522-0.27157-2.6951-0.47965-4.063-0.68368-0.065104-0.68174%200.087206-1.3819-0.073819-2.0512-0.32594-0.41119-0.96454-0.25273-1.3945-0.36415-2.9996-0.33196-6.0373-0.46947-9.0774-0.42882zm10.889%203.9145c1.3431%200.21436%202.6884%200.43224%204.0241%200.69567-0.63758%202.4766-2.3608%204.6877-4.7134%205.7402-0.58982%200.28364-1.1795%200.56755-1.769%200.8518%200.99166-1.6967%201.9763-3.4993%202.078-5.5067%200.0418-0.61132%200.02794-1.2046%200.028-1.837%200.11744%200.018668%200.23487%200.037279%200.35231%200.055947zm-19.163%200.30086c-0.09481%201.9417%200.33406%203.9162%201.3369%205.5906%200.14816%200.27918%200.50648%200.72317%200.1113%200.94974-0.34397%200.12249-0.6575-0.25722-0.99157-0.33543-2.0123-0.92485-3.6799-2.6125-4.4515-4.7021-0.14869-0.36263-0.26074-0.73897-0.38599-1.1102%201.4573-0.26789%202.9149-0.53146%204.3809-0.74599v0.35342z%22%20fill%3D%22%23f5c887%22%2F%3E%3C%2Fsvg%3E';
8+
9+
class CustomAchievements {
10+
constructor (runtime) {
11+
this.runtime = runtime;
12+
13+
this._cache = {};
14+
this._projectId = null;
15+
this._fetchPromise = null;
16+
17+
this._API_HOST = this.runtime.additionalInfo.API_HOST;
18+
this._API_BASE = `${this._API_HOST}/v1/`;
19+
this._TRUSTED_IFRAME_HOST = this.runtime.additionalInfo.TRUSTED_IFRAME_HOST;
20+
this._projectId = this.runtime.additionalInfo.projectId;
21+
this._accessToken = this.runtime.additionalInfo.authToken;
22+
this._customAchievements = this.runtime.additionalInfo.customAchievements;
23+
this._sendToUI('ACHIEVEMENTS_UPDATED', {achievementData: this._customAchievements});
24+
// basically says if your in editor or not
25+
this._canRecieveAchievement = this.runtime.additionalInfo.canRecieveAchievement;
26+
this._canSave = this.runtime.additionalInfo.canSave;
27+
28+
if (this._customAchievements && Array.isArray(this._customAchievements)) {
29+
// Load from provided data without fetching
30+
this._updateCacheFromData(this._customAchievements);
31+
} else {
32+
// No data provided, fetch immediately
33+
this._fetchData();
34+
}
35+
36+
// Poll every minute (60000ms) to sync with server
37+
this._pollInterval = setInterval(() => {
38+
this._fetchData();
39+
}, 60000);
40+
41+
42+
window.addEventListener('message', event => {
43+
if (event.origin !== this._TRUSTED_IFRAME_HOST) {
44+
console.warn('Ignored message from untrusted origin:', event.origin);
45+
return;
46+
}
47+
48+
const data = event.data;
49+
if (data?.type === 'block-compiler-action' && data?.action === 'ACHIEVEMENT MODIFIED') {
50+
if (data.achievementData && Array.isArray(data.achievementData)) {
51+
this._updateCacheFromData(data.achievementData);
52+
}
53+
}
54+
});
55+
56+
}
57+
58+
/**
59+
* Updates the internal cache from an array of achievement objects
60+
* @param {Array} data Array of achievement objects from API or preload
61+
*/
62+
_updateCacheFromData (data) {
63+
// Rebuild cache keyed by Name
64+
this._cache = {};
65+
data.forEach(ach => {
66+
this._cache[ach.name] = {
67+
id: ach.id,
68+
unlocked: ach.unlocked || false
69+
};
70+
});
71+
72+
// Refresh blocks to update the menu if manager is available
73+
if (this.runtime.extensionManager) {
74+
this.runtime.extensionManager.refreshBlocks();
75+
}
76+
}
77+
78+
/**
79+
* Fetches achievement data from the API.
80+
* @returns {Promise<void>}
81+
*/
82+
_fetchData () {
83+
const projectId = this._projectId;
84+
if (!projectId) {
85+
// Don't try to fetch if we don't know where to fetch from
86+
return Promise.resolve();
87+
}
88+
89+
// If we are already fetching, return the existing promise to prevent race conditions
90+
if (this._fetchPromise) return this._fetchPromise;
91+
92+
const url = `${this._API_BASE}projects/custom/${projectId}/achievements`;
93+
94+
const headers = {
95+
'Content-Type': 'application/json'
96+
};
97+
if (this._accessToken) {
98+
headers.Authorization = `Bearer ${this._accessToken}`;
99+
}
100+
101+
this._fetchPromise = fetch(url, {
102+
method: 'GET',
103+
headers: headers
104+
})
105+
.then(res => {
106+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
107+
return res.json();
108+
})
109+
.then(data => {
110+
this._updateCacheFromData(data);
111+
})
112+
.then(data => {
113+
this._sendToUI('ACHIEVEMENTS_UPDATED', {achievementData: data});
114+
})
115+
.catch(err => {
116+
console.warn('Failed to fetch custom achievements:', err);
117+
})
118+
.finally(() => {
119+
this._fetchPromise = null;
120+
});
121+
122+
return this._fetchPromise;
123+
}
124+
125+
getInfo () {
126+
return {
127+
id: 'customAchievements',
128+
name: formatMessage({
129+
id: 'customAchievements.categoryName',
130+
default: 'Achievements',
131+
description: 'Label for the Achievements extension category'
132+
}),
133+
color1: '#FFB700',
134+
color2: '#E6A500',
135+
color3: '#CC9200',
136+
menuIconURI: iconURI,
137+
blockIconURI: iconURI,
138+
blocks: [
139+
{
140+
opcode: 'unlockAchievement',
141+
blockType: BlockType.COMMAND,
142+
text: formatMessage({
143+
id: 'customAchievements.unlockAchievement',
144+
default: 'unlock achievement [ACHIEVEMENT]',
145+
description: 'Unlock an achievement'
146+
}),
147+
arguments: {
148+
ACHIEVEMENT: {
149+
type: ArgumentType.STRING,
150+
menu: 'achievementsMenu'
151+
}
152+
}
153+
},
154+
{
155+
opcode: 'isUnlocked',
156+
blockType: BlockType.BOOLEAN,
157+
text: formatMessage({
158+
id: 'customAchievements.isUnlocked',
159+
default: 'is achievement [ACHIEVEMENT] unlocked?',
160+
description: 'Check if an achievement is unlocked'
161+
}),
162+
arguments: {
163+
ACHIEVEMENT: {
164+
type: ArgumentType.STRING,
165+
menu: 'achievementsMenu'
166+
}
167+
}
168+
}
169+
],
170+
menus: {
171+
achievementsMenu: {
172+
acceptReporters: true,
173+
items: 'getAchievementsMenu'
174+
}
175+
}
176+
};
177+
}
178+
179+
getAchievementsMenu () {
180+
const projectId = this._projectId;
181+
if (!projectId) {
182+
return [
183+
{text: 'Error: No Project ID', value: 'error'}
184+
];
185+
}
186+
187+
if (Object.keys(this._cache).length === 0) {
188+
this._fetchData();
189+
return [
190+
{text: '', value: ''}
191+
];
192+
}
193+
194+
// Map keys (names) to menu items
195+
const items = Object.keys(this._cache).map(name => ({
196+
text: name,
197+
value: name
198+
}));
199+
200+
if (items.length === 0) {
201+
return [
202+
{text: 'No achievements found', value: ''}
203+
];
204+
}
205+
206+
return items;
207+
}
208+
209+
/**
210+
* Helper to send messages to the UI via postMessage.
211+
* @param {string} action The action type.
212+
* @param {object} payload The data to send.
213+
*/
214+
_sendToUI (action, payload = {}) {
215+
window.parent.postMessage({
216+
type: 'block-compiler-action',
217+
action: action,
218+
...payload
219+
}, this._TRUSTED_IFRAME_HOST || '*');
220+
}
221+
222+
async unlockAchievement (args) {
223+
const achievementName = Cast.toString(args.ACHIEVEMENT);
224+
const projectId = this._projectId;
225+
226+
if (!projectId || !achievementName) return;
227+
228+
if (!this._canRecieveAchievement) {
229+
// eslint-disable-next-line no-negated-condition
230+
if (!this._canSave) {
231+
// "To prevent abuse you cannot recieve achievement once going in editor"
232+
this._sendToUI('ACHIEVEMENT_ERROR', {
233+
reason: 'editor_abuse',
234+
achievementName
235+
});
236+
} else {
237+
// "You must be on main project page to recieve award, but you attempted to grant your self..."
238+
this._sendToUI('ACHIEVEMENT_ERROR', {
239+
reason: 'creator_mode',
240+
achievementName
241+
});
242+
}
243+
return;
244+
}
245+
246+
// Look up ID by name
247+
const achievementData = this._cache[achievementName];
248+
if (!achievementData) {
249+
console.warn(`Achievement "${achievementName}" not found in this project.`);
250+
return;
251+
}
252+
253+
const achievementId = achievementData.id;
254+
255+
try {
256+
const url = `${this._API_BASE}projects/custom/${projectId}/achievements/${achievementId}/unlock`;
257+
258+
const headers = {
259+
'Content-Type': 'application/json'
260+
};
261+
262+
if (this._accessToken && this._accessToken !== 'anonymous') {
263+
headers.Authorization = `Bearer ${this._accessToken}`;
264+
} else {
265+
this._sendToUI('ACHIEVEMENT_UNLOCKED', {
266+
achievementName
267+
});
268+
return;
269+
}
270+
271+
const res = await fetch(url, {
272+
method: 'POST',
273+
headers: headers
274+
});
275+
276+
if (res.ok) {
277+
// Update local cache immediately
278+
if (this._cache[achievementName]) {
279+
this._cache[achievementName].unlocked = true;
280+
} else {
281+
// If it wasn't in cache (e.g. added while project running), refresh
282+
this._fetchData();
283+
}
284+
285+
this._sendToUI('ACHIEVEMENT_UNLOCKED', {
286+
achievementName
287+
});
288+
289+
} else {
290+
console.warn(`Failed to unlock achievement ${achievementName}: ${res.status}`);
291+
}
292+
} catch (e) {
293+
console.error('Error unlocking achievement:', e);
294+
}
295+
}
296+
297+
isUnlocked (args) {
298+
const achievementName = Cast.toString(args.ACHIEVEMENT);
299+
if (this._cache[achievementName]) {
300+
return this._cache[achievementName].unlocked;
301+
}
302+
return false;
303+
}
304+
}
305+
306+
module.exports = CustomAchievements;

0 commit comments

Comments
 (0)