Add a doc file partially covering Ocelot Desktop

See #115.
This commit is contained in:
Fingercomp 2023-12-30 19:50:10 +07:00
parent 47149fe825
commit a1660155a2
No known key found for this signature in database
GPG Key ID: BBC71CEE45D86E37
2 changed files with 298 additions and 0 deletions

294
doc/ocelot-desktop.md Normal file
View File

@ -0,0 +1,294 @@
# Things you need to know to poke around Ocelot Desktop
Ocelot Desktop is a desktop (duh) application offering a graphical interface to ocelot-brain.
Like ocelot-brain, Ocelot Desktop is written in Scala 2.13; and unlike ocelot-brain, it bears no resemblance to the code of OpenComputers.
Ocelot Desktop deals with rendering the interface, handling user input, laying out the UI widget hierarchy, dispatching events, playing sounds, and tying all of the above to ocelot-brain's state.
It's highly recommended to read the ocelot-brain primer first.
The library lays down a framework for Ocelot Desktop to build upon, and this document will frequently reference concepts mentioned in the ocelot-brain explainer.
Now, assuming you're all set, let's start with the last item from that giant list of responsibilities above: how Ocelot Desktop manages its (and ocelot-brain's) state.
## Nodes
ocelot-brain, being that abstracted away from reality, is able to treat everyone truly the same (as an `Entity`).
Ocelot Desktop is not as naive and idealistic, and so it introduces a separation between "tile entities", contained directly in a workspace, and things you put inside them.
The former is called **nodes** in Ocelot Desktop.
The naming should make sense, because in the interface these are the nodes you can drag around and connect together.
Yet at the same time it can be extremely confusing, since ocelot-brain *also* has nodes, and, to aggravate the pain, these things are linked together.
Network connections between tile entities in ocelot-brain are represented as connections between Ocelot Desktop nodes.
But they don't map 1-to-1 to each other: an Ocelot Desktop node can potentially house multiple network nodes (this is how we support "sided" blocks like relays and racks) or none at all (like a chest).
(The chest isn't even exposed to ocelot-brain, in fact.
We'll talk about that later.)
So, nodes.
Each extends the `Node` class, or, if they are backed by an Ocelot entity, `EntityNode`.
1. **Persistence** is achieved by `Node` via its `load` and `save` methods.
2. `Node` also defines **event listeners** to allow the node to move around and connect to other nodes.
3. Nodes have **context menus**.
4. The two lifecycle methods, `update` and `dispose`, are also defined in `Node`.
- `update` is called every frame in the rendering thread (which is not the same as the ocelot-brain update thread).
- `dispose` is after the node is removed from the workspace to allow cleaning up its resources.
The workspace is rendered in several passes by `WorkspaceView`.
One of the `draw` methods is called on every node during a single pass.
If your node doesn't need any special rendering, it suffices to override the `icon`.
### Ports
To allow nodes to connect to each other, they provide an array of **ports**.
Each port corresponds to a single ocelot-brain network node and can optionally be associated with a side.
Ocelot Desktop uses the port's side to choose its color during rendering.
By default, `EntityNode`s get a single port that maps to their entity's node.
If the returned array is empty, the node won't be able to connect to other nodes.
Ex.: chest.
Finally, sided inventories return multiple ports, and each port is bound to a specific side.
### Persistence
Nodes are serialized to NBT and restored from it by `WorkspaceView`.
When serializing, the node's class name is written to NBT.
During deserialization, the class name is used to discover the node class.
If it's a subclass of `EntityNode`, its entity is deserialized first and then passed to the constructor.
Otherwise the default constructor is used for instantiation.
The usual caveats apply.
- You **must not** rename a node's class nor move it around, or saves will break.
- That node will simply be removed from the workspace.
- `EntityNode`s **must** provide a unary constructor that accepts the entity they manage.
- Other `Node`s **must** provide a nullary (default) constructor.
- And you **must not** use Scala's optional arguments in these constructors, as Scala will compile them away, and the constructor won't be found via reflection.
## Items, slots, and inventories
Some nodes act as a container for other things.
To support this, Ocelot Desktop provides an inventory subsystem.
Here's the terminology we'll use throughout this section:
- An **inventory** is a collection of **inventory slots**.
- Each **slot** has an index and may contain an **item**.
- In the UI **slots** are rendered via **slot widgets**.
- When you left-click a **slot widget**, you'll see an **item chooser** menu.
- The **item chooser** menu shows a list of **item groups**.
- An **item group** contains **item factories** (possibly one).
- An **item factory** provides an icon, a name, and other attributes to the item chooser and then builds an **item** when its entry is chosen.
### Items
Everything that can be put into a slot implements the `Item` trait.
There are a couple other useful item traits that make sense to use:
the most common are `ComponentItem` for items that manage a brain component and `PersistableItem` for items that require persisted state.
All items are defined in the `ocelot.desktop.inventory.item` package, and you can use classes there as a reference.
The base trait only requires to implement the `factory` method.
To explain what factories are, it's easier to go with an example — like a CPU item.
It has a tier, an address, a name, and an icon.
We could define these properties in the `Item` subclass directly, and for the most part it would be fine.
However, we'd also like it to show up in the item chooser list.
We can't just put an `Item` there — remember, the item has an already-generated, specific address;
when selecting the CPU entry from the item chooser we'd rather generate a new one instead.
For this reason the item chooser deals with **item factories**.
In addition to the actual `build()` method, which constructs a new item instance, the factory also defines a number of attributes used to display its item chooser menu entry.
These attributes include an icon, a name, and a tier.
They are also present in the `Item` trait — for convenience the default implementation of `Item` delegates such calls to the factory.
Therefore usually you only need to define these attributes in the factory.
If an item manages a brain component, you should extend the `ComponentItem` trait.
On the type system level, it only requires you to define the `component` method.
But it imposes an additional requirement that the item have a unary constructor that accepts the component.
The reasoning is the same as for nodes: when loading from NBT, the item's class name is looked up, its brain component is deserialized, and the class is instantiated with that component.
#### Accessing the slot
The `slot` method returns a reference to the slot an item is currently in.
You can use this to remove the item from the slot or replace it with another one.
You'll usually need this when you're changing some aspects of the brain component that the brain does not expect to change while the item is in an inventory.
For instance, before you change the address, you should first remove the item from the inventory; then put it back once you're done.
A convenience method `reinserting` does exactly that.
See its uses in your IDE to get an idea when you'd want to reach for it.
#### Communication
It's rather straightforward to make an item handle some event from its host inventory, since the inventory knows the exact type of its contents — you just call the method on the item.
Communicating in the other direction, however, is more difficult: an item cannot make any assumptions about the inventory.
As a rule of thumb, you should avoid item-to-slot communication, but sometimes there's no other viable choice.
In such cases the item can send out a `Notification` to the slot it's in — see the `notifySlot` method.
The slot will pass the notification over to the slot's observers, which can act on them.
The dispatch happens synchronously.
To create a new notification, you should subclass `Item.Notification`.
Scala's `case class` and `case object` may be useful for that, as they admit pattern-matching.
#### Tooltips and menus
Two other `Item` methods, `fillTooltip` and `fillRmbMenu`, are called to add entries to the tooltip and the context menu, respectively.
Make sure to dispatch to the superclass when overriding them.
For consistency:
- call `super.fillTooltip()` **before** you add your own lines
- call `super.fillRmbMenu()` **after** adding your entries
Consult other `Item` implementations for examples.
### Inventories
Items are stored in inventories, so let talk about them next.
Ocelot Desktop has three kinds of inventory classes:
- a base `Inventory` trait
- a `PersistedInventory` trait
- and a `SyncedInventory`
Though probably you'll mostly be dealing with the latter, each of the successive trait builds on top of the preceding ones.
Let's go over them in order.
#### The base Inventory trait
An `Inventory` lays down the basic functionality of the inventory system.
Here you'll find the definitions of the `Inventory#Slot` and `Inventory.SlotObserver` classes as well as its accessor methods.
All access to individual elements of an inventory has to go through the `Slot` wrapper.
Calling `Slot(i)` returns a proxy to the ith slot in the inventory.
It provides methods to retrieve and modify its contents as well as register slot observers.
Changing the contents of an inventory triggers calls to handler methods.
In all cases the modification happens first, then the `Inventory`'s `onItemAdded` or `onItemRemoved` methods are called, followed by the corresponding calls to observers for the updated slot.
In particular, at the point `onItemRemoved` is called, the original item is no longer in the inventory.
Please avoid changing the contents of the inventory in these callbacks if you value your sanity and well-being.
Note that a slot only keeps weak references to its observers.
To avoid the observer getting garbage-collected, you must retain a storng reference to it for the lifetime of the observer.
You should still remove the observer once you no longer need it: weak references are only intended to prevent memory leaks.
#### PersistedInventory
This trait augments the `Inventory` with the ability to persist its contents.
It requires items implement the `PersistableItem` trait.
During serialization, the class name of each item is recorded in the NBT.
Later, to load the item, the name is looked up and its default constructor (with 0 parameters) is called.
Again, your items **must** have a constructor (not necessarily primary) that has exactly **zero** parameters — including optional.
For items extending `ComponentItem` the persistence works a bit differently.
In addition to the class name, when saving, the component is stored separately in the NBT, so you don't need to include it in your item's `load` implementation.
Loading then first constructs the component; once finished, the class is looked up and instantiated using a unary constructor accepting the component.
Note, however, that the parameter type doesn't need to be an exact match: its superclass will also work fine.
While loading, the inventory starts out empty and items are added one by one, triggering invocation of callbacks.
Make sure you don't rely on the partial state in your callbacks by mistaking it for complete:
for instance, getting the contents of a slot that has not yet been loaded will give you a `None` even though it had an item while saving.
#### SyncedInventory
A `PersistedInventory` is perfectly suitable for Desktop-only inventories such as chests.
However, the majority of inventories needs to deal with their brain counterparts: cases, servers, disk drives, etc.
Each of these entities implements the brain's `ComponentInventory` trait, which cannot store Desktop items.
In such case deriving from a `SyncedInventory` is more appropriate.
A `SyncedInventory` is a `PersistedInventory` that synchronizes its contents with a brain inventory.
![A schematic depicting the relationship between SyncedInventory and PersistedInventory](./ocelot-inv.svg)
The synchronization is bi-directional and is only performed for one slot at a time.
There are three modes of synchronization:
- desktop-to-brain
- brain-to-desktop
- reconciliation
When the Desktop inventory initiates an inventory change, **desktop-to-brain** synchronization is performed.
It attempts to copy the contents of the desktop slot to the brain inventory, possibly removing or replacing the entity there.
When the Desktop inventory receives a brain event notifying it of a brain inventory change, **brain-to-desktop** synchronization occurs.
Usually this happens in response to a Desktop-originated change;
however, there are rare cases where ocelot-brain itself initiates the inventory update (such as invoking the `eject()` method of a disk drive).
The brain-to-desktop synchronization propagates the change over to the Desktop side.
If the brain entity is removed, the Desktop's item is removed as well.
However, the code also handles the case where an entity appears in the brain inventory instead by **recovering** the entity's item.
See the section on recoverers below.
Though currently this case only occurs due to broken saves or Ocelot bugs.
Finally, the third mode, **reconciliation**, acts as a recovery mechanism and is performed after loading the inventory contents.
Its goal is to ensure a slot in the Desktop inventory and the corresponding slot in the brain inventory have the same contents.
If the contents diverge, it tries to preserve as much data as possible:
- when one of the slots is empty, desktop-to-brain or brain-to-desktop sync is done to fill the empty slot with the contents of the other
- when both slots are non-empty, desktop-to-brain sync is performed because items contain more data than entities (the entity is removed and replaced as a result)
The desktop-to-brain synchronization in either of those cases should never arise during normal operation.
The brain-to-desktop sync is primarily used to load old saves made before the inventory system was introduced to Ocelot Desktop.
The synchronization algorithm is iterative, and it runs until it reaches a fixpoint.
To demonstrate this, here's what happens when either of the inventories changes — say, a Desktop item is inserted.
```plantuml
autonumber
actor User
control SyncedInventory as Sync
control "brain Inventory" as Brain
User -> Sync: inserts an item
activate Sync
Sync -> Sync: updates the contents
Sync -> Sync: onItemAdded()
activate Sync
Sync -> Sync: sync(DesktopToBrain)
activate Sync
Sync -> Brain: inserts the item's entity
activate Brain
Brain -> Brain: updates the contents
Brain -> Brain: onEntityAdded()
activate Brain
Brain -> Brain: sends an InventoryEntityAddedEvent
activate Brain
Brain -> Sync: dispatches the event
activate Sync
Sync -> Sync: sync(BrainToDesktop)
activate Sync
Sync -> Sync: the slots are in sync, no-op
autonumber stop
return
return
return
return
return
return
return
return
```
As you can see on this diagram, `sync` is recursively called to handle the brain inventory update, even though the first sync was the one to trigger the update.
This is handled as follows: when the two items are in sync, calling the `sync` method does not change the state in any way, and the method is a no-op.
This allows the recursion to terminate.
More complicated cases arise if one round-trip is not enough for the state to converge, which happens if a callback triggered on a slot update changes the contents of the slot.
In general, this is a pathological scenario which you should avoid if at all possible.
The `sync` method will still work in this case as long as the contents converge to a synchronized state.
But to avoid endless recursion and crashes, it puts a limit on the number of calls to `sync` by tracking its "fuel".
The first call starts out with the maximum amount of fuel.
Each call decrements it until it reaches zero.
Once the first call finishes, the fuel is again reset to the maximum value.
If the amount of fuel reaches zero, `sync` assumes it is stuck in an infinite loop and breaks out of it by forcibly removing the contents of the conflicting slots.
It doesn't perform any synchronization during the removal, so the inventories may end up in an inconsistent state after that.
This mechanism is mainly intended to prevent Ocelot from crashing outright, but otherwise the code should not rely on it:
endless synchronization is a bug that has to be fixed.
### Recoverers
Whenever a `SyncedInventory` needs to recover an item from an entity, it does so by using a **recoverer**.
Its job is, given the entity, create an item that manages it.
Recoverers are provided by item factories: one for each source type (or possibly none).
They are added to the registry when the factory is registered there.
The recoverer is then chosen from the registry based on the source entity's runtime class.
While searching for the recoverer, it also checks the source entity's superclasses (including traits), stopping when it finds an entry for the most specific class.
(Ex.: since the `CPU` class extends the `GenericCPU` trait, a recoverer for the `GenericCPU`, should it be registered, will be used, provided no more specific one exists.)
### Registry and item groups
The item registry stores all item factories available in Ocelot Desktop.
The factories are organized into groups, which directly correspond to the item chooser's menu entries.
If you add a new item, you should register it so it shows in the list.
### Slot widgets
- [ ] TODO: different kinds of slot widgets
- [ ] TODO: item admissibility and item chooser's list filtering

4
doc/ocelot-inv.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB