08 configuration items - OldStarchy/dnd GitHub Wiki
This CI defines the protocol for communication between the Host Instance and Member Instances of a Room via the Server.
GM's need to be able to create Rooms, which at this stage consist of an Initiative Table dataset and Unique Creature dataset. This data can be stored in LocalStorage on the GM's device for re-use across Rooms.
GM's can then host the room via a Server, which creates a shareable room code and unique membership tokens per connected instance.
Players use the room code to connect their devices which will then begin realtime sync with the GM.
The Room api will then provide players access to room information
- Updates to the Initiative Table dataset (realtime sync)
- Read/Write access to Unique Creature data (permission system TBD)
class RoomNotFoundError extends Error {}
class ConnectionFailedError extends Error {}
interface RoomMeta {
// TBC
name: string;
}
interface HostedRoom {
// TODO: consider mongodb-like (mongoose) layer around localstorage (or
// IndexedDB)
/**
* Creates a new room as a GM backed by LocalStorage
*/
static create(meta: RoomMeta): HostedRoom;
/**
* Rejoins an existing room via a membership token
*
* Throws: ConnectionFailedError if the serverApi is inaccessible
* Throws: RoomNotFoundError if the associated room no longer exists
*/
static reconnect(serverApi: ServerApi, token: MembershipToken): Promise<HostedRoom>;
readonly meta: DocumentApi<RoomMeta>;
/**
* Provides access to the database of creatures
*/
readonly creatures: CreatureCollection;
readonly members: MembersCollection;
// TODO: memos etc. (arbitrary things that people want to share with others)
// TODO: permissions for read/write meta and creatures
/**
* Connects to a Server to allow remote participants to join the Room via a
* server.
*
* A room can only be published to a server once at a time.
*
* ```
* const publication = await room.publish(new ServerApi(myServerUrl));
*
* console.log(publication.createShareUrl());
* ```
*/
publish(serverApi: ServerApi): Promise<RoomPublication>;
readonly hosts: ReadonlyMap<string, RoomPublication>;
/**
* Serves a similar role to publish, but instead allows a popout window
* to connect (for secondary monitors on the GM's device)
*/
connectPopout(port: MessagePort);
}
interface RoomPublication {
readonly token: MembershipToken;
readonly roomCode: string;
readonly serverApi: ServerApi;
createShareUrl(): URL;
/**
* Number of clients connected via this publication
*/
readonly clients: number;
/**
* Disconnect all clients and end the publication, invalidating the roomCode
* and all membership tokens.
*/
revoke(): Promise<void>;
}
interface JoinedRoom {
static join(serverApi: ServerApi, roomCode: string): Promise<JoinedRoom>;
/**
* Rejoins an existing room via a membership token
*
* Throws: ConnectionFailedError if the serverApi is inaccessible
* Throws: RoomNotFoundError if the associated room no longer exists
*/
static reconnect(serverApi: ServerApi, token: MembershipToken): Promise<JoinedRoom>;
static join(port: MessagePort): Promise<JoinedRoom>;
readonly meta: DocumentApi<RoomMeta>;
/**
* Provides access to the database of creatures
*/
readonly creatures: CreatureCollection;
readonly members: MembersCollection;
}
interface ServerApi {
constructor(hostAddress: string);
/**
* Creates a room, granting the GM role to the creator.
*
* The room can be connected to using the Membership Token and shared via the roomCode
*/
createRoom(): Promise<{ token: MembershipToken, roomCode: string }>;
/**
* Joins an existing room shared to a server via [host] with a room
* code.
*
* Throws: ConnectionFailedError if the serverApi is inaccessible
* Throws: RoomNotFoundError if the room code is invalid
*/
join(roomCode: string): Promise<{ token: MembershipToken }>;
connect(token: MembershipToken): Promise<WebSocket>;
}
interface Collection<T, TFilter> {
get(filter?: TFilter): Promise<DocumentApi<T>[]>;
getOne(filter: TFilter): Promise<DocumentApi<T> | null>;
create(creature: Creature): Promise<DocumentApi<T>>;
}
// TODO: consider "collection view" that wraps (adapter pattern) a collection
// with a permission layer per client.
interface CreatureCollection extends Collection<DocumentApi<T>> {
getByName(name: string): Promise<CreatureApi | null>;
}
interface DocumentApi<T> {
readonly data: BehaviorSubject<T>;
update(changeSet: ChangeSet<T>): Promise<void>;
delete(): Promise<void>;
subscribe(callback: (data: T) => void): () => void;
/**
* Convenience method for `useBehaviorSubject(this.data)`;
*/
use(): Readonly<T & {id: string}>;
}
interface CreatureApi extends DocumentApi<Creature> {}
interface MemberApi extends DocumentApi<Member> {
directMessageChannel: Collection<{message: string, time: Date}>;
}
// GM
const room = Room.createLocal();
await room.creatures.create({
name: 'Sybil Snow',
race: 'Human',
ac: 10,
debuffs: [
{ type: 'Poisoned', duration: 2 },
{ type: 'On Fire', },
]
});
const { roomCode } = room.host(new ServerApi(myBackendUrl));
console.log(roomCode);
//Player
const room = await Room.join(new ServerApi(myBackendUrl), roomCode);
localStorage.set('membershipToken', room.membershipToken);
const sybilApi = await room.creatures.getById('Sybil Snow');
if (sybilApi === null) {
throw new Error('No Sybil');
}
const sybil = sybilApi.data.value;
console.log(`${sybil.name}'s AC is ${sybil.ac}`);
sybilApi.data.pipe(skip(1), uniqueUntilChanged(s => s.ac)).subscribe((sybil) => {
console.log(`${sybil.name}'s AC is now ${sybil.ac}`);
});
sybilApi
.data
.pipe(
skip(1),
map(s => s.debuffs),
uniqueUntilChanged(),
)
.subscribe((debuffs) => {
console.log(JSON.stringify(debuffs));
});
await sybilApi.update({
debuffs: {
selected: [{
filter: 0,
merge: {
duration: { replace: 2 }
}
}]
}
});
Each connection is mediated by a transport.
There are 2 types of connections in the Sync API
- From Player to GM via Server
- From Secondary Screen to Main Screen
Multiple users can connect to a room via a Server, each one needs to be uniquely identifiable as each belongs to a player that needs access control and moderation.
The individual connections however are maintained by the Server, so information and metadata needs to be communicated between the GM Instance and the Server.
Thus the Player-GM connection is implemented by Player-Server and GM-Server connections. The collection of members connected this way is encapsulated by a Room.
The following Room manipulations are defined:
-
POST /room
Creates a Room on the server- Authentication: none
- Parameters: none
- Return: 200 The room was created
- Body:
{ roomCode: string; membershipToken: string; }
- Body:
-
POST /room/:roomCode/join
Joins a room- Authentication: none
- Parameters:
Path(roomCode)
- Return: 200 Room exists and was joined
- Body:
{ membershipToken: string; }
- Body:
- Return: 401 Membership Token not recognized
- Return: 403 Room is full
- Return: 404 Room does not exist
-
GET /room
Queries information about the Room associated with themembershipToken
- Authentication: Bearer token
membershipToken
- Parameters:
BasicAuth(membershipToken)
- Return: 200 Room exists
- Body:
{ id: string; gameMasterId: string; roomCode: string; members: { id: string; online: bool }[]; }
- Body:
- Return: 401 Membership Token not recognized
- Return: 404 Room does not exist
- Authentication: Bearer token
-
DELETE /room
Deletes the room associated with themembershipToken
- Authentication: Bearer token
membershipToken
belonging to the GM - Parameters:
BasicAuth(membershipToken)
- Return: 201 Room was deleted
- Return: 401 Membership Token not recognized
- Return: 403 Membership token does not belong to the GM
- Return: 404 Room not found
- Authentication: Bearer token
-
WEBSOCKET_UPGRADE /room/ws/:membershipToken
Connects to the room associated with themembershipToken
- Authentication: none
- Parameters:
Path(membershipToken)
- Return: WebSocket Room was found and connected
- Return: 401 Membership Token not recognized
In addition to being able to query the current state of a Room via the REST API, updates will be pushed to the GM over connected WebSockets.
Messages sent over WebSockets will be wrapped in an object and JSON encoded.
interface ServerToUserMessage<T in keyof RoomEvents> {
type: T;
data: AllEvents[T];
}
The following Room events are defined:
interface RoomEvents {
/**
* Triggered during a successful call to `ServerApi.join(roomCode)`
*/
'room.members.joined': { id: string; };
/**
* Reserved (unused)
*/
'room.members.left': { id: string; reason?: string; };
/**
* Reserved (unused)
*/
'room.members.kicked': { reason?: string };
/**
* Triggered when the first WebSocket from a user connects and when the last
* WebSocket from a user disconnects.
*/
'room.members.presence': { id: string; connected: boolean };
/**
* Sent to all connected sockets when the Room is deleted via the Rest API
*/
'room.deleted': void;
/**
* Triggered when a message from another user is sent to this user
*/
'user.message': { sender: string; data: unknown };
}
Note that a user ID is not necessarily the same as their membership token.
Obtaining information about the user is not part of the Server's role, and should be handled by the Player-GM pseudo connection. Such communication is handled by the 'user.message'
event.
Messages can be sent by users to one or more other users by sending a JSON serialized object.
interface UserToServerMessage {
/**
* The ID of the intended recipient, or undefined to broadcast
*/
to?: string;
/**
* Arbitrary data defined by the Sync API
*/
data: unknown;
}
For example, a user with id A
sends this message
Here the message type "dm" is just used as an example
{
"to": "B",
"data": {
"type": "dm",
"data": {
"parts": [{
"type": "plain",
"content": "Hi"
}]
}
}
}
will be received by user B
if they exist
{
"type": "user.message",
"data": {
"sender": "A",
"data": {
"type": "dm",
"data": {
"parts": [{
"type": "plain",
"content": "Hi"
}]
}
}
}
}
The Secondary Screen is a popout window the GM can display on a player-facing monitor when the App is operated via a PC. Therefor the data it can display is subject to the same restrictions of a player.
To (hopefully) simplify its integration in the Sync API it is treated as a player from a HostedRoom perspective.
Messages sent in either direction consist only of messages defined by the Sync API. Such messages are not defined by the Sync API Transport.
function applyChangeset<T>(item: Readonly<T>, changeSet: ChangeSet<T>): T;
type AtLeastOne<T> = {
[K in keyof T]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<keyof T, K>>>
}[keyof T];
type Replace<T> = { replace: T };
type Merge<T> = T extends Record<string, unknown>
? { merge: { [K in keyof T]?: ChangeSet<T[K]> } }
: never;
type ArrayChange<T> = T extends Array<infer U>
? (
| AtLeastOne<{
extend: U[];
selected: ({ filter: Filter<U> } & ChangeSet<U>)[];
remove: Filter<U>[];
reorder?: never;
}>
| { reorder: Filter<U>[] }
)
: never;
type Filter<T> =
| number
| (T extends { id: infer U }
? { id: U }
: never)
type ChangeSet<T> =
| undefined
| Replace<T>
| Merge<T>
| ArrayChange<T> & {replace?: undefined};