New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce new Syncing libraries #19

Merged
merged 35 commits into from Dec 18, 2017

Conversation

Projects
None yet
3 participants
@SamuelMoriarty
Copy link
Contributor

SamuelMoriarty commented Dec 5, 2017

Each library comes with an extensive doc detailing the principles behind the library, the standard usage scenario as seen by the end user, and in the case of Network, an implementation overview to help you get oriented.

There are also a few minor changes in the other packages that I added during the development of this, that I thought deserved to be in the stdlib.

@@ -1,5 +1,13 @@
package Trigger
import NoWurst
import Player

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

The changes you made here overlap with stuff from RegisterEvents

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Oh, thanks, did not realize it was there. Thought it'd be intuitive to have those extension functions for Trigger.

public interface ForForceCallback
function callback()

force dummy = CreateForce()

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

You can use bj_FORCE_ALL_PLAYERS instead of a dummy

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Unfortunately, to execute the callback exactly once, there needs to be only one player inside the Force. I think using bj_FORCE_ALL_PLAYERS would cause it to execute multiple times instead.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

use let or constant, and dumy isn't a suitable name

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks


// briefly selects the specified unit by the player
function player.onceSelect(unit what)
let temp = CreateGroup()

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

use ENUM_GROUP and for from to not have group leaks

This comment has been minimized.

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks. Will do.

@@ -5,9 +5,11 @@ import Unit

/** Use this array instead of Player() to avoid leaks */
public player array players
public player localPlayer

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

Can this be a constant?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Probably, thanks

private SynchronizationCallback callback = null

construct()
dummy = CreateUnit(DUMMY_PLAYER, DUMMY_ID, 0, 0, 0)

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

use cascade ..

gamecache key lengths short.
**/
public class DataStream
// max characters to store per compacting round

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

omit type

construct(player sender)
this.currentState = NetworkState.PREPARING
this.sender = sender
this.gcIntegerStream = new GamecacheStream(dataCache, mkey, StreamType.INTEGER)

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

can be inlined?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks

let syncRounds = maxLength div gcKeysCount + 1

// save the amount of sync rounds required so that other players know how many times they need to read
if localPlayer == sender

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

This if and the next one can be merged

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks

// receives info about the transmission after it has been sent by the originating player
private function receiveMetadata()
// check that we are transitioning from the correct state
if SAFETY_CHECKS_ENABLED and currentState != NetworkState.SENDING_META

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

2 whitespace after and

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

killmeplease

// destroy this instance
destroy this

// this is the function to start sending all data in the intermediate dataStream buffer

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

make these comments hotdoc

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks

@Frotty

This comment has been minimized.

Copy link
Member

Frotty commented Dec 6, 2017

consider renaming **Stream classes as the name implies different functionality/implementation.
Thanks a lot 🌴

SyncStoredUnit(this, missionKey, key)

public function gamecache.syncString(string missionKey, string key)
SyncStoredString(this, missionKey, key)

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Missing EoF LF

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks

init
for i = 0 to bj_MAX_PLAYER_SLOTS-1
players[i] = Player(i)
localPlayer = GetLocalPlayer()

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

No need to have this in init block, I don't think?

If it is needed: needs a comment

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Will inline into a constant

force dummy = CreateForce()

init
ForceAddPlayer(dummy, GetLocalPlayer())

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Can this just be ..addPlayer(localPlayer)? If not, can you add _force API?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks. Will add _force API as well.

init
ForceAddPlayer(dummy, GetLocalPlayer())

function callback() returns boolean

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

not a suitable function name for this internal fn

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

What name would be more suitable?

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

May I suggest _currentCallback? @Frotty ?

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

why underscore in front? the other libs (closureforgroups) use currentCallback which should be fine

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

That's fine too. Why underscore? Costs very little and benefit is slightly improved indication of "this is not public, and its use is very specific/controlled by this package"


// max amount to be written/read/synced per an execution to avoid hitting the OP limit
// this value can be higher if Wurst is compiled without stack traces and with all optimizations turned on
// and even higher with safety checks off

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

The above two comment are of the form "optimiations, safety checks, and stack traces affect suitable op-limit values". I don't think they add much value here.

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

I think these are important considerations for those who may feel like they are experiencing performance issues, and are not necessarily obvious to everyone. Especially not obvious what this constant is for, and why you may want to adjust it, hence, the comment.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

May I suggest

// Max amount to be written/read/synced per an execution, to avoid hitting the op-limit.
// Beware of changes in maximum value as compiler settings are adjusted.
// only disable this if you are 100% sure your code works correctly
@configurable constant boolean SAFETY_CHECKS_ENABLED = true

//TODO: I just realized that we can eliminate some delay by sending metadata and the first round together

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Don't commit TODOs


Rationale:
This library can be used to send arbitrary amounts of integer, real, boolean
and string data from one player to the rest.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Why would I want to do that?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Read my other long comment ;)

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

May I suggest making all the documents clear on this unusual behavior?

"In multiplayer games, this package is for synchronizing data between game clients. It's useful for when one player is the source of game data, such as from gamecache or file IO."

and data is received from the same buffer inside the callback when it has all been received.

Read SyncSimple docs for a slightly more in-depth overview of this particular
peculiarity of WC3.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Link?

2. Queue some data for sending from the sender. You can write as much as you need, there is no limit:
if localPlayer == sender
// keep in mind that before you start sending data,
// network.getData() is locked in a WRITE-ONLY MODE

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Why? What does that mean?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Hopefully makes much more sense with file example explanation.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Sorry, the purpose of my comment was to indicate that your code comment wasn't clear enough.

Rule of thumb: code comments explain why, not what.

if localPlayer == sender
// keep in mind that before you start sending data,
// network.getData() is locked in a WRITE-ONLY MODE
val stream = network.getData()

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

s/val/let/

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks! Habit from writing Kotlin code ;)

network.start()
// this will start the transmission
// keep in mind, that after you call this, you MUST NOT write/read to network.getData()
// as it is locked while the data is syncing

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

How is the stream processed at the receiver? Is there an Rx Queue?

"MUST NOT" - what happens if I try?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Frotty and I agreed that 'Stream' is a misnomer. Network is ultimately datagram based and sends the payload in smaller datagrams of gcKeysCount size.

If you try with SAFETY_CHECKS_ENABLED == true, you will simply receive an error. If you try without them, then it is undefined behaviour. You will probably just screw up the transmission.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Stream: why does that make it a misnomer? are you imagining that UDP streams offer no guarantee but datagram ones do, or are you saying that the presence of a sequence of frames does not fit the definition of stream?

In both cases I disagree. Stream is pretty abstract. See https://tokio.rs/docs/getting-started/streams-and-sinks/

About safety checks: consider rephrasing the sentence so "MUST NOT" doesn't exist.

  1. The API should do its best to not let someone do something wrong
  2. MUST NOT is an angry warning that will, on its own, fall upon deaf ears.
// as it is locked while the data is syncing

4. To receive the data, specify the callback and read the data inside it:
network.onFinish((stream) -> begin

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Is this documentation for accepting Tx stream from another host, or a callback function for Tx finished its async operation?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

'Stream' is a misnomore. This is a callback function for when the datagram has been fully received.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

"datagram" you mean when full set (stream) of datagrams have all been received?

This needs serious clarification imo.

"4. Register a callback function to be run on all game clients when the Network sync is complete."

...
end)

WARNING: Network is a one-time use class, and is automatically

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

You mean: start() performs a destroy this?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

destroy this is performed after the callback specified in .onFinish(cb) is called.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

I'm wondering if this warning is necessary. Will wurst compiler prevent you from use-after-free? @Frotty

string field4 = ...

// optional stream-constructor
construct (DataStream stream)

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

is the space between construct and ( standard practice?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Whoops, my bad


And then use it like so:
// writing
val myClass = ...

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Not clear. I guess you mean let myClass = new MyClass(...) ?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Yes. Will update the example to be more clear, thanks.

DataStream is a hashtable-based container with stream semantics for writing
integers, reals, booleans, strings and serializables.
Because we want to keep gamecache keys below a certain size, we can't store
everything immediately in the gamecache without risking exhausting available keys.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

You mean that gamecache might have a collision? Source?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Network performance degrades significantly with longer gamecache keys, since keys (as well as gamecache names themselves) also get sent over the network. This has been demonstrated by TriggerHappy previously in his findings for the Sync library.

https://www.hiveworkshop.com/pastebin/3a4f7861cb884e21312168bd654330585801/

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Is this documented in JassDoc? @lep ?

Code needs a direct reference if you're going to both (1) make claims about perf, and (2) they're actually relevant

@Cokemonkey11
Copy link
Contributor

Cokemonkey11 left a comment

Overall really great code quality. Thanks, this is by far the biggest standard library PR I've seen in a long time - maybe ever.

everything immediately in the gamecache without risking exhausting available keys.
We also need to know the size of data being sent prior to starting the transmission,
so we have to store all of it in an intermediate buffer, which is DataStream.
Prior to sending, all strings in the DataStream are 'exploded' into a stream

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

exploded -> encoded, packed, or unpacked

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks!

We also need to know the size of data being sent prior to starting the transmission,
so we have to store all of it in an intermediate buffer, which is DataStream.
Prior to sending, all strings in the DataStream are 'exploded' into a stream
of integers, because SyncStoredString doesn't work.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

The native is broken? Source?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Very old news. Source is TH's Sync library.

I tried this myself hoping it'd work on the newer patches somehow, but it simply doesn't sync strings correctly.
Might test this more in the future, but I have no reason to assume that it works.

This comment has been minimized.

@Frotty

Frotty Dec 6, 2017

Member

yes, the stdlib's packages are broken as well with 1.28.3+ or so.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Code comment needs a source more than I do. Is this in jassdoc? @lep

so we have to store all of it in an intermediate buffer, which is DataStream.
Prior to sending, all strings in the DataStream are 'exploded' into a stream
of integers, because SyncStoredString doesn't work.
After sending, they are 'compacted' back into strings and written to the DataStream.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

compacted -> decode, packed, or unpacked

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

Thanks!


GamecacheStream is a gamecache-based container with stream semantics for writing
integers, reals and booleans. There is a GamecacheStream for each primitive type,
integer, boolean, real and stringInts.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

stringInt -> asciiInt

all the heavy lifting.

Before starting the transmission, the DataStream is locked into a non-writable, non-readable
mode to prevent anyone from trying to mess with it while the transmission is going.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

What happens when someone tries?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

They will receive an error with safety checks enabled.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

"the DataStream is locked into an immutable state, in which incorrect mutation attempts will print warnings, so long as safety checks are not disabled."

synchronously for all players at the same time, allowing us to know for
certain when other players have acknowledged our unit selection, as well
as all network events that have been fired before it.
This is due to the fact that the WC3 game protocol is built on top of TCP,

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Could be inferred, but background knowledge probably more noisy than value here

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

What do you mean?

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

This is due to the fact that the WC3 game protocol is built on top of TCP,

What's the actual value of explaining why [mathematical deterministic thing] works in [mathematically deterministic way]?

This is good background knowledge but not so much documentation.

.sync() after a series of Sync natives, we ensure that the .onSynced()
callback will only be called after all players have received the data.

This way, we can send local data from one player to the rest, such as

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Probably needs a concrete demo.

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

What about the usage section? It provides a valid usage case, which is also the operating principle behind Network.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

By Concrete Demo I mean actual testmap. Here's an example of PR where testmap was warranted: wurstscript/WurstScript#472


// briefly selects the specified unit by the player
function player.onceSelect(unit what)
let temp = CreateGroup()

This comment has been minimized.


private unit dummy
private bitset syncedPlayers = emptyBitset()
private static bitset allPlayers = bitset(4095)

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

why 2^12 - 1 ?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 6, 2017

Author Contributor

2^12 - 1 is 1111 1111 1111 in binary, one bit for each player. neutrals don't count.

This comment has been minimized.

@Cokemonkey11

Cokemonkey11 Dec 6, 2017

Contributor

Needs a comment. Also would be nice if wurst had 0b1111_1111_1111 notation for ints @Frotty

private SynchronizationCallback callback = null

construct()
dummy = CreateUnit(DUMMY_PLAYER, DUMMY_ID, 0, 0, 0)

@SamuelMoriarty SamuelMoriarty force-pushed the SamuelMoriarty:master branch from 938205f to 56e49bb Dec 16, 2017

@SamuelMoriarty

This comment has been minimized.

Copy link
Contributor Author

SamuelMoriarty commented Dec 16, 2017

I've updated the PR, implementing most of the requests outlined above.
I've also done some refactoring in Network. Here's a short summary (off the top of my head)

  • Added Force API
  • Moved Buffer classes into wurst/data, added different implementations like HashBuffer and StringBuffer (to be used later for file I/O), also added unit tests for them
  • Split apart Network into multiple sub-packages: GamecacheBuffer, GamecacheKeys, Metadata, Network and StringEncoder
  • Metadata functionality has been externalized
  • String-encoding has been externalized
  • Network now sends first payload together with metadata, to avoid one redundant round-trip
@Frotty
Copy link
Member

Frotty left a comment

I'M still kinda asking myself what is the highest lvl api here and how will most users use this? Really nice work so far though :) A bit hefty to review though..

@@ -1,5 +1,6 @@
package Trigger
import NoWurst
import Player

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

import can be removed?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done

private static constant MAX_BUFFER_SIZE = 1024

// list of "chunks" that can grow/shrink as required
private var chunks = new LinkedList<ChunkElement>()

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

this list doesn't get destroyed?

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done


// loads the next chunk into the buffer
private function nextChunk()
if chunks.size() == 0

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

.isEmpty()

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done

fail(BufferFailureMode.EOF, "reached EOF")
return

let chunk = chunks.dequeue()

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

using push and then dequeue kinda doesn't make sense. Please stick to sensible naming conventions of the intended datatype.

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

should I use .add()/.dequeue() pair instead?

override function writeBoolean(bool value)
checkWrite()
pushTypeIdentifier(ValueType.BOOLEAN)
if value

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

value.toString()

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done

function getData() returns HashBuffer
return dataBuffer

// sends info about the amount of data to be expected to each player

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

hotdoc

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done

end)

// encode all strings into ints
execute(() -> begin

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

doesn't need begin/end

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done

let asciiIntCount = stringEncoder.getIntCount()

// calculate max length in each entity buffer
let maxLength = max(max(intCount, realCount), max(booleanCount, asciiIntCount))

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

should probably make a max(x,y,z) for maths package

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Added min/max variants for up to 6 arguments in Maths package

destroy this

// this is the function to start sending all data in the intermediate dataBuffer buffer
function start()

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

can combine start and onFinish I think

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done

2. Send some network events on one (or more) player using the Sync natives (or something else)
if sender == localPlayer
// value1 and value2 are some local values only known to localPlayer
SyncStoredInteger(gc, mkey, vkey1, value1)

This comment has been minimized.

@Frotty

Frotty Dec 16, 2017

Member

use extension funcs

This comment has been minimized.

@SamuelMoriarty

SamuelMoriarty Dec 16, 2017

Author Contributor

Done

@SamuelMoriarty SamuelMoriarty force-pushed the SamuelMoriarty:master branch from 56e49bb to fc73756 Dec 16, 2017

@Frotty

This comment has been minimized.

Copy link
Member

Frotty commented Dec 18, 2017

Sweet 🌴

@Frotty Frotty merged commit 01a48ce into wurstscript:master Dec 18, 2017

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment