Compare commits

...

55 Commits

Author SHA1 Message Date
2180f14850 Enha: bot names avoid collision 2025-07-09 13:47:02 +03:00
502317507b Feat: add password for player 2025-07-09 12:39:16 +03:00
50d042a19d Dep: htmx update 2025-07-08 20:57:28 +03:00
881a01bad0 Fix: sse updates 2025-07-08 20:33:09 +03:00
82b3692919 Enha: js 2025-07-08 18:57:55 +03:00
4be52d8a33 Enha: back to tailwind 2025-07-08 18:53:52 +03:00
49f7642937 Enha: move backlog 2025-07-08 17:47:37 +03:00
8f6a093ea1 Fix: nil check player 2025-07-08 12:22:18 +03:00
587adfbbda Fix: llmapi use same db conn, delete old test; 2025-07-08 10:34:54 +03:00
9a0e8d01ba Chore: code cleaning 2025-07-08 10:17:32 +03:00
ce5d55cc13 Fix: show journal 2025-07-08 10:05:36 +03:00
7ed430d8d7 Fix: dockerfile 2025-07-07 21:27:42 +03:00
723f335f0f Feat: add dockerfile 2025-07-07 16:14:47 +03:00
fe21c3e927 Fix: call the bot if something is wrong 2025-07-07 15:05:10 +03:00
22ddc88d82 Enha: timer package 2025-07-07 15:01:15 +03:00
75651d7f76 Feat: player stats [WIP] 2025-07-07 13:06:03 +03:00
2751b6b9dc Fix: limit 0 because of too early call of notifybot 2025-07-07 12:32:23 +03:00
a2c5f17e30 Fix: session to create player only if does not exist 2025-07-07 09:21:18 +03:00
7ae255cc04 Enha: clear marks by room id 2025-07-07 07:53:12 +03:00
a796b5b5de Chore: todos update 2025-07-06 15:42:41 +03:00
718c9c10be Enha: bot timer 2025-07-06 14:36:18 +03:00
a131183729 Enha: mark with partial name 2025-07-06 13:55:52 +03:00
357f42c354 Enha: db use same connection to avoid db locking 2025-07-06 13:20:28 +03:00
e84941d593 Enha: remove bot without room 2025-07-06 12:57:02 +03:00
a38472a685 Enha: cron for cleaning rooms from players 2025-07-06 10:13:38 +03:00
a685686b32 Fix: load cards to remove old cards from db 2025-07-06 09:32:51 +03:00
9900ebd3dd Fix: bot mime give clue -> update page 2025-07-06 08:39:58 +03:00
f97d91ac74 Fix: llm mime to set openthisturn to 0 2025-07-06 07:47:59 +03:00
e9b9b9e559 Fix: actions to have room_id 2025-07-06 07:31:29 +03:00
f46cbff602 Fix: buildable 2025-07-05 14:40:42 +03:00
6ad251fc47 Enha: journal repo [wip] 2025-07-05 14:32:48 +03:00
27e31603da Feat: add journal [wip] 2025-07-05 14:15:31 +03:00
5b24378956 Fix: linter complains 2025-07-05 13:33:34 +03:00
913228844a Feat: remove rooms with no action 2025-07-05 13:06:02 +03:00
de2cccf66d Fix: save bot actions 2025-07-05 11:30:58 +03:00
eef4b7941b Enha: remove marks 2025-07-05 10:16:17 +03:00
413edae4b6 Feat: card_mark repo 2025-07-05 09:14:45 +03:00
56845e6141 Fix: show-color 2025-07-04 21:56:46 +03:00
3e9a93fbb1 Chore: remove unused WCMap 2025-07-04 21:48:01 +03:00
3af3657c7a Fix: timer 2025-07-04 21:35:59 +03:00
0e2baa1a0f Fix: sql 2025-07-04 21:23:14 +03:00
a4dc8f4bbb Chore: remove seconds tracking inside of settings 2025-07-04 16:58:23 +03:00
2a2bf4e23d Chore: actions methods rename 2025-07-04 14:30:13 +03:00
705881f1ea Enha: settings with room 2025-07-04 14:20:25 +03:00
6be365473c Feat: settings repo 2025-07-04 13:32:59 +03:00
058d501774 Fix: save action on give-clue 2025-07-04 12:44:15 +03:00
c2d6812230 Enha: tx for cron 2025-07-04 12:25:20 +03:00
71a2d9d747 Feat: add cleaner cron 2025-07-04 10:34:08 +03:00
83215f5c14 Fix: test; limit changes on player update 2025-07-04 10:02:39 +03:00
6ca8afd13d Chore: remove unused 2025-07-04 07:04:44 +03:00
8b81e2e2c4 Enha: create tx; cardword test 2025-07-04 07:00:16 +03:00
c9196d3202 Fix: timer templ 2025-07-03 15:21:54 +03:00
788c4efd9e Feat: word card repo 2025-07-03 15:00:06 +03:00
66d7a633c8 Fix: recover bot 2025-07-03 14:42:56 +03:00
9e058b04e0 Refactor: remove pkg mem cache 2025-07-03 14:26:52 +03:00
51 changed files with 2671 additions and 730 deletions

20
Dockerfile Normal file
View File

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

View File

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

2
assets/htmx.min.js vendored

File diff suppressed because one or more lines are too long

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

View File

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

View File

@ -52,11 +52,20 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// w.Header().Set("Access-Control-Allow-Origin", "*")
origin := r.Header.Get("Origin")
if origin == "" {
origin = "*" // Fallback for non-browser clients
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
messageChan := make(NotifierChan)
broker.newClients <- messageChan
defer func() { broker.closingClients <- messageChan }()
ctx := r.Context()
// browser can close sse on its own
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
@ -70,6 +79,12 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
w.(http.Flusher).Flush()
case <-heartbeat.C:
// Send SSE heartbeat comment
if _, err := w.Write([]byte(": heartbeat\n\n")); err != nil {
return // Client disconnected
}
w.(http.Flusher).Flush()
}
}
}
@ -95,7 +110,8 @@ func (broker *Broker) Listen() {
select {
case clientMessageChan <- event:
case <-time.After(patience):
slog.Info("Client was skipped", "clients listening", len(broker.clients))
delete(broker.clients, clientMessageChan)
slog.Info("Client was removed", "clients listening", len(broker.clients))
}
}
}

View File

@ -3,11 +3,11 @@
<html lang="en">
<head>
<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>
<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"/>
<style type="text/css">

View File

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

View File

@ -4,8 +4,7 @@
{{ else if ne .LinkLogin "" }}
{{template "linklogin" .LinkLogin}}
{{ else if not .State.RoomID }}
<div id="hello-user">
<p>data: {{.}} {{.State}} {{.Room}}</p>
<div id="hello-user" class="text-xl py-2">
<p>Hello {{.State.Username}}</p>
</div>
<div id="create-room" class="create-room-div">

View File

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

View File

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

View File

@ -25,7 +25,7 @@
<hr />
{{if .Room.IsRunning}}
<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}}
<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>
@ -48,18 +48,19 @@
<!-- Right Panel -->
{{template "teamlist" .Room.RedTeam}}
</div>
<hr />
<div id="systembox" style="overflow-y: auto; max-height: 100px;">
Server says: <br>
<hr/>
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
bot thought: <br>
<ul>
{{range .Room.LogJournal}}
<li>{{.}}</li>
<li>{{.Username}}: {{.Entry}}</li>
{{end}}
</ul>
</div>
<div sse-swap="journal_{{.Room.ID}}">
bot thoughts
<div>
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
{{template "actionhistory" .Room.ActionHistory}}
</div>
<hr/>
<div id="cardtable">
{{template "cardtable" .Room}}
</div>
@ -77,9 +78,6 @@
<button hx-get="/renotify-bot" hx-swap="none" class="bg-gray-100 text-black px-1 py-1 rounded">Btn in case llm call failed</button>
{{end}}
</div>
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
{{template "actionhistory" .Room.ActionHistory}}
</div>
{{if not .Room.IsRunning}}
<div id="exitbtn">
<button button id="exit-room-btn" type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/exit" hx-target="#ancestor">Exit Room</button>

View File

@ -1,9 +1,6 @@
{{define "roomlist"}}
<div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#ancestor">
{{range .}}
<p>
{{.ID}}
</p>
<div hx-get="/room-join?id={{.ID}}" hx-target="#ancestor" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
<div class="flex justify-between items-center">
<div class="room-info">

View File

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

View File

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

View File

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

160
crons/main.go Normal file
View File

@ -0,0 +1,160 @@
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
}
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)
}
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)
}
// Move to the next room
continue
}
}
if err := tx.Commit(); err != nil {
cm.log.Error("failed to commit transaction", "err", err)
}
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)
}
}

View File

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

View File

@ -5,12 +5,11 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"gralias/models"
"gralias/pkg/cache"
"gralias/utils"
"html/template"
"log/slog"
"net/http"
"strings"
"time"
@ -38,7 +37,6 @@ func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
return
}
cleanName := utils.RemoveSpacesFromStr(username)
// allNames := getAllNames()
allNames, err := repo.PlayerListNames(r.Context())
if err != nil {
abortWithError(w, err.Error())
@ -75,10 +73,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, msg)
return
}
password := r.PostFormValue("password")
var makeplayer bool
roomID := r.PostFormValue("room_id")
// make sure username does not exists
cleanName := utils.RemoveSpacesFromStr(username)
clearPass := utils.RemoveSpacesFromStr(password)
// login user
cookie, err := makeCookie(cleanName, r.RemoteAddr)
if err != nil {
@ -86,15 +86,19 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
return
}
http.SetCookie(w, cookie)
// check if that user was already in db
// userstate, err := loadState(cleanName)
userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
if err != nil || userstate == nil {
log.Debug("making new player", "error", err, "state", userstate)
userstate = models.InitPlayer(cleanName)
makeplayer = true
}
if userstate.Password != clearPass {
log.Error("wrong password", "username", cleanName, "password", clearPass)
abortWithError(w, "wrong password")
return
}
http.SetCookie(w, cookie)
fi := &models.FullInfo{
State: userstate,
}
@ -107,20 +111,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
return
}
// room.PlayerList = append(room.PlayerList, fi.State.Username)
fi.Room = room
fi.List = nil
fi.State.RoomID = &room.ID
if err := repo.PlayerSetRoomID(r.Context(), fi.State.Username, room.ID); err != nil {
if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
abortWithError(w, err.Error())
return
}
// repo.RoomUpdate()
// save full info instead
// if err := saveFullInfo(r.Context(), fi); err != nil {
// abortWithError(w, err.Error())
// return
// }
} else {
log.Debug("no room_id in login")
// fi.List = listRooms(false)
@ -130,19 +126,15 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
return
}
// save state to cache
// if err := saveState(cleanName, userstate); err != nil {
if makeplayer {
userstate.Password = clearPass
if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
// if err := saveFullInfo(r.Context(), fi); err != nil {
log.Error("failed to save state", "error", err)
abortWithError(w, err.Error())
return
}
}
}
// if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
// log.Error("failed to execute base template", "error", err)
// }
http.Redirect(w, r, "/", 302)
}
@ -183,36 +175,17 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
cookie.Secure = false
log.Info("changing cookie domain", "domain", cookie.Domain)
}
// set ctx?
if err := repo.SessionCreate(context.Background(), session); err != nil {
player, err := repo.PlayerGetByName(context.Background(), username)
if err != nil || player == nil {
// make player first, since username is fk to players table
player = models.InitPlayer(username)
if err := repo.PlayerAdd(context.Background(), player); err != nil {
slog.Error("failed to create player", "username", username)
return nil, err
}
// set user in session
if err := cacheSetSession(sessionToken, session); err != nil {
}
if err := repo.SessionCreate(context.Background(), session); err != nil {
return nil, err
}
return cookie, nil
}
//nolint: unused
func cacheGetSession(key string) (*models.Session, error) {
userSessionB, err := cache.MemCache.Get(key)
if err != nil {
return nil, err
}
var us *models.Session
if err := json.Unmarshal(userSessionB, &us); err != nil {
return nil, err
}
return us, nil
}
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

@ -50,7 +50,8 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
return
}
color, exists := fi.Room.WCMap[word]
// color, exists := fi.Room.WCMap[word]
color, exists := fi.Room.FindColor(word)
if !exists {
abortWithError(w, "word is not found")
return
@ -60,7 +61,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
Color: color,
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
}
fi.Room.UpdateCounter()
action := models.Action{
Actor: fi.State.Username,
@ -68,10 +78,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
WordColor: string(color),
Action: models.ActionTypeGuess,
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)
// if opened card is of color of opp team, change turn
oppositeColor := fi.Room.GetOppositeTeamColor()
var clearMarks bool
fi.Room.OpenedThisTurn++
log.Debug("got show-color request", "word", word, "color", color,
"limit", fi.Room.ThisTurnLimit, "opened", fi.Room.OpenedThisTurn,
@ -83,7 +99,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0
fi.Room.ClearMarks()
clearMarks = true
StopTurnTimer(fi.Room.ID)
}
switch string(color) {
@ -94,6 +110,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = oppositeColor
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlack,
@ -102,15 +119,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
clearMarks = true
StopTurnTimer(fi.Room.ID)
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
fi.Room.TeamTurn = oppositeColor
fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0
clearMarks = true
StopTurnTimer(fi.Room.ID)
// check if no cards left => game over
if fi.Room.BlueCounter == 0 {
@ -119,13 +137,13 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = "blue"
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlue,
Action: models.ActionTypeGameOver,
}
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
}
if fi.Room.RedCounter == 0 {
// red won
@ -133,13 +151,13 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = "red"
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver,
}
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
}
default: // same color as the team
// check if game over
@ -148,13 +166,19 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true
fi.Room.TeamWon = fi.State.Team
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver,
}
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
}
}
if clearMarks {
fi.Room.ClearMarks()
if err := repo.CardMarksRemoveByRoomID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove marks", "error", err, "room_id", fi.Room.ID)
}
}
if err := saveFullInfo(r.Context(), fi); err != nil {
@ -186,8 +210,8 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
return
}
color, exists := fi.Room.WCMap[word]
log.Debug("got show-color request", "word", word, "color", color)
color, exists := fi.Room.FindColor(word)
log.Debug("got mark-card request", "word", word, "color", color)
if !exists {
abortWithError(w, "word is not found")
return
@ -205,20 +229,33 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
// Check if the current user already has an active mark on this card
found := false
var newMarks []models.CardMark
for _, mark := range card.Mark {
if mark.Username == fi.State.Username && mark.Active {
for _, mark := range card.Marks {
if mark.Username == fi.State.Username {
found = true
} else {
newMarks = append(newMarks, mark)
}
}
if !found {
newMarks = append(newMarks, models.CardMark{
cm := models.CardMark{
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]
}
if err := saveFullInfo(r.Context(), fi); err != nil {
@ -257,7 +294,14 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
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?
}
_, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false)
if err != nil {
abortWithError(w, err.Error())

View File

@ -40,22 +40,11 @@ func HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
}
fi.State.RoomID = &room.ID
fi.Room = room
// if err := repo.RoomCreate(r.Context(), room); 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 {
if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
log.Error("failed to set room id", "error", err)
abortWithError(w, err.Error())
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, "")
tmpl, err := template.ParseGlob("components/*.html")
if err != nil {
@ -159,6 +148,22 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error())
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.IsOver = false
fi.Room.TeamTurn = "blue"
@ -168,13 +173,53 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
fi.Room.UpdateCounter()
fi.Room.TeamWon = ""
action := models.Action{
RoomID: fi.Room.ID,
CreatedAt: time.Now(),
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: string(fi.State.Team),
Action: models.ActionTypeGameStarted,
}
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())
return
}
@ -201,7 +246,7 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("id")
room, err := repo.RoomGetByID(r.Context(), roomID)
room, err := repo.RoomGetExtended(r.Context(), roomID)
if err != nil {
abortWithError(w, err.Error())
return
@ -226,7 +271,8 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
fi.State.RoomID = &room.ID
fi.Room = room
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())
return
}
@ -278,6 +324,7 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
}
// ===
action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username,
ActorColor: string(fi.State.Team),
WordColor: string(fi.State.Team),
@ -286,21 +333,25 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
Number: num,
}
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.ThisTurnLimit = uint8(guessLimitU64) + 1
if guessLimitU64 == 0 {
fi.Room.ThisTurnLimit = 9
}
fi.Room.OpenedThisTurn = 0
fi.Room.Settings.TurnSecondsLeft = fi.Room.Settings.RoundTime
StartTurnTimer(fi.Room.ID, time.Duration(fi.Room.Settings.RoundTime)*time.Second)
StartTurnTimer(fi.Room.ID, fi.Room.Settings.RoundTime)
log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit)
notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
notifyBotIfNeeded(fi.Room)
// notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, clue+num)
if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error())
return
}
notifyBotIfNeeded(fi.Room)
}
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {

View File

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

View File

@ -6,7 +6,6 @@ import (
"crypto/sha256"
"encoding/base64"
"gralias/models"
"gralias/pkg/cache"
"net/http"
)
@ -29,8 +28,8 @@ func GetSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie(models.AuthCookie)
if err != nil {
msg := "auth failed; failed to get session token from cookies"
log.Debug(msg, "error", err)
// msg := "auth failed; failed to get session token from cookies"
// log.Debug(msg, "error", err)
next.ServeHTTP(w, r)
return
}
@ -71,7 +70,10 @@ func GetSession(next http.Handler) http.Handler {
return
}
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"
log.Debug(msg, "error", err, "token", sessionToken)
next.ServeHTTP(w, r)
@ -81,13 +83,13 @@ func GetSession(next http.Handler) http.Handler {
models.CtxUsernameKey, userSession.Username)
ctx = context.WithValue(ctx,
models.CtxSessionKey, userSession)
if err := cacheSetSession(sessionToken,
userSession); err != nil {
msg := "failed to marshal user session"
log.Warn(msg, "error", err)
next.ServeHTTP(w, r)
return
}
// 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))
})
}

View File

@ -3,70 +3,37 @@ package handlers
import (
"context"
"gralias/models"
"gralias/timer"
"log/slog"
"strconv"
"sync"
"time"
)
type roomTimer struct {
ticker *time.Ticker
done chan bool
}
func StartTurnTimer(roomID string, timeLeft uint32) {
logger := slog.Default().With("room_id", roomID)
var (
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:
onTurnEnd := func(ctx context.Context, roomID string) {
room, err := repo.RoomGetByID(context.Background(), roomID)
if err != nil {
log.Error("failed to get room by id", "error", err)
StopTurnTimer(roomID)
logger.Error("failed to get room by id", "error", err)
return
}
if room.Settings.TurnSecondsLeft <= 0 {
log.Info("turn time is over", "room_id", roomID)
logger.Info("turn time is over")
room.ChangeTurn()
room.MimeDone = false
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)
StopTurnTimer(roomID)
return
}
room.Settings.TurnSecondsLeft--
// if err := saveRoom(room); err != nil {
// log.Error("failed to save room", "error", err)
// }
notify(models.NotifyRoomUpdatePrefix+room.ID, "")
onTick := func(ctx context.Context, roomID string, currentLeft uint32) {
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10))
}
}
}()
timer.StartTurnTimer(context.Background(), roomID, timeLeft, onTurnEnd, onTick, logger)
}
func StopTurnTimer(roomID string) {
mu.Lock()
defer mu.Unlock()
if timer, exists := timers[roomID]; exists {
timer.ticker.Stop()
close(timer.done)
delete(timers, roomID)
}
timer.StopTurnTimer(roomID)
}

View File

@ -1,13 +1,13 @@
package llmapi
import (
"encoding/json"
"context"
"errors"
"fmt"
"gralias/broker"
"gralias/config"
"gralias/models"
"gralias/pkg/cache"
"gralias/repos"
"io"
"log/slog"
"net/http"
@ -19,12 +19,13 @@ import (
var (
// botname -> channel
repo = repos.RP
SignalChanMap = make(map[string]chan bool)
DoneChanMap = make(map[string]chan bool)
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
MimePrompt = `we are playing alias;\nyou are a mime (player who gives a clue of one noun word and number of cards you expect them to open) of the %s team (people who would guess by your clue want open the %s cards);\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the game info in json:\n%s`
GuesserPrompt = `we are playing alias;\nyou are to guess words of the %s team (you want open %s cards) by given clue and a number of meant guesses;\nplease return your guesses and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guesses\": [\"word1\", \"word2\", ...],\n\"could_be\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the cards (and other info), you need to choose revealed==false words:\n%s`
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that left:\n%v`
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that you can choose from:\n%v`
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;`
)
@ -49,25 +50,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 {
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",
color, "exists", exists, "limit", room.ThisTurnLimit,
"opened", room.OpenedThisTurn)
@ -75,8 +60,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
return fmt.Errorf("fn: checkGuess; %s does not exists", word)
}
room.RevealSpecificWord(word)
if err := repo.WordCardReveal(context.Background(), word, room.ID); err != nil {
b.log.Error("failed to reveal word in db", "word", word, "color",
color, "exists", exists, "limit", room.ThisTurnLimit,
"opened", room.OpenedThisTurn)
return err
}
room.UpdateCounter()
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: b.Team,
WordColor: string(color),
@ -96,6 +88,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.MimeDone = false
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
b.StopTurnTimer()
}
switch string(color) {
case string(models.WordColorBlack):
@ -106,18 +99,21 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: string(b.Team),
WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver,
}
room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
case string(models.WordColorWhite), string(oppositeColor):
// end turn
room.TeamTurn = oppositeColor
room.MimeDone = false
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
b.StopTurnTimer()
}
// check if no cards left => game over
if room.BlueCounter == 0 {
@ -128,12 +124,14 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: string(b.Team),
WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver,
}
room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
}
if room.RedCounter == 0 {
// red won
@ -143,14 +141,31 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0
room.ThisTurnLimit = 0
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: string(b.Team),
WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver,
}
room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
}
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)
err = fmt.Errorf("fn: checkGuess, failed to save room; err: %w", err)
return err
@ -162,18 +177,31 @@ func (b *Bot) BotMove() {
// botJournalName := models.NotifyJournalPrefix + b.RoomID
b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role)
// 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 {
b.log.Error("bot loop", "error", err)
return
}
eventName := models.NotifyBacklogPrefix + room.ID
// eventName := models.NotifyBacklogPrefix + room.ID
eventName := models.NotifyRoomUpdatePrefix + room.ID
eventPayload := ""
defer func() { // save room
// just incase, get the room once more
// room, err = repo.RoomGetExtended(context.Background(), b.RoomID)
// if err != nil {
// b.log.Error("bot loop", "error", err)
// return
// }
if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err)
return
}
if botName := room.WhichBotToMove(); botName != "" {
b.log.Debug("notifying bot", "name", botName)
SignalChanMap[botName] <- true
b.log.Debug("after sending the signal", "name", botName)
}
broker.Notifier.Notifier <- broker.NotificationEvent{
EventName: eventName,
Payload: eventPayload,
@ -185,13 +213,21 @@ func (b *Bot) BotMove() {
// call llm
llmResp, err := b.CallLLM(prompt)
if err != nil {
room.LogJournal = append(room.LogJournal, b.BotName+" send call got error: "+err.Error())
room.LogJournal = append(room.LogJournal, models.Journal{
Entry: "send call got error: " + err.Error(),
Username: b.BotName,
RoomID: room.ID,
})
b.log.Error("bot loop", "error", err)
return
}
tempMap, err := b.LLMParser.ParseBytes(llmResp)
if err != nil {
room.LogJournal = append(room.LogJournal, b.BotName+" parse resp got error: "+err.Error())
room.LogJournal = append(room.LogJournal, models.Journal{
Entry: "parse resp got error: " + err.Error(),
Username: b.BotName,
RoomID: room.ID,
})
b.log.Error("bot loop", "error", err, "resp", string(llmResp))
return
}
@ -207,6 +243,7 @@ func (b *Bot) BotMove() {
return
}
action := models.Action{
RoomID: room.ID,
Actor: b.BotName,
ActorColor: b.Team,
WordColor: b.Team,
@ -216,55 +253,73 @@ func (b *Bot) BotMove() {
}
room.ActionHistory = append(room.ActionHistory, action)
room.MimeDone = true
meant := fmt.Sprintf(b.BotName+" meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
room.LogJournal = append(room.LogJournal, meant)
entry := fmt.Sprintf("meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
lj := models.Journal{
Entry: entry,
Username: b.BotName,
RoomID: room.ID,
}
room.LogJournal = append(room.LogJournal, lj)
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
eventPayload = mimeResp.Clue + mimeResp.Number
guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8)
if err != nil {
b.log.Warn("failed to parse bot given limit", "mimeResp", mimeResp, "bot_name", b.BotName)
}
room.OpenedThisTurn = 0 // in case it is not
room.ThisTurnLimit = uint8(guessLimitU64)
if room.ThisTurnLimit == 0 {
b.log.Warn("turn limit is 0", "mimeResp", mimeResp)
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 {
b.log.Error("failed to save room", "error", err)
return
}
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)
if !ok || guess == "" {
b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName)
}
if err := b.checkGuess(guess, room); err != nil {
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
msg := fmt.Sprintf("failed to check guess; mimeResp: %v; bot_name: %s; guess: %s; error: %v", tempMap, b.BotName, guess, err)
room.LogJournal = append(room.LogJournal, msg)
b.log.Error("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
entry := fmt.Sprintf("failed to check guess; mimeResp: %v; guess: %s; error: %v", tempMap, guess, err)
lj := models.Journal{
Entry: entry,
Username: b.BotName,
RoomID: room.ID,
}
room.LogJournal = append(room.LogJournal, lj)
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
}
b.log.Info("guesser resp log", "guesserResp", tempMap)
couldBe, err := convertToSliceOfStrings(tempMap["could_be"])
if err != nil {
b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName)
}
room.LogJournal = append(room.LogJournal, fmt.Sprintf("%s also considered this: %v", b.BotName, couldBe))
eventName = models.NotifyRoomUpdatePrefix + room.ID
eventPayload = ""
// TODO: needs to decide if it wants to open the next cardword or end turn
// or end turn on limit
entry := fmt.Sprintf("%s guessed: %s; also considered this: %v", b.BotName, guess, couldBe)
lj := models.Journal{
Entry: entry,
Username: b.BotName,
RoomID: room.ID,
}
room.LogJournal = append(room.LogJournal, lj)
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
default:
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
return
}
if botName := room.WhichBotToMove(); botName != "" {
b.log.Debug("notifying bot", "name", botName)
SignalChanMap[botName] <- true
}
}
// StartBot
@ -291,9 +346,32 @@ func RemoveBot(botName string, room *models.Room) error {
delete(SignalChanMap, botName)
// remove role from room
room.RemovePlayer(botName)
slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
if err := repo.PlayerDelete(context.Background(), botName); err != nil {
slog.Error("failed to remove bot player", "name", botName, "room_id", room.ID)
return err
}
return saveRoom(room)
}
func RemoveBotNoRoom(botName string) error {
// 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)
// remove role from room
return repo.PlayerDelete(context.Background(), botName)
}
// EndBot
func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) {
@ -316,7 +394,8 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
bot.LLMParser = NewOpenRouterParser(bot.log)
}
// add to room
room, err := getRoomByID(bot.RoomID)
// room, err := getRoomByID(bot.RoomID)
room, err := repo.RoomGetExtended(context.Background(), bot.RoomID)
if err != nil {
return nil, err
}
@ -360,9 +439,11 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
if err := saveRoom(room); err != nil {
return nil, err
}
if !recovery {
if err := saveBot(bot); err != nil {
return nil, err
}
}
// buffered channel to send to it in the same goroutine
SignalChanMap[bot.BotName] = make(chan bool, 1)
DoneChanMap[bot.BotName] = make(chan bool, 1)
@ -371,35 +452,27 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
}
func saveBot(bot *Bot) error {
key := models.CacheBotPredix + bot.RoomID + bot.BotName
data, err := json.Marshal(bot)
if err != nil {
return err
}
cache.MemCache.Set(key, data)
return nil
}
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
// key := models.CacheBotPredix + bot.RoomID + bot.BotName
// data, err := json.Marshal(bot)
// if err != nil {
// return err
// // }
// cache.MemCache.Set(key, data)
botPlayer := bot.ToPlayer()
return repo.PlayerAdd(context.Background(), botPlayer)
}
func saveRoom(room *models.Room) error {
key := models.CacheRoomPrefix + room.ID
data, err := json.Marshal(room)
if err != nil {
return err
}
cache.MemCache.Set(key, data)
return nil
// key := models.CacheRoomPrefix + room.ID
// data, err := json.Marshal(room)
// if err != nil {
// return err
// }
// cache.MemCache.Set(key, data)
// ------------
// 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 {

View File

@ -2,6 +2,7 @@ package llmapi
import (
"gralias/config"
"gralias/models"
"log/slog"
)
@ -84,3 +85,13 @@ type Bot struct {
// SignalsCh 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,
}
}

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

13
main.go
View File

@ -3,8 +3,9 @@ package main
import (
"context"
"gralias/config"
"gralias/crons"
"gralias/handlers"
"gralias/pkg/cache"
"gralias/repos"
"log/slog"
"net/http"
"os"
@ -24,7 +25,7 @@ func ListenToRequests(port string) *http.Server {
server := &http.Server{
Handler: handlers.LogRequests(handlers.GetSession(mux)),
Addr: ":" + port,
ReadTimeout: time.Second * 5, // TODO: to cfg
// ReadTimeout: time.Second * 5, // does this timeout conflict with sse connection?
WriteTimeout: 0, // sse streaming
}
fs := http.FileServer(http.Dir("assets/"))
@ -61,14 +62,17 @@ func main() {
// Setup graceful shutdown
stop := make(chan os.Signal, 1)
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)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}()
<-stop
slog.Info("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@ -76,5 +80,4 @@ func main() {
if err := server.Shutdown(ctx); err != nil {
slog.Error("server shutdown failed", "error", err)
}
cache.MemCache.BackupNow()
}

View File

@ -21,10 +21,11 @@ CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT, -- nullable
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime'
is_bot BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES rooms(id)
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE word_cards (
@ -34,15 +35,15 @@ CREATE TABLE word_cards (
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)
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE 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)
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 (
@ -55,7 +56,7 @@ CREATE TABLE actions (
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)
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE settings (
@ -65,7 +66,7 @@ CREATE TABLE settings (
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)
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE sessions(
@ -74,5 +75,32 @@ CREATE TABLE sessions(
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)
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,
player_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 (player_username) REFERENCES players(username) ON DELETE CASCADE
);

View File

@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"gralias/utils"
"strings"
"time"
"github.com/rs/xid"
@ -98,14 +99,14 @@ type Action struct {
Word string `json:"word" db:"word"`
WordColor string `json:"word_color" db:"word_color"`
Number string `json:"number_associated" db:"number_associated"`
CreatedAt time.Time `json:"created_at" db:"-"`
CreatedAtUnix int64 `db:"created_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type Player struct {
ID uint32 `json:"id" db:"id"`
RoomID *string `json:"room_id" db:"room_id"`
Username string `json:"username" db:"username"`
Password string `json:"-" db:"password"`
Team UserTeam `json:"team" db:"team"`
Role UserRole `json:"role" db:"role"`
IsBot bool `json:"is_bot" db:"is_bot"`
@ -126,8 +127,31 @@ type BotPlayer struct {
}
type CardMark struct {
Username string
Active bool
CardID uint32 `db:"card_id"`
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"`
PlayerUsername string `db:"player_username"`
GamesPlayed int `db:"games_played"`
GamesWon int `db:"games_won"`
GamesLost int `db:"games_lost"`
OpenedOppositeWords int `db:"opened_opposite_words"`
OpenedWhiteWords int `db:"opened_white_words"`
OpenedBlackWords int `db:"opened_black_words"`
MimeWinrate float64 `db:"mime_winrate"`
GuesserWinrate float64 `db:"guesser_winrate"`
PlayedAsMime int `db:"played_as_mime"`
PlayedAsGuesser int `db:"played_as_guesser"`
}
type Room struct {
@ -150,16 +174,23 @@ type Room struct {
RedTeam Team `db:"-"`
BlueTeam Team `db:"-"`
Cards []WordCard `db:"-"`
WCMap map[string]WordColor `db:"-"`
BotMap map[string]BotPlayer `db:"-"`
Mark CardMark `db:"-"`
LogJournal []string `db:"-"`
LogJournal []Journal `db:"-"`
Settings GameSettings `db:"-"`
}
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() {
for i, _ := range r.Cards {
r.Cards[i].Mark = []CardMark{}
for i := range r.Cards {
r.Cards[i].Marks = []CardMark{}
}
}
@ -279,7 +310,7 @@ func getGuesser(m map[string]BotPlayer, team UserTeam) string {
func (r *Room) WhichBotToMove() string {
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning,
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime)
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime, "card-limit:", r.ThisTurnLimit, "opened:", r.OpenedThisTurn)
if !r.IsRunning {
return ""
}
@ -366,12 +397,14 @@ func (r *Room) GuesserView() {
}
}
func (r *Room) RevealSpecificWord(word string) {
func (r *Room) RevealSpecificWord(word string) uint32 {
for i, card := range r.Cards {
if card.Word == word {
r.Cards[i].Revealed = true
return card.ID
}
}
return 0
}
type WordCard struct {
@ -381,7 +414,7 @@ type WordCard struct {
Color WordColor `json:"color" db:"color"`
Revealed bool `json:"revealed" db:"revealed"`
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
@ -390,7 +423,7 @@ type GameSettings struct {
RoomID string `db:"room_id"`
Language string `json:"language" example:"en" form:"language" db:"language"`
RoomPass string `json:"room_pass" db:"room_pass"`
TurnSecondsLeft uint32 `db:"-"`
RoundTime uint32 `json:"round_time" db:"turn_time"`
CreatedAt time.Time `db:"created_at"`
}
@ -432,7 +465,6 @@ type FullInfo struct {
}
func (f *FullInfo) ExitRoom() *Room {
// f.Room.PlayerList = utils.RemoveFromSlice(f.State.Username, f.Room.PlayerList)
f.Room.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers)
f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers)
if f.Room.RedTeam.Mime == f.State.Username {
@ -441,8 +473,10 @@ func (f *FullInfo) ExitRoom() *Room {
if f.Room.BlueTeam.Mime == f.State.Username {
f.Room.BlueTeam.Mime = ""
}
// f.State.ExitRoom()
f.State.RoomID = nil
resp := f.Room
f.Room = nil
return resp
}
// =======

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 {
ListActions(ctx context.Context, roomID string) ([]models.Action, error)
CreateAction(ctx context.Context, roomID string, action *models.Action) error
GetLastClue(ctx context.Context, roomID string) (*models.Action, error)
DeleteActionsByRoomID(ctx context.Context, roomID string) error
ActionList(ctx context.Context, roomID string) ([]models.Action, error)
ActionCreate(ctx context.Context, action *models.Action) error
ActionGetLastClue(ctx context.Context, roomID string) (*models.Action, 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{}
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 {
return nil, err
}
for i := range actions {
actions[i].CreatedAt = time.Unix(0, actions[i].CreatedAtUnix)
}
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)
_, 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
}
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{}
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 {
return nil, err
}
action.CreatedAt = time.Unix(0, action.CreatedAtUnix)
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)
_, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id = ?`, roomID)
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"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
)
func setupActionsTestDB(t *testing.T) (*sqlx.DB, func()) {
@ -24,7 +24,7 @@ func setupActionsTestDB(t *testing.T) (*sqlx.DB, func()) {
word TEXT,
word_color TEXT,
number_associated TEXT,
created_at INTEGER
created_at TIMESTAMP
);
`
_, 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)
defer teardown()
@ -50,9 +50,10 @@ func TestActionsRepo_CreateAction(t *testing.T) {
WordColor: "red",
Number: "3",
CreatedAt: time.Now(),
RoomID: roomID,
}
err := repo.CreateAction(context.Background(), roomID, action)
err := repo.ActionCreate(context.Background(), action)
assert.NoError(t, err)
var retrievedAction models.Action
@ -75,6 +76,7 @@ func TestActionsRepo_ListActions(t *testing.T) {
Word: "apple",
WordColor: "red",
Number: "3",
RoomID: roomID,
CreatedAt: time.Now().Add(-2 * time.Second),
}
action2 := &models.Action{
@ -84,15 +86,16 @@ func TestActionsRepo_ListActions(t *testing.T) {
Word: "banana",
WordColor: "blue",
Number: "0",
RoomID: roomID,
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)
_, 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)
actions, err := repo.ListActions(context.Background(), roomID)
actions, err := repo.ActionList(context.Background(), roomID)
assert.NoError(t, err)
assert.Len(t, actions, 2)
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),
}
_, 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)
_, 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)
_, 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)
lastClue, err := repo.GetLastClue(context.Background(), roomID)
lastClue, err := repo.ActionGetLastClue(context.Background(), roomID)
assert.NoError(t, err)
assert.NotNil(t, lastClue)
assert.Equal(t, action2.Word, lastClue.Word)
@ -164,10 +167,10 @@ func TestActionsRepo_DeleteActionsByRoomID(t *testing.T) {
Number: "3",
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)
err = repo.DeleteActionsByRoomID(context.Background(), roomID)
err = repo.ActionDeleteByRoomID(context.Background(), roomID)
assert.NoError(t, err)
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 room_id = ?;", roomID)
return err
}

35
repos/journal.go Normal file
View File

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

View File

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

42
repos/player_stats.go Normal file
View File

@ -0,0 +1,42 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
type PlayerStatsRepo interface {
GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error)
UpdatePlayerStats(ctx context.Context, stats *models.PlayerStats) error
CreatePlayerStats(ctx context.Context, username string) error
}
func (p *RepoProvider) GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error) {
stats := &models.PlayerStats{}
err := sqlx.GetContext(ctx, p.DB, stats, "SELECT * FROM player_stats WHERE player_username = ?", username)
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
WHERE player_username = :player_username`, stats)
return err
}
func (p *RepoProvider) CreatePlayerStats(ctx context.Context, username string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "INSERT INTO player_stats (player_username) VALUES (?)", username)
return err
}

View File

@ -2,6 +2,7 @@ package repos
import (
"context"
"fmt"
"gralias/models"
"github.com/jmoiron/sqlx"
@ -11,11 +12,14 @@ type PlayersRepo interface {
PlayerGetByName(ctx context.Context, username string) (*models.Player, error)
PlayerAdd(ctx context.Context, player *models.Player) error
PlayerUpdate(ctx context.Context, player *models.Player) error
PlayerDelete(ctx context.Context, roomID, username string) error
PlayerSetRoomID(ctx context.Context, username, roomID string) error
PlayerDelete(ctx context.Context, username string) error
PlayerSetRoomID(ctx context.Context, roomID, username string) error
PlayerExitRoom(ctx context.Context, username string) error
PlayerListNames(ctx context.Context) ([]string, error)
PlayerList(ctx context.Context, isBot bool) ([]models.Player, error)
PlayerListAll(ctx context.Context) ([]models.Player, error)
PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error)
PlayerGetMaxID(ctx context.Context) (uint32, error)
}
func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
@ -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) {
var player models.Player
err := sqlx.GetContext(ctx, p.DB, &player, "SELECT id, room_id, username, team, role, is_bot FROM players WHERE username = ?", username)
err := sqlx.GetContext(ctx, p.DB, &player, "SELECT id, room_id, username, team, role, is_bot, password FROM players WHERE username = ?", username)
if err != nil {
return nil, err
}
@ -41,33 +45,40 @@ func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*m
func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)",
player.RoomID, player.Username, player.Team, player.Role, player.IsBot)
_, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot, password) VALUES (?, ?, ?, ?, ?, ?)",
player.RoomID, player.Username, player.Team, player.Role, player.IsBot, player.Password)
return err
}
func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "UPDATE players SET room_id = ?, username = ?, team = ?, role = ?, is_bot = ? WHERE id = ?",
player.RoomID, player.Username, player.Team, player.Role, player.IsBot, player.ID)
_, err := db.ExecContext(ctx, "UPDATE players SET team = ?, role = ? WHERE username = ?;",
player.Team, player.Role, player.Username)
return err
}
func (p *RepoProvider) PlayerDelete(ctx context.Context, roomID, username string) error {
func (p *RepoProvider) PlayerDelete(ctx context.Context, username string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "DELETE FROM players WHERE room_id = ? AND username = ?", roomID, username)
_, err := db.ExecContext(ctx, "DELETE FROM players WHERE username = ?", username)
return err
}
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)
_, 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
}
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 {
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
}
@ -85,3 +96,31 @@ func (p *RepoProvider) PlayerList(ctx context.Context, isBot bool) ([]models.Pla
}
return players, nil
}
func (p *RepoProvider) PlayerGetMaxID(ctx context.Context) (uint32, error) {
var maxID uint32
err := sqlx.GetContext(ctx, p.DB, &maxID, "SELECT COALESCE(MAX(id), 0) FROM players")
if err != nil {
return 0, err
}
return maxID, nil
}
func (p *RepoProvider) PlayerListAll(ctx context.Context) ([]models.Player, error) {
var players []models.Player
query := "SELECT id, room_id, username, team, role, is_bot FROM players;"
err := sqlx.SelectContext(ctx, p.DB, &players, query)
if err != nil {
return nil, err
}
return players, nil
}
func (p *RepoProvider) PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error) {
var players []models.Player
err := sqlx.SelectContext(ctx, p.DB, &players, "SELECT id, room_id, username, team, role, is_bot FROM players WHERE room_id = ?", roomID)
if err != nil {
return nil, err
}
return players, nil
}

View File

@ -6,8 +6,8 @@ import (
"testing"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
)
func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
@ -19,6 +19,7 @@ func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT,
username TEXT,
password TEXT NOT NULL DEFAULT '',
team TEXT,
role TEXT,
is_bot BOOLEAN
@ -98,7 +99,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)
assert.NoError(t, err)
err = repo.PlayerDelete(context.Background(), *player.RoomID, player.Username)
err = repo.PlayerDelete(context.Background(), player.Username)
assert.NoError(t, err)
var count int
@ -106,3 +107,4 @@ func TestPlayersRepo_DeletePlayer(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, 0, count)
}

View File

@ -2,6 +2,7 @@ package repos
import (
"context"
"fmt"
"gralias/models"
"github.com/jmoiron/sqlx"
@ -53,10 +54,6 @@ func (p *RepoProvider) RoomCreate(ctx context.Context, r *models.Room) error {
func (p *RepoProvider) RoomDeleteByID(ctx context.Context, id string) error {
db := getDB(ctx, p.DB)
_, 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
}
@ -74,16 +71,21 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
room := &models.Room{}
err := sqlx.GetContext(ctx, p.DB, room, `SELECT * FROM rooms WHERE id = ?`, id)
if err != nil {
err = fmt.Errorf("failed to get room; %w", err)
return nil, err
}
// Get players
players := []*models.Player{}
err = sqlx.SelectContext(ctx, p.DB, &players, `SELECT * FROM players WHERE room_id = ?`, id)
if err != nil {
err = fmt.Errorf("failed to get players; %w", err)
return nil, err
}
room.RedTeam.Color = string(models.UserTeamRed)
room.BlueTeam.Color = string(models.UserTeamBlue)
if room.BotMap == nil {
room.BotMap = make(map[string]models.BotPlayer)
}
for _, player := range players {
if player.Team == models.UserTeamRed {
if player.Role == models.UserRoleMime {
@ -99,9 +101,6 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
}
}
if player.IsBot {
if room.BotMap == nil {
room.BotMap = make(map[string]models.BotPlayer)
}
room.BotMap[player.Username] = models.BotPlayer{
Role: player.Role,
Team: player.Team,
@ -112,6 +111,7 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
wordCards := []models.WordCard{}
err = sqlx.SelectContext(ctx, p.DB, &wordCards, `SELECT * FROM word_cards WHERE room_id = ?`, id)
if err != nil {
err = fmt.Errorf("failed to get cards; %w", err)
return nil, err
}
room.Cards = wordCards
@ -119,6 +119,7 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
actions := []models.Action{}
err = sqlx.SelectContext(ctx, p.DB, &actions, `SELECT * FROM actions WHERE room_id = ? ORDER BY created_at ASC`, id)
if err != nil {
err = fmt.Errorf("failed to get actions; %w", err)
return nil, err
}
room.ActionHistory = actions
@ -126,8 +127,16 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
settings := &models.GameSettings{}
err = sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, id)
if err != nil {
err = fmt.Errorf("failed to get settings; %w", err)
return nil, err
}
room.Settings = *settings
// get log jounral
journals := []models.Journal{}
err = sqlx.SelectContext(ctx, p.DB, &journals, `SELECT id, created_at, entry, username, room_id FROM journal WHERE room_id = ? ORDER BY created_at ASC`, room.ID)
if err != nil {
return nil, err
}
room.LogJournal = journals
return room, nil
}

View File

@ -7,14 +7,18 @@ import (
"time"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
)
func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
db, err := sqlx.Connect("sqlite3", ":memory:")
assert.NoError(t, err)
// Enable foreign key constraints for SQLite
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
schema := `
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
@ -33,6 +37,47 @@ func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT NOT NULL,
@ -40,7 +85,16 @@ func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
room_pass TEXT NOT NULL DEFAULT '',
turn_time INTEGER NOT NULL DEFAULT 60,
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)
@ -95,6 +149,7 @@ func TestRoomsRepo_CreateRoom(t *testing.T) {
assert.Equal(t, room.Settings.Language, retrievedSettings.Language)
assert.Equal(t, room.Settings.RoundTime, retrievedSettings.RoundTime)
assert.Equal(t, room.Settings.RoomPass, retrievedSettings.RoomPass)
assert.Equal(t, room.Settings.RoundTime, retrievedSettings.RoundTime)
}
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)
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)
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)
assert.NoError(t, err)
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) {
@ -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)
_, 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)
@ -298,5 +369,6 @@ func TestRoomsRepo_UpdateRoom(t *testing.T) {
var updatedSettings models.GameSettings
err = db.Get(&updatedSettings, "SELECT * FROM settings WHERE room_id = ?", room.ID)
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

83
timer/timer.go Normal file
View File

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

View File

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