Skip to content

cluster.lic Documentation

Ryan P. McKinnon edited this page Nov 18, 2025 · 5 revisions

Cluster System Documentation

Table of Contents

  1. [Overview](#overview)
  2. [Setup](#setup)
  3. [Communication Patterns](#communication-patterns)
  4. [Contracts System](#contracts-system)
  5. [Registry (Key-Value Storage)](#registry-key-value-storage)
  6. [Complete Examples](#complete-examples)
  7. [Best Practices](#best-practices)

Overview

The Cluster system enables distributed communication between multiple Lich5 scripts running on different characters using Redis pub/sub. This allows characters to coordinate actions, share information, and collaborate on tasks.

Key Features

  • Broadcasts: Send messages to all connected characters
  • Casts: Send messages to a specific character
  • Requests: Send messages and wait for a response
  • Contracts: Auction-style system where characters bid to perform tasks
  • Registry: Shared key-value storage across characters

Setup

Installation

gem install redis connection_pool

Starting the Script

# Start the cluster script
;cluster

# With a remote Redis server
;cluster --url=redis://your-server:6379

# With debug logging
;cluster --debug

Creating Your Callbacks File

Create a file called cluster_callbacks.rb in your scripts/ directory. This file will be automatically loaded by the cluster script.

# scripts/cluster_callbacks.rb

# Your callbacks go here
Cluster.on_broadcast(:hello) do |_, req|
  echo "#{req.from} says hello!"
end

Communication Patterns

1. Broadcasts (One-to-Many)

Send a message to all connected characters (except yourself).

Sending a Broadcast

# Send a broadcast
Cluster.broadcast(:begin_hunt)

# With data
Cluster.broadcast(:command, val: '?')

Receiving a Broadcast

# In cluster_callbacks.rb
Cluster.on_broadcast(:begin_hunt) do |_, req|
  echo "Starting hunt routine from #{req.from}"
  Script.start('hunter')
end

Cluster.on_broadcast(:command) do |_, req|
  # Execute command on all characters
  do_client req.val
end

Use Cases:

  • Start/stop scripts on all characters
  • Announce events (hunt start, boss spawn, etc.)
  • Synchronized actions

2. Casts (One-to-One)

Send a message to a specific character.

Sending a Cast

# Send to specific character
Cluster.cast('John', channel: :command, val: '?')

# More complex example
Cluster.cast(Settings[:force_skinner], 
  channel: :skin_room,
  roomId: Room.current.id
)

Receiving a Cast

# In cluster_callbacks.rb
Cluster.on_cast(:command) do |_, req|
  do_client req.val
  echo "Executed command from #{req.from}"
end

Cluster.on_cast(:skin_room) do |_, req|
  # Only act if we're in the same room
  next unless Room.current.id.eql?(req.roomId)
  ELoot::Loot.skin()
end

Use Cases:

  • Direct commands to specific characters
  • Room-specific actions
  • Character-specific tasks

3. Requests (Request-Response)

Send a message and wait for a response.

Sending a Request

# Send request and wait for response (blocks)
response = Cluster.request('John', channel: :ready_to_move)

if response.ready
  echo "John is ready!"
else
  echo "John is not ready"
end

# With custom timeout (default is 5 seconds)
response = Cluster.request('John', 
  channel: :ready_to_move,
  timeout: 10
)

# Check for errors
if response.is_a?(Exception)
  echo "Request failed: #{response.message}"
end

Async Requests (Non-blocking)

# Start request in background
req_thread = Cluster.async_request('John', channel: :ready_to_move)

# Do other stuff...
do_something_else()

# Get the result when ready
response = req_thread.value

Map Requests (Multiple Characters)

# Request from multiple characters at once
characters = ['John', 'Paul', 'Ringo']
responses = Cluster.map(characters, channel: :ready_to_move)

responses.each do |resp|
  if resp.is_a?(Exception)
    echo "Failed: #{resp.message}"
  else
    echo "#{resp.from} ready: #{resp.ready}"
  end
end

Handling Requests

# In cluster_callbacks.rb
Cluster.on_request(:ready_to_move) do |_, req|
  {
    ready: checkrt.eql?(0) && standing?,
    health: percenthealth,
    mana: percentmana
  }
end

Cluster.on_request(:roundtime) do |_, req|
  {
    hard: checkrt,
    soft: checkcastrt
  }
end

Cluster.on_request(:stats) do |_, req|
  {
    name: Char.name,
    room: Room.current.id,
    exp: Exp.exp,
    mana: checkmana
  }
end

Use Cases:

  • Check character status before coordinated actions
  • Query information from other characters
  • Synchronization checks

Contracts System

The Contracts system is an auction mechanism where characters bid to perform tasks. The character with the highest bid wins the contract.

How Contracts Work

  1. A character opens a contract (announces a task)
  2. Valid bidders evaluate if they can/want to do the task
  3. Each bidder submits a bid (0.0 to 1.0, higher is better)
  4. The highest bidder wins and performs the task

Defining a Contract Handler

# In cluster_callbacks.rb
Contracts.on_contract(:poisoned, { 
  # Called when someone opens this contract
  contract_open: -> req {
    # Return -1 or don't bid if you can't/won't do it
    return -1 unless Spell[114].known?
    return -1 unless Spell[114].affordable?
    
    # Return a bid between 0.0 and 1.0
    # Higher mana = higher bid
    percentmana / 100.0
  },
  
  # Called if you win the contract
  contract_win: -> req {
    echo "Casting Unpoison on #{req.from}"
    Spell[114].cast(req.from)
  }
})

Opening a Contract

# Simple contract
Contracts.collect_bids(:poisoned, 
  valid_bidders: GameObj.pcs.map(&:noun)
)

# With minimum bid requirement
Contracts.collect_bids(:heal_me,
  valid_bidders: ['John', 'Paul', 'Ringo'],
  min_bid: 0.5
) do |contract|
  # This block runs if no valid bids received
  echo "No one could heal me :("
end

# With additional data
Contracts.collect_bids(:box,
  valid_bidders: GameObj.pcs.map(&:noun),
  box_id: target_box.id,
  difficulty: 'hard'
)

Contract Examples

Healing Contract

Contracts.on_contract(:heal, {
  contract_open: -> req {
    return -1 unless Spell[1101].known?  # Heal
    return -1 unless Spell[1101].affordable?
    return -1 if percentmana < 30
    
    # Bid based on mana available
    percentmana / 100.0
  },
  
  contract_win: -> req {
    target = Player[req.from]
    Spell[1101].cast(target)
  }
})

# To use it:
Contracts.collect_bids(:heal, 
  valid_bidders: room_pcs.map(&:noun)
)

Lockpicking Contract

Contracts.on_contract(:box, {
  contract_open: -> req {
    return -1 unless Skill["Picking Locks"].rank > 50
    return -1 if busy?
    
    # Bid based on skill and availability
    (Skill["Picking Locks"].rank / 300.0) * (1.0 - (checkrt / 10.0))
  },
  
  contract_win: -> req {
    box = GameObj[req.box_id]
    fput "open ##{box.id}"
  }
})

Buff Contract

Contracts.on_contract(:buff, {
  contract_open: -> req {
    spell_num = req.spell
    return -1 unless Spell[spell_num].known?
    return -1 unless Spell[spell_num].affordable?
    
    percentmana / 100.0
  },
  
  contract_win: -> req {
    target = Player[req.from]
    Spell[req.spell].cast(target)
  }
})

# To use it:
Contracts.collect_bids(:buff,
  valid_bidders: room_pcs.map(&:noun),
  spell: 1605  # Self Control
)

Contract Best Practices:

  • Always validate you can perform the task before bidding
  • Bid between 0.0 and 1.0 (higher = more likely to win)
  • Return -1 or don't return a bid if you can't/won't do the task
  • Use min_bid to ensure minimum capability
  • Include relevant data in the contract (target, item IDs, etc.)

Registry (Key-Value Storage)

The Registry provides shared key-value storage across all connected characters.

Basic Usage

# Create a registry (namespace defaults to character name)
reg = Cluster.registry

# Store data
reg.put('last_hunt_room', Room.current.id)
reg.put('party_leader', Char.name)
reg.put('loot_count', { boxes: 5, skins: 12 })

# Retrieve data
room_id = reg.get('last_hunt_room')
leader = reg.get('party_leader')

# Check if key exists
if reg.exists?('party_leader')
  echo "Party leader is #{reg.get('party_leader')}"
end

# Delete data
reg.delete('old_data')

Namespaced Registry

# Create a shared registry for all characters
party_reg = Cluster.registry('party')

# All characters can access this data
party_reg.put('meeting_room', 12345)
party_reg.put('hunt_target', 'trolls')

# On any character:
meeting = party_reg.get('meeting_room')

Registry Examples

Hunt Coordination

# Leader sets hunt info
hunt_reg = Cluster.registry('hunt')
hunt_reg.put('active', true)
hunt_reg.put('area', 'Trollfang')
hunt_reg.put('leader', Char.name)

# Followers check hunt status
if hunt_reg.get('active')
  area = hunt_reg.get('area')
  echo "Active hunt in #{area}"
end

Loot Tracking

loot_reg = Cluster.registry('loot')

# Track total loot
current = loot_reg.get('total_boxes') || 0
loot_reg.put('total_boxes', current + 1)

# Track per-character stats
my_stats = loot_reg.get("stats_#{Char.name}") || {boxes: 0, skins: 0}
my_stats[:boxes] += 1
loot_reg.put("stats_#{Char.name}", my_stats)

Complete Examples

Example 1: Coordinated Hunting

# In cluster_callbacks.rb

# Start hunt on all characters
Cluster.on_broadcast(:start_hunt) do |_, req|
  Script.start('hunting', [req.area])
end

# Stop hunt on all characters
Cluster.on_broadcast(:stop_hunt) do |_, req|
  Script.kill('hunting')
end

# Check if everyone is ready
Cluster.on_request(:hunt_ready) do |_, req|
  {
    ready: checkrt.eql?(0) && standing?,
    health: percenthealth,
    mana: percentmana,
    room: Room.current.id
  }
end

# Usage:
# Leader broadcasts start
Cluster.broadcast(:start_hunt, area: 'trolls')

# Leader checks readiness
party = ['John', 'Paul', 'Ringo']
statuses = Cluster.map(party, channel: :hunt_ready)

all_ready = statuses.all? { |s| !s.is_a?(Exception) && s.ready }
if all_ready
  echo "Everyone is ready!"
else
  echo "Waiting for party members..."
end

Example 2: Automated Healing System

# In cluster_callbacks.rb

# Request healing when needed
def request_heal
  return if percenthealth > 70
  
  healers = room_pcs.select { |name| 
    Cluster.connected.include?(name) && name != Char.name
  }
  
  Contracts.collect_bids(:emergency_heal,
    valid_bidders: healers,
    urgency: 100 - percenthealth
  ) do |contract|
    echo "No healers available!"
    # Fallback: use herb or retreat
  end
end

# Respond to healing requests
Contracts.on_contract(:emergency_heal, {
  contract_open: -> req {
    return -1 unless Spell[1101].known?
    return -1 unless Spell[1101].affordable?
    return -1 if percentmana < 20
    
    # Higher urgency + more mana = higher bid
    urgency_factor = req.urgency / 100.0
    mana_factor = percentmana / 100.0
    
    (urgency_factor * 0.7) + (mana_factor * 0.3)
  },
  
  contract_win: -> req {
    target = Player[req.from]
    unless target
      echo "Can't find #{req.from}!"
      return
    end
    
    echo "Healing #{req.from} (urgency: #{req.urgency})"
    Spell[1101].cast(target)
  }
})

Example 3: Box Pickup System

# In cluster_callbacks.rb

# Announce when box is ready
Cluster.on_cast(:box_ready) do |_, req|
  next unless Room.current.id.eql?(req.room_id)
  
  box = GameObj[req.box_id]
  if box
    fput "get ##{box.id}"
    echo "Picked up box from #{req.from}"
  end
end

# Request box pickup
def request_box_pickup(box)
  # Find available characters in room
  available = room_pcs.select { |name|
    Cluster.connected.include?(name) && name != Char.name
  }
  
  # Cast to first available
  if available.any?
    Cluster.cast(available.first,
      channel: :box_ready,
      box_id: box.id,
      room_id: Room.current.id
    )
  end
end

Example 4: Skinning Rotation

# In cluster_callbacks.rb

# Track who's turn it is to skin
@skin_rotation = []
@current_skinner = nil

Cluster.on_broadcast(:update_skinners) do |_, req|
  @skin_rotation = req.skinners
  @current_skinner = req.current
end

Cluster.on_cast(:skin_corpse) do |_, req|
  next unless Room.current.id.eql?(req.room_id)
  next unless Char.name.eql?(@current_skinner)
  
  corpse = GameObj[req.corpse_id]
  if corpse
    fput "skin ##{corpse.id}"
    
    # Rotate to next skinner
    next_skinner = @skin_rotation[(@skin_rotation.index(@current_skinner) + 1) % @skin_rotation.size]
    Cluster.broadcast(:update_skinners, 
      skinners: @skin_rotation,
      current: next_skinner
    )
  end
end

# Initialize rotation (run on leader)
skinners = Cluster.connected.select { |name| 
  # Check if they have skinning skill
  response = Cluster.request(name, channel: :has_skinning)
  !response.is_a?(Exception) && response.can_skin
}

Cluster.broadcast(:update_skinners,
  skinners: skinners,
  current: skinners.first
)

Best Practices

1. Connection Management

# Check who's connected before sending
if Cluster.connected.include?('John')
  Cluster.cast('John', channel: :command, val: 'inv')
end

# Get connected characters minus yourself
others = Cluster.connected - [Char.name]

# Get PCs in room who are also connected
connected_in_room = room_pcs & Cluster.connected

2. Error Handling

# Always check request responses
response = Cluster.request('John', channel: :stats)

if response.is_a?(Exception)
  echo "Request failed: #{response.message}"
elsif response.is_a?(TimeoutError)
  echo "Request timed out - is John connected?"
else
  echo "Stats: #{response.inspect}"
end

3. Defensive Programming

# Check prerequisites before acting
Cluster.on_cast(:buff_me) do |_, req|
  # Validate target exists
  target = Player[req.from]
  next unless target
  
  # Validate spell
  spell = Spell[req.spell_num]
  next unless spell && spell.known? && spell.affordable?
  
  # Validate we're in same room
  next unless target.room_id == Room.current.id
  
  spell.cast(target)
end

4. Using next vs return

# In callbacks, use 'next' to skip processing
Cluster.on_cast(:room_action) do |_, req|
  next unless Room.current.id.eql?(req.room_id)
  # Do action
end

# Use 'return' in contract bids
Contracts.on_contract(:task, {
  contract_open: -> req {
    return -1 unless can_do_task?
    calculate_bid()
  }
})

5. Timeouts

# Use appropriate timeouts for different operations
quick = Cluster.request('John', 
  channel: :simple_check, 
  timeout: 2
)

complex = Cluster.request('John',
  channel: :complex_task,
  timeout: 30
)

6. Data Validation

Cluster.on_cast(:teleport_to) do |_, req|
  # Validate required fields
  unless req.respond_to?(:target_room)
    echo "Missing target_room in request"
    next
  end
  
  room_id = req.target_room.to_i
  if room_id <= 0
    echo "Invalid room ID: #{req.target_room}"
    next
  end
  
  # Proceed with validated data
  Script.run('go2', room_id.to_s)
end

Debugging

Enable Debug Mode

# Start with debug logging
;cluster --debug

Check Connected Characters

# See who's connected
;e echo Cluster.connected.inspect

# Check if specific character is alive
;e echo Cluster.alive?('John')

Test Communication

# Test broadcast
;e Cluster.broadcast(:test, message: 'hello')

# Test cast
;e Cluster.cast('John', channel: :test, data: 123)

# Test request
;e p Cluster.request('John', channel: :test)

Common Patterns

Pattern: Room Coordination

# Only act if in the same room as sender
Cluster.on_cast(:room_action) do |_, req|
  next unless Room.current.id.eql?(req.room_id)
  perform_action()
end

Pattern: State Checking

# Check multiple states before acting
Cluster.on_request(:ready_check) do |_, req|
  {
    standing: standing?,
    rt_clear: checkrt.eql?(0),
    mana_ok: percentmana > 30,
    health_ok: percenthealth > 50
  }
end

Pattern: Resource Pooling

# Characters contribute resources to a pool
Contracts.on_contract(:need_mana, {
  contract_open: -> req {
    return -1 if percentmana < 50
    (percentmana - 50) / 50.0  # Bid based on excess mana
  },
  
  contract_win: -> req {
    target = Player[req.from]
    Spell[1711].cast(target)  # Mana Transfer
  }
})

Troubleshooting

Problem: Characters not seeing each other

  • Check Redis is running: redis-cli ping
  • Verify all characters using same Redis server
  • Check firewall settings

Problem: Requests timing out

  • Increase timeout parameter
  • Check target character has callback defined
  • Verify target is actually connected

Problem: Contract bids not working

  • Ensure contract_open returns a number (0.0-1.0) or -1
  • Check valid_bidders includes character name
  • Verify both callbacks (open and win) are defined

Problem: Registry data not persisting

  • Redis data is in-memory by default
  • Configure Redis persistence if needed
  • Check Redis memory limits

Additional Resources

Support

For issues or questions:

  1. Check if Redis is running: redis-cli ping
  2. Enable debug mode: ;cluster --debug
  3. Review callback definitions
  4. Test with simple examples first

Clone this wiki locally