Compare commits
39 Commits
d4c57c3262
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f61de5645d | ||
![]() |
d076bd1348 | ||
![]() |
33bda503fc | ||
![]() |
1664c26c0a | ||
![]() |
6daab9cb1a | ||
![]() |
a1f38c2b26 | ||
![]() |
4fed716f8c | ||
![]() |
ab60476688 | ||
![]() |
57a2abc1f9 | ||
![]() |
995f9f6249 | ||
![]() |
ccf0b8538f | ||
![]() |
123d6c240f | ||
![]() |
6951ec0535 | ||
![]() |
ad44dc0642 | ||
![]() |
155aa1b2cb | ||
![]() |
757586ea22 | ||
![]() |
a934d07be3 | ||
![]() |
acf1386c73 | ||
![]() |
f01fc12510 | ||
![]() |
329f849a72 | ||
![]() |
134b7b6262 | ||
![]() |
ea27d35254 | ||
![]() |
9a949757f2 | ||
![]() |
7beccb84a2 | ||
![]() |
3cb43d5129 | ||
![]() |
566d645230 | ||
![]() |
b64c3a4eab | ||
![]() |
d41ed9d822 | ||
![]() |
37fe76456e | ||
![]() |
d056c4a07e | ||
![]() |
89572e8fb5 | ||
![]() |
8040586043 | ||
![]() |
8392a764a2 | ||
![]() |
ff6fed073e | ||
![]() |
6f83d98799 | ||
![]() |
c946c07542 | ||
![]() |
85edb2d0ce | ||
![]() |
a03253593c | ||
![]() |
5ba97d3423 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ gralias
|
||||
store.json
|
||||
config.toml
|
||||
gralias.db
|
||||
traces.log
|
||||
|
@@ -1,23 +1,17 @@
|
||||
body{
|
||||
background-color: #0C1616FF;
|
||||
color: #8896b2;
|
||||
max-width: 800px;
|
||||
min-width: 0px;
|
||||
margin: 2em auto !important;
|
||||
margin: 2em 2em !important;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.5;
|
||||
font-size: 16px;
|
||||
font-family: Open Sans,Arial;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
a{
|
||||
color: #00a2e7;
|
||||
}
|
||||
a:visited{
|
||||
color: #ca1a70;
|
||||
}
|
||||
table{
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 10px 10px;
|
||||
@@ -29,18 +23,6 @@ tr{
|
||||
#usertable{
|
||||
display: block ruby;
|
||||
}
|
||||
.actiontable{
|
||||
display: inline flow-root;
|
||||
margin-inline: 10px;
|
||||
}
|
||||
.action_name{
|
||||
border: none;
|
||||
display: inline;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: none;
|
||||
width: auto;
|
||||
}
|
||||
#errorbox{
|
||||
border: 1px solid black;
|
||||
background-color: darkorange;
|
||||
|
Binary file not shown.
@@ -146,7 +146,6 @@ rage
|
||||
southwest
|
||||
cycle
|
||||
roman
|
||||
jay
|
||||
stage
|
||||
ability
|
||||
duration
|
||||
@@ -545,7 +544,6 @@ norm
|
||||
imaginary
|
||||
pace
|
||||
weather
|
||||
marina
|
||||
week
|
||||
scale
|
||||
step
|
||||
@@ -885,7 +883,6 @@ ready
|
||||
theorem
|
||||
disagreement
|
||||
led
|
||||
benjamin
|
||||
era
|
||||
snow
|
||||
bowl
|
||||
@@ -1355,7 +1352,6 @@ male
|
||||
red
|
||||
objective
|
||||
bond
|
||||
jimmy
|
||||
opera
|
||||
foliage
|
||||
motive
|
||||
@@ -1420,7 +1416,6 @@ landscape
|
||||
chin
|
||||
sermon
|
||||
celebration
|
||||
sba
|
||||
curriculum
|
||||
market
|
||||
bullet
|
||||
@@ -1453,7 +1448,6 @@ profit
|
||||
conductor
|
||||
elevator
|
||||
brutality
|
||||
tom
|
||||
community
|
||||
hydrogen
|
||||
noon
|
||||
@@ -1527,7 +1521,6 @@ touch
|
||||
association
|
||||
abandon
|
||||
buddy
|
||||
christ
|
||||
passion
|
||||
coverage
|
||||
region
|
||||
@@ -1541,13 +1534,11 @@ prince
|
||||
availability
|
||||
rebel
|
||||
development
|
||||
pro
|
||||
raise
|
||||
sink
|
||||
value
|
||||
discovery
|
||||
fly
|
||||
warren
|
||||
overhead
|
||||
spell
|
||||
cross
|
||||
@@ -1622,7 +1613,6 @@ star
|
||||
improvement
|
||||
object
|
||||
permanent
|
||||
pat
|
||||
carpet
|
||||
separation
|
||||
sport
|
||||
@@ -1924,7 +1914,6 @@ investment
|
||||
ancient
|
||||
listener
|
||||
impulse
|
||||
magnum
|
||||
affair
|
||||
realm
|
||||
tea
|
||||
@@ -1956,7 +1945,6 @@ darkness
|
||||
longer
|
||||
emotion
|
||||
funeral
|
||||
pip
|
||||
there
|
||||
secondary
|
||||
obligation
|
||||
@@ -2008,7 +1996,6 @@ rail
|
||||
aesthetic
|
||||
player
|
||||
porter
|
||||
sam
|
||||
singular
|
||||
text
|
||||
tongue
|
||||
@@ -2119,7 +2106,6 @@ juvenile
|
||||
extent
|
||||
prayer
|
||||
gin
|
||||
hogan
|
||||
food
|
||||
explosion
|
||||
past
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// the amount of time to wait when pushing a message to
|
||||
// a slow client or a client that closed after `range clients` started.
|
||||
const patience time.Duration = time.Second * 1
|
||||
const patience time.Duration = time.Millisecond * 500
|
||||
|
||||
type (
|
||||
NotificationEvent struct {
|
||||
@@ -59,12 +59,12 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
messageChan := make(NotifierChan)
|
||||
messageChan := make(NotifierChan, 10) // Buffered channel
|
||||
broker.newClients <- messageChan
|
||||
defer func() { broker.closingClients <- messageChan }()
|
||||
ctx := r.Context()
|
||||
// browser can close sse on its own
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
// browser can close sse on its own; ping every 2s to prevent
|
||||
heartbeat := time.NewTicker(2 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -81,7 +81,7 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.(http.Flusher).Flush()
|
||||
case <-heartbeat.C:
|
||||
// Send SSE heartbeat comment
|
||||
if _, err := w.Write([]byte(": heartbeat\n\n")); err != nil {
|
||||
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
|
||||
return // Client disconnected
|
||||
}
|
||||
w.(http.Flusher).Flush()
|
||||
@@ -91,7 +91,9 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Listen for new notifications and redistribute them to clients
|
||||
func (broker *Broker) Listen() {
|
||||
slog.Info("Broker listener started")
|
||||
for {
|
||||
slog.Info("Broker waiting for event")
|
||||
select {
|
||||
case s := <-broker.newClients:
|
||||
// A new client has connected.
|
||||
@@ -104,16 +106,21 @@ func (broker *Broker) Listen() {
|
||||
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.Info("Client was removed", "clients listening", len(broker.clients))
|
||||
slog.Warn("Client timed out, removed", "client", clientMessageChan, "clients listening", len(broker.clients))
|
||||
}
|
||||
}
|
||||
slog.Info("Finished broadcasting event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{{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:
|
||||
{{range .}}
|
||||
<div class="flex items-center justify-between p-2 rounded">
|
||||
@@ -14,14 +14,4 @@
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<script>
|
||||
if (!window.actionHistoryScrollSet) {
|
||||
htmx.onLoad(function(target) {
|
||||
if (target.id === 'actionHistoryContainer') {
|
||||
target.scrollTop = target.scrollHeight;
|
||||
}
|
||||
});
|
||||
window.actionHistoryScrollSet = true;
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
|
@@ -10,40 +10,12 @@
|
||||
<script src="/assets/helpers.js"></script>
|
||||
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
|
||||
<style type="text/css">
|
||||
body{
|
||||
background-color: #0C1616FF;
|
||||
color: #8896b2;
|
||||
max-width: 1000px;
|
||||
min-width: 0;
|
||||
margin: 2em auto !important;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.5;
|
||||
font-size: 16px;
|
||||
font-family: Open Sans,Arial;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
a{
|
||||
color: #00a2e7;
|
||||
}
|
||||
a:visited{
|
||||
color: #ca1a70;
|
||||
}
|
||||
table {
|
||||
border-collapse: separate !important;
|
||||
border-spacing: 10px 10px;
|
||||
border: 1px solid white;
|
||||
}
|
||||
tr{
|
||||
border: 1px solid white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse">
|
||||
<div id="main-content">
|
||||
{{template "main" .}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -3,7 +3,69 @@
|
||||
<div class="flex justify-center">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||
{{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}}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -9,7 +9,7 @@
|
||||
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else if .Mime}}
|
||||
{{else if or (.Mime) }}
|
||||
{{if eq .Color "amber"}}
|
||||
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
|
||||
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
|
||||
|
@@ -4,12 +4,17 @@
|
||||
Create a room <br/>
|
||||
or<br/>
|
||||
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button>
|
||||
<form hx-post="/room-create" hx-target="#ancestor">
|
||||
<form hx-post="/room-create" hx-target="#main-content" class="space-y-4">
|
||||
<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/>
|
||||
<input type="text" id="language" name="language" class="text-center text-white" value="en"/><br/>
|
||||
<label For="password">Password:</label><br/>
|
||||
<div>
|
||||
<select class="form-select text-white text-center bg-gray-900" id="languages" name="language">
|
||||
<option value="en">English</option>
|
||||
<option value="ru">Russian</option>
|
||||
</select>
|
||||
</div>
|
||||
<label For="room_pass">Password:</label><br/>
|
||||
<input type="text" id="password" name="room_pass" class="text-center text-white" value="" placeholder="Leave empty for open room"/><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" type="submit" >Create Room</button>
|
||||
</form>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{{define "error"}}
|
||||
<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>{{.}}</p>
|
||||
<p>Click this banner to return to main page.</p>
|
||||
|
@@ -4,8 +4,16 @@
|
||||
{{ else if ne .LinkLogin "" }}
|
||||
{{template "linklogin" .LinkLogin}}
|
||||
{{ else if not .State.RoomID }}
|
||||
<div id="hello-user" class="text-xl py-2">
|
||||
<p>Hello {{.State.Username}}</p>
|
||||
<div id="hello-user" class="grid grid-cols-3 items-center text-xl py-2">
|
||||
<div class="text-left">
|
||||
<a href="/stats" class="bg-indigo-600 text-white font-semibold text-sm px-4 py-2 rounded hover:bg-indigo-500 visited:text-white">
|
||||
stats
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-center">Hello {{.State.Username}}</p>
|
||||
<div class="text-right">
|
||||
<a href="/signout"><button class="bg-indigo-600 text-white font-semibold text-sm px-4 py-2 rounded hover:bg-indigo-500">signout</button></a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="create-room" class="create-room-div">
|
||||
<button button id="create-form-btn" 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" hx-get="/room/createform" hx-swap="outerHTML">SHOW ROOM CREATE FORM</button>
|
||||
|
11
components/journal.html
Normal file
11
components/journal.html
Normal 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}}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{{define "linklogin"}}
|
||||
<div id="logindiv">
|
||||
You're about to join room#{{.}}; but first!
|
||||
<form class="space-y-6" hx-post="/login" hx-target="#ancestor">
|
||||
<form class="space-y-6" hx-post="/login" hx-target="#main-content">
|
||||
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label>
|
||||
<div class="mt-2">
|
||||
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="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"/>
|
||||
|
@@ -1,19 +1,21 @@
|
||||
{{define "login"}}
|
||||
<div id="logindiv">
|
||||
<form class="space-y-6" hx-post="/login" hx-target="#ancestor">
|
||||
<form hx-post="/login" hx-target="#main-content" class="space-y-4">
|
||||
<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">
|
||||
<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 id="login_notice">this name looks available</div>
|
||||
</div>
|
||||
<div>
|
||||
<label For="password" class="block text-sm font-medium leading-6 text-white-900">password</label>
|
||||
<input id="password" name="password" type="password" class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
||||
<label For="password" class="text-sm font-medium text-center leading-6 text-white-900">password</label>
|
||||
<div class="mt-2">
|
||||
<input id="password" name="password" type="password" class="rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
@@ -5,13 +5,13 @@
|
||||
<a href="/">
|
||||
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
|
||||
<p class="font-bold">Be Warned</p>
|
||||
<p>This Name is already taken. You won't be able to login with it until other person leaves.</p>
|
||||
<p>This Name is already taken. But if it's yours, you should know the password.</p>
|
||||
</div>
|
||||
</a>
|
||||
{{ else }}
|
||||
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
|
||||
<p class="font-bold">Be Warned</p>
|
||||
<p>This Name is already taken. You won't be able to login with it until other person leaves.</p>
|
||||
<p>This Name is already taken. But if it's yours, you should know the password.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
{{define "room"}}
|
||||
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}" class=space-y-2>
|
||||
<div id="meta">
|
||||
<div id="headwrapper" class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
|
||||
<div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2">
|
||||
<p>Hello {{.State.Username}};</p>
|
||||
<p>Room created by {{.Room.CreatorName}};</p>
|
||||
<p>Room link:</p>
|
||||
@@ -16,13 +17,13 @@
|
||||
{{end}}
|
||||
<p>
|
||||
{{if eq .State.Team ""}}
|
||||
join the team!
|
||||
you don't have a role! join the team ->
|
||||
{{else}}
|
||||
you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>!
|
||||
{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<hr />
|
||||
<div id="infopatch" class="md:col-span-3">
|
||||
{{if .Room.IsRunning}}
|
||||
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p>
|
||||
{{template "turntimer" .Room}}
|
||||
@@ -48,39 +49,37 @@
|
||||
<!-- Right Panel -->
|
||||
{{template "teamlist" .Room.RedTeam}}
|
||||
</div>
|
||||
<hr/>
|
||||
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
|
||||
bot thought: <br>
|
||||
<ul>
|
||||
{{range .Room.LogJournal}}
|
||||
<li>{{.Username}}: {{.Entry}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
|
||||
<div class="md:col-span-1">
|
||||
{{template "actionhistory" .Room.ActionHistory}}
|
||||
</div>
|
||||
<hr/>
|
||||
<div id="cardtable">
|
||||
<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>
|
||||
{{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>
|
||||
{{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"}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<div>
|
||||
{{if and (eq .State.Username .Room.CreatorName) (.Room.IsRunning)}}
|
||||
{{if and (eq .State.Username .Room.CreatorName) (.Room.BotFailed)}}
|
||||
<button hx-get="/renotify-bot" hx-swap="none" class="bg-gray-100 text-black px-1 py-1 rounded">Btn in case llm call failed</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if not .Room.IsRunning}}
|
||||
<div id="exitbtn">
|
||||
<button button id="exit-room-btn" 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" hx-get="/room/exit" hx-target="#ancestor">Exit Room</button>
|
||||
<button button id="exit-room-btn" 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" hx-get="/room/exit" hx-target="#main-content">Exit Room</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{{define "roomlist"}}
|
||||
<div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#ancestor">
|
||||
<div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#main-content">
|
||||
{{range .}}
|
||||
<div hx-get="/room-join?id={{.ID}}" hx-target="#ancestor" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div hx-get="/room-join?id={{.ID}}" hx-target="#main-content" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="room-info">
|
||||
<div class="text-sm text-gray-500">
|
||||
|
55
components/stats.html
Normal file
55
components/stats.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{{define "stats"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Alias</title>
|
||||
<script src="/assets/tailwind.css"></script>
|
||||
<link rel="stylesheet" href="/assets/style.css"/>
|
||||
<script src="/assets/htmx.min.js"></script>
|
||||
<script src="/assets/htmx.sse.js"></script>
|
||||
<script src="/assets/helpers.js"></script>
|
||||
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main-content">
|
||||
<div class="container mx-auto p-4">
|
||||
<h1 class="text-2xl font-bold mb-4">Player Leaderboard</h1>
|
||||
<div class="mb-4">
|
||||
<a href="/" class="bg-indigo-600 text-white font-semibold text-sm px-4 py-2 rounded hover:bg-indigo-500 visited:text-white">
|
||||
back home
|
||||
</a>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead class="bg-gray-800 text-white">
|
||||
<tr>
|
||||
<th class="py-2 px-4">Player</th>
|
||||
<th class="py-2 px-4">Rating</th>
|
||||
<th class="py-2 px-4">Games Played</th>
|
||||
<th class="py-2 px-4">Games Won</th>
|
||||
<th class="py-2 px-4">Games Lost</th>
|
||||
<th class="py-2 px-4">Mime Winrate</th>
|
||||
<th class="py-2 px-4">Guesser Winrate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-700">
|
||||
{{range .}}
|
||||
<tr>
|
||||
<td class="py-2 px-4 border">{{.Username}}</td>
|
||||
<td class="py-2 px-4 border">{{.Rating}}</td>
|
||||
<td class="py-2 px-4 border">{{.GamesPlayed}}</td>
|
||||
<td class="py-2 px-4 border">{{.GamesWon}}</td>
|
||||
<td class="py-2 px-4 border">{{.GamesLost}}</td>
|
||||
<td class="py-2 px-4 border">{{printf "%.2f" .MimeWinrate}}</td>
|
||||
<td class="py-2 px-4 border">{{printf "%.2f" .GuesserWinrate}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
@@ -2,7 +2,7 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h2 class="text-xl mb-4">Join Blue Team</h2>
|
||||
<form hx-post="/join-team" hx-target="#ancestor">
|
||||
<form hx-post="/join-team" hx-target="#main-content">
|
||||
<input type="hidden" name="team" value="blue">
|
||||
<div class="mb-1">
|
||||
{{if and (eq .State.Role "guesser") (eq .State.Team "blue")}}
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl mb-4">Join Red Team</h2>
|
||||
<form hx-post="/join-team" hx-target="#ancestor">
|
||||
<form hx-post="/join-team" hx-target="#main-content">
|
||||
<input type="hidden" name="team" value="red">
|
||||
<div class="mb-1">
|
||||
{{if and (eq .State.Role "guesser") (eq .State.Team "red")}}
|
||||
|
@@ -57,6 +57,7 @@ func (cm *CronManager) CleanupRooms() {
|
||||
}
|
||||
return
|
||||
}
|
||||
roomListChange := false
|
||||
for _, room := range rooms {
|
||||
players, err := cm.repo.PlayerListByRoom(ctx, room.ID)
|
||||
if err != nil {
|
||||
@@ -71,6 +72,7 @@ func (cm *CronManager) CleanupRooms() {
|
||||
if err := cm.repo.SettingsDeleteByRoomID(ctx, room.ID); err != nil {
|
||||
cm.log.Error("failed to delete settings for empty room", "room_id", room.ID, "err", err)
|
||||
}
|
||||
roomListChange = true
|
||||
continue
|
||||
}
|
||||
creatorInRoom := false
|
||||
@@ -120,6 +122,7 @@ func (cm *CronManager) CleanupRooms() {
|
||||
if err := cm.repo.SettingsDeleteByRoomID(ctx, room.ID); err != nil {
|
||||
cm.log.Error("failed to delete settings for room", "room_id", room.ID, "reason", reason, "err", err)
|
||||
}
|
||||
roomListChange = true
|
||||
// Move to the next room
|
||||
continue
|
||||
}
|
||||
@@ -127,10 +130,12 @@ func (cm *CronManager) CleanupRooms() {
|
||||
if err := tx.Commit(); err != nil {
|
||||
cm.log.Error("failed to commit transaction", "err", err)
|
||||
}
|
||||
if roomListChange {
|
||||
broker.Notifier.Notifier <- broker.NotificationEvent{
|
||||
EventName: models.NotifyRoomListUpdate,
|
||||
Payload: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (cm *CronManager) CleanupActions() {
|
||||
|
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
ports:
|
||||
- "6831:6831/udp"
|
||||
- "14268:14268"
|
||||
- "16686:16686"
|
13
go.mod
13
go.mod
@@ -12,6 +12,19 @@ require (
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
27
go.sum
27
go.sum
@@ -4,8 +4,17 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
@@ -19,6 +28,24 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
|
||||
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
@@ -59,7 +59,7 @@ func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
|
||||
}
|
||||
resp.State = state
|
||||
if state.RoomID == nil || *state.RoomID == "" {
|
||||
log.Debug("returning state without room", "username", state.Username)
|
||||
// log.Debug("returning state without room", "username", state.Username)
|
||||
return resp, nil
|
||||
}
|
||||
// room, err := getRoomByID(state.RoomID)
|
||||
|
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"gralias/models"
|
||||
"gralias/utils"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -79,17 +77,10 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// make sure username does not exists
|
||||
cleanName := utils.RemoveSpacesFromStr(username)
|
||||
clearPass := utils.RemoveSpacesFromStr(password)
|
||||
// login user
|
||||
cookie, err := makeCookie(cleanName, r.RemoteAddr)
|
||||
if err != nil {
|
||||
log.Error("failed to login", "error", err)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
// check if that user was already in db
|
||||
userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
|
||||
if err != nil || userstate == nil {
|
||||
log.Debug("making new player", "error", err, "state", userstate)
|
||||
log.Debug("making new player", "error", err, "state", userstate, "clean_name", cleanName)
|
||||
userstate = models.InitPlayer(cleanName)
|
||||
makeplayer = true
|
||||
} else {
|
||||
@@ -99,6 +90,13 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// login user
|
||||
cookie, session, err := makeCookie(cleanName, r.RemoteAddr)
|
||||
if err != nil {
|
||||
log.Error("failed to login", "error", err)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
fi := &models.FullInfo{
|
||||
State: userstate,
|
||||
@@ -136,10 +134,15 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := repo.SessionCreate(r.Context(), session); err != nil {
|
||||
log.Error("failed to save session", "error", err)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", 302)
|
||||
}
|
||||
|
||||
func makeCookie(username string, remote string) (*http.Cookie, error) {
|
||||
func makeCookie(username string, remote string) (*http.Cookie, *models.Session, error) {
|
||||
// secret
|
||||
// Create a new random session token
|
||||
// sessionToken := xid.New().String()
|
||||
@@ -176,17 +179,37 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
|
||||
cookie.Secure = false
|
||||
log.Info("changing cookie domain", "domain", cookie.Domain)
|
||||
}
|
||||
player, err := repo.PlayerGetByName(context.Background(), username)
|
||||
if err != nil || player == nil {
|
||||
// make player first, since username is fk to players table
|
||||
player = models.InitPlayer(username)
|
||||
if err := repo.PlayerAdd(context.Background(), player); err != nil {
|
||||
slog.Error("failed to create player", "username", username)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := repo.SessionCreate(context.Background(), session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cookie, nil
|
||||
// player, err := repo.PlayerGetByName(context.Background(), username)
|
||||
// if err != nil || player == nil {
|
||||
// // make player first, since username is fk to players table
|
||||
// player = models.InitPlayer(username)
|
||||
// if err := repo.PlayerAdd(context.Background(), player); err != nil {
|
||||
// slog.Error("failed to create player", "username", username)
|
||||
// return nil, err
|
||||
// }
|
||||
// }
|
||||
// if err := repo.SessionCreate(context.Background(), session); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
return cookie, session, nil
|
||||
}
|
||||
|
||||
func HandleSignout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie := &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
}
|
||||
cookie.Secure = true
|
||||
cookie.SameSite = http.SameSiteNoneMode
|
||||
if strings.Contains(r.RemoteAddr, "192.168.0") {
|
||||
cookie.Domain = "192.168.0.100"
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
cookie.Secure = false
|
||||
log.Info("changing cookie domain for signout", "domain", cookie.Domain)
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
@@ -42,15 +42,15 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
fi, err := getFullInfoByCtx(ctx)
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
// color, exists := fi.Room.WCMap[word]
|
||||
color, exists := fi.Room.FindColor(word)
|
||||
if !exists {
|
||||
abortWithError(w, "word is not found")
|
||||
@@ -71,6 +71,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
updateStatsOnCardReveal(r.Context(), fi.State, color)
|
||||
fi.Room.UpdateCounter()
|
||||
action := models.Action{
|
||||
Actor: fi.State.Username,
|
||||
@@ -121,6 +122,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||
clearMarks = true
|
||||
StopTurnTimer(fi.Room.ID)
|
||||
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||
case string(models.WordColorWhite), string(oppositeColor):
|
||||
log.Debug("opened white or opposite color word", "word", word, "opposite-color", oppositeColor)
|
||||
// end turn
|
||||
@@ -144,6 +146,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
||||
Action: models.ActionTypeGameOver,
|
||||
}
|
||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||
}
|
||||
if fi.Room.RedCounter == 0 {
|
||||
// red won
|
||||
@@ -158,6 +161,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
||||
Action: models.ActionTypeGameOver,
|
||||
}
|
||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||
}
|
||||
default: // same color as the team
|
||||
// check if game over
|
||||
@@ -173,6 +177,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
||||
Action: models.ActionTypeGameOver,
|
||||
}
|
||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||
}
|
||||
}
|
||||
if clearMarks {
|
||||
@@ -202,12 +207,18 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
fi, err := getFullInfoByCtx(ctx)
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
color, exists := fi.Room.FindColor(word)
|
||||
@@ -270,8 +281,9 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func HandleActionHistory(w http.ResponseWriter, r *http.Request) {
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
tmpl, err := template.ParseGlob("components/*.html")
|
||||
@@ -288,10 +300,10 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
|
||||
// get team; // get role; make up a name
|
||||
team := r.URL.Query().Get("team")
|
||||
role := r.URL.Query().Get("role")
|
||||
log.Debug("got add-bot request", "team", team, "role", role)
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
var botname string
|
||||
@@ -302,6 +314,7 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
botname = fmt.Sprintf("bot_%d", maxID+1) // what if many rooms?
|
||||
}
|
||||
log.Debug("got add-bot request", "team", team, "role", role, "max_id", maxID, "botname", botname, "error", err)
|
||||
_, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false)
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
@@ -315,8 +328,9 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
|
||||
botName := r.URL.Query().Get("bot")
|
||||
log.Debug("got remove-bot request", "bot_name", botName)
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
if err := llmapi.RemoveBot(botName, fi.Room); err != nil {
|
||||
|
@@ -72,8 +72,13 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
// get username
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
if fi.Room == nil {
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
if fi.Room.IsRunning && role == "mime" {
|
||||
@@ -107,8 +112,9 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
|
||||
func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
|
||||
// get username
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
// check if one who pressed it is from the team who has the turn
|
||||
@@ -139,8 +145,9 @@ func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func HandleStartGame(w http.ResponseWriter, r *http.Request) {
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
// check if enough players
|
||||
@@ -246,13 +253,16 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := r.URL.Query().Get("id")
|
||||
log.Debug("got join-room request", "id", roomID)
|
||||
room, err := repo.RoomGetExtended(r.Context(), roomID)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch room", "error", err, "room_id", roomID)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
tmpl, err := template.ParseGlob("components/*.html")
|
||||
if err != nil {
|
||||
log.Error("failed to parse templates", "error", err, "room_id", roomID)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -264,6 +274,7 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||
log.Error("failed to execute base template", "error", err)
|
||||
}
|
||||
log.Error("failed to fetch fi", "error", err, "room_id", roomID)
|
||||
// abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -276,7 +287,7 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
|
||||
abortWithError(w, err.Error())
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -289,8 +300,9 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
|
||||
clue := r.PostFormValue("clue")
|
||||
num := r.PostFormValue("number")
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
guessLimitU64, err := strconv.ParseUint(num, 10, 8)
|
||||
@@ -356,8 +368,9 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
notifyBotIfNeeded(fi.Room)
|
||||
|
@@ -9,6 +9,8 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -55,6 +57,12 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
fi.Room.GuesserView()
|
||||
}
|
||||
// reverse actions if first action
|
||||
if len(fi.Room.ActionHistory) > 1 {
|
||||
if strings.EqualFold(fi.Room.ActionHistory[0].Action, models.ActionTypeGameStarted) {
|
||||
slices.Reverse(fi.Room.ActionHistory)
|
||||
}
|
||||
}
|
||||
}
|
||||
if fi != nil && fi.Room == nil {
|
||||
rooms, err := repo.RoomList(r.Context())
|
||||
@@ -75,35 +83,54 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
if fi.Room == nil {
|
||||
log.Error("failed to fetch room")
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
if fi.Room.IsRunning {
|
||||
abortWithError(w, "cannot leave when game is running")
|
||||
return
|
||||
}
|
||||
var creatorLeft bool
|
||||
// if creator leaves, remove all players from room and delete room
|
||||
if fi.Room.CreatorName == fi.State.Username {
|
||||
creatorLeft = true
|
||||
players, err := repo.PlayerListByRoom(r.Context(), fi.Room.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to list players in room", "error", err)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
exitedRoom := fi.ExitRoom()
|
||||
if creatorLeft {
|
||||
if err := repo.RoomDeleteByID(r.Context(), exitedRoom.ID); err != nil {
|
||||
for _, p := range players {
|
||||
if p.IsBot {
|
||||
if err := repo.PlayerDelete(r.Context(), p.Username); err != nil {
|
||||
log.Error("failed to delete bot", "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := repo.PlayerExitRoom(r.Context(), p.Username); err != nil {
|
||||
log.Error("failed to exit room", "error", err)
|
||||
}
|
||||
}
|
||||
if err := repo.RoomDeleteByID(r.Context(), fi.Room.ID); err != nil {
|
||||
log.Error("failed to remove room", "error", err)
|
||||
}
|
||||
notify(models.NotifyRoomListUpdate, "")
|
||||
}
|
||||
notify(models.NotifyRoomListUpdate, "") // why is it needed?
|
||||
} else {
|
||||
notify(models.NotifyRoomUpdatePrefix, "")
|
||||
// if regular player leaves, just exit room
|
||||
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
|
||||
log.Error("failed to exit room", "error", err)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := repo.RoomUpdate(r.Context(), exitedRoom); err != nil {
|
||||
log.Error("failed to update room", "error", err)
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
fi.Room = nil
|
||||
fi.State.RoomID = nil
|
||||
fi.List, err = repo.RoomList(r.Context())
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
@@ -113,3 +140,44 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
|
||||
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
||||
}
|
||||
}
|
||||
|
||||
func HandleStats(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("got stats call")
|
||||
tmpl, err := template.ParseGlob("components/*.html")
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
stats, err := repo.GetAllPlayerStats(r.Context())
|
||||
if err != nil {
|
||||
log.Error("failed to get all player stats", "error", err)
|
||||
abortWithError(w, "failed to retrieve player stats")
|
||||
return
|
||||
}
|
||||
fi, err := getFullInfoByCtx(r.Context())
|
||||
if err != nil || fi == nil {
|
||||
log.Error("failed to fetch fi", "error", err)
|
||||
http.Redirect(w, r, "/", 302)
|
||||
return
|
||||
}
|
||||
// there must be a better way
|
||||
if fi != nil && fi.Room != nil && fi.Room.ID != "" && fi.State != nil {
|
||||
fi.Room.UpdateCounter()
|
||||
if fi.State.Role == "mime" {
|
||||
fi.Room.MimeView() // there must be a better way
|
||||
} else {
|
||||
fi.Room.GuesserView()
|
||||
}
|
||||
}
|
||||
if fi != nil && fi.Room == nil {
|
||||
rooms, err := repo.RoomList(r.Context())
|
||||
if err != nil {
|
||||
log.Error("failed to list rooms;", "error", err)
|
||||
}
|
||||
fi.List = rooms
|
||||
}
|
||||
fi.List = nil
|
||||
if err := tmpl.ExecuteTemplate(w, "stats", stats); err != nil {
|
||||
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
||||
}
|
||||
}
|
||||
|
@@ -61,10 +61,8 @@ func GetSession(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
userSession, err := repo.SessionByToken(r.Context(), sessionToken)
|
||||
// userSession, err := cacheGetSession(sessionToken)
|
||||
// log.Debug("userSession from cache", "us", userSession)
|
||||
if err != nil {
|
||||
msg := "auth failed; session does not exists"
|
||||
msg := "auth failed; session does not exist"
|
||||
log.Debug(msg, "error", err, "key", sessionToken)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -83,13 +81,6 @@ func GetSession(next http.Handler) http.Handler {
|
||||
models.CtxUsernameKey, userSession.Username)
|
||||
ctx = context.WithValue(ctx,
|
||||
models.CtxSessionKey, userSession)
|
||||
// if err := cacheSetSession(sessionToken,
|
||||
// userSession); err != nil {
|
||||
// msg := "failed to marshal user session"
|
||||
// log.Warn(msg, "error", err)
|
||||
// next.ServeHTTP(w, r)
|
||||
// return
|
||||
// }
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
80
handlers/stats.go
Normal file
80
handlers/stats.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gralias/models"
|
||||
)
|
||||
|
||||
// updateStatsOnCardReveal updates player stats when a card is revealed.
|
||||
func updateStatsOnCardReveal(ctx context.Context, player *models.Player, cardColor models.WordColor) {
|
||||
if player.IsBot {
|
||||
return
|
||||
}
|
||||
stats, err := repo.GetPlayerStats(ctx, player.Username)
|
||||
if err != nil {
|
||||
log.Error("failed to get player stats for card reveal update", "username", player.Username, "error", err)
|
||||
return
|
||||
}
|
||||
playerTeamColorStr := string(player.Team)
|
||||
switch cardColor {
|
||||
case models.WordColorBlack:
|
||||
stats.OpenedBlackWords++
|
||||
case models.WordColorWhite:
|
||||
stats.OpenedWhiteWords++
|
||||
default:
|
||||
if string(cardColor) != playerTeamColorStr {
|
||||
stats.OpenedOppositeWords++
|
||||
}
|
||||
}
|
||||
if err := repo.UpdatePlayerStats(ctx, stats); err != nil {
|
||||
log.Error("failed to update player stats on card reveal", "username", player.Username, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// updateStatsOnGameOver updates stats for all players in a room when a game ends.
|
||||
func updateStatsOnGameOver(ctx context.Context, room *models.Room) {
|
||||
// Get all players in the room
|
||||
players, err := repo.PlayerListByRoom(ctx, room.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to list players by room for stats update", "room_id", room.ID, "error", err)
|
||||
return
|
||||
}
|
||||
for _, player := range players {
|
||||
if player.IsBot {
|
||||
continue
|
||||
}
|
||||
stats, err := repo.GetPlayerStats(ctx, player.Username)
|
||||
if err != nil {
|
||||
log.Error("failed to get player stats for game over update", "username", player.Username, "error", err)
|
||||
continue
|
||||
}
|
||||
stats.GamesPlayed++
|
||||
if player.Team == room.TeamWon {
|
||||
stats.GamesWon++
|
||||
} else {
|
||||
stats.GamesLost++
|
||||
}
|
||||
if player.Role == models.UserRoleMime {
|
||||
stats.PlayedAsMime++
|
||||
if stats.PlayedAsMime > 0 {
|
||||
gamesWonAsMime := stats.MimeWinrate * float32(stats.PlayedAsMime-1)
|
||||
if player.Team == room.TeamWon {
|
||||
gamesWonAsMime++
|
||||
}
|
||||
stats.MimeWinrate = gamesWonAsMime / float32(stats.PlayedAsMime)
|
||||
}
|
||||
} else if player.Role == models.UserRoleGuesser {
|
||||
stats.PlayedAsGuesser++
|
||||
if stats.PlayedAsGuesser > 0 {
|
||||
gamesWonAsGuesser := stats.GuesserWinrate * float32(stats.PlayedAsGuesser-1)
|
||||
if player.Team == room.TeamWon {
|
||||
gamesWonAsGuesser++
|
||||
}
|
||||
stats.GuesserWinrate = gamesWonAsGuesser / float32(stats.PlayedAsGuesser)
|
||||
}
|
||||
}
|
||||
if err := repo.UpdatePlayerStats(ctx, stats); err != nil {
|
||||
log.Error("failed to update player stats on game over", "username", player.Username, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
@@ -12,7 +12,7 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
|
||||
logger := slog.Default().With("room_id", roomID)
|
||||
|
||||
onTurnEnd := func(ctx context.Context, roomID string) {
|
||||
room, err := repo.RoomGetByID(context.Background(), roomID)
|
||||
room, err := repo.RoomGetExtended(context.Background(), roomID)
|
||||
if err != nil {
|
||||
logger.Error("failed to get room by id", "error", err)
|
||||
return
|
||||
@@ -23,7 +23,7 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
|
||||
if err := repo.RoomUpdate(context.Background(), room); err != nil {
|
||||
logger.Error("failed to save room", "error", err)
|
||||
}
|
||||
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
|
||||
// notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
|
||||
notifyBotIfNeeded(room)
|
||||
}
|
||||
|
||||
@@ -31,9 +31,10 @@ func StartTurnTimer(roomID string, timeLeft uint32) {
|
||||
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) {
|
||||
timer.StopTurnTimer(roomID)
|
||||
}
|
||||
|
||||
|
152
llmapi/main.go
152
llmapi/main.go
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -22,11 +23,12 @@ var (
|
||||
repo = repos.RP
|
||||
SignalChanMap = make(map[string]chan bool)
|
||||
DoneChanMap = make(map[string]chan bool)
|
||||
mapMutex = &sync.RWMutex{}
|
||||
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
|
||||
MimePrompt = `we are playing alias;\nyou are a mime (player who gives a clue of one noun word and number of cards you expect them to open) of the %s team (people who would guess by your clue want open the %s cards);\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the game info in json:\n%s`
|
||||
GuesserPrompt = `we are playing alias;\nyou are to guess words of the %s team (you want open %s cards) by given clue and a number of meant guesses;\nplease return your guesses and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guesses\": [\"word1\", \"word2\", ...],\n\"could_be\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the cards (and other info), you need to choose revealed==false words:\n%s`
|
||||
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that you can choose from:\n%v`
|
||||
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;`
|
||||
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.`
|
||||
GuesserSimplePromptRU = `мы играем в alias;\n тебе дана подсказка (clue): \"%s\";\nпожалуйста, верни свою догадку (guess), а также слова, что тоже подходят к подсказке, но ты меньше в них уверен, в формате json; пример:\n{\n\"guess\": \"отгадка\",\n\"could_be\": [\"слово1\", \"слово2\", ...]\n}\nвот список слов из которых нужно выбрать:\n%v`
|
||||
MimeSimplePromptRU = `мы играем в alias;\nтебе нужно дать подсказку одним словом и число слов, что ты подразумевал этой подсказкой; слова твоей комманды: %v;\nслова противоположной комманды, что ты хочешь избежать: %v;\nи вот ЧЕРНОЕ СЛОВО, открыв которое твоя комманда проиграет игру: %s;\nпожалуйста, верни подсказку (одним словом) и количество слов, что ты подразумеваешь в формате json; пример:\n{\n\"clue\": \"подсказка\",\n\"number\": \"число-от-0-до-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"слово1\", \"слово2\", ...]\n}\nпожалуйста верни только json.`
|
||||
)
|
||||
|
||||
func convertToSliceOfStrings(value any) ([]string, error) {
|
||||
@@ -107,6 +109,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
||||
}
|
||||
room.ActionHistory = append(room.ActionHistory, action)
|
||||
b.StopTurnTimer()
|
||||
updateStatsOnGameOver(context.Background(), room)
|
||||
case string(models.WordColorWhite), string(oppositeColor):
|
||||
// end turn
|
||||
room.TeamTurn = oppositeColor
|
||||
@@ -132,6 +135,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
||||
}
|
||||
room.ActionHistory = append(room.ActionHistory, action)
|
||||
b.StopTurnTimer()
|
||||
updateStatsOnGameOver(context.Background(), room)
|
||||
}
|
||||
if room.RedCounter == 0 {
|
||||
// red won
|
||||
@@ -149,6 +153,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
||||
}
|
||||
room.ActionHistory = append(room.ActionHistory, action)
|
||||
b.StopTurnTimer()
|
||||
updateStatsOnGameOver(context.Background(), room)
|
||||
}
|
||||
ctx, tx, err := repo.InitTx(context.Background())
|
||||
// nolint: errcheck
|
||||
@@ -183,6 +188,11 @@ func (b *Bot) BotMove() {
|
||||
b.log.Error("bot loop", "error", err)
|
||||
return
|
||||
}
|
||||
if room.BotFailed {
|
||||
if err := repo.RoomUnSetBotFailed(context.Background(), room.ID); err != nil {
|
||||
b.log.Error("failed to unset bot failed bool", "error", err)
|
||||
}
|
||||
}
|
||||
// eventName := models.NotifyBacklogPrefix + room.ID
|
||||
eventName := models.NotifyRoomUpdatePrefix + room.ID
|
||||
eventPayload := ""
|
||||
@@ -199,7 +209,11 @@ func (b *Bot) BotMove() {
|
||||
}
|
||||
if botName := room.WhichBotToMove(); botName != "" {
|
||||
b.log.Debug("notifying bot", "name", botName)
|
||||
SignalChanMap[botName] <- true
|
||||
mapMutex.RLock()
|
||||
if sigChan, ok := SignalChanMap[botName]; ok {
|
||||
sigChan <- true
|
||||
}
|
||||
mapMutex.RUnlock()
|
||||
b.log.Debug("after sending the signal", "name", botName)
|
||||
}
|
||||
broker.Notifier.Notifier <- broker.NotificationEvent{
|
||||
@@ -213,21 +227,30 @@ func (b *Bot) BotMove() {
|
||||
// call llm
|
||||
llmResp, err := b.CallLLM(prompt)
|
||||
if err != nil {
|
||||
room.LogJournal = append(room.LogJournal, models.Journal{
|
||||
Entry: "send call got error: " + err.Error(),
|
||||
lj := models.Journal{
|
||||
Entry: fmt.Sprintf("bot '%s' exceeded attempts to call llm;", b.BotName),
|
||||
Username: b.BotName,
|
||||
RoomID: room.ID,
|
||||
})
|
||||
RoomID: b.RoomID,
|
||||
}
|
||||
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||
b.log.Warn("failed to write to journal", "entry", lj)
|
||||
}
|
||||
b.log.Error("bot loop", "error", err)
|
||||
if err := repo.RoomSetBotFailed(context.Background(), room.ID); err != nil {
|
||||
b.log.Error("failed to set bot failed bool", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
tempMap, err := b.LLMParser.ParseBytes(llmResp)
|
||||
if err != nil {
|
||||
room.LogJournal = append(room.LogJournal, models.Journal{
|
||||
Entry: "parse resp got error: " + err.Error(),
|
||||
lj := models.Journal{
|
||||
Entry: fmt.Sprintf("bot '%s' parsing resp failed;", b.BotName),
|
||||
Username: b.BotName,
|
||||
RoomID: room.ID,
|
||||
})
|
||||
RoomID: b.RoomID,
|
||||
}
|
||||
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||
b.log.Warn("failed to write to journal", "entry", lj)
|
||||
}
|
||||
b.log.Error("bot loop", "error", err, "resp", string(llmResp))
|
||||
return
|
||||
}
|
||||
@@ -236,6 +259,22 @@ func (b *Bot) BotMove() {
|
||||
mimeResp := MimeResp{}
|
||||
b.log.Info("mime resp log", "mimeResp", tempMap)
|
||||
mimeResp.Clue = strings.ToLower(tempMap["clue"].(string))
|
||||
for _, card := range room.Cards {
|
||||
if strings.ToLower(card.Word) == mimeResp.Clue {
|
||||
b.log.Warn("bot-mime clue is one of the words on the board; retrying", "clue", mimeResp.Clue, "bot", b.BotName)
|
||||
entry := fmt.Sprintf("bot-mime '%s' gave a clue '%s' which is one of the words on the board. retrying.", b.BotName, mimeResp.Clue)
|
||||
lj := models.Journal{
|
||||
Entry: entry,
|
||||
Username: b.BotName,
|
||||
RoomID: room.ID,
|
||||
}
|
||||
room.LogJournal = append(room.LogJournal, lj)
|
||||
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||
b.log.Warn("failed to write to journal", "entry", lj)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
var ok bool
|
||||
mimeResp.Number, ok = tempMap["number"].(string)
|
||||
if !ok {
|
||||
@@ -253,16 +292,16 @@ func (b *Bot) BotMove() {
|
||||
}
|
||||
room.ActionHistory = append(room.ActionHistory, action)
|
||||
room.MimeDone = true
|
||||
entry := fmt.Sprintf("meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
|
||||
lj := models.Journal{
|
||||
Entry: entry,
|
||||
Username: b.BotName,
|
||||
RoomID: room.ID,
|
||||
}
|
||||
room.LogJournal = append(room.LogJournal, lj)
|
||||
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||
b.log.Warn("failed to write to journal", "entry", lj)
|
||||
}
|
||||
// entry := fmt.Sprintf("meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
|
||||
// lj := models.Journal{
|
||||
// Entry: entry,
|
||||
// Username: b.BotName,
|
||||
// RoomID: room.ID,
|
||||
// }
|
||||
// room.LogJournal = append(room.LogJournal, lj)
|
||||
// if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||
// b.log.Warn("failed to write to journal", "entry", lj)
|
||||
// }
|
||||
eventPayload = mimeResp.Clue + mimeResp.Number
|
||||
guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8)
|
||||
if err != nil {
|
||||
@@ -324,11 +363,21 @@ func (b *Bot) BotMove() {
|
||||
|
||||
// StartBot
|
||||
func (b *Bot) StartBot() {
|
||||
mapMutex.Lock()
|
||||
signalChan, sOk := SignalChanMap[b.BotName]
|
||||
doneChan, dOk := DoneChanMap[b.BotName]
|
||||
mapMutex.Unlock()
|
||||
|
||||
if !sOk || !dOk {
|
||||
b.log.Error("bot channels not found in map", "bot-name", b.BotName)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-SignalChanMap[b.BotName]:
|
||||
case <-signalChan:
|
||||
b.BotMove()
|
||||
case <-DoneChanMap[b.BotName]:
|
||||
case <-doneChan:
|
||||
b.log.Debug("got done signal", "bot-name", b.BotName)
|
||||
return
|
||||
}
|
||||
@@ -336,14 +385,21 @@ func (b *Bot) StartBot() {
|
||||
}
|
||||
|
||||
func RemoveBot(botName string, room *models.Room) error {
|
||||
mapMutex.Lock()
|
||||
// channels
|
||||
DoneChanMap[botName] <- true
|
||||
close(DoneChanMap[botName])
|
||||
close(SignalChanMap[botName])
|
||||
if doneChan, ok := DoneChanMap[botName]; ok {
|
||||
doneChan <- true
|
||||
close(doneChan)
|
||||
}
|
||||
if signalChan, ok := SignalChanMap[botName]; ok {
|
||||
close(signalChan)
|
||||
}
|
||||
// maps
|
||||
delete(room.BotMap, botName)
|
||||
delete(DoneChanMap, botName)
|
||||
delete(SignalChanMap, botName)
|
||||
mapMutex.Unlock()
|
||||
|
||||
delete(room.BotMap, botName)
|
||||
// remove role from room
|
||||
room.RemovePlayer(botName)
|
||||
slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
|
||||
@@ -355,6 +411,7 @@ func RemoveBot(botName string, room *models.Room) error {
|
||||
}
|
||||
|
||||
func RemoveBotNoRoom(botName string) error {
|
||||
mapMutex.Lock()
|
||||
// channels
|
||||
dc, ok := DoneChanMap[botName]
|
||||
if ok {
|
||||
@@ -368,6 +425,7 @@ func RemoveBotNoRoom(botName string) error {
|
||||
// maps
|
||||
delete(DoneChanMap, botName)
|
||||
delete(SignalChanMap, botName)
|
||||
mapMutex.Unlock()
|
||||
// remove role from room
|
||||
return repo.PlayerDelete(context.Background(), botName)
|
||||
}
|
||||
@@ -444,9 +502,13 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
bot.log.Debug("before adding to ch map", "name", bot.BotName)
|
||||
// buffered channel to send to it in the same goroutine
|
||||
mapMutex.Lock()
|
||||
SignalChanMap[bot.BotName] = make(chan bool, 1)
|
||||
DoneChanMap[bot.BotName] = make(chan bool, 1)
|
||||
mapMutex.Unlock()
|
||||
bot.log.Debug("after adding to ch map", "name", bot.BotName)
|
||||
go bot.StartBot() // run bot routine
|
||||
return bot, nil
|
||||
}
|
||||
@@ -491,6 +553,9 @@ func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
|
||||
}
|
||||
words[i] = card.Word
|
||||
}
|
||||
if strings.EqualFold(room.Settings.Language, "ru") {
|
||||
return fmt.Sprintf(MimeSimplePromptRU, clueAction.Word, words)
|
||||
}
|
||||
return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
|
||||
}
|
||||
|
||||
@@ -519,34 +584,20 @@ func (b *Bot) BuildSimpleMimePrompt(room *models.Room) string {
|
||||
theirwords = append(theirwords, card.Word)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord, room.BlueCounter, room.RedCounter)
|
||||
if strings.EqualFold(room.Settings.Language, "ru") {
|
||||
return fmt.Sprintf(MimeSimplePromptRU, ourwords, theirwords, blackWord)
|
||||
}
|
||||
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord)
|
||||
}
|
||||
|
||||
func (b *Bot) BuildPrompt(room *models.Room) string {
|
||||
if b.Role == "" {
|
||||
return ""
|
||||
}
|
||||
// toText := make(map[string]any)
|
||||
// toText["backlog"] = room.ActionHistory
|
||||
// // mime sees all colors;
|
||||
// // guesser sees only revealed ones
|
||||
// if b.Role == models.UserRoleMime {
|
||||
// toText["cards"] = room.Cards
|
||||
// }
|
||||
// data, err := json.Marshal(toText)
|
||||
// if err != nil {
|
||||
// b.log.Error("failed to marshal", "error", err)
|
||||
// return ""
|
||||
// }
|
||||
// Escape the JSON string for inclusion in another JSON field
|
||||
// escapedData := strings.ReplaceAll(string(data), `"`, `\"`)
|
||||
if b.Role == models.UserRoleMime {
|
||||
// return fmt.Sprintf(MimeSimplePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
||||
// return fmt.Sprintf(MimePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
||||
return b.BuildSimpleMimePrompt(room)
|
||||
}
|
||||
if b.Role == models.UserRoleGuesser {
|
||||
// return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
||||
return b.BuildSimpleGuesserPrompt(room)
|
||||
}
|
||||
return ""
|
||||
@@ -564,7 +615,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
||||
req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payloadReader)
|
||||
if err != nil {
|
||||
if attempt == maxRetries-1 {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
return nil, fmt.Errorf("LLM call failed after %d retries on request creation: %w", maxRetries, err)
|
||||
}
|
||||
b.log.Error("failed to make new request; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||
time.Sleep(time.Duration(baseDelay) * time.Second * time.Duration(attempt+1))
|
||||
@@ -576,7 +627,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if attempt == maxRetries-1 {
|
||||
return nil, fmt.Errorf("http request failed: %w", err)
|
||||
return nil, fmt.Errorf("LLM call failed after %d retries on client.Do: %w", maxRetries, err)
|
||||
}
|
||||
b.log.Error("http request failed; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
||||
@@ -587,7 +638,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
if attempt == maxRetries-1 {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
return nil, fmt.Errorf("LLM call failed after %d retries on reading body: %w", maxRetries, err)
|
||||
}
|
||||
b.log.Error("failed to read response body; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
||||
@@ -597,7 +648,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
||||
// Check status code
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
||||
if attempt == maxRetries-1 {
|
||||
return nil, fmt.Errorf("after %d retries, still got status %d", maxRetries, resp.StatusCode)
|
||||
return nil, fmt.Errorf("LLM call failed after %d retries, got status %d", maxRetries, resp.StatusCode)
|
||||
}
|
||||
b.log.Warn("retriable status code; will retry", "code", resp.StatusCode, "attempt", attempt)
|
||||
delay := time.Duration((baseDelay * (1 << attempt))) * time.Second
|
||||
@@ -612,6 +663,5 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
||||
b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||
return body, nil
|
||||
}
|
||||
// This line should not be reached because each error path returns in the loop.
|
||||
return nil, errors.New("unknown error in retry loop")
|
||||
}
|
||||
|
@@ -95,3 +95,52 @@ func (b *Bot) ToPlayer() *models.Player {
|
||||
IsBot: true,
|
||||
}
|
||||
}
|
||||
|
||||
type ORModel struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalSlug string `json:"canonical_slug"`
|
||||
HuggingFaceID string `json:"hugging_face_id"`
|
||||
Name string `json:"name"`
|
||||
Created int `json:"created"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length"`
|
||||
Architecture struct {
|
||||
Modality string `json:"modality"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
OutputModalities []string `json:"output_modalities"`
|
||||
Tokenizer string `json:"tokenizer"`
|
||||
InstructType any `json:"instruct_type"`
|
||||
} `json:"architecture"`
|
||||
Pricing struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Completion string `json:"completion"`
|
||||
Request string `json:"request"`
|
||||
Image string `json:"image"`
|
||||
Audio string `json:"audio"`
|
||||
WebSearch string `json:"web_search"`
|
||||
InternalReasoning string `json:"internal_reasoning"`
|
||||
} `json:"pricing,omitempty"`
|
||||
TopProvider struct {
|
||||
ContextLength int `json:"context_length"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens"`
|
||||
IsModerated bool `json:"is_moderated"`
|
||||
} `json:"top_provider"`
|
||||
PerRequestLimits any `json:"per_request_limits"`
|
||||
SupportedParameters []string `json:"supported_parameters"`
|
||||
}
|
||||
|
||||
// https://openrouter.ai/api/v1/models
|
||||
type ORModels struct {
|
||||
Data []ORModel `json:"data"`
|
||||
}
|
||||
|
||||
func (orm *ORModels) ListFree() []string {
|
||||
resp := []string{}
|
||||
for _, model := range orm.Data {
|
||||
if model.Pricing.Prompt == "0" && model.Pricing.Request == "0" &&
|
||||
model.Pricing.Completion == "0" {
|
||||
resp = append(resp, model.ID)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
64
llmapi/or.go
Normal file
64
llmapi/or.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@@ -158,21 +158,12 @@ func (p *openRouterParser) ParseBytes(body []byte) (map[string]any, error) {
|
||||
}
|
||||
|
||||
func (p *openRouterParser) MakePayload(prompt string) io.Reader {
|
||||
// Models to rotate through
|
||||
models := []string{
|
||||
"google/gemini-2.0-flash-exp:free",
|
||||
"deepseek/deepseek-chat-v3-0324:free",
|
||||
"mistralai/mistral-small-3.2-24b-instruct:free",
|
||||
"qwen/qwen3-14b:free",
|
||||
"deepseek/deepseek-r1:free",
|
||||
"google/gemma-3-27b-it:free",
|
||||
"meta-llama/llama-3.3-70b-instruct:free",
|
||||
}
|
||||
// Get next model index using atomic addition for thread safety
|
||||
p.modelIndex++
|
||||
model := models[int(p.modelIndex)%len(models)]
|
||||
model := ORFreeModels[int(p.modelIndex)%len(ORFreeModels)]
|
||||
strPayload := fmt.Sprintf(`{
|
||||
"model": "%s",
|
||||
"max_tokens": 300,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
|
70
llmapi/stats.go
Normal file
70
llmapi/stats.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package llmapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gralias/models"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
var log *slog.Logger
|
||||
|
||||
func init() {
|
||||
log = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
AddSource: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// updateStatsOnGameOver updates stats for all players in a room when a game ends.
|
||||
func updateStatsOnGameOver(ctx context.Context, room *models.Room) {
|
||||
// Get all players in the room
|
||||
players, err := repo.PlayerListByRoom(ctx, room.ID)
|
||||
if err != nil {
|
||||
log.Error("failed to list players by room for stats update", "room_id", room.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, player := range players {
|
||||
if player.IsBot {
|
||||
continue
|
||||
}
|
||||
|
||||
stats, err := repo.GetPlayerStats(ctx, player.Username)
|
||||
if err != nil {
|
||||
log.Error("failed to get player stats for game over update", "username", player.Username, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
stats.GamesPlayed++
|
||||
if player.Team == room.TeamWon {
|
||||
stats.GamesWon++
|
||||
} else {
|
||||
stats.GamesLost++
|
||||
}
|
||||
|
||||
if player.Role == models.UserRoleMime {
|
||||
stats.PlayedAsMime++
|
||||
if stats.PlayedAsMime > 0 {
|
||||
gamesWonAsMime := stats.MimeWinrate * float32(stats.PlayedAsMime-1)
|
||||
if player.Team == room.TeamWon {
|
||||
gamesWonAsMime++
|
||||
}
|
||||
stats.MimeWinrate = gamesWonAsMime / float32(stats.PlayedAsMime)
|
||||
}
|
||||
} else if player.Role == models.UserRoleGuesser {
|
||||
stats.PlayedAsGuesser++
|
||||
if stats.PlayedAsGuesser > 0 {
|
||||
gamesWonAsGuesser := stats.GuesserWinrate * float32(stats.PlayedAsGuesser-1)
|
||||
if player.Team == room.TeamWon {
|
||||
gamesWonAsGuesser++
|
||||
}
|
||||
stats.GuesserWinrate = gamesWonAsGuesser / float32(stats.PlayedAsGuesser)
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.UpdatePlayerStats(ctx, stats); err != nil {
|
||||
log.Error("failed to update player stats on game over", "username", player.Username, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
@@ -13,7 +13,7 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
|
||||
logger := b.log.With("room_id", b.RoomID)
|
||||
|
||||
onTurnEnd := func(ctx context.Context, roomID string) {
|
||||
room, err := repos.RP.RoomGetByID(context.Background(), roomID)
|
||||
room, err := repos.RP.RoomGetExtended(context.Background(), roomID)
|
||||
if err != nil {
|
||||
logger.Error("failed to get room by id", "error", err)
|
||||
return
|
||||
@@ -24,10 +24,10 @@ func (b *Bot) StartTurnTimer(timeLeft uint32) {
|
||||
if err := repos.RP.RoomUpdate(context.Background(), room); err != nil {
|
||||
logger.Error("failed to save room", "error", err)
|
||||
}
|
||||
broker.Notifier.Notifier <- broker.NotificationEvent{
|
||||
EventName: models.NotifyTurnTimerPrefix + room.ID,
|
||||
Payload: strconv.FormatUint(uint64(room.Settings.RoundTime), 10),
|
||||
}
|
||||
// broker.Notifier.Notifier <- broker.NotificationEvent{
|
||||
// EventName: models.NotifyTurnTimerPrefix + room.ID,
|
||||
// Payload: strconv.FormatUint(uint64(room.Settings.RoundTime), 10),
|
||||
// }
|
||||
// notifyBotIfNeeded(room)
|
||||
if botName := room.WhichBotToMove(); botName != "" {
|
||||
SignalChanMap[botName] <- true
|
||||
@@ -41,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() {
|
||||
timer.StopTurnTimer(b.RoomID)
|
||||
}
|
||||
|
||||
|
34
main.go
34
main.go
@@ -5,9 +5,12 @@ import (
|
||||
"gralias/config"
|
||||
"gralias/crons"
|
||||
"gralias/handlers"
|
||||
"gralias/llmapi"
|
||||
"gralias/repos"
|
||||
"gralias/telemetry"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -20,6 +23,7 @@ var cfg *config.Config
|
||||
|
||||
func init() {
|
||||
cfg = config.LoadConfigOrDefault("")
|
||||
go llmapi.ORModelListUpdateTicker(context.Background())
|
||||
}
|
||||
|
||||
// GzipFileServer serves pre-compressed .gz files if available
|
||||
@@ -56,8 +60,11 @@ func getContentType(path string) string {
|
||||
|
||||
func ListenToRequests(port string) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
var handler http.Handler = mux
|
||||
handler = handlers.LogRequests(handlers.GetSession(handler))
|
||||
handler = telemetry.OtelMiddleware(handler)
|
||||
server := &http.Server{
|
||||
Handler: handlers.LogRequests(handlers.GetSession(mux)),
|
||||
Handler: handler,
|
||||
Addr: ":" + port,
|
||||
// ReadTimeout: time.Second * 5, // does this timeout conflict with sse connection?
|
||||
WriteTimeout: 0, // sse streaming
|
||||
@@ -68,7 +75,9 @@ func ListenToRequests(port string) *http.Server {
|
||||
//
|
||||
mux.HandleFunc("GET /ping", handlers.HandlePing)
|
||||
mux.HandleFunc("GET /", handlers.HandleHome)
|
||||
mux.HandleFunc("GET /stats", handlers.HandleStats)
|
||||
mux.HandleFunc("POST /login", handlers.HandleFrontLogin)
|
||||
mux.HandleFunc("GET /signout", handlers.HandleSignout)
|
||||
mux.HandleFunc("POST /join-team", handlers.HandleJoinTeam)
|
||||
mux.HandleFunc("GET /end-turn", handlers.HandleEndTurn)
|
||||
mux.HandleFunc("POST /room-create", handlers.HandleCreateRoom)
|
||||
@@ -94,6 +103,8 @@ func ListenToRequests(port string) *http.Server {
|
||||
}
|
||||
|
||||
func main() {
|
||||
shutdown := telemetry.InitTracer()
|
||||
defer shutdown()
|
||||
// Setup graceful shutdown
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
@@ -103,16 +114,33 @@ func main() {
|
||||
cm := crons.NewCronManager(repo, slog.Default())
|
||||
cm.Start()
|
||||
server := ListenToRequests(cfg.ServerConfig.Port)
|
||||
pprofPort := "6060"
|
||||
pprofServer := &http.Server{
|
||||
Addr: ":" + pprofPort,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("Pprof server listening", "addr", pprofPort)
|
||||
if err := pprofServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("Pprof server failed", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
panic(err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-stop
|
||||
slog.Info("Shutting down server...")
|
||||
slog.Info("Shutting down servers...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
slog.Error("server shutdown failed", "error", err)
|
||||
slog.Error("Main server shutdown failed", "error", err)
|
||||
}
|
||||
if err := pprofServer.Shutdown(ctx); err != nil {
|
||||
slog.Error("Pprof server shutdown failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
@@ -13,6 +13,7 @@ CREATE TABLE rooms (
|
||||
mime_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_over BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
bot_failed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
team_won TEXT NOT NULL DEFAULT '',
|
||||
room_link TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
@@ -90,7 +91,7 @@ CREATE TABLE journal(
|
||||
|
||||
CREATE TABLE player_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_username TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
games_played INTEGER NOT NULL DEFAULT 0,
|
||||
games_won INTEGER NOT NULL DEFAULT 0,
|
||||
games_lost INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -101,6 +102,6 @@ CREATE TABLE player_stats (
|
||||
guesser_winrate REAL NOT NULL DEFAULT 0.0,
|
||||
played_as_mime INTEGER NOT NULL DEFAULT 0,
|
||||
played_as_guesser INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (player_username) REFERENCES players(username) ON DELETE CASCADE
|
||||
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
3
migrations/002_add_stats_elo.down.sql
Normal file
3
migrations/002_add_stats_elo.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TRIGGER IF EXISTS update_player_rating;
|
||||
|
||||
ALTER TABLE DROP COLUMN rating;
|
20
migrations/002_add_stats_elo.up.sql
Normal file
20
migrations/002_add_stats_elo.up.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE player_stats
|
||||
ADD COLUMN rating REAL NOT NULL DEFAULT 1000.0;
|
||||
|
||||
CREATE TRIGGER update_player_rating
|
||||
AFTER UPDATE OF games_played, games_won ON player_stats
|
||||
WHEN NEW.games_played = OLD.games_played + 1
|
||||
BEGIN
|
||||
UPDATE player_stats
|
||||
SET rating = OLD.rating +
|
||||
32.0 * (
|
||||
CASE
|
||||
WHEN NEW.games_won = OLD.games_won + 1
|
||||
THEN 1.0 - 0.5 -- Win term: 0.5
|
||||
ELSE 0.0 - 0.5 -- Loss term: -0.5
|
||||
END
|
||||
) +
|
||||
0.05 * (1000.0 - OLD.rating)
|
||||
WHERE id = OLD.id;
|
||||
END;
|
||||
|
@@ -141,17 +141,18 @@ type Journal struct {
|
||||
|
||||
type PlayerStats struct {
|
||||
ID uint32 `db:"id"`
|
||||
PlayerUsername string `db:"player_username"`
|
||||
Username string `db:"username"`
|
||||
GamesPlayed int `db:"games_played"`
|
||||
GamesWon int `db:"games_won"`
|
||||
GamesLost int `db:"games_lost"`
|
||||
OpenedOppositeWords int `db:"opened_opposite_words"`
|
||||
OpenedWhiteWords int `db:"opened_white_words"`
|
||||
OpenedBlackWords int `db:"opened_black_words"`
|
||||
MimeWinrate float64 `db:"mime_winrate"`
|
||||
GuesserWinrate float64 `db:"guesser_winrate"`
|
||||
MimeWinrate float32 `db:"mime_winrate"`
|
||||
GuesserWinrate float32 `db:"guesser_winrate"`
|
||||
PlayedAsMime int `db:"played_as_mime"`
|
||||
PlayedAsGuesser int `db:"played_as_guesser"`
|
||||
Rating float32 `db:"rating"`
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
@@ -177,6 +178,8 @@ type Room struct {
|
||||
BotMap map[string]BotPlayer `db:"-"`
|
||||
LogJournal []Journal `db:"-"`
|
||||
Settings GameSettings `db:"-"`
|
||||
//
|
||||
BotFailed bool `db:"bot_failed"`
|
||||
}
|
||||
|
||||
func (r *Room) FindColor(word string) (WordColor, bool) {
|
||||
@@ -225,11 +228,20 @@ func (r *Room) FetchLastClue() (*Action, error) {
|
||||
}
|
||||
|
||||
func (r *Room) FetchLastClueWord() string {
|
||||
if len(r.ActionHistory) > 1 {
|
||||
if strings.EqualFold(r.ActionHistory[0].Action, ActionTypeGameStarted) {
|
||||
for i := len(r.ActionHistory) - 1; i >= 0; i-- {
|
||||
if r.ActionHistory[i].Action == string(ActionTypeClue) {
|
||||
return r.ActionHistory[i].Word
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := 0; i <= len(r.ActionHistory)-1; i++ {
|
||||
if r.ActionHistory[i].Action == string(ActionTypeClue) {
|
||||
return r.ActionHistory[i].Word
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -407,12 +419,20 @@ func (r *Room) RevealSpecificWord(word string) uint32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (r *Room) SetGameOverToCards(isover bool) {
|
||||
for i := range r.Cards {
|
||||
r.Cards[i].IsOver = isover
|
||||
}
|
||||
}
|
||||
|
||||
type WordCard struct {
|
||||
ID uint32 `json:"id" db:"id"`
|
||||
RoomID string `json:"room_id" db:"room_id"`
|
||||
Word string `json:"word" db:"word"`
|
||||
Color WordColor `json:"color" db:"color"`
|
||||
Revealed bool `json:"revealed" db:"revealed"`
|
||||
// pain; but at the end of the game players should see color of unopen cards
|
||||
IsOver bool
|
||||
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
|
||||
Marks []CardMark `json:"marks" db:"-"`
|
||||
}
|
||||
|
@@ -39,6 +39,6 @@ func (r *RepoProvider) CardMarksByRoomID(ctx context.Context, roomID string) ([]
|
||||
}
|
||||
func (r *RepoProvider) CardMarksRemoveByRoomID(ctx context.Context, roomID string) error {
|
||||
db := getDB(ctx, r.DB)
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM card_marks WHERE room_id = ?;", roomID)
|
||||
_, err := db.ExecContext(ctx, "DELETE FROM card_marks WHERE card_id IN (select id from word_cards where room_id = ?);", roomID)
|
||||
return err
|
||||
}
|
||||
|
@@ -9,13 +9,20 @@ import (
|
||||
|
||||
type PlayerStatsRepo interface {
|
||||
GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error)
|
||||
GetAllPlayerStats(ctx context.Context) ([]*models.PlayerStats, error)
|
||||
UpdatePlayerStats(ctx context.Context, stats *models.PlayerStats) error
|
||||
CreatePlayerStats(ctx context.Context, username string) error
|
||||
}
|
||||
|
||||
func (p *RepoProvider) GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error) {
|
||||
stats := &models.PlayerStats{}
|
||||
err := sqlx.GetContext(ctx, p.DB, stats, "SELECT * FROM player_stats WHERE player_username = ?", username)
|
||||
err := sqlx.GetContext(ctx, p.DB, stats, "SELECT * FROM player_stats WHERE username = ?", username)
|
||||
return stats, err
|
||||
}
|
||||
|
||||
func (p *RepoProvider) GetAllPlayerStats(ctx context.Context) ([]*models.PlayerStats, error) {
|
||||
var stats []*models.PlayerStats
|
||||
err := sqlx.SelectContext(ctx, p.DB, &stats, "SELECT * FROM player_stats ORDER BY games_won DESC")
|
||||
return stats, err
|
||||
}
|
||||
|
||||
@@ -30,13 +37,14 @@ func (p *RepoProvider) UpdatePlayerStats(ctx context.Context, stats *models.Play
|
||||
mime_winrate = :mime_winrate,
|
||||
guesser_winrate = :guesser_winrate,
|
||||
played_as_mime = :played_as_mime,
|
||||
played_as_guesser = :played_as_guesser
|
||||
WHERE player_username = :player_username`, stats)
|
||||
played_as_guesser = :played_as_guesser,
|
||||
rating = :rating
|
||||
WHERE username = :username`, stats)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *RepoProvider) CreatePlayerStats(ctx context.Context, username string) error {
|
||||
db := getDB(ctx, p.DB)
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO player_stats (player_username) VALUES (?)", username)
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO player_stats (username) VALUES (?)", username)
|
||||
return err
|
||||
}
|
||||
|
@@ -33,7 +33,7 @@ func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
|
||||
|
||||
func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*models.Player, error) {
|
||||
var player models.Player
|
||||
err := sqlx.GetContext(ctx, p.DB, &player, "SELECT id, room_id, username, team, role, is_bot, password FROM players WHERE username = ?", username)
|
||||
err := sqlx.GetContext(ctx, p.DB, &player, "SELECT id, room_id, username, team, role, is_bot, password FROM players WHERE username=?;", username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -47,7 +47,13 @@ func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) err
|
||||
db := getDB(ctx, p.DB)
|
||||
_, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot, password) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
player.RoomID, player.Username, player.Team, player.Role, player.IsBot, player.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !player.IsBot {
|
||||
return p.CreatePlayerStats(ctx, player.Username)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error {
|
||||
|
@@ -14,18 +14,139 @@ func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
|
||||
db, err := sqlx.Connect("sqlite3", ":memory:")
|
||||
assert.NoError(t, err)
|
||||
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
// Load schema from migration files
|
||||
schema001 := `
|
||||
-- migrations/001_initial_schema.up.sql
|
||||
|
||||
CREATE TABLE rooms (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
creator_name TEXT NOT NULL,
|
||||
team_turn TEXT NOT NULL DEFAULT '',
|
||||
this_turn_limit INTEGER NOT NULL DEFAULT 0,
|
||||
opened_this_turn INTEGER NOT NULL DEFAULT 0,
|
||||
blue_counter INTEGER NOT NULL DEFAULT 0,
|
||||
red_counter INTEGER NOT NULL DEFAULT 0,
|
||||
red_turn BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
mime_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_over BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
team_won TEXT NOT NULL DEFAULT '',
|
||||
room_link TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT,
|
||||
username TEXT,
|
||||
room_id TEXT, -- nullable
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
team TEXT,
|
||||
role TEXT,
|
||||
is_bot BOOLEAN
|
||||
);
|
||||
`
|
||||
_, err = db.Exec(schema)
|
||||
team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
|
||||
role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime'
|
||||
is_bot BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE word_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
word TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE card_marks (
|
||||
card_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
FOREIGN KEY (card_id) REFERENCES word_cards(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
|
||||
PRIMARY KEY (card_id, username)
|
||||
);
|
||||
|
||||
CREATE TABLE actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_color TEXT NOT NULL DEFAULT '',
|
||||
action_type TEXT NOT NULL,
|
||||
word TEXT NOT NULL DEFAULT '',
|
||||
word_color TEXT NOT NULL DEFAULT '',
|
||||
number_associated TEXT NOT NULL DEFAULT '', -- for clues
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
room_pass TEXT NOT NULL DEFAULT '',
|
||||
turn_time INTEGER NOT NULL DEFAULT 60, -- seconds
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE sessions(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
lifetime INTEGER NOT NULL DEFAULT 3600,
|
||||
token_key TEXT NOT NULL DEFAULT '' UNIQUE, -- encoded value
|
||||
username TEXT NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE journal(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
entry TEXT NOT NULL DEFAULT '',
|
||||
username TEXT NOT NULL,
|
||||
room_id TEXT NOT NULL,
|
||||
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE player_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
games_played INTEGER NOT NULL DEFAULT 0,
|
||||
games_won INTEGER NOT NULL DEFAULT 0,
|
||||
games_lost INTEGER NOT NULL DEFAULT 0,
|
||||
opened_opposite_words INTEGER NOT NULL DEFAULT 0,
|
||||
opened_white_words INTEGER NOT NULL DEFAULT 0,
|
||||
opened_black_words INTEGER NOT NULL DEFAULT 0,
|
||||
mime_winrate REAL NOT NULL DEFAULT 0.0,
|
||||
guesser_winrate REAL NOT NULL DEFAULT 0.0,
|
||||
played_as_mime INTEGER NOT NULL DEFAULT 0,
|
||||
played_as_guesser INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
_, err = db.Exec(schema001)
|
||||
assert.NoError(t, err)
|
||||
|
||||
schema002 := `
|
||||
ALTER TABLE player_stats
|
||||
ADD COLUMN rating REAL NOT NULL DEFAULT 1000.0;
|
||||
|
||||
CREATE TRIGGER update_player_rating
|
||||
AFTER UPDATE OF games_played, games_won ON player_stats
|
||||
WHEN NEW.games_played = OLD.games_played + 1
|
||||
BEGIN
|
||||
UPDATE player_stats
|
||||
SET rating = OLD.rating +
|
||||
32.0 * (
|
||||
CASE
|
||||
WHEN NEW.games_won = OLD.games_won + 1
|
||||
THEN 1.0 - 0.5 -- Win term: 0.5
|
||||
ELSE 0.0 - 0.5 -- Loss term: -0.5
|
||||
END
|
||||
) +
|
||||
0.05 * (1000.0 - OLD.rating)
|
||||
WHERE id = OLD.id;
|
||||
END;
|
||||
`
|
||||
_, err = db.Exec(schema002)
|
||||
assert.NoError(t, err)
|
||||
|
||||
return db, func() {
|
||||
@@ -33,6 +154,39 @@ func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerStatsRatingUpdate(t *testing.T) {
|
||||
db, teardown := setupPlayersTestDB(t)
|
||||
defer teardown()
|
||||
|
||||
username := "test_player_rating"
|
||||
_, err := db.Exec(`INSERT INTO players (username) VALUES (?)`, username)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = db.Exec(`INSERT INTO player_stats (username, games_played, games_won, rating) VALUES (?, 0, 0, 1000.0)`, username)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Simulate a win
|
||||
_, err = db.Exec(`UPDATE player_stats SET games_played = 1, games_won = 1 WHERE username = ?`, username)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var ratingAfterWin float64
|
||||
err = db.Get(&ratingAfterWin, `SELECT rating FROM player_stats WHERE username = ?`, username)
|
||||
assert.NoError(t, err)
|
||||
// Expected: 1000 + 32 * (1 - 0.5) + 0.05 * (1000 - 1000) = 1000 + 16 = 1016
|
||||
assert.InDelta(t, 1016.0, ratingAfterWin, 0.001)
|
||||
|
||||
// Simulate a loss
|
||||
_, err = db.Exec(`UPDATE player_stats SET games_played = 2, games_won = 1 WHERE username = ?`, username)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var ratingAfterLoss float64
|
||||
err = db.Get(&ratingAfterLoss, `SELECT rating FROM player_stats WHERE username = ?`, username)
|
||||
assert.NoError(t, err)
|
||||
// Expected: 1016 + 32 * (0 - 0.5) + 0.05 * (1000 - 1016) = 1016 - 16 + 0.05 * (-16) = 1000 - 0.8 = 999.2
|
||||
assert.InDelta(t, 999.2, ratingAfterLoss, 0.001)
|
||||
}
|
||||
|
||||
|
||||
func TestPlayersRepo_AddPlayer(t *testing.T) {
|
||||
db, teardown := setupPlayersTestDB(t)
|
||||
defer teardown()
|
||||
@@ -107,4 +261,3 @@ func TestPlayersRepo_DeletePlayer(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
|
@@ -15,6 +15,20 @@ type RoomsRepo interface {
|
||||
RoomCreate(ctx context.Context, room *models.Room) error
|
||||
RoomDeleteByID(ctx context.Context, id string) error
|
||||
RoomUpdate(ctx context.Context, room *models.Room) error
|
||||
RoomSetBotFailed(ctx context.Context, roomID string) error
|
||||
RoomUnSetBotFailed(ctx context.Context, roomID string) error
|
||||
}
|
||||
|
||||
func (p *RepoProvider) RoomSetBotFailed(ctx context.Context, roomID string) error {
|
||||
db := getDB(ctx, p.DB)
|
||||
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = true WHERE id = ?", roomID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *RepoProvider) RoomUnSetBotFailed(ctx context.Context, roomID string) error {
|
||||
db := getDB(ctx, p.DB)
|
||||
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = false WHERE id = ?", roomID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {
|
||||
|
63
telemetry/telemetry.go
Normal file
63
telemetry/telemetry.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/jaeger"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
|
||||
)
|
||||
|
||||
// newJaegerExporter creates a new Jaeger exporter.
|
||||
func newJaegerExporter() (sdktrace.SpanExporter, error) {
|
||||
return jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint("http://localhost:14268/api/traces")))
|
||||
}
|
||||
|
||||
// NewTracerProvider creates a new tracer provider.
|
||||
func NewTracerProvider(exp sdktrace.SpanExporter) *sdktrace.TracerProvider {
|
||||
res := resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName("gralias"),
|
||||
semconv.ServiceVersion("v0.1.0"),
|
||||
)
|
||||
|
||||
tracerProvider := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithBatcher(exp),
|
||||
)
|
||||
return tracerProvider
|
||||
}
|
||||
|
||||
// OtelMiddleware wraps the provided http.Handler with OpenTelemetry tracing.
|
||||
func OtelMiddleware(handler http.Handler) http.Handler {
|
||||
return otelhttp.NewHandler(handler, "http.server",
|
||||
otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
|
||||
return r.URL.Path
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func InitTracer() func() {
|
||||
exp, err := newJaegerExporter()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create exporter: %v", err)
|
||||
}
|
||||
|
||||
tp := NewTracerProvider(exp)
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
return func() {
|
||||
if err := tp.Shutdown(context.Background()); err != nil {
|
||||
log.Printf("Error shutting down tracer provider: %v", err)
|
||||
}
|
||||
if err := exp.Shutdown(context.Background()); err != nil {
|
||||
log.Printf("Error shutting down exporter: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
@@ -28,7 +28,7 @@ var (
|
||||
)
|
||||
|
||||
// StartTurnTimer initializes and starts a new turn timer for a given room.
|
||||
func StartTurnTimer(ctx context.Context, roomID string, timeLeft 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()
|
||||
defer mu.Unlock()
|
||||
|
||||
@@ -62,7 +62,7 @@ func StartTurnTimer(ctx context.Context, roomID string, timeLeft uint32, onTurnE
|
||||
StopTurnTimer(roomID)
|
||||
return
|
||||
}
|
||||
rt.onTick(ctx, roomID, currentLeft)
|
||||
rt.onTick(ctx, roomID, uint32(currentLeft))
|
||||
currentLeft--
|
||||
}
|
||||
}
|
||||
|
26
todos.md
26
todos.md
@@ -21,8 +21,8 @@
|
||||
- redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
|
||||
- better styles and fluff;
|
||||
- common auth system between sites;
|
||||
- signup vs login;
|
||||
- passwords (to room and to login);
|
||||
- signup vs login; +
|
||||
- passwords (to room and to login); +
|
||||
===
|
||||
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
|
||||
- gameover to backlog;
|
||||
@@ -33,7 +33,12 @@
|
||||
- possibly turn markings into parts of names of users (first three letters?); +
|
||||
- at game creation list languages and support them at backend; +
|
||||
- sql ping goroutine with reconnect on fail; +
|
||||
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words.
|
||||
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words. +
|
||||
- at the end of the game, all colors should be revealed;
|
||||
- tracing;
|
||||
====
|
||||
- action history to bot (not json); basic stuff: what words were previously given as clue and guessed (maybe what team bot is on);
|
||||
- automate getting list of free and non-thinking openrouter models;
|
||||
|
||||
#### sse points
|
||||
- clue sse update;
|
||||
@@ -89,3 +94,18 @@
|
||||
- mime sees the clue input out of turn; (eh)
|
||||
- there is a problem of two timers, they both could switch turn, but it is not easy to stop them from llmapi or handlers. +
|
||||
- journal still does not work; +
|
||||
- lose/win game; then exit room (while being the creator), then press to stats -> cannot find session in db, although cookie in place and session in db; +
|
||||
- exit endpoints delets player from db; +
|
||||
- timer end did not update the page;
|
||||
- timer did not change turn (guesser did not manage to guess 1 word);
|
||||
|
||||
- timers conflict; stop timers;
|
||||
- clue snatching;
|
||||
- llm resp token amount limit;
|
||||
|
||||
=============
|
||||
- autoscroll backlog to the last action; (reversed order) +
|
||||
- mimes to see marks on the words; +
|
||||
- clearer ways to see opened words; (line through) +
|
||||
- guesser sees end turn button before clue was given by mime; +
|
||||
- sql no rows when joining by link?
|
||||
|
Reference in New Issue
Block a user