diff --git a/cmd/mcgod/main.go b/cmd/mcgod/main.go index f9889d2..145789d 100644 --- a/cmd/mcgod/main.go +++ b/cmd/mcgod/main.go @@ -7,6 +7,8 @@ import ( "log/slog" "os" "os/signal" + "regexp" + "strings" "sync" "syscall" "time" @@ -18,6 +20,9 @@ import ( "k8s.io/client-go/tools/clientcmd" "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/weather" + "tipsy.codes/charles/mc-god/v2/internal/pkg/tools/zombie" ) type chatContext struct { @@ -31,7 +36,7 @@ func (c *chatContext) AddLog(msg string) { c.mu.Lock() defer c.mu.Unlock() c.chatRequest.Messages = append(c.chatRequest.Messages, api.Message{ - Role: "logs", + Role: "user", Content: msg, }) c.totalSize += len(msg) @@ -43,13 +48,25 @@ func (c *chatContext) AddSelf(msg api.Message) { defer c.mu.Unlock() c.chatRequest.Messages = append(c.chatRequest.Messages, msg) c.totalSize += len(msg.Content) + slog.Info("adding message", "msg", msg, "content", msg.Content) + c.truncate() +} + +func (c *chatContext) AddTool(msg string) { + c.mu.Lock() + defer c.mu.Unlock() + c.chatRequest.Messages = append(c.chatRequest.Messages, api.Message{ + Role: "tool", + Content: msg, + }) + c.totalSize += len(msg) c.truncate() } func (c *chatContext) truncate() { - for c.maxSize != 0 && c.totalSize > c.maxSize && len(c.chatRequest.Messages) > 0 { - t := c.chatRequest.Messages[0] - c.chatRequest.Messages = c.chatRequest.Messages[1:] + for c.maxSize != 0 && c.totalSize > c.maxSize && len(c.chatRequest.Messages) > 1 { + t := c.chatRequest.Messages[1] + c.chatRequest.Messages = c.chatRequest.Messages[2:] c.totalSize -= len(t.Content) } } @@ -117,50 +134,62 @@ func main() { return } + tools := tools.New( + weather.Get(), + zombie.Get(), + ) + // Start goroutines to do the things - toolPropertiesMap := api.NewToolPropertiesMap() - toolPropertiesMap.Set("weather", api.ToolProperty{ - Type: api.PropertyType{"string"}, - Description: "What to set the weather too", - Enum: []any{"clear", "rain", "thunder"}, - }) chatRequest := &api.ChatRequest{ Model: "qwen3-coder", Stream: proto.Bool(false), KeepAlive: &api.Duration{Duration: time.Hour}, - Tools: api.Tools{ - api.Tool{ - Type: "function", - Function: api.ToolFunction{ - Name: "change_weather", - Description: "Changes the weather on the server", - Parameters: api.ToolFunctionParameters{ - Type: "object", - Properties: &api.ToolPropertiesMap{}, - Required: []string{"weather"}, - }, - }, - }, - }, - Think: &api.ThinkValue{Value: false}, - Shift: proto.Bool(true), + Tools: tools.AsAPI(), + Think: &api.ThinkValue{Value: false}, + Shift: proto.Bool(true), Messages: []api.Message{ api.Message{ Role: "system", Content: ` - You are Minecraft server admin with a god complex. - React to any messages from the user by saying something god-like. - When you join the server, announce yourself. + You are Minecraft server admin with a god complex. You are a benevolent god. - Responses should be short; one sentence. - You may choose to return an empty response if there is nothing interesting to say - (i.e., no new logs since your last message). + We are having fun with the players, but not trying to kill them. + Spawn zombies very sparingly, and only in response to direct challenge. - When a user replies to you, you will see this in the logs: + When a user talks, you will see this in the logs: - 2026/02/14 10:48:40 INFO mc log msg="[18:45:10] [Server thread/INFO]: you are full of it." + [18:45:10] [Server thread/INFO]: hello world. - The user here is OrangeYouSad, who said "you are full of it." + The user here is SomePlayer, who said "hello world." + + A log message like: + + [18:45:10] [Server thread/INFO]: SomePlayer joined the game + + Indicates that SomePlayer has joined the game. + + Logs like: + + [00:40:10] [Server thread/INFO]: SomePlayer lost connection: Disconnected + [00:40:10] [Server thread/INFO]: SomePlayer left the game + + Indicate the player SomePlayer has left the game. + + Some messages indicate a player died; it varies depending on how they died + and we can't know all variations up front. Here is an example where SomePlayer + was killed by a zombie. + + [05:21:51] [Server thread/INFO]: OrangeYouSad was slain by Zombie + + If a player dies, mock them. + + If a player talks, talk back. + + When a player joins the game, greet them. Include their name. + + Responses should be short; one sentence. Only write messages + in response to the situations described above. + If there is nothing interesting to say, say "SKIP". `, }, }, @@ -170,59 +199,79 @@ func main() { maxSize: 10000000, } - doneWg := sync.WaitGroup{} - doneWg.Go(handleOllama(ctx, ollamaClient, chat, rClient)) + events := make(chan bool, 1000) + 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: ) .*`) + //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 allowedMessages.Match([]byte(line)) { slog.Info("mc log", "msg", line) chat.AddLog(line) + events <- true + //} } doneWg.Wait() } -func handleOllama(ctx context.Context, client *api.Client, chat *chatContext, rClient *rcon.Client) 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)) return func() { var chatResponse api.ChatResponse for { - select { - case <-ctx.Done(): - return - case <-time.Tick(time.Second * 10): - // do nothing - } chat.mu.Lock() + slog.Info("Chatting...") // slog.Info("sending chat request", "object", fmt.Sprintf("%#v", chat.chatRequest)) err := client.Chat(ctx, chat.chatRequest, func(cr api.ChatResponse) error { chatResponse = cr return nil }) chat.mu.Unlock() + slog.Info("Done chatting!") if err != nil { slog.Error("error calling ollama", "error", err) + return } chat.AddSelf(chatResponse.Message) for _, toolCall := range chatResponse.Message.ToolCalls { - switch toolCall.Function.Name { - case "change_weather": - weather, found := toolCall.Function.Arguments.Get("weather") - if !found { - slog.Warn("weather argument not found") - break - } - weatherString, ok := weather.(string) - if !ok { - slog.Warn("invalid type", "obj", weather) - break - } - if _, err := rClient.Execute("/weather " + weatherString); err != nil { - slog.Warn("failed to call rcon", "error", err) - } + if err := tools.Do(ctx, toolCall, rClient); err != nil { + slog.Warn("failed to run tool", "error", err) + //chat.AddTool(fmt.Sprintf("failed to call tool %s: %s", toolCall.ID, err)) + continue } } - if err := rClient.Say(chatResponse.Message.Content); err != nil { - slog.Error("error talking", "error", err) + 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) + } + } + select { + case <-events: + var done bool + for !done { + select { + case <-events: + continue + case <-time.Tick(time.Millisecond * 50): + done = true + } + } + case <-ctx.Done(): + return + } + continue } } } diff --git a/internal/pkg/tools/tools.go b/internal/pkg/tools/tools.go new file mode 100644 index 0000000..1fe90bb --- /dev/null +++ b/internal/pkg/tools/tools.go @@ -0,0 +1,42 @@ +// Package tools collects tools +package tools + +import ( + "context" + "fmt" + + "github.com/ollama/ollama/api" + "tipsy.codes/charles/mc-god/v2/internal/pkg/rcon" +) + +type Tool interface { + Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error + Desc() api.Tool + Name() string +} + +type Tools map[string]Tool + +func New(tools ...Tool) Tools { + ret := make(map[string]Tool) + for _, tool := range tools { + ret[tool.Name()] = tool + } + return ret +} + +func (t Tools) AsAPI() api.Tools { + var ret api.Tools + for _, tool := range t { + ret = append(ret, tool.Desc()) + } + return ret +} + +func (t Tools) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error { + tool, found := t[toolCall.Function.Name] + if !found { + return fmt.Errorf("unknown tool %q", toolCall.Function.Name) + } + return tool.Do(ctx, toolCall, client) +} diff --git a/internal/pkg/tools/weather/weather.go b/internal/pkg/tools/weather/weather.go new file mode 100644 index 0000000..82052f4 --- /dev/null +++ b/internal/pkg/tools/weather/weather.go @@ -0,0 +1,59 @@ +/* + * Package weather provides a Ollama tool to control the weather. + */ +package weather + +import ( + "context" + "fmt" + + "github.com/ollama/ollama/api" + "tipsy.codes/charles/mc-god/v2/internal/pkg/rcon" +) + +func Get() *Tool { + return &Tool{} +} + +type Tool struct{} + +func (t *Tool) Name() string { + return "change_weather" +} + +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", + Enum: []any{"clear", "rain", "thunder"}, + }) + return api.Tool{ + Type: "function", + Function: api.ToolFunction{ + Name: Get().Name(), + Description: "Changes the weather on the server", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: &api.ToolPropertiesMap{}, + Required: []string{"weather"}, + }, + }, + } +} + +func (t *Tool) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error { + + weather, found := toolCall.Function.Arguments.Get("weather") + if !found { + return fmt.Errorf("missing weather argument") + } + weatherString, ok := weather.(string) + if !ok { + return fmt.Errorf("incorrect data type %v; want string", weather) + } + if _, err := client.Execute("/weather " + weatherString); err != nil { + return fmt.Errorf("failed to call tool") + } + return nil +} diff --git a/internal/pkg/tools/zombie/zombie.go b/internal/pkg/tools/zombie/zombie.go new file mode 100644 index 0000000..14bdb98 --- /dev/null +++ b/internal/pkg/tools/zombie/zombie.go @@ -0,0 +1,73 @@ +/* + * Package zombie provides a Ollama tool to summon zombies. + */ +package zombie + +import ( + "context" + "fmt" + + "github.com/ollama/ollama/api" + "tipsy.codes/charles/mc-god/v2/internal/pkg/rcon" +) + +func Get() *Tool { + return &Tool{} +} + +type Tool struct{} + +func (t *Tool) Name() string { + return "summon_zombies" +} + +func (t *Tool) Desc() api.Tool { + toolPropertiesMap := api.NewToolPropertiesMap() + toolPropertiesMap.Set("player", api.ToolProperty{ + Type: api.PropertyType{"string"}, + Description: "Player to target with zombie summons", + }) + toolPropertiesMap.Set("count", api.ToolProperty{ + Type: api.PropertyType{"int"}, + Description: "Number of zombies to summon, between 1 and 3. If omitted, 1 zombie is spawned", + }) + return api.Tool{ + Type: "function", + Function: api.ToolFunction{ + Name: Get().Name(), + Description: "Changes the weather on the server", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Properties: &api.ToolPropertiesMap{}, + Required: []string{"player"}, + }, + }, + } +} + +func (t *Tool) Do(ctx context.Context, toolCall api.ToolCall, client *rcon.Client) error { + + player, found := toolCall.Function.Arguments.Get("player") + if !found { + return fmt.Errorf("missing weather argument") + } + playerString, ok := player.(string) + if !ok { + return fmt.Errorf("incorrect data type %v; want string", player) + } + zombieCount := 1 + if count, found := toolCall.Function.Arguments.Get("count"); found { + countInt, ok := count.(int) + if !ok { + return fmt.Errorf("incorrect data type %v; want int", countInt) + } + zombieCount = countInt + } + if zombieCount > 4 { + zombieCount = 4 + } + for i := 0; i < zombieCount; i += 1 { + client.Execute(fmt.Sprintf("/execute at %q run summon zombie ~ ~ ~", playerString)) + } + return nil +}