Monitor an MMO
Full Recipe¶
Shared by: Ethan Bell
Model real-time player kill data from Planetside 2. Use API calls to supplement the killfeed graph with detailed information about the player characters and the weapons used.
Monitor an MMO Recipe
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | |
Scenario¶
Ingest the killfeed websocket output from Daybreak Games' MMOFPS "PlanetSide 2", invoking the getJsonLines procedure to lazily fill out unknown static data. Replace all instances of s:example with a service-id acquired from http://census.daybreakgames.com/#service-id
Sample Data¶
Death events will stream in from the websocket once the recipe is started. The events are JSON objects like the one below.
{
"payload":{
"attacker_character_id":"5428010618015189713",
"attacker_fire_mode_id":"26103",
"attacker_loadout_id":"15",
"attacker_vehicle_id":"0",
"attacker_weapon_id":"26003",
"character_id":"5428168624838258657",
"character_loadout_id":"6",
"event_name":"Death",
"is_headshot":"1",
"timestamp":"1392056954",
"vehicle_id":"0",
"world_id":"1",
"zone_id":"2"
},
"service":"event",
"type":"serviceMessage"
}
How it Works¶
The recipe connects an ingest stream to the PS2 Event Streaming WebSocket to manifest Death events as murder, victim, attacker, weapon, and character nodes in Quine.
The ingest stream processes events emitted from the WebSocket:
ingestStreams:
- name: planetside-killfeed
source:
type: WebsocketClient
url: wss://push.planetside2.com/streaming?environment=ps2&service-id=s:example
format:
type: Json
initMessages:
- |-
{
"service":"event",
"action":"subscribe",
"worlds": ["all"],
"characters":["all"],
"eventNames":["Death"]
}
characterEncoding: UTF-8
query: |-
WITH * WHERE $that.type = 'serviceMessage'
CREATE (m:murder)
SET m = COALESCE($that.payload, {})
WITH id(m) as mId
MATCH (murder) WHERE id(murder) = mId
MATCH (victim) WHERE id(victim) = idFrom('character', murder.character_id)
MATCH (attacker) WHERE id(attacker) = idFrom('character', murder.attacker_character_id)
MATCH (weapon) WHERE id(weapon) = idFrom('weapon', murder.attacker_weapon_id)
SET weapon.uninitialized = weapon.weapon_id IS NULL
SET victim:character, attacker:character, weapon:weapon,
victim.character_id = murder.character_id, attacker.character_id = murder.attacker_character_id,
weapon.weapon_id = murder.attacker_weapon_id
CREATE (victim)<-[:victim]-(murder)-[:attacker]->(attacker), (murder)-[:weapon]->(weapon)
WITH murder, victim, attacker
UNWIND [victim, attacker] AS character
SET character.last_update = murder.timestamp
{
"name": "planetside-killfeed",
"source": {
"type": "WebsocketClient",
"url": "wss://push.planetside2.com/streaming?environment=ps2&service-id=s:example",
"format": {
"type": "Json"
},
"initMessages": [
"{\n \"service\":\"event\",\n \"action\":\"subscribe\",\n \"worlds\": [\"all\"],\n \"characters\":[\"all\"],\n \"eventNames\":[\"Death\"]\n}"
],
"characterEncoding": "UTF-8"
},
"query": "WITH * WHERE $that.type = 'serviceMessage'\nCREATE (m:murder)\nSET m = COALESCE($that.payload, {})\nWITH id(m) as mId\nMATCH (murder) WHERE id(murder) = mId\nMATCH (victim) WHERE id(victim) = idFrom('character', murder.character_id)\nMATCH (attacker) WHERE id(attacker) = idFrom('character', murder.attacker_character_id)\nMATCH (weapon) WHERE id(weapon) = idFrom('weapon', murder.attacker_weapon_id)\nSET weapon.uninitialized = weapon.weapon_id IS NULL\nSET victim:character, attacker:character, weapon:weapon,\n victim.character_id = murder.character_id, attacker.character_id = murder.attacker_character_id,\n weapon.weapon_id = murder.attacker_weapon_id\nCREATE (victim)<-[:victim]-(murder)-[:attacker]->(attacker), (murder)-[:weapon]->(weapon)\nWITH murder, victim, attacker\nUNWIND [victim, attacker] AS character\nSET character.last_update = murder.timestamp"
}
A standing query matches each new character node to populate the character properties via the census API.
standingQueries:
- name: populate-character-data
pattern:
type: Cypher
query: MATCH (newCharacter:character) WHERE newCharacter.character_id IS NOT NULL RETURN DISTINCT id(newCharacter) AS id
mode: DISTINCT_ID
outputs:
- name: populate-fresh-character
preEnrichmentTransformation:
type: InlineData
resultEnrichment:
query: |-
MATCH (c)
WHERE id(c) = $that.id
CALL loadJsonLines("https://census.daybreakgames.com/s:example/get/ps2:v2/character/?character_id="+c.character_id) YIELD value
SET c += COALESCE(value.character_list[0], {})
RETURN null
parameter: that
destinations:
- type: Drop
{
"name": "populate-character-data",
"pattern": {
"type": "Cypher",
"query": "MATCH (newCharacter:character) WHERE newCharacter.character_id IS NOT NULL RETURN DISTINCT id(newCharacter) AS id",
"mode": "DISTINCT_ID"
},
"outputs": [
{
"name": "populate-fresh-character",
"preEnrichmentTransformation": {
"type": "InlineData"
},
"resultEnrichment": {
"query": "MATCH (c)\nWHERE id(c) = $that.id\nCALL loadJsonLines(\"https://census.daybreakgames.com/s:example/get/ps2:v2/character/?character_id=\"+c.character_id) YIELD value\nSET c += COALESCE(value.character_list[0], {})\nRETURN null",
"parameter": "that"
},
"destinations": [
{
"type": "Drop"
}
]
}
]
}
The character_list object returned from the API is similar to the one below.
{"character_list":[{"character_id":"5428010618020694593","name":{"first":"Dreadnaut","first_lower":"dreadnaut"},"faction_id":"1","head_id":"1","title_id":"97","times":{"creation":"1353434436","creation_date":"2012-11-20 18:00:36.0","last_save":"1508990907","last_save_date":"2017-10-26 04:08:27.0","last_login":"1508985950","last_login_date":"2017-10-26 02:45:50.0","login_count":"971","minutes_played":"101148"},"certs":{"earned_points":"206533","gifted_points":"9358","spent_points":"205791","available_points":"10100","percent_to_next":"0.002999999962901"},"battle_rank":{"percent_to_next":"43","value":"102"},"profile_id":"21","daily_ribbon":{"count":"5","time":"1508911200","date":"2017-10-25 06:00:00.0"},"prestige_level":"0"}],"returned":1}
A second standing query matches each new weapon node to populate the weapon properties via the census API.
standingQueries:
- name: populate-weapon-data
pattern:
type: Cypher
query: MATCH (weapon:weapon) WHERE weapon.uninitialized = true AND weapon.weapon_id IS NOT NULL RETURN DISTINCT id(weapon) AS id
mode: DISTINCT_ID
outputs:
- name: populate-weapon
preEnrichmentTransformation:
type: InlineData
resultEnrichment:
query: |-
MATCH (weapon) WHERE id(weapon) = $that.id
CALL loadJsonLines("https://census.daybreakgames.com/s:example/get/ps2:v2/item?item_id="+weapon.weapon_id+"&c:join=weapon_datasheet") YIELD value
SET weapon += COALESCE(value.item_list[0], {})
REMOVE weapon.uninitialized
RETURN null
parameter: that
destinations:
- type: Drop
{
"name": "populate-weapon-data",
"pattern": {
"type": "Cypher",
"query": "MATCH (weapon:weapon) WHERE weapon.uninitialized = true AND weapon.weapon_id IS NOT NULL RETURN DISTINCT id(weapon) AS id",
"mode": "DISTINCT_ID"
},
"outputs": [
{
"name": "populate-weapon",
"preEnrichmentTransformation": {
"type": "InlineData"
},
"resultEnrichment": {
"query": "MATCH (weapon) WHERE id(weapon) = $that.id\nCALL loadJsonLines(\"https://census.daybreakgames.com/s:example/get/ps2:v2/item?item_id=\"+weapon.weapon_id+\"&c:join=weapon_datasheet\") YIELD value\nSET weapon += COALESCE(value.item_list[0], {})\nREMOVE weapon.uninitialized\nRETURN null",
"parameter": "that"
},
"destinations": [
{
"type": "Drop"
}
]
}
]
}
The item_list object returned from the API is similar to the one below.
{"item_list":[{"item_id":"7","item_type_id":"26","item_category_id":"139","is_vehicle_weapon":"0","name":{"de":"Spawn -Leuchte","en":"Spawn Beacon","es":"Baliza de apariciones","fr":"Balise de réapparition","it":"Faro di rigenerazione","tr":"Animation Control Sign"},"description":{"de":"Eine Signalleuchte zum schnellen Absetzen von Truppmitgliedern in einen Bereich aus geringer Höhe.","en":"A signal-emitting beacon that allows squad members to drop pod into an area from low orbit.","es":"Una baliza que emite una señal y permite a los miembros del escuadrón aterrizar con cápsula dentro de una zona desde una órbita baja.","fr":"Une balise émettrice de signaux qui module de aux membres de l'es un déploiement à partir d'une orbite basse.","it":"Faro emettitore di segnali che permette ai membri della squadra di inserirsi a caldo nell'area.","tr":"It allows squad members to come from low orbit to instantaneous space is a scattering checkmark."},"faction_id":"0","max_stack_size":"1","image_set_id":"1568","image_id":"3056","image_path":"/files/ps2 /images/static/3056.png","is_default_attachment":"0"}],"returned":1}
Running the Recipe¶
Warning
Before running this recipe beyond a few seconds, you'll need to apply for a service ID from Daybreak Games in order to access their API. The service ID "s:example" is available for casual use--it is throttled to 10 requests per minute per client IP address.
Update a local copy of this recipe once you receive your personal service-id.
Please don't share the updated recipe or your service ID with others.
❯ java -jar quine-2.2.1.jar -r planetside-2.yaml
Graph is ready
Using 4 node appearances
Running Standing Query populate-character-data
Running Standing Query populate-weapon-data
Ingest Stream planetside-killfeed: Could not verify that the provided ingest query is idempotent. If timeouts occur, query execution may be retried and duplicate data may be created.
Running Ingest Stream planetside-killfeed
Quine web server available at http://127.0.0.1:8080
Default API version: v2
Once the recipe is running, you can explore the graph to find subgraphs formed around Death events.
