# Python Client

A Python port of the TypeScript `@vuer-ai/vuer-rtc` library. Multiple clients can concurrently edit a shared scene graph and all changes converge automatically.

## Install

```bash
pip install vuer-rtc
```

## Quick Start

```python
from vuer_rtc import create_graph

# Create a client store
store = create_graph("client-1", on_send=lambda msg: send_to_server(msg))

# Build a scene
store.edit({
    "ot": "node.insert",
    "key": "",            # parent key ("" = root)
    "path": "children",
    "value": {"key": "scene", "tag": "Scene", "name": "My Scene"},
})
store.edit({
    "ot": "node.insert",
    "key": "scene",
    "path": "children",
    "value": {"key": "cube", "tag": "Mesh", "position": [0, 1, 0], "opacity": 1.0},
})
store.commit("create scene")

# Edit properties
store.edit({"ot": "vector3.set", "key": "cube", "path": "position", "value": [3, 2, 1]})
store.edit({"ot": "number.set",  "key": "cube", "path": "opacity",  "value": 0.5})
store.commit("move and fade cube")

# Read state
graph = store.get_state().graph
print(graph.nodes["cube"].get_property("position"))  # [3, 2, 1]
```

## Two-Client Example

```python
from vuer_rtc import create_graph

messages_a, messages_b = [], []

store_a = create_graph("alice", on_send=lambda msg: messages_a.append(msg))
store_b = create_graph("bob",   on_send=lambda msg: messages_b.append(msg))

# Alice creates a node
store_a.edit({
    "ot": "node.insert",
    "key": "",
    "path": "children",
    "value": {"key": "obj", "tag": "Mesh", "health": 100},
})
msg = store_a.commit("create obj")

# Bob receives Alice's message
store_b.receive(msg)

# Both edit concurrently
store_a.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -25})
msg_a = store_a.commit("damage")

store_b.edit({"ot": "number.add", "key": "obj", "path": "health", "value": -10})
msg_b = store_b.commit("poison")

# Exchange messages
store_a.receive(msg_b)
store_b.receive(msg_a)

# Both converge: 100 + (-25) + (-10) = 65
assert store_a.get_state().graph.nodes["obj"].get_property("health") == 65
assert store_b.get_state().graph.nodes["obj"].get_property("health") == 65
```

## Operations

Every operation is a dict with `ot`, `key`, `path`, and typically `value`. The Python client supports all the same operations as the TypeScript client.

### Number

```python
{"ot": "number.set",      "key": "n", "path": "score",    "value": 42}     # LWW
{"ot": "number.add",      "key": "n", "path": "counter",  "value": 1}      # additive
{"ot": "number.multiply", "key": "n", "path": "scale",    "value": 2}      # multiplicative
{"ot": "number.min",      "key": "n", "path": "cooldown", "value": 5}      # min
{"ot": "number.max",      "key": "n", "path": "health",   "value": 0}      # max
```

### Vector3 / Quaternion / Euler

```python
{"ot": "vector3.set", "key": "n", "path": "position", "value": [1, 2, 3]}
{"ot": "vector3.add", "key": "n", "path": "velocity", "value": [0, 1, 0]}

{"ot": "quaternion.set",      "key": "n", "path": "rotation", "value": [0, 0, 0, 1]}
{"ot": "quaternion.multiply", "key": "n", "path": "rotation", "value": [0, 0.707, 0, 0.707]}

{"ot": "euler.set", "key": "n", "path": "rotation", "value": [0, 1.57, 0]}
{"ot": "euler.add", "key": "n", "path": "rotation", "value": [0, 0.1, 0]}
```

### String / Boolean / Color

```python
{"ot": "string.set",   "key": "n", "path": "name",    "value": "Cube"}
{"ot": "string.concat", "key": "n", "path": "log",     "value": " line2", "separator": "\n"}

{"ot": "boolean.set", "key": "n", "path": "visible", "value": True}
{"ot": "boolean.or",  "key": "n", "path": "dirty",   "value": True}
{"ot": "boolean.and", "key": "n", "path": "locked",  "value": False}

{"ot": "color.set",   "key": "n", "path": "color", "value": "#ff0000"}
{"ot": "color.blend",  "key": "n", "path": "color", "value": "#0000ff", "alpha": 0.5}
```

### Array / Object

```python
{"ot": "array.set",    "key": "n", "path": "items", "value": [1, 2, 3]}
{"ot": "array.push",   "key": "n", "path": "items", "value": 4}
{"ot": "array.remove",  "key": "n", "path": "items", "value": 2}
{"ot": "array.union",   "key": "n", "path": "tags",  "value": ["a", "b"]}

{"ot": "object.set",   "key": "n", "path": "config", "value": {"debug": True}}
{"ot": "object.merge",  "key": "n", "path": "config", "value": {"verbose": True}}
```

### Node (Scene Graph Structure)

```python
# Insert a child node
{"ot": "node.insert", "key": "parent", "path": "children",
 "value": {"key": "child", "tag": "Mesh", "name": "Child"}}

# Remove (soft-delete / tombstone)
{"ot": "node.remove", "key": "parent", "path": "children", "value": "child"}

# Move a node to a new parent
{"ot": "node.move", "key": "old-parent", "path": "children",
 "value": {"nodeKey": "child", "newParent": "new-parent"}}
```

### Text (Collaborative CRDT)

```python
# Initialize a text property
{"ot": "text.init",    "key": "doc", "path": "content"}

# Insert text at a position. `value` is an [anchor, content] tuple; use None for
# the anchor on a position-based local insert (the CRDT computes it).
{"ot": "text.insert",  "key": "doc", "path": "content", "position": 0, "value": [None, "Hello"]}

# Delete a range
{"ot": "text.delete",  "key": "doc", "path": "content", "position": 0, "length": 5}

# Atomic delete + insert (for select-and-type)
{"ot": "text.replace", "key": "doc", "path": "content", "position": 0, "length": 5, "value": [None, "Hi"]}
```

**Important:** Use `text.replace` instead of separate `text.delete` + `text.insert` when replacing a selection. The edit buffer deduplicates by `key:path`, so a delete followed by an insert on the same key and path will lose the delete.

## Undo / Redo

```python
store.edit({"ot": "number.set", "key": "n", "path": "x", "value": 10})
store.commit("set x")

store.undo()  # x reverts to previous value
store.redo()  # x = 10 again
```

Undo/redo is journal-based. Each `undo()` marks a journal entry as deleted and replays the remaining entries. This works correctly across concurrent edits from multiple clients.

## Edit Buffer

Edits are buffered until `commit()`. Additive operations on the same `key:path` are merged automatically:

```python
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [1, 0, 0]})
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 1, 0]})
store.edit({"ot": "vector3.add", "key": "n", "path": "pos", "value": [0, 0, 1]})

# Only one op in the buffer: value = [1, 1, 1]
assert len(store.get_state().edits.ops) == 1

store.commit("combined move")
```

Use `store.cancel()` to discard uncommitted edits and revert to the pre-edit graph.

## Receiving Remote Messages

```python
store.receive(msg)      # apply a CRDTMessage from the server
store.ack(msg_id)       # mark one of our messages as server-acknowledged
```

Duplicate messages are automatically ignored (idempotent).

## Retry and Compaction

```python
from vuer_rtc import get_unacked_messages

# Retry unacknowledged messages (e.g. after reconnect)
for msg in get_unacked_messages(store.get_state()):
    send_to_server(msg)

# Compact acknowledged journal entries into a snapshot
store.compact()
```

## API Reference

### GraphStore

| Method | Description |
|--------|-------------|
| `edit(op)` | Add operation to edit buffer (optimistic apply) |
| `commit(description?)` | Commit edits as a single `CRDTMessage` |
| `cancel()` | Discard uncommitted edits |
| `receive(msg)` | Process incoming remote `CRDTMessage` |
| `ack(msg_id)` | Mark a journal entry as server-acknowledged |
| `undo()` | Undo last committed message from this session |
| `redo()` | Redo last undone message from this session |
| `compact()` | Bake acknowledged entries into snapshot |
| `get_state()` | Return current `ClientState` |

### Pure Functions

For advanced use cases, the bare state-transition functions are also exported:

```python
from vuer_rtc import (
    create_initial_state,
    on_edit, commit_edits, cancel_edits,
    on_server_ack, on_remote_message,
    undo, redo, compact, rebuild_graph,
)
```

### Low-Level Operations

```python
from vuer_rtc import (
    apply_operation,    # Apply a single op to a SceneGraph (mutates in place)
    apply_message,      # Apply a CRDTMessage (returns new graph)
    apply_message_mut,  # Apply a CRDTMessage (mutates in place)
    apply_messages,     # Apply multiple messages
    create_empty_graph, # Create an empty SceneGraph
)
```

## Conflict Resolution

| Merge Strategy | Operation Types |
|---|---|
| **Last-Write-Wins** (LWW) | `*.set` — highest Lamport timestamp wins |
| **Additive** | `number.add`, `vector3.add`, `quaternion.multiply` — values accumulate |
| **Commutative** | `boolean.or`/`boolean.and`, `number.min`/`number.max`, `array.union` |
| **Deep merge** | `object.merge` — recursive per-key merge |
| **CRDT text** | `text.insert`/`text.delete`/`text.replace` — RGA/YATA algorithm |

All strategies are deterministic: given the same set of operations (in any order), every client converges to the same state.

## Running Tests

```bash
pip install -e ".[test]"
pytest tests/ -x -q
```

To skip slow benchmarks:

```bash
pytest tests/ -x -q -m "not slow"
```

## Lossless checkpoints (Python 0.0.2)

Keep the server snapshot's `textRopes` field when constructing `Snapshot`. Both `createGraph(initial_snapshot=...)` and `GraphStore.fromServer(...)` hydrate this metadata automatically; custom consumers can call `hydrateTextSnapshot(snapshot)`. The Python graph retains its existing `_textCrdt.<path>` rope plus visible string representation. TypeScript camelCase rope fields and deleted anchors are supported, and fresh local inserts use the local agent ID.

The raw MessagePack codec forwards additive `sync-check`, `sync-status`, and checkpoint metadata unchanged. Python does not automatically run a checksum monitor or manage held drafts. Compare note-body hashes only at equal committed clocks with no pending edits; legacy strings cannot recover discarded character identities.
