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{ body{
background-color: #0C1616FF; background-color: #0C1616FF;
color: #8896b2; color: #8896b2;
max-width: 1000px;
min-width: 0px; min-width: 0px;
margin: 2em 2em !important; margin: 2em auto !important;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
line-height: 1.5; line-height: 1.5;
font-size: 16px; font-size: 16px;
font-family: Open Sans,Arial; font-family: Open Sans,Arial;
font-weight: bold;
text-align: center; text-align: center;
display: block; display: block;
} }
@@ -23,6 +23,18 @@ tr{
#usertable{ #usertable{
display: block ruby; 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{ #errorbox{
border: 1px solid black; border: 1px solid black;
background-color: darkorange; background-color: darkorange;

BIN
assets/style.css.gz Normal file

Binary file not shown.

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,13 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"time" "time"
) )
// the amount of time to wait when pushing a message to // the amount of time to wait when pushing a message to
// a slow client or a client that closed after `range clients` started. // 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 ( type (
NotificationEvent struct { NotificationEvent struct {
@@ -20,22 +21,18 @@ type (
Broker struct { Broker struct {
// Events are pushed to this channel by the main events-gathering routine // Events are pushed to this channel by the main events-gathering routine
Notifier NotifierChan Notifier NotifierChan
// New client connections log *slog.Logger
newClients chan NotifierChan
// Closed client connections
closingClients chan NotifierChan
// Client connections registry
clients map[NotifierChan]struct{}
} }
) )
func NewBroker() (broker *Broker) { func NewBroker() (broker *Broker) {
// Instantiate a broker // Instantiate a broker
return &Broker{ return &Broker{
Notifier: make(NotifierChan, 1), Notifier: make(NotifierChan, 100),
newClients: make(chan NotifierChan), log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
closingClients: make(chan NotifierChan), Level: slog.LevelDebug,
clients: make(map[NotifierChan]struct{}), AddSource: true,
})),
} }
} }
@@ -44,7 +41,6 @@ var Notifier *Broker
// for use in different packages // for use in different packages
func init() { func init() {
Notifier = NewBroker() Notifier = NewBroker()
go Notifier.Listen()
} }
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true") 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() ctx := r.Context()
// browser can close sse on its own; ping every 2s to prevent // browser can close sse on its own; ping every 2s to prevent
heartbeat := time.NewTicker(2 * time.Second) heartbeat := time.NewTicker(2 * time.Second)
@@ -69,12 +62,14 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
broker.log.Debug("broker: got ctx done")
// Client disconnected // Client disconnected
return 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) _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil { if err != nil {
fmt.Println(err) broker.log.Error("failed to write event", "error", err)
// Client disconnected // Client disconnected
return return
} }
@@ -82,45 +77,10 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case <-heartbeat.C: case <-heartbeat.C:
// Send SSE heartbeat comment // Send SSE heartbeat comment
if _, err := fmt.Fprint(w, ":\n\n"); err != nil { if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
broker.log.Error("failed to write heartbeat", "error", err)
return // Client disconnected return // Client disconnected
} }
w.(http.Flusher).Flush() 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"}} {{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: Backlog:
{{range .}} {{range .}}
<div class="flex items-center justify-between p-2 rounded"> <div class="flex items-center justify-between p-2 rounded">
<span class="font-mono text-sm"> <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-gray-600">{{.Action}}:</span>
<span class="text-{{.WordColor}}-500 font-medium">{{.Word}}</span> <span class="text-{{.WordColor}}-500 font-medium">{{.Word}}</span>
{{if .Number}} {{if .Number}}
<span class="text-gray-400">- {{.Number}}</span> <span class="text-gray-400">- {{.Number}}</span>
{{end}} {{end}}
</span> </span>
</div> </div>
{{end}} {{end}}
</div> </div>
<script>
if (!window.actionHistoryScrollSet) {
htmx.onLoad(function(target) {
if (target.id === 'actionHistoryContainer') {
target.scrollTop = target.scrollHeight;
}
});
window.actionHistoryScrollSet = true;
}
</script>
{{end}} {{end}}

View File

@@ -13,6 +13,12 @@
</head> </head>
<body> <body>
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse"> <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"> <div id="main-content">
{{template "main" .}} {{template "main" .}}
</div> </div>

View File

@@ -3,69 +3,7 @@
<div class="flex justify-center"> <div class="flex justify-center">
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2"> <div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
{{range .Cards}} {{range .Cards}}
{{if .Revealed}} {{template "cardword" .}}
{{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}}
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@@ -9,7 +9,7 @@
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}} style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
</div> </div>
{{end}} {{end}}
{{else if or (.Mime) }} {{else if .Mime}}
{{if eq .Color "amber"}} {{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" <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}} style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}

View File

@@ -4,9 +4,9 @@
Create a room <br/> Create a room <br/>
or<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> <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/> <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/> <label For="language">Language:</label><br/>
<div> <div>
<select class="form-select text-white text-center bg-gray-900" id="languages" name="language"> <select class="form-select text-white text-center bg-gray-900" id="languages" name="language">

View File

@@ -1,6 +1,6 @@
{{define "error"}} {{define "error"}}
<a href="/"> <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 class="font-bold">An error from server</p>
<p>{{.}}</p> <p>{{.}}</p>
<p>Click this banner to return to main page.</p> <p>Click this banner to return to main page.</p>

View File

@@ -22,6 +22,7 @@
{{template "roomlist" .List}} {{template "roomlist" .List}}
</div> </div>
{{else}} {{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"> <div id="room">
{{template "room" .}} {{template "room" .}}
</div> </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"}} {{define "login"}}
<div id="logindiv"> <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> <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"> <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>
<div id="login_notice">this name looks available</div> <div id="login_notice">this name looks available</div>
</div> </div>
<div> <div>
<label For="password" class="text-sm font-medium text-center leading-6 text-white-900">password</label> <label For="password" class="block text-sm font-medium leading-6 text-white-900">password</label>
<div class="mt-2"> <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"/>
<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>
</div> </div>
<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> </div>
</form> </form>
</div> </div>

View File

@@ -1,7 +1,6 @@
{{define "room"}} {{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">
<div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2">
<p>Hello {{.State.Username}};</p> <p>Hello {{.State.Username}};</p>
<p>Room created by {{.Room.CreatorName}};</p> <p>Room created by {{.Room.CreatorName}};</p>
<p>Room link:</p> <p>Room link:</p>
@@ -17,13 +16,13 @@
{{end}} {{end}}
<p> <p>
{{if eq .State.Team ""}} {{if eq .State.Team ""}}
you don't have a role! join the team -> join the team!
{{else}} {{else}}
you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>! you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>!
{{end}} {{end}}
</p> </p>
</div> </div>
<div id="infopatch" class="md:col-span-3"> <hr />
{{if .Room.IsRunning}} {{if .Room.IsRunning}}
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p> <p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p>
{{template "turntimer" .Room}} {{template "turntimer" .Room}}
@@ -49,31 +48,33 @@
<!-- Right Panel --> <!-- Right Panel -->
{{template "teamlist" .Room.RedTeam}} {{template "teamlist" .Room.RedTeam}}
</div> </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>
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
{{template "actionhistory" .Room.ActionHistory}}
</div> </div>
<hr/> <hr/>
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4"> <div id="cardtable">
<div class="md:col-span-1"> {{template "cardtable" .Room}}
{{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>
<div> <div>
{{if .Room.IsRunning}} {{if .Room.IsRunning}}
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn) (.Room.MimeDone)}} {{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> <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)}} {{else if and (eq .State.Role "mime") (not .Room.MimeDone)}}
{{template "mimeclue"}} {{template "mimeclue"}}
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
<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> <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}} {{end}}
</div> </div>

View File

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

View File

@@ -42,9 +42,8 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
return return
} }
fi, err := getFullInfoByCtx(ctx) fi, err := getFullInfoByCtx(ctx)
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
if err := validateMove(fi, models.UserRoleGuesser); err != nil { if err := validateMove(fi, models.UserRoleGuesser); err != nil {
@@ -207,18 +206,12 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
return return
} }
fi, err := getFullInfoByCtx(ctx) fi, err := getFullInfoByCtx(ctx)
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
if err := validateMove(fi, models.UserRoleGuesser); err != nil { if err := validateMove(fi, models.UserRoleGuesser); err != nil {
// abortWithError(w, err.Error()) 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")
return return
} }
color, exists := fi.Room.FindColor(word) 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) { func HandleActionHistory(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
@@ -301,9 +293,8 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
team := r.URL.Query().Get("team") team := r.URL.Query().Get("team")
role := r.URL.Query().Get("role") role := r.URL.Query().Get("role")
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
var botname string var botname string
@@ -328,9 +319,8 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
botName := r.URL.Query().Get("bot") botName := r.URL.Query().Get("bot")
log.Debug("got remove-bot request", "bot_name", botName) log.Debug("got remove-bot request", "bot_name", botName)
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
if err := llmapi.RemoveBot(botName, fi.Room); err != nil { 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, "") 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 // get username
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
if fi.Room == nil { if fi.Room == nil {
@@ -112,9 +111,8 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
func HandleEndTurn(w http.ResponseWriter, r *http.Request) { func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
// get username // get username
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
// check if one who pressed it is from the team who has the turn // 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) { func HandleStartGame(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
// check if enough players // check if enough players
@@ -253,16 +250,13 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) { func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("id") roomID := r.URL.Query().Get("id")
log.Debug("got join-room request", "id", roomID)
room, err := repo.RoomGetExtended(r.Context(), roomID) room, err := repo.RoomGetExtended(r.Context(), roomID)
if err != nil { if err != nil {
log.Error("failed to fetch room", "error", err, "room_id", roomID)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
if err != nil { if err != nil {
log.Error("failed to parse templates", "error", err, "room_id", roomID)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@@ -274,7 +268,6 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil { if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
log.Error("failed to execute base template", "error", err) log.Error("failed to execute base template", "error", err)
} }
log.Error("failed to fetch fi", "error", err, "room_id", roomID)
// abortWithError(w, err.Error()) // abortWithError(w, err.Error())
return return
} }
@@ -287,7 +280,7 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return 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) 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") clue := r.PostFormValue("clue")
num := r.PostFormValue("number") num := r.PostFormValue("number")
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
guessLimitU64, err := strconv.ParseUint(num, 10, 8) 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) { func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return return
} }
notifyBotIfNeeded(fi.Room) notifyBotIfNeeded(fi.Room)

View File

@@ -9,8 +9,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"slices"
"strings"
) )
var ( var (
@@ -57,12 +55,6 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
} else { } else {
fi.Room.GuesserView() 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 { if fi != nil && fi.Room == nil {
rooms, err := repo.RoomList(r.Context()) rooms, err := repo.RoomList(r.Context())
@@ -83,14 +75,8 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
return return
} }
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil || fi == nil { if err != nil {
log.Error("failed to fetch fi", "error", err) abortWithError(w, err.Error())
http.Redirect(w, r, "/", 302)
return
}
if fi.Room == nil {
log.Error("failed to fetch room")
http.Redirect(w, r, "/", 302)
return return
} }
if fi.Room.IsRunning { 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 { if err := repo.RoomDeleteByID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove room", "error", err) log.Error("failed to remove room", "error", err)
} }
notify(models.NotifyRoomListUpdate, "") // why is it needed? notify(models.NotifyRoomListUpdate, "")
} else { } else {
notify(models.NotifyRoomUpdatePrefix, "")
// if regular player leaves, just exit room // if regular player leaves, just exit room
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil { if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
log.Error("failed to exit room", "error", err) 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) logger := slog.Default().With("room_id", roomID)
onTurnEnd := func(ctx context.Context, roomID string) { onTurnEnd := func(ctx context.Context, roomID string) {
room, err := repo.RoomGetExtended(context.Background(), roomID) room, err := repo.RoomGetByID(context.Background(), roomID)
if err != nil { if err != nil {
logger.Error("failed to get room by id", "error", err) logger.Error("failed to get room by id", "error", err)
return return
@@ -23,7 +23,7 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
if err := repo.RoomUpdate(context.Background(), room); err != nil { if err := repo.RoomUpdate(context.Background(), room); err != nil {
logger.Error("failed to save room", "error", err) 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) notifyBotIfNeeded(room)
} }
@@ -31,10 +31,9 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10)) 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) { func StopTurnTimer(roomID string) {
timer.StopTurnTimer(roomID) timer.StopTurnTimer(roomID)
} }

View File

@@ -25,10 +25,10 @@ var (
DoneChanMap = make(map[string]chan bool) DoneChanMap = make(map[string]chan bool)
mapMutex = &sync.RWMutex{} mapMutex = &sync.RWMutex{}
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0 // 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` // 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`
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.` // 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`
GuesserSimplePromptRU = `мы играем в alias;\n тебе дана подсказка (clue): \"%s\";\nпожалуйста, верни свою догадку (guess), а также слова, что тоже подходят к подсказке, но ты меньше в них уверен, в формате json; пример:\n{\n\"guess\": \"отгадка\",\n\"could_be\": [\"слово1\", \"слово2\", ...]\n}\nвот список слов из которых нужно выбрать:\n%v` 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`
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.` 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) { func convertToSliceOfStrings(value any) ([]string, error) {
@@ -188,11 +188,6 @@ func (b *Bot) BotMove() {
b.log.Error("bot loop", "error", err) b.log.Error("bot loop", "error", err)
return 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.NotifyBacklogPrefix + room.ID
eventName := models.NotifyRoomUpdatePrefix + room.ID eventName := models.NotifyRoomUpdatePrefix + room.ID
eventPayload := "" eventPayload := ""
@@ -236,9 +231,6 @@ func (b *Bot) BotMove() {
b.log.Warn("failed to write to journal", "entry", lj) b.log.Warn("failed to write to journal", "entry", lj)
} }
b.log.Error("bot loop", "error", err) 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 return
} }
tempMap, err := b.LLMParser.ParseBytes(llmResp) tempMap, err := b.LLMParser.ParseBytes(llmResp)
@@ -553,9 +545,6 @@ func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
} }
words[i] = card.Word 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) return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
} }
@@ -584,20 +573,34 @@ func (b *Bot) BuildSimpleMimePrompt(room *models.Room) string {
theirwords = append(theirwords, card.Word) theirwords = append(theirwords, card.Word)
} }
} }
if strings.EqualFold(room.Settings.Language, "ru") { return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord, room.BlueCounter, room.RedCounter)
return fmt.Sprintf(MimeSimplePromptRU, ourwords, theirwords, blackWord)
}
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord)
} }
func (b *Bot) BuildPrompt(room *models.Room) string { func (b *Bot) BuildPrompt(room *models.Room) string {
if b.Role == "" { if b.Role == "" {
return "" 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 { 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) return b.BuildSimpleMimePrompt(room)
} }
if b.Role == models.UserRoleGuesser { if b.Role == models.UserRoleGuesser {
// return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
return b.BuildSimpleGuesserPrompt(room) return b.BuildSimpleGuesserPrompt(room)
} }
return "" 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) b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
return body, nil 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") return nil, errors.New("unknown error in retry loop")
} }

View File

@@ -95,52 +95,3 @@ func (b *Bot) ToPlayer() *models.Player {
IsBot: true, 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 { 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 // Get next model index using atomic addition for thread safety
p.modelIndex++ p.modelIndex++
model := ORFreeModels[int(p.modelIndex)%len(ORFreeModels)] model := models[int(p.modelIndex)%len(models)]
strPayload := fmt.Sprintf(`{ strPayload := fmt.Sprintf(`{
"model": "%s", "model": "%s",
"max_tokens": 300,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ CREATE TABLE rooms (
mime_done BOOLEAN NOT NULL DEFAULT FALSE, mime_done BOOLEAN NOT NULL DEFAULT FALSE,
is_running BOOLEAN NOT NULL DEFAULT FALSE, is_running BOOLEAN NOT NULL DEFAULT FALSE,
is_over 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 '', team_won TEXT NOT NULL DEFAULT '',
room_link TEXT NOT NULL DEFAULT '' room_link TEXT NOT NULL DEFAULT ''
); );

View File

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

View File

@@ -15,20 +15,6 @@ type RoomsRepo interface {
RoomCreate(ctx context.Context, room *models.Room) error RoomCreate(ctx context.Context, room *models.Room) error
RoomDeleteByID(ctx context.Context, id string) error RoomDeleteByID(ctx context.Context, id string) error
RoomUpdate(ctx context.Context, room *models.Room) 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) { 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 TickCallback func(ctx context.Context, roomID string, timeLeft uint32)
type RoomTimer struct { type RoomTimer struct {
ticker *time.Ticker ticker *time.Ticker
done chan bool done chan bool
roomID string roomID string
onTurnEnd TurnEndCallback onTurnEnd TurnEndCallback
onTick TickCallback onTick TickCallback
log *slog.Logger log *slog.Logger
} }
var ( var (
@@ -28,7 +28,7 @@ var (
) )
// StartTurnTimer initializes and starts a new turn timer for a given room. // 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() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
@@ -41,12 +41,12 @@ func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEn
done := make(chan bool) done := make(chan bool)
rt := &RoomTimer{ rt := &RoomTimer{
ticker: ticker, ticker: ticker,
done: done, done: done,
roomID: roomID, roomID: roomID,
onTurnEnd: onTurnEnd, onTurnEnd: onTurnEnd,
onTick: onTick, onTick: onTick,
log: logger, log: logger,
} }
timers[roomID] = rt timers[roomID] = rt
@@ -62,7 +62,7 @@ func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEn
StopTurnTimer(roomID) StopTurnTimer(roomID)
return return
} }
rt.onTick(ctx, roomID, uint32(currentLeft)) rt.onTick(ctx, roomID, currentLeft)
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; + - 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; - better styles and fluff;
- common auth system between sites; - common auth system between sites;
- signup vs login; + - signup vs login;
- passwords (to room and to 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; - 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; - gameover to backlog;
@@ -33,12 +33,9 @@
- possibly turn markings into parts of names of users (first three letters?); + - possibly turn markings into parts of names of users (first three letters?); +
- at game creation list languages and support them at backend; + - at game creation list languages and support them at backend; +
- sql ping goroutine with reconnect on fail; + - 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; - at the end of the game, all colors should be revealed;
- tracing; - 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 #### sse points
- clue sse update; - clue sse update;
@@ -94,18 +91,5 @@
- mime sees the clue input out of turn; (eh) - 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. + - 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; + - 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; + - 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; + - 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" "unicode"
) )
const (
SaneLengthMax = 20
)
func RemoveSpacesFromStr(origin string) string { func RemoveSpacesFromStr(origin string) string {
return strings.Map(func(r rune) rune { return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) { if unicode.IsSpace(r) {
@@ -39,13 +35,3 @@ func RemoveFromSlice(key string, sl []string) []string {
} }
return resp return resp
} }
func IsInputSane(s string) bool {
if len(s) > SaneLengthMax {
return false
}
if strings.ContainsAny(s, "{}?!$&:/[]~") {
return false
}
return true
}