Compare commits

4 Commits

Author SHA1 Message Date
Grail Finder
817d69c425 Enha: sse update 2025-08-03 16:13:51 +03:00
Grail Finder
acc3f11ee3 Enha: remove extra connection 2025-07-17 23:02:51 +03:00
Grail Finder
9fc36eb7ea Merge branch 'master' into enha/sse-try 2025-07-15 16:13:59 +03:00
Grail Finder
8f9865db3f Enha: simplify sse (worsened event recieving) 2025-07-12 20:33:41 +03:00
31 changed files with 412 additions and 537 deletions

View File

@@ -2,13 +2,12 @@ body{
background-color: #0C1616FF;
color: #8896b2;
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 +22,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

@@ -1,15 +1,17 @@
package broker
import (
"context"
"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 {
@@ -19,23 +21,23 @@ type (
NotifierChan chan NotificationEvent
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{}
Notifier NotifierChan
log *slog.Logger
addClient chan NotifierChan
clients map[NotifierChan]struct{}
}
)
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,
})),
addClient: make(chan NotifierChan, 10),
clients: map[NotifierChan]struct{}{},
}
}
@@ -44,7 +46,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,29 +60,38 @@ 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()
msgChan := make(NotifierChan, 10)
broker.addClient <- msgChan
// browser can close sse on its own; ping every 2s to prevent
heartbeat := time.NewTicker(2 * time.Second)
heartbeat := time.NewTicker(8 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
broker.log.Debug("broker: got ctx done")
// Client disconnected
return
case event := <-messageChan:
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil {
fmt.Println(err)
// Client disconnected
return
case event := <-broker.Notifier:
broker.log.Debug("got event", "event", event)
for i := 0; i < 10; i++ { // Repeat 3 times
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil {
broker.log.Error("write failed", "error", err)
return
}
w.(http.Flusher).Flush()
// Short delay between sends (non-blocking)
select {
case <-time.After(20 * time.Millisecond): // Adjust delay as needed
case <-ctx.Done():
return
}
}
w.(http.Flusher).Flush()
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()
@@ -89,38 +99,14 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// Listen for new notifications and redistribute them to clients
func (broker *Broker) Listen() {
slog.Info("Broker listener started")
func (broker *Broker) Listen(ctx context.Context) {
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")
case <-ctx.Done():
return
case clientChan := <-broker.addClient:
// mutex
broker.clients[clientChan] = struct{}{}
}
}
}

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.scrollToBottom();
}
});
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,5 +1,5 @@
{{define "room"}}
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}" class=space-y-2>
<div id="room-interier" 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">
<p>Hello {{.State.Username}};</p>
@@ -53,23 +53,29 @@
</div>
<hr/>
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div class="md:col-span-1">
<div hx-get="/actionhistory" 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 class="hidden md:block md:col-span-1"></div> <!-- Spacer -->
</div>
<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>
{{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>

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

@@ -213,12 +213,7 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
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)
@@ -339,3 +334,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

@@ -253,16 +253,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 +271,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 +283,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)
}
}

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())
@@ -88,11 +80,6 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", 302)
return
}
if fi.Room == nil {
log.Error("failed to fetch room")
http.Redirect(w, r, "/", 302)
return
}
if fi.Room.IsRunning {
abortWithError(w, "cannot leave when game is running")
return
@@ -119,9 +106,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

@@ -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

@@ -228,16 +228,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 +410,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

@@ -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

@@ -36,9 +36,6 @@
- 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;
@@ -96,16 +93,3 @@
- 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?

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
}