Howdy, Stranger!

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

[SugarCube 2] The Set() data structure

Hey there,

Using SugarCube 2.1x.x
Twine 2.1.0

I tried recently to implement a Quest system that works with "templates" of different quest types (fetch quests, put given number of items into a specified container, visit a location and check game time while doing so, defeat an enemy type etc.), where I would have a MissionLog object for each mission these would be elements of a set. These sets would represent accomplished and still on going missions.

When a mission is complete I would move the mission from the on-going mission into the accomplished set.
The problem is that I can only call add() and delete() member functions inside JavaScript, since there's no equivalent in SugarCube 2. Now, of course in order to initialize the Set, I call the constructor inside SugarCube 2:
<<set accomplished_mission = new Set()>>

which yields "something" (I'm not sure what it is, probably a malformed array.)
I refer to this set in my JS code as follows:
State.variables.accomplished_mission

When I try to add() new elements to this "set" using JS code, for some reason, the system ignores the fact that I requested to work with a Set() data structure and creates multiple instances of the same object inside accomplished_mission. I checked for the "isArray()" property to find out what I'm working with but it yielded a big undefined for me. So, I'm not sure what is going on here. I found my self opening the console next to Twine 2 and trying everything that came to mind to find out more about the data structure that I got from SugarCube 2.

Now, of course it's needless to say that there is an easy workaround for this kind of problem in JS (simply check if the "set" already holds the item with the same Id and filter any new additions), but the fact that things don't work the way they supposed to, kind of bothers me. I'm afraid things like these could come back and bite me in the a$$ later down the road as I develop my game in SugarCube 2. I never thought I'd run into so many problems, which have delayed me significantly.

Can anyone explain to me what is going on here and why are data structures and some standard JS functions behave so strangely?

Comments

  • I created the following test case and it worked as I expected it to:
    <<set $accomplished_mission = new Set()>>
    
    <<set $accomplished_mission.add("A")>>
    <<set $accomplished_mission.add("B")>>
    
    set: <<print $accomplished_mission>>
    

    The accomplished_mission variable in your first example is missing a dollar-sign, if you cut-n-pasted that example from your story's code then try adding one.
  • edited February 13
    Could you try that with a complex object?
    Let's say:
    <<set $Spain ={
     "continent" : Europe
     }>>
    
    <<set $apple = {
       "cost" : 2,
       "quantity" : 1,
       "origin" : $Spain,
     }>>
    

    But this time try and do this:
    <<set $accomplished_mission = new Set()>>
    
    <<set $accomplished_mission.add($apple)>>
    <<set $accomplished_mission.add($apple)>>
    
    set: <<print $accomplished_mission>>
    

    I played around with sets in my chrome browser and they worked just fine with objects like the ones stated above. I also don't use SugarCube to call add(), but instead I use macros where additional checks are being carried out. Let's see what happens here?
    Also: I'm pretty sure it wasn't the dollar sign. This example was not copied from my own code.
  • edited February 13
    Fix missing quotes (I really shouldn't be posting from my cellphone):
    <<set $Spain ={
     "continent" : "Europe"
     }>>
    
  • edited February 13
    As explained in the first line of MDN's Set object documentation:
    The Set object lets you store unique values of any type, whether primitive values or object references.
    So the issue is not the complexity of the object values that you are trying to store in the collection, it is the uniqueness of those values. This is the major difference between an Array object and a Set object, the first allows multiple instances of the same value to be added to the collection where the second does not.
  • edited February 13
    I think you misunderstood me there. I have a very comprehensive knowledge of how sets need to work.

    Please read carefully below:
    As I said. I tried out the code in my browser before (where it worked flawlessly, no duplicates appeared inside the created set, which is correct) applying it to my SugarCube story and it did not work (meaning duplicates were added, which strange and it should not be the case).

    The objective of the experiment was to see if a duplicate of the same object will appear inside the set. In the browser it did not. In my twine game it did and I had to implement filtering in order to handle the data structure correctly.
  • edited February 13
    Are you trying to add the 2nd instance of the 'object' to the collection in the same navigable passage as the 1st instance, or do you traverse between passages before adding the second instance.

    eg. Are you doing something like the following? (using TWEE Notation)
    :: First Passage
    <<set $apple = {"cost" : 2, "quantity" : 1, "origin" : $Spain} >>
    <<set $accomplished_mission = new Set()>>
    <<set $accomplished_mission.add($apple)>>
    [[Next|Second Passage]
    
    
    :: Second Passage
    <<set $accomplished_mission.add($apple)>>
    var should contain two objects: <<print $accomplished_mission>>
    

    Each time the story traverses between passages a copy of all known variables is made and this copy is what is made available to the next passage being rendered. This means that the $apple variable in the second passage does not reference the same object as the $apple variable in the first passage, it references a new object of the 'same' value.

    Because the two object references are different they can both exist in a Set object at the same time.
  • edited February 13
    Disane wrote: »
    When a mission is complete I would move the mission from the on-going mission into the accomplished set.
    The problem is that I can only call add() and delete() member functions inside JavaScript, since there's no equivalent in SugarCube 2.
    That probably made sense in your head, however, not so much on the outside.

    If you meant that you think you cannot use the <Set>.add() and <Set>.delete() methods on an instance of Set within SugarCube's TwineScript, then you are mistaken. In SugarCube, TwineScript is essentially a layer of syntactical sugar atop standard JavaScript, so virtually anything you can do in raw/pure JavaScript, you can do in TwineScript, because it is JavaScript. SugarCube does not define a custom Set API, so the only one available normally is the standard JavaScript Set—or a standard polyfill, in case the browser is ancient enough to not have a native Set (EDIT: Unlike SugarCube v2, v1 does not include the polyfill, so the existence of Set cannot be guaranteed when using it).

    If you meant something else, then you're going to need to make a better attempt at an explanation.

    Disane wrote: »
    Now, of course in order to initialize the Set, I call the constructor inside SugarCube 2:
    <<set accomplished_mission = new Set()>>
    

    which yields "something" (I'm not sure what it is, probably a malformed array.)
    I refer to this set in my JS code as follows:
    State.variables.accomplished_mission
    
    That example cannot work as written. You forgot to use the story variable sigil within the <<set>>, so accomplished_mission will be treated as a variable local to that <<set>> invocation and since no such variable exists an error will be returned.

    Since the error would be kind of obvious and you didn't mention seeing one, I'm going to assume that was a transcription error. (ASIDE: Based upon past experience alone, you tend to make transcription errors routinely. Policing that more stringently would really be helpful. Seriously. For realsies.)

    Disane wrote: »
    When I try to add() new elements to this "set" using JS code, for some reason, the system ignores the fact that I requested to work with a Set() data structure and creates multiple instances of the same object inside accomplished_mission.
    You're attempting this how, exactly? By choosing to not show the code you're having issues with, we cannot tell you what's going on.

    That said, I'm going to go out on a limb and guess that you're attempting this across multiple turns/moments and have forgotten that the contents of story variables are cloned with each new turn/moment and referential relationships between objects are not kept—this was explained to you within the inventory thread you participated in and was the whole reason unique IDs were necessary there.

    That caveat with story variables and objects—and everything in JavaScript which isn't a primitive type is an object—limits the usefulness of Set instances when attempting to store story variable based objects within them, since there's no way to override its native uniqueness test—which is, basically, strict equality.

    Disane wrote: »
    I checked for the "isArray()" property to find out what I'm working with but it yielded a big undefined for me. So, I'm not sure what is going on here. I found my self opening the console next to Twine 2 and trying everything that came to mind to find out more about the data structure that I got from SugarCube 2.
    The Set API contains no isArray() method—because instances of Set are not arrays—so of course Set.isArray() is undefined, if that's what you tried. While instances of Array and Set share some commonality, they are distinct data types.

    Even if you used Array.isArray() on an instance of Set it would return false, since Set instances are not arrays.

    Frankly, I'm curious as to how you even thought that was something that you could do in the first place.

    Disane wrote: »
    Could you try that with a complex object?
    Let's say:
    […]

    But this time try and do this:
    <<set $accomplished_mission = new Set()>>
    
    <<set $accomplished_mission.add($apple)>>
    <<set $accomplished_mission.add($apple)>>
    
    set: <<print $accomplished_mission>>
    
    As that example is written, the $accomplished_mission Set instance will contain a single entry which is a copy of the object reference contained within $apple. It will not contain two references as Set enforces uniqueness amongst its entries.

    Beyond that, and as explained up post, what you're doing there is perilous as $apple is a story variable containing an object. If you attempt the above across multiple turns/moments, then the Set will contain references to distinct copies of the $apple object as the references will no longer be the same. For example: (using Twee notation here; assume $apple is defined as above)
    :: Passage_A
    /*
    	Initialize $accomplished_mission as an empty Set.
    */
    <<set $accomplished_mission = new Set()>>
    
    /*
    	Attempt to add the current $apple object to the Set, which succeeds
    	as it does not yet exist within the Set.
    */
    <<set $accomplished_mission.add($apple)>>
    
    /*
    	Attempt to add another current $apple object to the Set, which fails
    	as it already exists within the Set.
    */
    <<set $accomplished_mission.add($apple)>>
    
    /*
    	Result: $accomplished_mission contains one $apple object.
    */
    
    
    :: Passage_B
    /*
    	Between Passage_A and Passage_B the story variable store was cloned
    	without maintaining referential relationships.  The Passage_A $apple
    	object and Passage_B $apple object are equivalent, yet inequal objects—
    	i.e. their contents are equivalent, but they are distinct objects.
    */
    
    /*
    	Attempt to add the current $apple object to the Set, which succeeds
    	as it does not yet exist within the Set.
    */
    <<set $accomplished_mission.add($apple)>>
    
    /*
    	Result: $accomplished_mission contains two distinct $apple objects—
    	one a copy of the Passage_A $apple object and one equal to the current
    	$apple object.
    */
    
  • edited February 13
    Beyond that, and as explained up post, what you're doing there is perilous as $apple is a story variable containing an object. If you attempt the above across multiple turns/moments, then the Set will contain references to distinct copies of the $apple object as the references will no longer be the same. For example: (using Twee notation here; assume $apple is defined as above)
    Between Passage_A and Passage_B the story variable store was cloned
    without maintaining referential relationships. The Passage_A $apple
    object and Passage_B $apple object are equivalent, yet inequal objects—
    i.e. their contents are equivalent, but they are distinct objects.
    */

    /*
    Attempt to add the current $apple object to the Set, which succeeds
    as it does not yet exist within the Set.
    */
    <<set $accomplished_mission.add($apple)>>

    /*
    Result: $accomplished_mission contains two distinct $apple objects—
    one a copy of the Passage_A $apple object and one equal to the current
    $apple object.
    */

    Thank you! Exactly the info I needed!
  • To not leave my response at "you're doing it wrong", here are some additional comments and suggestions.

    I'm unclear on why you're storing the quest objects within story variables and then storing them again within a Set instance, which is within a story variable. Even if new turn/moment cloning didn't occur you'd be double dipping their storage.

    It would probably be easier to store the quest objects within the story variable store, as it seems you were planning, and simply use a property to record their states—e.g. 'unstarted', 'active', 'finished', 'failed', etc.

    For example:
    :: StoryInit
    <<set $towelQuest to {
    	name   : "Life, the Universe, and a Towel",
    	status : "unstarted",
    
    	[…]
    }>>
    
    :: Passage_Get_TowelQuest
    <<set $towelQuest.status to "active">>
    
    :: Passage_Working_On_TowelQuest
    <<if $towelQuest.status is "active">>
    	…do $towelQuest stuff…
    <</if>>
    
    :: Passage_Finish_TowelQuest
    <<set $towelQuest.status to "finished">>
    


    Writing some functions to handle the state manipulation for you—to guard against fat fingering—would be easily enough done. For example:
    /* Quest State Manipulation Functions */
    window.startQuest = function (quest) {
    	quest.status = "active";
    };
    window.finishQuest = function (quest) {
    	quest.status = "finished";
    };
    window.failQuest = function (quest) {
    	quest.status = "failed";
    };
    
    /* Quest State Query Functions */
    window.isQuestUnstarted = function (quest) {
    	return quest.status === "unstarted";
    };
    window.isQuestActive = function (quest) {
    	return quest.status === "active";
    };
    window.isQuestFinished = function (quest) {
    	return quest.status === "finished";
    };
    window.isQuestFailed = function (quest) {
    	return quest.status === "failed";
    };
    
    Example usage—based on the original example:
    :: StoryInit
    /* no change */
    
    :: Passage_Get_TowelQuest
    <<set startQuest($towelQuest)>>
    
    :: Passage_Working_On_TowelQuest
    <<if isQuestActive($towelQuest)>>
    	…do $towelQuest stuff…
    <</if>>
    
    :: Passage_Finish_TowelQuest
    <<set finishQuest($towelQuest)>>
    
  • edited February 14
    Hey Mad!

    Thank you for the code examples. It is not the states that I'm storing in those extra objects. It is of course my fault not going into more details about the system I've gone for. So... here's what I did:
    /%
    MissionType - Equip, Fetch or Buy, Defeat, Visit + time interval + location, Placement + time, Status, Use + time+ location
    Where: 
    Equip - check the player's inventory if a given item has been equipped
    Fetch or Buy - check if the player bought a given item
    Defeat - check if the player has defeated an enemy
    Visit - the player has visited a place at a given TIME-interval
    These are the conditions to complete a given mission
    Placement - put a specified item into a specified inventory / container
    Status - with a specified status type reach a given status value
    Use - Use a specified item at a given location and time
    %/
    /%
    Equip missions:
    Slot - the slot to which an item needs to be assigned by the player
    Item - the item that needs to be assigned to a specified slot by marking it as equipped
    %/
    <<set $MissionEquipTrousers = 
    {
    	"MissionType" : "Equip",
    	"Slot" : "Pants",
    	"Id" : "trousers"
    }>>
    
    <<set $MissionBuyFancyWatch = 
    {
    	"MissionType" : "Buy",
    	"Quantity" : 1,
    	"Id" : "fancy.watch"
    }>>
    
    <<set $MissionPlacementOldTrousers = 
    {
    	"MissionType" : "Placement",
    	"Quantity" : 1,
    	"Id" : "old.trousers",
    	"Container" : "Cabinet"
    }>>
    
    /%
    The mission log objects is responsible for keeping track of an objective.
    StartPassage - the passage, which needs to be visited by the player in order to activate the mission and be added to the list of active missions.
    Name - name of the mission
    TimesCompleted - the number of times the player has completed this mission.
    %/
    <<set $MissionLogEquipTrousers = 
    {
    	"StartPassage" : "MessageToSelf",
    	"Name" : "Equip Trousers",
    	"Id" : "equip.trousers",
    	"ObjectiveType" : $MissionEquipTrousers,
    	"TimesCompleted" : 0,
    	"isRepeatable" : false,
    	"Description" : "Go to the Plaza and buy some new trousers at the clothing vendor."
    }>>
    
    <<set $MissionLogPlacementOldTrousers = 
    {
    	"StartPassage" : "MessageToSelf",
    	"Name" : "Put Old Trousers into the Cabinet",
    	"Id" : "put.old.trousers",
    	"ObjectiveType" : $MissionPlacementOldTrousers,
    	"TimesCompleted" : 0,
    	"isRepeatable" : false,
    	"Description" : "Put your old trousers into your cabinet at home."
    }>>
    
    <<set $MissionLogBuyFancyWatch = 
    {
    	"StartPassage" : "MessageToSelf",
    	"Name" : "Buy a Fancy Watch",
    	"Id" : "buy.fancy.watch",
    	"ObjectiveType" : $MissionBuyFancyWatch,
    	"TimesCompleted" : 0,
    	"isRepeatable" : false,
    	"Description" : "Buy a Fancy Watch at the plaza's electronics shop."
    }>>
    
    <<ActivateMission $MissionLogEquipTrousers>>
    <<ActivateMission $MissionLogPlacementOldTrousers>>
    <<ActivateMission $MissionLogBuyFancyWatch>>
    

    Now, I've created two sets, to utilize one to store the missions that are still active and another one to store missions that were already finished. So this way I can iterate through missions quickly and not to get the two kind (finished and active) mixed up and print their objective details separately (active and finished missions printed separately). I also chose sets because I wanted to spare my self from working on checks to filter duplicates and to re-use missions when they get reactivated (I simply move the ones that are repeatable and are triggered by the player back into the active mission set). Now, using sets as you mentioned made no sense since they would have not worked the way I thought they would.

    Another thing that I thought about is composing mission/quest types by having a template for the basic mission types, but be able to modify the objective details easily. Say for example, you have a mission where you need to put a trouser into the cabinet, this can be grabbed and easily turned into a mission where you put two pistols under your pillow (you just copy the template of the "placement" mission type and change some parameters, assign it to a mission log, finally, add the mission log to the active missions). Now, this would work better if I could instantiate these objects and create them dynamically, which is something I have not figured out yet.
  • Disane wrote: »
    […] It is not the states that I'm storing in those extra objects. […]
    I didn't say that you were, I said that it would probably be easier to do, and better since you're not double dipping their storage.

    Disane wrote: »
    Now, this would work better if I could instantiate these objects and create them dynamically, which is something I have not figured out yet.
    You could use Object.assign(), either by itself, within a factory function of sorts, or within a constructor function.

    By itself example:
    /*
    	Object.assign(
    		{ /* empty object */ },
    		{ /* base mission template object */ },
    		{ /* mission specifics object */ }
    	)
    */
    <<set $someNewBuyMission to Object.assign({}, setup.buyMissionTemplate, {
    	/* new buy mission specifics */
    })>>
    
    I used the setup object to store the template object in the example because you do not want to be storing invariants within the story variable store—it bloats the history for no good purpose.


    Factory function example:
    window.createMission = function () {
    	return Object.assign.apply(null, [].concat({}, Array.from(arguments)));
    };
    
    Usage:
    /*
    	createMission(
    		{ /* base mission template object */ },
    		{ /* mission specifics object */ }
    	)
    */
    <<set $someNewBuyMission to createMission(setup.buyMissionTemplate, {
    	/* new buy mission specifics */
    })>>
    
    As with the first example, the template object is again stored within the setup object to keep the story variable store, and thus the history, free of bloat.


    Constructor function example:
    window.BuyMission = function (specificsObj) {
    	Object.assign(this, {
    		/* buy template properties go here */
    	}, specificsObj);
    };
    
    Usage:
    <<set $someNewBuyMission to new BuyMission({
    	/* new buy mission specifics */
    })>>
    
    The template object here is built into its constructor, so there's no external template to worry about.
  • edited February 14
    https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign

    Found more info here. I'll look into that and start experimenting with it.
    I'm not sure if I'll have the dynamic mission creation implemented in this story, but I want to at least understand what is going on with copies and instances.

    I'm not sure what you meant when you mentioned the setup object and history. I have not looked into these yet. I assume they are SugarCube 2 specific things. If you could post a link to the documentation or to an article about these, or explain them, I'd really appreciate that.
  • edited February 14
    Posting this from my phone, so brevity is the order.

    Disane wrote: »
    I did provide that very link in my post, when I first mentioned it.

    Disane wrote: »
    I'm not sure what you meant when you mentioned the setup object and history. I have not looked into these yet. I assume they are SugarCube 2 specific things. If you could post a link to the documentation or to an article about these, or explain them, I'd really appreciate that.
    SugarCube v2 documentation.

    EDIT:
    The setup object is simply a generic object which is guaranteed to be in scope and available to user code. It's briefly mentioned within Special Names.

    The history is chiefly described within API: State.
Sign In or Register to comment.