Skip to content
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

add documentation for app defined events #57

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default async function (
blockHeader: BlockHeader,
randomnessGenerator: Prando,
dbConn: Pool
): Promise<SQLUpdate[]> {
): Promise<{ stateTransitions: SQLUpdate[], events: [] }> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While writing this I started to wonder if it would be worth to have it accept either {stateTransitions: SQLUpdate[], events: ...} or just SQLUpdate[] to keep the existing code working. We can dynamically check if the returned object is an object with the stateTransitions key, so we could branch on that on the engine's side. It depends on how much do we care about the breaking change and how common is the no-events case I guess.

console.log(inputData, 'parsing input data');
const user = inputData.userAddress.toLowerCase();

Expand All @@ -53,7 +53,7 @@ export default async function (
case 'createLobby':
// handle this input however you need (but needs to be deterministic)
default:
return [];
return { stateTransitions: [], events: [] };
}
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Events are defined by two components:
1. A set of `fields` which defines the event content. Fields are made up of
1. A `name` for a *positional argument*
2. A `type` defined with [typebox](https://github.com/sinclairzx81/typebox) to ensure JSON compatibility
3. A `indexed` boolean for whether or not events will be [indexable](https://en.wikipedia.org/wiki/Database_index) on this field
3. A `indexed` boolean for whether or not events will be [indexable](https://en.wikipedia.org/wiki/Database_index) on this field. One index per field will be created on the underlying table.

Here is an example of an event that tracks completing quests in a game. In this example, we create an `index` on the `playerId` so a client could get realtime updates whenever a user has completing a quest.

Expand All @@ -29,7 +29,20 @@ const QuestCompletionEvent = genEvent({
} as const);
```

*TODO*: the API to register these events with Paima Engine itself is still under development
# Register app events

In order for Paima Engine to be able to use the events, these need to be
exported through a module in the packaged files. This can be generated by having
an `events` package in the game's directory, where the result of
`generateAppEvents` is exported:

```ts
const eventDefinitions = [
QuestCompletionEvent,
] as const;

export const events = generateAppEvents(eventDefinitions);
```

# Listening to events

Expand Down Expand Up @@ -59,10 +72,183 @@ await unsubscribe();

# Posting new events

You can publish messages from your game's state machine at any time. You need to provide *all* fields (both those indexed and those that aren't). Paima Engine, under the hood, will take care of only sending these events to the clients that need them.
You can publish messages from your game's state machine at any time. You need to
provide *all* fields (both those indexed and those that aren't). This is done by
returning the new events as a part of the state transition function's result,
alongside the SQL queries that change the state. If all the changes to the
database state are applied correctly for a particular input, then Paima Engine
will take care of only sending these events to the clients that
need them.

```ts
import { PaimaEventManager } from '@paima/sdk/events';

await PaimaEventListener.Instance.sendMessage(QuestCompletionEvent, { questId: 5, playerId: 10 });
```

## Typed helpers

From Paima Engine's point of view, the type of the `stateTransitionFunction`
looks something like this.

```ts
async function stateTransitionFunction (
inputData: SubmittedChainData,
header: { blockHeight: number; timestamp: number },
randomnessGenerator: Prando,
dbConn: Pool
): Promise<{
stateTransitions: SQLUpdate[];
events: {
address: `0x${string}`;
data: {
name: string;
fields: { [fieldName:string]: any };
topic: string;
};
}[];
}>;
```

Since the event definitions are loaded at runtime, there is no way for it
to narrow the type of the events.

Then the most straightforward way of emitting events from the stf would be this:

```ts
return {
stateTransitions: [],
events: [
{
address: precompiles.foo,
data: {
name: QuestCompletion.name,
fields: {
questId: 5,
playerId: 10,
},
topic: toSignatureHash(QuestCompletion),
},
},
],
};
```

However, this doesn't leverage the typescript's type system at all, which makes
it error prone. Instead, the recommended approach is to use the typed helpers
provided in the SDK.

The main one is the `EventQueue` type, which can be used to statically guarantee
that the emitted events are part of the exported events. For example:

```ts
type Events = EventQueue<typeof eventDefinitions>;

async function stateTransitionFunction (
inputData: SubmittedChainData,
header: { blockHeight: number; timestamp: number },
randomnessGenerator: Prando,
dbConn: Pool
): Promise<{
stateTransitions: SQLUpdate[];
events: Events;
}>;
```

This prevents you from emitting events that are not part of the
`eventDefinitions` array.

The second helper is the `encodeEventForStf` function, which can be used to
rewrite the previous code like this:

```ts
return {
stateTransitions: [],
events: [
encodeEventForStf({
from: precompiles.foo,
topic: QuestCompletion,
data: {
questId: 5,
playerId: 10,
},
}),
],
};
```

The main reason to use this function is to ensure that the signature hash
matches the event type through encapsulation. The easiest way to make this
mistake would be when there are overloaded events.

For example, if there was another registered event with this definition:

```ts
const QuestCompletionEvent_v2 = genEvent({
name: 'QuestCompletion',
fields: [
{
name: 'playerId',
type: Type.Integer(),
indexed: true,
},
{
name: 'questId',
type: Type.Integer(),
},
{
name: 'points',
type: Type.Integer(),
},
],
} as const);
```

Then the following event will typecheck, but the topic will be incorrect, since
it has a different signature.

```ts
return {
stateTransitions: [],
events: [
{
address: precompiles.foo,
data: {
name: QuestCompletion.name,
fields: {
questId: 5,
playerId: 10,
points: 20
},
topic: toSignatureHash(QuestCompletion),
},
},
],
};
```

Using `encodeEventForStf` also has the secondary advantage of providing slightly
better error messages and editor support in the case of overloads, since once
the topic argument is fixed, the type of the data can be fully computed instead
of having to compare to the full union of possible values.

# Signature hash

A unique identifier is computed for each app defined event. This can be computed
with the `toSignatureHash` function.

```ts
const questCompletion = toSignatureHash(QuestCompletionEvent);
```

The way this works is that the signature is first encoded as text:

`QuestCompletion(integer,integer)`

And then hashed with keccak_256 to get the identifier:

`3e3198e308aafca217c68bc72b3adcc82aa03160ef5e9e7b97e1d4afa8f792d5`

This gets stored in the database on startup, and it's used to check that no
events are removed (or modified). Note that this doesn't take into account
whether the fields are indexed or not.
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const QuestCompletionEvent = genEvent({
- The content of the MQTT messages is `{ questId: number }`

Note that all events starts with a prefix depending on its origin (`TopicPrefix`):
- `app` for events defined by the user
- `app/{signatureHash}` for events defined by the user. The `signatureHash` is explained [here](./100-general-interface.md#signature-hash).
- `batcher` for events coming from the [batcher](../../200-direct-write/400-batched-mode.md)
- `node` for events that come from the Paima Engine node

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ export default async function (
blockHeader: BlockHeader,
randomnessGenerator: Prando,
dbConn: Pool
): Promise<SQLUpdate[]> {
): Promise<{ stateTransitions: SQLUpdate[], events: [] }> {

const input = parse(inputData.inputData);

Expand All @@ -147,7 +147,7 @@ export default async function (
));
// highlight-end

return commands;
return { stateTransitions: commands, events: [] };
}
}
...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default async function (
blockHeight: number,
randomnessGenerator: Prando,
dbConn: Pool
): Promise<SQLUpdate[]> {
): Promise<{ stateTransitions: SQLUpdate[], events: [] }> {
// highlight-start
/* use this user to identify the player instead of userAddress or realAddress */
const user = String(inputData.userId);
Expand Down
Loading