Howdy, Stranger!

It looks like you're new here. If you want to get involved, click one of these buttons!

[SugarCube 2.14.0] Sanity check: Am I serialising my objects correctly?

As I continue down the path of trying to create a real-time, event-driven story in SugarCube, I find myself encountering some interesting challenges. The latest of which is getting object prototypes and anonymous functions to play nicely with story variables.

I think I've fixed the problem by following advice given by @TheMadExile in the thread Keeping complex JS objects in variables?, but I'm not sure I've fixed it "correctly".

If any of you have experience with JSON serialisation, could you please take a look over the following and let me know if there's a better way?

Events:
Creates a future-dated event, to be fired when $game_clock >= Event.time
/*
setup.Event(time, [title[, passage[, openFn[, closeFn[, bHidden[, bPopUp[, log]]]]]]])
time:    When the event will occur, the only mandatory field.
title:   Name of event to be displayed on UI.
passage: Passage to be displayed if user clicks on the event's title in UI (title will only be a link if passage is not undefined).
openFn:  Function to be fired immediately after the event is fired.
closeFn: Function to be fired after the user closes the pop-up dialog, only works if bPopUp is true and log is not undefined.
bHidden: Whether or not the event should be displayed on the UI.
bPopUp:  Whether or the event's log should pop-up on screen as a SugarCube dialog, only works if log is not undefined.
log:     Historical log entry to be created after the event is fired.
*/

setup.Event = function(time, title, passage, openFn, closeFn, bHidden, bPopUp, log) {
  if (!(time instanceof Date)) {
    time = new Date(time);
  }
  if (openFn instanceof Array) {
    openFn = eval(openFn[1]);
  }
  if (closeFn instanceof Array) {
    closeFn = eval(closeFn[1]);
  }
  if (log instanceof Array) {
    log = eval(log[1][0]);
  }
  
  this.time = time;
  this.title = title;
  this.passage = passage;
  this.openFn = openFn;
  this.closeFn =  closeFn;
  this.bHidden =  bHidden;
  this.bPopUp = bPopUp;
  this.log = log;
};

setup.Event.prototype = {
  toJSON: function () {
    var logStr, returnFn;
    
    if (this.log) {
      /* log is an instance of setup.EventLog, see below */
      logStr = this.log.toJSON();
    }
    
    returnFn = 'new setup.Event(';
    returnFn += JSON.stringify(this.time.getTime()) +',';
    returnFn += JSON.stringify(this.title) +',';
    returnFn += JSON.stringify(this.passage) +',';
    returnFn += JSON.stringify(this.openFn) +',';
    returnFn += JSON.stringify(this.closeFn) +',';
    returnFn += JSON.stringify(this.bHidden) +',';
    returnFn += JSON.stringify(this.bPopUp) +',';
    returnFn += JSON.stringify(logStr) +')';

    return JSON.reviveWrapper(returnFn);
  }
};


Event Logs:
Creates a log, a historical record of past events for the user to see.
/*
setup.EventLog(time, logSev, logType, logMessage[, learnid[, learnmsg[, read]]])
time:       When the event occured.
logSev:     Log's severity, for filtering purposes.
logType:    Log's type, for filtering purposes.
logMessage: Main body text of the log message.
learnid:    What $player_knowledge is unlocked the first time the user reads this log.
learnmsg:   Additional text to display the first time this $player_knowledge is found.
read:       A boolean to track if the user has read this log.
*/

setup.EventLog = function(time, logSev, logType, logMessage, learnid, learnmsg, read) {
  if (!(time instanceof Date)) {
    time = new Date(time);
  }
  
  this.time = time;
  this.severity = logSev;
  this.type = logType;
  this.message = logMessage;
  this.learnid =  learnid;
  this.learnmsg =  learnmsg;
  this.read = (read || false);
};

setup.EventLog.prototype = {  
  /* Prints the number of days since epoch */
  getDays: function () { /* snip */ },

  /* Prints the current time (24H). */
  getTime: function () { /* snip */ },

  /* creates a summary of this log for use in a <jQuery>.wiki() call */
  format: function() { /* snip */ },

  /* display a SugarCube dialog containing this log's information */
  showDialog: function(closeFn) { /* snip */ },
  
  toJSON: function () {
    var returnFn = 'new setup.EventLog(';
    returnFn += JSON.stringify(this.time.getTime()) +',';
    returnFn += JSON.stringify(this.severity) +',';
    returnFn += JSON.stringify(this.type) +',';
    returnFn += JSON.stringify(this.message) +',';
    returnFn += JSON.stringify(this.learnid) +',';
    returnFn += JSON.stringify(this.learnmsg) +',';
    returnFn += JSON.stringify(this.read) +')';

    return JSON.reviveWrapper(returnFn);
  }
};


Example Usage:
This creates a hidden Event that will be not be displayed in the 'Upcoming Events' section of the UI. When the event fires, it will immediately stop any time acceleration, and add this event's EventLog to the list of historical logs.

It will also display a SugarCube dialog displaying the same log information. When the user closes this dialog, they will be taken to the passage 'Event Intro Alarm'.
setup.cannedEvents = {
  /* snip */

  introCollisionWarning: function() {
    var time = new Date('1901-04-07T07:20:00Z');

    var event = new setup.Event(
      time, //time
      'introCollisionWarning', //title
      null, //passage
      setup.timeSystem.clearTimeGoal, //openFn
      function() { Engine.play("Event Intro Alarm"); }, //closeFn
      true, //bHidden
      true //bPopUp
    );

    var log = new setup.EventLog(
      time, //time
      'High', //logSev
      'Collision Warning', //logType
      "Sensor event SE-4562 has now been upgraded to a high severity, anomaly appears to be mass-bearing. Due to possibility of kinetic impact, ship status has been upgraded to high alert status.<br/><br/>''Warning:'' Current trajectory suggests ESSD Cetacea will now encounter anomaly in ''T-1 hour.'' Brace for severe warp turbulence." //logMessage
    );

    setup.eventSystem.addEvent(event, log);
  },

  /* snip */
}

Comments

  • Rokiyo wrote: »
    I think I've fixed the problem by following advice given by @TheMadExile in the thread Keeping complex JS objects in variables?, but I'm not sure I've fixed it "correctly".
    Before I get into what I'd recommend, I want to cover a few points and ask a few questions.


    1. The linked thread is almost three years old, so it's not state of the art anymore.


    2. I really do not recommend using individual parameters for this. If you're really married to that, then that's okay. Using a single object parameter, however, would be nearly as easy to use and make the (de)serialization code a tad simpler. What would you rather see in an example, a big list of parameters or an object parameter?


    3. You're doing some peculiar—i.e. probably wrong—things in the setup.Event constructor. For example, within the following I'm suspicious of what you're doing for time and log and I have no idea why you're doing what you're doing for openFn and closeFn:
      if (!(time instanceof Date)) {
        time = new Date(time);
      }
      if (openFn instanceof Array) {
        openFn = eval(openFn[1]);
      }
      if (closeFn instanceof Array) {
        closeFn = eval(closeFn[1]);
      }
      if (log instanceof Array) {
        log = eval(log[1][0]);
      }
    
    Does any of that serve an actual purpose or were you throwing stuff at the wall? I ask because SugarCube serializes and restores Date objects and plain functions, so I'm unsure why you seem to attempting to force the issue with the first three. For log, as long as setup.EventLog is properly setup to be (de)serialized, then what you're doing there is unnecessary as well.


    4. You're doing some peculiar—i.e. probably wrong—things in the <setup.Event>.toJSON method. For example, within the following you should not need, or want, to call <setup.EventLog>.toJSON method:
        if (this.log) {
          /* log is an instance of setup.EventLog, see below */
          logStr = this.log.toJSON();
        }
    
    The toJSON method is a magic method, which is automatically called by the JSON serializer if it exists. Forcing it is unnecessary.


    5. You're passing methods as if they're regular functions in places, which may or may not be okay depending on the method—i.e. static methods may be okay, prototype and instance methods are very likely not. It mostly depends on scoping and use of this. For example, the following is probably okay if the setup.timeSystem.clearTimeGoal method does not use local scope and, if it's an instance or prototype method, does not use this.
          setup.timeSystem.clearTimeGoal, //openFn
    
    If does use local scope or this, then you'll need to wrap it within a callback to ensure that it will be invoked properly when restored from serialization. For example:
    	function () { setup.timeSystem.clearTimeGoal(); }, //openFn
    
    You could very well be on top of this. I simply thought that I should mention it, just in case.
  • 1. The linked thread is almost three years old, so it's not state of the art anymore.
    Ah, fair enough. It's about the most concise solution I could find when I went googling. I don't like wasting other people's time unnecessarily so I do try to research as much as possible before asking questions.

    I should probably point out I'm not very experienced with serialisation in general.

    2. I really do not recommend using individual parameters for this.
    Definitely not married to it. I did not encounter the serialisation issue until I accidentally refreshed the page... And then I tried to shoehorn the solution into my existing code.

    It was just a matter of laziness, I will switch over to a single object parameter soon.

    3. You're doing some peculiar—i.e. probably wrong—things in the setup.Event constructor.

    Does any of that serve an actual purpose or were you throwing stuff at the wall? I ask because SugarCube serializes and restores Date objects and plain functions, so I'm unsure why you seem to attempting to force the issue with the first three.
    I'm just as suspicious of what I've done here, and this is exactly what prompted me to raise this thread. The problem I'm having is that when I use JSON.stringify() on Dates and functions, what gets revived is an Array, not an actual Date or function.

    For example, with openFn and closeFn, in Event.toJSON, I call:
    returnFn += JSON.stringify(this.openFn) +',';
    returnFn += JSON.stringify(this.closeFn) +',';
    

    What comes back after hitting F5 is stuff like:
    closeFn: Array[2]
        0 : "(revive:eval)"
        1 : "(function () { Engine.play("Event Intro Alarm"); })"
        length : 2
        __proto__ : Array[0]
    

    This is why I use eval(closeFn[1]): The function I'm attempting to revive is being passed to Event's constructor as the second element in an Array during deserialisation.

    4. You're doing some peculiar—i.e. probably wrong—things in the <setup.Event>.toJSON method.
    I can see where I went wrong here. While trying to get to the bottom of why functions were coming back as arrays, I was looking at JSON.stringify() output along these lines:
    "["(revive:eval)",["new setup.Event(-2169131880000,\"makeLog\",null,null,null,true,false,[\"(revive:eval)\",[\"new setup.EventLog(-2169131880000,\\\"Severe\\\",\\\"Ship malfunction\\\",\\\"Logging information: The autonav failed to initiate an automated shut down of the warp drive. The error code returned was: E82 - Unknown error. Warp core diagnostics already in progress, awaiting results.\\\",undefined,undefined,false)\",null]])",null]]"
    

    I incorrectly assumed all of the escaped characters were part of the problem, and tried to work around it with the above. I have removed the forcing portion of the code, and can confirm that deserialisation works without it.

    That being said, I still need to call log = eval(log[1][0]); to properly revive the log object, as it still coming back as a nested array:
    log : Array[2]
        0 : "(revive:eval)"
        1 : Array[2]
            0 : "new setup.EventLog(-2169132000000,"High","Sensor anomaly","On day 462 at 0740hrs SGT, sensor event SE-4562 produced a series of gravitational spike exceeding safe operating limits. Sensor event SE-4562 has now been upgraded to a high severity. Ship status has been upgraded to high alert status. Damage control systems are on standby. Human oversight required - sounding general ship alarm. Extreme caution is advised.<br/><br/>''Warning:'' Current trajectory suggests ESSD Cetacea will now encounter anomaly in ''T-1 hour.'' Brace for severe warp turbulence.","intro_deadline","The Cetacea will encounter the anomaly at 0820hrs. You might want to stop the ship before then.",false)"
            1 : null
            length : 2
            __proto__ : Array[0]
        length : 2
        __proto__ : Array[0]
    
    Is this because I'm passing individual parameters? Would all of this work fine if I had been using an object parameter instead?


    5. You're passing methods as if they're regular functions in places, which may or may not be okay depending on the method.
    Thanks, that's helpful. I had noticed that this wasn't working but didn't know why (I was only wrapping functions inside callbacks where I wanted to use pass parameters to the functions being called).


    Last but not least, last time I linked you my work in progress, the server hosting my site seemed unreachable from outside Australia. You're welcome to give it another go, on the off-chance they've resolved whatever issue there was last time:
    http://www.rokiyo.net/games/cetacea/StoryEngine.html
  • Rokiyo wrote: »
    I'm just as suspicious of what I've done here, and this is exactly what prompted me to raise this thread. The problem I'm having is that when I use JSON.stringify() on Dates and functions, what gets revived is an Array, not an actual Date or function.
    Not quite. The revival array what gets returned by those types' custom toJSON methods, which is then serialized into a string. SugarCube's customized deserializer reverses both transformations.

    If you're seeing either the revival array itself—i.e. an array whose first member is something like "(revive:…)"—or its serialized form, then you're doing it wrong. This is likely a consequence of attempting to force things, rather than letting the system handle it. By letting the system function as intended, you should get the proper type back.

    I'll try to post a example later today, tomorrow at the outside.

    Rokiyo wrote: »
    Last but not least, last time I linked you my work in progress, the server hosting my site seemed unreachable from outside Australia. You're welcome to give it another go, on the off-chance they've resolved whatever issue there was last time:
    http://www.rokiyo.net/games/cetacea/StoryEngine.html
    Still unresponsive, unfortunately. I know it's not a general problem with me being able to reach Australia or something like that, as I visit various sites at Monash University from time to time.
  • I'll try to post a example later today, tomorrow at the outside.
    Thanks, much appreciated.
    Still unresponsive, unfortunately. I know it's not a general problem with me being able to reach Australia or something like that, as I visit various sites at Monash University from time to time.
    Here, try this link: http://philome.la/rokiyo/ceteceawip/play
  • NOTES:
    • The serializer does not support getters/setters, so neither does the example.
    • I'm not a fan of clobbering existing prototype objects, so the example assigns each method to the prototype in turn.
    • The example uses the JSON.reviveWrapper() method's second parameter, reviveData, to pass the instance's data, which is exposed within the revival code string via the $ReviveData$ special variable.

    I'd suggest something like the following: (untested)
    /*******************************************************************************
    Events: Creates a future-dated event, to be fired when $game_clock >= Event.time.
    *******************************************************************************/
    
    /*
    	setup.Event(obj)
    
    	obj is:
    	{
    		time    : When the event will occur.
    		title   : (optional) Name of event to be displayed on UI.
    		passage : (optional) Passage to be displayed if user clicks on the event's title in UI, title will only be a link if passage is not undefined.
    		openFn  : (optional) Function to be fired immediately after the event is fired.
    		closeFn : (optional) Function to be fired after the user closes the pop-up dialog, only works if bPopUp is true and log is not undefined.
    		bHidden : (optional) Whether or not the event should be displayed on the UI.
    		bPopUp  : (optional) Whether or the event's log should pop-up on screen as a dialog, only works if log is not undefined.
    		log     : (optional) Historical log entry to be created after the event is fired.
    	}
    */
    setup.Event = function(obj) {
    	// Assign `this` defaults here, if any.
    
    	if (typeof obj === 'object') {
    		Object.assign(this, obj);
    	}
    };
    
    /*
    	<setup.Event>.toJSON()
    
    	Custom toJSON method which serializes this class instance so that it may be
    	automatically revived.
    */
    setup.Event.prototype.toJSON = function () {
    	return JSON.reviveWrapper(
    		'new setup.Event($ReviveData$)',
    		Object.assign({}, this)
    	);
    };
    
    
    /*******************************************************************************
    Event Logs: Creates a historical record of past events for the user to see.
    *******************************************************************************/
    
    /*
    	setup.EventLog(obj)
    
    	obj is:
    	{
    		time     : When the event occurred.
    		severity : Severity, for filtering purposes.
    		type     : Type, for filtering purposes.
    		message  : Main body text of the log message.
    		learnid  : (optional) What $player_knowledge is unlocked the first time the user reads this log.
    		learnmsg : (optional) Additional text to display the first time this $player_knowledge is found.
    		read     : (optional) A boolean to track if the user has read this log.
    	}
    */
    setup.EventLog = function(obj) {
    	// Assign `this` defaults here, if any.
    	this.read = false; // n.b. You were doing this before, but this is probably unnecessary.
    
    	if (typeof obj === 'object') {
    		Object.assign(this, obj);
    	}
    };
    
    /*
    	<setup.Event>.toJSON()
    
    	Custom toJSON method which serializes this class instance so that it may be
    	automatically revived.
    */
    setup.EventLog.prototype.toJSON = function () {
    	return JSON.reviveWrapper(
    		'new setup.EventLog($ReviveData$)',
    		Object.assign({}, this)
    	);
    };
    
    /*
    	<setup.Event>.getDays()
    
    	Prints the number of days since epoch.
    */
    setup.EventLog.prototype.getDays = function () { /* snip */ };
    
    /*
    	<setup.Event>.getTime()
    
    	Prints the current time (24H).
    */
    setup.EventLog.prototype.getTime = function () { /* snip */ };
    
    /*
    	<setup.Event>.format()
    
    	Creates a summary of this log for use in a <jQuery>.wiki() call.
    */
    setup.EventLog.prototype.format = function () { /* snip */ };
    
    /*
    	<setup.Event>.showDialog(closeFn)
    
    	Display a SugarCube dialog containing this log's information.
    */
    setup.EventLog.prototype.showDialog = function (closeFn) { /* snip */ };
    


    Example Usage:
    setup.cannedEvents = {
    	/* snip */
    
    	introCollisionWarning : function () {
    		var eventTime = new Date('1901-04-07T07:20:00Z');
    
    		var event = new setup.Event({
    			time    : eventTime,
    			title   : 'introCollisionWarning',
    			passage : null,
    			openFn  : function () { setup.timeSystem.clearTimeGoal(); },
    			closeFn : function () { Engine.play('Event Intro Alarm'); },
    			bHidden : true,
    			bPopUp  : true
    		});
    
    		var log = new setup.EventLog({
    			time     : eventTime,
    			severity : 'High',
    			type     : 'Collision Warning',
    			message  : "Sensor event SE-4562 has now been upgraded to a high severity, anomaly appears to be mass-bearing. Due to possibility of kinetic impact, ship status has been upgraded to high alert status.<br/><br/>''Warning:'' Current trajectory suggests ESSD Cetacea will now encounter anomaly in ''T-1 hour.'' Brace for severe warp turbulence."
    		});
    
    		setup.eventSystem.addEvent(event, log);
    	},
    
    	/* snip */
    };
    
  • *250 lines worth of canned events later*
    Aaaaaand done!

    I've applied each of your suggested changes and can confirm that it's working perfectly, thanks for your help on this.

    While I was at it, I also went through and removed the unnecessary step of relying on addEvent to create the association between Event and EventLog (that was an artifact from when I was trying to create circular references between the two, which is what prompted that Bitbucket issue I raised a week ago).
    introCollision: function() {
        var eventTime = new Date('1901-04-07T08:20:00Z');
    
        var log = new setup.EventLog({
          time     : eventTime,
          severity : 'High',
          type     : 'Lithobreaking Event',
          message  : "You crashed into a planet. Good job."
        });
    
        var event = new setup.Event({
          time    : eventTime,
          title   : 'introCollision',
          passage : null,
          openFn  : function() {
            setup.timeSystem.clearTimeGoal();
            Engine.play("Event IntroImpact");
          },
          closeFn : null,
          bHidden : true,
          bPopUp  : false,
          log     : log
        });
        
        setup.eventSystem.addEvent(event);
      },
    
Sign In or Register to comment.