Compare commits

..

90 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
Grail Finder
5b24378956 Fix: linter complains 2025-07-05 13:33:34 +03:00
Grail Finder
913228844a Feat: remove rooms with no action 2025-07-05 13:06:02 +03:00
Grail Finder
de2cccf66d Fix: save bot actions 2025-07-05 11:30:58 +03:00
Grail Finder
eef4b7941b Enha: remove marks 2025-07-05 10:16:17 +03:00
Grail Finder
413edae4b6 Feat: card_mark repo 2025-07-05 09:14:45 +03:00
Grail Finder
56845e6141 Fix: show-color 2025-07-04 21:56:46 +03:00
Grail Finder
3e9a93fbb1 Chore: remove unused WCMap 2025-07-04 21:48:01 +03:00
Grail Finder
3af3657c7a Fix: timer 2025-07-04 21:35:59 +03:00
Grail Finder
0e2baa1a0f Fix: sql 2025-07-04 21:23:14 +03:00
Grail Finder
a4dc8f4bbb Chore: remove seconds tracking inside of settings 2025-07-04 16:58:23 +03:00
Grail Finder
2a2bf4e23d Chore: actions methods rename 2025-07-04 14:30:13 +03:00
Grail Finder
705881f1ea Enha: settings with room 2025-07-04 14:20:25 +03:00
Grail Finder
6be365473c Feat: settings repo 2025-07-04 13:32:59 +03:00
Grail Finder
058d501774 Fix: save action on give-clue 2025-07-04 12:44:15 +03:00
Grail Finder
c2d6812230 Enha: tx for cron 2025-07-04 12:25:20 +03:00
Grail Finder
71a2d9d747 Feat: add cleaner cron 2025-07-04 10:34:08 +03:00
Grail Finder
83215f5c14 Fix: test; limit changes on player update 2025-07-04 10:02:39 +03:00
Grail Finder
6ca8afd13d Chore: remove unused 2025-07-04 07:04:44 +03:00
Grail Finder
8b81e2e2c4 Enha: create tx; cardword test 2025-07-04 07:00:16 +03:00
Grail Finder
c9196d3202 Fix: timer templ 2025-07-03 15:21:54 +03:00
Grail Finder
788c4efd9e Feat: word card repo 2025-07-03 15:00:06 +03:00
Grail Finder
66d7a633c8 Fix: recover bot 2025-07-03 14:42:56 +03:00
Grail Finder
9e058b04e0 Refactor: remove pkg mem cache 2025-07-03 14:26:52 +03:00
73 changed files with 3638 additions and 1035 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ gralias
store.json store.json
config.toml config.toml
gralias.db 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 .PHONY: all init deps install test lint run stop
run: run: migrate-up
go build go build
./gralias start ./gralias start
@@ -32,7 +32,8 @@ stop-container:
docker rm -f gralias 2>/dev/null && echo "old container removed" docker rm -f gralias 2>/dev/null && echo "old container removed"
run-container: stop-container 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-up:
migrate -database 'sqlite3://gralias.db' -path migrations 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{ body{
background-color: #0C1616FF; background-color: #0C1616FF;
color: #8896b2; color: #8896b2;
max-width: 800px;
min-width: 0px; min-width: 0px;
margin: 2em auto !important; margin: 2em auto !important;
margin-left: auto; margin-left: auto;
@@ -12,12 +11,6 @@ body{
text-align: center; text-align: center;
display: block; display: block;
} }
a{
color: #00a2e7;
}
a:visited{
color: #ca1a70;
}
table{ table{
border-collapse: separate !important; border-collapse: separate !important;
border-spacing: 10px 10px; 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

@@ -1636,7 +1636,6 @@ increase
computer computer
bobby bobby
accused accused
500
nonsense nonsense
close close
finger finger
@@ -2726,7 +2725,6 @@ attendance
present present
find find
lead lead
wtv
champion champion
gasoline gasoline
national national
@@ -2746,7 +2744,6 @@ excitement
quote quote
forehead forehead
wax wax
mckinley
television television
can can
voyage voyage
@@ -2835,7 +2832,6 @@ least
boot boot
alien alien
employer employer
viscosity
theft theft
wall wall
vapor vapor
@@ -2848,7 +2844,6 @@ sovereign
smoke smoke
fool fool
intelligence intelligence
indictment
flame flame
advance advance
mud mud
@@ -2981,7 +2976,6 @@ agent
motel motel
punishment punishment
lime lime
magnification
snap snap
surgeon surgeon
short short
@@ -3150,7 +3144,6 @@ cope
law law
lap lap
recommendation recommendation
patrolman
purple purple
imagery imagery
offer offer

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 sba
учебная программа
рынок рынок
пуля пуля
устная устная
@@ -1453,7 +1434,6 @@ sba
ненависть ненависть
самовывоз самовывоз
скидка скидка
т estament
администратор администратор
бить бить
наклон наклон
@@ -1493,7 +1473,6 @@ sba
трек трек
архитектура архитектура
ракета ракета
сообщение унификация
вероятный вероятный
преемственность преемственность
токен токен
@@ -1510,7 +1489,6 @@ sba
антиквариат антиквариат
рукав рукав
обследование обследование
дикая местность
остановка остановка
касание касание
ассоциация ассоциация
@@ -1534,7 +1512,6 @@ sba
повышение повышение
раковина раковина
стоимость стоимость
дисков ery
fly fly
warren warren
overhead overhead
@@ -1574,7 +1551,6 @@ paper
over over
complement complement
nursery nursery
arrange ment
консерватизм консерватизм
индивидуальный индивидуальный
грант грант
@@ -1610,7 +1586,6 @@ arrange ment
звезда звезда
улучшение улучшение
объект объект
постоянный nt
pat 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 gin
hogan hogan
еда еда
@@ -2129,7 +2084,6 @@ hogan
диаметр диаметр
сестра сестра
производитель производитель
точка зрения
кальций кальций
винт винт
шахта шахта
@@ -2147,11 +2101,9 @@ hogan
истинный истинный
пена пена
теология теология
работа по делу
польский польский
армия армия
отклонить отклонить
петух ail
low 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 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 package broker
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"time" "time"
) )
// the amount of time to wait when pushing a message to // the amount of time to wait when pushing a message to
// a slow client or a client that closed after `range clients` started. // a slow client or a client that closed after `range clients` started.
const patience time.Duration = time.Second * 1 // const patience time.Duration = time.Second * 1
type ( type (
NotificationEvent struct { NotificationEvent struct {
@@ -20,11 +22,8 @@ type (
Broker struct { Broker struct {
// Events are pushed to this channel by the main events-gathering routine // Events are pushed to this channel by the main events-gathering routine
Notifier NotifierChan Notifier NotifierChan
// New client connections log *slog.Logger
newClients chan NotifierChan addClient chan NotifierChan
// Closed client connections
closingClients chan NotifierChan
// Client connections registry
clients map[NotifierChan]struct{} clients map[NotifierChan]struct{}
} }
) )
@@ -32,10 +31,13 @@ type (
func NewBroker() (broker *Broker) { func NewBroker() (broker *Broker) {
// Instantiate a broker // Instantiate a broker
return &Broker{ return &Broker{
Notifier: make(NotifierChan, 1), Notifier: make(NotifierChan, 100),
newClients: make(chan NotifierChan), log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
closingClients: make(chan NotifierChan), Level: slog.LevelDebug,
clients: make(map[NotifierChan]struct{}), AddSource: true,
})),
addClient: make(chan NotifierChan, 10),
clients: map[NotifierChan]struct{}{},
} }
} }
@@ -44,7 +46,6 @@ var Notifier *Broker
// for use in different packages // for use in different packages
func init() { func init() {
Notifier = NewBroker() Notifier = NewBroker()
go Notifier.Listen()
} }
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -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("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*") // w.Header().Set("Access-Control-Allow-Origin", "*")
messageChan := make(NotifierChan) origin := r.Header.Get("Origin")
broker.newClients <- messageChan if origin == "" {
defer func() { broker.closingClients <- messageChan }() 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() 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 { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
broker.log.Debug("broker: got ctx done")
// Client disconnected // Client disconnected
return return
case event := <-messageChan: case event := <-broker.Notifier:
broker.log.Debug("got event", "event", event)
for i := 0; i < 10; i++ { // Repeat 3 times
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload) _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
if err != nil { if err != nil {
fmt.Println(err) broker.log.Error("write failed", "error", err)
// Client disconnected
return return
} }
w.(http.Flusher).Flush() 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(ctx context.Context) {
func (broker *Broker) Listen() {
for { for {
select { select {
case s := <-broker.newClients: case <-ctx.Done():
// A new client has connected. return
// Register their message channel case clientChan := <-broker.addClient:
broker.clients[s] = struct{}{} // mutex
slog.Info("Client added", "clients listening", len(broker.clients)) broker.clients[clientChan] = struct{}{}
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))
}
}
} }
} }
} }

View File

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

View File

@@ -3,47 +3,25 @@
<html lang="en"> <html lang="en">
<head> <head>
<title>Alias</title> <title>Alias</title>
<script src="/assets/helpers.js"></script>
<script src="/assets/htmx.min.js"></script>
<script src="/assets/htmx.sse.js"></script>
<script src="/assets/tailwind.css"></script> <script src="/assets/tailwind.css"></script>
<link rel="stylesheet" href="/assets/style.css"/> <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"/> <meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/> <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> </head>
<body> <body>
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse"> <div id="ancestor" hx-ext="sse" sse-connect="/sub/sse">
<script type="text/javascript">
document.body.addEventListener('htmx:sseError', function (e) {
// do something before the event data is swapped in
console.log(e)
})
</script>
<div id="main-content">
{{template "main" .}} {{template "main" .}}
</div>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -28,9 +28,12 @@
</div> </div>
<div class="h-6 bg-stone-600 rounded-b flex items-center justify-center text-white text-sm cursor-pointer" <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"> hx-get="/mark-card?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
{{range .Mark}} {{range .Marks}}
{{if .Active}} {{ $length := len .Username }}
<span class="mx-0.5">X</span> {{ if lt $length 3 }}
<span class="mx-0.5">{{.Username}}</span>
{{else}}
<span class="mx-0.5">{{slice .Username 0 3}}</span>
{{end}} {{end}}
{{end}} {{end}}
</div> </div>

View File

@@ -4,12 +4,17 @@
Create a room <br/> Create a room <br/>
or<br/> or<br/>
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button> <button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button>
<form hx-post="/room-create" hx-target="#ancestor"> <form hx-post="/room-create" hx-target="#main-content">
<label For="game_time">Turn Seconds:</label><br/> <label For="game_time">Turn Seconds:</label><br/>
<input type="number" id="game_time" name="game_time" class="text-center text-white" value="300"/><br/> <input type="number" id="game_time" name="game_time" class="text-center text-white" value="300"/><br/>
<label For="language">Language:</label><br/> <label For="language">Language:</label><br/>
<input type="text" id="language" name="language" class="text-center text-white" value="en"/><br/> <div>
<label For="password">Password:</label><br/> <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/> <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> </form>

View File

@@ -4,9 +4,16 @@
{{ else if ne .LinkLogin "" }} {{ else if ne .LinkLogin "" }}
{{template "linklogin" .LinkLogin}} {{template "linklogin" .LinkLogin}}
{{ else if not .State.RoomID }} {{ else if not .State.RoomID }}
<div id="hello-user"> <div id="hello-user" class="grid grid-cols-3 items-center text-xl py-2">
<p>data: {{.}} {{.State}} {{.Room}}</p> <div class="text-left">
<p>Hello {{.State.Username}}</p> <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>
<div id="create-room" class="create-room-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> <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}} {{template "roomlist" .List}}
</div> </div>
{{else}} {{else}}
<div id="sse-listener" sse-connect="/sub/sse" hx-trigger="sse:roomupdate_{{.State.RoomID}}" hx-get="/room" hx-target="#room-interier" hx-swap="none" style="display:none;"></div>
<div id="room"> <div id="room">
{{template "room" .}} {{template "room" .}}
</div> </div>

View File

@@ -1,13 +1,17 @@
{{define "linklogin"}} {{define "linklogin"}}
<div id="logindiv"> <div id="logindiv">
You're about to join room#{{.}}; but first! 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> <label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label>
<div class="mt-2"> <div class="mt-2">
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/> <input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="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={{.}}> <input type="hidden" name="room_id" value={{.}}>
</div> </div>
<div id="login_notice">this name looks available</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> <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> </div>

View File

@@ -1,12 +1,16 @@
{{define "login"}} {{define "login"}}
<div id="logindiv"> <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> <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"> <div class="mt-2">
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/> <input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div> </div>
<div id="login_notice">this name looks available</div> <div id="login_notice">this name looks available</div>
</div>
<div>
<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>
<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>

View File

@@ -5,13 +5,13 @@
<a href="/"> <a href="/">
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert"> <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 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> </div>
</a> </a>
{{ else }} {{ else }}
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert"> <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 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> </div>
{{end}} {{end}}
{{end}} {{end}}

View File

@@ -1,6 +1,7 @@
{{define "room"}} {{define "room"}}
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}" class=space-y-2> <div id="room-interier" class=space-y-2>
<div id="meta"> <div id="headwrapper" class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
<div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2">
<p>Hello {{.State.Username}};</p> <p>Hello {{.State.Username}};</p>
<p>Room created by {{.Room.CreatorName}};</p> <p>Room created by {{.Room.CreatorName}};</p>
<p>Room link:</p> <p>Room link:</p>
@@ -16,16 +17,16 @@
{{end}} {{end}}
<p> <p>
{{if eq .State.Team ""}} {{if eq .State.Team ""}}
join the team! you don't have a role! join the team ->
{{else}} {{else}}
you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>! you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>!
{{end}} {{end}}
</p> </p>
</div> </div>
<hr /> <div id="infopatch" class="md:col-span-3">
{{if .Room.IsRunning}} {{if .Room.IsRunning}}
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p> <p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p>
{{template "turntimer" .Room.Settings}} {{template "turntimer" .Room}}
{{if .Room.MimeDone}} {{if .Room.MimeDone}}
<p class="text-{{.Room.TeamTurn}}-500 text-xl">Waiting for guessers</p> <p class="text-{{.Room.TeamTurn}}-500 text-xl">Waiting for guessers</p>
<p class="text-{{.Room.TeamTurn}}-500 text-xl">Given Clue: "{{.Room.FetchLastClueWord}}"</p> <p class="text-{{.Room.TeamTurn}}-500 text-xl">Given Clue: "{{.Room.FetchLastClueWord}}"</p>
@@ -48,21 +49,26 @@
<!-- Right Panel --> <!-- Right Panel -->
{{template "teamlist" .Room.RedTeam}} {{template "teamlist" .Room.RedTeam}}
</div> </div>
<hr /> </div>
<div id="systembox" style="overflow-y: auto; max-height: 100px;"> </div>
Server says: <br> <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> <ul>
{{range .Room.LogJournal}} {{range .Room.LogJournal}}
<li>{{.}}</li> <li>{{.Username}}: {{.Entry}}</li>
{{end}} {{end}}
</ul> </ul>
</div> </div>
<div sse-swap="journal_{{.Room.ID}}">
bot thoughts
<div>
<div id="cardtable">
{{template "cardtable" .Room}}
</div>
<div> <div>
{{if .Room.IsRunning}} {{if .Room.IsRunning}}
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}} {{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}}
@@ -73,16 +79,13 @@
{{end}} {{end}}
</div> </div>
<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> <button hx-get="/renotify-bot" hx-swap="none" class="bg-gray-100 text-black px-1 py-1 rounded">Btn in case llm call failed</button>
{{end}} {{end}}
</div> </div>
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
{{template "actionhistory" .Room.ActionHistory}}
</div>
{{if not .Room.IsRunning}} {{if not .Room.IsRunning}}
<div id="exitbtn"> <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> </div>
{{end}} {{end}}
</div> </div>

View File

@@ -1,10 +1,7 @@
{{define "roomlist"}} {{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 .}} {{range .}}
<p> <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">
{{.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 class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="room-info"> <div class="room-info">
<div class="text-sm text-gray-500"> <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 class="space-y-4">
<div> <div>
<h2 class="text-xl mb-4">Join Blue Team</h2> <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"> <input type="hidden" name="team" value="blue">
<div class="mb-1"> <div class="mb-1">
{{if and (eq .State.Role "guesser") (eq .State.Team "blue")}} {{if and (eq .State.Role "guesser") (eq .State.Team "blue")}}
@@ -23,7 +23,7 @@
</div> </div>
<div> <div>
<h2 class="text-xl mb-4">Join Red Team</h2> <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"> <input type="hidden" name="team" value="red">
<div class="mb-1"> <div class="mb-1">
{{if and (eq .State.Role "guesser") (eq .State.Team "red")}} {{if and (eq .State.Role "guesser") (eq .State.Team "red")}}

View File

@@ -1,5 +1,5 @@
{{define "turntimer"}} {{define "turntimer"}}
<div> <div>
Timer: <span sse-swap="turntimer_{{.ID}}">{{.Settings.TurnSecondsLeft}}</span> Timer: <span sse-swap="turntimer_{{.ID}}">no time to lose</span>
</div> </div>
{{end}} {{end}}

View File

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

View File

@@ -13,6 +13,7 @@ type Config struct {
SessionLifetime int64 `toml:"SESSION_LIFETIME_SECONDS"` SessionLifetime int64 `toml:"SESSION_LIFETIME_SECONDS"`
CookieSecret string `toml:"COOKIE_SECRET"` CookieSecret string `toml:"COOKIE_SECRET"`
LLMConfig LLMConfig `toml:"LLM"` LLMConfig LLMConfig `toml:"LLM"`
DBPath string `toml:"DB_PATH"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -38,6 +39,7 @@ func LoadConfigOrDefault(fn string) *Config {
config.CookieSecret = "test" config.CookieSecret = "test"
config.ServerConfig.Host = "localhost" config.ServerConfig.Host = "localhost"
config.ServerConfig.Port = "3000" config.ServerConfig.Port = "3000"
config.DBPath = "gralias.db"
} }
fmt.Printf("config debug; config.LLMConfig.URL: %s\n", config.LLMConfig.URL) fmt.Printf("config debug; config.LLMConfig.URL: %s\n", config.LLMConfig.URL)
return config return config

165
crons/main.go Normal file
View File

@@ -0,0 +1,165 @@
package crons
import (
"context"
"database/sql"
"errors"
"gralias/broker"
"gralias/models"
"gralias/repos"
"log/slog"
"time"
)
type CronManager struct {
repo repos.AllRepos
log *slog.Logger
}
func NewCronManager(repo repos.AllRepos, log *slog.Logger) *CronManager {
return &CronManager{
repo: repo,
log: log,
}
}
func (cm *CronManager) Start() {
ticker := time.NewTicker(30 * time.Second)
go func() {
for range ticker.C {
cm.CleanupRooms()
cm.CleanupActions()
cm.CleanupPlayersRoom()
ticker.Reset(30 * time.Second)
}
}()
}
func (cm *CronManager) CleanupRooms() {
ctx, tx, err := cm.repo.InitTx(context.Background())
if err != nil {
cm.log.Error("failed to init transaction", "err", err)
return
}
defer func() {
if r := recover(); r != nil {
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction", "err", err)
}
panic(r)
}
}()
rooms, err := cm.repo.RoomList(ctx)
if err != nil {
cm.log.Error("failed to get rooms list", "err", err)
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction", "err", err)
}
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 {
cm.log.Error("failed to delete empty room", "room_id", room.ID, "err", err)
}
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 {
creatorInRoom = true
break
}
}
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); err != nil {
cm.log.Error("failed to delete bot player", "room_id", room.ID, "username", player.Username, "err", err)
}
} else {
if err := cm.repo.PlayerExitRoom(ctx, player.Username); err != nil {
cm.log.Error("failed to update player room", "room_id", room.ID, "username", player.Username, "err", err)
}
}
}
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
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 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() {
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)
}
}()
if err := cm.repo.ActionDeleteOrphaned(ctx); err != nil {
cm.log.Error("failed to delete orphaned actions", "err", err)
if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
}
return
}
if err := tx.Commit(); err != nil {
cm.log.Error("failed to commit transaction for actions 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 ( require (
github.com/davecgh/go-spew v1.1.1 // indirect 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 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 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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 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 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 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/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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -24,63 +24,18 @@ func createRoom(ctx context.Context, req *models.RoomReq) (*models.Room, error)
return room, nil return room, nil
} }
// // DEPRECATED
// func saveRoom(room *models.Room) error {
// key := models.CacheRoomPrefix + room.ID
// data, err := json.Marshal(room)
// if err != nil {
// return err
// }
// memcache.Set(key, data)
// // do I need last action here? since room save is kind of an action on itself
// // time.Now().Add(time.Hour).Sub(room.LastActionTS)
// anHour := int64(216000) // 60 * 60 * 60
// memcache.Expire(key, anHour)
// return nil
// }
// func getRoomByID(roomID string) (*models.Room, error) {
// roomBytes, err := memcache.Get(models.CacheRoomPrefix + roomID)
// if err != nil {
// return nil, err
// }
// resp := &models.Room{}
// if err := json.Unmarshal(roomBytes, &resp); err != nil {
// return nil, err
// }
// return resp, nil
// }
// func removeRoom(roomID string) {
// key := models.CacheRoomPrefix + roomID
// memcache.RemoveKey(key)
// }
// context
// func getStateByCtx(ctx context.Context) (*models.UserState, error) {
// username, ok := ctx.Value(models.CtxUsernameKey).(string)
// if !ok {
// log.Debug("no username in ctx")
// return &models.UserState{}, errors.New("no username in ctx")
// }
// us, err := loadState(username)
// if err != nil {
// return &models.UserState{}, err
// }
// return us, nil
// }
// func dbCreate(fi *models.FullInfo) error{
// repo.CreateRoom()
// }
func saveFullInfo(ctx context.Context, fi *models.FullInfo) 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 // 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 { if err := repo.PlayerUpdate(ctx, fi.State); err != nil {
return err return err
} }
log.Debug("saved user state", "state", fi.State) log.Debug("saved user state", "state", fi.State)
// save or update
// fi.Room.Cards
// fi.Room.WCMap
if err := repo.RoomUpdate(ctx, fi.Room); err != nil { if err := repo.RoomUpdate(ctx, fi.Room); err != nil {
return err return err
} }
@@ -95,48 +50,16 @@ func notifyBotIfNeeded(room *models.Room) {
} }
} }
// cache
// func saveState(username string, state *models.UserState) error {
// key := models.CacheStatePrefix + username
// data, err := json.Marshal(state)
// if err != nil {
// return err
// }
// memcache.Set(key, data)
// return nil
// }
// func getAllNames() []string {
// names := []string{}
// // will not scale
// session := &models.Session{}
// // filter by key size only sessions
// for _, name := range wholeMemStore {
// // xid is 20 in len
// if len(k) != 20 {
// continue
// }
// if err := json.Unmarshal(v, &session); err != nil {
// log.Error("failed to unmarshal", "error", err)
// continue
// }
// names = append(names, session.Username)
// }
// return names
// }
// can room exists without state? I think no // can room exists without state? I think no
func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) { func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
resp := &models.FullInfo{} resp := &models.FullInfo{}
// state, err := getStateByCtx(ctx)
state, err := getPlayerByCtx(ctx) state, err := getPlayerByCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp.State = state resp.State = state
if state.RoomID == nil || *state.RoomID == "" { 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 return resp, nil
} }
// room, err := getRoomByID(state.RoomID) // room, err := getRoomByID(state.RoomID)
@@ -145,20 +68,44 @@ func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
if err != nil { if err != nil {
// room was deleted; remove it from player; // room was deleted; remove it from player;
log.Warn("failed to find room despite knowing room_id;", log.Warn("failed to find room despite knowing room_id;",
"room_id", state.RoomID) "room_id", state.RoomID, "error", err)
state.Team = models.UserTeamNone state.Team = models.UserTeamNone
state.Role = models.UserRoleNone state.Role = models.UserRoleNone
if err := repo.PlayerExitRoom(ctx, state.Username); err != nil { if err := repo.PlayerExitRoom(ctx, state.Username); err != nil {
log.Warn("failed to exit room", log.Warn("failed to exit room", "error", err,
"room_id", state.RoomID, "username", state.Username) "room_id", state.RoomID, "username", state.Username)
return resp, err return resp, err
} }
return nil, err return nil, err
} }
// get card_marks
if room.IsRunning && room.MimeDone {
if err := fillCardMarks(ctx, room); err != nil {
log.Warn("failed to fill card marks", "error", err,
"room_id", state.RoomID, "username", state.Username)
return nil, err
}
}
resp.Room = room resp.Room = room
return resp, nil return resp, nil
} }
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 {
for _, mark := range marks {
if mark.CardID == card.ID {
room.Cards[i].Marks = append(room.Cards[i].Marks, mark)
}
}
}
return nil
}
func getPlayerByCtx(ctx context.Context) (*models.Player, error) { func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
username, ok := ctx.Value(models.CtxUsernameKey).(string) username, ok := ctx.Value(models.CtxUsernameKey).(string)
if !ok { if !ok {
@@ -168,18 +115,6 @@ func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
return repo.PlayerGetByName(ctx, username) 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) { func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) {
// get username // get username
fi, _ := getFullInfoByCtx(ctx) fi, _ := getFullInfoByCtx(ctx)
@@ -235,26 +170,6 @@ func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error)
return fi, nil 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 // get bots
func listBots() []models.Player { func listBots() []models.Player {
bots, err := repo.PlayerList(context.Background(), true) bots, err := repo.PlayerList(context.Background(), true)
@@ -273,6 +188,11 @@ func notify(event, msg string) {
} }
func loadCards(room *models.Room) { 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 // store it somewhere
wordMap := map[string]string{ wordMap := map[string]string{
"en": "assets/words/en_nouns.txt", "en": "assets/words/en_nouns.txt",
@@ -285,10 +205,6 @@ func loadCards(room *models.Room) {
fmt.Println("failed to load cards", "error", err) fmt.Println("failed to load cards", "error", err)
} }
room.Cards = cards room.Cards = cards
room.WCMap = make(map[string]models.WordColor)
for _, card := range room.Cards {
room.WCMap[card.Word] = card.Color
}
} }
func recoverBots() { func recoverBots() {
@@ -316,35 +232,6 @@ func recoverBot(bm models.Player) error {
return nil 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 // validateMove checks if it is players turn
func validateMove(fi *models.FullInfo, ur models.UserRole) error { func validateMove(fi *models.FullInfo, ur models.UserRole) error {
if fi.State.Role != ur { if fi.State.Role != ur {

View File

@@ -1,14 +1,11 @@
package handlers package handlers
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"gralias/models" "gralias/models"
"gralias/pkg/cache"
"gralias/utils" "gralias/utils"
"html/template" "html/template"
"net/http" "net/http"
@@ -38,7 +35,6 @@ func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
cleanName := utils.RemoveSpacesFromStr(username) cleanName := utils.RemoveSpacesFromStr(username)
// allNames := getAllNames()
allNames, err := repo.PlayerListNames(r.Context()) allNames, err := repo.PlayerListNames(r.Context())
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
@@ -75,26 +71,33 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, msg) abortWithError(w, msg)
return return
} }
password := r.PostFormValue("password")
var makeplayer bool var makeplayer bool
roomID := r.PostFormValue("room_id") roomID := r.PostFormValue("room_id")
// make sure username does not exists // make sure username does not exists
cleanName := utils.RemoveSpacesFromStr(username) 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 // login user
cookie, err := makeCookie(cleanName, r.RemoteAddr) cookie, session, err := makeCookie(cleanName, r.RemoteAddr)
if err != nil { if err != nil {
log.Error("failed to login", "error", err) log.Error("failed to login", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
http.SetCookie(w, cookie) 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{ fi := &models.FullInfo{
State: userstate, State: userstate,
} }
@@ -107,20 +110,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// room.PlayerList = append(room.PlayerList, fi.State.Username)
fi.Room = room
fi.List = nil fi.List = nil
fi.State.RoomID = &room.ID fi.State.RoomID = &room.ID
if err := repo.PlayerSetRoomID(r.Context(), fi.State.Username, room.ID); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// repo.RoomUpdate()
// save full info instead
// if err := saveFullInfo(r.Context(), fi); err != nil {
// abortWithError(w, err.Error())
// return
// }
} else { } else {
log.Debug("no room_id in login") log.Debug("no room_id in login")
// fi.List = listRooms(false) // fi.List = listRooms(false)
@@ -130,23 +125,24 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
// save state to cache // save state to cache
// if err := saveState(cleanName, userstate); err != nil {
if makeplayer { if makeplayer {
userstate.Password = clearPass
if err := repo.PlayerAdd(r.Context(), userstate); err != nil { 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) log.Error("failed to save state", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
} }
} }
// if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil { if err := repo.SessionCreate(r.Context(), session); err != nil {
// log.Error("failed to execute base template", "error", err) log.Error("failed to save session", "error", err)
// } abortWithError(w, err.Error())
return
}
http.Redirect(w, r, "/", 302) 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 // secret
// Create a new random session token // Create a new random session token
// sessionToken := xid.New().String() // sessionToken := xid.New().String()
@@ -183,36 +179,37 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
cookie.Secure = false cookie.Secure = false
log.Info("changing cookie domain", "domain", cookie.Domain) log.Info("changing cookie domain", "domain", cookie.Domain)
} }
// set ctx? // player, err := repo.PlayerGetByName(context.Background(), username)
if err := repo.SessionCreate(context.Background(), session); err != nil { // if err != nil || player == nil {
return nil, err // // make player first, since username is fk to players table
} // player = models.InitPlayer(username)
// set user in session // if err := repo.PlayerAdd(context.Background(), player); err != nil {
if err := cacheSetSession(sessionToken, session); err != nil { // slog.Error("failed to create player", "username", username)
return nil, err // return nil, err
} // }
return cookie, nil // }
// if err := repo.SessionCreate(context.Background(), session); err != nil {
// return nil, err
// }
return cookie, session, nil
} }
//nolint: unused func HandleSignout(w http.ResponseWriter, r *http.Request) {
func cacheGetSession(key string) (*models.Session, error) { cookie := &http.Cookie{
userSessionB, err := cache.MemCache.Get(key) Name: "session_token",
if err != nil { Value: "",
return nil, err Path: "/",
MaxAge: -1,
HttpOnly: true,
} }
var us *models.Session cookie.Secure = true
if err := json.Unmarshal(userSessionB, &us); err != nil { cookie.SameSite = http.SameSiteNoneMode
return nil, err 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)
} }
return us, nil http.SetCookie(w, cookie)
} http.Redirect(w, r, "/", http.StatusFound)
func cacheSetSession(key string, session *models.Session) error {
sesb, err := json.Marshal(session)
if err != nil {
return err
}
cache.MemCache.Set(key, sesb)
cache.MemCache.Expire(key, cfg.SessionLifetime)
return nil
} }

View File

@@ -42,15 +42,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
return return
} }
fi, err := getFullInfoByCtx(ctx) fi, err := getFullInfoByCtx(ctx)
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
if err := validateMove(fi, models.UserRoleGuesser); err != nil { if err := validateMove(fi, models.UserRoleGuesser); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
color, exists := fi.Room.WCMap[word] color, exists := fi.Room.FindColor(word)
if !exists { if !exists {
abortWithError(w, "word is not found") abortWithError(w, "word is not found")
return return
@@ -60,7 +61,17 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
Color: color, Color: color,
Revealed: true, Revealed: true,
} }
fi.Room.RevealSpecificWord(word) revCardID := fi.Room.RevealSpecificWord(word)
if revCardID == 0 {
// error
abortWithError(w, "word has 0 id")
return
}
if err := repo.WordCardReveal(r.Context(), word, fi.Room.ID); err != nil {
abortWithError(w, err.Error())
return
}
updateStatsOnCardReveal(r.Context(), fi.State, color)
fi.Room.UpdateCounter() fi.Room.UpdateCounter()
action := models.Action{ action := models.Action{
Actor: fi.State.Username, Actor: fi.State.Username,
@@ -68,10 +79,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
WordColor: string(color), WordColor: string(color),
Action: models.ActionTypeGuess, Action: models.ActionTypeGuess,
Word: word, Word: word,
RoomID: fi.Room.ID,
}
if err := repo.ActionCreate(r.Context(), &action); err != nil {
abortWithError(w, err.Error())
return
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
// if opened card is of color of opp team, change turn // if opened card is of color of opp team, change turn
oppositeColor := fi.Room.GetOppositeTeamColor() oppositeColor := fi.Room.GetOppositeTeamColor()
var clearMarks bool
fi.Room.OpenedThisTurn++ fi.Room.OpenedThisTurn++
log.Debug("got show-color request", "word", word, "color", color, log.Debug("got show-color request", "word", word, "color", color,
"limit", fi.Room.ThisTurnLimit, "opened", fi.Room.OpenedThisTurn, "limit", fi.Room.ThisTurnLimit, "opened", fi.Room.OpenedThisTurn,
@@ -83,7 +100,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.MimeDone = false fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0 fi.Room.ThisTurnLimit = 0
fi.Room.ClearMarks() clearMarks = true
StopTurnTimer(fi.Room.ID) StopTurnTimer(fi.Room.ID)
} }
switch string(color) { switch string(color) {
@@ -94,6 +111,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = oppositeColor fi.Room.TeamWon = oppositeColor
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
@@ -102,15 +120,17 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0 fi.Room.ThisTurnLimit = 0
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks() clearMarks = true
StopTurnTimer(fi.Room.ID) StopTurnTimer(fi.Room.ID)
updateStatsOnGameOver(r.Context(), fi.Room)
case string(models.WordColorWhite), string(oppositeColor): case string(models.WordColorWhite), string(oppositeColor):
log.Debug("opened opposite color word", "room", fi.Room, "opposite-color", oppositeColor) log.Debug("opened white or opposite color word", "word", word, "opposite-color", oppositeColor)
// end turn // end turn
fi.Room.TeamTurn = oppositeColor fi.Room.TeamTurn = oppositeColor
fi.Room.MimeDone = false fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0 fi.Room.ThisTurnLimit = 0
clearMarks = true
StopTurnTimer(fi.Room.ID) StopTurnTimer(fi.Room.ID)
// check if no cards left => game over // check if no cards left => game over
if fi.Room.BlueCounter == 0 { if fi.Room.BlueCounter == 0 {
@@ -119,13 +139,14 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = "blue" fi.Room.TeamWon = "blue"
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlue, WordColor: models.WordColorBlue,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks() updateStatsOnGameOver(r.Context(), fi.Room)
} }
if fi.Room.RedCounter == 0 { if fi.Room.RedCounter == 0 {
// red won // red won
@@ -133,13 +154,14 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = "red" fi.Room.TeamWon = "red"
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed, WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks() updateStatsOnGameOver(r.Context(), fi.Room)
} }
default: // same color as the team default: // same color as the team
// check if game over // check if game over
@@ -148,13 +170,20 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = fi.State.Team fi.Room.TeamWon = fi.State.Team
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed, WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
updateStatsOnGameOver(r.Context(), fi.Room)
}
}
if clearMarks {
fi.Room.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 { if err := saveFullInfo(r.Context(), fi); err != nil {
@@ -178,16 +207,17 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
return return
} }
fi, err := getFullInfoByCtx(ctx) fi, err := getFullInfoByCtx(ctx)
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
if err := validateMove(fi, models.UserRoleGuesser); err != nil { if err := validateMove(fi, models.UserRoleGuesser); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
color, exists := fi.Room.WCMap[word] color, exists := fi.Room.FindColor(word)
log.Debug("got show-color request", "word", word, "color", color) log.Debug("got mark-card request", "word", word, "color", color)
if !exists { if !exists {
abortWithError(w, "word is not found") abortWithError(w, "word is not found")
return return
@@ -205,20 +235,33 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
// Check if the current user already has an active mark on this card // Check if the current user already has an active mark on this card
found := false found := false
var newMarks []models.CardMark var newMarks []models.CardMark
for _, mark := range card.Mark { for _, mark := range card.Marks {
if mark.Username == fi.State.Username && mark.Active { if mark.Username == fi.State.Username {
found = true found = true
} else { } else {
newMarks = append(newMarks, mark) newMarks = append(newMarks, mark)
} }
} }
if !found { if !found {
newMarks = append(newMarks, models.CardMark{ cm := models.CardMark{
Username: fi.State.Username, Username: fi.State.Username,
Active: true, CardID: card.ID,
})
} }
fi.Room.Cards[i].Mark = newMarks newMarks = append(newMarks, cm)
if err := repo.CardMarksAdd(r.Context(), &cm); err != nil {
log.Error("failed to add mark", "error", err, "card", card)
abortWithError(w, "failed to add mark")
return
}
} else {
// 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")
return
}
}
fi.Room.Cards[i].Marks = newMarks
cardword = fi.Room.Cards[i] cardword = fi.Room.Cards[i]
} }
if err := saveFullInfo(r.Context(), fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
@@ -233,8 +276,9 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
func HandleActionHistory(w http.ResponseWriter, r *http.Request) { func HandleActionHistory(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
@@ -251,13 +295,21 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
// get team; // get role; make up a name // get team; // get role; make up a name
team := r.URL.Query().Get("team") team := r.URL.Query().Get("team")
role := r.URL.Query().Get("role") role := r.URL.Query().Get("role")
log.Debug("got add-bot request", "team", team, "role", role)
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return 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) _, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false)
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
@@ -271,8 +323,9 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
botName := r.URL.Query().Get("bot") botName := r.URL.Query().Get("bot")
log.Debug("got remove-bot request", "bot_name", botName) log.Debug("got remove-bot request", "bot_name", botName)
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
if err := llmapi.RemoveBot(botName, fi.Room); err != nil { if err := llmapi.RemoveBot(botName, fi.Room); err != nil {
@@ -281,3 +334,19 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
} }
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "") notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
} }
func HandleGetRoom(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context())
if err != nil {
abortWithError(w, err.Error())
return
}
tmpl, err := template.ParseGlob("components/*.html")
if err != nil {
abortWithError(w, err.Error())
return
}
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
log.Error("failed to execute template", "error", err)
}
}

View File

@@ -40,22 +40,11 @@ func HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
} }
fi.State.RoomID = &room.ID fi.State.RoomID = &room.ID
fi.Room = room fi.Room = room
// if err := repo.RoomCreate(r.Context(), room); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
// log.Error("failed to create a room", "error", err)
// abortWithError(w, err.Error())
// return
// }
if err := repo.PlayerSetRoomID(r.Context(), fi.State.Username, room.ID); err != nil {
log.Error("failed to set room id", "error", err) log.Error("failed to set room id", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// if err := saveFullInfo(r.Context(), fi); err != nil {
// msg := "failed to set current room to session"
// log.Error(msg, "error", err)
// abortWithError(w, msg)
// return
// }
notify(models.NotifyRoomListUpdate, "") notify(models.NotifyRoomListUpdate, "")
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
if err != nil { if err != nil {
@@ -83,8 +72,13 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
} }
// get username // get username
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return
}
if fi.Room == nil {
http.Redirect(w, r, "/", 302)
return return
} }
if fi.Room.IsRunning && role == "mime" { if fi.Room.IsRunning && role == "mime" {
@@ -118,8 +112,9 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
func HandleEndTurn(w http.ResponseWriter, r *http.Request) { func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
// get username // get username
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
// check if one who pressed it is from the team who has the turn // check if one who pressed it is from the team who has the turn
@@ -150,8 +145,9 @@ func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
func HandleStartGame(w http.ResponseWriter, r *http.Request) { func HandleStartGame(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
// check if enough players // check if enough players
@@ -159,6 +155,22 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// Initialize transaction
ctx, tx, err := repo.InitTx(r.Context())
if err != nil {
log.Error("failed to init transaction", "error", err)
abortWithError(w, err.Error())
return
}
defer func() {
if r := recover(); r != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
panic(r)
}
}()
fi.Room.MimeDone = false
fi.Room.IsRunning = true fi.Room.IsRunning = true
fi.Room.IsOver = false fi.Room.IsOver = false
fi.Room.TeamTurn = "blue" fi.Room.TeamTurn = "blue"
@@ -168,13 +180,53 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
fi.Room.UpdateCounter() fi.Room.UpdateCounter()
fi.Room.TeamWon = "" fi.Room.TeamWon = ""
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
CreatedAt: time.Now(),
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: string(fi.State.Team), WordColor: string(fi.State.Team),
Action: models.ActionTypeGameStarted, Action: models.ActionTypeGameStarted,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
if err := saveFullInfo(r.Context(), fi); err != nil { // Use the new context with transaction
// if err := saveFullInfo(ctx, fi); err != nil {
// if err := tx.Rollback(); err != nil {
// log.Error("failed to rollback transaction", "error", err)
// }
// abortWithError(w, err.Error())
// return
// }
// Save action history
if err := repo.ActionCreate(ctx, &action); err != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
log.Error("failed to save action", "error", err)
abortWithError(w, err.Error())
return
}
// Save word cards
for _, card := range fi.Room.Cards {
card.RoomID = fi.Room.ID // Ensure RoomID is set for each card
if err := repo.WordCardsCreate(ctx, &card); err != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
log.Error("failed to save word card", "error", err)
abortWithError(w, err.Error())
return
}
}
if err := repo.RoomUpdate(ctx, fi.Room); err != nil {
log.Error("failed to update room", "error", err)
// nolint: errcheck
tx.Rollback()
abortWithError(w, err.Error())
return
}
// Commit the transaction
if err := tx.Commit(); err != nil {
log.Error("failed to commit transaction", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@@ -201,7 +253,7 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) { func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("id") roomID := r.URL.Query().Get("id")
room, err := repo.RoomGetByID(r.Context(), roomID) room, err := repo.RoomGetExtended(r.Context(), roomID)
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
@@ -226,7 +278,8 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
fi.State.RoomID = &room.ID fi.State.RoomID = &room.ID
fi.Room = room fi.Room = room
fi.List = nil fi.List = nil
if err := saveFullInfo(r.Context(), fi); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
log.Error("failed to set room_id for player", "error", err, "username", fi.State.Username, "room_id", room.ID)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@@ -243,8 +296,9 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
clue := r.PostFormValue("clue") clue := r.PostFormValue("clue")
num := r.PostFormValue("number") num := r.PostFormValue("number")
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
guessLimitU64, err := strconv.ParseUint(num, 10, 8) guessLimitU64, err := strconv.ParseUint(num, 10, 8)
@@ -278,6 +332,7 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
} }
// === // ===
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: string(fi.State.Team), WordColor: string(fi.State.Team),
@@ -286,27 +341,32 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
Number: num, Number: num,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
if err := repo.ActionCreate(r.Context(), &action); err != nil {
abortWithError(w, err.Error())
return
}
fi.Room.MimeDone = true fi.Room.MimeDone = true
fi.Room.ThisTurnLimit = uint8(guessLimitU64) + 1 fi.Room.ThisTurnLimit = uint8(guessLimitU64) + 1
if guessLimitU64 == 0 { if guessLimitU64 == 0 {
fi.Room.ThisTurnLimit = 9 fi.Room.ThisTurnLimit = 9
} }
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.Settings.TurnSecondsLeft = fi.Room.Settings.RoundTime StartTurnTimer(fi.Room.ID, fi.Room.Settings.RoundTime)
StartTurnTimer(fi.Room.ID, time.Duration(fi.Room.Settings.RoundTime)*time.Second)
log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit) log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit)
notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num) // notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
notifyBotIfNeeded(fi.Room) notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, clue+num)
if err := saveFullInfo(r.Context(), fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
notifyBotIfNeeded(fi.Room)
} }
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) { func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
notifyBotIfNeeded(fi.Room) notifyBotIfNeeded(fi.Room)

View File

@@ -24,16 +24,11 @@ func init() {
Level: slog.LevelDebug, Level: slog.LevelDebug,
AddSource: true, AddSource: true,
})) }))
// memcache = cache.MemCache
cfg = config.LoadConfigOrDefault("") cfg = config.LoadConfigOrDefault("")
Notifier = broker.Notifier Notifier = broker.Notifier
// cache.MemCache.StartBackupRoutine(15 * time.Second) // Reduced backup interval // repo = repos.NewRepoProvider("sqlite3://../gralias.db")
// bot loader repo = repos.RP
// check the rooms if it has bot_{digits} in them, create bots if have
repo = repos.NewRepoProvider("sqlite3://../gralias.db")
recoverBots() recoverBots()
// if player has a roomID, but no team and role, try to recover
// recoverPlayers()
} }
func HandlePing(w http.ResponseWriter, r *http.Request) { func HandlePing(w http.ResponseWriter, r *http.Request) {
@@ -80,45 +75,48 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
return return
} }
fi, err := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if err != nil { if err != nil || fi == nil {
abortWithError(w, err.Error()) log.Error("failed to fetch fi", "error", err)
http.Redirect(w, r, "/", 302)
return return
} }
if fi.Room.IsRunning { if fi.Room.IsRunning {
abortWithError(w, "cannot leave when game is running") abortWithError(w, "cannot leave when game is running")
return return
} }
var creatorLeft bool // if creator leaves, remove all players from room and delete room
if fi.Room.CreatorName == fi.State.Username { if fi.Room.CreatorName == fi.State.Username {
creatorLeft = true players, err := repo.PlayerListByRoom(r.Context(), fi.Room.ID)
if err != nil {
log.Error("failed to list players in room", "error", err)
abortWithError(w, err.Error())
return
} }
exitedRoom := fi.ExitRoom() for _, p := range players {
// if err := saveRoom(exitedRoom); err != nil { if p.IsBot {
// abortWithError(w, err.Error()) if err := repo.PlayerDelete(r.Context(), p.Username); err != nil {
// return log.Error("failed to delete bot", "error", err)
// } }
if creatorLeft { continue
if err := repo.RoomDeleteByID(r.Context(), exitedRoom.ID); err != nil { }
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) log.Error("failed to remove room", "error", err)
} }
// removeRoom(exitedRoom.ID)
// TODO: notify users if creator left
// and throw them away
notify(models.NotifyRoomListUpdate, "") notify(models.NotifyRoomListUpdate, "")
} } else {
// scary to update the whole room // if regular player leaves, just exit room
fiToSave := &models.FullInfo{
Room: exitedRoom,
}
if err := saveFullInfo(r.Context(), fiToSave); err != nil {
abortWithError(w, err.Error())
return
}
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil { if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
log.Error("failed to exit room", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// fi.List = listRooms(false) }
fi.Room = nil
fi.State.RoomID = nil
fi.List, err = repo.RoomList(r.Context()) fi.List, err = repo.RoomList(r.Context())
if err != nil { if err != nil {
abortWithError(w, err.Error()) 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") 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

@@ -6,7 +6,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"gralias/models" "gralias/models"
"gralias/pkg/cache"
"net/http" "net/http"
) )
@@ -29,8 +28,8 @@ func GetSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie(models.AuthCookie) sessionCookie, err := r.Cookie(models.AuthCookie)
if err != nil { if err != nil {
msg := "auth failed; failed to get session token from cookies" // msg := "auth failed; failed to get session token from cookies"
log.Debug(msg, "error", err) // log.Debug(msg, "error", err)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@@ -62,16 +61,17 @@ func GetSession(next http.Handler) http.Handler {
return return
} }
userSession, err := repo.SessionByToken(r.Context(), sessionToken) userSession, err := repo.SessionByToken(r.Context(), sessionToken)
// userSession, err := cacheGetSession(sessionToken)
// log.Debug("userSession from cache", "us", userSession)
if err != nil { if err != nil {
msg := "auth failed; session does not exists" msg := "auth failed; session does not exist"
log.Debug(msg, "error", err, "key", sessionToken) log.Debug(msg, "error", err, "key", sessionToken)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
if userSession.IsExpired() { if userSession.IsExpired() {
cache.MemCache.RemoveKey(sessionToken) if err := repo.SessionDelete(r.Context(), sessionToken); err != nil {
log.Error("failed to delete session", "error", err)
}
// cache.MemCache.RemoveKey(sessionToken)
msg := "session is expired" msg := "session is expired"
log.Debug(msg, "error", err, "token", sessionToken) log.Debug(msg, "error", err, "token", sessionToken)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@@ -81,13 +81,6 @@ func GetSession(next http.Handler) http.Handler {
models.CtxUsernameKey, userSession.Username) models.CtxUsernameKey, userSession.Username)
ctx = context.WithValue(ctx, ctx = context.WithValue(ctx,
models.CtxSessionKey, userSession) 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)) 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,70 +3,37 @@ package handlers
import ( import (
"context" "context"
"gralias/models" "gralias/models"
"gralias/timer"
"log/slog"
"strconv" "strconv"
"sync"
"time"
) )
type roomTimer struct { func StartTurnTimer(roomID string, timeLeft uint32) {
ticker *time.Ticker logger := slog.Default().With("room_id", roomID)
done chan bool
}
var ( onTurnEnd := func(ctx context.Context, roomID string) {
timers = make(map[string]*roomTimer)
mu sync.Mutex
)
func StartTurnTimer(roomID string, duration time.Duration) {
mu.Lock()
defer mu.Unlock()
if _, exists := timers[roomID]; exists {
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:
room, err := repo.RoomGetByID(context.Background(), roomID) room, err := repo.RoomGetByID(context.Background(), roomID)
if err != nil { if err != nil {
log.Error("failed to get room by id", "error", err) logger.Error("failed to get room by id", "error", err)
StopTurnTimer(roomID)
return return
} }
if room.Settings.TurnSecondsLeft <= 0 { logger.Info("turn time is over")
log.Info("turn time is over", "room_id", roomID)
room.ChangeTurn() room.ChangeTurn()
room.MimeDone = false room.MimeDone = false
if err := repo.RoomUpdate(context.Background(), room); err != nil { if err := repo.RoomUpdate(context.Background(), room); err != nil {
log.Error("failed to save room", "error", err) logger.Error("failed to save room", "error", err)
} }
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.TurnSecondsLeft), 10)) notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
notifyBotIfNeeded(room) notifyBotIfNeeded(room)
StopTurnTimer(roomID)
return
} }
room.Settings.TurnSecondsLeft--
// if err := saveRoom(room); err != nil { onTick := func(ctx context.Context, roomID string, currentLeft uint32) {
// log.Error("failed to save room", "error", err) notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10))
// }
notify(models.NotifyRoomUpdatePrefix+room.ID, "")
} }
}
}() timer.StartTurnTimer(context.Background(), roomID, timeLeft, onTurnEnd, onTick, logger)
} }
func StopTurnTimer(roomID string) { func StopTurnTimer(roomID string) {
mu.Lock() timer.StopTurnTimer(roomID)
defer mu.Unlock()
if timer, exists := timers[roomID]; exists {
timer.ticker.Stop()
close(timer.done)
delete(timers, roomID)
}
} }

View File

@@ -1,31 +1,34 @@
package llmapi package llmapi
import ( import (
"encoding/json" "context"
"errors" "errors"
"fmt" "fmt"
"gralias/broker" "gralias/broker"
"gralias/config" "gralias/config"
"gralias/models" "gralias/models"
"gralias/pkg/cache" "gralias/repos"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
var ( var (
// botname -> channel // botname -> channel
repo = repos.RP
SignalChanMap = make(map[string]chan bool) SignalChanMap = make(map[string]chan bool)
DoneChanMap = 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 // 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` 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`
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` 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.`
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` GuesserSimplePromptRU = `мы играем в alias;\n тебе дана подсказка (clue): \"%s\";\nпожалуйста, верни свою догадку (guess), а также слова, что тоже подходят к подсказке, но ты меньше в них уверен, в формате json; пример:\n{\n\"guess\": \"отгадка\",\n\"could_be\": [\"слово1\", \"слово2\", ...]\n}\nвот список слов из которых нужно выбрать:\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;` 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) { func convertToSliceOfStrings(value any) ([]string, error) {
@@ -49,25 +52,9 @@ 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 { func (b *Bot) checkGuess(word string, room *models.Room) error {
color, exists := room.WCMap[word] // color, exists := room.WCMap[word]
color, exists := room.FindColor(word)
b.log.Debug("bot trying to open card", "word", word, "color", b.log.Debug("bot trying to open card", "word", word, "color",
color, "exists", exists, "limit", room.ThisTurnLimit, color, "exists", exists, "limit", room.ThisTurnLimit,
"opened", room.OpenedThisTurn) "opened", room.OpenedThisTurn)
@@ -75,8 +62,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
return fmt.Errorf("fn: checkGuess; %s does not exists", word) return fmt.Errorf("fn: checkGuess; %s does not exists", word)
} }
room.RevealSpecificWord(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() room.UpdateCounter()
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: b.Team, ActorColor: b.Team,
WordColor: string(color), WordColor: string(color),
@@ -96,6 +90,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.MimeDone = false room.MimeDone = false
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
b.StopTurnTimer()
} }
switch string(color) { switch string(color) {
case string(models.WordColorBlack): case string(models.WordColorBlack):
@@ -106,18 +101,22 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: string(b.Team), ActorColor: string(b.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
updateStatsOnGameOver(context.Background(), room)
case string(models.WordColorWhite), string(oppositeColor): case string(models.WordColorWhite), string(oppositeColor):
// end turn // end turn
room.TeamTurn = oppositeColor room.TeamTurn = oppositeColor
room.MimeDone = false room.MimeDone = false
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
b.StopTurnTimer()
} }
// check if no cards left => game over // check if no cards left => game over
if room.BlueCounter == 0 { if room.BlueCounter == 0 {
@@ -128,12 +127,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: string(b.Team), ActorColor: string(b.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
updateStatsOnGameOver(context.Background(), room)
} }
if room.RedCounter == 0 { if room.RedCounter == 0 {
// red won // red won
@@ -143,14 +145,32 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: string(b.Team), ActorColor: string(b.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
updateStatsOnGameOver(context.Background(), room)
} }
if err := saveRoom(room); err != nil { ctx, tx, err := repo.InitTx(context.Background())
// nolint: errcheck
defer tx.Commit()
if err != nil {
b.log.Error("failed to init tx", "error", err)
}
if err := repo.ActionCreate(ctx, &action); err != nil {
// nolint: errcheck
tx.Rollback()
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()
b.log.Error("failed to save room", "room", room) b.log.Error("failed to save room", "room", room)
err = fmt.Errorf("fn: checkGuess, failed to save room; err: %w", err) err = fmt.Errorf("fn: checkGuess, failed to save room; err: %w", err)
return err return err
@@ -162,18 +182,40 @@ func (b *Bot) BotMove() {
// botJournalName := models.NotifyJournalPrefix + b.RoomID // botJournalName := models.NotifyJournalPrefix + b.RoomID
b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role) b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role)
// get room cards and actions // get room cards and actions
room, err := getRoomByID(b.RoomID) // room, err := getRoomByID(b.RoomID)
room, err := repo.RoomGetExtended(context.Background(), b.RoomID)
if err != nil { if err != nil {
b.log.Error("bot loop", "error", err) b.log.Error("bot loop", "error", err)
return 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 := "" eventPayload := ""
defer func() { // save room 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 { if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err) b.log.Error("failed to save room", "error", err)
return 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{ broker.Notifier.Notifier <- broker.NotificationEvent{
EventName: eventName, EventName: eventName,
Payload: eventPayload, Payload: eventPayload,
@@ -185,13 +227,30 @@ func (b *Bot) BotMove() {
// call llm // call llm
llmResp, err := b.CallLLM(prompt) llmResp, err := b.CallLLM(prompt)
if err != nil { 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) b.log.Error("bot loop", "error", err)
if err := repo.RoomSetBotFailed(context.Background(), room.ID); err != nil {
b.log.Error("failed to set bot failed bool", "error", err)
}
return return
} }
tempMap, err := b.LLMParser.ParseBytes(llmResp) tempMap, err := b.LLMParser.ParseBytes(llmResp)
if err != nil { 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)) b.log.Error("bot loop", "error", err, "resp", string(llmResp))
return return
} }
@@ -200,6 +259,22 @@ func (b *Bot) BotMove() {
mimeResp := MimeResp{} mimeResp := MimeResp{}
b.log.Info("mime resp log", "mimeResp", tempMap) b.log.Info("mime resp log", "mimeResp", tempMap)
mimeResp.Clue = strings.ToLower(tempMap["clue"].(string)) 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 var ok bool
mimeResp.Number, ok = tempMap["number"].(string) mimeResp.Number, ok = tempMap["number"].(string)
if !ok { if !ok {
@@ -207,6 +282,7 @@ func (b *Bot) BotMove() {
return return
} }
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: b.Team, ActorColor: b.Team,
WordColor: b.Team, WordColor: b.Team,
@@ -216,64 +292,92 @@ func (b *Bot) BotMove() {
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
room.MimeDone = true room.MimeDone = true
meant := fmt.Sprintf(b.BotName+" meant to open: %v", tempMap["words_I_mean_my_team_to_open"]) // entry := fmt.Sprintf("meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
room.LogJournal = append(room.LogJournal, meant) // 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 eventPayload = mimeResp.Clue + mimeResp.Number
guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8) guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8)
if err != nil { if err != nil {
b.log.Warn("failed to parse bot given limit", "mimeResp", mimeResp, "bot_name", b.BotName) 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) room.ThisTurnLimit = uint8(guessLimitU64)
if room.ThisTurnLimit == 0 { if room.ThisTurnLimit == 0 {
b.log.Warn("turn limit is 0", "mimeResp", mimeResp) b.log.Warn("turn limit is 0", "mimeResp", mimeResp)
room.ThisTurnLimit = 9 room.ThisTurnLimit = 9
} }
if err := repo.ActionCreate(context.Background(), &action); err != nil {
b.log.Error("failed to create action", "error", err)
return
}
b.StartTurnTimer(room.Settings.RoundTime)
if err := saveRoom(room); err != nil { if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err) b.log.Error("failed to save room", "error", err)
return return
} }
case models.UserRoleGuesser: case models.UserRoleGuesser:
// // deprecated
// if err := b.checkGuesses(tempMap, room); err != nil {
// b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName)
// continue
// }
guess, ok := tempMap["guess"].(string) guess, ok := tempMap["guess"].(string)
if !ok || guess == "" { if !ok || guess == "" {
b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName) b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName)
} }
if err := b.checkGuess(guess, room); err != nil { 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) b.log.Error("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) entry := fmt.Sprintf("failed to check guess; mimeResp: %v; guess: %s; error: %v", tempMap, guess, err)
room.LogJournal = append(room.LogJournal, msg) 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) b.log.Info("guesser resp log", "guesserResp", tempMap)
couldBe, err := convertToSliceOfStrings(tempMap["could_be"]) couldBe, err := convertToSliceOfStrings(tempMap["could_be"])
if err != nil { if err != nil {
b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName) 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)) entry := fmt.Sprintf("%s guessed: %s; also considered this: %v", b.BotName, guess, couldBe)
eventName = models.NotifyRoomUpdatePrefix + room.ID lj := models.Journal{
eventPayload = "" Entry: entry,
// TODO: needs to decide if it wants to open the next cardword or end turn Username: b.BotName,
// or end turn on limit 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: default:
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap) b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
return return
} }
if botName := room.WhichBotToMove(); botName != "" {
b.log.Debug("notifying bot", "name", botName)
SignalChanMap[botName] <- true
}
} }
// StartBot // StartBot
func (b *Bot) 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 { for {
select { select {
case <-SignalChanMap[b.BotName]: case <-signalChan:
b.BotMove() b.BotMove()
case <-DoneChanMap[b.BotName]: case <-doneChan:
b.log.Debug("got done signal", "bot-name", b.BotName) b.log.Debug("got done signal", "bot-name", b.BotName)
return return
} }
@@ -281,19 +385,51 @@ func (b *Bot) StartBot() {
} }
func RemoveBot(botName string, room *models.Room) error { func RemoveBot(botName string, room *models.Room) error {
mapMutex.Lock()
// channels // channels
DoneChanMap[botName] <- true if doneChan, ok := DoneChanMap[botName]; ok {
close(DoneChanMap[botName]) doneChan <- true
close(SignalChanMap[botName]) close(doneChan)
}
if signalChan, ok := SignalChanMap[botName]; ok {
close(signalChan)
}
// maps // maps
delete(room.BotMap, botName)
delete(DoneChanMap, botName) delete(DoneChanMap, botName)
delete(SignalChanMap, botName) delete(SignalChanMap, botName)
mapMutex.Unlock()
delete(room.BotMap, botName)
// remove role from room // remove role from room
room.RemovePlayer(botName) room.RemovePlayer(botName)
slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
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) 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 // EndBot
func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) { func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) {
@@ -316,7 +452,8 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
bot.LLMParser = NewOpenRouterParser(bot.log) bot.LLMParser = NewOpenRouterParser(bot.log)
} }
// add to room // add to room
room, err := getRoomByID(bot.RoomID) // room, err := getRoomByID(bot.RoomID)
room, err := repo.RoomGetExtended(context.Background(), bot.RoomID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -360,46 +497,44 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
if err := saveRoom(room); err != nil { if err := saveRoom(room); err != nil {
return nil, err return nil, err
} }
if !recovery {
if err := saveBot(bot); err != nil { if err := saveBot(bot); err != nil {
return nil, err return nil, err
} }
}
bot.log.Debug("before adding to ch map", "name", bot.BotName)
// buffered channel to send to it in the same goroutine // buffered channel to send to it in the same goroutine
mapMutex.Lock()
SignalChanMap[bot.BotName] = make(chan bool, 1) SignalChanMap[bot.BotName] = make(chan bool, 1)
DoneChanMap[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 go bot.StartBot() // run bot routine
return bot, nil return bot, nil
} }
func saveBot(bot *Bot) error { func saveBot(bot *Bot) error {
key := models.CacheBotPredix + bot.RoomID + bot.BotName // key := models.CacheBotPredix + bot.RoomID + bot.BotName
data, err := json.Marshal(bot) // data, err := json.Marshal(bot)
if err != nil { // if err != nil {
return err // return err
} // // }
cache.MemCache.Set(key, data) // cache.MemCache.Set(key, data)
return nil botPlayer := bot.ToPlayer()
} return repo.PlayerAdd(context.Background(), botPlayer)
func getRoomByID(roomID string) (*models.Room, error) {
roomBytes, err := cache.MemCache.Get(models.CacheRoomPrefix + roomID)
if err != nil {
return nil, err
}
resp := &models.Room{}
if err := json.Unmarshal(roomBytes, &resp); err != nil {
return nil, err
}
return resp, nil
} }
func saveRoom(room *models.Room) error { func saveRoom(room *models.Room) error {
key := models.CacheRoomPrefix + room.ID // key := models.CacheRoomPrefix + room.ID
data, err := json.Marshal(room) // data, err := json.Marshal(room)
if err != nil { // if err != nil {
return err // return err
} // }
cache.MemCache.Set(key, data) // cache.MemCache.Set(key, data)
return nil // ------------
// probably need to update other tables
// like word_cards or marks;
return repo.RoomUpdate(context.Background(), room)
} }
func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string { func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
@@ -418,6 +553,9 @@ func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
} }
words[i] = card.Word words[i] = card.Word
} }
if strings.EqualFold(room.Settings.Language, "ru") {
return fmt.Sprintf(MimeSimplePromptRU, clueAction.Word, words)
}
return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words) return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
} }
@@ -446,34 +584,20 @@ func (b *Bot) BuildSimpleMimePrompt(room *models.Room) string {
theirwords = append(theirwords, card.Word) 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 { func (b *Bot) BuildPrompt(room *models.Room) string {
if b.Role == "" { if b.Role == "" {
return "" return ""
} }
// toText := make(map[string]any)
// toText["backlog"] = room.ActionHistory
// // mime sees all colors;
// // guesser sees only revealed ones
// if b.Role == models.UserRoleMime {
// toText["cards"] = room.Cards
// }
// data, err := json.Marshal(toText)
// if err != nil {
// b.log.Error("failed to marshal", "error", err)
// return ""
// }
// Escape the JSON string for inclusion in another JSON field
// escapedData := strings.ReplaceAll(string(data), `"`, `\"`)
if b.Role == models.UserRoleMime { if b.Role == models.UserRoleMime {
// return fmt.Sprintf(MimeSimplePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
// return fmt.Sprintf(MimePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
return b.BuildSimpleMimePrompt(room) return b.BuildSimpleMimePrompt(room)
} }
if b.Role == models.UserRoleGuesser { if b.Role == models.UserRoleGuesser {
// return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
return b.BuildSimpleGuesserPrompt(room) return b.BuildSimpleGuesserPrompt(room)
} }
return "" return ""
@@ -491,7 +615,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payloadReader) req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payloadReader)
if err != nil { if err != nil {
if attempt == maxRetries-1 { 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) 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)) time.Sleep(time.Duration(baseDelay) * time.Second * time.Duration(attempt+1))
@@ -503,7 +627,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
if attempt == maxRetries-1 { 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) 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 delay := time.Duration(baseDelay*(attempt+1)) * time.Second
@@ -514,7 +638,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
resp.Body.Close() resp.Body.Close()
if err != nil { if err != nil {
if attempt == maxRetries-1 { 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) 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 delay := time.Duration(baseDelay*(attempt+1)) * time.Second
@@ -524,7 +648,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
// Check status code // Check status code
if resp.StatusCode >= 400 && resp.StatusCode < 600 { if resp.StatusCode >= 400 && resp.StatusCode < 600 {
if attempt == maxRetries-1 { 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) b.log.Warn("retriable status code; will retry", "code", resp.StatusCode, "attempt", attempt)
delay := time.Duration((baseDelay * (1 << attempt))) * time.Second delay := time.Duration((baseDelay * (1 << attempt))) * time.Second
@@ -539,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) b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
return body, nil 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") return nil, errors.New("unknown error in retry loop")
} }

View File

@@ -2,6 +2,7 @@ package llmapi
import ( import (
"gralias/config" "gralias/config"
"gralias/models"
"log/slog" "log/slog"
) )
@@ -84,3 +85,13 @@ type Bot struct {
// SignalsCh chan bool // SignalsCh chan bool
// DoneCh chan bool // DoneCh chan bool
} }
func (b *Bot) ToPlayer() *models.Player {
return &models.Player{
Role: models.StrToUserRole(b.Role),
Team: models.StrToUserTeam(b.Team),
RoomID: &b.RoomID,
Username: b.BotName,
IsBot: true,
}
}

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

83
main.go
View File

@@ -3,12 +3,17 @@ package main
import ( import (
"context" "context"
"gralias/config" "gralias/config"
"gralias/crons"
"gralias/handlers" "gralias/handlers"
"gralias/pkg/cache" "gralias/repos"
"gralias/telemetry"
"log/slog" "log/slog"
"net/http" "net/http"
_ "net/http/pprof"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
) )
@@ -19,20 +24,58 @@ func init() {
cfg = config.LoadConfigOrDefault("") 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 { func ListenToRequests(port string) *http.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
var handler http.Handler = mux
handler = handlers.LogRequests(handlers.GetSession(handler))
handler = telemetry.OtelMiddleware(handler)
server := &http.Server{ server := &http.Server{
Handler: handlers.LogRequests(handlers.GetSession(mux)), Handler: handler,
Addr: ":" + port, Addr: ":" + port,
ReadTimeout: time.Second * 5, // TODO: to cfg // ReadTimeout: time.Second * 5, // does this timeout conflict with sse connection?
WriteTimeout: 0, // sse streaming WriteTimeout: 0, // sse streaming
} }
fs := http.FileServer(http.Dir("assets/")) // fs := http.FileServer(http.Dir("assets/"))
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs)) fs := http.Dir("assets/")
mux.Handle("GET /assets/", http.StripPrefix("/assets/", GzipFileServer(fs)))
// //
mux.HandleFunc("GET /ping", handlers.HandlePing) mux.HandleFunc("GET /ping", handlers.HandlePing)
mux.HandleFunc("GET /", handlers.HandleHome) mux.HandleFunc("GET /", handlers.HandleHome)
mux.HandleFunc("GET /stats", handlers.HandleStats)
mux.HandleFunc("POST /login", handlers.HandleFrontLogin) mux.HandleFunc("POST /login", handlers.HandleFrontLogin)
mux.HandleFunc("GET /signout", handlers.HandleSignout)
mux.HandleFunc("POST /join-team", handlers.HandleJoinTeam) mux.HandleFunc("POST /join-team", handlers.HandleJoinTeam)
mux.HandleFunc("GET /end-turn", handlers.HandleEndTurn) mux.HandleFunc("GET /end-turn", handlers.HandleEndTurn)
mux.HandleFunc("POST /room-create", handlers.HandleCreateRoom) mux.HandleFunc("POST /room-create", handlers.HandleCreateRoom)
@@ -49,6 +92,7 @@ func ListenToRequests(port string) *http.Server {
mux.HandleFunc("GET /add-bot", handlers.HandleAddBot) mux.HandleFunc("GET /add-bot", handlers.HandleAddBot)
mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot) mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot)
mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard) mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard)
mux.HandleFunc("GET /room", handlers.HandleGetRoom)
// special // special
mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot) mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot)
// sse // sse
@@ -58,11 +102,29 @@ func ListenToRequests(port string) *http.Server {
} }
func main() { func main() {
shutdown := telemetry.InitTracer()
defer shutdown()
// Setup graceful shutdown // Setup graceful shutdown
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM) signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
// repo := repos.NewRepoProvider(cfg.DBPath)
repo := repos.RP
defer repo.Close()
cm := crons.NewCronManager(repo, slog.Default())
cm.Start()
server := ListenToRequests(cfg.ServerConfig.Port) 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() { go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err) panic(err)
@@ -70,11 +132,14 @@ func main() {
}() }()
<-stop <-stop
slog.Info("Shutting down server...") slog.Info("Shutting down servers...")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := server.Shutdown(ctx); err != nil { 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)
} }
cache.MemCache.BackupNow()
} }

View File

@@ -13,6 +13,7 @@ CREATE TABLE rooms (
mime_done BOOLEAN NOT NULL DEFAULT FALSE, mime_done BOOLEAN NOT NULL DEFAULT FALSE,
is_running BOOLEAN NOT NULL DEFAULT FALSE, is_running BOOLEAN NOT NULL DEFAULT FALSE,
is_over BOOLEAN NOT NULL DEFAULT FALSE, is_over BOOLEAN NOT NULL DEFAULT FALSE,
bot_failed BOOLEAN NOT NULL DEFAULT FALSE,
team_won TEXT NOT NULL DEFAULT '', team_won TEXT NOT NULL DEFAULT '',
room_link TEXT NOT NULL DEFAULT '' room_link TEXT NOT NULL DEFAULT ''
); );
@@ -21,10 +22,11 @@ CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT, -- nullable room_id TEXT, -- nullable
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue' team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime' role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime'
is_bot BOOLEAN NOT NULL DEFAULT FALSE, is_bot BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE word_cards ( CREATE TABLE word_cards (
@@ -34,15 +36,15 @@ CREATE TABLE word_cards (
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
revealed BOOLEAN NOT NULL DEFAULT FALSE, revealed BOOLEAN NOT NULL DEFAULT FALSE,
mime_view BOOLEAN NOT NULL DEFAULT FALSE, mime_view BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE card_marks ( CREATE TABLE card_marks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER NOT NULL, card_id INTEGER NOT NULL,
username TEXT NOT NULL, username TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE, FOREIGN KEY (card_id) REFERENCES word_cards(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES word_cards(id) FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
PRIMARY KEY (card_id, username)
); );
CREATE TABLE actions ( CREATE TABLE actions (
@@ -55,7 +57,7 @@ CREATE TABLE actions (
word_color TEXT NOT NULL DEFAULT '', word_color TEXT NOT NULL DEFAULT '',
number_associated TEXT NOT NULL DEFAULT '', -- for clues number_associated TEXT NOT NULL DEFAULT '', -- for clues
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE settings ( CREATE TABLE settings (
@@ -65,7 +67,7 @@ CREATE TABLE settings (
room_pass TEXT NOT NULL DEFAULT '', room_pass TEXT NOT NULL DEFAULT '',
turn_time INTEGER NOT NULL DEFAULT 60, -- seconds turn_time INTEGER NOT NULL DEFAULT 60, -- seconds
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE sessions( CREATE TABLE sessions(
@@ -74,5 +76,32 @@ CREATE TABLE sessions(
lifetime INTEGER NOT NULL DEFAULT 3600, lifetime INTEGER NOT NULL DEFAULT 3600,
token_key TEXT NOT NULL DEFAULT '' UNIQUE, -- encoded value token_key TEXT NOT NULL DEFAULT '' UNIQUE, -- encoded value
username TEXT NOT NULL, username TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) 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

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"gralias/utils" "gralias/utils"
"strings"
"time" "time"
"github.com/rs/xid" "github.com/rs/xid"
@@ -98,14 +99,14 @@ type Action struct {
Word string `json:"word" db:"word"` Word string `json:"word" db:"word"`
WordColor string `json:"word_color" db:"word_color"` WordColor string `json:"word_color" db:"word_color"`
Number string `json:"number_associated" db:"number_associated"` Number string `json:"number_associated" db:"number_associated"`
CreatedAt time.Time `json:"created_at" db:"-"` CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedAtUnix int64 `db:"created_at"`
} }
type Player struct { type Player struct {
ID uint32 `json:"id" db:"id"` ID uint32 `json:"id" db:"id"`
RoomID *string `json:"room_id" db:"room_id"` RoomID *string `json:"room_id" db:"room_id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password string `json:"-" db:"password"`
Team UserTeam `json:"team" db:"team"` Team UserTeam `json:"team" db:"team"`
Role UserRole `json:"role" db:"role"` Role UserRole `json:"role" db:"role"`
IsBot bool `json:"is_bot" db:"is_bot"` IsBot bool `json:"is_bot" db:"is_bot"`
@@ -126,8 +127,32 @@ type BotPlayer struct {
} }
type CardMark struct { type CardMark struct {
Username string CardID uint32 `db:"card_id"`
Active bool 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 { type Room struct {
@@ -150,16 +175,25 @@ type Room struct {
RedTeam Team `db:"-"` RedTeam Team `db:"-"`
BlueTeam Team `db:"-"` BlueTeam Team `db:"-"`
Cards []WordCard `db:"-"` Cards []WordCard `db:"-"`
WCMap map[string]WordColor `db:"-"`
BotMap map[string]BotPlayer `db:"-"` BotMap map[string]BotPlayer `db:"-"`
Mark CardMark `db:"-"` LogJournal []Journal `db:"-"`
LogJournal []string `db:"-"`
Settings GameSettings `db:"-"` Settings GameSettings `db:"-"`
//
BotFailed bool `db:"bot_failed"`
}
func (r *Room) FindColor(word string) (WordColor, bool) {
for _, card := range r.Cards {
if strings.EqualFold(card.Word, word) {
return card.Color, true
}
}
return "", false
} }
func (r *Room) ClearMarks() { func (r *Room) ClearMarks() {
for i, _ := range r.Cards { for i := range r.Cards {
r.Cards[i].Mark = []CardMark{} r.Cards[i].Marks = []CardMark{}
} }
} }
@@ -279,7 +313,7 @@ func getGuesser(m map[string]BotPlayer, team UserTeam) string {
func (r *Room) WhichBotToMove() string { func (r *Room) WhichBotToMove() string {
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn, fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning, "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 { if !r.IsRunning {
return "" return ""
} }
@@ -366,12 +400,14 @@ func (r *Room) GuesserView() {
} }
} }
func (r *Room) RevealSpecificWord(word string) { func (r *Room) RevealSpecificWord(word string) uint32 {
for i, card := range r.Cards { for i, card := range r.Cards {
if card.Word == word { if card.Word == word {
r.Cards[i].Revealed = true r.Cards[i].Revealed = true
return card.ID
} }
} }
return 0
} }
type WordCard struct { type WordCard struct {
@@ -381,7 +417,7 @@ type WordCard struct {
Color WordColor `json:"color" db:"color"` Color WordColor `json:"color" db:"color"`
Revealed bool `json:"revealed" db:"revealed"` Revealed bool `json:"revealed" db:"revealed"`
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
Mark []CardMark `json:"marks" db:"-"` Marks []CardMark `json:"marks" db:"-"`
} }
// table: settings // table: settings
@@ -390,7 +426,7 @@ type GameSettings struct {
RoomID string `db:"room_id"` RoomID string `db:"room_id"`
Language string `json:"language" example:"en" form:"language" db:"language"` Language string `json:"language" example:"en" form:"language" db:"language"`
RoomPass string `json:"room_pass" db:"room_pass"` RoomPass string `json:"room_pass" db:"room_pass"`
TurnSecondsLeft uint32 `db:"-"`
RoundTime uint32 `json:"round_time" db:"turn_time"` RoundTime uint32 `json:"round_time" db:"turn_time"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
} }
@@ -432,7 +468,6 @@ type FullInfo struct {
} }
func (f *FullInfo) ExitRoom() *Room { 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.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers)
f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers) f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers)
if f.Room.RedTeam.Mime == f.State.Username { if f.Room.RedTeam.Mime == f.State.Username {
@@ -441,8 +476,10 @@ func (f *FullInfo) ExitRoom() *Room {
if f.Room.BlueTeam.Mime == f.State.Username { if f.Room.BlueTeam.Mime == f.State.Username {
f.Room.BlueTeam.Mime = "" f.Room.BlueTeam.Mime = ""
} }
// f.State.ExitRoom() f.State.RoomID = nil
resp := f.Room resp := f.Room
f.Room = nil f.Room = nil
return resp return resp
} }
// =======

146
pkg/cache/impl.go vendored
View File

@@ -1,146 +0,0 @@
package cache
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"sync"
"time"
)
const storeFileName = "store.json"
// var MemCache Cache
var (
MemCache *MemoryCache
)
func readJSON(fileName string) (map[string][]byte, error) {
data := make(map[string][]byte)
file, err := os.Open(fileName)
if err != nil {
return data, err
}
defer file.Close()
decoder := json.NewDecoder(file)
if err := decoder.Decode(&data); err != nil {
return data, err
}
return data, nil
}
func init() {
data, err := readJSON(storeFileName)
if err != nil {
slog.Error("failed to load store from file")
}
MemCache = &MemoryCache{
data: data,
timeMap: make(map[string]time.Time),
lock: &sync.RWMutex{},
}
MemCache.StartExpiryRoutine(time.Minute)
MemCache.StartBackupRoutine(time.Minute)
}
type MemoryCache struct {
data map[string][]byte
timeMap map[string]time.Time
lock *sync.RWMutex
}
// Get a value by key from the cache
func (mc *MemoryCache) Get(key string) (value []byte, err error) {
var ok bool
mc.lock.RLock()
if value, ok = mc.data[key]; !ok {
err = fmt.Errorf("not found data in mc for the key: %v", key)
}
mc.lock.RUnlock()
return value, err
}
// Update a single value in the cache
func (mc *MemoryCache) Set(key string, value []byte) {
// no async writing
mc.lock.Lock()
mc.data[key] = value
mc.lock.Unlock()
}
func (mc *MemoryCache) Expire(key string, exp int64) {
mc.lock.RLock()
mc.timeMap[key] = time.Now().Add(time.Duration(exp) * time.Second)
mc.lock.RUnlock()
}
func (mc *MemoryCache) GetAll() (resp map[string][]byte) {
resp = make(map[string][]byte)
mc.lock.RLock()
for k, v := range mc.data {
resp[k] = v
}
mc.lock.RUnlock()
return
}
func (mc *MemoryCache) GetAllTime() (resp map[string]time.Time) {
resp = make(map[string]time.Time)
mc.lock.RLock()
for k, v := range mc.timeMap {
resp[k] = v
}
mc.lock.RUnlock()
return
}
func (mc *MemoryCache) RemoveKey(key string) {
mc.lock.RLock()
delete(mc.data, key)
delete(mc.timeMap, key)
mc.lock.RUnlock()
}
func (mc *MemoryCache) BackupNow() {
data := mc.GetAll()
jsonString, err := json.Marshal(data)
if err != nil {
slog.Warn("immediate backup failed to marshal", "err", err)
return
}
err = os.WriteFile(storeFileName, jsonString, os.ModePerm)
if err != nil {
slog.Warn("immediate backup failed to write", "err", err)
}
}
func (mc *MemoryCache) StartExpiryRoutine(n time.Duration) {
ticker := time.NewTicker(n)
go func() {
for {
<-ticker.C
// get all
timeData := mc.GetAllTime()
// check time
currentTS := time.Now()
for k, ts := range timeData {
if ts.Before(currentTS) {
// delete exp keys
mc.RemoveKey(k)
slog.Debug("remove by expiry", "key", k)
}
}
}
}()
}
func (mc *MemoryCache) StartBackupRoutine(n time.Duration) {
ticker := time.NewTicker(n)
go func() {
for {
<-ticker.C
mc.BackupNow()
}
}()
}

9
pkg/cache/main.go vendored
View File

@@ -1,9 +0,0 @@
package cache
type Cache interface {
Get(key string) ([]byte, error)
Set(key string, value []byte)
Expire(key string, exp int64)
GetAll() (resp map[string][]byte)
RemoveKey(key string)
}

View File

@@ -9,42 +9,56 @@ import (
) )
type ActionsRepo interface { type ActionsRepo interface {
ListActions(ctx context.Context, roomID string) ([]models.Action, error) ActionList(ctx context.Context, roomID string) ([]models.Action, error)
CreateAction(ctx context.Context, roomID string, action *models.Action) error ActionCreate(ctx context.Context, action *models.Action) error
GetLastClue(ctx context.Context, roomID string) (*models.Action, error) ActionGetLastClue(ctx context.Context, roomID string) (*models.Action, error)
DeleteActionsByRoomID(ctx context.Context, roomID string) error ActionDeleteByRoomID(ctx context.Context, roomID string) error
ActionDeleteOrphaned(ctx context.Context) error
ActionGetLastTimeByRoomID(ctx context.Context, roomID string) (time.Time, error)
} }
func (p *RepoProvider) ListActions(ctx context.Context, roomID string) ([]models.Action, error) { func (p *RepoProvider) ActionList(ctx context.Context, roomID string) ([]models.Action, error) {
actions := []models.Action{} actions := []models.Action{}
err := sqlx.SelectContext(ctx, p.DB, &actions, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? ORDER BY created_at ASC`, roomID) err := sqlx.SelectContext(ctx, p.DB, &actions, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? ORDER BY created_at ASC`, roomID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for i := range actions {
actions[i].CreatedAt = time.Unix(0, actions[i].CreatedAtUnix)
}
return actions, nil return actions, nil
} }
func (p *RepoProvider) CreateAction(ctx context.Context, roomID string, a *models.Action) error { func (p *RepoProvider) ActionCreate(ctx context.Context, a *models.Action) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, a.Actor, a.ActorColor, a.Action, a.Word, a.WordColor, a.Number, a.CreatedAt.UnixNano()) _, err := db.ExecContext(ctx, `INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, a.RoomID, a.Actor, a.ActorColor, a.Action, a.Word, a.WordColor, a.Number, time.Now())
return err return err
} }
func (p *RepoProvider) GetLastClue(ctx context.Context, roomID string) (*models.Action, error) { func (p *RepoProvider) ActionGetLastTimeByRoomID(ctx context.Context, roomID string) (time.Time, error) {
lastTime := time.Time{}
err := sqlx.GetContext(ctx, p.DB, &lastTime,
`SELECT created_at FROM actions WHERE room_id = ? ORDER BY created_at DESC LIMIT 1`, roomID)
if err != nil {
return lastTime, err
}
return lastTime, nil
}
func (p *RepoProvider) ActionGetLastClue(ctx context.Context, roomID string) (*models.Action, error) {
action := &models.Action{} action := &models.Action{}
err := sqlx.GetContext(ctx, p.DB, action, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? AND action_type = 'gave_clue' ORDER BY created_at DESC LIMIT 1`, roomID) err := sqlx.GetContext(ctx, p.DB, action, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? AND action_type = 'gave_clue' ORDER BY created_at DESC LIMIT 1`, roomID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
action.CreatedAt = time.Unix(0, action.CreatedAtUnix)
return action, nil return action, nil
} }
func (p *RepoProvider) DeleteActionsByRoomID(ctx context.Context, roomID string) error { func (p *RepoProvider) ActionDeleteByRoomID(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id = ?`, roomID) _, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id = ?`, roomID)
return err return err
} }
func (p *RepoProvider) ActionDeleteOrphaned(ctx context.Context) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id NOT IN (SELECT id FROM rooms)`)
return err
}

View File

@@ -7,8 +7,8 @@ import (
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
) )
func setupActionsTestDB(t *testing.T) (*sqlx.DB, func()) { func setupActionsTestDB(t *testing.T) (*sqlx.DB, func()) {
@@ -24,7 +24,7 @@ func setupActionsTestDB(t *testing.T) (*sqlx.DB, func()) {
word TEXT, word TEXT,
word_color TEXT, word_color TEXT,
number_associated TEXT, number_associated TEXT,
created_at INTEGER created_at TIMESTAMP
); );
` `
_, err = db.Exec(schema) _, err = db.Exec(schema)
@@ -35,7 +35,7 @@ func setupActionsTestDB(t *testing.T) (*sqlx.DB, func()) {
} }
} }
func TestActionsRepo_CreateAction(t *testing.T) { func TestActionsRepo_ActionCreate(t *testing.T) {
db, teardown := setupActionsTestDB(t) db, teardown := setupActionsTestDB(t)
defer teardown() defer teardown()
@@ -50,9 +50,10 @@ func TestActionsRepo_CreateAction(t *testing.T) {
WordColor: "red", WordColor: "red",
Number: "3", Number: "3",
CreatedAt: time.Now(), CreatedAt: time.Now(),
RoomID: roomID,
} }
err := repo.CreateAction(context.Background(), roomID, action) err := repo.ActionCreate(context.Background(), action)
assert.NoError(t, err) assert.NoError(t, err)
var retrievedAction models.Action var retrievedAction models.Action
@@ -75,6 +76,7 @@ func TestActionsRepo_ListActions(t *testing.T) {
Word: "apple", Word: "apple",
WordColor: "red", WordColor: "red",
Number: "3", Number: "3",
RoomID: roomID,
CreatedAt: time.Now().Add(-2 * time.Second), CreatedAt: time.Now().Add(-2 * time.Second),
} }
action2 := &models.Action{ action2 := &models.Action{
@@ -84,15 +86,16 @@ func TestActionsRepo_ListActions(t *testing.T) {
Word: "banana", Word: "banana",
WordColor: "blue", WordColor: "blue",
Number: "0", Number: "0",
RoomID: roomID,
CreatedAt: time.Now().Add(-1 * time.Second), CreatedAt: time.Now().Add(-1 * time.Second),
} }
_, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt.UnixNano()) _, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt)
assert.NoError(t, err) assert.NoError(t, err)
_, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action2.Actor, action2.ActorColor, action2.Action, action2.Word, action2.WordColor, action2.Number, action2.CreatedAt.UnixNano()) _, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action2.Actor, action2.ActorColor, action2.Action, action2.Word, action2.WordColor, action2.Number, action2.CreatedAt)
assert.NoError(t, err) assert.NoError(t, err)
actions, err := repo.ListActions(context.Background(), roomID) actions, err := repo.ActionList(context.Background(), roomID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, actions, 2) assert.Len(t, actions, 2)
assert.Equal(t, action1.Word, actions[0].Word) assert.Equal(t, action1.Word, actions[0].Word)
@@ -135,14 +138,14 @@ func TestActionsRepo_GetLastClue(t *testing.T) {
CreatedAt: time.Now().Add(-1 * time.Second), CreatedAt: time.Now().Add(-1 * time.Second),
} }
_, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt.UnixNano()) _, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt)
assert.NoError(t, err) assert.NoError(t, err)
_, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action2.Actor, action2.ActorColor, action2.Action, action2.Word, action2.WordColor, action2.Number, action2.CreatedAt.UnixNano()) _, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action2.Actor, action2.ActorColor, action2.Action, action2.Word, action2.WordColor, action2.Number, action2.CreatedAt)
assert.NoError(t, err) assert.NoError(t, err)
_, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action3.Actor, action3.ActorColor, action3.Action, action3.Word, action3.WordColor, action3.Number, action3.CreatedAt.UnixNano()) _, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action3.Actor, action3.ActorColor, action3.Action, action3.Word, action3.WordColor, action3.Number, action3.CreatedAt)
assert.NoError(t, err) assert.NoError(t, err)
lastClue, err := repo.GetLastClue(context.Background(), roomID) lastClue, err := repo.ActionGetLastClue(context.Background(), roomID)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, lastClue) assert.NotNil(t, lastClue)
assert.Equal(t, action2.Word, lastClue.Word) assert.Equal(t, action2.Word, lastClue.Word)
@@ -164,10 +167,10 @@ func TestActionsRepo_DeleteActionsByRoomID(t *testing.T) {
Number: "3", Number: "3",
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
_, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt.UnixNano()) _, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt)
assert.NoError(t, err) assert.NoError(t, err)
err = repo.DeleteActionsByRoomID(context.Background(), roomID) err = repo.ActionDeleteByRoomID(context.Background(), roomID)
assert.NoError(t, err) assert.NoError(t, err)
var count int var count int

44
repos/card_marks.go Normal file
View File

@@ -0,0 +1,44 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
type CardMarksRepo interface {
CardMarksByCardID(ctx context.Context, cardID uint32) ([]models.CardMark, error)
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) {
var cardMarks []models.CardMark
err := sqlx.SelectContext(ctx, getDB(ctx, r.DB), &cardMarks, "SELECT * FROM card_marks WHERE card_id = ?", cardID)
return cardMarks, err
}
func (r *RepoProvider) CardMarksAdd(ctx context.Context, cm *models.CardMark) error {
_, err := getDB(ctx, r.DB).ExecContext(ctx, "INSERT INTO card_marks (card_id, username) VALUES (?, ?)", cm.CardID, cm.Username)
return err
}
func (r *RepoProvider) CardMarksRemove(ctx context.Context, cardID uint32, username string) error {
db := getDB(ctx, r.DB)
_, err := db.ExecContext(ctx, "DELETE FROM card_marks WHERE card_id = ? AND username = ?", cardID, username)
return err
}
func (r *RepoProvider) CardMarksByRoomID(ctx context.Context, roomID string) ([]models.CardMark, error) {
var cardMarks []models.CardMark
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 ( import (
"context" "context"
"gralias/config"
"log/slog" "log/slog"
"os" "os"
"sync" "sync"
@@ -16,6 +17,13 @@ type AllRepos interface {
ActionsRepo ActionsRepo
PlayersRepo PlayersRepo
SessionsRepo SessionsRepo
WordCardsRepo
SettingsRepo
CardMarksRepo
PlayerStatsRepo
JournalRepo
InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error)
Close()
} }
type RepoProvider struct { type RepoProvider struct {
@@ -24,20 +32,39 @@ type RepoProvider struct {
pathToDB string 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) db, err := sqlx.Connect("sqlite3", pathToDB)
if err != nil { 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) 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") slog.Info("Successfully connected to database")
// db.SetMaxOpenConns(2)
rp := &RepoProvider{ rp := &RepoProvider{
DB: db, DB: db,
pathToDB: pathToDB, pathToDB: pathToDB,
} }
go rp.pingLoop() go rp.pingLoop()
return rp return rp
} }
@@ -94,3 +121,15 @@ func getDB(ctx context.Context, db *sqlx.DB) sqlx.ExtContext {
} }
return db return db
} }
func (p *RepoProvider) InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error) {
tx, err := p.DB.BeginTxx(ctx, nil)
if err != nil {
return nil, nil, err
}
return context.WithValue(ctx, "tx", tx), tx, nil
}
func (p *RepoProvider) Close() {
p.DB.Close()
}

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

@@ -2,6 +2,7 @@ package repos
import ( import (
"context" "context"
"fmt"
"gralias/models" "gralias/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -11,11 +12,14 @@ type PlayersRepo interface {
PlayerGetByName(ctx context.Context, username string) (*models.Player, error) PlayerGetByName(ctx context.Context, username string) (*models.Player, error)
PlayerAdd(ctx context.Context, player *models.Player) error PlayerAdd(ctx context.Context, player *models.Player) error
PlayerUpdate(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, username, roomID string) error PlayerSetRoomID(ctx context.Context, roomID, username string) error
PlayerExitRoom(ctx context.Context, username string) error PlayerExitRoom(ctx context.Context, username string) error
PlayerListNames(ctx context.Context) ([]string, error) PlayerListNames(ctx context.Context) ([]string, error)
PlayerList(ctx context.Context, isBot bool) ([]models.Player, 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) { func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
@@ -29,7 +33,7 @@ func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*models.Player, error) { func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*models.Player, error) {
var player models.Player 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 { if err != nil {
return nil, err return nil, err
} }
@@ -41,33 +45,46 @@ func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*m
func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error { func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)", _, 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.RoomID, player.Username, player.Team, player.Role, player.IsBot, player.Password)
if err != nil {
return err return err
}
if !player.IsBot {
return p.CreatePlayerStats(ctx, player.Username)
}
return nil
} }
func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error { func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE players SET room_id = ?, username = ?, team = ?, role = ?, is_bot = ? WHERE id = ?", _, err := db.ExecContext(ctx, "UPDATE players SET team = ?, role = ? WHERE username = ?;",
player.RoomID, player.Username, player.Team, player.Role, player.IsBot, player.ID) player.Team, player.Role, player.Username)
return err 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) 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 return err
} }
func (p *RepoProvider) PlayerSetRoomID(ctx context.Context, username, roomID string) error { func (p *RepoProvider) PlayerSetRoomID(ctx context.Context, roomID, username string) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE players SET room_id = ? WHERE username = ?", roomID, username) res, err := db.ExecContext(ctx, "UPDATE players SET room_id = ? WHERE username = ?", roomID, username)
if err != nil {
return err return err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return fmt.Errorf("failed to set room_id (%s) for player (%s)", roomID, username)
}
return nil
} }
func (p *RepoProvider) PlayerExitRoom(ctx context.Context, username string) error { func (p *RepoProvider) PlayerExitRoom(ctx context.Context, username string) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE players SET room_id='', team='', role='' WHERE username = ?", username) _, err := db.ExecContext(ctx, "UPDATE players SET room_id=null, team='', role='' WHERE username = ?", username)
return err return err
} }
@@ -85,3 +102,31 @@ func (p *RepoProvider) PlayerList(ctx context.Context, isBot bool) ([]models.Pla
} }
return players, nil 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)
if err != nil {
return nil, err
}
return players, nil
}

View File

@@ -6,25 +6,147 @@ import (
"testing" "testing"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
) )
func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) { func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
db, err := sqlx.Connect("sqlite3", ":memory:") db, err := sqlx.Connect("sqlite3", ":memory:")
assert.NoError(t, err) assert.NoError(t, err)
schema := ` // Load schema from migration files
CREATE TABLE IF NOT EXISTS players ( 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, id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT, room_id TEXT, -- nullable
username TEXT, username TEXT NOT NULL UNIQUE,
team TEXT, password TEXT NOT NULL DEFAULT '',
role TEXT, team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
is_bot BOOLEAN 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
_, err = db.Exec(schema) );
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) assert.NoError(t, err)
return db, func() { 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) { func TestPlayersRepo_AddPlayer(t *testing.T) {
db, teardown := setupPlayersTestDB(t) db, teardown := setupPlayersTestDB(t)
defer teardown() defer teardown()
@@ -98,7 +253,7 @@ 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) _, 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) assert.NoError(t, err)
err = repo.PlayerDelete(context.Background(), *player.RoomID, player.Username) err = repo.PlayerDelete(context.Background(), player.Username)
assert.NoError(t, err) assert.NoError(t, err)
var count int var count int

View File

@@ -2,6 +2,7 @@ package repos
import ( import (
"context" "context"
"fmt"
"gralias/models" "gralias/models"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@@ -14,6 +15,20 @@ type RoomsRepo interface {
RoomCreate(ctx context.Context, room *models.Room) error RoomCreate(ctx context.Context, room *models.Room) error
RoomDeleteByID(ctx context.Context, id string) error RoomDeleteByID(ctx context.Context, id string) error
RoomUpdate(ctx context.Context, room *models.Room) error RoomUpdate(ctx context.Context, room *models.Room) error
RoomSetBotFailed(ctx context.Context, roomID string) error
RoomUnSetBotFailed(ctx context.Context, roomID string) error
}
func (p *RepoProvider) RoomSetBotFailed(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = true WHERE id = ?", roomID)
return err
}
func (p *RepoProvider) RoomUnSetBotFailed(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = false WHERE id = ?", roomID)
return err
} }
func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) { func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {
@@ -53,10 +68,6 @@ func (p *RepoProvider) RoomCreate(ctx context.Context, r *models.Room) error {
func (p *RepoProvider) RoomDeleteByID(ctx context.Context, id string) error { func (p *RepoProvider) RoomDeleteByID(ctx context.Context, id string) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, id) _, err := db.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, id)
if err != nil {
return err
}
_, err = db.ExecContext(ctx, `DELETE FROM settings WHERE room_id = ?`, id)
return err return err
} }
@@ -74,16 +85,21 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
room := &models.Room{} room := &models.Room{}
err := sqlx.GetContext(ctx, p.DB, room, `SELECT * FROM rooms WHERE id = ?`, id) err := sqlx.GetContext(ctx, p.DB, room, `SELECT * FROM rooms WHERE id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get room; %w", err)
return nil, err return nil, err
} }
// Get players // Get players
players := []*models.Player{} players := []*models.Player{}
err = sqlx.SelectContext(ctx, p.DB, &players, `SELECT * FROM players WHERE room_id = ?`, id) err = sqlx.SelectContext(ctx, p.DB, &players, `SELECT * FROM players WHERE room_id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get players; %w", err)
return nil, err return nil, err
} }
room.RedTeam.Color = string(models.UserTeamRed) room.RedTeam.Color = string(models.UserTeamRed)
room.BlueTeam.Color = string(models.UserTeamBlue) room.BlueTeam.Color = string(models.UserTeamBlue)
if room.BotMap == nil {
room.BotMap = make(map[string]models.BotPlayer)
}
for _, player := range players { for _, player := range players {
if player.Team == models.UserTeamRed { if player.Team == models.UserTeamRed {
if player.Role == models.UserRoleMime { if player.Role == models.UserRoleMime {
@@ -99,9 +115,6 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
} }
} }
if player.IsBot { if player.IsBot {
if room.BotMap == nil {
room.BotMap = make(map[string]models.BotPlayer)
}
room.BotMap[player.Username] = models.BotPlayer{ room.BotMap[player.Username] = models.BotPlayer{
Role: player.Role, Role: player.Role,
Team: player.Team, Team: player.Team,
@@ -112,6 +125,7 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
wordCards := []models.WordCard{} wordCards := []models.WordCard{}
err = sqlx.SelectContext(ctx, p.DB, &wordCards, `SELECT * FROM word_cards WHERE room_id = ?`, id) err = sqlx.SelectContext(ctx, p.DB, &wordCards, `SELECT * FROM word_cards WHERE room_id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get cards; %w", err)
return nil, err return nil, err
} }
room.Cards = wordCards room.Cards = wordCards
@@ -119,6 +133,7 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
actions := []models.Action{} actions := []models.Action{}
err = sqlx.SelectContext(ctx, p.DB, &actions, `SELECT * FROM actions WHERE room_id = ? ORDER BY created_at ASC`, id) err = sqlx.SelectContext(ctx, p.DB, &actions, `SELECT * FROM actions WHERE room_id = ? ORDER BY created_at ASC`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get actions; %w", err)
return nil, err return nil, err
} }
room.ActionHistory = actions room.ActionHistory = actions
@@ -126,8 +141,16 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
settings := &models.GameSettings{} settings := &models.GameSettings{}
err = sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, id) err = sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get settings; %w", err)
return nil, err return nil, err
} }
room.Settings = *settings 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 return room, nil
} }

View File

@@ -7,14 +7,18 @@ import (
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
) )
func setupTestDB(t *testing.T) (*sqlx.DB, func()) { func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
db, err := sqlx.Connect("sqlite3", ":memory:") db, err := sqlx.Connect("sqlite3", ":memory:")
assert.NoError(t, err) assert.NoError(t, err)
// Enable foreign key constraints for SQLite
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
schema := ` schema := `
CREATE TABLE IF NOT EXISTS rooms ( CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -33,6 +37,47 @@ func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
room_link TEXT NOT NULL DEFAULT '' room_link TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT,
username TEXT NOT NULL UNIQUE,
team TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT '',
is_bot BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS card_marks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER NOT NULL,
username TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
FOREIGN KEY (card_id) REFERENCES word_cards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS 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 '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL, room_id TEXT NOT NULL,
@@ -40,7 +85,16 @@ func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
room_pass TEXT NOT NULL DEFAULT '', room_pass TEXT NOT NULL DEFAULT '',
turn_time INTEGER NOT NULL DEFAULT 60, turn_time INTEGER NOT NULL DEFAULT 60,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS 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,
username TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
); );
` `
_, err = db.Exec(schema) _, err = db.Exec(schema)
@@ -95,6 +149,7 @@ func TestRoomsRepo_CreateRoom(t *testing.T) {
assert.Equal(t, room.Settings.Language, retrievedSettings.Language) assert.Equal(t, room.Settings.Language, retrievedSettings.Language)
assert.Equal(t, room.Settings.RoundTime, retrievedSettings.RoundTime) assert.Equal(t, room.Settings.RoundTime, retrievedSettings.RoundTime)
assert.Equal(t, room.Settings.RoomPass, retrievedSettings.RoomPass) assert.Equal(t, room.Settings.RoomPass, retrievedSettings.RoomPass)
assert.Equal(t, room.Settings.RoundTime, retrievedSettings.RoundTime)
} }
func TestRoomsRepo_GetRoomByID(t *testing.T) { func TestRoomsRepo_GetRoomByID(t *testing.T) {
@@ -236,6 +291,17 @@ func TestRoomsRepo_DeleteRoomByID(t *testing.T) {
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room.ID, room.Settings.Language, room.Settings.RoomPass, room.Settings.RoundTime) _, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room.ID, room.Settings.Language, room.Settings.RoomPass, room.Settings.RoundTime)
assert.NoError(t, err) assert.NoError(t, err)
// Insert a word card for the room
wordCard := &models.WordCard{
RoomID: room.ID,
Word: "test_word",
Color: models.WordColorBlue,
Revealed: false,
Mime: false,
}
_, err = db.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, wordCard.RoomID, wordCard.Word, wordCard.Color, wordCard.Revealed, wordCard.Mime)
assert.NoError(t, err)
err = repo.RoomDeleteByID(context.Background(), room.ID) err = repo.RoomDeleteByID(context.Background(), room.ID)
assert.NoError(t, err) assert.NoError(t, err)
@@ -247,6 +313,10 @@ func TestRoomsRepo_DeleteRoomByID(t *testing.T) {
err = db.Get(&count, "SELECT COUNT(*) FROM settings WHERE room_id = ?", room.ID) err = db.Get(&count, "SELECT COUNT(*) FROM settings WHERE room_id = ?", room.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 0, count) assert.Equal(t, 0, count)
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", room.ID)
assert.NoError(t, err)
assert.Equal(t, 0, count)
} }
func TestRoomsRepo_UpdateRoom(t *testing.T) { func TestRoomsRepo_UpdateRoom(t *testing.T) {
@@ -277,7 +347,8 @@ func TestRoomsRepo_UpdateRoom(t *testing.T) {
}, },
} }
_, err := db.Exec(`INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsRunning, room.IsOver, room.TeamWon, room.RoomLink) var err error
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsRunning, room.IsOver, room.TeamWon, room.RoomLink)
assert.NoError(t, err) assert.NoError(t, err)
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room.ID, room.Settings.Language, room.Settings.RoomPass, room.Settings.RoundTime) _, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room.ID, room.Settings.Language, room.Settings.RoomPass, room.Settings.RoundTime)
assert.NoError(t, err) assert.NoError(t, err)
@@ -298,5 +369,6 @@ func TestRoomsRepo_UpdateRoom(t *testing.T) {
var updatedSettings models.GameSettings var updatedSettings models.GameSettings
err = db.Get(&updatedSettings, "SELECT * FROM settings WHERE room_id = ?", room.ID) err = db.Get(&updatedSettings, "SELECT * FROM settings WHERE room_id = ?", room.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, uint32(120), updatedSettings.RoundTime)
} }

35
repos/settings.go Normal file
View File

@@ -0,0 +1,35 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
type SettingsRepo interface {
SettingsGetByRoomID(ctx context.Context, roomID string) (*models.GameSettings, error)
SettingsUpdate(ctx context.Context, settings *models.GameSettings) error
SettingsDeleteByRoomID(ctx context.Context, roomID string) error
}
func (p *RepoProvider) SettingsGetByRoomID(ctx context.Context, roomID string) (*models.GameSettings, error) {
settings := &models.GameSettings{}
err := sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, roomID)
if err != nil {
return nil, err
}
return settings, nil
}
func (p *RepoProvider) SettingsUpdate(ctx context.Context, s *models.GameSettings) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `UPDATE settings SET language = ?, room_pass = ?, turn_time = ? WHERE room_id = ?`, s.Language, s.RoomPass, s.RoundTime, s.RoomID)
return err
}
func (p *RepoProvider) SettingsDeleteByRoomID(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM settings WHERE room_id = ?`, roomID)
return err
}

57
repos/settings_test.go Normal file
View File

@@ -0,0 +1,57 @@
package repos
import (
"context"
"gralias/models"
"testing"
"time"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3"
)
func TestSettingsRepo_SettingsUpdate(t *testing.T) {
db, teardown := setupTestDB(t)
defer teardown()
repo := &RepoProvider{DB: db}
// Create a dummy room first
room := &models.Room{
ID: "test_room_settings",
CreatedAt: time.Now(),
CreatorName: "test_creator",
}
_, err := db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName)
assert.NoError(t, err)
settings := &models.GameSettings{
RoomID: "test_room_settings",
Language: "en",
RoomPass: "pass123",
RoundTime: 60,
CreatedAt: time.Now(),
}
// Insert initial settings
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time, created_at) VALUES (?, ?, ?, ?, ?)`, settings.RoomID, settings.Language, settings.RoomPass, settings.RoundTime, settings.CreatedAt)
assert.NoError(t, err)
// Update settings
settings.RoundTime = 120
settings.Language = "ru"
err = repo.SettingsUpdate(context.Background(), settings)
assert.NoError(t, err)
// Verify updated settings
var updatedSettings models.GameSettings
err = db.Get(&updatedSettings, "SELECT * FROM settings WHERE room_id = ?", settings.RoomID)
assert.NoError(t, err)
assert.Equal(t, uint32(120), updatedSettings.RoundTime)
assert.Equal(t, "ru", updatedSettings.Language)
}

59
repos/word_cards.go Normal file
View File

@@ -0,0 +1,59 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
type WordCardsRepo interface {
WordCardsCreate(ctx context.Context, card *models.WordCard) error
WordCardsGetByRoomID(ctx context.Context, roomID string) ([]models.WordCard, error)
WordCardGetByWordAndRoomID(ctx context.Context, word, roomID string) (*models.WordCard, error)
WordCardReveal(ctx context.Context, word, roomID string) error
WordCardsRevealAll(ctx context.Context, roomID string) error
WordCardsDeleteByRoomID(ctx context.Context, roomID string) error
}
func (p *RepoProvider) WordCardsCreate(ctx context.Context, card *models.WordCard) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card.RoomID, card.Word, card.Color, card.Revealed, card.Mime)
return err
}
func (p *RepoProvider) WordCardsGetByRoomID(ctx context.Context, roomID string) ([]models.WordCard, error) {
cards := []models.WordCard{}
err := sqlx.SelectContext(ctx, p.DB, &cards, `SELECT * FROM word_cards WHERE room_id = ?`, roomID)
if err != nil {
return nil, err
}
return cards, nil
}
func (p *RepoProvider) WordCardGetByWordAndRoomID(ctx context.Context, word, roomID string) (*models.WordCard, error) {
card := &models.WordCard{}
err := sqlx.GetContext(ctx, p.DB, card, `SELECT * FROM word_cards WHERE word = ? AND room_id = ?`, word, roomID)
if err != nil {
return nil, err
}
return card, nil
}
func (p *RepoProvider) WordCardReveal(ctx context.Context, word, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `UPDATE word_cards SET revealed = TRUE WHERE word = ? AND room_id = ?`, word, roomID)
return err
}
func (p *RepoProvider) WordCardsRevealAll(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `UPDATE word_cards SET revealed = TRUE WHERE room_id = ?`, roomID)
return err
}
func (p *RepoProvider) WordCardsDeleteByRoomID(ctx context.Context, roomID string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM word_cards WHERE room_id = ?`, roomID)
return err
}

496
repos/word_cards_test.go Normal file
View File

@@ -0,0 +1,496 @@
package repos
import (
"context"
"gralias/models"
"os"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3"
)
func TestWordCardsRepo_Create(t *testing.T) {
// Setup temporary file-based SQLite database for this test
tempFile, err := os.CreateTemp("", "test_db_*.db")
assert.NoError(t, err)
tempFile.Close()
defer os.Remove(tempFile.Name())
db, err := sqlx.Connect("sqlite3", tempFile.Name())
assert.NoError(t, err)
defer db.Close()
// Enable foreign key constraints
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
// Apply schema
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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
);
`
_, err = db.Exec(schema)
assert.NoError(t, err)
ctx := context.Background()
roomID := "test_room_1"
// Insert a room first, as word_cards has a foreign key to rooms
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
assert.NoError(t, err)
// Test single card creation
card1 := &models.WordCard{
RoomID: roomID,
Word: "apple",
Color: models.WordColorRed,
Revealed: false,
Mime: false,
}
_, err = db.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card1.RoomID, card1.Word, card1.Color, card1.Revealed, card1.Mime)
assert.NoError(t, err)
var count int
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 1, count)
// Test batch card creation with transaction commit
tx, err := db.BeginTxx(ctx, nil)
assert.NoError(t, err)
card2 := &models.WordCard{
RoomID: roomID,
Word: "banana",
Color: models.WordColorBlue,
Revealed: false,
Mime: false,
}
card3 := &models.WordCard{
RoomID: roomID,
Word: "cherry",
Color: models.WordColorBlack,
Revealed: false,
Mime: false,
}
_, err = tx.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card2.RoomID, card2.Word, card2.Color, card2.Revealed, card2.Mime)
assert.NoError(t, err)
_, err = tx.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card3.RoomID, card3.Word, card3.Color, card3.Revealed, card3.Mime)
assert.NoError(t, err)
// Before commit, count should not reflect changes if using a transaction context
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 1, count) // Should still be 1 if not committed
err = tx.Commit()
assert.NoError(t, err)
// After commit, count should reflect changes
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 3, count)
// Test transaction rollback
tx2, err := db.BeginTxx(ctx, nil)
assert.NoError(t, err)
card4 := &models.WordCard{
RoomID: roomID,
Word: "date",
Color: models.WordColorWhite,
Revealed: false,
Mime: false,
}
_, err = tx2.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card4.RoomID, card4.Word, card4.Color, card4.Revealed, card4.Mime)
assert.NoError(t, err)
err = tx2.Rollback()
assert.NoError(t, err)
// After rollback, count should not reflect changes
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 3, count)
}
func TestWordCardsRepo_GetByWordAndRoomID(t *testing.T) {
// Setup temporary file-based SQLite database for this test
tempFile, err := os.CreateTemp("", "test_db_*.db")
assert.NoError(t, err)
tempFile.Close()
defer os.Remove(tempFile.Name())
db, err := sqlx.Connect("sqlite3", tempFile.Name())
assert.NoError(t, err)
defer db.Close()
// Enable foreign key constraints
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
// Apply schema
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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
);
`
_, err = db.Exec(schema)
assert.NoError(t, err)
repo := &RepoProvider{DB: db}
ctx := context.Background()
roomID := "test_room_3"
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
assert.NoError(t, err)
card := &models.WordCard{
RoomID: roomID,
Word: "gamma",
Color: models.WordColorRed,
Revealed: false,
Mime: false,
}
err = repo.WordCardsCreate(ctx, card)
assert.NoError(t, err)
retrievedCard, err := repo.WordCardGetByWordAndRoomID(ctx, "gamma", roomID)
assert.NoError(t, err)
assert.NotNil(t, retrievedCard)
assert.Equal(t, "gamma", retrievedCard.Word)
assert.Equal(t, roomID, retrievedCard.RoomID)
// Test non-existent card
_, err = repo.WordCardGetByWordAndRoomID(ctx, "non_existent", roomID)
assert.Error(t, err)
}
func TestWordCardsRepo_Reveal(t *testing.T) {
// Setup temporary file-based SQLite database for this test
tempFile, err := os.CreateTemp("", "test_db_*.db")
assert.NoError(t, err)
tempFile.Close()
defer os.Remove(tempFile.Name())
db, err := sqlx.Connect("sqlite3", tempFile.Name())
assert.NoError(t, err)
defer db.Close()
// Enable foreign key constraints
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
// Apply schema
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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
);
`
_, err = db.Exec(schema)
assert.NoError(t, err)
repo := &RepoProvider{DB: db}
ctx := context.Background()
roomID := "test_room_4"
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
assert.NoError(t, err)
card := &models.WordCard{
RoomID: roomID,
Word: "delta",
Color: models.WordColorRed,
Revealed: false,
Mime: false,
}
err = repo.WordCardsCreate(ctx, card)
assert.NoError(t, err)
// Verify initial state
var revealed bool
err = db.Get(&revealed, "SELECT revealed FROM word_cards WHERE word = ? AND room_id = ?", "delta", roomID)
assert.NoError(t, err)
assert.False(t, revealed)
// Reveal the card
err = repo.WordCardReveal(ctx, "delta", roomID)
assert.NoError(t, err)
// Verify revealed state
err = db.Get(&revealed, "SELECT revealed FROM word_cards WHERE word = ? AND room_id = ?", "delta", roomID)
assert.NoError(t, err)
assert.True(t, revealed)
}
func TestWordCardsRepo_RevealAll(t *testing.T) {
// Setup temporary file-based SQLite database for this test
tempFile, err := os.CreateTemp("", "test_db_*.db")
assert.NoError(t, err)
tempFile.Close()
defer os.Remove(tempFile.Name())
db, err := sqlx.Connect("sqlite3", tempFile.Name())
assert.NoError(t, err)
defer db.Close()
// Enable foreign key constraints
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
// Apply schema
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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
);
`
_, err = db.Exec(schema)
assert.NoError(t, err)
repo := &RepoProvider{DB: db}
ctx := context.Background()
roomID := "test_room_5"
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
assert.NoError(t, err)
cardsToInsert := []*models.WordCard{
{
RoomID: roomID,
Word: "echo",
Color: models.WordColorRed,
Revealed: false,
Mime: false,
},
{
RoomID: roomID,
Color: models.WordColorBlue,
Revealed: false,
Mime: false,
},
}
for _, card := range cardsToInsert {
err = repo.WordCardsCreate(ctx, card)
assert.NoError(t, err)
}
// Verify initial state
var count int
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ? AND revealed = FALSE", roomID)
assert.NoError(t, err)
assert.Equal(t, 2, count)
// Reveal all cards
err = repo.WordCardsRevealAll(ctx, roomID)
assert.NoError(t, err)
// Verify all cards are revealed
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ? AND revealed = FALSE", roomID)
assert.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestWordCardsRepo_DeleteByRoomID(t *testing.T) {
// Setup temporary file-based SQLite database for this test
tempFile, err := os.CreateTemp("", "test_db_*.db")
assert.NoError(t, err)
tempFile.Close()
defer os.Remove(tempFile.Name())
db, err := sqlx.Connect("sqlite3", tempFile.Name())
assert.NoError(t, err)
defer db.Close()
// Enable foreign key constraints
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
// Apply schema
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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
);
`
_, err = db.Exec(schema)
assert.NoError(t, err)
repo := &RepoProvider{DB: db}
ctx := context.Background()
roomID := "test_room_6"
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
assert.NoError(t, err)
cardsToInsert := []*models.WordCard{
{
RoomID: roomID,
Word: "golf",
Color: models.WordColorRed,
Revealed: false,
Mime: false,
},
{
RoomID: roomID,
Word: "hotel",
Color: models.WordColorBlue,
Revealed: false,
Mime: false,
},
}
for _, card := range cardsToInsert {
err = repo.WordCardsCreate(ctx, card)
assert.NoError(t, err)
}
// Verify initial state
var count int
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 2, count)
// Delete cards by room ID
err = repo.WordCardsDeleteByRoomID(ctx, roomID)
assert.NoError(t, err)
// Verify cards are deleted
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 0, count)
}
func TestWordCardsRepo_CascadeDeleteRoom(t *testing.T) {
// Setup temporary file-based SQLite database for this test
tempFile, err := os.CreateTemp("", "test_db_*.db")
assert.NoError(t, err)
tempFile.Close()
defer os.Remove(tempFile.Name())
db, err := sqlx.Connect("sqlite3", tempFile.Name())
assert.NoError(t, err)
defer db.Close()
// Enable foreign key constraints
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
// Apply schema
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator_name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS 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
);
`
_, err = db.Exec(schema)
assert.NoError(t, err)
repo := &RepoProvider{DB: db}
ctx := context.Background()
roomID := "test_room_7"
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
assert.NoError(t, err)
card := &models.WordCard{
RoomID: roomID,
Word: "india",
Color: models.WordColorRed,
Revealed: false,
Mime: false,
}
err = repo.WordCardsCreate(ctx, card)
assert.NoError(t, err)
var count int
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 1, count)
_, err = db.Exec(`DELETE FROM rooms WHERE id = ?`, roomID)
assert.NoError(t, err)
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
assert.NoError(t, err)
assert.Equal(t, 0, count)
}

View File

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; + - redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
- better styles and fluff; - better styles and fluff;
- common auth system between sites; - common auth system between sites;
- signup vs login; +
- passwords (to room and to login); +
=== ===
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right; - show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
- gameover to backlog; - gameover to backlog;
@@ -28,9 +30,12 @@
- ended turn action to backlog; - ended turn action to backlog;
=== ===
- clear indication that model (llm) is thinking / answered; - clear indication that model (llm) is thinking / answered;
- possibly turn markings into parts of names of users (first three letters?); - possibly turn markings into parts of names of users (first three letters?); +
- at game creation list languages and support them at backend; - at game creation list languages and support them at backend; +
- sql ping goroutine with reconnect on fail; - sql ping goroutine with reconnect on fail; +
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words. +
- at the end of the game, all colors should be revealed;
- tracing;
#### sse points #### sse points
- clue sse update; - clue sse update;
@@ -61,10 +66,30 @@
- there is a clue window for a mime before game started; + - 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); - 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; - invite link gets cutoff;
- when llm guesses the word it is not removed from a pool of words making it keep guessing it; - 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); - bot team does not loses their turn after white card (or limit); +
- name check does not work; - name check does not work;
- game did not end when all blue cards were open; - game did not end when all blue cards were open; +
- bot ends a turn after guessing one word only; - 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; +
- backlog shows white word with opposite color;
- bot actions are not recorded; +
- bot recieves opp-color clue because of it ^; +
- old cards are still around; +
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue; - 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; +