diff --git a/cmd/mcgod/main.go b/cmd/mcgod/main.go index 2c4ab05..32318ba 100644 --- a/cmd/mcgod/main.go +++ b/cmd/mcgod/main.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "os/signal" + "regexp" "strings" "sync" "syscall" @@ -20,6 +21,9 @@ import ( "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/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" "tipsy.codes/charles/mc-god/v2/internal/pkg/tools/weather" "tipsy.codes/charles/mc-god/v2/internal/pkg/tools/zombie" @@ -138,6 +142,9 @@ func main() { weather.Get(), zombie.Get(), timetool.Get(), + say.Get(), + teleport.Get(), + give.Get(), ) // 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: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]: 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. @@ -201,8 +210,10 @@ func main() { Responses should be short; one or two sentences. 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". + + 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.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)`) for line := range tailer.NextLine() { - /*if rconRegex.Match([]byte(line)) { - slog.Info("Skipping line; RCON") - continue - }*/ + if rconRegex.Match([]byte(line)) { + slog.Info("Skipping line; RCON") + continue + } //if allowedMessages.Match([]byte(line)) { slog.Info("mc log", "msg", 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() { slog.Info("got chat request", "object", fmt.Sprintf("%+v", chat.chatRequest)) 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 for { + <-events chat.mu.Lock() slog.Info("Chatting...") // 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 } } - if len(chatResponse.Message.ToolCalls) == 0 { - if strings.TrimSpace(chatResponse.Message.Content) == "SKIP" { - slog.Info("nothing to do; napping") - } else { - msg := chatResponse.Message.Content - msg = strings.ReplaceAll(msg, "\n", " ") - if err := rClient.Say(msg); err != nil { - slog.Error("error talking", "error", err) + if strings.TrimSpace(chatResponse.Message.Content) == "SKIP" { + slog.Info("nothing to do; napping") + } else if len(chatResponse.Message.Content) > 0 { + msg := chatResponse.Message.Content + msg = strings.ReplaceAll(msg, "\n", " ") + if err := rClient.Say(msg); err != nil { + slog.Error("error talking", "error", err) + } + var done bool + for !done { + select { + case <-events: + case <-time.Tick(time.Millisecond * 50): + done = true + case <-ctx.Done(): + return } } - select { - case <-events: - var done bool - for !done { - select { - case <-events: - continue - case <-time.Tick(time.Millisecond * 50): - done = true - } - } - case <-ctx.Done(): - return - } + // Send an event to trigger us to pass, if needed + events <- true continue } } diff --git a/internal/pkg/rcon/rcon.go b/internal/pkg/rcon/rcon.go index 5aae31d..9ff2f5d 100644 --- a/internal/pkg/rcon/rcon.go +++ b/internal/pkg/rcon/rcon.go @@ -3,6 +3,7 @@ package rcon import ( "context" "fmt" + "log/slog" "os" "time" @@ -37,6 +38,8 @@ func (c *Client) Execute(command string) (string, error) { return "", fmt.Errorf("failed to execute command '%s': %w", command, err) } + slog.Info("executed command", "cmd", command, "resp", response) + return response, nil } @@ -54,21 +57,6 @@ func (c *Client) Ping() error { 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 { _, err := c.Execute("/say " + msg) return err diff --git a/internal/pkg/tools/give/give.go b/internal/pkg/tools/give/give.go new file mode 100644 index 0000000..cb3db43 --- /dev/null +++ b/internal/pkg/tools/give/give.go @@ -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" +} diff --git a/internal/pkg/tools/say/say.go b/internal/pkg/tools/say/say.go new file mode 100644 index 0000000..1054a70 --- /dev/null +++ b/internal/pkg/tools/say/say.go @@ -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" +} diff --git a/internal/pkg/tools/teleport/teleport.go b/internal/pkg/tools/teleport/teleport.go new file mode 100644 index 0000000..fecfe59 --- /dev/null +++ b/internal/pkg/tools/teleport/teleport.go @@ -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" +} diff --git a/internal/pkg/tools/tools.go b/internal/pkg/tools/tools.go index 1fe90bb..e1e8738 100644 --- a/internal/pkg/tools/tools.go +++ b/internal/pkg/tools/tools.go @@ -4,9 +4,16 @@ package tools import ( "context" "fmt" + "log/slog" "github.com/ollama/ollama/api" "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 { @@ -25,6 +32,18 @@ func New(tools ...Tool) Tools { 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 { var ret api.Tools for _, tool := range t { @@ -38,5 +57,10 @@ func (t Tools) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Clien if !found { 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) } diff --git a/internal/pkg/tools/weather/weather.go b/internal/pkg/tools/weather/weather.go index 82052f4..8ebc154 100644 --- a/internal/pkg/tools/weather/weather.go +++ b/internal/pkg/tools/weather/weather.go @@ -25,7 +25,7 @@ func (t *Tool) Desc() api.Tool { toolPropertiesMap := api.NewToolPropertiesMap() toolPropertiesMap.Set("weather", api.ToolProperty{ 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"}, }) return api.Tool{