Compare commits

...

2 Commits

Author SHA1 Message Date
d4daa02155 Fix: recover bot; llama.cpp fix 2025-06-15 11:44:39 +03:00
30e322d9c6 Feat: graceful shutdown 2025-06-15 09:51:25 +03:00
14 changed files with 264 additions and 54 deletions

View File

@ -1,5 +1,5 @@
{{define "actionhistory"}}
<div class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
<div id="actionHistoryContainer" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
Backlog:
{{range .}}
<div class="flex items-center justify-between p-2 rounded">
@ -14,4 +14,11 @@
</div>
{{end}}
</div>
<script>
// Scroll to the bottom of the action history container
const container = document.getElementById('actionHistoryContainer');
if (container) {
container.scrollTop = container.scrollHeight;
}
</script>
{{end}}

View File

@ -1,6 +1,9 @@
{{define "cardcounter"}}
<div class="flex justify-center">
<p>Blue cards left: {{.BlueCounter}}</p>
<p>Red cards left: {{.RedCounter}}</p>
<p>Blue cards left: {{.BlueCounter}} </p>
<p>Red cards left: {{.RedCounter}} </p>
<hr>
<p>Limit of cards to open: {{.ThisTurnLimit}} </p>
<p>Opened this turn: {{.OpenedThisTurn}} </p>
</div>
{{end}}

View File

@ -32,7 +32,6 @@
{{if .Room.IsRunning}}
{{template "cardcounter" .Room}}
{{end}}
<div id="addbot">
{{if and (eq .State.Username .Room.CreatorName) (not .Room.IsRunning)}}
{{template "addbot" .}}

View File

@ -70,10 +70,11 @@ func getStateByCtx(ctx context.Context) (*models.UserState, error) {
}
func saveFullInfo(fi *models.FullInfo) error {
// INFO: unfortunately working no transactions; so case are possible where first object is updated but the second is not
// INFO: no transactions; so case is possible where first object is updated but the second is not
if err := saveState(fi.State.Username, fi.State); err != nil {
return err
}
log.Debug("saved user state", "state", fi.State)
if err := saveRoom(fi.Room); err != nil {
return err
}
@ -82,6 +83,7 @@ func saveFullInfo(fi *models.FullInfo) error {
func notifyBotIfNeeded(fi *models.FullInfo) {
if botName := fi.Room.WhichBotToMove(); botName != "" {
log.Debug("got botname", "name", botName)
llmapi.SignalChanMap[botName] <- true
}
log.Debug("no bot", "room_id", fi.Room.ID)
@ -230,7 +232,7 @@ func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error)
}
// get all rooms
func listPublicRooms() []*models.Room {
func listRooms(allRooms bool) []*models.Room {
cacheMap := memcache.GetAll()
publicRooms := []*models.Room{}
// no way to know if room is public until unmarshal -_-;
@ -242,7 +244,7 @@ func listPublicRooms() []*models.Room {
continue
}
log.Debug("consider room for list", "room", room, "key", key)
if room.IsPublic {
if room.IsPublic || allRooms {
publicRooms = append(publicRooms, room)
}
}
@ -250,6 +252,24 @@ func listPublicRooms() []*models.Room {
return publicRooms
}
// get bots
func listBots() map[string]map[string]string {
cacheMap := memcache.GetAll()
resp := make(map[string]map[string]string)
// no way to know if room is public until unmarshal -_-;
for key, value := range cacheMap {
if strings.HasPrefix(key, models.CacheBotPredix) {
botMap := make(map[string]string)
if err := json.Unmarshal(value, &botMap); err != nil {
log.Warn("failed to unmarshal bot", "error", err)
continue
}
resp[botMap["bot_name"]] = botMap
}
}
return resp
}
func notify(event, msg string) {
Notifier.Notifier <- broker.NotificationEvent{
EventName: event,
@ -270,3 +290,22 @@ func loadCards(room *models.Room) {
room.WCMap[card.Word] = card.Color
}
}
func recoverBots() {
bots := listBots()
for botName, botMap := range bots {
if err := recoverBot(botMap); err != nil {
log.Warn("failed to recover bot", "botName", botName, "error", err)
}
}
}
func recoverBot(bm map[string]string) error {
// TODO: check if room still exists
log.Debug("recovering bot", "bot", bm)
_, err := llmapi.NewBot(bm["role"], bm["team"], bm["bot_name"], bm["room_id"], cfg)
if err != nil {
return err
}
return nil
}

107
handlers/actions_test.go Normal file
View File

@ -0,0 +1,107 @@
package handlers
import (
"encoding/json"
"gralias/models"
"os"
"testing"
)
func TestSaveState(t *testing.T) {
// Create test state
state := &models.UserState{
Username: "testuser",
RoomID: "testroom",
Team: models.UserTeamBlue,
Role: models.UserRoleMime,
}
// Save state
err := saveState(state.Username, state)
if err != nil {
t.Fatalf("saveState failed: %v", err)
}
// Load state
loadedState, err := loadState(state.Username)
if err != nil {
t.Fatalf("loadState failed: %v", err)
}
// Verify loaded state matches original
if loadedState.Username != state.Username {
t.Errorf("Username mismatch: got %s, want %s", loadedState.Username, state.Username)
}
if loadedState.RoomID != state.RoomID {
t.Errorf("RoomID mismatch: got %s, want %s", loadedState.RoomID, state.RoomID)
}
if loadedState.Team != state.Team {
t.Errorf("Team mismatch: got %s, want %s", loadedState.Team, state.Team)
}
if loadedState.Role != state.Role {
t.Errorf("Role mismatch: got %s, want %s", loadedState.Role, state.Role)
}
// Test JSON serialization/deserialization
data, err := json.Marshal(state)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
testMap := make(map[string][]byte)
testMap["testkey"] = data
// Create a temporary file
tmpFile, err := os.CreateTemp("", "test_store_*.json")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tmpFileName := tmpFile.Name()
// defer os.Remove(tmpFileName)
// Write testMap to the temp file
fileData, err := json.Marshal(testMap)
if err != nil {
t.Fatalf("failed to marshal testMap: %v", err)
}
if err := os.WriteFile(tmpFileName, fileData, 0644); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
// Read the temp file
readData, err := os.ReadFile(tmpFileName)
if err != nil {
t.Fatalf("failed to read temp file: %v", err)
}
// Unmarshal the data
var testMapRead map[string][]byte
if err := json.Unmarshal(readData, &testMapRead); err != nil {
t.Fatalf("failed to unmarshal testMap: %v", err)
}
// Get the state bytes from the map
stateBytes, ok := testMapRead["testkey"]
if !ok {
t.Fatalf("key 'testkey' not found in testMapRead")
}
// Unmarshal the state bytes
stateRead := &models.UserState{}
if err := json.Unmarshal(stateBytes, stateRead); err != nil {
t.Fatalf("failed to unmarshal state: %v", err)
}
// Compare the state
if stateRead.Username != state.Username {
t.Errorf("Username mismatch from file: got %s, want %s", stateRead.Username, state.Username)
}
if stateRead.RoomID != state.RoomID {
t.Errorf("RoomID mismatch from file: got %s, want %s", stateRead.RoomID, state.RoomID)
}
if stateRead.Team != state.Team {
t.Errorf("Team mismatch from file: got %s, want %s", stateRead.Team, state.Team)
}
if stateRead.Role != state.Role {
t.Errorf("Role mismatch from file: got %s, want %s", stateRead.Role, state.Role)
}
}

View File

@ -107,7 +107,7 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
}
} else {
log.Debug("no room_id in login")
fi.List = listPublicRooms()
fi.List = listRooms(false)
// save state to cache
if err := saveState(cleanName, userstate); err != nil {
// if err := saveFullInfo(fi); err != nil {

View File

@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"os"
"time"
)
var (
@ -26,6 +27,10 @@ func init() {
memcache = cache.MemCache
cfg = config.LoadConfigOrDefault("")
Notifier = broker.Notifier
cache.MemCache.StartBackupRoutine(15 * time.Second) // Reduced backup interval
// bot loader
// check the rooms if it has bot_{digits} in them, create bots if have
recoverBots()
}
func HandlePing(w http.ResponseWriter, r *http.Request) {
@ -49,7 +54,7 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
}
}
if fi != nil && fi.Room == nil {
fi.List = listPublicRooms()
fi.List = listRooms(false)
}
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
log.Error("failed to exec templ;", "error", err, "templ", "base")
@ -90,7 +95,7 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
return
}
fi.List = listPublicRooms()
fi.List = listRooms(false)
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
log.Error("failed to exec templ;", "error", err, "templ", "base")
}

View File

@ -37,18 +37,35 @@ type DSResp struct {
Object string `json:"object"`
}
// type LLMResp struct {
// Choices []struct {
// FinishReason string `json:"finish_reason"`
// Index int `json:"index"`
// Message struct {
// Content string `json:"content"`
// Role string `json:"role"`
// } `json:"message"`
// } `json:"choices"`
// Created int `json:"created"`
// Model string `json:"model"`
// Object string `json:"object"`
// }
type LLMResp struct {
Choices []struct {
FinishReason string `json:"finish_reason"`
Index int `json:"index"`
Message struct {
Content string `json:"content"`
Role string `json:"role"`
} `json:"message"`
} `json:"choices"`
Created int `json:"created"`
Tokens []any `json:"tokens"`
IDSlot int `json:"id_slot"`
Stop bool `json:"stop"`
Model string `json:"model"`
Object string `json:"object"`
TokensPredicted int `json:"tokens_predicted"`
TokensEvaluated int `json:"tokens_evaluated"`
Prompt string `json:"prompt"`
HasNewLine bool `json:"has_new_line"`
Truncated bool `json:"truncated"`
StopType string `json:"stop_type"`
StoppingWord string `json:"stopping_word"`
TokensCached int `json:"tokens_cached"`
}
type MimeResp struct {
@ -63,13 +80,13 @@ type GusserResp struct {
}
type Bot struct {
Role string // gueeser | mime
Team string
cfg *config.Config
RoomID string // can we get a room from here?
BotName string
log *slog.Logger
LLMParser RespParser
Role string `json:"role"`
Team string `json:"team"`
cfg *config.Config `json:"-"`
RoomID string `json:"room_id"` // can we get a room from here?
BotName string `json:"bot_name"`
log *slog.Logger `json:"-"`
LLMParser RespParser `json:"-"`
// channels for communicaton
// channels are not serializable
// SignalsCh chan bool
@ -271,7 +288,7 @@ func NewBot(role, team, name, roomID string, cfg *config.Config) (*Bot, error) {
}
func saveBot(bot *Bot) error {
key := "botkey_" + bot.RoomID + bot.BotName
key := models.CacheBotPredix + bot.RoomID + bot.BotName
data, err := json.Marshal(bot)
if err != nil {
return err

View File

@ -66,12 +66,13 @@ func (p *lcpRespParser) ParseBytes(body []byte) (map[string]any, error) {
p.log.Error("failed to unmarshal", "error", err)
return nil, err
}
if len(resp.Choices) == 0 {
p.log.Error("empty choices", "resp", resp)
err := errors.New("empty choices in resp")
return nil, err
}
text := resp.Choices[0].Message.Content
// if len(resp.Choices) == 0 {
// p.log.Error("empty choices", "resp", resp)
// err := errors.New("empty choices in resp")
// return nil, err
// }
// text := resp.Choices[0].Message.Content
text := resp.Content
li := strings.Index(text, "{")
ri := strings.LastIndex(text, "}")
if li < 0 || ri < 1 {

28
main.go
View File

@ -1,10 +1,15 @@
package main
import (
"context"
"gralias/config"
"gralias/handlers"
"gralias/pkg/cache"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
@ -14,7 +19,7 @@ func init() {
cfg = config.LoadConfigOrDefault("")
}
func ListenToRequests(port string) error {
func ListenToRequests(port string) *http.Server {
mux := http.NewServeMux()
server := &http.Server{
Handler: handlers.LogRequests(handlers.GetSession(mux)),
@ -47,12 +52,27 @@ func ListenToRequests(port string) error {
// sse
mux.Handle("GET /sub/sse", handlers.Notifier)
slog.Info("Listening", "addr", port)
return server.ListenAndServe()
return server
}
func main() {
err := ListenToRequests(cfg.ServerConfig.Port)
if err != nil {
// Setup graceful shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
server := ListenToRequests(cfg.ServerConfig.Port)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
<-stop
slog.Info("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
slog.Error("server shutdown failed", "error", err)
}
cache.MemCache.BackupNow()
}

View File

@ -8,6 +8,7 @@ var (
// cache
CacheRoomPrefix = "room#"
CacheStatePrefix = "state-"
CacheBotPredix = "botkey_"
// sse
NotifyRoomListUpdate = "roomlistupdate"
NotifyRoomUpdatePrefix = "roomupdate_"

View File

@ -116,7 +116,9 @@ func getGuesser(m map[string]BotPlayer, team UserTeam) string {
// WhichBotToMove returns bot name that have to move or empty string
func (r *Room) WhichBotToMove() string {
fmt.Println("looking for bot to move", "team-turn", r.TeamTurn, "mime-done", r.MimeDone, "bot-map", r.BotMap, "is_running", r.IsRunning)
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning,
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime)
if !r.IsRunning {
return ""
}

26
pkg/cache/impl.go vendored
View File

@ -102,6 +102,19 @@ func (mc *MemoryCache) RemoveKey(key string) {
mc.lock.RUnlock()
}
func (mc *MemoryCache) BackupNow() {
data := mc.GetAll()
jsonString, err := json.Marshal(data)
if err != nil {
slog.Warn("immediate backup failed to marshal", "err", err)
return
}
err = os.WriteFile(storeFileName, jsonString, os.ModePerm)
if err != nil {
slog.Warn("immediate backup failed to write", "err", err)
}
}
func (mc *MemoryCache) StartExpiryRoutine(n time.Duration) {
ticker := time.NewTicker(n)
go func() {
@ -127,18 +140,7 @@ func (mc *MemoryCache) StartBackupRoutine(n time.Duration) {
go func() {
for {
<-ticker.C
// get all
data := mc.GetAll()
jsonString, err := json.Marshal(data)
if err != nil {
slog.Warn("failed to marshal", "err", err)
continue
}
err = os.WriteFile(storeFileName, jsonString, os.ModePerm)
if err != nil {
slog.Warn("failed to write", "err", err)
continue
}
mc.BackupNow()
}
}()
}

View File

@ -11,6 +11,11 @@
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
- hide clue input for mime when it's not their turn;
- needs resend to llm btn; +
- better styles and fluff;
- common auth system between sites;
- autoscroll down backlog on update;
- gameover to backlog;
- ended turn action to backlog;
#### sse points
- clue sse update;
@ -29,6 +34,8 @@
- 0 should mean without limit;
- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine);
- after starting a new game (after old one) blue mime has no clue input;
- gameover to backlog;
- remove verbs from word file;
- invite link gets cutoff;
- mime rejoined the room: does not see colors; state save in store.json has empty role and team +
- restart bot routines after server restart; +
- guesser did not have same number of guesses (move ended after 1 guess); show how much guesses left on the page;