Compare commits

...

67 Commits

Author SHA1 Message Date
Grail Finder
817d69c425 Enha: sse update 2025-08-03 16:13:51 +03:00
Grail Finder
acc3f11ee3 Enha: remove extra connection 2025-07-17 23:02:51 +03:00
Grail Finder
9fc36eb7ea Merge branch 'master' into enha/sse-try 2025-07-15 16:13:59 +03:00
Grail Finder
123d6c240f Feat: ru prompts for llm 2025-07-14 19:04:35 +03:00
Grail Finder
6951ec0535 Chore: styles 2025-07-13 12:45:55 +03:00
Grail Finder
ad44dc0642 Chore: styles 2025-07-13 12:28:52 +03:00
Grail Finder
155aa1b2cb Feat: bot failed state to show/hide llm restart btn 2025-07-13 08:31:16 +03:00
Grail Finder
757586ea22 Enha: avoid nil panics 2025-07-12 21:02:10 +03:00
Grail Finder
8f9865db3f Enha: simplify sse (worsened event recieving) 2025-07-12 20:33:41 +03:00
Grail Finder
a934d07be3 Fix: rating update 2025-07-11 21:38:38 +03:00
Grail Finder
acf1386c73 Fix: stats and marks queries 2025-07-11 21:19:16 +03:00
Grail Finder
f01fc12510 Feat: add rating 2025-07-11 18:45:33 +03:00
Grail Finder
329f849a72 Fix: freezes 2025-07-11 17:55:21 +03:00
Grail Finder
134b7b6262 Enha: tracing [WIP] 2025-07-11 17:34:07 +03:00
Grail Finder
ea27d35254 Feat: add telemetry 2025-07-11 16:20:43 +03:00
Grail Finder
9a949757f2 Fix: test 2025-07-11 14:18:10 +03:00
Grail Finder
7beccb84a2 Enha: llm errors to journal 2025-07-11 14:12:05 +03:00
Grail Finder
3cb43d5129 Fix: exit from room deletes player 2025-07-11 13:02:44 +03:00
Grail Finder
566d645230 Enha: avoid replacing div that connects to sse 2025-07-11 12:43:04 +03:00
Grail Finder
b64c3a4eab Enha: mutexes for global maps 2025-07-11 12:21:51 +03:00
Grail Finder
d41ed9d822 Chore: debug logs 2025-07-11 11:58:57 +03:00
Grail Finder
37fe76456e Fix: on login add player once 2025-07-11 09:03:18 +03:00
Grail Finder
d056c4a07e Chore: styles cleaning 2025-07-11 08:51:20 +03:00
Grail Finder
89572e8fb5 Feat: bot ends game to the stats 2025-07-10 20:21:10 +03:00
Grail Finder
8040586043 Feat: update stats 2025-07-10 20:12:37 +03:00
Grail Finder
8392a764a2 Feat: stats [WIP] 2025-07-10 19:55:46 +03:00
Grail Finder
ff6fed073e Enha: lang select from options 2025-07-10 13:58:40 +03:00
Grail Finder
6f83d98799 Chore: more logs for debug 2025-07-10 11:53:33 +03:00
Grail Finder
c946c07542 Enha: roomlistupdate only on change; heartbeat every 2s 2025-07-10 11:35:56 +03:00
Grail Finder
85edb2d0ce Enha: do not show mime thoughts 2025-07-10 11:19:07 +03:00
Grail Finder
a03253593c Feat: signout button 2025-07-10 10:56:48 +03:00
Grail Finder
5ba97d3423 Feat: signout endpoint 2025-07-10 10:43:52 +03:00
Grail Finder
d4c57c3262 Enha: gziped css and js 2025-07-10 09:48:12 +03:00
Grail Finder
a9c9837b7c Enha: ru words cleaning 2025-07-10 08:29:29 +03:00
Grail Finder
a95dc82515 Fix: check user pass only if user exists 2025-07-09 15:39:11 +03:00
Grail Finder
2180f14850 Enha: bot names avoid collision 2025-07-09 13:47:02 +03:00
Grail Finder
502317507b Feat: add password for player 2025-07-09 12:39:16 +03:00
Grail Finder
50d042a19d Dep: htmx update 2025-07-08 20:57:28 +03:00
Grail Finder
881a01bad0 Fix: sse updates 2025-07-08 20:33:09 +03:00
Grail Finder
82b3692919 Enha: js 2025-07-08 18:57:55 +03:00
Grail Finder
4be52d8a33 Enha: back to tailwind 2025-07-08 18:53:52 +03:00
Grail Finder
49f7642937 Enha: move backlog 2025-07-08 17:47:37 +03:00
Grail Finder
8f6a093ea1 Fix: nil check player 2025-07-08 12:22:18 +03:00
Grail Finder
587adfbbda Fix: llmapi use same db conn, delete old test; 2025-07-08 10:34:54 +03:00
Grail Finder
9a0e8d01ba Chore: code cleaning 2025-07-08 10:17:32 +03:00
Grail Finder
ce5d55cc13 Fix: show journal 2025-07-08 10:05:36 +03:00
Grail Finder
7ed430d8d7 Fix: dockerfile 2025-07-07 21:27:42 +03:00
Grail Finder
723f335f0f Feat: add dockerfile 2025-07-07 16:14:47 +03:00
Grail Finder
fe21c3e927 Fix: call the bot if something is wrong 2025-07-07 15:05:10 +03:00
Grail Finder
22ddc88d82 Enha: timer package 2025-07-07 15:01:15 +03:00
Grail Finder
75651d7f76 Feat: player stats [WIP] 2025-07-07 13:06:03 +03:00
Grail Finder
2751b6b9dc Fix: limit 0 because of too early call of notifybot 2025-07-07 12:32:23 +03:00
Grail Finder
a2c5f17e30 Fix: session to create player only if does not exist 2025-07-07 09:21:18 +03:00
Grail Finder
7ae255cc04 Enha: clear marks by room id 2025-07-07 07:53:12 +03:00
Grail Finder
a796b5b5de Chore: todos update 2025-07-06 15:42:41 +03:00
Grail Finder
718c9c10be Enha: bot timer 2025-07-06 14:36:18 +03:00
Grail Finder
a131183729 Enha: mark with partial name 2025-07-06 13:55:52 +03:00
Grail Finder
357f42c354 Enha: db use same connection to avoid db locking 2025-07-06 13:20:28 +03:00
Grail Finder
e84941d593 Enha: remove bot without room 2025-07-06 12:57:02 +03:00
Grail Finder
a38472a685 Enha: cron for cleaning rooms from players 2025-07-06 10:13:38 +03:00
Grail Finder
a685686b32 Fix: load cards to remove old cards from db 2025-07-06 09:32:51 +03:00
Grail Finder
9900ebd3dd Fix: bot mime give clue -> update page 2025-07-06 08:39:58 +03:00
Grail Finder
f97d91ac74 Fix: llm mime to set openthisturn to 0 2025-07-06 07:47:59 +03:00
Grail Finder
e9b9b9e559 Fix: actions to have room_id 2025-07-06 07:31:29 +03:00
Grail Finder
f46cbff602 Fix: buildable 2025-07-05 14:40:42 +03:00
Grail Finder
6ad251fc47 Enha: journal repo [wip] 2025-07-05 14:32:48 +03:00
Grail Finder
27e31603da Feat: add journal [wip] 2025-07-05 14:15:31 +03:00
60 changed files with 2409 additions and 686 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ gralias
store.json
config.toml
gralias.db
traces.log

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Build stage
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache build-base gcc
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -a -o /gralias .
# Final stage
FROM alpine:latest
WORKDIR /app
COPY --from=builder /gralias /app/gralias
# copy assets and templates
COPY assets ./assets
COPY components ./components
COPY config.toml ./config.toml
COPY migrations ./migrations
EXPOSE 3000
CMD ["/app/gralias"]

View File

@@ -1,6 +1,6 @@
.PHONY: all init deps install test lint run stop
run:
run: migrate-up
go build
./gralias start
@@ -32,7 +32,8 @@ stop-container:
docker rm -f gralias 2>/dev/null && echo "old container removed"
run-container: stop-container
docker run --name=gralias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:3000:3000 -d gralias:master
migrate -database 'sqlite3://gralias.db' -path migrations up
docker run --name=gralias -v $(CURDIR)/gralias.db:/app/gralias.db -p 0.0.0.0:3003:3000 -d gralias:master
migrate-up:
migrate -database 'sqlite3://gralias.db' -path migrations up

BIN
assets/helpers.js.gz Normal file

Binary file not shown.

2
assets/htmx.min.js vendored

File diff suppressed because one or more lines are too long

BIN
assets/htmx.min.js.gz Normal file

Binary file not shown.

BIN
assets/htmx.sse.js.gz Normal file

Binary file not shown.

691
assets/output.css Normal file
View File

@@ -0,0 +1,691 @@
/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, base, components, utilities;
@layer theme {
:root, :host {
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
--color-red-300: oklch(80.8% 0.114 19.571);
--color-red-400: oklch(70.4% 0.191 22.216);
--color-red-500: oklch(63.7% 0.237 25.331);
--color-red-700: oklch(50.5% 0.213 27.518);
--color-orange-100: oklch(95.4% 0.038 75.164);
--color-orange-500: oklch(70.5% 0.213 47.604);
--color-orange-700: oklch(55.3% 0.195 38.402);
--color-amber-100: oklch(96.2% 0.059 95.617);
--color-green-600: oklch(62.7% 0.194 149.214);
--color-green-700: oklch(52.7% 0.154 150.069);
--color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-blue-500: oklch(62.3% 0.214 259.815);
--color-blue-600: oklch(54.6% 0.245 262.881);
--color-blue-700: oklch(48.8% 0.243 264.376);
--color-indigo-500: oklch(58.5% 0.233 277.117);
--color-indigo-600: oklch(51.1% 0.262 276.966);
--color-gray-100: oklch(96.7% 0.003 264.542);
--color-gray-300: oklch(87.2% 0.01 258.338);
--color-gray-600: oklch(44.6% 0.03 256.802);
--color-gray-900: oklch(21% 0.034 264.665);
--color-stone-400: oklch(70.9% 0.01 56.259);
--color-black: #000;
--color-white: #fff;
--spacing: 0.25rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-lg: 1.125rem;
--text-lg--line-height: calc(1.75 / 1.125);
--text-xl: 1.25rem;
--text-xl--line-height: calc(1.75 / 1.25);
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
}
}
@layer base {
*, ::after, ::before, ::backdrop, ::file-selector-button {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
html, :host {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent;
}
hr {
height: 0;
color: inherit;
border-top-width: 1px;
}
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
-webkit-text-decoration: inherit;
text-decoration: inherit;
}
b, strong {
font-weight: bolder;
}
code, kbd, samp, pre {
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
font-feature-settings: var(--default-mono-font-feature-settings, normal);
font-variation-settings: var(--default-mono-font-variation-settings, normal);
font-size: 1em;
}
small {
font-size: 80%;
}
sub, sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
table {
text-indent: 0;
border-color: inherit;
border-collapse: collapse;
}
:-moz-focusring {
outline: auto;
}
progress {
vertical-align: baseline;
}
summary {
display: list-item;
}
ol, ul, menu {
list-style: none;
}
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
vertical-align: middle;
}
img, video {
max-width: 100%;
height: auto;
}
button, input, select, optgroup, textarea, ::file-selector-button {
font: inherit;
font-feature-settings: inherit;
font-variation-settings: inherit;
letter-spacing: inherit;
color: inherit;
border-radius: 0;
background-color: transparent;
opacity: 1;
}
:where(select:is([multiple], [size])) optgroup {
font-weight: bolder;
}
:where(select:is([multiple], [size])) optgroup option {
padding-inline-start: 20px;
}
::file-selector-button {
margin-inline-end: 4px;
}
::placeholder {
opacity: 1;
}
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
::placeholder {
color: currentcolor;
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, currentcolor 50%, transparent);
}
}
}
textarea {
resize: vertical;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-date-and-time-value {
min-height: 1lh;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
padding-block: 0;
}
:-moz-ui-invalid {
box-shadow: none;
}
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
appearance: button;
}
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
height: auto;
}
[hidden]:where(:not([hidden='until-found'])) {
display: none !important;
}
}
@layer utilities {
.mt-2 {
margin-top: calc(var(--spacing) * 2);
}
.mb-1 {
margin-bottom: calc(var(--spacing) * 1);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
.block {
display: block;
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.max-h-96 {
max-height: calc(var(--spacing) * 96);
}
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-full {
width: 100%;
}
.min-w-\[100px\] {
min-width: 100px;
}
.flex-1 {
flex: 1;
}
.cursor-pointer {
cursor: pointer;
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.justify-center {
justify-content: center;
}
.gap-2 {
gap: calc(var(--spacing) * 2);
}
.gap-4 {
gap: calc(var(--spacing) * 4);
}
.space-y-2 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-4 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-6 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-x-2 {
:where(& > :not(:last-child)) {
--tw-space-x-reverse: 0;
margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));
margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)));
}
}
.space-x-4 {
:where(& > :not(:last-child)) {
--tw-space-x-reverse: 0;
margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));
margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
}
}
.overflow-y-auto {
overflow-y: auto;
}
.rounded {
border-radius: 0.25rem;
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
.border {
border-style: var(--tw-border-style);
border-width: 1px;
}
.border-0 {
border-style: var(--tw-border-style);
border-width: 0px;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-l-4 {
border-left-style: var(--tw-border-style);
border-left-width: 4px;
}
.border-gray-300 {
border-color: var(--color-gray-300);
}
.border-orange-500 {
border-color: var(--color-orange-500);
}
.border-stone-400 {
border-color: var(--color-stone-400);
}
.bg-amber-100 {
background-color: var(--color-amber-100);
}
.bg-blue-300 {
background-color: var(--color-blue-300);
}
.bg-blue-400 {
background-color: var(--color-blue-400);
}
.bg-blue-500 {
background-color: var(--color-blue-500);
}
.bg-blue-700 {
background-color: var(--color-blue-700);
}
.bg-gray-100 {
background-color: var(--color-gray-100);
}
.bg-green-600 {
background-color: var(--color-green-600);
}
.bg-indigo-600 {
background-color: var(--color-indigo-600);
}
.bg-orange-100 {
background-color: var(--color-orange-100);
}
.bg-red-300 {
background-color: var(--color-red-300);
}
.bg-red-400 {
background-color: var(--color-red-400);
}
.bg-red-500 {
background-color: var(--color-red-500);
}
.bg-red-700 {
background-color: var(--color-red-700);
}
.bg-white {
background-color: var(--color-white);
}
.p-2 {
padding: calc(var(--spacing) * 2);
}
.p-4 {
padding: calc(var(--spacing) * 4);
}
.px-1 {
padding-inline: calc(var(--spacing) * 1);
}
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
.px-3 {
padding-inline: calc(var(--spacing) * 3);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-1 {
padding-block: calc(var(--spacing) * 1);
}
.py-1\.5 {
padding-block: calc(var(--spacing) * 1.5);
}
.py-2 {
padding-block: calc(var(--spacing) * 2);
}
.text-center {
text-align: center;
}
.font-mono {
font-family: var(--font-mono);
}
.text-lg {
font-size: var(--text-lg);
line-height: var(--tw-leading, var(--text-lg--line-height));
}
.text-sm {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
.text-xl {
font-size: var(--text-xl);
line-height: var(--tw-leading, var(--text-xl--line-height));
}
.leading-6 {
--tw-leading: calc(var(--spacing) * 6);
line-height: calc(var(--spacing) * 6);
}
.font-bold {
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
}
.font-medium {
--tw-font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-medium);
}
.font-semibold {
--tw-font-weight: var(--font-weight-semibold);
font-weight: var(--font-weight-semibold);
}
.text-black {
color: var(--color-black);
}
.text-blue-400 {
color: var(--color-blue-400);
}
.text-blue-500 {
color: var(--color-blue-500);
}
.text-blue-600 {
color: var(--color-blue-600);
}
.text-blue-700 {
color: var(--color-blue-700);
}
.text-gray-600 {
color: var(--color-gray-600);
}
.text-gray-900 {
color: var(--color-gray-900);
}
.text-orange-700 {
color: var(--color-orange-700);
}
.text-red-400 {
color: var(--color-red-400);
}
.text-red-500 {
color: var(--color-red-500);
}
.text-red-700 {
color: var(--color-red-700);
}
.text-white {
color: var(--color-white);
}
.shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-1 {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.ring-gray-300 {
--tw-ring-color: var(--color-gray-300);
}
.transition-colors {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.ring-inset {
--tw-ring-inset: inset;
}
.placeholder\:text-gray-300 {
&::placeholder {
color: var(--color-gray-300);
}
}
.hover\:bg-green-700 {
&:hover {
@media (hover: hover) {
background-color: var(--color-green-700);
}
}
}
.hover\:bg-indigo-500 {
&:hover {
@media (hover: hover) {
background-color: var(--color-indigo-500);
}
}
}
.focus\:border-blue-500 {
&:focus {
border-color: var(--color-blue-500);
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus\:ring-blue-500 {
&:focus {
--tw-ring-color: var(--color-blue-500);
}
}
.focus\:ring-indigo-600 {
&:focus {
--tw-ring-color: var(--color-indigo-600);
}
}
.focus\:ring-inset {
&:focus {
--tw-ring-inset: inset;
}
}
.focus-visible\:outline {
&:focus-visible {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
}
.focus-visible\:outline-2 {
&:focus-visible {
outline-style: var(--tw-outline-style);
outline-width: 2px;
}
}
.focus-visible\:outline-offset-2 {
&:focus-visible {
outline-offset: 2px;
}
}
.focus-visible\:outline-indigo-600 {
&:focus-visible {
outline-color: var(--color-indigo-600);
}
}
.sm\:grid-cols-5 {
@media (width >= 40rem) {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
.sm\:text-sm {
@media (width >= 40rem) {
font-size: var(--text-sm);
line-height: var(--tw-leading, var(--text-sm--line-height));
}
}
.sm\:leading-6 {
@media (width >= 40rem) {
--tw-leading: calc(var(--spacing) * 6);
line-height: calc(var(--spacing) * 6);
}
}
}
@property --tw-space-y-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-space-x-reverse {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-border-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@property --tw-leading {
syntax: "*";
inherits: false;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-inset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-shadow-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-shadow-alpha {
syntax: "<percentage>";
inherits: false;
initial-value: 100%;
}
@property --tw-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-inset-ring-color {
syntax: "*";
inherits: false;
}
@property --tw-inset-ring-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-ring-inset {
syntax: "*";
inherits: false;
}
@property --tw-ring-offset-width {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --tw-ring-offset-color {
syntax: "*";
inherits: false;
initial-value: #fff;
}
@property --tw-ring-offset-shadow {
syntax: "*";
inherits: false;
initial-value: 0 0 #0000;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-space-y-reverse: 0;
--tw-space-x-reverse: 0;
--tw-border-style: solid;
--tw-leading: initial;
--tw-font-weight: initial;
--tw-shadow: 0 0 #0000;
--tw-shadow-color: initial;
--tw-shadow-alpha: 100%;
--tw-inset-shadow: 0 0 #0000;
--tw-inset-shadow-color: initial;
--tw-inset-shadow-alpha: 100%;
--tw-ring-color: initial;
--tw-ring-shadow: 0 0 #0000;
--tw-inset-ring-color: initial;
--tw-inset-ring-shadow: 0 0 #0000;
--tw-ring-inset: initial;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-outline-style: solid;
}
}
}

BIN
assets/output.css.gz Normal file

Binary file not shown.

View File

@@ -1,7 +1,6 @@
body{
background-color: #0C1616FF;
color: #8896b2;
max-width: 800px;
min-width: 0px;
margin: 2em auto !important;
margin-left: auto;
@@ -12,12 +11,6 @@ body{
text-align: center;
display: block;
}
a{
color: #00a2e7;
}
a:visited{
color: #ca1a70;
}
table{
border-collapse: separate !important;
border-spacing: 10px 10px;

BIN
assets/style.css.gz Normal file

Binary file not shown.

BIN
assets/tailwind.css.gz Normal file

Binary file not shown.

View File

@@ -140,7 +140,6 @@
патриот
мечеть
ярость
юго-запад
цикл
роман
джей
@@ -269,7 +268,6 @@
облегчение
налогообложение
враг
свободный человек
инцест
демографический
ипотека
@@ -295,7 +293,6 @@
профессор
путешествие
невинный
несчастный случай
стих
владение
движение
@@ -305,7 +302,6 @@
кадр
указание
пенни
как подпись
простота
холодильник
разнообразие
@@ -329,7 +325,6 @@
рождение
нож
значение
молочные продукты
имущество
песок
убийство
@@ -369,7 +364,6 @@
свинина
конкурс
ясный
внешний вид
ограничение
назад
поражение
@@ -400,7 +394,6 @@
единообразие
рацион
нести
судебный процесс
слияние
дополнительно
диаграмма
@@ -422,10 +415,7 @@
воздействие
автор
упряжь
конц ert
текстиль
окружающая среда
в целом
лидер
измерение
компания
@@ -626,7 +616,6 @@ hank
спрей
завтра
девушка
не нравится
обед
такси
внутри
@@ -726,7 +715,6 @@ hank
фильм
отношение
добавить
лунный свет
бледный
аромат
змея
@@ -933,7 +921,6 @@ hank
очарование
слово
пероверхий
моющее средство
трейлер
болезнь
азот
@@ -997,7 +984,6 @@ hank
актер
любовь
группа
восход солнца
удача
агентство
милосердие
@@ -1265,7 +1251,6 @@ fallout
пенсия
персонал
корреляция
солнечный свет
портативный
голова
картофель
@@ -1277,7 +1262,6 @@ fallout
эмансипация
дискриминация
восстановление
из-за
портрет
приобретение
сталь
@@ -1333,7 +1317,6 @@ palfrey
уход
контракт
прогресс
центр города
соглашение
авеню
утопия
@@ -1370,7 +1353,6 @@ palfrey
провод
технология
вера
вертикально t
акции
цена
канал
@@ -1410,7 +1392,6 @@ palfrey
проповедь
празднование
sba
учебная программа
рынок
пуля
устная
@@ -1453,7 +1434,6 @@ sba
ненависть
самовывоз
скидка
т estament
администратор
бить
наклон
@@ -1493,7 +1473,6 @@ sba
трек
архитектура
ракета
сообщение унификация
вероятный
преемственность
токен
@@ -1510,7 +1489,6 @@ sba
антиквариат
рукав
обследование
дикая местность
остановка
касание
ассоциация
@@ -1534,7 +1512,6 @@ sba
повышение
раковина
стоимость
дисков ery
fly
warren
overhead
@@ -1574,7 +1551,6 @@ paper
over
complement
nursery
arrange ment
консерватизм
индивидуальный
грант
@@ -1610,7 +1586,6 @@ arrange ment
звезда
улучшение
объект
постоянный nt
pat
ковёр
разделение
@@ -1657,7 +1632,6 @@ pat
шедевр
семя
нет
розничная торговля
валун
десятилетие
коррупция
@@ -1685,14 +1659,12 @@ pat
посвящение
духи
спальня
консервный завод
ржавчина
профессиональный
кремль
свобода
лагуна
конфиденциальность
attenda nt
диффузия
светский
резина
@@ -1733,11 +1705,9 @@ attenda nt
республиканец
уголок
куст
dru г
персона
грудь
латунь
сточные воды
старший
лимон
стандарт
@@ -1774,7 +1744,6 @@ dru г
ощущение
завершение
влияние
ge neration
поиск
гнев
рыба
@@ -1791,14 +1760,12 @@ ge neration
секрет
влажный
артерия
рабочая сила
уступать
сам
койка
счет
потерянный
приятель
кто-то
специалист
поэт
главный
@@ -1819,7 +1786,6 @@ ge neration
план
журнал
денди
ve rtical
дождь
револьвер
вперед
@@ -1920,7 +1886,6 @@ magnum
любитель
концепция
империя
дикая природа
кукуруза
горчица
компромисс
@@ -1940,7 +1905,6 @@ magnum
сигарета
прирост
имитация
рем ote
тьма
длиннее
эмоция
@@ -1980,12 +1944,10 @@ magnum
призрак
грабли
джерси
пресер vation
обещание
уровень
зарплата
трава
девственная плева
хватка
менеджер
потряс
@@ -1998,7 +1960,6 @@ magnum
игрок
носильщик
сэм
единственное число
текст
язык
пиломатериалы
@@ -2020,7 +1981,6 @@ magnum
композитор
окисление
кислород
voi ce
прогрессивный
коммунизм
толпа
@@ -2032,7 +1992,6 @@ voi ce
юрист
театр
смелый
подводная лодка
стоимость
словарь
знак
@@ -2061,10 +2020,8 @@ voi ce
убийца
бутылка
день
все еще
конгрессмен
спикер
резо fance
общественный
самолет
гребень
@@ -2100,13 +2057,11 @@ voi ce
идеальный
пан
динамический
подъездная дорога
резерв
обслуживание
гражданин
ювенальный
степень
молитва r
gin
hogan
еда
@@ -2129,7 +2084,6 @@ hogan
диаметр
сестра
производитель
точка зрения
кальций
винт
шахта
@@ -2147,11 +2101,9 @@ hogan
истинный
пена
теология
работа по делу
польский
армия
отклонить
петух ail
low
пропаганда
характер
@@ -2234,7 +2186,6 @@ low
представитель
вход
патент
среда обитания
дренаж
мост
период
@@ -2275,7 +2226,6 @@ low
соседство
пик
кен
вор th
предварительный
злой
экран
@@ -2299,7 +2249,6 @@ low
кабина
решимость
нельсон
кто-то
двигатель
количество
лекция
@@ -2316,7 +2265,6 @@ low
компульсивный
франклин
ракушка
голубой e
статуя
люкс
бар
@@ -2359,49 +2307,45 @@ low
наблюдение
порода
фунт
владелец p
stem
depot
immortality
answer
god
disposition
document
grand
exercise
bee
reputation
pleasure
commission
encouragement
fluid
fog
whisper
panel
field
special
continuation
infant
climax
chemistry
capability
income
dust
fell
keelson
game
shelter
medium
homer
aged
oxen
little
tourist
lane
cure
arch
electricity
sta rt
стебель
депо
бессмертие
ответ
бог
расположение
документ
великий
упражнение
пчела
репутация
удовольствие
комиссия
поощрение
жидкость
туман
шепот
панель
поле
специальный
продолжение
младенец
кульминация
химия
способность
доход
пыль
упал
игра
убежище
средний
гомер
волы
маленький
туристический
переулок
лечение
арка
электричество
амбар
почва
популярность
@@ -2419,12 +2363,10 @@ sta rt
губа
консультация
клевер
в сторону
независимый
принятие
принцип
рациональный
северо-восток
участие
одиночный
фантастика
@@ -2440,7 +2382,6 @@ sta rt
параллельный
внутренний
схватывание
v itality
театральный
банда
усыновление
@@ -2466,7 +2407,6 @@ v itality
блондин
униформа
пара
железная дорога
поддержка
история
разоружение
@@ -2484,7 +2424,6 @@ v itality
душа
стремительный
идеал
день рождения
ларек
речь
свечение
@@ -2606,7 +2545,6 @@ v itality
клуб
генератор
сказать
fa shion
тест
моб
масло
@@ -2618,7 +2556,6 @@ fa shion
управление
забор
ребенок
летучая мышь
форд
нерв
хвост
@@ -2730,7 +2667,6 @@ wtv
волнение
цитата
лоб
wa x
mckinley
телевидение
может
@@ -2770,7 +2706,6 @@ mckinley
фонтан
исполнение
отдел
st способный
деревня
посол
читать
@@ -2789,7 +2724,6 @@ st способный
грабеж
связанный
фриз
берег реки
выборы
демократия
оркестр
@@ -2798,7 +2732,6 @@ st способный
разум
коп
промах
список битв
этнический
камера
регулярный
@@ -2969,7 +2902,6 @@ net
размах
безопасность
тост
ри ce
память
трубка
ткань
@@ -3255,8 +3187,6 @@ net
горничная
мясо
социология
северо-запад
желчь ery
гора
могила
релевантность

View File

@@ -1,15 +1,17 @@
package broker
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"time"
)
// the amount of time to wait when pushing a message to
// a slow client or a client that closed after `range clients` started.
const patience time.Duration = time.Second * 1
// const patience time.Duration = time.Second * 1
type (
NotificationEvent struct {
@@ -19,23 +21,23 @@ type (
NotifierChan chan NotificationEvent
Broker struct {
// Events are pushed to this channel by the main events-gathering routine
Notifier NotifierChan
// New client connections
newClients chan NotifierChan
// Closed client connections
closingClients chan NotifierChan
// Client connections registry
clients map[NotifierChan]struct{}
Notifier NotifierChan
log *slog.Logger
addClient chan NotifierChan
clients map[NotifierChan]struct{}
}
)
func NewBroker() (broker *Broker) {
// Instantiate a broker
return &Broker{
Notifier: make(NotifierChan, 1),
newClients: make(chan NotifierChan),
closingClients: make(chan NotifierChan),
clients: make(map[NotifierChan]struct{}),
Notifier: make(NotifierChan, 100),
log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true,
})),
addClient: make(chan NotifierChan, 10),
clients: map[NotifierChan]struct{}{},
}
}
@@ -44,7 +46,6 @@ var Notifier *Broker
// for use in different packages
func init() {
Notifier = NewBroker()
go Notifier.Listen()
}
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -52,52 +53,60 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
messageChan := make(NotifierChan)
broker.newClients <- messageChan
defer func() { broker.closingClients <- messageChan }()
// w.Header().Set("Access-Control-Allow-Origin", "*")
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*" // Fallback for non-browser clients
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
ctx := r.Context()
msgChan := make(NotifierChan, 10)
broker.addClient <- msgChan
// browser can close sse on its own; ping every 2s to prevent
heartbeat := time.NewTicker(8 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
broker.log.Debug("broker: got ctx done")
// Client disconnected
return
case event := <-messageChan:
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil {
fmt.Println(err)
// Client disconnected
return
case event := <-broker.Notifier:
broker.log.Debug("got event", "event", event)
for i := 0; i < 10; i++ { // Repeat 3 times
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil {
broker.log.Error("write failed", "error", err)
return
}
w.(http.Flusher).Flush()
// Short delay between sends (non-blocking)
select {
case <-time.After(20 * time.Millisecond): // Adjust delay as needed
case <-ctx.Done():
return
}
}
case <-heartbeat.C:
// Send SSE heartbeat comment
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
broker.log.Error("failed to write heartbeat", "error", err)
return // Client disconnected
}
w.(http.Flusher).Flush()
}
}
}
// Listen for new notifications and redistribute them to clients
func (broker *Broker) Listen() {
func (broker *Broker) Listen(ctx context.Context) {
for {
select {
case s := <-broker.newClients:
// A new client has connected.
// Register their message channel
broker.clients[s] = struct{}{}
slog.Info("Client added", "clients listening", len(broker.clients))
case s := <-broker.closingClients:
// A client has dettached and we want to
// stop sending them messages.
delete(broker.clients, s)
slog.Info("Client removed", "clients listening", len(broker.clients))
case event := <-broker.Notifier:
// We got a new event from the outside!
// Send event to all connected clients
for clientMessageChan := range broker.clients {
select {
case clientMessageChan <- event:
case <-time.After(patience):
slog.Info("Client was skipped", "clients listening", len(broker.clients))
}
}
case <-ctx.Done():
return
case clientChan := <-broker.addClient:
// mutex
broker.clients[clientChan] = struct{}{}
}
}
}

View File

@@ -18,7 +18,7 @@
if (!window.actionHistoryScrollSet) {
htmx.onLoad(function(target) {
if (target.id === 'actionHistoryContainer') {
target.scrollTop = target.scrollHeight;
target.scrollToBottom();
}
});
window.actionHistoryScrollSet = true;

View File

@@ -3,47 +3,25 @@
<html lang="en">
<head>
<title>Alias</title>
<script src="/assets/helpers.js"></script>
<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/tailwind.css"></script>
<link rel="stylesheet" href="/assets/style.css"/>
<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">
{{template "main" .}}
<script type="text/javascript">
document.body.addEventListener('htmx:sseError', function (e) {
// do something before the event data is swapped in
console.log(e)
})
</script>
<div id="main-content">
{{template "main" .}}
</div>
</div>
</body>
</html>

View File

@@ -29,7 +29,12 @@
<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>

View File

@@ -4,14 +4,19 @@
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">
<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/>
<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>
<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>
</div>
{{else}}

View File

@@ -4,9 +4,16 @@
{{ else if ne .LinkLogin "" }}
{{template "linklogin" .LinkLogin}}
{{ else if not .State.RoomID }}
<div id="hello-user">
<p>data: {{.}} {{.State}} {{.Room}}</p>
<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>
@@ -15,6 +22,7 @@
{{template "roomlist" .List}}
</div>
{{else}}
<div id="sse-listener" sse-connect="/sub/sse" hx-trigger="sse:roomupdate_{{.State.RoomID}}" hx-get="/room" hx-target="#room-interier" hx-swap="none" style="display:none;"></div>
<div id="room">
{{template "room" .}}
</div>

View File

@@ -1,15 +1,19 @@
{{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"/>
<input type="hidden" name="room_id" value={{.}}>
</div>
<div id="login_notice">this name looks available</div>
<label For="password" class="block text-sm font-medium leading-6 text-white-900">password</label>
<div>
<input id="password" name="password" type="password" class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div>
<div>
<button type="submit" class="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="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
</div>
</form>
</div>

View File

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

View File

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

View File

@@ -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="room-interier" class=space-y-2>
<div id="headwrapper" class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2">
<p>Hello {{.State.Username}};</p>
<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,21 +49,26 @@
<!-- Right Panel -->
{{template "teamlist" .Room.RedTeam}}
</div>
<hr />
<div id="systembox" style="overflow-y: auto; max-height: 100px;">
Server says: <br>
</div>
</div>
<hr/>
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div hx-get="/actionhistory" class="md:col-span-1">
{{template "actionhistory" .Room.ActionHistory}}
</div>
<div id="cardtable" class="md:col-span-3">
{{template "cardtable" .Room}}
</div>
<div class="hidden md:block md:col-span-1"></div> <!-- Spacer -->
</div>
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
bot thought: <br>
<ul>
{{range .Room.LogJournal}}
<li>{{.}}</li>
<li>{{.Username}}: {{.Entry}}</li>
{{end}}
</ul>
</div>
<div sse-swap="journal_{{.Room.ID}}">
bot thoughts
<div>
<div id="cardtable">
{{template "cardtable" .Room}}
</div>
<div>
{{if .Room.IsRunning}}
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}}
@@ -73,16 +79,13 @@
{{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>
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
{{template "actionhistory" .Room.ActionHistory}}
</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>

View File

@@ -1,10 +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 .}}
<p>
{{.ID}}
</p>
<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
View 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}}

View File

@@ -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")}}

View File

@@ -1,7 +1,7 @@
BASE_URL = "https://localhost:3000"
SESSION_LIFETIME_SECONDS = 30000
COOKIE_SECRET = "test"
DB_PATH = "sqlite3://gralias.db"
DB_PATH = "gralias.db"
[SERVICE]
HOST = "localhost"

View File

@@ -39,7 +39,7 @@ func LoadConfigOrDefault(fn string) *Config {
config.CookieSecret = "test"
config.ServerConfig.Host = "localhost"
config.ServerConfig.Port = "3000"
config.DBPath = "sqlite3://gralias.db"
config.DBPath = "gralias.db"
}
fmt.Printf("config debug; config.LLMConfig.URL: %s\n", config.LLMConfig.URL)
return config

View File

@@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"errors"
"gralias/broker"
"gralias/models"
"gralias/repos"
"log/slog"
"time"
@@ -27,7 +29,8 @@ func (cm *CronManager) Start() {
for range ticker.C {
cm.CleanupRooms()
cm.CleanupActions()
cm.CleanupInactiveRooms()
cm.CleanupPlayersRoom()
ticker.Reset(30 * time.Second)
}
}()
}
@@ -46,7 +49,6 @@ func (cm *CronManager) CleanupRooms() {
panic(r)
}
}()
rooms, err := cm.repo.RoomList(ctx)
if err != nil {
cm.log.Error("failed to get rooms list", "err", err)
@@ -55,14 +57,13 @@ func (cm *CronManager) CleanupRooms() {
}
return
}
roomListChange := false
for _, room := range rooms {
players, err := cm.repo.PlayerListByRoom(ctx, room.ID)
if err != nil {
cm.log.Error("failed to get players for room", "room_id", room.ID, "err", err)
continue
}
if len(players) == 0 {
cm.log.Info("deleting empty room", "room_id", room.ID)
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
@@ -71,9 +72,9 @@ 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
for _, player := range players {
if player.Username == room.CreatorName {
@@ -81,12 +82,32 @@ func (cm *CronManager) CleanupRooms() {
break
}
}
if !creatorInRoom {
cm.log.Info("deleting room because creator left", "room_id", room.ID)
isInactive := false
// If the creator is in the room and the room is more than one hour old, check for inactivity
if creatorInRoom && time.Since(room.CreatedAt) > time.Hour {
lastActionTime, err := cm.repo.ActionGetLastTimeByRoomID(ctx, room.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
cm.log.Error("failed to get last action time for room", "room_id", room.ID, "err", err)
// Skip setting isInactive and proceed
} else {
// If there are no actions, lastActionTime is the zero value (or from sql.ErrNoRows we get zero as well)
if lastActionTime.IsZero() {
isInactive = true
} else if time.Since(lastActionTime) > time.Hour {
isInactive = true
}
}
}
// If the creator is not in the room or the room is inactive, it's time to delete
if !creatorInRoom || isInactive {
reason := "creator left"
if isInactive {
reason = "inactive"
}
cm.log.Info("deleting room", "room_id", room.ID, "reason", reason)
for _, player := range players {
if player.IsBot {
if err := cm.repo.PlayerDelete(ctx, room.ID, player.Username); err != nil {
if err := cm.repo.PlayerDelete(ctx, room.ID); err != nil {
cm.log.Error("failed to delete bot player", "room_id", room.ID, "username", player.Username, "err", err)
}
} else {
@@ -96,17 +117,25 @@ func (cm *CronManager) CleanupRooms() {
}
}
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
cm.log.Error("failed to delete room after creator left", "room_id", room.ID, "err", err)
cm.log.Error("failed to delete room", "room_id", room.ID, "reason", reason, "err", err)
}
if err := cm.repo.SettingsDeleteByRoomID(ctx, room.ID); err != nil {
cm.log.Error("failed to delete settings after creator left", "room_id", room.ID, "err", err)
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
}
}
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() {
@@ -123,7 +152,6 @@ func (cm *CronManager) CleanupActions() {
panic(r)
}
}()
if err := cm.repo.ActionDeleteOrphaned(ctx); err != nil {
cm.log.Error("failed to delete orphaned actions", "err", err)
if err := tx.Rollback(); err != nil {
@@ -131,56 +159,7 @@ func (cm *CronManager) CleanupActions() {
}
return
}
if err := tx.Commit(); err != nil {
cm.log.Error("failed to commit transaction for actions cleanup", "err", err)
}
}
func (cm *CronManager) CleanupInactiveRooms() {
ctx, tx, err := cm.repo.InitTx(context.Background())
if err != nil {
cm.log.Error("failed to init transaction for inactive rooms cleanup", "err", err)
return
}
defer func() {
if r := recover(); r != nil {
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for inactive rooms cleanup", "err", err)
}
panic(r)
}
}()
rooms, err := cm.repo.RoomList(ctx)
if err != nil {
cm.log.Error("failed to get rooms list for inactive rooms cleanup", "err", err)
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for inactive rooms cleanup", "err", err)
}
return
}
for _, room := range rooms {
lastActionTime, err := cm.repo.ActionGetLastTimeByRoomID(ctx, room.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
cm.log.Error("failed to get last action time for room", "room_id", room.ID, "err", err)
continue
}
if lastActionTime.IsZero() && time.Since(room.CreatedAt) > time.Hour {
cm.log.Info("deleting inactive room (no actions)", "room_id", room.ID, "created_at", room.CreatedAt)
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
cm.log.Error("failed to delete inactive room (no actions)", "room_id", room.ID, "err", err)
}
continue
}
if !lastActionTime.IsZero() && time.Since(lastActionTime) > time.Hour && time.Since(room.CreatedAt) > time.Hour {
cm.log.Info("deleting inactive room (last action older than 1 hour)", "room_id", room.ID, "last_action_time", lastActionTime)
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
cm.log.Error("failed to delete inactive room (last action older than 1 hour)", "room_id", room.ID, "err", err)
}
}
}
if err := tx.Commit(); err != nil {
cm.log.Error("failed to commit transaction for inactive rooms cleanup", "err", err)
}
}

74
crons/players.go Normal file
View File

@@ -0,0 +1,74 @@
package crons
import (
"context"
"gralias/llmapi"
)
func (cm *CronManager) CleanupPlayersRoom() {
ctx, tx, err := cm.repo.InitTx(context.Background())
if err != nil {
cm.log.Error("failed to init transaction for actions cleanup", "err", err)
return
}
defer func() {
if r := recover(); r != nil {
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
}
panic(r)
}
}()
players, err := cm.repo.PlayerListAll(ctx)
if err != nil {
cm.log.Error("failed to list players", "err", err)
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
}
return
}
// get all rooms to have only one req
rooms, err := cm.repo.RoomList(ctx)
if err != nil {
cm.log.Error("failed to list rooms", "err", err)
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
}
return
}
for _, player := range players {
found := false
for _, room := range rooms {
// check if room exists
if player.RoomID != nil && room.ID == *player.RoomID {
found = true
break
}
}
if !found {
cm.log.Debug("player routine; not found room", "username", player.Username)
if !player.IsBot && player.RoomID != nil {
// delete roomid from player
if err := cm.repo.PlayerExitRoom(ctx, player.Username); err != nil {
cm.log.Error("failed to unset room", "err", err)
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
}
}
}
if player.IsBot {
cm.log.Debug("trying to remove bot", "name", player.Username)
// delete player and stop the bot
if err := llmapi.RemoveBotNoRoom(player.Username); err != nil {
cm.log.Error("failed to remove bot", "err", err)
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
}
}
}
}
}
if err := tx.Commit(); err != nil {
cm.log.Error("failed to commit transaction for actions cleanup", "err", err)
}
}

8
docker-compose.yml Normal file
View 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
View File

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

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

View File

@@ -26,6 +26,9 @@ func createRoom(ctx context.Context, req *models.RoomReq) (*models.Room, error)
func saveFullInfo(ctx context.Context, fi *models.FullInfo) error {
// INFO: no transactions; so case is possible where first object is updated but the second is not
if fi.State == nil {
return errors.New("player is nil")
}
if err := repo.PlayerUpdate(ctx, fi.State); err != nil {
return err
}
@@ -56,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)
@@ -88,12 +91,17 @@ func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
}
func fillCardMarks(ctx context.Context, room *models.Room) error {
marks, err := repo.CardMarksByRoomID(ctx, room.ID)
if err != nil {
log.Warn("failed to fetch card marks by room_id", "room_id", room.ID, "error", err)
return err
}
for i, card := range room.Cards {
marks, err := repo.CardMarksByCardID(ctx, card.ID)
if err != nil {
return err
for _, mark := range marks {
if mark.CardID == card.ID {
room.Cards[i].Marks = append(room.Cards[i].Marks, mark)
}
}
room.Cards[i].Marks = marks
}
return nil
}
@@ -107,18 +115,6 @@ func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
return repo.PlayerGetByName(ctx, username)
}
// // DEPRECATED
// func leaveRole(fi *models.FullInfo) {
// fi.Room.RedTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.RedTeam.Guessers)
// fi.Room.BlueTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.BlueTeam.Guessers)
// if fi.Room.RedTeam.Mime == fi.State.Username {
// fi.Room.RedTeam.Mime = ""
// }
// if fi.Room.BlueTeam.Mime == fi.State.Username {
// fi.Room.BlueTeam.Mime = ""
// }
// }
func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) {
// get username
fi, _ := getFullInfoByCtx(ctx)
@@ -174,26 +170,6 @@ func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error)
return fi, nil
}
// get all rooms
// func listRooms(allRooms bool) []*models.Room {
// cacheMap := memcache.GetAll()
// publicRooms := []*models.Room{}
// // no way to know if room is public until unmarshal -_-;
// for key, value := range cacheMap {
// if strings.HasPrefix(key, models.CacheRoomPrefix) {
// room := &models.Room{}
// if err := json.Unmarshal(value, &room); err != nil {
// log.Warn("failed to unmarshal room", "error", err)
// continue
// }
// if room.IsPublic || allRooms {
// publicRooms = append(publicRooms, room)
// }
// }
// }
// return publicRooms
// }
// get bots
func listBots() []models.Player {
bots, err := repo.PlayerList(context.Background(), true)
@@ -214,6 +190,9 @@ func notify(event, msg string) {
func loadCards(room *models.Room) {
// remove old cards
room.Cards = []models.WordCard{}
// try to delete old cards from db (in case players play another round)
// nolint: errcheck
repo.WordCardsDeleteByRoomID(context.Background(), room.ID)
// store it somewhere
wordMap := map[string]string{
"en": "assets/words/en_nouns.txt",
@@ -226,10 +205,6 @@ func loadCards(room *models.Room) {
fmt.Println("failed to load cards", "error", err)
}
room.Cards = cards
// room.WCMap = make(map[string]models.WordColor)
// for _, card := range room.Cards {
// room.WCMap[card.Word] = card.Color
// }
}
func recoverBots() {
@@ -257,35 +232,6 @@ func recoverBot(bm models.Player) error {
return nil
}
// func recoverPlayers() {
// players := listPlayers()
// for playerName, playerMap := range players {
// if err := recoverPlayer(playerMap); err != nil {
// log.Warn("failed to recover player", "playerName", playerName, "error", err)
// }
// }
// }
// func recoverPlayer(pm map[string]string) error {
// // check if room still exists
// room, err := repo.RoomGetByID(context.Background(), pm["RoomID"])
// if err != nil {
// return fmt.Errorf("no such room: %s; err: %w", pm["RoomID"], err)
// }
// log.Debug("recovering player", "player", pm)
// role, team, ok := room.GetPlayerByName(pm["Username"])
// if !ok {
// return fmt.Errorf("failed to find player %s in the room %v", pm["Username"], room)
// }
// us := &models.Player{
// Username: pm["Username"],
// RoomID: pm["RoomID"],
// Team: team,
// Role: role,
// }
// return saveState(pm["Username"], us)
// }
// validateMove checks if it is players turn
func validateMove(fi *models.FullInfo, ur models.UserRole) error {
if fi.State.Role != ur {

View File

@@ -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"
@@ -37,7 +35,6 @@ func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
return
}
cleanName := utils.RemoveSpacesFromStr(username)
// allNames := getAllNames()
allNames, err := repo.PlayerListNames(r.Context())
if err != nil {
abortWithError(w, err.Error())
@@ -74,25 +71,33 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, msg)
return
}
password := r.PostFormValue("password")
var makeplayer bool
roomID := r.PostFormValue("room_id")
// make sure username does not exists
cleanName := utils.RemoveSpacesFromStr(username)
clearPass := utils.RemoveSpacesFromStr(password)
// 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, "clean_name", cleanName)
userstate = models.InitPlayer(cleanName)
makeplayer = true
} else {
if userstate.Password != clearPass {
log.Error("wrong password", "username", cleanName, "password", clearPass)
abortWithError(w, "wrong password")
return
}
}
// login user
cookie, err := makeCookie(cleanName, r.RemoteAddr)
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)
// check if that user was already in db
// userstate, err := loadState(cleanName)
userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
if err != nil || userstate == nil {
userstate = models.InitPlayer(cleanName)
makeplayer = true
}
fi := &models.FullInfo{
State: userstate,
}
@@ -105,20 +110,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
return
}
// room.PlayerList = append(room.PlayerList, fi.State.Username)
fi.Room = room
fi.List = nil
fi.State.RoomID = &room.ID
if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
abortWithError(w, err.Error())
return
}
// repo.RoomUpdate()
// save full info instead
// if err := saveFullInfo(r.Context(), fi); err != nil {
// abortWithError(w, err.Error())
// return
// }
} else {
log.Debug("no room_id in login")
// fi.List = listRooms(false)
@@ -128,23 +125,24 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
return
}
// save state to cache
// if err := saveState(cleanName, userstate); err != nil {
if makeplayer {
userstate.Password = clearPass
if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
// if err := saveFullInfo(r.Context(), fi); err != nil {
log.Error("failed to save state", "error", err)
abortWithError(w, err.Error())
return
}
}
}
// if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
// log.Error("failed to execute base template", "error", err)
// }
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()
@@ -181,14 +179,37 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
cookie.Secure = false
log.Info("changing cookie domain", "domain", cookie.Domain)
}
// 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)
}

View File

@@ -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,
@@ -87,6 +88,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
// if opened card is of color of opp team, change turn
oppositeColor := fi.Room.GetOppositeTeamColor()
var clearMarks bool
fi.Room.OpenedThisTurn++
log.Debug("got show-color request", "word", word, "color", color,
"limit", fi.Room.ThisTurnLimit, "opened", fi.Room.OpenedThisTurn,
@@ -98,7 +100,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0
fi.Room.ClearMarks()
clearMarks = true
StopTurnTimer(fi.Room.ID)
}
switch string(color) {
@@ -109,6 +111,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = oppositeColor
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlack,
@@ -117,8 +120,9 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
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
@@ -126,6 +130,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0
clearMarks = true
StopTurnTimer(fi.Room.ID)
// check if no cards left => game over
if fi.Room.BlueCounter == 0 {
@@ -134,13 +139,14 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = "blue"
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlue,
Action: models.ActionTypeGameOver,
}
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
updateStatsOnGameOver(r.Context(), fi.Room)
}
if fi.Room.RedCounter == 0 {
// red won
@@ -148,13 +154,14 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = "red"
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver,
}
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
updateStatsOnGameOver(r.Context(), fi.Room)
}
default: // same color as the team
// check if game over
@@ -163,13 +170,20 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = fi.State.Team
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver,
}
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
updateStatsOnGameOver(r.Context(), fi.Room)
}
}
if clearMarks {
fi.Room.ClearMarks()
if err := repo.CardMarksRemoveByRoomID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove marks", "error", err, "room_id", fi.Room.ID)
}
}
if err := saveFullInfo(r.Context(), fi); err != nil {
@@ -193,8 +207,9 @@ 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 {
@@ -239,7 +254,7 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
return
}
} else {
// TODO: if mark was found, it needs to be removed
// if mark was found, it needs to be removed
if err := repo.CardMarksRemove(r.Context(), card.ID, fi.State.Username); err != nil {
log.Error("failed to remove mark", "error", err, "card", card)
abortWithError(w, "failed to remove mark")
@@ -261,8 +276,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")
@@ -279,13 +295,21 @@ 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
}
botname := fmt.Sprintf("bot_%d", len(llmapi.SignalChanMap)+1) // what if many rooms?
var botname string
maxID, err := repo.PlayerGetMaxID(r.Context())
if err != nil {
log.Warn("failed to get players max id")
botname = fmt.Sprintf("bot_%d", len(llmapi.SignalChanMap)+1) // what if many rooms?
} 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())
@@ -299,8 +323,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 {
@@ -309,3 +334,19 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
}
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
}
func HandleGetRoom(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context())
if err != nil {
abortWithError(w, err.Error())
return
}
tmpl, err := template.ParseGlob("components/*.html")
if err != nil {
abortWithError(w, err.Error())
return
}
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
log.Error("failed to execute template", "error", err)
}
}

View File

@@ -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
@@ -173,6 +180,8 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
fi.Room.UpdateCounter()
fi.Room.TeamWon = ""
action := models.Action{
RoomID: fi.Room.ID,
CreatedAt: time.Now(),
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: string(fi.State.Team),
@@ -188,8 +197,6 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
// return
// }
// Save action history
action.RoomID = fi.Room.ID
action.CreatedAt = time.Now()
if err := repo.ActionCreate(ctx, &action); err != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
@@ -289,8 +296,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)
@@ -345,18 +353,20 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
fi.Room.OpenedThisTurn = 0
StartTurnTimer(fi.Room.ID, fi.Room.Settings.RoundTime)
log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit)
notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
notifyBotIfNeeded(fi.Room)
// notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, clue+num)
if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error())
return
}
notifyBotIfNeeded(fi.Room)
}
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)

View File

@@ -24,16 +24,11 @@ func init() {
Level: slog.LevelDebug,
AddSource: true,
}))
// memcache = cache.MemCache
cfg = config.LoadConfigOrDefault("")
Notifier = broker.Notifier
// cache.MemCache.StartBackupRoutine(15 * time.Second) // Reduced backup interval
// bot loader
// check the rooms if it has bot_{digits} in them, create bots if have
repo = repos.NewRepoProvider("sqlite3://../gralias.db")
// repo = repos.NewRepoProvider("sqlite3://../gralias.db")
repo = repos.RP
recoverBots()
// if player has a roomID, but no team and role, try to recover
// recoverPlayers()
}
func HandlePing(w http.ResponseWriter, r *http.Request) {
@@ -80,45 +75,48 @@ 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.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
}
exitedRoom := fi.ExitRoom()
// if err := saveRoom(exitedRoom); err != nil {
// abortWithError(w, err.Error())
// return
// }
if creatorLeft {
if err := repo.RoomDeleteByID(r.Context(), exitedRoom.ID); err != nil {
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
}
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)
}
// removeRoom(exitedRoom.ID)
// TODO: notify users if creator left
// and throw them away
notify(models.NotifyRoomListUpdate, "")
} else {
// 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
}
}
// scary to update the whole room
fiToSave := &models.FullInfo{
Room: exitedRoom,
}
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
abortWithError(w, err.Error())
return
}
if err := saveFullInfo(r.Context(), fiToSave); err != nil {
abortWithError(w, err.Error())
return
}
// fi.List = listRooms(false)
fi.Room = nil
fi.State.RoomID = nil
fi.List, err = repo.RoomList(r.Context())
if err != nil {
abortWithError(w, err.Error())
@@ -128,3 +126,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")
}
}

View File

@@ -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
View 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)
}
}
}

View File

@@ -3,69 +3,37 @@ package handlers
import (
"context"
"gralias/models"
"gralias/timer"
"log/slog"
"strconv"
"sync"
"time"
)
type roomTimer struct {
ticker *time.Ticker
done chan bool
}
var (
timers = make(map[string]*roomTimer)
mu sync.Mutex
)
func StartTurnTimer(roomID string, timeLeft uint32) {
mu.Lock()
defer mu.Unlock()
if _, exists := timers[roomID]; exists {
slog.Debug("trying to launch already running timer", "room_id", roomID)
return // Timer already running
}
ticker := time.NewTicker(1 * time.Second)
done := make(chan bool)
timers[roomID] = &roomTimer{ticker: ticker, done: done}
go func() {
for {
select {
case <-done:
return
case <-ticker.C:
if timeLeft <= 0 {
room, err := repo.RoomGetByID(context.Background(), roomID)
if err != nil {
log.Error("failed to get room by id", "error", err)
StopTurnTimer(roomID)
return
}
log.Info("turn time is over", "room_id", roomID)
room.ChangeTurn()
room.MimeDone = false
if err := repo.RoomUpdate(context.Background(), room); err != nil {
log.Error("failed to save room", "error", err)
}
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
notifyBotIfNeeded(room)
StopTurnTimer(roomID)
return
}
timeLeft--
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(timeLeft), 10))
}
logger := slog.Default().With("room_id", roomID)
onTurnEnd := func(ctx context.Context, roomID string) {
room, err := repo.RoomGetByID(context.Background(), roomID)
if err != nil {
logger.Error("failed to get room by id", "error", err)
return
}
}()
logger.Info("turn time is over")
room.ChangeTurn()
room.MimeDone = false
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))
notifyBotIfNeeded(room)
}
onTick := func(ctx context.Context, roomID string, currentLeft uint32) {
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10))
}
timer.StartTurnTimer(context.Background(), roomID, timeLeft, onTurnEnd, onTick, logger)
}
func StopTurnTimer(roomID string) {
mu.Lock()
defer mu.Unlock()
if timer, exists := timers[roomID]; exists {
timer.ticker.Stop()
close(timer.done)
delete(timers, roomID)
}
}
timer.StopTurnTimer(roomID)
}

View File

@@ -14,19 +14,21 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
)
var (
// botname -> channel
repo = repos.NewRepoProvider("sqlite3://../gralias.db")
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 left:\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;`
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that you can choose from:\n%v`
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.`
GuesserSimplePromptRU = `мы играем в alias;\n тебе дана подсказка (clue): \"%s\";\nпожалуйста, верни свою догадку (guess), а также слова, что тоже подходят к подсказке, но ты меньше в них уверен, в формате json; пример:\n{\n\"guess\": \"отгадка\",\n\"could_be\": [\"слово1\", \"слово2\", ...]\n}\nвот список слов из которых нужно выбрать:\n%v`
MimeSimplePromptRU = `мы играем в alias;\nтебе нужно дать подсказку одним словом и число слов, что ты подразумевал этой подсказкой; слова твоей комманды: %v;\nслова противоположной комманды, что ты хочешь избежать: %v;\nи вот ЧЕРНОЕ СЛОВО, открыв которое твоя комманда проиграет игру: %s;\nпожалуйста, верни подсказку (одним словом) и количество слов, что ты подразумеваешь в формате json; пример:\n{\n\"clue\": \"подсказка\",\n\"number\": \"число-от-0-до-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"слово1\", \"слово2\", ...]\n}\nпожалуйста верни только json.`
)
func convertToSliceOfStrings(value any) ([]string, error) {
@@ -50,23 +52,6 @@ func convertToSliceOfStrings(value any) ([]string, error) {
}
}
// nolint: unused
func (b *Bot) checkGuesses(tempMap map[string]any, room *models.Room) error {
guesses, err := convertToSliceOfStrings(tempMap["guesses"])
if err != nil {
b.log.Warn("failed to parse bot given guesses", "mimeResp", tempMap, "bot_name", b.BotName)
return err
}
for _, word := range guesses {
if err := b.checkGuess(word, room); err != nil {
// log error
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", word)
return err
}
}
return nil
}
func (b *Bot) checkGuess(word string, room *models.Room) error {
// color, exists := room.WCMap[word]
color, exists := room.FindColor(word)
@@ -77,8 +62,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
return fmt.Errorf("fn: checkGuess; %s does not exists", word)
}
room.RevealSpecificWord(word)
if err := repo.WordCardReveal(context.Background(), word, room.ID); err != nil {
b.log.Error("failed to reveal word in db", "word", word, "color",
color, "exists", exists, "limit", room.ThisTurnLimit,
"opened", room.OpenedThisTurn)
return err
}
room.UpdateCounter()
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: b.Team,
WordColor: string(color),
@@ -98,6 +90,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.MimeDone = false
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
b.StopTurnTimer()
}
switch string(color) {
case string(models.WordColorBlack):
@@ -108,18 +101,22 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: string(b.Team),
WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver,
}
room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
updateStatsOnGameOver(context.Background(), room)
case string(models.WordColorWhite), string(oppositeColor):
// end turn
room.TeamTurn = oppositeColor
room.MimeDone = false
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
b.StopTurnTimer()
}
// check if no cards left => game over
if room.BlueCounter == 0 {
@@ -130,12 +127,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: string(b.Team),
WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver,
}
room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
updateStatsOnGameOver(context.Background(), room)
}
if room.RedCounter == 0 {
// red won
@@ -145,12 +145,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: string(b.Team),
WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver,
}
room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
updateStatsOnGameOver(context.Background(), room)
}
ctx, tx, err := repo.InitTx(context.Background())
// nolint: errcheck
@@ -164,6 +167,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
b.log.Error("failed to create action", "error", err, "action", action)
return err
}
if err := repo.RoomUpdate(ctx, room); err != nil {
// nolint: errcheck
tx.Rollback()
@@ -184,13 +188,34 @@ func (b *Bot) BotMove() {
b.log.Error("bot loop", "error", err)
return
}
eventName := models.NotifyBacklogPrefix + room.ID
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 := ""
defer func() { // save room
// just incase, get the room once more
// room, err = repo.RoomGetExtended(context.Background(), b.RoomID)
// if err != nil {
// b.log.Error("bot loop", "error", err)
// return
// }
if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err)
return
}
if botName := room.WhichBotToMove(); botName != "" {
b.log.Debug("notifying bot", "name", botName)
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{
EventName: eventName,
Payload: eventPayload,
@@ -202,13 +227,30 @@ func (b *Bot) BotMove() {
// call llm
llmResp, err := b.CallLLM(prompt)
if err != nil {
room.LogJournal = append(room.LogJournal, b.BotName+" 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: 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, b.BotName+" parse resp got error: "+err.Error())
lj := models.Journal{
Entry: fmt.Sprintf("bot '%s' parsing resp failed;", b.BotName),
Username: b.BotName,
RoomID: b.RoomID,
}
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
b.log.Error("bot loop", "error", err, "resp", string(llmResp))
return
}
@@ -217,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 {
@@ -224,6 +282,7 @@ func (b *Bot) BotMove() {
return
}
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: b.Team,
WordColor: b.Team,
@@ -233,13 +292,22 @@ func (b *Bot) BotMove() {
}
room.ActionHistory = append(room.ActionHistory, action)
room.MimeDone = true
meant := fmt.Sprintf(b.BotName+" meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
room.LogJournal = append(room.LogJournal, meant)
// 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 {
b.log.Warn("failed to parse bot given limit", "mimeResp", mimeResp, "bot_name", b.BotName)
}
room.OpenedThisTurn = 0 // in case it is not
room.ThisTurnLimit = uint8(guessLimitU64)
if room.ThisTurnLimit == 0 {
b.log.Warn("turn limit is 0", "mimeResp", mimeResp)
@@ -249,6 +317,7 @@ func (b *Bot) BotMove() {
b.log.Error("failed to create action", "error", err)
return
}
b.StartTurnTimer(room.Settings.RoundTime)
if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err)
return
@@ -259,37 +328,56 @@ func (b *Bot) BotMove() {
b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName)
}
if err := b.checkGuess(guess, room); err != nil {
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
msg := fmt.Sprintf("failed to check guess; mimeResp: %v; bot_name: %s; guess: %s; error: %v", tempMap, b.BotName, guess, err)
room.LogJournal = append(room.LogJournal, msg)
b.log.Error("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
entry := fmt.Sprintf("failed to check guess; mimeResp: %v; guess: %s; error: %v", tempMap, guess, err)
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)
}
}
b.log.Info("guesser resp log", "guesserResp", tempMap)
couldBe, err := convertToSliceOfStrings(tempMap["could_be"])
if err != nil {
b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName)
}
room.LogJournal = append(room.LogJournal, fmt.Sprintf("%s also considered this: %v", b.BotName, couldBe))
eventName = models.NotifyRoomUpdatePrefix + room.ID
eventPayload = ""
// TODO: needs to decide if it wants to open the next cardword or end turn
// or end turn on limit
entry := fmt.Sprintf("%s guessed: %s; also considered this: %v", b.BotName, guess, couldBe)
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)
}
default:
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
return
}
if botName := room.WhichBotToMove(); botName != "" {
b.log.Debug("notifying bot", "name", botName)
SignalChanMap[botName] <- true
}
}
// 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
}
@@ -297,24 +385,51 @@ 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)
if err := repo.PlayerDelete(context.Background(), room.ID, botName); err != nil {
if err := repo.PlayerDelete(context.Background(), botName); err != nil {
slog.Error("failed to remove bot player", "name", botName, "room_id", room.ID)
return err
}
return saveRoom(room)
}
func RemoveBotNoRoom(botName string) error {
mapMutex.Lock()
// channels
dc, ok := DoneChanMap[botName]
if ok {
dc <- true
close(DoneChanMap[botName])
}
sc, ok := SignalChanMap[botName]
if ok {
close(sc)
}
// maps
delete(DoneChanMap, botName)
delete(SignalChanMap, botName)
mapMutex.Unlock()
// remove role from room
return repo.PlayerDelete(context.Background(), botName)
}
// EndBot
func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) {
@@ -387,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
}
@@ -434,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)
}
@@ -462,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 ""
@@ -507,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))
@@ -519,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
@@ -530,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
@@ -540,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
@@ -555,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")
}

70
llmapi/stats.go Normal file
View 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)
}
}
}

49
llmapi/timer.go Normal file
View File

@@ -0,0 +1,49 @@
package llmapi
import (
"context"
"gralias/broker"
"gralias/models"
"gralias/repos"
"gralias/timer"
"strconv"
)
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)
if err != nil {
logger.Error("failed to get room by id", "error", err)
return
}
logger.Info("turn time is over")
room.ChangeTurn()
room.MimeDone = false
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),
}
// notifyBotIfNeeded(room)
if botName := room.WhichBotToMove(); botName != "" {
SignalChanMap[botName] <- true
}
}
onTick := func(ctx context.Context, roomID string, currentLeft uint32) {
broker.Notifier.Notifier <- broker.NotificationEvent{
EventName: models.NotifyTurnTimerPrefix + roomID,
Payload: strconv.FormatUint(uint64(currentLeft), 10),
}
}
timer.StartTurnTimer(context.Background(), b.RoomID, timeLeft, onTurnEnd, onTick, logger)
}
func (b *Bot) StopTurnTimer() {
timer.StopTurnTimer(b.RoomID)
}

75
main.go
View File

@@ -6,10 +6,14 @@ import (
"gralias/crons"
"gralias/handlers"
"gralias/repos"
"gralias/telemetry"
"log/slog"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
)
@@ -20,20 +24,58 @@ func init() {
cfg = config.LoadConfigOrDefault("")
}
// GzipFileServer serves pre-compressed .gz files if available
func GzipFileServer(root http.FileSystem) http.Handler {
fs := http.FileServer(root)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if client accepts gzip
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
// Check for .gz version of the file
gzPath := r.URL.Path + ".gz"
if file, err := root.Open(gzPath); err == nil {
file.Close()
// Set headers for gzip
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Type", getContentType(r.URL.Path))
r.URL.Path = gzPath
}
}
fs.ServeHTTP(w, r)
})
}
// Helper to set correct Content-Type
func getContentType(path string) string {
switch filepath.Ext(path) {
case ".css":
return "text/css"
case ".js":
return "application/javascript"
default:
return "" // http.FileServer will detect it
}
}
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
}
fs := http.FileServer(http.Dir("assets/"))
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs))
// fs := http.FileServer(http.Dir("assets/"))
fs := http.Dir("assets/")
mux.Handle("GET /assets/", http.StripPrefix("/assets/", GzipFileServer(fs)))
//
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)
@@ -50,6 +92,7 @@ func ListenToRequests(port string) *http.Server {
mux.HandleFunc("GET /add-bot", handlers.HandleAddBot)
mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot)
mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard)
mux.HandleFunc("GET /room", handlers.HandleGetRoom)
// special
mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot)
// sse
@@ -59,24 +102,44 @@ 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)
repo := repos.NewRepoProvider(cfg.DBPath)
// repo := repos.NewRepoProvider(cfg.DBPath)
repo := repos.RP
defer repo.Close()
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)
}
}

View File

@@ -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 ''
);
@@ -21,6 +22,7 @@ CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT, -- nullable
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime'
is_bot BOOLEAN NOT NULL DEFAULT FALSE,
@@ -76,3 +78,30 @@ CREATE TABLE sessions(
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
);

View File

@@ -0,0 +1,3 @@
DROP TRIGGER IF EXISTS update_player_rating;
ALTER TABLE DROP COLUMN rating;

View 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;

View File

@@ -106,6 +106,7 @@ type Player struct {
ID uint32 `json:"id" db:"id"`
RoomID *string `json:"room_id" db:"room_id"`
Username string `json:"username" db:"username"`
Password string `json:"-" db:"password"`
Team UserTeam `json:"team" db:"team"`
Role UserRole `json:"role" db:"role"`
IsBot bool `json:"is_bot" db:"is_bot"`
@@ -130,6 +131,30 @@ type CardMark struct {
Username string `db:"username"`
}
type Journal struct {
ID uint32 `db:"id"`
Username string `db:"username"`
RoomID string `db:"room_id"`
Entry string `db:"entry"`
CreatedAt time.Time `db:"created_at"`
}
type PlayerStats struct {
ID uint32 `db:"id"`
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 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 {
ID string `json:"id" db:"id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
@@ -151,8 +176,10 @@ type Room struct {
BlueTeam Team `db:"-"`
Cards []WordCard `db:"-"`
BotMap map[string]BotPlayer `db:"-"`
LogJournal []string `db:"-"`
LogJournal []Journal `db:"-"`
Settings GameSettings `db:"-"`
//
BotFailed bool `db:"bot_failed"`
}
func (r *Room) FindColor(word string) (WordColor, bool) {
@@ -165,7 +192,7 @@ func (r *Room) FindColor(word string) (WordColor, bool) {
}
func (r *Room) ClearMarks() {
for i, _ := range r.Cards {
for i := range r.Cards {
r.Cards[i].Marks = []CardMark{}
}
}
@@ -286,7 +313,7 @@ func getGuesser(m map[string]BotPlayer, team UserTeam) string {
func (r *Room) WhichBotToMove() string {
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning,
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime)
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime, "card-limit:", r.ThisTurnLimit, "opened:", r.OpenedThisTurn)
if !r.IsRunning {
return ""
}
@@ -441,7 +468,6 @@ type FullInfo struct {
}
func (f *FullInfo) ExitRoom() *Room {
// f.Room.PlayerList = utils.RemoveFromSlice(f.State.Username, f.Room.PlayerList)
f.Room.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers)
f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers)
if f.Room.RedTeam.Mime == f.State.Username {
@@ -450,8 +476,10 @@ func (f *FullInfo) ExitRoom() *Room {
if f.Room.BlueTeam.Mime == f.State.Username {
f.Room.BlueTeam.Mime = ""
}
// f.State.ExitRoom()
f.State.RoomID = nil
resp := f.Room
f.Room = nil
return resp
}
// =======

View File

@@ -12,6 +12,7 @@ type CardMarksRepo interface {
CardMarksAdd(ctx context.Context, cm *models.CardMark) error
CardMarksRemove(ctx context.Context, cardID uint32, username string) error
CardMarksByRoomID(ctx context.Context, roomID string) ([]models.CardMark, error)
CardMarksRemoveByRoomID(ctx context.Context, roomID string) error
}
func (r *RepoProvider) CardMarksByCardID(ctx context.Context, cardID uint32) ([]models.CardMark, error) {
@@ -36,3 +37,8 @@ func (r *RepoProvider) CardMarksByRoomID(ctx context.Context, roomID string) ([]
err := sqlx.SelectContext(ctx, getDB(ctx, r.DB), &cardMarks, "SELECT * FROM card_marks WHERE card_id IN (select id from word_cards where room_id = ?)", roomID)
return cardMarks, err
}
func (r *RepoProvider) CardMarksRemoveByRoomID(ctx context.Context, roomID string) error {
db := getDB(ctx, r.DB)
_, err := db.ExecContext(ctx, "DELETE FROM card_marks WHERE card_id IN (select id from word_cards where room_id = ?);", roomID)
return err
}

35
repos/journal.go Normal file
View File

@@ -0,0 +1,35 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
type JournalRepo interface {
JournalByRoomID(ctx context.Context, roomID string) ([]models.Journal, error)
JournalCreate(ctx context.Context, j *models.Journal) error
JournalDeleteByRoomID(ctx context.Context, roomID string) error
}
func (p *RepoProvider) JournalByRoomID(ctx context.Context, roomID string) ([]models.Journal, error) {
journals := []models.Journal{}
err := sqlx.SelectContext(ctx, p.DB, &journals, `SELECT id, created_at, entry, username, room_id FROM journal WHERE room_id = ? ORDER BY created_at ASC`, roomID)
if err != nil {
return nil, err
}
return journals, nil
}
func (p *RepoProvider) JournalCreate(ctx context.Context, j *models.Journal) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `INSERT INTO journal (entry, username, room_id) VALUES (?, ?, ?)`, j.Entry, j.Username, j.RoomID)
return err
}
func (p *RepoProvider) JournalDeleteByRoomID(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM journal WHERE room_id = ?`, roomID)
return err
}

View File

@@ -2,6 +2,7 @@ package repos
import (
"context"
"gralias/config"
"log/slog"
"os"
"sync"
@@ -19,7 +20,10 @@ type AllRepos interface {
WordCardsRepo
SettingsRepo
CardMarksRepo
PlayerStatsRepo
JournalRepo
InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error)
Close()
}
type RepoProvider struct {
@@ -28,25 +32,39 @@ type RepoProvider struct {
pathToDB string
}
func NewRepoProvider(pathToDB string) *RepoProvider {
var RP AllRepos
func init() {
cfg := config.LoadConfigOrDefault("")
// sqlite3 has lock on write, so we need to have only one connection per whole app
// https://github.com/mattn/go-sqlite3/issues/274#issuecomment-232942571
RP = NewRepoProvider(cfg.DBPath)
}
func NewRepoProvider(pathToDB string) AllRepos {
db, err := sqlx.Connect("sqlite3", pathToDB)
if err != nil {
slog.Error("Unable to connect to database", "error", err)
slog.Error("Unable to connect to database", "error", err, "pathToDB", pathToDB)
os.Exit(1)
}
_, err = db.Exec("PRAGMA foreign_keys = ON;")
if err != nil {
slog.Error("Unable to enable foreign keys", "error", err)
os.Exit(1)
stmts := []string{
"PRAGMA foreign_keys = ON;",
"PRAGMA busy_timeout=200;",
}
for _, stmt := range stmts {
_, err = db.Exec(stmt)
if err != nil {
slog.Error("Unable to enable foreign keys", "error", err)
os.Exit(1)
}
}
slog.Info("Successfully connected to database")
// db.SetMaxOpenConns(2)
rp := &RepoProvider{
DB: db,
pathToDB: pathToDB,
}
go rp.pingLoop()
return rp
}

View File

@@ -1,24 +0,0 @@
package repos
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewRepoProvider(t *testing.T) {
// Create a temporary SQLite database file for testing
tmpDBFile := "./test_gralias.db"
defer os.Remove(tmpDBFile) // Clean up the temporary file after the test
// Initialize a new RepoProvider
repoProvider := NewRepoProvider(tmpDBFile)
// Assert that the DB connection is not nil
assert.NotNil(t, repoProvider.DB, "DB connection should not be nil")
// Close the database connection
err := repoProvider.DB.Close()
assert.NoError(t, err, "Error closing database connection")
}

50
repos/player_stats.go Normal file
View File

@@ -0,0 +1,50 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
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 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
}
func (p *RepoProvider) UpdatePlayerStats(ctx context.Context, stats *models.PlayerStats) error {
_, err := p.DB.NamedExecContext(ctx, `UPDATE player_stats SET
games_played = :games_played,
games_won = :games_won,
games_lost = :games_lost,
opened_opposite_words = :opened_opposite_words,
opened_white_words = :opened_white_words,
opened_black_words = :opened_black_words,
mime_winrate = :mime_winrate,
guesser_winrate = :guesser_winrate,
played_as_mime = :played_as_mime,
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 (username) VALUES (?)", username)
return err
}

View File

@@ -12,12 +12,14 @@ type PlayersRepo interface {
PlayerGetByName(ctx context.Context, username string) (*models.Player, error)
PlayerAdd(ctx context.Context, player *models.Player) error
PlayerUpdate(ctx context.Context, player *models.Player) error
PlayerDelete(ctx context.Context, roomID, username string) error
PlayerDelete(ctx context.Context, username string) error
PlayerSetRoomID(ctx context.Context, roomID, username string) error
PlayerExitRoom(ctx context.Context, username string) error
PlayerListNames(ctx context.Context) ([]string, error)
PlayerList(ctx context.Context, isBot bool) ([]models.Player, error)
PlayerListAll(ctx context.Context) ([]models.Player, error)
PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error)
PlayerGetMaxID(ctx context.Context) (uint32, error)
}
func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
@@ -31,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 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
}
@@ -43,9 +45,15 @@ func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*m
func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)",
player.RoomID, player.Username, player.Team, player.Role, player.IsBot)
return err
_, 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 {
@@ -55,9 +63,9 @@ func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player)
return err
}
func (p *RepoProvider) PlayerDelete(ctx context.Context, roomID, username string) error {
func (p *RepoProvider) PlayerDelete(ctx context.Context, username string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "DELETE FROM players WHERE room_id = ? AND username = ?", roomID, username)
_, err := db.ExecContext(ctx, "DELETE FROM players WHERE username = ?", username)
return err
}
@@ -95,6 +103,25 @@ func (p *RepoProvider) PlayerList(ctx context.Context, isBot bool) ([]models.Pla
return players, nil
}
func (p *RepoProvider) PlayerGetMaxID(ctx context.Context) (uint32, error) {
var maxID uint32
err := sqlx.GetContext(ctx, p.DB, &maxID, "SELECT COALESCE(MAX(id), 0) FROM players")
if err != nil {
return 0, err
}
return maxID, nil
}
func (p *RepoProvider) PlayerListAll(ctx context.Context) ([]models.Player, error) {
var players []models.Player
query := "SELECT id, room_id, username, team, role, is_bot FROM players;"
err := sqlx.SelectContext(ctx, p.DB, &players, query)
if err != nil {
return nil, err
}
return players, nil
}
func (p *RepoProvider) PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error) {
var players []models.Player
err := sqlx.SelectContext(ctx, p.DB, &players, "SELECT id, room_id, username, team, role, is_bot FROM players WHERE room_id = ?", roomID)

View File

@@ -6,25 +6,147 @@ import (
"testing"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
)
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT,
username TEXT,
team TEXT,
role TEXT,
is_bot BOOLEAN
);
`
_, err = db.Exec(schema)
// 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, -- nullable
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
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() {
@@ -32,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()
@@ -98,11 +253,11 @@ func TestPlayersRepo_DeletePlayer(t *testing.T) {
_, err := db.Exec(`INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)`, player.RoomID, player.Username, player.Team, player.Role, player.IsBot)
assert.NoError(t, err)
err = repo.PlayerDelete(context.Background(), *player.RoomID, player.Username)
err = repo.PlayerDelete(context.Background(), player.Username)
assert.NoError(t, err)
var count int
err = db.Get(&count, "SELECT COUNT(*) FROM players WHERE room_id = ? AND username = ?", player.RoomID, player.Username)
assert.NoError(t, err)
assert.Equal(t, 0, count)
}
}

View File

@@ -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) {
@@ -131,5 +145,12 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
return nil, err
}
room.Settings = *settings
// get log jounral
journals := []models.Journal{}
err = sqlx.SelectContext(ctx, p.DB, &journals, `SELECT id, created_at, entry, username, room_id FROM journal WHERE room_id = ? ORDER BY created_at ASC`, room.ID)
if err != nil {
return nil, err
}
room.LogJournal = journals
return room, nil
}

63
telemetry/telemetry.go Normal file
View 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)
}
}
}

83
timer/timer.go Normal file
View File

@@ -0,0 +1,83 @@
package timer
import (
"context"
"log/slog"
"sync"
"time"
)
// TurnEndCallback defines the function signature for actions to be performed when a turn ends.
type TurnEndCallback func(ctx context.Context, roomID string)
// TickCallback defines the function signature for actions to be performed on each timer tick.
type TickCallback func(ctx context.Context, roomID string, timeLeft uint32)
type RoomTimer struct {
ticker *time.Ticker
done chan bool
roomID string
onTurnEnd TurnEndCallback
onTick TickCallback
log *slog.Logger
}
var (
timers = make(map[string]*RoomTimer)
mu sync.Mutex
)
// 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) {
mu.Lock()
defer mu.Unlock()
if _, exists := timers[roomID]; exists {
logger.Debug("trying to launch already running timer", "room_id", roomID)
return // Timer already running
}
ticker := time.NewTicker(1 * time.Second)
done := make(chan bool)
rt := &RoomTimer{
ticker: ticker,
done: done,
roomID: roomID,
onTurnEnd: onTurnEnd,
onTick: onTick,
log: logger,
}
timers[roomID] = rt
go func() {
currentLeft := timeLeft
for {
select {
case <-done:
return
case <-ticker.C:
if currentLeft <= 0 {
rt.onTurnEnd(ctx, roomID)
StopTurnTimer(roomID)
return
}
rt.onTick(ctx, roomID, currentLeft)
currentLeft--
}
}
}()
}
// StopTurnTimer stops the timer for a given room.
func StopTurnTimer(roomID string) {
mu.Lock()
defer mu.Unlock()
if rt, exists := timers[roomID]; exists {
rt.ticker.Stop()
close(rt.done)
delete(timers, roomID)
rt.log.Info("timer stopped", "room_id", roomID)
}
}

View File

@@ -21,6 +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); +
===
- 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;
@@ -28,9 +30,12 @@
- ended turn action to backlog;
===
- clear indication that model (llm) is thinking / answered;
- 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;
- 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. +
- at the end of the game, all colors should be revealed;
- tracing;
#### sse points
- clue sse update;
@@ -61,16 +66,30 @@
- there is a clue window for a mime before game started; +
- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine) (did not reoccur so far);
- invite link gets cutoff;
- when llm guesses the word it is not removed from a pool of words making it keep guessing it;
- bot team does not loses their turn after white card (or limit);
- when llm guesses the word it is not removed from a pool of words making it keep guessing it; +
- bot team does not loses their turn after white card (or limit); +
- name check does not work;
- game did not end when all blue cards were open;
- bot ends a turn after guessing one word only;
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue;
- game did not end when all blue cards were open; +
- bot ends a turn after guessing one word only; +
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue; +
-----------------
- card marks; +
- on server recover relaunch guess timer if needed;
- start new game: clear last clue; mimedone to false; unload old cards;
- start new game: clear last clue; mimedone to false; unload old cards; +
- backlog shows white word with opposite color;
- bot actions are not recorder;
- bot recieves opp-color clue because of it ^;
- bot actions are not recorded; +
- bot recieves opp-color clue because of it ^; +
- old cards are still around; +
- bot mime makes a clue -> no update in the room for players; +
- red moves after bot gave (metal - 3) ended after two guesses (showed in logs, that opened 3. double click on the same card? or somewhere counter is not dropped?); the first guess moved counter to 2, in logs only two requests as expected; +
- bot mime gve a blue -> timer did not start; timer should be in third package, maybe in crons; +
- marks did not clear after end of the turn (feature?) +
- start new game satrted timer for a mime; (feature? in other cases mime has no timer);
- timer ended and went to 300;
- 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; +