# Dice Roller Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a d20 roller to the player HUD; results are broadcast to all connected peers; DM sees a DC input field in the Regiepult.

**Architecture:** Two new RPCs in `network_manager.gd` handle roll broadcast using the same server-relay pattern as overlay: player sends to server via `rpc_id(1)`, server re-broadcasts to all via `_relay_roll.rpc()`. Player-side UI lives in a CanvasLayer added dynamically by `tavern.gd`. DM-side DC display is added to `dm_view.tscn`/`dm_view.gd`. No new Autoload. Roll log is local per client (no sync needed beyond the broadcast).

**Tech Stack:** Godot 4.x GDScript, MultiplayerAPI RPC, CanvasLayer UI

---

## Context

- `NetworkManager.players` maps `peer_id → {name, role}` — used to look up roller name
- `NetworkManager.my_id` is the local peer ID
- `tavern.gd._ready()` already skips PlayerController for DM — dice UI must also skip for DM
- `dm_view.tscn` SidePanel has `OverlayScroll` — DC section goes below it
- Roll flow: Player rolls locally → `broadcast_roll.rpc_id(1, ...)` → server receives → `_relay_roll.rpc()` → all peers receive + emit `roll_received` signal → update local log
- In ENet star topology, clients cannot directly address all peers — all broadcasts must go through the server. Same pattern as `request_set_overlay` → `set_overlay`.

## Files

| File | Action |
|------|--------|
| `scripts/network_manager.gd` | Add `broadcast_roll` RPC (client→server) and `_relay_roll` RPC (server→all) |
| `scripts/tavern.gd` | Add dice CanvasLayer + roll logic |
| `scenes/dm_view.tscn` | Add DC section to SidePanel |
| `scripts/dm_view.gd` | Handle incoming rolls, update DC display |

---

## Task 1: Add broadcast_roll RPC to NetworkManager

**Files:**
- Modify: `scripts/network_manager.gd` — append one RPC

The roll is trusted from the client (cooperative game, not competitive). Uses the same server-relay pattern as overlays: client → server → server broadcasts to all.

- [ ] **Step 1: Add signal and two RPCs at end of network_manager.gd**

  Add the signal near the other signal declarations at the top of the file:
  ```gdscript
  signal roll_received(roller_peer_id: int, player_name: String, d20_result: int, modifier: int, total: int)
  ```

  Add two RPCs at the end of the file:
  ```gdscript
  # Client calls this on server only (rpc_id(1, ...))
  @rpc("any_peer", "call_remote", "reliable")
  func broadcast_roll(roller_peer_id: int, d20_result: int, modifier: int) -> void:
      if not multiplayer.is_server(): return
      _relay_roll.rpc(roller_peer_id, d20_result, modifier)

  # Server broadcasts to all (including itself via call_local)
  @rpc("authority", "call_local", "reliable")
  func _relay_roll(roller_peer_id: int, d20_result: int, modifier: int) -> void:
      var player_name: String = players.get(roller_peer_id, {}).get("name", "???")
      var total := d20_result + modifier
      roll_received.emit(roller_peer_id, player_name, d20_result, modifier, total)
  ```

- [ ] **Step 2: Open Godot, check for parse errors**

  Project → Reload. No errors in NetworkManager.

- [ ] **Step 3: Commit**

  ```bash
  git add ruf-der-pilze/scripts/network_manager.gd
  git commit -m "feat: network_manager — add broadcast_roll RPC + roll_received signal"
  ```

---

## Task 2: Add dice UI to tavern.gd (player side)

The dice panel is a CanvasLayer added programmatically in `tavern.gd`. DM clients skip this (they get a different view in dm_view.gd).

**Files:**
- Modify: `scripts/tavern.gd` — add `_setup_dice_ui()`, `_on_roll_pressed()`, `_on_roll_received()`

- [ ] **Step 1: Add _setup_dice_ui() to tavern.gd**

  Add this method. Call it from `_ready()`, after the player spawn block (only for players, not DM):

  ```gdscript
  var _modifier: int = 0
  var _roll_log: RichTextLabel

  func _setup_dice_ui() -> void:
      var canvas := CanvasLayer.new()
      canvas.name = "DiceUI"
      add_child(canvas)

      var panel := PanelContainer.new()
      panel.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT)
      panel.offset_top = -120
      panel.offset_right = 400
      canvas.add_child(panel)

      var vbox := VBoxContainer.new()
      panel.add_child(vbox)

      # Roll log
      _roll_log = RichTextLabel.new()
      _roll_log.custom_minimum_size = Vector2(380, 60)
      _roll_log.bbcode_enabled = true
      vbox.add_child(_roll_log)

      # Controls row
      var hbox := HBoxContainer.new()
      vbox.add_child(hbox)

      var roll_btn := Button.new()
      roll_btn.text = "d20 Würfeln"
      roll_btn.pressed.connect(_on_roll_pressed)
      hbox.add_child(roll_btn)

      var mod_label := Label.new()
      mod_label.text = "  Mod:"
      hbox.add_child(mod_label)

      # Declare mod_display BEFORE the lambdas that capture it
      var mod_display := Label.new()
      mod_display.text = "0"
      mod_display.custom_minimum_size = Vector2(30, 0)
      mod_display.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER

      var mod_minus := Button.new()
      mod_minus.text = "-"
      mod_minus.pressed.connect(func() -> void: _modifier -= 1; _update_mod_display(mod_display))
      hbox.add_child(mod_minus)

      hbox.add_child(mod_display)

      var mod_plus := Button.new()
      mod_plus.text = "+"
      mod_plus.pressed.connect(func() -> void: _modifier += 1; _update_mod_display(mod_display))
      hbox.add_child(mod_plus)

      NetworkManager.roll_received.connect(_on_roll_received)

  func _update_mod_display(label: Label) -> void:
      label.text = "%+d" % _modifier if _modifier != 0 else "0"

  func _on_roll_pressed() -> void:
      var d20 := randi() % 20 + 1
      NetworkManager.broadcast_roll.rpc_id(1, NetworkManager.my_id, d20, _modifier)

  func _on_roll_received(roller_peer_id: int, player_name: String, d20_result: int, modifier: int, total: int) -> void:
      var mod_str := " %+d" % modifier if modifier != 0 else ""
      var line := "[b]%s[/b] würfelt %d%s = [b]%d[/b]" % [player_name, d20_result, mod_str, total]
      if d20_result == 20:
          line = "[color=gold]" + line + " ★ NAT 20![/color]"
      elif d20_result == 1:
          line = "[color=red]" + line + " ☠ NAT 1[/color]"
      _roll_log.append_text(line + "\n")
  ```

- [ ] **Step 2: Call _setup_dice_ui() in _ready() for players only**

  In the existing `_ready()`, add at the very end — after the timer block. The DM already returns early at the top of `_ready()` (before `_spawn_player` is reached), so this call is DM-safe without any extra guard:

  ```gdscript
  _setup_dice_ui()
  ```

  **Important:** do not add this inside the `if role == "dm": return` block and do not add it before the timer. Place it as the last statement in `_ready()`.

- [ ] **Step 3: Open Godot, check for parse errors**

- [ ] **Step 4: Run as player client, click "d20 Würfeln"**

  Verify: roll result appears in log. With a second client connected, verify both see the roll.

- [ ] **Step 5: Commit**

  ```bash
  git add ruf-der-pilze/scripts/tavern.gd
  git commit -m "feat: tavern — add dice roller UI with roll log for players"
  ```

---

## Task 3: Add DC display to dm_view

DM needs to see incoming rolls and be able to set a DC per check to compare against.

**Files:**
- Modify: `scenes/dm_view.tscn` — add DCSection to SidePanel below OverlayScroll
- Modify: `scripts/dm_view.gd` — add `_setup_dc_section()`, `_on_roll_received()`

- [ ] **Step 1: Add DC section nodes to dm_view.tscn**

  In `dm_view.tscn`, under `RootLayout/TopSection/SidePanel`, add below `OverlayScroll`:

  ```
  HSeparator "SepDice"
  Label "LblDice" — text = "Würfelwürfe"
  HBoxContainer "DCRow"
    Label — text = "DC:"
    SpinBox "DCSpinBox" — min=1, max=30, value=12, step=1
  ScrollContainer "RollLogScroll" — size_flags_vertical=EXPAND_FILL
    RichTextLabel "RollLog" — bbcode_enabled=true, fit_content=true
  ```

- [ ] **Step 2: Add _setup_dc_section() to dm_view.gd**

  ```gdscript
  var _dm_roll_log: RichTextLabel

  func _setup_dc_section() -> void:
      _dm_roll_log = $RootLayout/TopSection/SidePanel/RollLogScroll/RollLog as RichTextLabel
      NetworkManager.roll_received.connect(_on_roll_received_dm)

  func _on_roll_received_dm(roller_peer_id: int, player_name: String, d20_result: int, modifier: int, total: int) -> void:
      var dc: int = ($RootLayout/TopSection/SidePanel/DCRow/DCSpinBox as SpinBox).value as int
      var mod_str := " %+d" % modifier if modifier != 0 else ""
      var success := total >= dc
      var result_str := "[color=green]Erfolg[/color]" if success else "[color=red]Fehlschlag[/color]"
      var line := "[b]%s[/b]: %d%s = [b]%d[/b] vs DC%d → %s" % [player_name, d20_result, mod_str, total, dc, result_str]
      if d20_result == 20:
          line += " [color=gold]★[/color]"
      elif d20_result == 1:
          line += " [color=red]☠[/color]"
      _dm_roll_log.append_text(line + "\n")
  ```

- [ ] **Step 3: Call _setup_dc_section() in dm_view.gd _ready()**

  Append to end of `_ready()`:
  ```gdscript
  _setup_dc_section()
  ```

- [ ] **Step 4: Run as DM client, have player roll**

  Verify: roll appears in DM panel with DC comparison. Set DC to 15 and verify success/failure label is correct.

- [ ] **Step 5: Commit**

  ```bash
  git add ruf-der-pilze/scenes/dm_view.tscn ruf-der-pilze/scripts/dm_view.gd
  git commit -m "feat: dm_view — add roll log + DC display for incoming player rolls"
  ```

---

## Task 4: Update STATUS.md

- [ ] **Step 1: Mark Schritt 8 ✅ in docs/STATUS.md and ruf-der-pilze/CLAUDE.md**

- [ ] **Step 2: Commit**

  ```bash
  git add docs/STATUS.md ruf-der-pilze/CLAUDE.md
  git commit -m "docs: mark Dice Roller complete"
  ```
