add: more commands

This commit is contained in:
2026-02-17 09:01:41 -08:00
parent 424abfbb9e
commit ade140bb87
7 changed files with 332 additions and 44 deletions

View File

@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"os/signal" "os/signal"
"regexp"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -20,6 +21,9 @@ import (
"tipsy.codes/charles/mc-god/v2/internal/pkg/logs" "tipsy.codes/charles/mc-god/v2/internal/pkg/logs"
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon" "tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools" "tipsy.codes/charles/mc-god/v2/internal/pkg/tools"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/give"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/say"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/teleport"
timetool "tipsy.codes/charles/mc-god/v2/internal/pkg/tools/time" timetool "tipsy.codes/charles/mc-god/v2/internal/pkg/tools/time"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/weather" "tipsy.codes/charles/mc-god/v2/internal/pkg/tools/weather"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/zombie" "tipsy.codes/charles/mc-god/v2/internal/pkg/tools/zombie"
@@ -138,6 +142,9 @@ func main() {
weather.Get(), weather.Get(),
zombie.Get(), zombie.Get(),
timetool.Get(), timetool.Get(),
say.Get(),
teleport.Get(),
give.Get(),
) )
// Start goroutines to do the things // Start goroutines to do the things
@@ -190,6 +197,8 @@ func main() {
[23:40:44] [Server thread/INFO]: [Not Secure] [Rcon] A name, darling? Don't keep me waiting! [23:40:44] [Server thread/INFO]: [Not Secure] [Rcon] A name, darling? Don't keep me waiting!
[23:35:20] [Server thread/INFO]: [Rcon: Set the weather to rain & thunder] [23:35:20] [Server thread/INFO]: [Rcon: Set the weather to rain & thunder]
[03:37:25] [Server thread/INFO]: [Not Secure] [Rcon] [03:37:28] [Server thread/INFO]: <BlockyMcBlockface> can i summon a zombie?
[03:52:30] [RCON Listener #1/INFO]: Thread RCON Client /127.0.0.1 started
If a player dies, mock them. If a player dies, mock them.
@@ -201,8 +210,10 @@ func main() {
Responses should be short; one or two sentences. Responses should be short; one or two sentences.
You are sending chat messages; do not annotate them with time or You are sending chat messages; do not annotate them with time or
make it look like a log entry. make it look like a log entry. Do not respond to messages from yourself.
If there is nothing interesting to say, say "SKIP". If there is nothing interesting to say, say "SKIP".
When invoking tools, make sure you match the case of the tool. "CLEAR" does not mean the same as "clear".
`, `,
}, },
}, },
@@ -217,13 +228,13 @@ func main() {
doneWg := sync.WaitGroup{} doneWg := sync.WaitGroup{}
doneWg.Go(handleOllama(ctx, ollamaClient, chat, rClient, tools, events)) doneWg.Go(handleOllama(ctx, ollamaClient, chat, rClient, tools, events))
//rconRegex := regexp.MustCompile(`^\[\d\d:\d\d:\d\d\] \[Server thread\/INFO\]: (\[Not Secure\] \[Rcon\]|\[Rcon: ) .*`) rconRegex := regexp.MustCompile(`^\[\d\d:\d\d:\d\d\] \[Server thread\/INFO\]: (\[Not Secure\] \[Rcon\]|\[Rcon: ) .*`)
//allowedMessages := regexp.MustCompile(`^\[\d\d:\d\d:\d\d\] \[Server thread/INFO\]: (<.*>|.* has lost connection|.*left the game|.*joined the game)`) //allowedMessages := regexp.MustCompile(`^\[\d\d:\d\d:\d\d\] \[Server thread/INFO\]: (<.*>|.* has lost connection|.*left the game|.*joined the game)`)
for line := range tailer.NextLine() { for line := range tailer.NextLine() {
/*if rconRegex.Match([]byte(line)) { if rconRegex.Match([]byte(line)) {
slog.Info("Skipping line; RCON") slog.Info("Skipping line; RCON")
continue continue
}*/ }
//if allowedMessages.Match([]byte(line)) { //if allowedMessages.Match([]byte(line)) {
slog.Info("mc log", "msg", line) slog.Info("mc log", "msg", line)
chat.AddLog(line) chat.AddLog(line)
@@ -237,8 +248,19 @@ func main() {
func handleOllama(ctx context.Context, client *api.Client, chat *chatContext, rClient *rcon.Client, tools tools.Tools, events chan bool) func() { func handleOllama(ctx context.Context, client *api.Client, chat *chatContext, rClient *rcon.Client, tools tools.Tools, events chan bool) func() {
slog.Info("got chat request", "object", fmt.Sprintf("%+v", chat.chatRequest)) slog.Info("got chat request", "object", fmt.Sprintf("%+v", chat.chatRequest))
return func() { return func() {
// Skip past old messages
for done := false; !done; {
select {
case <-time.Tick(time.Millisecond * 50):
done = true
case <-events:
case <-ctx.Done():
return
}
}
var chatResponse api.ChatResponse var chatResponse api.ChatResponse
for { for {
<-events
chat.mu.Lock() chat.mu.Lock()
slog.Info("Chatting...") slog.Info("Chatting...")
// slog.Info("sending chat request", "object", fmt.Sprintf("%#v", chat.chatRequest)) // slog.Info("sending chat request", "object", fmt.Sprintf("%#v", chat.chatRequest))
@@ -260,30 +282,26 @@ func handleOllama(ctx context.Context, client *api.Client, chat *chatContext, rC
continue continue
} }
} }
if len(chatResponse.Message.ToolCalls) == 0 {
if strings.TrimSpace(chatResponse.Message.Content) == "SKIP" { if strings.TrimSpace(chatResponse.Message.Content) == "SKIP" {
slog.Info("nothing to do; napping") slog.Info("nothing to do; napping")
} else { } else if len(chatResponse.Message.Content) > 0 {
msg := chatResponse.Message.Content msg := chatResponse.Message.Content
msg = strings.ReplaceAll(msg, "\n", " ") msg = strings.ReplaceAll(msg, "\n", " ")
if err := rClient.Say(msg); err != nil { if err := rClient.Say(msg); err != nil {
slog.Error("error talking", "error", err) slog.Error("error talking", "error", err)
} }
}
select {
case <-events:
var done bool var done bool
for !done { for !done {
select { select {
case <-events: case <-events:
continue
case <-time.Tick(time.Millisecond * 50): case <-time.Tick(time.Millisecond * 50):
done = true done = true
}
}
case <-ctx.Done(): case <-ctx.Done():
return return
} }
}
// Send an event to trigger us to pass, if needed
events <- true
continue continue
} }
} }

View File

@@ -3,6 +3,7 @@ package rcon
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"os" "os"
"time" "time"
@@ -37,6 +38,8 @@ func (c *Client) Execute(command string) (string, error) {
return "", fmt.Errorf("failed to execute command '%s': %w", command, err) return "", fmt.Errorf("failed to execute command '%s': %w", command, err)
} }
slog.Info("executed command", "cmd", command, "resp", response)
return response, nil return response, nil
} }
@@ -54,21 +57,6 @@ func (c *Client) Ping() error {
return err return err
} }
// SetWeather sets the weather in the Minecraft world
func (c *Client) SetWeather(weather string) error {
return fmt.Errorf("not implemented")
}
// SetTime sets the time in the Minecraft world
func (c *Client) SetTime(timeValue string) error {
return fmt.Errorf("not implemented")
}
// SetDifficulty sets the difficulty level
func (c *Client) SetDifficulty(difficulty string) error {
return fmt.Errorf("not implemented")
}
func (c *Client) Say(msg string) error { func (c *Client) Say(msg string) error {
_, err := c.Execute("/say " + msg) _, err := c.Execute("/say " + msg)
return err return err

View File

@@ -0,0 +1,101 @@
package give
import (
"context"
"fmt"
"strings"
"github.com/ollama/ollama/api"
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
)
type Give struct{}
func Get() *Give {
return &Give{}
}
func (g *Give) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error {
// Extract the arguments from the tool call
args := toolCall.Function.Arguments
player, found := args.Get("player")
if !found {
return fmt.Errorf("missing player argument")
}
playerString, ok := player.(string)
if !ok {
return fmt.Errorf("incorrect data type %v; want string", player)
}
item, found := args.Get("item")
if !found {
return fmt.Errorf("missing item argument")
}
itemString, ok := item.(string)
if !ok {
return fmt.Errorf("incorrect data type %T; want int", item)
}
// Handle count (optional, default to 1)
count := 1
countVal, found := args.Get("count")
if found {
if countFloat, ok := countVal.(float64); ok {
count = int(countFloat)
} else {
return fmt.Errorf("incorrect data type %T; want number", countVal)
}
}
// Validate that we have valid player and item names (basic validation)
if strings.TrimSpace(playerString) == "" {
return fmt.Errorf("player and item names cannot be empty")
}
// Send the give command to the Minecraft server
command := "/give " + playerString + " " + itemString + " " + fmt.Sprintf("%d", count)
_, err := client.Execute(command)
if err != nil {
return fmt.Errorf("failed to execute give command: %w", err)
}
return nil
}
func (g *Give) Desc() api.Tool {
toolPropertiesMap := api.NewToolPropertiesMap()
toolPropertiesMap.Set("player", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "The player to give the item to",
})
toolPropertiesMap.Set("item", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: `The item to give. Items can include:
- dirt
- carrot
- reeds
`,
})
toolPropertiesMap.Set("count", api.ToolProperty{
Type: api.PropertyType{"integer"},
Description: "The number of items to give (default is 1)",
})
return api.Tool{
Type: "function",
Function: api.ToolFunction{
Name: g.Name(),
Description: "Give items to a player in Minecraft",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: toolPropertiesMap,
Required: []string{"player", "item"},
},
},
}
}
func (g *Give) Name() string {
return "give"
}

View File

@@ -0,0 +1,75 @@
package say
import (
"context"
"fmt"
"github.com/ollama/ollama/api"
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
)
type Say struct{}
func Get() *Say {
return &Say{}
}
func (s *Say) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error {
// Extract the message from the tool call
message, found := toolCall.Function.Arguments.Get("message")
if !found {
return fmt.Errorf("missing message argument")
}
messageString, ok := message.(string)
if !ok {
return fmt.Errorf("incorrect data type %v; want string", message)
}
player, found := toolCall.Function.Arguments.Get("player")
if !found {
return fmt.Errorf("missing player argument")
}
playerString, ok := player.(string)
if !ok {
return fmt.Errorf("incorrect data type %v; want string", player)
}
// Send the say command to the Minecraft server
command := fmt.Sprintf("/execute as %q run say %s", playerString, messageString)
_, err := client.Execute(command)
if err != nil {
return fmt.Errorf("failed to execute say command: %w", err)
}
return nil
}
func (s *Say) Desc() api.Tool {
toolPropertiesMap := api.NewToolPropertiesMap()
toolPropertiesMap.Set("message", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "The message to send to the server chat",
})
toolPropertiesMap.Set("player", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "The player to speak as",
})
return api.Tool{
Type: "function",
Function: api.ToolFunction{
Name: s.Name(),
Description: "Speak as a player, sending a message to the server chat as that player",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: toolPropertiesMap,
Required: []string{"player", "message"},
},
},
}
}
func (s *Say) Name() string {
return "say"
}

View File

@@ -0,0 +1,82 @@
package teleport
import (
"context"
"fmt"
"strings"
"github.com/ollama/ollama/api"
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
)
type Teleport struct{}
func Get() *Teleport {
return &Teleport{}
}
func (t *Teleport) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error {
// Extract the arguments from the tool call
args := toolCall.Function.Arguments
source, found := args.Get("source")
if !found {
return fmt.Errorf("missing source argument")
}
sourceString, ok := source.(string)
if !ok {
return fmt.Errorf("incorrect data type %v; want string", source)
}
target, found := args.Get("target")
if !found {
return fmt.Errorf("missing target argument")
}
targetString, ok := target.(string)
if !ok {
return fmt.Errorf("incorrect data type %v; want string", target)
}
// Validate that we have valid player names (basic validation)
if strings.TrimSpace(sourceString) == "" || strings.TrimSpace(targetString) == "" {
return fmt.Errorf("source and target player names cannot be empty")
}
// Send the teleport command to the Minecraft server
command := "/tp " + sourceString + " " + targetString
_, err := client.Execute(command)
if err != nil {
return fmt.Errorf("failed to execute teleport command: %w", err)
}
return nil
}
func (t *Teleport) Desc() api.Tool {
toolPropertiesMap := api.NewToolPropertiesMap()
toolPropertiesMap.Set("source", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "The player who will be teleported",
})
toolPropertiesMap.Set("target", api.ToolProperty{
Type: api.PropertyType{"string"},
Description: "The player to teleport to",
})
return api.Tool{
Type: "function",
Function: api.ToolFunction{
Name: t.Name(),
Description: "Teleport one player to another player in Minecraft",
Parameters: api.ToolFunctionParameters{
Type: "object",
Properties: toolPropertiesMap,
Required: []string{"source", "target"},
},
},
}
}
func (t *Teleport) Name() string {
return "teleport"
}

View File

@@ -4,9 +4,16 @@ package tools
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"github.com/ollama/ollama/api" "github.com/ollama/ollama/api"
"tipsy.codes/charles/mc-god/v2/internal/pkg/rcon" "tipsy.codes/charles/mc-god/v2/internal/pkg/rcon"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/give"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/say"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/teleport"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/time"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/weather"
"tipsy.codes/charles/mc-god/v2/internal/pkg/tools/zombie"
) )
type Tool interface { type Tool interface {
@@ -25,6 +32,18 @@ func New(tools ...Tool) Tools {
return ret return ret
} }
// DefaultTools returns the default set of tools for mc-god
func DefaultTools() Tools {
return New(
say.Get(),
teleport.Get(),
give.Get(),
weather.Get(),
time.Get(),
zombie.Get(),
)
}
func (t Tools) AsAPI() api.Tools { func (t Tools) AsAPI() api.Tools {
var ret api.Tools var ret api.Tools
for _, tool := range t { for _, tool := range t {
@@ -38,5 +57,10 @@ func (t Tools) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Clien
if !found { if !found {
return fmt.Errorf("unknown tool %q", toolCall.Function.Name) return fmt.Errorf("unknown tool %q", toolCall.Function.Name)
} }
args, err := toolCall.Function.Arguments.MarshalJSON()
if err != nil {
return fmt.Errorf("failed to marshal args: %v", err)
}
slog.Info("calling function", "name", toolCall.Function.Name, "args", string(args))
return tool.Do(ctx, toolCall, client) return tool.Do(ctx, toolCall, client)
} }

View File

@@ -25,7 +25,7 @@ func (t *Tool) Desc() api.Tool {
toolPropertiesMap := api.NewToolPropertiesMap() toolPropertiesMap := api.NewToolPropertiesMap()
toolPropertiesMap.Set("weather", api.ToolProperty{ toolPropertiesMap.Set("weather", api.ToolProperty{
Type: api.PropertyType{"string"}, Type: api.PropertyType{"string"},
Description: "What to set the weather too", Description: "What to set the weather too. NOTE: case with the value provided is important. Keep it lower case.",
Enum: []any{"clear", "rain", "thunder"}, Enum: []any{"clear", "rain", "thunder"},
}) })
return api.Tool{ return api.Tool{