Compare commits

...

11 Commits

Author SHA1 Message Date
Grail Finder
f61de5645d Fix: last clue; give clue mime input 2025-09-14 21:16:57 +03:00
Grail Finder
d076bd1348 Chore: remove ный words 2025-09-14 20:13:16 +03:00
Grail Finder
33bda503fc Fix: join room by link to load css 2025-09-11 19:29:03 +03:00
Grail Finder
1664c26c0a Fix: mime to see marks; reverse actions order; hide endturn button 2025-09-11 18:42:41 +03:00
Grail Finder
6daab9cb1a Enha: mime to see marks 2025-09-10 09:38:07 +03:00
Grail Finder
a1f38c2b26 Enha: use ormodels 2025-09-02 10:12:11 +03:00
Grail Finder
4fed716f8c Feat: openrouter fetch/update free model list 2025-08-03 17:37:34 +03:00
Grail Finder
ab60476688 Chore: word cleaning 2025-07-27 09:28:01 +03:00
Grail Finder
57a2abc1f9 Fix: timer update [WIP] 2025-07-16 11:51:27 +03:00
Grail Finder
995f9f6249 Fix: timers and styles 2025-07-16 11:06:13 +03:00
Grail Finder
ccf0b8538f Enha: shorter sse patience; style cleaning 2025-07-16 07:34:44 +03:00
25 changed files with 344 additions and 344 deletions

View File

@@ -2,12 +2,13 @@ body{
background-color: #0C1616FF; background-color: #0C1616FF;
color: #8896b2; color: #8896b2;
min-width: 0px; min-width: 0px;
margin: 2em auto !important; margin: 2em 2em !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;
} }
@@ -22,18 +23,6 @@ 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;

Binary file not shown.

View File

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

@@ -9,7 +9,7 @@ import (
// 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.Second * 1 const patience time.Duration = time.Millisecond * 500
type ( type (
NotificationEvent struct { NotificationEvent struct {

View File

@@ -1,27 +1,17 @@
{{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"> <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">
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.scrollToBottom();
}
});
window.actionHistoryScrollSet = true;
}
</script>
{{end}} {{end}}

View File

@@ -3,7 +3,69 @@
<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}}
{{template "cardword" .}} {{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}}
{{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 .Mime}} {{else if or (.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"> <form hx-post="/room-create" hx-target="#main-content" class="space-y-4">
<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="300"/><br/> <input type="number" id="game_time" name="game_time" class="text-center text-white" value="120"/><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-orange-500 text-orange-700 p-4" role="alert"> <div id=errorbox class="bg-orange-100 border-l-4 border-black text-black 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>

11
components/journal.html Normal file
View File

@@ -0,0 +1,11 @@
{{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,19 +1,21 @@
{{define "login"}} {{define "login"}}
<div id="logindiv"> <div id="logindiv">
<form class="space-y-6" hx-post="/login" hx-target="#main-content"> <form hx-post="/login" hx-target="#main-content" class="space-y-4">
<div> <div>
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username (signup|login)</label> <label For="username" class="text-sm text-center 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="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="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"/>
</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="block text-sm font-medium leading-6 text-white-900">password</label> <label For="password" class="text-sm font-medium text-center 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 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>
</div> </div>
<div> <div>
<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> <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>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -53,29 +53,23 @@
</div> </div>
<hr/> <hr/>
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4"> <div class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}" class="md:col-span-1"> <div class="md:col-span-1">
{{template "actionhistory" .Room.ActionHistory}} {{template "actionhistory" .Room.ActionHistory}}
</div> </div>
<div id="cardtable" class="md:col-span-3"> <div id="cardtable" class="md:col-span-3">
{{template "cardtable" .Room}} {{template "cardtable" .Room}}
</div> </div>
<div class="hidden md:block md:col-span-1"></div> <!-- Spacer --> <div class="hidden md:block md:col-span-1">
</div> {{template "journal" .Room}}
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2"> </div> <!-- Spacer -->
bot thought: <br> </div>
<ul>
{{range .Room.LogJournal}}
<li>{{.Username}}: {{.Entry}}</li>
{{end}}
</ul>
</div>
<div> <div>
{{if .Room.IsRunning}} {{if .Room.IsRunning}}
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}} {{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> <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)}} {{else if and (eq .State.Role "mime") (not .Room.MimeDone) (eq .State.Team .Room.TeamTurn)}}
{{template "mimeclue"}} {{template "mimeclue"}}
{{end}} {{end}}
{{end}} {{end}}
</div> </div>
<div> <div>

View File

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

View File

@@ -253,13 +253,16 @@ 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
} }
@@ -271,6 +274,7 @@ 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
} }
@@ -283,7 +287,7 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil { if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
log.Error("failed to execute room template", "error", err) log.Error("failed to execute room template", "error", err)
} }
} }

View File

@@ -9,6 +9,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"slices"
"strings"
) )
var ( var (
@@ -55,6 +57,12 @@ 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())
@@ -80,6 +88,11 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", 302) http.Redirect(w, r, "/", 302)
return return
} }
if fi.Room == nil {
log.Error("failed to fetch room")
http.Redirect(w, r, "/", 302)
return
}
if fi.Room.IsRunning { if fi.Room.IsRunning {
abortWithError(w, "cannot leave when game is running") abortWithError(w, "cannot leave when game is running")
return return
@@ -106,8 +119,9 @@ 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, "") notify(models.NotifyRoomListUpdate, "") // why is it needed?
} 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

@@ -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.RoomGetByID(context.Background(), roomID) room, err := repo.RoomGetExtended(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,9 +31,10 @@ 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, timeLeft, onTurnEnd, onTick, logger) timer.StartTurnTimer(context.Background(), roomID, int32(timeLeft), onTurnEnd, onTick, logger)
} }
func StopTurnTimer(roomID string) { func StopTurnTimer(roomID string) {
timer.StopTurnTimer(roomID) timer.StopTurnTimer(roomID)
} }

View File

@@ -95,3 +95,52 @@ 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
}

64
llmapi/or.go Normal file
View File

@@ -0,0 +1,64 @@
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,21 +158,12 @@ 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 := models[int(p.modelIndex)%len(models)] model := ORFreeModels[int(p.modelIndex)%len(ORFreeModels)]
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.RoomGetByID(context.Background(), roomID) room, err := repos.RP.RoomGetExtended(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,9 +41,10 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
} }
} }
timer.StartTurnTimer(context.Background(), b.RoomID, timeLeft, onTurnEnd, onTick, logger) timer.StartTurnTimer(context.Background(), b.RoomID, int32(timeLeft), onTurnEnd, onTick, logger)
} }
func (b *Bot) StopTurnTimer() { func (b *Bot) StopTurnTimer() {
timer.StopTurnTimer(b.RoomID) timer.StopTurnTimer(b.RoomID)
} }

View File

@@ -5,6 +5,7 @@ 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"
@@ -22,6 +23,7 @@ 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

View File

@@ -228,7 +228,16 @@ func (r *Room) FetchLastClue() (*Action, error) {
} }
func (r *Room) FetchLastClueWord() string { func (r *Room) FetchLastClueWord() string {
for i := len(r.ActionHistory) - 1; i >= 0; i-- { 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++ {
if r.ActionHistory[i].Action == string(ActionTypeClue) { if r.ActionHistory[i].Action == string(ActionTypeClue) {
return r.ActionHistory[i].Word return r.ActionHistory[i].Word
} }
@@ -410,14 +419,22 @@ 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"`
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime // pain; but at the end of the game players should see color of unopen cards
Marks []CardMark `json:"marks" db:"-"` IsOver bool
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

@@ -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 uint32, onTurnEnd TurnEndCallback, onTick TickCallback, logger *slog.Logger) { func StartTurnTimer(ctx context.Context, roomID string, timeLeft int32, onTurnEnd TurnEndCallback, onTick TickCallback, logger *slog.Logger) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
@@ -39,14 +39,14 @@ func StartTurnTimer(ctx context.Context, roomID string, timeLeft uint32, onTurnE
ticker := time.NewTicker(1 * time.Second) ticker := time.NewTicker(1 * time.Second)
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 uint32, onTurnE
StopTurnTimer(roomID) StopTurnTimer(roomID)
return return
} }
rt.onTick(ctx, roomID, currentLeft) rt.onTick(ctx, roomID, uint32(currentLeft))
currentLeft-- currentLeft--
} }
} }

View File

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