From ab9307fd81e6dc848dd36acf60f04100eaba76a1 Mon Sep 17 00:00:00 2001 From: Mohamed Elsherif Date: Thu, 3 Sep 2026 16:28:33 -0700 Subject: [PATCH 1/3] Show achievements when live milestones are met --- arcade.css | 4 +-- arcade.js | 7 ++++- battle-tanks/scripts/app.js | 20 ++++++++++++- docs/adr/0003-results-and-achievements.md | 7 +++++ docs/architecture.md | 6 ++++ server/accounts.js | 34 ++++++++++++++++++++++- server/achievements.js | 20 ++++++------- server/index.js | 5 ++++ tests/achievements.test.js | 25 +++++++++++++++++ tests/shared-game-assets.test.js | 12 +++++++- tetris/scripts/app.js | 15 ++++++++-- 11 files changed, 137 insertions(+), 18 deletions(-) diff --git a/arcade.css b/arcade.css index 8d0743f..25f1219 100644 --- a/arcade.css +++ b/arcade.css @@ -95,10 +95,10 @@ body.arena-fullscreen .arcade-account { display:none; } .achievement-dialog > header { position:sticky; z-index:1; top:0; display:flex; align-items:center; justify-content:space-between; padding:22px 24px; background:var(--arcade-dialog-bg); border-bottom:1px solid var(--arcade-nav-line); } .achievement-dialog h2,.achievement-dialog header small,.achievement-card p { margin:0; }.achievement-dialog h2{font:800 27px/1.2 system-ui}.achievement-dialog header small,.achievement-card small{color:var(--arcade-dialog-muted);font:700 11px/1.4 system-ui;text-transform:uppercase;letter-spacing:.08em}.achievement-dialog header button{width:36px;height:36px;padding:0;color:var(--arcade-nav-ink);background:var(--arcade-dialog-field);border:0;border-radius:50%;font-size:24px;cursor:pointer} .achievement-list{display:grid;gap:12px;padding:18px}.achievement-card{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;padding:16px;color:var(--arcade-dialog-muted);background:var(--arcade-dialog-field);border:1px solid var(--arcade-nav-line);border-radius:14px;filter:grayscale(1);opacity:.72}.achievement-card.is-unlocked{color:var(--arcade-nav-ink);filter:none;opacity:1;border-color:#83e6c366}.achievement-icon{display:grid;width:48px;height:48px;place-items:center;background:var(--arcade-dialog-bg);border-radius:13px;font-size:25px}.achievement-card strong{display:block;margin:2px 0;font:800 17px/1.3 system-ui}.achievement-card p{font:13px/1.4 system-ui}.achievement-card progress{width:min(180px,100%);height:7px;margin-right:8px;accent-color:#83e6c3}.achievement-card button{padding:8px 11px;color:#10271f;background:#83e6c3;border:0;border-radius:9px;font:700 12px system-ui;cursor:pointer} -.achievement-toast{position:fixed;z-index:2000;right:20px;bottom:20px;display:flex;gap:13px;align-items:center;min-width:280px;padding:16px;color:#f7f8fc;background:#17231f;border:1px solid #83e6c366;border-radius:16px;box-shadow:0 18px 60px #0008;animation:achievement-in .35s ease-out}.achievement-toast>span{font-size:30px}.achievement-toast small,.achievement-toast strong{display:block}.achievement-toast small{color:#83e6c3;font:700 10px system-ui;text-transform:uppercase;letter-spacing:.1em}.achievement-toast strong{margin-top:3px;font:800 16px system-ui}@keyframes achievement-in{from{opacity:0;transform:translateY(15px)}} +.achievement-toast{position:fixed;z-index:2000;right:20px;bottom:20px;display:flex;gap:13px;align-items:center;min-width:280px;padding:16px;color:#f7f8fc;background:#17231f;border:1px solid #83e6c366;border-radius:16px;box-shadow:0 18px 60px #0008;animation:achievement-in .35s ease-out;pointer-events:none}.achievement-toast>span{font-size:30px}.achievement-toast small,.achievement-toast strong{display:block}.achievement-toast small{color:#83e6c3;font:700 10px system-ui;text-transform:uppercase;letter-spacing:.1em}.achievement-toast strong{margin-top:3px;font:800 16px system-ui}@keyframes achievement-in{from{opacity:0;transform:translateY(15px)}} .top-score-toast{position:fixed;z-index:2100;right:20px;bottom:20px;display:grid;grid-template-columns:auto minmax(230px,1fr);gap:14px;width:min(390px,calc(100% - 40px));padding:20px;color:#fffdf8;background:linear-gradient(135deg,#17231f,#244c40);border:1px solid #ffd36a99;border-radius:20px;box-shadow:0 20px 70px #0009;animation:top-score-in .55s cubic-bezier(.2,.9,.25,1.2);font-family:system-ui,sans-serif}.top-score-toast>button{position:absolute;top:9px;right:9px;width:30px;height:30px;padding:0;color:#fffdf8;background:#ffffff12;border:0;border-radius:50%;font-size:20px;cursor:pointer}.top-score-confetti{font-size:38px;filter:drop-shadow(0 4px 8px #0006);animation:top-score-trophy .8s ease-in-out alternate 3}.top-score-toast small,.top-score-toast strong{display:block}.top-score-toast small{color:#ffd36a;font:800 10px/1.3 system-ui;letter-spacing:.13em;text-transform:uppercase}.top-score-toast strong{max-width:290px;margin:3px 26px 8px 0;font:800 18px/1.25 system-ui}.top-score-toast p{display:flex;align-items:center;gap:12px;margin:0 0 9px}.top-score-toast s{color:#b7c9c2;font:700 17px system-ui}.top-score-toast b{color:#ffd36a;font:900 29px/1 system-ui}.top-score-toast b::before{margin-right:10px;color:#83e6c3;content:'→';font-size:18px}.top-score-toast em{display:block;margin:-3px 0 10px;color:#ffd36a;font:700 12px system-ui;font-style:normal}.top-score-toast a{color:#bff7e3;font:700 13px system-ui;text-underline-offset:3px}@keyframes top-score-in{from{opacity:0;transform:translateY(25px) scale(.94)}}@keyframes top-score-trophy{to{transform:rotate(10deg) scale(1.12)}} @media(prefers-reduced-motion:reduce){.top-score-toast,.top-score-confetti{animation:none}} -@media(max-width:650px){.achievement-toast{right:10px;bottom:78px;left:10px}.achievement-card{grid-template-columns:auto 1fr}.achievement-card button{grid-column:2;justify-self:start}} +@media(max-width:650px){.achievement-toast{top:max(118px,calc(env(safe-area-inset-top) + 84px));right:10px;bottom:auto;left:10px}.achievement-card{grid-template-columns:auto 1fr}.achievement-card button{grid-column:2;justify-self:start}} @media(max-width:650px){.top-score-toast{right:10px;bottom:78px;width:calc(100% - 20px)}} /* Appearance picker and experience-level shell tokens. */ diff --git a/arcade.js b/arcade.js index 025c0dc..da76b32 100644 --- a/arcade.js +++ b/arcade.js @@ -242,6 +242,7 @@ }; const scoreMessages = ['The leaderboard just felt that!', 'New legend status unlocked!', 'That record never stood a chance!', 'History, officially rewritten!']; const notificationQueue = []; + const notifiedAchievementIds = new Set(); let showingNotification = false; const showNextNotification = () => { const notification = notificationQueue.shift(); @@ -273,7 +274,10 @@ notificationQueue.push(...notifications); if (!showingNotification) showNextNotification(); }; - const showUnlocks = unlocked => enqueueNotifications(unlocked.map(detail => ({ type: 'achievement', detail }))); + const showUnlocks = unlocked => { + const fresh = unlocked.filter(detail => { if (!detail?.id || notifiedAchievementIds.has(detail.id)) return false; notifiedAchievementIds.add(detail.id); return true; }); + enqueueNotifications(fresh.map(detail => ({ type: 'achievement', detail }))); + }; const showTopScore = topScore => { if (topScore) enqueueNotifications([{ type: 'top-score', detail: topScore }]); }; const notifyResult = result => { if (!result || typeof result !== 'object') return result; @@ -334,6 +338,7 @@ signIn: requestAuthentication, saves: saveManager, record: async result => { if (!currentUser) return null; return notifyResult(await api('/api/results', { method: 'POST', body: JSON.stringify(result) })); }, + checkpoint: async checkpoint => { if (!currentUser) return null; return notifyResult(await api('/api/achievement-checkpoints', { method: 'POST', body: JSON.stringify(checkpoint) })); }, achievements: loadAchievements, notifyAchievements: showUnlocks, notifyResult, diff --git a/battle-tanks/scripts/app.js b/battle-tanks/scripts/app.js index e2409c6..bb0b37f 100644 --- a/battle-tanks/scripts/app.js +++ b/battle-tanks/scripts/app.js @@ -14,6 +14,7 @@ let arenaColors,lastImpactSerial=0,combatEffects=[],soloStatistics={shots:0,hits const POWER_CARD_INTERVAL=3600,powerCardLayer=document.querySelector('#power-card-layer'),powerCard=document.querySelector('#power-card'),dismissPowerCardButton=document.querySelector('#dismiss-power-card'),powerCardQueue=[]; let currentPowerCard=null,powerCardTimer=null,powerCardRemaining=POWER_CARD_INTERVAL,powerCardStarted=0,presentationMatchId=null,highestPresentedEventId=0; let presentedResultEvent='',suppressNextOnlineEvents=false,lastControlEventAt=0,presentedPauseState=null,presentedProgressState='',savePaused=false,savePausedAt=null; +let checkpointMatchId=null;const checkpointedAchievements=new Set(); const iconGlyphs={health:'♥',shield:'⬢',hidden:'◈','heavy-shell':'●',homing:'➤',laser:'⌁','wide-blast':'✹',damage:'⚡',blast:'✺'}; const pickupVisuals={ 'health-pack':{token:'health',glyph:'♥'},shield:{token:'shield',glyph:'⬢'},invisibility:{token:'invisibility',glyph:'◈'}, @@ -99,6 +100,23 @@ function needsAnimation(){return state.phase==='projectile-flight'||combatEffect function requestRenderFrame(){if(animationFrame!==null)return;if(last===null)last=performance.now();animationFrame=requestAnimationFrame(frame);} function render(){renderArena();renderEffects();renderPickups();renderTanks();renderProjectiles();if(!savePaused)scheduleAiTurn();if(needsAnimation()&&!savePaused)requestRenderFrame();} +function checkpointLiveAchievements(){ + const matchId=state.matchId??'local';if(matchId!==checkpointMatchId){checkpointMatchId=matchId;checkpointedAchievements.clear();} + if(mode==='online'||!window.Arcade?.checkpoint||!window.Arcade.user())return; + const statistics=optionalResultStatistics(state.statistics,0),reached=[]; + if(statistics.powerUpsAcquired>=1)reached.push('tanks-power-first'); + if(statistics.powerUpTypesUsed?.length>=3)reached.push('tanks-power-variety'); + if(statistics.shieldDamageAbsorbed>=50)reached.push('tanks-shield-break'); + if(statistics.laserRicochetHits>=1)reached.push('tanks-laser-ricochet'); + if(statistics.laserSelfDamage>=1&&tankHealth(0)>0)reached.push('tanks-laser-self-hit'); + if(statistics.homingHits>=1)reached.push('tanks-homing-hit'); + if(statistics.heavyProjectileMaxDamage>=40)reached.push('tanks-heavy-hit'); + const fresh=reached.filter(id=>!checkpointedAchievements.has(id));if(!fresh.length)return; + fresh.forEach(id=>checkpointedAchievements.add(id)); + const shots=mode==='solo'?soloStatistics.shots:state.shots; + window.Arcade.checkpoint({game:'battletanks',details:{mode,shots,...statistics}}).catch(()=>{}); +} + function syncInventory(locked){const side=mode==='online'?(mySide??state.activePlayer):state.activePlayer,items=Array.isArray(state.inventories?.[side])?state.inventories[side]:[];for(const box of [document.querySelector('#inventory'),document.querySelector('#fullscreen-inventory'),document.querySelector('#mobile-inventory')]){box.replaceChildren();items.forEach(id=>{const item=POWER_UP_CATALOG[id];if(!item)return;const button=document.createElement('button'),unavailable=item.onlineOnly&&mode!=='online';button.type='button';button.textContent=unavailable?`${item.label} (online only)`:item.label;button.disabled=locked||unavailable;button.title=unavailable?'This power-up can only be activated in an online room.':`${item.kind==='weapon'?'Equip':'Use'} ${item.label} without ending your turn.`;button.addEventListener('click',()=>usePowerUp(id));box.append(button);});if(!box.children.length)box.textContent='Empty — drive over a pickup';}const requested=state.equippedWeapons?.[side]||'shell',weapon=WEAPON_REGISTRY[requested]||WEAPON_REGISTRY.shell,weaponId=weapon.id,ammo=weapon.ammo.unlimited?'Unlimited':String(state.weaponAmmo?.[side]?.[weaponId]||0);document.querySelector('#weapon-status').textContent=`Weapon: ${weapon.label} · ${ammo}`;document.querySelector('#ammo-status').textContent=weapon.ammo.unlimited?'Unlimited ammunition':`${ammo} rounds remaining`;document.querySelector('#fullscreen-ammo').textContent=weapon.ammo.unlimited?'Unlimited':`${ammo} rounds`;document.querySelector('#mobile-ammo').textContent=weapon.ammo.unlimited?'Unlimited':`${ammo} rounds`;for(const select of [document.querySelector('#weapon-select'),document.querySelector('#fullscreen-weapon'),document.querySelector('#mobile-weapon')]){select.value=weaponId;select.disabled=locked;}const effects=Array.isArray(state.activeEffects?.[side])?state.activeEffects[side]:[],effectText=effects.length?effects.map(effect=>effect.effect==='absorb'?`Shield ${effect.remainingTurns??0} turns, ${effect.remainingCapacity??0} capacity`:`${POWER_UP_CATALOG[effect.id]?.label||'Effect'} ${effect.remainingTurns??0} turns`).join(' · '):'No active effects',mobileEffectStatus=document.querySelector('#mobile-effect-status');document.querySelector('#effect-status').textContent=effectText;mobileEffectStatus.textContent=effectText;mobileEffectStatus.hidden=!effects.length;} function chooseWeapon(id){if(mode==='online')command('select-weapon',{weaponId:id});else{selectWeapon(state,state.activePlayer,id);events.emit('game:progressed',{intensity:.32,danger:1-Math.min(tankHealth(0),tankHealth(1))/STARTING_HEALTH});}sync();render();} for(const select of [document.querySelector('#weapon-select'),document.querySelector('#fullscreen-weapon'),document.querySelector('#mobile-weapon')]){Object.values(WEAPON_REGISTRY).forEach(weapon=>{const option=document.createElement('option');option.value=weapon.id;option.textContent=weapon.label;select.append(option);});select.addEventListener('change',event=>chooseWeapon(event.target.value));} @@ -113,7 +131,7 @@ function syncArenaHud(){const active=state.activePlayer===1?1:0,tank=state.tanks function syncEffectsHud(){const shields=[0,1].map(index=>{const shield=(state.activeEffects?.[index]||[]).find(effect=>effect.effect==='absorb');return shield?`${playerLabel(index)} shield: ${shield.remainingTurns??0} turns, ${shield.remainingCapacity??0} capacity`:'';}).filter(Boolean),announcement=mode==='solo'?(state.announcement||'').replaceAll('Player 2','CPU'):state.announcement;status.textContent=[state.opponentConcealed?'Opponent concealed.':announcement,...shields].filter(Boolean).join(' ');} function announceStateChanges(){const messages=[];if(state.lastImpact?.serial>announcedImpactSerial){announcedImpactSerial=state.lastImpact.serial;const absorbed=(state.lastImpact.affected||[]).reduce((sum,item)=>sum+(Number(item.absorbedDamage)||0),0);if(absorbed)messages.push(`Shields absorbed ${absorbed} damage.`);}if(state.announcement&&state.announcement!==previousAnnouncement)messages.push(state.announcement);[0,1].forEach(index=>{const health=tankHealth(index);if(health>previousHealth[index])messages.push(`Player ${index+1} healed ${health-previousHealth[index]} health.`);const oldShield=previousEffects[index].find(effect=>effect.effect==='absorb'),shield=(state.activeEffects?.[index]||[]).find(effect=>effect.effect==='absorb');if(oldShield&&shield&&shield.remainingCapacity{if(!(state.activeEffects?.[index]||[]).some(current=>current.id===effect.id))messages.push(`${POWER_UP_CATALOG[effect.id]?.label||'Effect'} expired for Player ${index+1}.`);});const weapon=state.equippedWeapons?.[index]||'shell';if(weapon!==previousWeapons[index])messages.push(`Player ${index+1} selected ${WEAPON_REGISTRY[weapon]?.label||'Standard shell'}.`);previousHealth[index]=health;previousEffects[index]=(state.activeEffects?.[index]||[]).map(effect=>({...effect}));previousWeapons[index]=weapon;});previousAnnouncement=state.announcement||'';if(messages.length)document.querySelector('#pickup-announcement').textContent=messages.join(' ');} function syncControls(){const cpuTurn=mode==='solo'&&state.activePlayer===1,locked=savePaused||state.phase!=='aiming'||cpuTurn||(mode==='online'&&(mySide!==state.activePlayer||state.paused));document.querySelectorAll('[data-action],[data-move-direction]').forEach(button=>button.disabled=locked);fire.disabled=locked;fullscreenFire.disabled=locked;mobileFire.disabled=locked;syncInventory(locked);const gameOver=state.phase==='game-over',health=Math.min(...(state.tanks||[]).map(tank=>Math.max(0,Number(tank.health)||0))),danger=1-health/STARTING_HEALTH,paused=Boolean(state.paused)||savePaused;if(paused!==presentedPauseState){presentedPauseState=paused;events.emit('game:paused',{paused});}const progressState=`${state.matchId}:${state.phase}:${danger.toFixed(2)}`;if(!gameOver&&progressState!==presentedProgressState){presentedProgressState=progressState;events.emit('game:progressed',{intensity:state.phase==='projectile-flight'?.82:.32+danger*.35,danger});}resultLayer.hidden=!gameOver;if(gameOver){const resultKey=`${state.matchId??'local'}:${state.winner??'draw'}`;if(resultKey!==presentedResultEvent){presentedResultEvent=resultKey;const won=!state.draw&&(mode!=='online'?state.winner===0:state.winner===mySide);events.emit('game:completed',{outcome:state.draw?'draw':won?'win':'loss',shots:state.shots});}document.querySelector('#result-title').textContent=state.draw?'Draw!':`${playerLabel(state.winner)} wins!`;document.querySelector('#result-summary').textContent=state.draw?`Both tanks fell after ${state.shots} shots.`:mode==='solo'?`Battle ended after ${state.shots} shots · your accuracy ${soloStatistics.shots?Math.floor(soloStatistics.hits*100/soloStatistics.shots):0}%.`:`Victory secured in ${state.shots} shots with ${state.hits} hits.`;}} -function sync(){syncAcquisitionEvents();syncTankHud();syncImpactHud();syncArenaHud();syncEffectsHud();announceStateChanges();syncControls();} +function sync(){syncAcquisitionEvents();syncTankHud();syncImpactHud();syncArenaHud();syncEffectsHud();announceStateChanges();syncControls();checkpointLiveAchievements();} function sendOnline(message){if(socket?.readyState===WebSocket.OPEN)socket.send(JSON.stringify(message));} function command(type,extra={}){sendOnline({type,matchId:state.matchId,turnId:state.turnId,...extra});} diff --git a/docs/adr/0003-results-and-achievements.md b/docs/adr/0003-results-and-achievements.md index aa7448e..aff0d1f 100644 --- a/docs/adr/0003-results-and-achievements.md +++ b/docs/adr/0003-results-and-achievements.md @@ -19,3 +19,10 @@ flowchart LR client-reported results still require plausibility validation and catalog changes are deployments rather than data administration. +Irrevocable single-run milestones may be evaluated before a game ends through +a rate-limited checkpoint API. The API accepts bounded game facts rather than +achievement IDs, applies game-specific consistency checks, and evaluates only +catalog entries explicitly marked for live unlocks. It does not write game +results or advance cumulative completion rules; final result processing remains +the fallback for clients that do not send checkpoints. + diff --git a/docs/architecture.md b/docs/architecture.md index fac1031..d8bb5d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -176,6 +176,12 @@ sequenceDiagram A-->>G: toast notifications ``` +Irrevocable in-run milestones use the same validation and catalog path through +a bounded achievement checkpoint. Checkpoints can unlock only catalog entries +marked as live; they never create results, scores, leaderboard entries, or +cumulative finish progress. Terminal conditions such as wins and completed-run +counts remain exclusively in the result flow. + Registration/login uses scrypt passcode hashes. A random session token is sent only in an HttpOnly, SameSite=Strict cookie; only its SHA-256 hash is stored. Profile changes revoke existing sessions and rotate the current one. Public diff --git a/server/accounts.js b/server/accounts.js index 41f31de..9cdbf00 100644 --- a/server/accounts.js +++ b/server/accounts.js @@ -110,6 +110,15 @@ class Accounts { return { id: Number(insert.lastInsertRowid), score, unlocked, topScore }; } + checkpoint(userId, checkpoint) { + const game = String(checkpoint.game || '').toLowerCase(); + if (!GAMES.has(game)) throw new Error('Unknown game.'); + const details = checkpoint.details && typeof checkpoint.details === 'object' && !Array.isArray(checkpoint.details) ? checkpoint.details : {}; + const normalizedDetails = validateAchievementCheckpoint(game, details); + const unlocked = this.achievements?.process(userId, game, 'checkpoint', { game, details: normalizedDetails }) || []; + return { unlocked }; + } + profile(userId, pageValue = 1, pageSizeValue = 10) { const page = Math.max(1, Number.parseInt(pageValue, 10) || 1); const pageSize = Math.min(10, Math.max(1, Number.parseInt(pageSizeValue, 10) || 10)); @@ -141,6 +150,29 @@ function integer(value, minimum, maximum, label) { return number; } +function validateAchievementCheckpoint(game, details) { + if (game === 'tetris') return validateResult(game, false, details).normalizedDetails; + if (game !== 'battletanks' || !['solo', 'local'].includes(details.mode)) throw new Error('Invalid achievement checkpoint.'); + const field = (name, maximum) => integer(details[name] ?? 0, 0, maximum, `Battle Tanks ${name}`); + const shots = field('shots', 200), powerUpsAcquired = field('powerUpsAcquired', 200), powerUpsUsed = field('powerUpsUsed', 200); + const knownPowerUps = new Set(['health-pack', 'shield', 'invisibility', 'weapon-heavy-shell', 'weapon-homing', 'weapon-laser', 'weapon-wide-blast', 'damage-boost', 'blast-radius-boost']); + const suppliedTypes = details.powerUpTypesUsed ?? []; + if (!Array.isArray(suppliedTypes) || suppliedTypes.length > knownPowerUps.size || suppliedTypes.some(id => typeof id !== 'string' || !knownPowerUps.has(id)) || new Set(suppliedTypes).size !== suppliedTypes.length) throw new Error('Invalid Battle Tanks powerUpTypesUsed.'); + const weapons = {}; + if (details.weapons !== undefined) { + const allowed = new Set(['shell', 'wide-blast', 'heavy-shell', 'homing', 'laser']); + if (!details.weapons || typeof details.weapons !== 'object' || Array.isArray(details.weapons)) throw new Error('Invalid Battle Tanks weapons.'); + for (const [id, count] of Object.entries(details.weapons)) { + if (!allowed.has(id) || !Number.isSafeInteger(count) || count < 0 || count > shots) throw new Error('Invalid Battle Tanks weapons.'); + weapons[id] = count; + } + if (Object.values(weapons).reduce((sum, count) => sum + count, 0) > shots) throw new Error('Invalid Battle Tanks weapons.'); + } + const shieldDamageAbsorbed = field('shieldDamageAbsorbed', 12000), laserRicochetHits = field('laserRicochetHits', shots), laserSelfDamage = field('laserSelfDamage', 7000), homingHits = field('homingHits', shots), heavyProjectileMaxDamage = field('heavyProjectileMaxDamage', 100); + if (powerUpsUsed > powerUpsAcquired || suppliedTypes.length > powerUpsUsed || shieldDamageAbsorbed > powerUpsUsed * 60 || laserRicochetHits > (weapons.laser || 0) || laserSelfDamage > 0 && !(weapons.laser > 0) || homingHits > (weapons.homing || 0) || heavyProjectileMaxDamage > 0 && !(weapons['heavy-shell'] > 0)) throw new Error('Invalid Battle Tanks achievement statistics.'); + return { mode: details.mode, shots, weapons, powerUpsAcquired, powerUpsUsed, powerUpTypesUsed: suppliedTypes, shieldDamageAbsorbed, laserRicochetHits, laserSelfDamage, homingHits, heavyProjectileMaxDamage }; +} + function validateResult(game, wonValue, details, trustedOnline = false) { const won = wonValue === true; if (game === 'tetris') { @@ -231,4 +263,4 @@ function validateResult(game, wonValue, details, trustedOnline = false) { return { won, score: player * 100 + opponent, normalizedDetails: { mode: details.mode, score: `${player}-${opponent}`, seconds } }; } -module.exports = { Accounts, hashPasscode, verifyPasscode, validateResult }; +module.exports = { Accounts, hashPasscode, verifyPasscode, validateAchievementCheckpoint, validateResult }; diff --git a/server/achievements.js b/server/achievements.js index 463363c..3097e31 100644 --- a/server/achievements.js +++ b/server/achievements.js @@ -19,23 +19,23 @@ const catalog = [ { id: 'tic-online', game: 'tictactoe', icon: '📡', title: 'Three Bars, Three Marks', condition: 'Finish an online Tic-tac-toe match.', event: 'result', where: { 'details.mode': 'online' }, target: 1 }, { id: 'tic-speed', game: 'tictactoe', icon: '💨', title: 'Blink and You Missed X', condition: 'Win Tic-tac-toe in five moves or fewer.', event: 'result', where: { won: true, 'details.moves': { lte: 5 } }, target: 1 }, { id: 'tetris-first', game: 'tetris', icon: '🧱', title: 'Block Party', condition: 'Finish your first Tetris run.', event: 'result', target: 1 }, - { id: 'tetris-four-line', game: 'tetris', icon: '4️⃣', title: 'Fourgone Conclusion', condition: 'Clear four lines at once.', event: 'result', where: { 'details.tetrises': { gte: 1 } }, target: 1 }, - { id: 'tetris-level-ten', game: 'tetris', icon: '🔟', title: 'Double Digits', condition: 'Reach level 10.', event: 'result', where: { 'details.level': { gte: 10 } }, target: 1 }, + { id: 'tetris-four-line', game: 'tetris', icon: '4️⃣', title: 'Fourgone Conclusion', condition: 'Clear four lines at once.', event: 'result', live: true, where: { 'details.tetrises': { gte: 1 } }, target: 1 }, + { id: 'tetris-level-ten', game: 'tetris', icon: '🔟', title: 'Double Digits', condition: 'Reach level 10.', event: 'result', live: true, where: { 'details.level': { gte: 10 } }, target: 1 }, { id: 'tetris-five', game: 'tetris', icon: '🏗️', title: 'Piece and Persist', condition: 'Finish five Tetris runs.', event: 'result', target: 5 }, { id: 'tanks-first', game: 'battletanks', icon: '🏁', title: 'Battle Tested', condition: 'Finish a Battle Tanks match.', event: 'result', target: 1 }, { id: 'tanks-win', game: 'battletanks', icon: '🏆', title: 'Tank Commander', condition: 'Win a Battle Tanks match.', event: 'result', where: { won: true }, target: 1 }, { id: 'tanks-accurate', game: 'battletanks', icon: '🎯', title: 'Deadeye', condition: 'Win with at least 50% accuracy.', event: 'result', where: { won: true, 'details.accuracy': { gte: 50 } }, target: 1 }, { id: 'tanks-untouched', game: 'battletanks', icon: '🛡️', title: 'Untouchable', condition: 'Win without taking damage.', event: 'result', where: { won: true, 'details.damageTaken': 0 }, target: 1 }, { id: 'tanks-online', game: 'battletanks', icon: '🌐', title: 'Long-Distance Call', condition: 'Finish an online Battle Tanks match.', event: 'result', where: { 'details.mode': 'online' }, target: 1 }, - { id: 'tanks-power-first', game: 'battletanks', icon: '🃏', title: 'Card on the Table', condition: 'Acquire your first power-up.', event: 'result', where: { 'details.powerUpsAcquired': { gte: 1 } }, target: 1 }, - { id: 'tanks-power-variety', game: 'battletanks', icon: '🎴', title: 'Full Deck', condition: 'Use three different power-up types in one match.', event: 'result', where: { 'details.powerUpTypesUsed': { lengthGte: 3 } }, target: 1 }, - { id: 'tanks-shield-break', game: 'battletanks', icon: '🛡️', title: 'Not Even a Scratch', condition: 'Absorb at least 50 damage with shields in one match.', event: 'result', where: { 'details.shieldDamageAbsorbed': { gte: 50 } }, target: 1 }, + { id: 'tanks-power-first', game: 'battletanks', icon: '🃏', title: 'Card on the Table', condition: 'Acquire your first power-up.', event: 'result', live: true, where: { 'details.powerUpsAcquired': { gte: 1 } }, target: 1 }, + { id: 'tanks-power-variety', game: 'battletanks', icon: '🎴', title: 'Full Deck', condition: 'Use three different power-up types in one match.', event: 'result', live: true, where: { 'details.powerUpTypesUsed': { lengthGte: 3 } }, target: 1 }, + { id: 'tanks-shield-break', game: 'battletanks', icon: '🛡️', title: 'Not Even a Scratch', condition: 'Absorb at least 50 damage with shields in one match.', event: 'result', live: true, where: { 'details.shieldDamageAbsorbed': { gte: 50 } }, target: 1 }, { id: 'tanks-second-wind', game: 'battletanks', icon: '💚', title: 'Back in the Fight', condition: 'Restore at least 25 health with health packs and win.', event: 'result', where: { won: true, 'details.healthRestored': { gte: 25 } }, target: 1 }, { id: 'tanks-invisible-win', game: 'battletanks', icon: '👻', title: 'Now You See Me', condition: 'Win an online match after activating invisibility.', event: 'result', where: { won: true, 'details.mode': 'online', 'details.invisibilityActivations': { gte: 1 } }, target: 1 }, - { id: 'tanks-laser-ricochet', game: 'battletanks', icon: '📐', title: 'Geometry Wins', condition: 'Damage an opponent with a reflected laser.', event: 'result', where: { 'details.laserRicochetHits': { gte: 1 } }, target: 1 }, - { id: 'tanks-laser-self-hit', game: 'battletanks', icon: '⚠️', title: 'Calculated Risk', condition: 'Survive damage from your own reflected laser.', event: 'result', where: { 'details.laserSelfDamage': { gte: 1 } }, target: 1 }, - { id: 'tanks-homing-hit', game: 'battletanks', icon: '↩️', title: 'Return to Sender', condition: 'Damage an opponent with a homing projectile.', event: 'result', where: { 'details.homingHits': { gte: 1 } }, target: 1 }, - { id: 'tanks-heavy-hit', game: 'battletanks', icon: '💥', title: 'Heavy Artillery', condition: 'Deal at least 40 health damage with one heavy projectile.', event: 'result', where: { 'details.heavyProjectileMaxDamage': { gte: 40 } }, target: 1 }, + { id: 'tanks-laser-ricochet', game: 'battletanks', icon: '📐', title: 'Geometry Wins', condition: 'Damage an opponent with a reflected laser.', event: 'result', live: true, where: { 'details.laserRicochetHits': { gte: 1 } }, target: 1 }, + { id: 'tanks-laser-self-hit', game: 'battletanks', icon: '⚠️', title: 'Calculated Risk', condition: 'Survive damage from your own reflected laser.', event: 'result', live: true, where: { 'details.laserSelfDamage': { gte: 1 } }, target: 1 }, + { id: 'tanks-homing-hit', game: 'battletanks', icon: '↩️', title: 'Return to Sender', condition: 'Damage an opponent with a homing projectile.', event: 'result', live: true, where: { 'details.homingHits': { gte: 1 } }, target: 1 }, + { id: 'tanks-heavy-hit', game: 'battletanks', icon: '💥', title: 'Heavy Artillery', condition: 'Deal at least 40 health damage with one heavy projectile.', event: 'result', live: true, where: { 'details.heavyProjectileMaxDamage': { gte: 40 } }, target: 1 }, { id: 'tanks-powered-win', game: 'battletanks', icon: '⚡', title: 'Power Player', condition: 'Win after two power-up-powered hits.', event: 'result', where: { won: true, 'details.poweredHits': { gte: 2 } }, target: 1 }, { id: 'tanks-power-collector', game: 'battletanks', icon: '🗂️', title: 'Deck Builder', condition: 'Finish ten matches in which you acquired a power-up.', event: 'result', where: { 'details.powerUpsAcquired': { gte: 1 } }, target: 10 } ]; @@ -62,7 +62,7 @@ class Achievements { } process(userId, game, event, payload) { const unlocked = []; - for (const item of catalog.filter(item => item.game === game && item.event === event && matches(item.where, payload))) { + for (const item of catalog.filter(item => item.game === game && (item.event === event || event === 'checkpoint' && item.live) && matches(item.where, payload))) { const existing = this.database.prepare('SELECT * FROM achievement_progress WHERE user_id = ? AND achievement_id = ?').get(userId, item.id); if (existing?.unlocked_at) continue; const progress = Math.min(item.target, (existing?.progress || 0) + 1); diff --git a/server/index.js b/server/index.js index ce63244..03fcdd7 100644 --- a/server/index.js +++ b/server/index.js @@ -80,6 +80,7 @@ const loginLimiter = new RateLimiter(10, 15 * 60 * 1000); const loginIpLimiter = new RateLimiter(50, 15 * 60 * 1000); const registrationLimiter = new RateLimiter(5, 60 * 60 * 1000); const resultLimiter = new RateLimiter(60, 60 * 60 * 1000); +const achievementCheckpointLimiter = new RateLimiter(120, 60 * 60 * 1000); const saveLimiter = new RateLimiter(120, 60 * 60 * 1000); function clientIp(request) { return getClientIp(request, trustProxy); } function readRequestJson(request, maximum) { return readJson(request, maximum, { guard: requestBodyGuard, ip: clientIp(request) }); } @@ -168,6 +169,10 @@ const server = http.createServer(async (request, response) => { const retryAfter = resultLimiter.consume(user.id); if (retryAfter) return throttle(response, retryAfter); return json(response, 201, accounts.record(user.id, await readRequestJson(request))); } + if (pathname === '/api/achievement-checkpoints' && request.method === 'POST') { + const retryAfter = achievementCheckpointLimiter.consume(user.id); if (retryAfter) return throttle(response, retryAfter); + return json(response, 200, accounts.checkpoint(user.id, await readRequestJson(request))); + } return json(response, 404, { error: 'API endpoint not found.' }); } catch (error) { if (error instanceof SaveError) return json(response, error.status, { error: error.message, code: error.code, ...(error.current ? { current: error.current } : {}) }); diff --git a/tests/achievements.test.js b/tests/achievements.test.js index 12e6f95..ada9a21 100644 --- a/tests/achievements.test.js +++ b/tests/achievements.test.js @@ -42,6 +42,31 @@ test('result events unlock once and cumulative progress persists', async t => { assert.equal(accounts.profile(user.id).achievements.filter(item => item.unlocked).length, 3); }); +test('live checkpoints unlock eligible milestones without recording results or cumulative progress', async t => { + const { accounts, achievements } = fixture(t); + const user = await accounts.create('LiveBadges', 'passcode'); + const tetris = { game: 'tetris', details: { mode: 'marathon', seconds: 80, lines: 40, level: 5, pieces: 25, singles: 0, doubles: 0, triples: 0, tetrises: 10, softDropCells: 0, hardDropCells: 0 } }; + assert.deepEqual(accounts.checkpoint(user.id, tetris).unlocked.map(item => item.id), ['tetris-four-line']); + assert.equal(accounts.profile(user.id).recent.length, 0); + assert.equal(achievements.list(user.id, 'tetris').find(item => item.id === 'tetris-five').progress, 0); + assert.deepEqual(accounts.checkpoint(user.id, tetris).unlocked, []); + const result = accounts.record(user.id, { ...tetris, won: false }); + assert.equal(result.unlocked.some(item => item.id === 'tetris-four-line'), false); + assert.equal(achievements.list(user.id, 'tetris').find(item => item.id === 'tetris-five').progress, 1); +}); + +test('Battle Tanks checkpoints validate bounded facts and leave terminal badges locked', async t => { + const { accounts, achievements } = fixture(t); + const user = await accounts.create('LiveTank', 'passcode'); + const checkpoint = { game: 'battletanks', details: { mode: 'solo', shots: 3, weapons: { laser: 1, homing: 1, 'heavy-shell': 1 }, powerUpsAcquired: 3, powerUpsUsed: 3, powerUpTypesUsed: ['weapon-laser', 'weapon-homing', 'weapon-heavy-shell'], shieldDamageAbsorbed: 50, laserRicochetHits: 1, homingHits: 1, heavyProjectileMaxDamage: 40 } }; + const unlocked = accounts.checkpoint(user.id, checkpoint).unlocked.map(item => item.id); + for (const id of ['tanks-power-first','tanks-power-variety','tanks-shield-break','tanks-laser-ricochet','tanks-homing-hit','tanks-heavy-hit']) assert.ok(unlocked.includes(id), id); + assert.equal(achievements.list(user.id, 'battletanks').find(item => item.id === 'tanks-first').unlocked, false); + assert.equal(achievements.list(user.id, 'battletanks').find(item => item.id === 'tanks-power-collector').progress, 0); + assert.throws(() => accounts.checkpoint(user.id, { ...checkpoint, details: { ...checkpoint.details, homingHits: 2 } }), /Invalid Battle Tanks/); + assert.throws(() => accounts.checkpoint(user.id, { game: 'battletanks', details: { ...checkpoint.details, mode: 'online' } }), /Invalid achievement checkpoint/); +}); + test('achievement progress is isolated between users', async t => { const { accounts, achievements } = fixture(t); const winner = await accounts.create('Winner', 'passcode'); diff --git a/tests/shared-game-assets.test.js b/tests/shared-game-assets.test.js index 3942afd..51a92eb 100644 --- a/tests/shared-game-assets.test.js +++ b/tests/shared-game-assets.test.js @@ -123,7 +123,7 @@ test('modern games keep tablet play surfaces visible without clipping page scrol test('top-score and achievement notifications share one sequential queue', () => { const script = read('arcade.js'); assert.match(script, /notificationQueue\.push\(\.\.\.notifications\)/); - assert.match(script, /showUnlocks = unlocked => enqueueNotifications/); + assert.match(script, /showUnlocks = unlocked => \{[\s\S]*enqueueNotifications/); assert.match(script, /showTopScore = topScore => \{ if \(topScore\) enqueueNotifications/); assert.match(script, /notifyResult = result =>/); assert.match(script, /record: async result =>.*notifyResult\(await api/); @@ -131,6 +131,16 @@ test('top-score and achievement notifications share one sequential queue', () => assert.doesNotMatch(script, /index \* 450/); }); +test('live achievement checkpoints notify during play without duplicating result unlocks', () => { + const shell = read('arcade.js'), styles = read('arcade.css'), tetris = read('tetris/scripts/app.js'), tanks = read('battle-tanks/scripts/app.js'); + assert.match(shell, /notifiedAchievementIds = new Set\(\)[\s\S]*notifiedAchievementIds\.has\(detail\.id\)/); + assert.match(shell, /checkpoint: async checkpoint =>.*achievement-checkpoints/); + assert.match(tetris, /game\.tetrises >= 1[\s\S]*game\.level >= 10[\s\S]*Arcade\.checkpoint/); + assert.match(tanks, /powerUpsAcquired>=1[\s\S]*shieldDamageAbsorbed>=50[\s\S]*Arcade\.checkpoint/); + assert.match(styles, /\.achievement-toast\{[^}]*pointer-events:none/); + assert.match(styles, /max-width:650px[^}]*\.achievement-toast\{top:max\([^}]*bottom:auto/); +}); + test('achievement shares include a generated image on game and profile pages', () => { const share = read('scripts/share-result.js'); assert.match(share, /function achievement\(/); diff --git a/tetris/scripts/app.js b/tetris/scripts/app.js index acdd196..ec7bf29 100644 --- a/tetris/scripts/app.js +++ b/tetris/scripts/app.js @@ -9,6 +9,7 @@ const destructionElement = document.querySelector('#magic-destruction'), compactionElement = document.querySelector('#compaction-effect'), powerUpBannerElement = document.querySelector('#power-up-banner'), powerUpIconElement = document.querySelector('#power-up-icon'), powerUpTitleElement = document.querySelector('#power-up-title'), powerUpMessageElement = document.querySelector('#power-up-message'), useShakeButton = document.querySelector('#use-shake'); const cells = Array.from({ length: 200 }, () => { const cell = document.createElement('span'); cell.className = 'tetris-cell'; boardElement.append(cell); return cell; }); let activeMilliseconds = 0, lastFrame = performance.now(), submitted = false, themeColors = {}, miniatureSignature = '', presentedClearId = 0, presentedDestructionId = 0, presentedCompactionId = 0, presentedPowerUpId = 0, presentedPieces = game.pieces, presentedProgress = '', clearEffectTimer = 0, destructionTimer = 0, compactionTimer = 0, recordTimer = 0, standingBest = Number(localStorage.getItem('tetris-best-score')) || 0, liveBest = standingBest, recordBroken = false, motionPermission = 'unknown', previousMotion = null, lastShakeAt = 0; + const checkpointedAchievements = new Set(); const tokenNames = ['board','grid','border','empty','ghost','ghost-line','ink','panel','overlay','shadow','piece-edge','magic','i','j','l','o','s','t','z']; const formatTime = total => `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; const seconds = () => Math.max(1, Math.floor(activeMilliseconds / 1000)); @@ -93,6 +94,16 @@ const highest = board.findIndex(row => row.some(Boolean)), danger = highest < 0 ? 0 : Math.max(0, Math.min(1, (8 - highest) / 8)); return { level: game.level, intensity: Math.min(.9, .2 + game.level * .07), danger }; } + function checkpointLiveAchievements() { + if (!window.Arcade?.checkpoint || !window.Arcade.user()) return; + const reached = []; + if (game.tetrises >= 1) reached.push('tetris-four-line'); + if (game.level >= 10) reached.push('tetris-level-ten'); + const fresh = reached.filter(id => !checkpointedAchievements.has(id)); + if (!fresh.length) return; + fresh.forEach(id => checkpointedAchievements.add(id)); + window.Arcade.checkpoint({ game: 'tetris', details: game.details(seconds()) }).catch(() => {}); + } function render() { const board = game.visibleBoard(), active = new Map(game.activeCells().filter(([,y]) => y >= 0).map(([x,y]) => [`${x},${y}`, game.piece.type])), ghost = new Set(game.ghostCells().filter(([,y]) => y >= 0).map(([x,y]) => `${x},${y}`)); if (game.pieces > presentedPieces) { presentedPieces = game.pieces; events.emit('tetris:piece-locked', { pieces: game.pieces }); } @@ -108,7 +119,7 @@ if (game.score > liveBest) { liveBest = game.score; localStorage.setItem('tetris-best-score', liveBest); } bestElement.textContent = liveBest.toLocaleString(); if (game.score > standingBest && !recordBroken) celebrateHighScore(); boardElement.setAttribute('aria-label', `Tetris board. Score ${game.score}, ${game.lines} lines, level ${game.level}. ${game.paused ? 'Paused.' : game.gameOver ? 'Run complete.' : game.shakeReady ? 'Shake power-up ready.' : game.isMagic() ? 'Magic breaker falling.' : `${game.piece.type} piece falling.`}`); - renderMiniatures(); presentPowerUp(); presentDestruction(); presentCompaction(); presentLineClear(); + renderMiniatures(); presentPowerUp(); presentDestruction(); presentCompaction(); presentLineClear(); checkpointLiveAchievements(); } function finish() { if (submitted) return; submitted = true; @@ -123,7 +134,7 @@ } function startGame() { saves?.startRun(); - game.reset(); activeMilliseconds = 0; submitted = false; miniatureSignature = ''; presentedClearId = 0; presentedDestructionId = 0; presentedCompactionId = 0; presentedPowerUpId = 0; presentedPieces = game.pieces; presentedProgress = ''; previousMotion = null; standingBest = Number(localStorage.getItem('tetris-best-score')) || 0; liveBest = standingBest; recordBroken = false; clearTimeout(clearEffectTimer); clearTimeout(destructionTimer); clearTimeout(compactionTimer); clearTimeout(recordTimer); stageElement.classList.remove('is-clearing','is-new-record','is-magic','is-magic-impact','is-shake-ready','is-compacting'); clearEffectElement.classList.remove('is-active'); destructionElement.classList.remove('is-active'); compactionElement.classList.remove('is-active'); recordCalloutElement.classList.remove('is-active'); document.body.classList.remove('is-magic-power','is-shake-ready'); document.querySelector('.best-stat').classList.remove('is-record'); powerUpBannerElement.hidden = true; + game.reset(); activeMilliseconds = 0; submitted = false; checkpointedAchievements.clear(); miniatureSignature = ''; presentedClearId = 0; presentedDestructionId = 0; presentedCompactionId = 0; presentedPowerUpId = 0; presentedPieces = game.pieces; presentedProgress = ''; previousMotion = null; standingBest = Number(localStorage.getItem('tetris-best-score')) || 0; liveBest = standingBest; recordBroken = false; clearTimeout(clearEffectTimer); clearTimeout(destructionTimer); clearTimeout(compactionTimer); clearTimeout(recordTimer); stageElement.classList.remove('is-clearing','is-new-record','is-magic','is-magic-impact','is-shake-ready','is-compacting'); clearEffectElement.classList.remove('is-active'); destructionElement.classList.remove('is-active'); compactionElement.classList.remove('is-active'); recordCalloutElement.classList.remove('is-active'); document.body.classList.remove('is-magic-power','is-shake-ready'); document.querySelector('.best-stat').classList.remove('is-record'); powerUpBannerElement.hidden = true; finishElement.hidden = true; pauseButton.textContent = 'Pause'; statusElement.textContent = 'Use the controls to place the falling piece.'; lastFrame = performance.now(); render(); boardElement.focus?.(); events.emit('game:started', { intensity: .2, danger: 0, mode: 'marathon' }); } From 6ea3c81dc9f77cb6cd2be29065e7fd45c86f8cc8 Mon Sep 17 00:00:00 2001 From: Mohamed Elsherif Date: Thu, 3 Sep 2026 16:31:41 -0700 Subject: [PATCH 2/3] Prepare 1.2.0 release notes --- package-lock.json | 4 ++-- package.json | 2 +- releases.json | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8894d3c..30f053d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "javascript-playground", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "javascript-playground", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "ws": "8.21.3" }, diff --git a/package.json b/package.json index f1f74b4..aaeea2e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-playground", - "version": "1.1.0", + "version": "1.2.0", "private": true, "description": "Browser games with authoritative online multiplayer", "main": "server/index.js", diff --git a/releases.json b/releases.json index 4167d43..e82fffd 100644 --- a/releases.json +++ b/releases.json @@ -1,6 +1,21 @@ { "schemaVersion": 1, "releases": [ + { + "version": "1.2.0", + "title": "JavaScript Playground 1.2.0", + "summary": "Achievements now celebrate key Tetris and Battle Tanks milestones at the moment players earn them.", + "highlights": [ + "See achievement unlocks during active play when reaching Tetris level 10, clearing four lines at once, or completing eligible Battle Tanks power-up and tactical challenges." + ], + "fixes": [ + "Prevents live achievement notifications from appearing again when the final game result is recorded.", + "Keeps achievement celebrations clear of phone touch controls so they never interrupt play." + ], + "technical": [ + "Adds rate-limited, server-validated achievement checkpoints for irrevocable in-game milestones without creating results, scores, leaderboard entries, or cumulative completion progress." + ] + }, { "version": "1.1.0", "title": "JavaScript Playground 1.1.0", From 0cd147b6c93fde06a5a1427184326a92e99e48bd Mon Sep 17 00:00:00 2001 From: Mohamed Elsherif Date: Thu, 3 Sep 2026 16:33:26 -0700 Subject: [PATCH 3/3] Reset achievement notifications on account change --- arcade.js | 3 +++ tests/shared-game-assets.test.js | 1 + 2 files changed, 4 insertions(+) diff --git a/arcade.js b/arcade.js index da76b32..6dc0eed 100644 --- a/arcade.js +++ b/arcade.js @@ -243,6 +243,7 @@ const scoreMessages = ['The leaderboard just felt that!', 'New legend status unlocked!', 'That record never stood a chance!', 'History, officially rewritten!']; const notificationQueue = []; const notifiedAchievementIds = new Set(); + let notifiedAchievementUserId = null; let showingNotification = false; const showNextNotification = () => { const notification = notificationQueue.shift(); @@ -308,6 +309,8 @@ const settleAuthentication = value => { const waiters = authenticationWaiters; authenticationWaiters = []; waiters.forEach(resolve => resolve(value)); }; const saveManager = game && window.ArcadeSaveManager ? window.ArcadeSaveManager.create({ game, api, user: () => currentUser, signIn: requestAuthentication }) : null; const render = () => { + const userId = currentUser?.id ?? null; + if (userId !== notifiedAchievementUserId) { notifiedAchievementUserId = userId; notifiedAchievementIds.clear(); } account.replaceChildren(); account.append(appearanceButton); if (game && audio) { updateAudioControls(); account.append(audioButton); } diff --git a/tests/shared-game-assets.test.js b/tests/shared-game-assets.test.js index 51a92eb..a7b8b3d 100644 --- a/tests/shared-game-assets.test.js +++ b/tests/shared-game-assets.test.js @@ -134,6 +134,7 @@ test('top-score and achievement notifications share one sequential queue', () => test('live achievement checkpoints notify during play without duplicating result unlocks', () => { const shell = read('arcade.js'), styles = read('arcade.css'), tetris = read('tetris/scripts/app.js'), tanks = read('battle-tanks/scripts/app.js'); assert.match(shell, /notifiedAchievementIds = new Set\(\)[\s\S]*notifiedAchievementIds\.has\(detail\.id\)/); + assert.match(shell, /userId !== notifiedAchievementUserId[^}]*notifiedAchievementIds\.clear\(\)/); assert.match(shell, /checkpoint: async checkpoint =>.*achievement-checkpoints/); assert.match(tetris, /game\.tetrises >= 1[\s\S]*game\.level >= 10[\s\S]*Arcade\.checkpoint/); assert.match(tanks, /powerUpsAcquired>=1[\s\S]*shieldDamageAbsorbed>=50[\s\S]*Arcade\.checkpoint/);