Compare commits

..

1 Commits

Author SHA1 Message Date
Grail Finder
8f9865db3f Enha: simplify sse (worsened event recieving) 2025-07-12 20:33:41 +03:00
34 changed files with 452 additions and 621 deletions

View File

@@ -1,14 +1,14 @@
body{
background-color: #0C1616FF;
color: #8896b2;
max-width: 1000px;
min-width: 0px;
margin: 2em 2em !important;
margin: 2em auto !important;
margin-left: auto;
margin-right: auto;
line-height: 1.5;
font-size: 16px;
font-family: Open Sans,Arial;
font-weight: bold;
text-align: center;
display: block;
}
@@ -23,6 +23,18 @@ tr{
#usertable{
display: block ruby;
}
.actiontable{
display: inline flow-root;
margin-inline: 10px;
}
.action_name{
border: none;
display: inline;
font-family: inherit;
font-size: inherit;
padding: none;
width: auto;
}
#errorbox{
border: 1px solid black;
background-color: darkorange;

BIN
assets/style.css.gz Normal file

Binary file not shown.

View File

@@ -146,6 +146,7 @@ rage
southwest
cycle
roman
jay
stage
ability
duration
@@ -544,6 +545,7 @@ norm
imaginary
pace
weather
marina
week
scale
step
@@ -883,6 +885,7 @@ ready
theorem
disagreement
led
benjamin
era
snow
bowl
@@ -1352,6 +1355,7 @@ male
red
objective
bond
jimmy
opera
foliage
motive
@@ -1416,6 +1420,7 @@ landscape
chin
sermon
celebration
sba
curriculum
market
bullet
@@ -1448,6 +1453,7 @@ profit
conductor
elevator
brutality
tom
community
hydrogen
noon
@@ -1521,6 +1527,7 @@ touch
association
abandon
buddy
christ
passion
coverage
region
@@ -1534,11 +1541,13 @@ prince
availability
rebel
development
pro
raise
sink
value
discovery
fly
warren
overhead
spell
cross
@@ -1613,6 +1622,7 @@ star
improvement
object
permanent
pat
carpet
separation
sport
@@ -1914,6 +1924,7 @@ investment
ancient
listener
impulse
magnum
affair
realm
tea
@@ -1945,6 +1956,7 @@ darkness
longer
emotion
funeral
pip
there
secondary
obligation
@@ -1996,6 +2008,7 @@ rail
aesthetic
player
porter
sam
singular
text
tongue
@@ -2106,6 +2119,7 @@ juvenile
extent
prayer
gin
hogan
food
explosion
past

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,13 @@ import (
"fmt"
"log/slog"
"net/http"
"os"
"time"
)
// the amount of time to wait when pushing a message to
// a slow client or a client that closed after `range clients` started.
const patience time.Duration = time.Millisecond * 500
const patience time.Duration = time.Second * 1
type (
NotificationEvent struct {
@@ -20,22 +21,18 @@ type (
Broker struct {
// Events are pushed to this channel by the main events-gathering routine
Notifier NotifierChan
// New client connections
newClients chan NotifierChan
// Closed client connections
closingClients chan NotifierChan
// Client connections registry
clients map[NotifierChan]struct{}
log *slog.Logger
}
)
func NewBroker() (broker *Broker) {
// Instantiate a broker
return &Broker{
Notifier: make(NotifierChan, 1),
newClients: make(chan NotifierChan),
closingClients: make(chan NotifierChan),
clients: make(map[NotifierChan]struct{}),
Notifier: make(NotifierChan, 100),
log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true,
})),
}
}
@@ -44,7 +41,6 @@ var Notifier *Broker
// for use in different packages
func init() {
Notifier = NewBroker()
go Notifier.Listen()
}
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -59,9 +55,6 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
messageChan := make(NotifierChan, 10) // Buffered channel
broker.newClients <- messageChan
defer func() { broker.closingClients <- messageChan }()
ctx := r.Context()
// browser can close sse on its own; ping every 2s to prevent
heartbeat := time.NewTicker(2 * time.Second)
@@ -69,12 +62,14 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
for {
select {
case <-ctx.Done():
broker.log.Debug("broker: got ctx done")
// Client disconnected
return
case event := <-messageChan:
case event := <-broker.Notifier:
broker.log.Debug("got event", "event", event)
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil {
fmt.Println(err)
broker.log.Error("failed to write event", "error", err)
// Client disconnected
return
}
@@ -82,45 +77,10 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case <-heartbeat.C:
// Send SSE heartbeat comment
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
broker.log.Error("failed to write heartbeat", "error", err)
return // Client disconnected
}
w.(http.Flusher).Flush()
}
}
}
// Listen for new notifications and redistribute them to clients
func (broker *Broker) Listen() {
slog.Info("Broker listener started")
for {
slog.Info("Broker waiting for event")
select {
case s := <-broker.newClients:
// A new client has connected.
// Register their message channel
broker.clients[s] = struct{}{}
slog.Info("Client added", "clients listening", len(broker.clients))
case s := <-broker.closingClients:
// A client has dettached and we want to
// stop sending them messages.
delete(broker.clients, s)
slog.Info("Client removed", "clients listening", len(broker.clients))
case event := <-broker.Notifier:
slog.Info("Received new event", "event", event.EventName, "payload", event.Payload)
// We got a new event from the outside!
// Send event to all connected clients
slog.Info("Broadcasting event to clients", "client_count", len(broker.clients))
for clientMessageChan := range broker.clients {
slog.Info("Sending event to client", "client", clientMessageChan)
select {
case clientMessageChan <- event:
slog.Info("Successfully sent event to client", "client", clientMessageChan)
case <-time.After(patience):
delete(broker.clients, clientMessageChan)
slog.Warn("Client timed out, removed", "client", clientMessageChan, "clients listening", len(broker.clients))
}
}
slog.Info("Finished broadcasting event")
}
}
}

View File

@@ -1,17 +1,27 @@
{{define "actionhistory"}}
<div id="actionHistoryContainer" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2 h-full flex-col-reverse justify-end">
<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">
<span class="font-mono text-sm">
<span class="text-{{.ActorColor}}-600">{{.Actor}}:</span>
<span class="text-{{.ActorColor}}-600">{{.Actor}}:</span>
<span class="text-gray-600">{{.Action}}:</span>
<span class="text-{{.WordColor}}-500 font-medium">{{.Word}}</span>
{{if .Number}}
<span class="text-gray-400">- {{.Number}}</span>
<span class="text-gray-400">- {{.Number}}</span>
{{end}}
</span>
</div>
{{end}}
</div>
<script>
if (!window.actionHistoryScrollSet) {
htmx.onLoad(function(target) {
if (target.id === 'actionHistoryContainer') {
target.scrollTop = target.scrollHeight;
}
});
window.actionHistoryScrollSet = true;
}
</script>
{{end}}

View File

@@ -13,6 +13,12 @@
</head>
<body>
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse">
<script type="text/javascript">
document.body.addEventListener('htmx:sseError', function (e) {
// do something before the event data is swapped in
console.log(e)
})
</script>
<div id="main-content">
{{template "main" .}}
</div>

View File

@@ -3,69 +3,7 @@
<div class="flex justify-center">
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
{{range .Cards}}
{{if .Revealed}}
{{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border-8 border-stine-400 p-4 min-w-[100px] text-center text-white cursor-pointer line-through"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{else}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border-8 border-stone-400 p-4 min-w-[100px] text-center text-white cursor-pointer line-through"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{end}}
{{else if $.IsOver}}
{{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{else}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{end}}
{{else if .Mime}}
{{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-gray-500 rounded-lg min-w-[100px] cursor-pointer flex flex-col h-full">
{{else}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border border-gray-500 rounded-lg min-w-[100px] cursor-pointer flex flex-col h-full">
{{end}}
<div class="flex-grow text-center p-4 flex items-center justify-center text-white"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.8);"
hx-get="/word/show-color?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{.Word}}
</div>
<div class="h-6 bg-stone-600 rounded-b flex items-center justify-center text-white text-sm cursor-pointer"
hx-get="/mark-card?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{range .Marks}}
{{ $length := len .Username }}
{{ if lt $length 3 }}
<span class="mx-0.5">{{.Username}}</span>
{{else}}
<span class="mx-0.5">{{slice .Username 0 3}}</span>
{{end}}
{{end}}
</div>
</div>
{{else}}
<div id="card-{{.Word}}" class="bg-stone-400 border border-gray-500 rounded-lg min-w-[100px] cursor-pointer flex flex-col h-full">
<div class="flex-grow text-center p-4 flex items-center justify-center text-white"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.8);"
hx-get="/word/show-color?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{.Word}}
</div>
<div class="h-6 bg-stone-600 rounded-b flex items-center justify-center text-white text-sm cursor-pointer"
hx-get="/mark-card?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{range .Marks}}
{{ $length := len .Username }}
{{ if lt $length 3 }}
<span class="mx-0.5">{{.Username}}</span>
{{else}}
<span class="mx-0.5">{{slice .Username 0 3}}</span>
{{end}}
{{end}}
</div>
</div>
{{end}}
{{template "cardword" .}}
{{end}}
</div>
</div>

View File

@@ -9,7 +9,7 @@
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div>
{{end}}
{{else if or (.Mime) }}
{{else if .Mime}}
{{if eq .Color "amber"}}
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}

View File

@@ -4,9 +4,9 @@
Create a room <br/>
or<br/>
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button>
<form hx-post="/room-create" hx-target="#main-content" class="space-y-4">
<form hx-post="/room-create" hx-target="#main-content">
<label For="game_time">Turn Seconds:</label><br/>
<input type="number" id="game_time" name="game_time" class="text-center text-white" value="120"/><br/>
<input type="number" id="game_time" name="game_time" class="text-center text-white" value="300"/><br/>
<label For="language">Language:</label><br/>
<div>
<select class="form-select text-white text-center bg-gray-900" id="languages" name="language">

View File

@@ -1,6 +1,6 @@
{{define "error"}}
<a href="/">
<div id=errorbox class="bg-orange-100 border-l-4 border-black text-black p-4" role="alert">
<div id=errorbox class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
<p class="font-bold">An error from server</p>
<p>{{.}}</p>
<p>Click this banner to return to main page.</p>

View File

@@ -22,6 +22,7 @@
{{template "roomlist" .List}}
</div>
{{else}}
<div id="sse-listener" sse-connect="/sub/sse" hx-trigger="sse:roomupdate_{{.State.RoomID}}" hx-get="/room" hx-target="#room-interier" hx-swap="none" style="display:none;"></div>
<div id="room">
{{template "room" .}}
</div>

View File

@@ -1,11 +0,0 @@
{{define "journal"}}
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
bot journal: <br>
<ul>
{{range .LogJournal}}
<li>{{.Username}}: {{.Entry}}</li>
{{end}}
</ul>
</div>
{{end}}

View File

@@ -1,21 +1,19 @@
{{define "login"}}
<div id="logindiv">
<form hx-post="/login" hx-target="#main-content" class="space-y-4">
<form class="space-y-6" hx-post="/login" hx-target="#main-content">
<div>
<label For="username" class="text-sm text-center font-medium leading-6 text-white-900">tell us your username (signup|login)</label>
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username (signup|login)</label>
<div class="mt-2">
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="text-center rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div>
<div id="login_notice">this name looks available</div>
</div>
<div>
<label For="password" class="text-sm font-medium text-center leading-6 text-white-900">password</label>
<div class="mt-2">
<input id="password" name="password" type="password" class="rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div>
<label For="password" class="block text-sm font-medium leading-6 text-white-900">password</label>
<input id="password" name="password" type="password" class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div>
<div>
<button type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
</div>
</form>
</div>

View File

@@ -1,7 +1,6 @@
{{define "room"}}
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}" class=space-y-2>
<div id="headwrapper" class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2">
<div id="room-interier" class=space-y-2>
<div id="meta">
<p>Hello {{.State.Username}};</p>
<p>Room created by {{.Room.CreatorName}};</p>
<p>Room link:</p>
@@ -17,13 +16,13 @@
{{end}}
<p>
{{if eq .State.Team ""}}
you don't have a role! join the team ->
join the team!
{{else}}
you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>!
{{end}}
</p>
</div>
<div id="infopatch" class="md:col-span-3">
<hr />
{{if .Room.IsRunning}}
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p>
{{template "turntimer" .Room}}
@@ -49,31 +48,33 @@
<!-- Right Panel -->
{{template "teamlist" .Room.RedTeam}}
</div>
<hr/>
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
bot thought: <br>
<ul>
{{range .Room.LogJournal}}
<li>{{.Username}}: {{.Entry}}</li>
{{end}}
</ul>
</div>
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
{{template "actionhistory" .Room.ActionHistory}}
</div>
<hr/>
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div class="md:col-span-1">
{{template "actionhistory" .Room.ActionHistory}}
</div>
<div id="cardtable" class="md:col-span-3">
{{template "cardtable" .Room}}
</div>
<div class="hidden md:block md:col-span-1">
{{template "journal" .Room}}
</div> <!-- Spacer -->
</div>
<div id="cardtable">
{{template "cardtable" .Room}}
</div>
<div>
{{if .Room.IsRunning}}
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn) (.Room.MimeDone)}}
<button hx-get="/end-turn" hx-target="#room" class="bg-amber-100 text-black px-4 py-2 rounded">End Turn</button>
{{else if and (eq .State.Role "mime") (not .Room.MimeDone) (eq .State.Team .Room.TeamTurn)}}
{{template "mimeclue"}}
{{end}}
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}}
<button hx-get="/end-turn" hx-target="#room" class="bg-amber-100 text-black px-4 py-2 rounded">End Turn</button>
{{else if and (eq .State.Role "mime") (not .Room.MimeDone)}}
{{template "mimeclue"}}
{{end}}
{{end}}
</div>
<div>
{{if and (eq .State.Username .Room.CreatorName) (.Room.BotFailed)}}
{{if and (eq .State.Username .Room.CreatorName) (.Room.IsRunning)}}
<button hx-get="/renotify-bot" hx-swap="none" class="bg-gray-100 text-black px-1 py-1 rounded">Btn in case llm call failed</button>
{{end}}
</div>

View File

@@ -2,10 +2,8 @@ package handlers
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"gralias/models"
"gralias/utils"
@@ -67,7 +65,7 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
return
}
username := r.PostFormValue("username")
if username == "" || !utils.IsInputSane(username) {
if username == "" {
msg := "username not provided"
log.Error(msg)
abortWithError(w, msg)
@@ -79,12 +77,6 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
// make sure username does not exists
cleanName := utils.RemoveSpacesFromStr(username)
clearPass := utils.RemoveSpacesFromStr(password)
var hashedPass string
if clearPass != "" {
// hash the password with md5
hash := md5.Sum([]byte(clearPass))
hashedPass = hex.EncodeToString(hash[:])
}
// check if that user was already in db
userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
if err != nil || userstate == nil {
@@ -92,8 +84,8 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
userstate = models.InitPlayer(cleanName)
makeplayer = true
} else {
if userstate.Password != hashedPass {
log.Error("wrong password", "username", cleanName)
if userstate.Password != clearPass {
log.Error("wrong password", "username", cleanName, "password", clearPass)
abortWithError(w, "wrong password")
return
}
@@ -134,7 +126,7 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
}
// save state to cache
if makeplayer {
userstate.Password = hashedPass
userstate.Password = clearPass
if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
log.Error("failed to save state", "error", err)
abortWithError(w, err.Error())

View File

@@ -42,9 +42,8 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
return
}
fi, err := getFullInfoByCtx(ctx)
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
@@ -207,18 +206,12 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
return
}
fi, err := getFullInfoByCtx(ctx)
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
// abortWithError(w, err.Error())
log.Debug("pressed mark-card out of move", "error", err)
return
}
if fi.State.Role == models.UserRoleMime {
log.Debug("mime pressed mark-card")
abortWithError(w, err.Error())
return
}
color, exists := fi.Room.FindColor(word)
@@ -281,9 +274,8 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
func HandleActionHistory(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
tmpl, err := template.ParseGlob("components/*.html")
@@ -301,9 +293,8 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
team := r.URL.Query().Get("team")
role := r.URL.Query().Get("role")
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
var botname string
@@ -328,9 +319,8 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
botName := r.URL.Query().Get("bot")
log.Debug("got remove-bot request", "bot_name", botName)
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
if err := llmapi.RemoveBot(botName, fi.Room); err != nil {
@@ -339,3 +329,19 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
}
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
}
func HandleGetRoom(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context())
if err != nil {
abortWithError(w, err.Error())
return
}
tmpl, err := template.ParseGlob("components/*.html")
if err != nil {
abortWithError(w, err.Error())
return
}
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
log.Error("failed to execute template", "error", err)
}
}

View File

@@ -72,9 +72,8 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
}
// get username
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
if fi.Room == nil {
@@ -112,9 +111,8 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
// get username
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
// check if one who pressed it is from the team who has the turn
@@ -145,9 +143,8 @@ func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
func HandleStartGame(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
// check if enough players
@@ -253,16 +250,13 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("id")
log.Debug("got join-room request", "id", roomID)
room, err := repo.RoomGetExtended(r.Context(), roomID)
if err != nil {
log.Error("failed to fetch room", "error", err, "room_id", roomID)
abortWithError(w, err.Error())
return
}
tmpl, err := template.ParseGlob("components/*.html")
if err != nil {
log.Error("failed to parse templates", "error", err, "room_id", roomID)
abortWithError(w, err.Error())
return
}
@@ -274,7 +268,6 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
log.Error("failed to execute base template", "error", err)
}
log.Error("failed to fetch fi", "error", err, "room_id", roomID)
// abortWithError(w, err.Error())
return
}
@@ -287,7 +280,7 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
return
}
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
log.Error("failed to execute room template", "error", err)
}
}
@@ -300,9 +293,8 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
clue := r.PostFormValue("clue")
num := r.PostFormValue("number")
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
guessLimitU64, err := strconv.ParseUint(num, 10, 8)
@@ -368,9 +360,8 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
notifyBotIfNeeded(fi.Room)

View File

@@ -9,8 +9,6 @@ import (
"log/slog"
"net/http"
"os"
"slices"
"strings"
)
var (
@@ -57,12 +55,6 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
} else {
fi.Room.GuesserView()
}
// reverse actions if first action
if len(fi.Room.ActionHistory) > 1 {
if strings.EqualFold(fi.Room.ActionHistory[0].Action, models.ActionTypeGameStarted) {
slices.Reverse(fi.Room.ActionHistory)
}
}
}
if fi != nil && fi.Room == nil {
rooms, err := repo.RoomList(r.Context())
@@ -83,14 +75,8 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
return
}
fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil {
log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return
}
if fi.Room == nil {
log.Error("failed to fetch room")
http.Redirect(w, r, "/", 302)
if err != nil {
abortWithError(w, err.Error())
return
}
if fi.Room.IsRunning {
@@ -119,9 +105,8 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
if err := repo.RoomDeleteByID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove room", "error", err)
}
notify(models.NotifyRoomListUpdate, "") // why is it needed?
notify(models.NotifyRoomListUpdate, "")
} else {
notify(models.NotifyRoomUpdatePrefix, "")
// if regular player leaves, just exit room
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
log.Error("failed to exit room", "error", err)

View File

View File

@@ -12,7 +12,7 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
logger := slog.Default().With("room_id", roomID)
onTurnEnd := func(ctx context.Context, roomID string) {
room, err := repo.RoomGetExtended(context.Background(), roomID)
room, err := repo.RoomGetByID(context.Background(), roomID)
if err != nil {
logger.Error("failed to get room by id", "error", err)
return
@@ -23,7 +23,7 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
if err := repo.RoomUpdate(context.Background(), room); err != nil {
logger.Error("failed to save room", "error", err)
}
// notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
notifyBotIfNeeded(room)
}
@@ -31,10 +31,9 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10))
}
timer.StartTurnTimer(context.Background(), roomID, int32(timeLeft), onTurnEnd, onTick, logger)
timer.StartTurnTimer(context.Background(), roomID, timeLeft, onTurnEnd, onTick, logger)
}
func StopTurnTimer(roomID string) {
timer.StopTurnTimer(roomID)
}
}

View File

@@ -25,10 +25,10 @@ var (
DoneChanMap = make(map[string]chan bool)
mapMutex = &sync.RWMutex{}
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that you can choose from:\n%v`
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.`
GuesserSimplePromptRU = `мы играем в alias;\n тебе дана подсказка (clue): \"%s\";\nпожалуйста, верни свою догадку (guess), а также слова, что тоже подходят к подсказке, но ты меньше в них уверен, в формате json; пример:\n{\n\"guess\": \"отгадка\",\n\"could_be\": [\"слово1\", \"слово2\", ...]\n}\nвот список слов из которых нужно выбрать:\n%v`
MimeSimplePromptRU = `мы играем в alias;\nтебе нужно дать подсказку одним словом и число слов, что ты подразумевал этой подсказкой; слова твоей комманды: %v;\nслова противоположной комманды, что ты хочешь избежать: %v;\nи вот ЧЕРНОЕ СЛОВО, открыв которое твоя комманда проиграет игру: %s;\nпожалуйста, верни подсказку (одним словом) и количество слов, что ты подразумеваешь в формате json; пример:\n{\n\"clue\": \"подсказка\",\n\"number\": \"число-от-0-до-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"слово1\", \"слово2\", ...]\n}\nпожалуйста верни только json.`
// MimePrompt = `we are playing alias;\nyou are a mime (player who gives a clue of one noun word and number of cards you expect them to open) of the %s team (people who would guess by your clue want open the %s cards);\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the game info in json:\n%s`
// GuesserPrompt = `we are playing alias;\nyou are to guess words of the %s team (you want open %s cards) by given clue and a number of meant guesses;\nplease return your guesses and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guesses\": [\"word1\", \"word2\", ...],\n\"could_be\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the cards (and other info), you need to choose revealed==false words:\n%s`
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that you can choose from:\n%v`
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;`
)
func convertToSliceOfStrings(value any) ([]string, error) {
@@ -188,11 +188,6 @@ func (b *Bot) BotMove() {
b.log.Error("bot loop", "error", err)
return
}
if room.BotFailed {
if err := repo.RoomUnSetBotFailed(context.Background(), room.ID); err != nil {
b.log.Error("failed to unset bot failed bool", "error", err)
}
}
// eventName := models.NotifyBacklogPrefix + room.ID
eventName := models.NotifyRoomUpdatePrefix + room.ID
eventPayload := ""
@@ -236,9 +231,6 @@ func (b *Bot) BotMove() {
b.log.Warn("failed to write to journal", "entry", lj)
}
b.log.Error("bot loop", "error", err)
if err := repo.RoomSetBotFailed(context.Background(), room.ID); err != nil {
b.log.Error("failed to set bot failed bool", "error", err)
}
return
}
tempMap, err := b.LLMParser.ParseBytes(llmResp)
@@ -553,9 +545,6 @@ func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
}
words[i] = card.Word
}
if strings.EqualFold(room.Settings.Language, "ru") {
return fmt.Sprintf(MimeSimplePromptRU, clueAction.Word, words)
}
return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
}
@@ -584,20 +573,34 @@ func (b *Bot) BuildSimpleMimePrompt(room *models.Room) string {
theirwords = append(theirwords, card.Word)
}
}
if strings.EqualFold(room.Settings.Language, "ru") {
return fmt.Sprintf(MimeSimplePromptRU, ourwords, theirwords, blackWord)
}
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord)
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord, room.BlueCounter, room.RedCounter)
}
func (b *Bot) BuildPrompt(room *models.Room) string {
if b.Role == "" {
return ""
}
// toText := make(map[string]any)
// toText["backlog"] = room.ActionHistory
// // mime sees all colors;
// // guesser sees only revealed ones
// if b.Role == models.UserRoleMime {
// toText["cards"] = room.Cards
// }
// data, err := json.Marshal(toText)
// if err != nil {
// b.log.Error("failed to marshal", "error", err)
// return ""
// }
// Escape the JSON string for inclusion in another JSON field
// escapedData := strings.ReplaceAll(string(data), `"`, `\\"`)
if b.Role == models.UserRoleMime {
// return fmt.Sprintf(MimeSimplePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
// return fmt.Sprintf(MimePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
return b.BuildSimpleMimePrompt(room)
}
if b.Role == models.UserRoleGuesser {
// return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
return b.BuildSimpleGuesserPrompt(room)
}
return ""
@@ -663,5 +666,16 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
return body, nil
}
entry := fmt.Sprintf("bot '%s' exceeded attempts to call llm;", b.BotName)
lj := models.Journal{
Entry: entry,
Username: b.BotName,
RoomID: b.RoomID,
}
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
// notify room
// This line should not be reached because each error path returns in the loop.
return nil, errors.New("unknown error in retry loop")
}

View File

@@ -95,52 +95,3 @@ func (b *Bot) ToPlayer() *models.Player {
IsBot: true,
}
}
type ORModel struct {
ID string `json:"id"`
CanonicalSlug string `json:"canonical_slug"`
HuggingFaceID string `json:"hugging_face_id"`
Name string `json:"name"`
Created int `json:"created"`
Description string `json:"description"`
ContextLength int `json:"context_length"`
Architecture struct {
Modality string `json:"modality"`
InputModalities []string `json:"input_modalities"`
OutputModalities []string `json:"output_modalities"`
Tokenizer string `json:"tokenizer"`
InstructType any `json:"instruct_type"`
} `json:"architecture"`
Pricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
Request string `json:"request"`
Image string `json:"image"`
Audio string `json:"audio"`
WebSearch string `json:"web_search"`
InternalReasoning string `json:"internal_reasoning"`
} `json:"pricing,omitempty"`
TopProvider struct {
ContextLength int `json:"context_length"`
MaxCompletionTokens int `json:"max_completion_tokens"`
IsModerated bool `json:"is_moderated"`
} `json:"top_provider"`
PerRequestLimits any `json:"per_request_limits"`
SupportedParameters []string `json:"supported_parameters"`
}
// https://openrouter.ai/api/v1/models
type ORModels struct {
Data []ORModel `json:"data"`
}
func (orm *ORModels) ListFree() []string {
resp := []string{}
for _, model := range orm.Data {
if model.Pricing.Prompt == "0" && model.Pricing.Request == "0" &&
model.Pricing.Completion == "0" {
resp = append(resp, model.ID)
}
}
return resp
}

View File

@@ -1,64 +0,0 @@
package llmapi
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
var (
ormodelsLink = "https://openrouter.ai/api/v1/models"
ORFreeModels = []string{
"google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-chat-v3-0324:free",
"mistralai/mistral-small-3.2-24b-instruct:free",
"qwen/qwen3-14b:free",
"google/gemma-3-27b-it:free",
"meta-llama/llama-3.3-70b-instruct:free",
}
)
func ListORModels() ([]string, error) {
resp, err := http.Get(ormodelsLink)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
err := fmt.Errorf("failed to fetch or models; status: %s", resp.Status)
return nil, err
}
data := &ORModels{}
if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
return nil, err
}
freeModels := data.ListFree()
return freeModels, nil
}
func ORModelListUpdateTicker(ctx context.Context) {
ticker := time.NewTicker(time.Hour * 2)
freeModels, err := ListORModels()
slog.Info("updated free models list", "list", freeModels)
if err != nil {
slog.Error("failed to update free models list", "list", freeModels)
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
freeModels, err := ListORModels()
slog.Info("updated free models list", "list", freeModels)
if err != nil {
slog.Error("failed to update free models list", "list", freeModels)
// log
continue
}
ORFreeModels = freeModels
}
}
}

View File

@@ -158,12 +158,21 @@ func (p *openRouterParser) ParseBytes(body []byte) (map[string]any, error) {
}
func (p *openRouterParser) MakePayload(prompt string) io.Reader {
// Models to rotate through
models := []string{
"google/gemini-2.0-flash-exp:free",
"deepseek/deepseek-chat-v3-0324:free",
"mistralai/mistral-small-3.2-24b-instruct:free",
"qwen/qwen3-14b:free",
"deepseek/deepseek-r1:free",
"google/gemma-3-27b-it:free",
"meta-llama/llama-3.3-70b-instruct:free",
}
// Get next model index using atomic addition for thread safety
p.modelIndex++
model := ORFreeModels[int(p.modelIndex)%len(ORFreeModels)]
model := models[int(p.modelIndex)%len(models)]
strPayload := fmt.Sprintf(`{
"model": "%s",
"max_tokens": 300,
"messages": [
{
"role": "user",

View File

@@ -13,7 +13,7 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
logger := b.log.With("room_id", b.RoomID)
onTurnEnd := func(ctx context.Context, roomID string) {
room, err := repos.RP.RoomGetExtended(context.Background(), roomID)
room, err := repos.RP.RoomGetByID(context.Background(), roomID)
if err != nil {
logger.Error("failed to get room by id", "error", err)
return
@@ -24,10 +24,10 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
if err := repos.RP.RoomUpdate(context.Background(), room); err != nil {
logger.Error("failed to save room", "error", err)
}
// broker.Notifier.Notifier <- broker.NotificationEvent{
// EventName: models.NotifyTurnTimerPrefix + room.ID,
// Payload: strconv.FormatUint(uint64(room.Settings.RoundTime), 10),
// }
broker.Notifier.Notifier <- broker.NotificationEvent{
EventName: models.NotifyTurnTimerPrefix + room.ID,
Payload: strconv.FormatUint(uint64(room.Settings.RoundTime), 10),
}
// notifyBotIfNeeded(room)
if botName := room.WhichBotToMove(); botName != "" {
SignalChanMap[botName] <- true
@@ -41,10 +41,9 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
}
}
timer.StartTurnTimer(context.Background(), b.RoomID, int32(timeLeft), onTurnEnd, onTick, logger)
timer.StartTurnTimer(context.Background(), b.RoomID, timeLeft, onTurnEnd, onTick, logger)
}
func (b *Bot) StopTurnTimer() {
timer.StopTurnTimer(b.RoomID)
}
}

View File

@@ -5,7 +5,6 @@ import (
"gralias/config"
"gralias/crons"
"gralias/handlers"
"gralias/llmapi"
"gralias/repos"
"gralias/telemetry"
"log/slog"
@@ -23,7 +22,6 @@ var cfg *config.Config
func init() {
cfg = config.LoadConfigOrDefault("")
go llmapi.ORModelListUpdateTicker(context.Background())
}
// GzipFileServer serves pre-compressed .gz files if available
@@ -94,6 +92,7 @@ func ListenToRequests(port string) *http.Server {
mux.HandleFunc("GET /add-bot", handlers.HandleAddBot)
mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot)
mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard)
mux.HandleFunc("GET /room", handlers.HandleGetRoom)
// special
mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot)
// sse

View File

@@ -13,7 +13,6 @@ CREATE TABLE rooms (
mime_done BOOLEAN NOT NULL DEFAULT FALSE,
is_running BOOLEAN NOT NULL DEFAULT FALSE,
is_over BOOLEAN NOT NULL DEFAULT FALSE,
bot_failed BOOLEAN NOT NULL DEFAULT FALSE,
team_won TEXT NOT NULL DEFAULT '',
room_link TEXT NOT NULL DEFAULT ''
);

View File

@@ -178,8 +178,6 @@ type Room struct {
BotMap map[string]BotPlayer `db:"-"`
LogJournal []Journal `db:"-"`
Settings GameSettings `db:"-"`
//
BotFailed bool `db:"bot_failed"`
}
func (r *Room) FindColor(word string) (WordColor, bool) {
@@ -228,16 +226,7 @@ func (r *Room) FetchLastClue() (*Action, error) {
}
func (r *Room) FetchLastClueWord() string {
if len(r.ActionHistory) > 1 {
if strings.EqualFold(r.ActionHistory[0].Action, ActionTypeGameStarted) {
for i := len(r.ActionHistory) - 1; i >= 0; i-- {
if r.ActionHistory[i].Action == string(ActionTypeClue) {
return r.ActionHistory[i].Word
}
}
}
}
for i := 0; i <= len(r.ActionHistory)-1; i++ {
for i := len(r.ActionHistory) - 1; i >= 0; i-- {
if r.ActionHistory[i].Action == string(ActionTypeClue) {
return r.ActionHistory[i].Word
}
@@ -419,22 +408,14 @@ func (r *Room) RevealSpecificWord(word string) uint32 {
return 0
}
func (r *Room) SetGameOverToCards(isover bool) {
for i := range r.Cards {
r.Cards[i].IsOver = isover
}
}
type WordCard struct {
ID uint32 `json:"id" db:"id"`
RoomID string `json:"room_id" db:"room_id"`
Word string `json:"word" db:"word"`
Color WordColor `json:"color" db:"color"`
Revealed bool `json:"revealed" db:"revealed"`
// pain; but at the end of the game players should see color of unopen cards
IsOver bool
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
Marks []CardMark `json:"marks" db:"-"`
ID uint32 `json:"id" db:"id"`
RoomID string `json:"room_id" db:"room_id"`
Word string `json:"word" db:"word"`
Color WordColor `json:"color" db:"color"`
Revealed bool `json:"revealed" db:"revealed"`
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
Marks []CardMark `json:"marks" db:"-"`
}
// table: settings

View File

@@ -15,20 +15,6 @@ type RoomsRepo interface {
RoomCreate(ctx context.Context, room *models.Room) error
RoomDeleteByID(ctx context.Context, id string) error
RoomUpdate(ctx context.Context, room *models.Room) error
RoomSetBotFailed(ctx context.Context, roomID string) error
RoomUnSetBotFailed(ctx context.Context, roomID string) error
}
func (p *RepoProvider) RoomSetBotFailed(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = true WHERE id = ?", roomID)
return err
}
func (p *RepoProvider) RoomUnSetBotFailed(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = false WHERE id = ?", roomID)
return err
}
func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {

View File

@@ -1,113 +0,0 @@
#!/usr/bin/env python3
"""
One-time script to migrate player passwords from plaintext to MD5 hashes.
This script:
1. Connects to the SQLite database
2. Retrieves all players with their passwords
3. Skips passwords that already look like MD5 hashes (32 hex chars)
4. Hashes plaintext passwords with MD5
5. Updates the database with hashed passwords
Usage:
python scripts/migrate_passwords.py [path_to_db]
If no path is provided, defaults to 'gralias.db' in the current directory.
"""
import hashlib
import re
import sqlite3
import sys
from pathlib import Path
def is_md5_hash(password: str) -> bool:
"""Check if password looks like an MD5 hash (32 hex characters)."""
return bool(re.match(r"^[a-fA-F0-9]{32}$", password))
def hash_password(password: str) -> str:
"""Hash password with MD5."""
return hashlib.md5(password.encode("utf-8")).hexdigest()
def migrate_passwords(db_path: str) -> None:
"""Migrate all player passwords to MD5 hashes."""
print(f"Connecting to database: {db_path}")
if not Path(db_path).exists():
print(f"Error: Database file not found: {db_path}")
sys.exit(1)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Get all players
cursor.execute("SELECT username, password FROM players;")
players = cursor.fetchall()
if not players:
print("No players found in database.")
return
print(f"Found {len(players)} player(s)")
print("-" * 60)
migrated = 0
skipped = 0
errors = 0
for username, password in players:
if not password:
print(f"[SKIP] {username}: empty password")
skipped += 1
continue
if is_md5_hash(password):
print(f"[SKIP] {username}: already MD5 hashed")
skipped += 1
continue
# Hash the password
hashed = hash_password(password)
try:
cursor.execute(
"UPDATE players SET password = ? WHERE username = ?;",
(hashed, username),
)
print(f"[MIGRATED] {username}: '{password}' -> '{hashed}'")
migrated += 1
except sqlite3.Error as e:
print(f"[ERROR] {username}: {e}")
errors += 1
print("-" * 60)
print(f"Summary: {migrated} migrated, {skipped} skipped, {errors} errors")
# Commit changes
conn.commit()
print("Changes committed to database.")
except sqlite3.Error as e:
print(f"Database error: {e}")
conn.rollback()
sys.exit(1)
finally:
conn.close()
def main():
# Get database path from command line or use default
if len(sys.argv) > 1:
db_path = sys.argv[1]
else:
db_path = "gralias.db"
migrate_passwords(db_path)
if __name__ == "__main__":
main()

View File

@@ -14,12 +14,12 @@ type TurnEndCallback func(ctx context.Context, roomID string)
type TickCallback func(ctx context.Context, roomID string, timeLeft uint32)
type RoomTimer struct {
ticker *time.Ticker
done chan bool
roomID string
ticker *time.Ticker
done chan bool
roomID string
onTurnEnd TurnEndCallback
onTick TickCallback
log *slog.Logger
onTick TickCallback
log *slog.Logger
}
var (
@@ -28,7 +28,7 @@ var (
)
// StartTurnTimer initializes and starts a new turn timer for a given room.
func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEnd TurnEndCallback, onTick TickCallback, logger *slog.Logger) {
func StartTurnTimer(ctx context.Context, roomID string, timeLeft uint32, onTurnEnd TurnEndCallback, onTick TickCallback, logger *slog.Logger) {
mu.Lock()
defer mu.Unlock()
@@ -39,14 +39,14 @@ func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEn
ticker := time.NewTicker(1 * time.Second)
done := make(chan bool)
rt := &RoomTimer{
ticker: ticker,
done: done,
roomID: roomID,
ticker: ticker,
done: done,
roomID: roomID,
onTurnEnd: onTurnEnd,
onTick: onTick,
log: logger,
onTick: onTick,
log: logger,
}
timers[roomID] = rt
@@ -62,7 +62,7 @@ func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEn
StopTurnTimer(roomID)
return
}
rt.onTick(ctx, roomID, uint32(currentLeft))
rt.onTick(ctx, roomID, currentLeft)
currentLeft--
}
}

View File

@@ -21,8 +21,8 @@
- redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
- better styles and fluff;
- common auth system between sites;
- signup vs login; +
- passwords (to room and to login); +
- signup vs login;
- passwords (to room and to login);
===
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
- gameover to backlog;
@@ -33,12 +33,9 @@
- possibly turn markings into parts of names of users (first three letters?); +
- at game creation list languages and support them at backend; +
- sql ping goroutine with reconnect on fail; +
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words. +
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words.
- at the end of the game, all colors should be revealed;
- tracing;
====
- action history to bot (not json); basic stuff: what words were previously given as clue and guessed (maybe what team bot is on);
- automate getting list of free and non-thinking openrouter models;
#### sse points
- clue sse update;
@@ -94,18 +91,5 @@
- mime sees the clue input out of turn; (eh)
- there is a problem of two timers, they both could switch turn, but it is not easy to stop them from llmapi or handlers. +
- journal still does not work; +
- lose/win game; then exit room (while being the creator), then press to stats -> cannot find session in db, although cookie in place and session in db; +
- exit endpoints delets player from db; +
- timer end did not update the page;
- timer did not change turn (guesser did not manage to guess 1 word);
- timers conflict; stop timers;
- clue snatching;
- llm resp token amount limit;
=============
- autoscroll backlog to the last action; (reversed order) +
- mimes to see marks on the words; +
- clearer ways to see opened words; (line through) +
- guesser sees end turn button before clue was given by mime; +
- sql no rows when joining by link?
- lose/win game; then exit room (while being the creator), then press to stats -> cannot find session in db, although cookie in place and session in db;
- exit endpoints delets player from db;

View File

@@ -5,10 +5,6 @@ import (
"unicode"
)
const (
SaneLengthMax = 20
)
func RemoveSpacesFromStr(origin string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
@@ -39,13 +35,3 @@ func RemoveFromSlice(key string, sl []string) []string {
}
return resp
}
func IsInputSane(s string) bool {
if len(s) > SaneLengthMax {
return false
}
if strings.ContainsAny(s, "{}?!$&:/[]~") {
return false
}
return true
}