From 2cc6948946a467f1600aa4c9d7848e0d6046aaa1 Mon Sep 17 00:00:00 2001 From: Mentor Palokaj Date: Fri, 7 Jan 2022 13:39:01 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20multi-outfit=20generation=20alpha?= =?UTF-8?q?=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/index.js | 7 + functions/integrations/changingroom.js | 36 ++-- functions/nft-media/changing-room.js | 196 ++++++++++++++++++--- minter/src/components/organisms/Outfits.js | 11 +- 4 files changed, 198 insertions(+), 52 deletions(-) diff --git a/functions/index.js b/functions/index.js index 6ab4445..2d01dd4 100644 --- a/functions/index.js +++ b/functions/index.js @@ -13,3 +13,10 @@ exports.testnetMetadata = functions.runWith( runtime ).https.onRequest( testnetA // Mainnet endpoint exports.mainnetMetadata = functions.runWith( runtime ).https.onRequest( mainnetAPI ) + +/* /////////////////////////////// +// Firestore listeners +// /////////////////////////////*/ +const { handleQueuedRocketeerOutfit } = require( './nft-media/changing-room' ) +exports.mainnetGenerateOutfitsOnQueue = functions.runWith( runtime ).firestore.document( `mainnetQueueOutfitGeneration/{rocketeerId}` ).onWrite( handleQueuedRocketeerOutfit ) +exports.rinkebyGenerateOutfitsOnQueue = functions.runWith( runtime ).firestore.document( `rinkebyQueueOutfitGeneration/{rocketeerId}` ).onWrite( handleQueuedRocketeerOutfit ) diff --git a/functions/integrations/changingroom.js b/functions/integrations/changingroom.js index 2019c4d..845062b 100644 --- a/functions/integrations/changingroom.js +++ b/functions/integrations/changingroom.js @@ -1,4 +1,4 @@ -const { generateNewOutfitFromId, generateNewOutfitsByAddress } = require( '../nft-media/changing-room' ) +const { generateNewOutfitFromId, queueRocketeersOfAddressForOutfitChange } = require( '../nft-media/changing-room' ) const { db, dataFromSnap } = require( '../modules/firebase' ) // Web3 APIs @@ -69,17 +69,17 @@ exports.generateNewOutfit = async function( req, res ) { exports.generateMultipleNewOutfits = async function( req, res ) { // Parse the request - let { address } = req.params - if( !address ) return res.json( { error: `No address specified in URL` } ) + let { address } = req.params + if( !address ) return res.json( { error: `No address specified in URL` } ) - // Protect against malformed input - if( !address.match( /0x.{40}/ ) ) return res.json( { error: `Malformed request` } ) + // Protect against malformed input + if( !address.match( /0x.{40}/ ) ) return res.json( { error: `Malformed request` } ) // Lowercase the address address = address.toLowerCase() - // Internal beta - if( !address.includes( '0xe3ae14' ) && !address.includes( '0x7dbf68' ) ) return res.json( { error: `Sorry this endpoint is in private beta for now <3` } ) + // Internal beta + // if( !address.includes( '0xe3ae14' ) && !address.includes( '0x7dbf68' ) ) return res.json( { error: `Sorry this endpoint is in private beta for now <3` } ) try { @@ -97,28 +97,30 @@ exports.generateMultipleNewOutfits = async function( req, res ) { const network = chainId == '0x1' ? 'mainnet' : 'rinkeby' if( signer.toLowerCase() !== confirmedSignatory.toLowerCase() || action != 'generateMultipleNewOutfits' || !network ) throw new Error( `Invalid setPrimaryOutfit message with ${ signer }, ${confirmedSignatory}, ${action}, ${chainId}, ${network}` ) + // Check that the signer is the owner of the token - const outfits = await generateNewOutfitsByAddress( address, network ) + const amountOfOutfits = await queueRocketeersOfAddressForOutfitChange( address, network ) await db.collection( 'meta' ).doc( address ).set( { last_changing_room: Date.now(), - last_changing_room_errors: outfits.error.map( ( { id } ) => id ), - last_changing_room_successes: outfits.success.map( ( { id } ) => id ) + outfits_last_changing_room: amountOfOutfits, + outfits_in_queue: amountOfOutfits, + updated: Date.now() }, { merge: true } ) - return res.json( outfits ) + return res.json( { amountOfOutfits } ) - } catch( e ) { + } catch( e ) { - // Log error for debugging - console.error( `POST generateMultipleNewOutfits Changing room api error for ${ address }: `, e ) + // Log error for debugging + console.error( `POST generateMultipleNewOutfits Changing room api error for ${ address }: `, e ) - // Return error to frontend - return res.json( { error: e.mesage || e.toString() } ) + // Return error to frontend + return res.json( { error: e.mesage || e.toString() } ) - } + } } diff --git a/functions/nft-media/changing-room.js b/functions/nft-media/changing-room.js index 518b8e0..274dfee 100644 --- a/functions/nft-media/changing-room.js +++ b/functions/nft-media/changing-room.js @@ -1,13 +1,30 @@ -const { db, dataFromSnap } = require( '../modules/firebase' ) +const { db, dataFromSnap, FieldValue } = require( '../modules/firebase' ) const { getRgbArrayFromColorName, randomNumberBetween } = require( '../modules/helpers' ) const { getTokenIdsOfAddress } = require( '../modules/contract' ) const svgFromAttributes = require( './svg-generator' ) -const Throttle = require( 'promise-parallel-throttle' ) const { notifyDiscordWebhook } = require( '../integrations/discord' ) // /////////////////////////////// -// Rocketeer generator +// Rocketeer outfit generator // /////////////////////////////// +async function getRocketeerIfOutfitAvailable( id, network='mainnet' ) { + + const newOutfitAllowedInterval = 1000 * 60 * 60 * 24 * 30 + + // Retreive old Rocketeer data + const rocketeer = await db.collection( `${ network }Rocketeers` ).doc( id ).get().then( dataFromSnap ) + + // Validate this request + const { value: last_outfit_change } = rocketeer.attributes.find( ( { trait_type } ) => trait_type == "last outfit change" ) || { value: 0 } + + // Check whether this Rocketeer is allowed to change + const timeUntilAllowedToChange = newOutfitAllowedInterval - ( Date.now() - last_outfit_change ) + if( timeUntilAllowedToChange > 0 ) throw new Error( `You changed your outfit too recently, a change is avalable in ${ Math.floor( timeUntilAllowedToChange / ( 1000 * 60 * 60 ) ) } hours (${ new Date( Date.now() + timeUntilAllowedToChange ).toString() })` ) + + return rocketeer + + +} async function generateNewOutfitFromId( id, network='mainnet' ) { /* /////////////////////////////// @@ -15,12 +32,11 @@ async function generateNewOutfitFromId( id, network='mainnet' ) { // /////////////////////////////*/ // Set the entropy level. 255 would mean 0 can become 255 and -255 let colorEntropy = 10 - const newOutfitAllowedInterval = 1000 * 60 * 60 * 24 * 30 const specialEditionMultiplier = 1.1 const entropyMultiplier = 1.1 - // Retreive old Rocketeer data - const rocketeer = await db.collection( `${ network }Rocketeers` ).doc( id ).get().then( dataFromSnap ) + // Retreive old Rocketeer data if outfit is available + const rocketeer = await getRocketeerIfOutfitAvailable( id, network ) // Validate this request const { value: available_outfits } = rocketeer.attributes.find( ( { trait_type } ) => trait_type == "available outfits" ) || { value: 0 } @@ -31,10 +47,6 @@ async function generateNewOutfitFromId( id, network='mainnet' ) { if( edition != 'regular' ) colorEntropy *= specialEditionMultiplier if( available_outfits ) colorEntropy *= ( entropyMultiplier ** available_outfits ) - // Check whether this Rocketeer is allowed to change - const timeUntilAllowedToChange = newOutfitAllowedInterval - ( Date.now() - last_outfit_change ) - if( timeUntilAllowedToChange > 0 ) throw new Error( `You changed your outfit too recently, a change is avalable in ${ Math.floor( timeUntilAllowedToChange / ( 1000 * 60 * 60 ) ) } hours (${ new Date( Date.now() + timeUntilAllowedToChange ).toString() })` ) - // Grab attributes that will not change const staticAttributes = rocketeer.attributes.filter( ( { trait_type } ) => ![ 'last outfit change', 'available outfits' ].includes( trait_type ) ) @@ -120,32 +132,162 @@ async function generateNewOutfitFromId( id, network='mainnet' ) { } -async function generateNewOutfitsByAddress( address, network='mainnet' ) { +async function queueRocketeersOfAddressForOutfitChange( address, network='mainnet' ) { - const ids = await getTokenIdsOfAddress( address, network ) - const queue = ids.map( id => function() { - return generateNewOutfitFromId( id, network ).then( outfit => ( { id: id, src: outfit } ) ).catch( e => { - console.error( `Error in generateNewOutfitsByAddress: `, e.message || e ) - return { id: id, error: e.message } - } ) - } ) + try { - const outfits = await Throttle.all( queue, { - maxInProgress: 1, - failFast: false, - progressCallback: ( { amountDone } ) => process.env.NODE_ENV == 'development' ? console.log( `Completed ${amountDone}/${queue.length}` ) : false - } ) + const ids = await getTokenIdsOfAddress( address, network ) + + const idsWithOutfitsAvailable = await Promise.all( ids.map( async id => { + + try { + + // If rocketeer has outfit, return id + await getRocketeerIfOutfitAvailable( id ) + return id + + } catch( e ) { + + // If no outfit available, return false + return false + + } + + } ) ) + + // Filter out the 'false' entries + const onlyIds = idsWithOutfitsAvailable.filter( id => id ) + + // Mark Rocketeers for processing + await Promise.all( onlyIds.map( id => db.collection( `${ network }QueueOutfitGeneration` ).doc( id ).set( { + updated: Date.now(), + running: false, + network, + address + }, { merge: true } ) ) ) + + // Return amount queued for meta tracking + return onlyIds.length + + + } catch( e ) { + + console.error( `Error in queueRocketeersOfAddressForOutfitChange: `, e ) + throw e - return { - success: outfits.filter( ( { src } ) => src ), - error: outfits.filter( ( { error } ) => error ), } } +async function handleQueuedRocketeerOutfit( change, context ) { + + // If this was not a newly added queue item, exit gracefully + if( change.before.exists ) return + + // If this was a deletion, exit gracefully + if( !change.after.exists ) return + + const { rocketeerId } = context.params + const { network, running, address } = change.after.data() + + try { + + ///// + // Validations + + // If process is already running, stop + if( running ) throw new Error( `Rocketeer ${ rocketeerId } is already generating a new outfit for ${ network }` ) + + ///// + // Start the generation process + + // Mark this entry as running + await db.collection( `${network}QueueOutfitGeneration` ).doc( rocketeerId ).set( { running: true, updated: Date.now() }, { merge: true } ) + + // Generate the new outfit + await generateNewOutfitFromId( rocketeerId, network ) + + } catch( e ) { + + // if this was just a "too recently" error, exit gracefully + if( e.message.includes( 'You changed your outfit too recently' ) ) return + + // Log error to console and store + console.error( `handleQueuedRocketeerOutfit error: `, e ) + await db.collection( 'errors' ).add( { + source: `handleQueuedRocketeerOutfit`, + network, + rocketeerId, + updated: Date.now(), + timestamp: new Date().toString(), + error: e.message || e.toString() + } ) + + } finally { + + // Delete queue entry + await db.collection( `${network}QueueOutfitGeneration` ).doc( rocketeerId ).delete( ) + + // Mark the outfits generating as decremented + await db.collection( 'meta' ).doc( address ).set( { + updated: Date.now(), + outfits_in_queue: FieldValue.increment( -1 ) + }, { merge: true } ) + + } + + + +} + +// async function generateNewOutfitsByAddress( address, network='mainnet' ) { + + +// try { +// const ids = await getTokenIdsOfAddress( address, network ) + +// // Build outfit generation queue +// const queue = ids.map( id => function() { + +// // Generate new outfit and return it +// // Since "no outfit available until X" is an error, we'll catch the errors and propagate them +// return generateNewOutfitFromId( id, network ).then( outfit => ( { id: id, src: outfit } ) ).catch( e => { + +// // Log out unexpected errors +// if( !e.message.includes( 'You changed your outfit too recently' ) ) console.error( 'Unexpected error in generateNewOutfitFromId: ', e ) + +// return { id: id, error: e.message || e.toString() } + +// } ) + +// } ) + +// const outfits = await Throttle.all( queue, { +// maxInProgress: 10, +// failFast: false, +// progressCallback: ( { amountDone, rejectedIndexes } ) => { +// process.env.NODE_ENV == 'development' ? console.log( `Completed ${amountDone}/${queue.length}, rejected: `, rejectedIndexes ) : false +// } +// } ) + +// return { +// success: outfits.filter( ( { src } ) => src ), +// error: outfits.filter( ( { error } ) => error ), +// } + +// } catch( e ) { +// console.error( `Error in generateNewOutfitsByAddress: `, e ) +// throw e +// } + + +// } + module.exports = { generateNewOutfitFromId, - generateNewOutfitsByAddress + // generateNewOutfitsByAddress, + queueRocketeersOfAddressForOutfitChange, + handleQueuedRocketeerOutfit } \ No newline at end of file diff --git a/minter/src/components/organisms/Outfits.js b/minter/src/components/organisms/Outfits.js index cd4ba22..1a423e3 100644 --- a/minter/src/components/organisms/Outfits.js +++ b/minter/src/components/organisms/Outfits.js @@ -136,19 +136,14 @@ export default function Verifier() { setLoading( 'Generating new outfits, this can take a few minutes' ) - const { error, success } = await callApi( `/rocketeers/${ address }`, { + const { amountOfOutfits } = await callApi( `/rocketeers/${ address }`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( signature ) } ) - log( `Errored: `, error ) - log( `Succeeded: `, success ) + log( `Amount of outfits queued: `, amountOfOutfits ) - if( typeof error == 'string' ) throw new Error( error ) - - - alert( `Success! ${ success.length } outfits generated, ${ error.length } failed.` ) - window?.location.reload() + alert( `Success! Outfit generation started, check back in a few minutes to view your new outfits!` ) } catch( e ) {