Java worker API
The Java worker API closely mirrors the C# worker API. The full API reference is available as in-code documentation.
Overview
The most important units of functionality in the Java worker API are:
- The
improbable.worker.Locator
, which is responsible for querying for and connecting to remote cloud deployments. - The
improbable.worker.Connection
, which represents a worker’s connection to a SpatialOS simulated world. - The
improbable.worker.Dispatcher
, which is responsible for invoking user-provided callbacks based on data received from SpatialOS via theimprobable.worker.Connection
. - The
improbable.worker.Entity
, which is a container for the component data associated with a single entity.
Connecting to SpatialOS
Before it can interact with the simulated world, the worker must connect to SpatialOS.
There are two ways of doing this:
- Instantiate a
improbable.worker.Connection
object directly. This allows to connect a managed worker (i.e running in the cloud and started by the SpatialOS runtime) or a remote worker used for debugging (viaspatial cloud connect external
). The connection is made through the receptionist service of the targeted deployment for which the IP and port should be passed as arguments to the worker. These values can be filled-in automatically at runtime by using theIMPROBABLE_RECEPTIONIST_HOST
andIMPROBABLE_RECEPTIONIST_PORT
placeholders in your worker’s launch configuration. - Use the
improbable.worker.Locator
object to enumerate cloud deployments and connect to a chosen deployment with authentication. This is typically used to connect an external client to a cloud deployment.
The value given for the useExternalIp
field in the NetworkParameters
class is also relevant.
The table below summarizes the connection possibilities:
Using improbable.worker.Connection directly |
Using improbable.worker.Locator |
|
---|---|---|
useExternalIp == true |
Local client connecting via spatial cloud connect external proxy |
External client connecting to cloud deployment |
useExternalIp == false |
Managed cloud worker; local client connecting to local deployment |
The example below illustrates a very basic connection setup, where the function takes three arguments specifying the worker’s own ID as well as the receptionist’s IP and port. It will use TCP and connect using the internal IP address.
private static Connection getConnection(String workerId, String hostname, int port) {
ConnectionParameters parameters = new ConnectionParameters();
parameters.workerType = "JavaDocsWorker";
parameters.network = new NetworkParameters();
parameters.network.connectionType = NetworkConnectionType.Tcp;
parameters.network.useExternalIp = false;
return Connection.connectAsync(hostname, port, workerId, parameters).get();
}
Note that improbable.worker.Connection
objects are not thread-safe.
Handling data received from SpatialOS
It is up to each worker to decide how the basic event loop should be structured, depending on its
requirements. The worker should call improbable.worker.Connection.getOpList
to get a list of
“operations” (for example a new entity, or a component update) from SpatialOS that have been sent since the
last time the function was called. This improbable.worker.OpList
then needs to be passed to a
dispatcher in order to invoke user-provided callbacks.
The following snippet shows a (very) simple example implementation of an event loop that processes operations from SpatialOS 60 times per second:
private static final int FRAMES_PER_SECOND = 60;
private static void runEventLoop(Connection connection, Dispatcher dispatcher) {
java.time.Duration maxWait = java.time.Duration.ofMillis(Math.round(1000.0 / FRAMES_PER_SECOND));
while (true) {
long startTime = System.nanoTime();
OpList opList = connection.getOpList(0 /* non-blocking */);
// Invoke callbacks.
dispatcher.process(opList);
// Do other work here...
long stopTime = System.nanoTime();
java.time.Duration waitFor = maxWait.minusNanos(stopTime - startTime);
try {
Thread.sleep(Math.max(waitFor.toMillis(), 0));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
If positive timeout (in milliseconds) is provided to getOpList
, the function will block until
there is at least one operation to return, or until the timout has been exceeded.
Note that all callbacks provided to the
improbable.worker.Dispatcher
will be invoked only when improbable.worker.Dispatcher.process
is called, and only on the thread that is currently calling
improbable.worker.Dispatcher.process
: the user has complete control over callback timing and
threading decisions.
If the connection fails (when Connection.isConnected() == false
), any error messages explaining the cause
will be returned by getOpList(). Make sure to process this list in order to receive all
available diagnostic information.
Dispatcher callbacks
Several kinds of callbacks can be registered on the improbable.worker.Dispatcher
. Each method takes a
Callback<Param>
, where the parameter type Param
depends on the particular kind of callback being
registered. The following table has the details:
Method | Parameter type (and fields) | Invoked when… |
---|---|---|
onDisconnect |
improbable.worker.Ops.Disconnect (String reason) |
the Connection is no longer connected and can no longer be used. Check the log for errors relating to the disconnection. |
onLogMessage |
improbable.worker.Ops.LogMessage (improbable.worker.LogLevel level , String message ) |
the SDK issues a log message for the worker to print. This does not include messages sent using Connection.sendLogMessage . |
onMetrics |
improbable.worker.Ops.Metrics (improbable.worker.Metrics metrics ) |
the SDK reports built-in internal metrics. |
onCriticalSection |
improbable.worker.Ops.CriticalSection (bool inCriticalSection ) |
a critical section is about to be entered or has just been left. |
onAddEntity |
improbable.worker.Ops.AddEntity (improbable.worker.EntityId entityId ) |
an entity is added to the worker’s view of the simulation. |
onRemoveEntity |
improbable.worker.Ops.RemoveEntity (improbable.worker.EntityId entityId ) |
an entity is removed from the worker’s view of the simulation. |
onReserveEntityIdResponse |
improbable.worker.Ops.ReserveEntityIdResponse (improbable.worker.RequestId<ReserveEntityIdRequest> requestId , improbable.worker.StatusCode statusCode , String message , improbable.collection.Option<improbable.worker.EntityId> entityId ) |
the worker has received a response for an entity ID reservation it had requested previously. |
onReserveEntityIdsResponse |
improbable.worker.Ops.ReserveEntityIdsResponse (improbable.worker.RequestId<ReserveEntityIdsRequest> requestId , improbable.worker.StatusCode statusCode , String message , improbable.collection.Option<improbable.worker.EntityId> entityId , int numberOfEntityIds ) |
the worker has received a response for a reservation of an entity ID range it had requested previously. |
onCreateEntityResponse |
improbable.worker.Ops.CreateEntityResponse (improbable.worker.RequestId<CreateEntityRequest> requestId , improbable.worker.StatusCode statusCode , String message , improbable.collection.Option<improbable.worker.EntityId> entityId ) |
the worker has received a response for an entity creation it had requested previously. |
onDeleteEntityResponse |
improbable.worker.Ops.DeleteEntityResponse (improbable.worker.RequestId<DeleteEntityRequest> requestId , improbable.worker.EntityId entityId , improbable.worker.StatusCode statusCode , String message ) |
the worker has received a response for an entity deletion it had requested previously. |
onEntityQueryResponse |
improbable.worker.Ops.EntityQueryResponse (improbable.worker.RequestId<EntityQueryRequest> requestId , improbable.worker.StatusCode statusCode , String message , int resultCount , Map<improbable.worker.EntityId, improbable.worker.Entity> result ) |
the worker has received a response for an entity query it had requested previously. |
onAddComponent<C> |
improbable.worker.Ops.AddComponent<C> (improbable.worker.EntityId entityId , improbable.worker.IComponentData<C> data ) |
a component is added to an existing entity in the worker’s view of the simulation. |
onRemoveComponent<C> |
improbable.worker.Ops.RemoveComponent (improbable.worker.EntityId entityId ) |
a component is removed from an existing entity in the worker’s view of the simulation. |
onAuthorityChange<C> |
improbable.worker.Ops.AuthorityChange (improbable.worker.EntityId entityId , improbable.worker.Authority authority ) |
the worker’s authority state over a component is changed to Authoritative, Not Authoritative or Authority Loss Imminent. |
onComponentUpdate<C> |
improbable.worker.Ops.ComponentUpdate<C> (improbable.worker.EntityId entityId , improbable.worker.IComponentUpdate<C> update ) |
a component for an entity in the worker’s view of the simulation has been updated. |
onCommandRequest<C> |
improbable.worker.Ops.CommandRequest<C> (improbable.worker.RequestId<IncomingCommandRequest> requestId , improbable.worker.EntityId entityId , int timeoutMillis , String callerWorkerId , List<String> callerAttributeSet , improbable.worker.CommandRequest<C> request ) |
the worker has received a command request for a component on an entity over which it has authority. |
onCommandResponse<C> |
improbable.worker.Ops.CommandResponse<C> (improbable.worker.RequestId<OutgoingCommandRequest> requestId , improbable.worker.EntityId entityId , improbable.worker.StatusCode statusCode , improbable.collection.Option<improbable.worker.CommandResponse<C>> response ) |
the worker has received a command response for a request it issued previously. |
Here’s an example of registering callbacks:
private static java.util.List<Long> registerCallbacks(Connection connection, Dispatcher dispatcher) {
java.util.List<Long> callbackKeys = new java.util.ArrayList<Long>();
long callbackKey = dispatcher.onAddEntity(addEntity -> {
// Do something with addEntity.entityId.
}
);
callbackKeys.add(callbackKey);
callbackKey = dispatcher.onComponentUpdate(Switch.COMPONENT, op -> {
Switch.Update update = op.update;
for (SwitchToggled toggleEvent : update.getToggled()) {
System.out.println("Switch has been toggled at " + toggleEvent.getTime());
}
});
callbackKeys.add(callbackKey);
return callbackKeys;
}
If you want to unregister a callback, call remove()
with the long
returned from the registration method:
private static void unregisterCallbacks(Dispatcher dispatcher, java.util.List<Long> callbackKeys) {
for (Long key : callbackKeys) {
dispatcher.remove(key);
}
}
Using the View
improbable.worker.View
is subclass of improbable.worker.Dispatcher
, which adds the functionality of
automatically maintaining the current state of the worker’s view of the simulated world. It has a field
called entities
, which is a map from entity ID to improbable.worker.Entity
objects. When a
View
processes an OpList
, it automatically updates the state of this map
as appropriate. Any user-defined callbacks are also invoked, as per the Dispatcher
contract.
Dispatcher invocation order
When processing an improbable.worker.OpList
, callbacks for most operations are invoked in the
order they were registered with the improbable.worker.Dispatcher.
However, some operations
naturally come in pairs of “start” and “end” operations, and the callbacks for the “end” operation
are invoked in the reverse of the order they were registered.
This means that pairs of callbacks registered for the “start” and “end” operations can correctly
depend on resources managed by pairs of callbacks registered previously,
similar to the usual construction and destruction order found in e.g. C++.
For example, Ops.AddEntity
and Ops.AddComponent
callbacks are invoked in the order they were
registered with the improbable.worker.Dispatcher
, while Ops.RemoveEntity
and Ops.RemoveComponent
callbacks are invoked in reverse order. Similarly, Ops.AuthorityChange
callbacks are invoked in the
order they were registered when authority is granted, but in reverse order when authority is revoked
(or authority loss is imminent). Ops.CriticalSection
s are invoked in usual order when a critical
section is entered but reverse order when a critical section is left. The Ops.FlagUpdate
callbacks
are invoked in the order they were registered when a worker flag is created or its value is changed,
but in reverse order when a worker flag is deleted.
In particular, this invocation order has the side-effect that the final state of a component
removed in an Ops.RemoveComponent
callback can still be inspected in the improbable.worker.View
.
Critical sections
The protocol supports the concept of critical sections. A critical section is a block of operations that should be processed atomically by the worker. For example, a set of entities representing a stack of cubes should be added to the scene in its entirety before any physics simulation is done. Note that this notion of a critical section does not provide any guarantees.
The Ops.CriticalSection
callback is invoked with inCriticalSection == true
just before
the first operation of the critical section is delivered, and is invoked again with
inCriticalSection == false
just after the last operation of the critical section is delivered.
Worker flags
To access the value of a worker flag, use the
getWorkerFlag
method of a Connection
:
Option<String> getWorkerFlag(String flagName)
For example:
private static final double DEFAULT_WORK_SPEED = 10;
private static void getSpeedWorkerFlag(Connection connection) {
improbable.collections.Option<String> speedFlag = connection.getWorkerFlag("mycompany_theproject_speed");
if (speedFlag.isPresent()) {
setWorkSpeed(Double.parseDouble(speedFlag.get()));
} else {
setWorkSpeed(DEFAULT_WORK_SPEED);
}
}
Sending data to SpatialOS
A worker can send data such as logging, metrics, and component updates to SpatialOS using the
improbable.worker.Connection
.
Sending and receiving metrics
You can optionally send metrics by calling Connection.sendMetrics
. You can view metrics by going to the Inspector World view and clicking on a worker.
There are two typical use cases for sending metrics:
- Reporting your own custom metrics. Both time series and histogram metrics are supported.
- Updating a worker’s load when the worker is using dynamic load balancing. The load of a worker is a floating-point value, with 0 indicating an unloaded worker and values above 1 corresponding to an overloaded worker. The reported values direct SpatialOS’s load balancing strategy.
The following example demonstrates both use cases:
// A queue of tasks the worker has to complete.
private final static java.util.Queue TaskQueue = new java.util.ArrayDeque<>();
private final static double MAXIMUM_QUEUE_SIZE = 200.0; // An arbitrary maximum value.
// Collect and send user-defined metrics to SpatialOS.
// Note that you should generally call sendMetrics on the same user thread as you call
// other Connection methods.
private static void sendUserMetrics(Connection connection) {
Metrics metrics = new Metrics();
// Update the current load of the worker.
double load = TaskQueue.size() / MAXIMUM_QUEUE_SIZE;
metrics.load = improbable.collections.Option.of(load);
// Add custom metrics.
metrics.gaugeMetrics.put("MyCustomMetric", 1.0);
HistogramMetric histogram = new HistogramMetric();
histogram.recordObservation(123);
metrics.histogramMetrics.put("MyCustomHistogram", histogram);
connection.sendMetrics(metrics);
}
Workers automatically send several built-in, internal metrics at a period defined by the
builtInMetricsReportPeriodMillis
field of ConnectionParameters
. You can register a callback
to receive these metrics inside a Ops.MetricsOp
using Dispatcher.OnMetrics
.
Sending and receiving component updates
When the worker has authority over a particular component for some entity, it can send component
updates to SpatialOS. This is done using the improbable.worker.Connection
method
sendComponentUpdate
, which takes an EntityId
instance and an instance of appropriate update class
defined in the schema-generated code. The update classes are always called Update
and are defined
as a static nested classes of each implementation of ComponentMetaclass
. For example, if we want to update a component
MyComponent
, the appropriate update class is nested in it and can by accessed as MyComponent.Update
.
A component update can modify the value of a property or trigger an event. You can modify multiple properties and trigger multiple events in the same component update.
Component updates sent by the worker will appear in the operation list returned by a subsequent
call to improbable.worker.Connection.getOpList
. This means that Ops.ComponentUpdate
callbacks will also be
invoked for component updates triggered by the worker itself, but not immediately.
There are a couple of (related) reasons that callbacks for sent component updates should not be invoked
immediately: this can lead to extremely unintuitive control flow when components are recursively
updated inside a callback; it violates the guarantee that callbacks are only invoked as a result
of a call to the process
method on the improbable.worker.Dispatcher
.
To be notified when a worker receives a component update on an entity in the worker’s local
view of the simulation, use the improbable.worker.Dispatcher
method onComponentUpdate
with the same
ComponentMetaclass
as for sending updates. Note that the component updates can be partial, i.e.
only update some properties of the component, do not necessarily have to contain data that is different
from the workers current view of the component, and could have been sent by SpatialOS rather than a worker
for synchronization purposes.
Sending and receiving component events
Sending and receiving events works much in the same way as component updates. For a schema like the following,
package example;
type SwitchToggled {
int64 time = 1;
}
component Switch {
id = 1234;
bool is_enabled = 1;
event SwitchToggled toggled;
}
triggering an event can be done as follows:
private static void triggerEvent(Connection connection, EntityId entityId) {
Switch.Update update = new Switch.Update();
update.addToggled(new SwitchToggled(1));
connection.sendComponentUpdate(Switch.COMPONENT, entityId, update);
}
If you are not authoritative on the component, your event will be silently ignored.
Receiving an event works just like receiving a component update, by registering a callback on the dispatcher:
callbackKey = dispatcher.onComponentUpdate(Switch.COMPONENT, op -> {
Switch.Update update = op.update;
for (SwitchToggled toggleEvent : update.getToggled()) {
System.out.println("Switch has been toggled at " + toggleEvent.getTime());
}
});
Sending component interests
For each entity in its view, a worker receives updates for a set of the entity’s components. This set is the union of the set of components the worker has authority over, and a set of components it is explicitly interested in.
The initial set of explicitly interested components can be configured in the
bridge settings.
This is the default set of explicit interest for every entity. At runtime, the worker can override this
set on a per-entity basis, by using the Connection.sendComponentInterest
method.
Whenever the set of interested components for an entity changes, the worker will receive the appropriate
onAddComponent
and onRemoveComponent
callbacks to reflect this change.
For example, you might be interested in one specific switch, but not every switch in the world:
Note that a worker is always considered to be interested in the components of the entities it is authoritative on, in addition to any others specificed in the bridge settings or using the method described above.
Sending and receiving component commands
To send a command to be executed by the worker currently authoritative over a particular component
for a particular entity, use the improbable.worker.Connection
method sendCommandRequest
.
This function takes a Class<C>
instance, an entity ID, a request object, an optional timeout
and an optional CommandParameters
object. The C
type needs to be a subclass of CommandMetaclass
defined in the schema-generated code, and the request object needs has to be of the same type as
defined in schema for given command C
. The CommandParameters
object contains a field called
AllowShortCircuiting
, which if set to true will try to “short-circuit” the command and avoid a
round trip to SpatialOS in some cases. See
documentation on commands for more information.
Before sending the command, a callback to handle the response should be registered with the
improbable.worker.Dispatcher
with onCommandResponse(Class<C>)
. The request ID (of type
improbable.worker.RequestId<OutgoingCommandRequest>
) returned by
sendCommandRequest
can be matched up with the one in the improbable.worker.Ops.CommandResponse
to
identify the request that is being responded to.
Note that commands may fail, so the improbable.worker.StatusCode
field in the
improbable.worker.Ops.CommandResponse
should be checked, and the command can be retried as necessary.
The caller will always get a response callback, but it can be one of several failure cases, including:
ApplicationError
(rejected by the target worker or by SpatialOS)AuthorityLost
(target worker lost authority, or no worker had authority)NotFound
(target entity, or target component on the entity, didn’t exist)PermissionDenied
(sending worker didn’t have permission to send request)Timeout
InternalError
(most likely indicates a bug in SpatialOS, should be reported)
To handle commands issued by another worker, the opposite flow is used. Register a callback with the
improbable.worker.Dispatcher
with onCommandRequest(Class<C>)
; when the callback is executed, the worker should
make sure to call the improbable.worker.Connection
method sendCommandResponse
, supplying the
request ID (of type improbable.worker.RequestId<IncomingCommandRequest>
) provided by the
improbable.worker.Ops.CommandRequest
and an appropriate response object, matching the response type
defined in the schema. Alternatively, the worker can call sendCommandFailure
to fail the command
instead.
Entity queries
Note: In order to send an entity query, a worker must have permission to do so. For more information, see the Worker permissions page.
A worker can run remote entity queries against the simulation by using the improbable.worker.Connection
method sendEntityQueryRequest
. This takes an improbable.worker.Query.EntityQuery
object and an optional timeout.
The query object is made of an improbable.worker.Query.Constraint
and an improbable.worker.Query.ResultType
.
The constraint determines which entities are matched by the query, and the result type determines what data is
returned for matched entities. Available constraints and result types are described below.
Constraint | Description |
---|---|
EntityIdConstraint |
Matches a specific entity ID. |
ComponentConstraint |
Matches entities with a particular component. |
SphereConstraint |
Matches entities contained in the given sphere. |
AndConstraint |
Matches entities that match all of the given subconstraints. |
OrConstraint |
Matches entities that match any of the given subconstraints. |
NotConstraint |
Matches entities that do not match the given subconstraint. |
Result type | Description |
---|---|
CountResultType |
Returns the number of entities that matched the query. |
SnapshotResultType |
Returns a snapshot of component data for entities that matched the query. To select all components, use new SnapshotResultType() . To select every component whose ID is contained in the given set, use new SnapshotResultType(componentIdSet) (thus, pass an empty set to get no components but entity IDs only). |
Important: You should keep entity queries as limited as possible. All queries hit the network and cause a runtime lookup, which is expensive even in the best cases. This means you should:
- always limit queries to a specific sphere of the world
- only return the information you need from queries (eg the specific components you care about)
- if you’re looking for entities that are within your worker’s checkout radius, search internally on the worker instead of using a query
Like other request methods, this returns an improbable.worker.RequestId
request ID, which can be
used to match a request with its response. The response is received via a callback registered with the
improbable.worker.Dispatcher
using the onEntityQueryResponse
method.
The Ops.EntityQueryResponse
contains an int resultCount
field (for CountResultType
requests)
and a Map<EntityId, Entity>
(for SnapshotResultType
) requests. Again, success or failure of the
request is indicated by the statusCode
field of the response object, but in the failure case the
result may still contain some data: the count or snapshot map might still contain the data for
some entities that matched the query, but won’t necessarily contain all matching entities. This is
because the worker might still be able to do something useful with a partial result.
Creating and deleting entities
Note: In order to create or delete an entity, a worker must have permission to do so. For more information, see the Worker permissions page.
A worker can request SpatialOS to reserve one entity ID, reserve a range of entity IDs, create an entity,
or delete an entity, by using the improbable.worker.Connection
methods SendReserveEntityIdRequest
,
SendReserveEntityIdsRequest
, SendCreateEntityRequest
, and SendDeleteEntityRequest
, respectively.
These methods return an improbable.worker.RequestId<C>
request ID, which can be used to match a request
with its response. The type parameter C
depends on the type of request being sent.
The response is received via a callback registered with the
improbable.worker.Dispatcher
using onReserveEntityIdResponse
, OnReserveEntityIdsResponse
,
onCreateEntityResponse
, and onDeleteEntityResponse
, respectively.
Note that these operations may fail, so the statusCode
field in the worker.Ops.ReserveEntityIdResponse
,
worker.Ops.ReserveEntityIdsResponse
, worker.Ops.CreateEntityResponse
, or worker.Ops.DeleteEntityResponse
argument of the respective callback should be checked, and the command can be retried as necessary. The caller
will always get a response callback, but it might be due to a timeout.
sendReserveEntityIdRequest
takes an optional timeout. If the operation succeeds, the response contains
an entity ID, which is guaranteed to be unused in the current deployment.
SendReserveEntityIdsRequest
takes an optional timeout. If the operation succeeds, the response contains
an entity ID, which is the first in a contiguous range guaranteed to be unused in the current deployment,
and the corresponding number of entity IDs in the reserved range. If the operation fails, the entity ID
is not set and the number of entity IDs in the reserved range is 0.
sendCreateEntityRequest
takes a improbable.worker.Entity
representing the initial state of the entity,
an optional entity ID (which, if provided, must have been obtained by a previous call to sendReserveEntityIdRequest
),
and an optional timeout. If the operation succeeds, the response contains the ID of the newly created entity.
sendDeleteEntityRequest
takes an entity ID and an optional timeout. The response contains no
additional data.
Example
Here’s an example of reserving an entity ID, creating an entity with that ID and some initial state, and finally deleting it:
private static void createDeleteEntity(Dispatcher dispatcher, Connection connection) {
final String entityType = "Player";
final improbable.collections.Option<Integer> timeoutMillis = improbable.collections.Option.of(500);
class RequestIds {
private RequestId<CreateEntityRequest> entityCreationRequest;
private RequestId<DeleteEntityRequest> entityDeletionRequest;
}
final RequestIds requestIds = new RequestIds();
// Reserve an entity ID.
RequestId<ReserveEntityIdRequest> entityIdReservationRequestId =
connection.sendReserveEntityIdRequest(timeoutMillis);
// When the reservation succeeds, create an entity with the reserved ID.
dispatcher.onReserveEntityIdResponse(op -> {
if (op.requestId.equals(entityIdReservationRequestId) && op.statusCode == StatusCode.SUCCESS) {
Entity entity = new Entity();
entity.add(improbable.Metadata.COMPONENT, new improbable.MetadataData(entityType));
// Empty ACL - should be customised.
entity.add(improbable.EntityAcl.COMPONENT, new improbable.EntityAclData(new improbable.WorkerRequirementSet(new java.util.ArrayList<>()), new java.util.HashMap<>()));
// Needed for the entity to be persisted in snapshots.
entity.add(improbable.Persistence.COMPONENT, new improbable.PersistenceData());
entity.add(improbable.Position.COMPONENT, new improbable.PositionData(new improbable.Coordinates(1, 2, 3)));
requestIds.entityCreationRequest =
connection.sendCreateEntityRequest(entity, op.entityId, timeoutMillis);
}
});
// When the creation succeeds, delete the entity.
dispatcher.onCreateEntityResponse(op -> {
if (op.requestId.equals(requestIds.entityCreationRequest) && op.statusCode == StatusCode.SUCCESS) {
requestIds.entityDeletionRequest = connection.sendDeleteEntityRequest(op.entityId.get(), timeoutMillis);
}
});
// When the deletion succeeds, we're done.
dispatcher.onDeleteEntityResponse(op -> {
if (op.requestId.equals(requestIds.entityDeletionRequest) && op.statusCode == StatusCode.SUCCESS) {
System.out.println("Successfully created and deleted entity.");
}
});
}
Entity ACLs
Entity ACLs are exposed to the worker as a component, and
can be manipulated as any other component. ACLs can be set at entity creation time, or modified
dynamically at runtime by the worker that has authority over the EntityAcl
component.
This example adds an EntityAcl
to an Entity
given a CommandRequestOp
, which is currently the only
way to make a specific worker (as opposed to, potentially, a set of workers) to qualify for authority of
a component. Specifically:
- The entity will be visible to workers that have the “client” or “physics” worker attribute.
- Any “physics” worker (i.e. a worker with “physics” as one of its attributes) can be authoritative over the
entity’s
Position
andEntityAcl
components. - The worker, which issued the
Ops.CommandRequest
, is the only worker that can be authoritative over thePlayerControls
component.
This can be used as part of creating a new entity in response to a command request.
public static void addComponentDelegations(Ops.CommandRequest<?, ?> op, Entity entity) {
// This requirement set matches only the command caller, i.e. the worker that issued the command,
// since callerWorkerAttributes includes the caller's unique attribute.
improbable.WorkerAttributeSet callerWorkerAttributeSet = new improbable.WorkerAttributeSet(op.callerAttributeSet);
improbable.WorkerRequirementSet callerWorkerRequirementSet =
new improbable.WorkerRequirementSet(java.util.Collections.singletonList(callerWorkerAttributeSet));
// Worker attribute set of a physics worker
improbable.WorkerAttributeSet physicsWorkerAttributeSet =
new improbable.WorkerAttributeSet(java.util.Collections.singletonList("physics"));
// Worker attribute set of a client worker
improbable.WorkerAttributeSet clientWorkerAttributeSet =
new improbable.WorkerAttributeSet(java.util.Collections.singletonList("client"));
// This requirement set matches any worker with the attribute "physics".
improbable.WorkerRequirementSet physicsWorkerRequirementSet =
new improbable.WorkerRequirementSet(java.util.Collections.singletonList(physicsWorkerAttributeSet));
// This requirement set matches any worker with the attribute "client" or "physics".
java.util.List<improbable.WorkerAttributeSet> clientOrPhysicsAttributeSets = new java.util.LinkedList<>();
clientOrPhysicsAttributeSets.add(clientWorkerAttributeSet);
clientOrPhysicsAttributeSets.add(physicsWorkerAttributeSet);
improbable.WorkerRequirementSet clientOrPhysicsRequirementSet =
new improbable.WorkerRequirementSet(clientOrPhysicsAttributeSets);
// Give authority over Position and EntityAcl to any physics worker, and over PlayerControls to the caller worker.
java.util.Map<Integer, improbable.WorkerRequirementSet> componentAcl = new java.util.HashMap<>();
componentAcl.put(improbable.Position.COMPONENT_ID, physicsWorkerRequirementSet);
componentAcl.put(improbable.EntityAcl.COMPONENT_ID, physicsWorkerRequirementSet);
componentAcl.put(PlayerControls.COMPONENT_ID, callerWorkerRequirementSet);
entity.add(improbable.EntityAcl.COMPONENT, new improbable.EntityAclData(
/* read */ clientOrPhysicsRequirementSet, /* write */ componentAcl));
}
The worker authoritative over the EntityAcl
component can later decide to give the authority over
position (or any other component) to a different worker, e.g. the client. In order to do this, EntityAclData
needs to be modified in order to map Position.COMPONENT_ID
to callerWorkerRequirementSet
(created above).
This change can be made using the method below:
public static improbable.EntityAclData delegateComponent(
improbable.EntityAclData currentAcl,
Integer componentId,
improbable.WorkerRequirementSet requirementSet) {
// Take a deep copy, so that this does not modify the current EntityAcl.
improbable.EntityAclData newAcl = currentAcl.deepCopy();
// Set the write ACL for the specified component to the specified attribute set.
newAcl.getComponentWriteAcl().put(componentId, requirementSet);
return newAcl;
}
For these changes to take effect, the worker authoritative over the EntityAcl
component needs send the changes
through a component update.
Please read Worker attributes and worker requirement sets for more information.
Generated code
The Java schema-generated code consists of two main parts: data classes and component classes. Data
classes are used to represent data at rest and correspond to the schemalang type
definitions;
component classes correspond to schemalang component
definitions and contain metadata and classes
for sending and receiving component data (updates, command requests and command responses).
A data class will be generated for each type defined in the schema, with fields corresponding to each field in the schema definition.
For each component, we generate:
- a metaclass implementing
improbable.worker.ComponentMetaclass
. These metaclasses are used when referring to specific components when using the API - every generic type parameterC
expects a metaclass argument. - an
Update
class nested inside the metaclass. This has an optional field for each field in the component (since it represents a diff). - command
Request
andResponse
classes nested inside the metaclass. These have an optional field for each command defined by the component (using the request type and response type of the command respectively); however, only one field can be set at a time. Note that the behaviour of responding to a command request using the response field for a different command is undefined.
Data representation
- Strings are represented as UTF-8 encoded
String
members. Make sure to use this same encoding when updating a component. list<T>
fields are represented as ajava.util.List<T>
of the repeated type.map<Key, Value>
fields are represented asjava.util.Map<Key, Value>
.option<T>
fields are represented asimprobable.collections.Option<T>
.
Example
Consider the following simple schema (in package example;
):
type StatusEffect {
string name = 1;
int32 multiplier = 2;
}
component Creature {
id = 12345;
int32 health = 1;
list<StatusEffect> effects = 2;
}
The generated code will contain the Creature
ComponentMetaclass
and the Creature.Update
class. Moreover, there will be a StatusEffect
class
representing the StatusEffect
type,
and CreatureData
type for the (auto-generated) underlying type of the component.
Here are some ways that these classes can be used with the API:
public static void generatedCodeExamples(Connection connection) {
try (Dispatcher dispatcher = new Dispatcher()) {
dispatcher.onAuthorityChange(Creature.COMPONENT, op -> {
switch (op.authority) {
case AUTHORITATIVE:
// We were granted authority over the status component. Send an update.
Creature.Update update = new Creature.Update();
update.setHealth(10);
connection.sendComponentUpdate(Creature.COMPONENT, op.entityId, update);
break;
case AUTHORITY_LOSS_IMMINENT:
// Authority loss imminent.
break;
case NOT_AUTHORITATIVE:
// Authority was revoked.
break;
}
});
dispatcher.onComponentUpdate(Creature.COMPONENT, op -> {
// Again, use the extension method Get() to get the concrete type of update.
Creature.Update update = op.update;
if (update.getEffects().isPresent()) {
// The `effects` field was updated.
}
});
} catch (java.io.IOException e) {
e.printStackTrace();
}
}
Snapshots
The SDK provides two classes to manipulate snapshots stored in files; Improbable.worker.SnapshotInputStream
to read snapshot files from disk, one entity at a time, and Improbable.worker.SnapshotOutputStream
to
write snapshot files to disk, one entity at a time. These stream classes are the recommended methods of
manipulating snapshots as they do not require the entire snapshot to be stored in memory when reading
or writing a snapshot.
Improbable.worker.SnapshotInputStream
has a constructor and three public methods:
/**
* Open a SnapshotInputStream to load a snapshot from a file.
*
* @param path The path to the snapshot file.
*/
public SnapshotInputStream(String path);
/**
* Returns true if the SnapshotInputStream has not reached the end of the snapshot file.
*
* @return A boolean which is true if the SnapshotInputStream has not reached the end of the snapshot and false otherwise.
*/
public boolean hasNext();
/**
* Load the next (EntityId, Entity) pair from the snapshot file. Throws a RuntimeException if an error occured.
*
* @return A Map.Entry<EntityId, Entity> containing the (EntityId, Entity) pair read from the snapshot file.
*/
public Map.Entry<EntityId, Entity> readEntity();
/**
* Close the SnapshotInputStream and release its resources.
*/
public void close();
Improbable.worker.SnapshotOutputStream
has a constructor and two public methods:
/**
* Open a SnapshotOutputStream to write a snapshot to a file saved at the String path.
*
* @param path The path to write the snapshot file to.
*/
public SnapshotOutputStream(String path);
/**
* Write the (EntityId, Entity) pair to the snapshot.
*
* @param entityId The EntityId of the Entity to be written to the snapshot.
* @param entity The Entity to be written to the snapshot.
*
* @return An Optional string which contains an error message if an error occured and is empty otherwise.
*/
public Option<String> writeEntity(EntityId entityId, Entity entity);
/**
* Close the SnapshotOutputStream.
*/
public void close();
Note that, unlike the rest of the API described in this document, snapshot manipulation does not require a Connection
,
making it possible to develop standalone, offline snapshot manipulation tools. However, we recommend using the build
infrastructure provided by SpatialOS for workers to build your standalone tools.
Here is an example of loading a snapshot, performing some manipulation on it, and saving it back. It uses the types and components defined in the example above.
private static void addLowHealthEffectToEntities(String snapshotFilename, String newSnapshotFilename) {
// Create a SnapshotInputStream to read from the snapshot file.
SnapshotInputStream inputStream = new SnapshotInputStream(snapshotFilename);
// Create a SnapshotOutputStream to write to a snapshot file.
SnapshotOutputStream outputStream = new SnapshotOutputStream(newSnapshotFilename);
java.util.Map.Entry<EntityId, Entity> entry;
// Iterate over each entity in the snapshot.
while (inputStream.hasNext()) {
// Read the next Map.Entry<Entity, Entity> from the stream.
entry = inputStream.readEntity();
EntityId entityId = entry.getKey();
Entity entity = entry.getValue();
// Add the "LowHealth" effect to all entities that have a Creature component and less than 10 health points.
if (entity.get(Creature.COMPONENT).isPresent()) {
CreatureData status = entity.get(Creature.COMPONENT).get();
if (status.getHealth() < 10) {
status.getEffects().add(new StatusEffect("LowHealth", 100));
}
}
// Write the (entityId, entity) pair to the snapshot file.
outputStream.writeEntity(entityId, entity);
}
// Write the end of the snapshot and release the SnapshotOutputStream's resources.
outputStream.close();
inputStream.close();
}
Using Dynamic
The improbable.worker.Dynamic
class provides a way to execute arbitrary component-related logic in a
type-safe way even when the component is not known statically. Dynamic.forComponent
will invoke a
custom handler for a component with a specific ID; Dynamic.forEachComponent
will invoke a custom
handler for every known component. These handlers have enough information to register Dispatcher
callbacks, manipulate component updates and data, and so on.
This allows, for example, a custom View with any desired semantics to be implemented from scratch.
To do this, you would extend Dispatcher, adding data structures such as Map<EntityId, Entity> entities
and
Map<EntityId, HashSet<Integer>> authority
below to track the entities in the worker’s view.
You would then also need to define a class which implements the Dynamic.Handler
interface, providing type-safe
behaviour in the accept(final Metaclass metaclass)
method.
Dynamic
allows you to implement, for example, a custom View
with any desired semantics from scratch.
package docs;
import improbable.worker.*;
public class CustomView extends Dispatcher {
// Maintain a Map of the entities in this worker's view.
public final java.util.Map<EntityId, Entity> entities = new java.util.LinkedHashMap<>();
public final java.util.Map<EntityId, java.util.Map<Integer, Authority>> authority = new java.util.LinkedHashMap<>();
public <Metaclass extends ComponentMetaclass>
Authority getAuthority(Metaclass metaclass, EntityId entityId) {
java.util.Map<Integer, Authority> componentAuthorities = authority.get(entityId);
if (componentAuthorities == null) {
return Authority.NOT_AUTHORITATIVE;
}
Authority authority = componentAuthorities.get(metaclass.getComponentId());
return authority != null ? authority : Authority.NOT_AUTHORITATIVE;
}
// An implementation of Dynamic.Handler to provide type-safe behaviour for
// the components.
private class TrackComponent implements Dynamic.Handler {
// The Accept method defines the type-safe behaviour.
// Here, we are specifying callbacks to update our view's state
// when components are added or removed, when the worker's authority changes,
// or when we receive component updates.
@Override
public <Metaclass extends ComponentMetaclass<Data, Update>, Data, Update>
void accept(final Metaclass metaclass) {
onAddComponent(metaclass, op -> {
Entity entity = entities.get(op.entityId);
if (entity != null) {
entity.add(metaclass, op.data);
}
java.util.Map<Integer, Authority> entityAuthority = authority.get(op.entityId);
if (entityAuthority != null) {
entityAuthority.put(metaclass.getComponentId(), Authority.NOT_AUTHORITATIVE);
}
});
onRemoveComponent(metaclass, op -> {
Entity entity = entities.get(op.entityId);
if (entity != null) {
entity.remove(metaclass);
}
java.util.Map<Integer, Authority> entityAuthority = authority.get(op.entityId);
if (entityAuthority != null) {
entityAuthority.remove(metaclass.getComponentId());
}
});
onAuthorityChange(metaclass, op -> {
java.util.Map<Integer, Authority> entityAuthority = authority.get(op.entityId);
if (entityAuthority != null) {
entityAuthority.put(metaclass.getComponentId(), op.authority);
}
});
onComponentUpdate(metaclass, op -> {
Entity entity = entities.get(op.entityId);
if (entity != null) {
entity.update(metaclass, op.update);
}
});
}
}
public CustomView() {
// Update the entities Map to reflect when entities are added or removed.
onAddEntity(op -> {
entities.put(op.entityId, new Entity());
authority.put(op.entityId, new java.util.HashMap<>());
});
onRemoveEntity(op -> {
entities.remove(op.entityId);
authority.remove(op.entityId);
});
// Invoke the TrackComponent handler for every component.
Dynamic.forEachComponent(new TrackComponent());
}
}