Compare commits

...

64 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
873c35ab08 Enha: roomid confusion 2025-07-03 14:11:52 +03:00
3fa0d608de Chore: template update 2025-07-03 13:17:41 +03:00
d8338fe382 Feat: nullable roomid 2025-07-03 12:29:39 +03:00
2a593739ae Enha: nullable roomID for player [WIP] 2025-07-03 11:00:33 +03:00
c82439d43a Feat: db reconnect 2025-07-03 10:39:14 +03:00
e02554b181 Enha: use of sql sessions 2025-07-03 08:15:54 +03:00
130ed3763b Fix: unittests 2025-07-02 19:00:39 +03:00
a438d5b665 Feat: session db methods and try at tx 2025-07-02 16:47:17 +03:00
8d159baad7 Feat: session table and interface 2025-07-02 16:29:52 +03:00
56 changed files with 3055 additions and 958 deletions

1
.gitignore vendored
View File

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

20
Dockerfile Normal file
View File

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

View File

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

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

View File

@ -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("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*") // w.Header().Set("Access-Control-Allow-Origin", "*")
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) messageChan := make(NotifierChan)
broker.newClients <- messageChan broker.newClients <- messageChan
defer func() { broker.closingClients <- messageChan }() defer func() { broker.closingClients <- messageChan }()
ctx := r.Context() ctx := r.Context()
// browser can close sse on its own
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -70,6 +79,12 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
w.(http.Flusher).Flush() 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 { select {
case clientMessageChan <- event: case clientMessageChan <- event:
case <-time.After(patience): 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"> <html lang="en">
<head> <head>
<title>Alias</title> <title>Alias</title>
<script src="/assets/helpers.js"></script>
<script src="/assets/htmx.min.js"></script>
<script src="/assets/htmx.sse.js"></script>
<script src="/assets/tailwind.css"></script> <script src="/assets/tailwind.css"></script>
<link rel="stylesheet" href="/assets/style.css"/> <link rel="stylesheet" href="/assets/style.css"/>
<script src="/assets/htmx.min.js"></script>
<script src="/assets/htmx.sse.js"></script>
<script src="/assets/helpers.js"></script>
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/> <meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/> <link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
<style type="text/css"> <style type="text/css">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,6 @@
{{define "roomlist"}} {{define "roomlist"}}
<div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#ancestor"> <div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#ancestor">
{{range .}} {{range .}}
<p>
{{.ID}}
</p>
<div hx-get="/room-join?id={{.ID}}" hx-target="#ancestor" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors"> <div hx-get="/room-join?id={{.ID}}" hx-target="#ancestor" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="room-info"> <div class="room-info">
@ -12,7 +9,7 @@
<span class="font-medium text-gray-700">{{.CreatorName}}</span> <span class="font-medium text-gray-700">{{.CreatorName}}</span>
</div> </div>
<div class="mt-1 flex items-center gap-3"> <div class="mt-1 flex items-center gap-3">
<span class="px-2 py-1 text-xs font-medium rounded-full {{if .IsGameRunning}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-600{{end}}"> <span class="px-2 py-1 text-xs font-medium rounded-full {{if .IsRunning}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-600{{end}}">
{{if .IsRunning}}Game Active{{else}}Waiting Room{{end}} {{if .IsRunning}}Game Active{{else}}Waiting Room{{end}}
</span> </span>
</div> </div>

View File

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

View File

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

View File

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

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

Binary file not shown.

View File

@ -24,64 +24,19 @@ func createRoom(ctx context.Context, req *models.RoomReq) (*models.Room, error)
return room, nil return room, nil
} }
// // DEPRECATED func saveFullInfo(ctx context.Context, fi *models.FullInfo) error {
// 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(fi *models.FullInfo) error {
// INFO: no transactions; so case is possible where first object is updated but the second is not // INFO: no transactions; so case is possible where first object is updated but the second is not
if err := repo.PlayerUpdate(fi.State); err != nil { if fi.State == nil {
return errors.New("player is nil")
}
if err := repo.PlayerUpdate(ctx, fi.State); err != nil {
return err return err
} }
log.Debug("saved user state", "state", fi.State) log.Debug("saved user state", "state", fi.State)
if err := repo.RoomUpdate(context.Background(), fi.Room); err != nil { // save or update
// fi.Room.Cards
// fi.Room.WCMap
if err := repo.RoomUpdate(ctx, fi.Room); err != nil {
return err return err
} }
return nil return nil
@ -95,81 +50,71 @@ func notifyBotIfNeeded(room *models.Room) {
} }
} }
// cache
// func saveState(username string, state *models.UserState) error {
// key := models.CacheStatePrefix + username
// data, err := json.Marshal(state)
// if err != nil {
// return err
// }
// memcache.Set(key, data)
// return nil
// }
// func getAllNames() []string {
// names := []string{}
// // will not scale
// session := &models.Session{}
// // filter by key size only sessions
// for _, name := range wholeMemStore {
// // xid is 20 in len
// if len(k) != 20 {
// continue
// }
// if err := json.Unmarshal(v, &session); err != nil {
// log.Error("failed to unmarshal", "error", err)
// continue
// }
// names = append(names, session.Username)
// }
// return names
// }
// can room exists without state? I think no // can room exists without state? I think no
func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) { func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
resp := &models.FullInfo{} resp := &models.FullInfo{}
// state, err := getStateByCtx(ctx)
// if err != nil {
// return nil, err
// }
state, err := getPlayerByCtx(ctx) state, err := getPlayerByCtx(ctx)
if err != nil {
return nil, err
}
resp.State = state resp.State = state
if state.RoomID == "" { if state.RoomID == nil || *state.RoomID == "" {
log.Debug("returning state without room", "username", state.Username)
return resp, nil return resp, nil
} }
// room, err := getRoomByID(state.RoomID) // room, err := getRoomByID(state.RoomID)
room, err := repo.RoomGetByID(ctx, state.RoomID) room, err := repo.RoomGetExtended(ctx, *state.RoomID)
// room, err := repo.RoomGetByID(ctx, *state.RoomID)
if err != nil { if err != nil {
// room was deleted; remove it from player;
log.Warn("failed to find room despite knowing room_id;", log.Warn("failed to find room despite knowing room_id;",
"room_id", state.RoomID) "room_id", state.RoomID, "error", err)
state.Team = models.UserTeamNone
state.Role = models.UserRoleNone
if err := repo.PlayerExitRoom(ctx, state.Username); err != nil {
log.Warn("failed to exit room", "error", err,
"room_id", state.RoomID, "username", state.Username)
return resp, err
}
return nil, err return nil, err
} }
// get card_marks
if room.IsRunning && room.MimeDone {
if err := fillCardMarks(ctx, room); err != nil {
log.Warn("failed to fill card marks", "error", err,
"room_id", state.RoomID, "username", state.Username)
return nil, err
}
}
resp.Room = room resp.Room = room
return resp, nil return resp, nil
} }
func fillCardMarks(ctx context.Context, room *models.Room) error {
marks, err := repo.CardMarksByRoomID(ctx, room.ID)
if err != nil {
log.Warn("failed to fetch card marks by room_id", "room_id", room.ID, "error", err)
return err
}
for i, card := range room.Cards {
for _, mark := range marks {
if mark.CardID == card.ID {
room.Cards[i].Marks = append(room.Cards[i].Marks, mark)
}
}
}
return nil
}
func getPlayerByCtx(ctx context.Context) (*models.Player, error) { func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
username, ok := ctx.Value(models.CtxUsernameKey).(string) username, ok := ctx.Value(models.CtxUsernameKey).(string)
if !ok { if !ok {
log.Debug("no username in ctx") log.Debug("no username in ctx")
return &models.Player{}, errors.New("no username in ctx") return &models.Player{}, errors.New("no username in ctx")
} }
return repo.PlayerGetByName(username) return repo.PlayerGetByName(ctx, username)
} }
// // DEPRECATED
// func leaveRole(fi *models.FullInfo) {
// fi.Room.RedTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.RedTeam.Guessers)
// fi.Room.BlueTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.BlueTeam.Guessers)
// if fi.Room.RedTeam.Mime == fi.State.Username {
// fi.Room.RedTeam.Mime = ""
// }
// if fi.Room.BlueTeam.Mime == fi.State.Username {
// fi.Room.BlueTeam.Mime = ""
// }
// }
func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) { func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) {
// get username // get username
fi, _ := getFullInfoByCtx(ctx) fi, _ := getFullInfoByCtx(ctx)
@ -219,35 +164,15 @@ func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error)
err := errors.New("uknown role:" + role) err := errors.New("uknown role:" + role)
return nil, err return nil, err
} }
if err := saveFullInfo(fi); err != nil { if err := saveFullInfo(ctx, fi); err != nil {
return nil, err return nil, err
} }
return fi, nil return fi, nil
} }
// get all rooms
// func listRooms(allRooms bool) []*models.Room {
// cacheMap := memcache.GetAll()
// publicRooms := []*models.Room{}
// // no way to know if room is public until unmarshal -_-;
// for key, value := range cacheMap {
// if strings.HasPrefix(key, models.CacheRoomPrefix) {
// room := &models.Room{}
// if err := json.Unmarshal(value, &room); err != nil {
// log.Warn("failed to unmarshal room", "error", err)
// continue
// }
// if room.IsPublic || allRooms {
// publicRooms = append(publicRooms, room)
// }
// }
// }
// return publicRooms
// }
// get bots // get bots
func listBots() []models.Player { func listBots() []models.Player {
bots, err := repo.PlayerList(true) bots, err := repo.PlayerList(context.Background(), true)
if err != nil { if err != nil {
log.Error("failed to fetch bots from db", "error", err) log.Error("failed to fetch bots from db", "error", err)
} }
@ -263,6 +188,11 @@ func notify(event, msg string) {
} }
func loadCards(room *models.Room) { func loadCards(room *models.Room) {
// remove old cards
room.Cards = []models.WordCard{}
// try to delete old cards from db (in case players play another round)
// nolint: errcheck
repo.WordCardsDeleteByRoomID(context.Background(), room.ID)
// store it somewhere // store it somewhere
wordMap := map[string]string{ wordMap := map[string]string{
"en": "assets/words/en_nouns.txt", "en": "assets/words/en_nouns.txt",
@ -275,10 +205,6 @@ func loadCards(room *models.Room) {
fmt.Println("failed to load cards", "error", err) fmt.Println("failed to load cards", "error", err)
} }
room.Cards = cards room.Cards = cards
room.WCMap = make(map[string]models.WordColor)
for _, card := range room.Cards {
room.WCMap[card.Word] = card.Color
}
} }
func recoverBots() { func recoverBots() {
@ -292,46 +218,20 @@ func recoverBots() {
func recoverBot(bm models.Player) error { func recoverBot(bm models.Player) error {
// check if room still exists // check if room still exists
if _, err := repo.RoomGetByID(context.Background(), bm.RoomID); err != nil { if bm.RoomID == nil {
return fmt.Errorf("no such room: %s; err: %w", bm.RoomID, err) return errors.New("bot has no room id")
}
if _, err := repo.RoomGetByID(context.Background(), *bm.RoomID); err != nil {
return fmt.Errorf("no such room: %s; err: %w", *bm.RoomID, err)
} }
log.Debug("recovering bot", "bot", bm) log.Debug("recovering bot", "bot", bm)
_, err := llmapi.NewBot(string(bm.Role), string(bm.Team), bm.Username, bm.RoomID, cfg, true) _, err := llmapi.NewBot(string(bm.Role), string(bm.Team), bm.Username, *bm.RoomID, cfg, true)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
// func recoverPlayers() {
// players := listPlayers()
// for playerName, playerMap := range players {
// if err := recoverPlayer(playerMap); err != nil {
// log.Warn("failed to recover player", "playerName", playerName, "error", err)
// }
// }
// }
// func recoverPlayer(pm map[string]string) error {
// // check if room still exists
// room, err := repo.RoomGetByID(context.Background(), pm["RoomID"])
// if err != nil {
// return fmt.Errorf("no such room: %s; err: %w", pm["RoomID"], err)
// }
// log.Debug("recovering player", "player", pm)
// role, team, ok := room.GetPlayerByName(pm["Username"])
// if !ok {
// return fmt.Errorf("failed to find player %s in the room %v", pm["Username"], room)
// }
// us := &models.Player{
// Username: pm["Username"],
// RoomID: pm["RoomID"],
// Team: team,
// Role: role,
// }
// return saveState(pm["Username"], us)
// }
// validateMove checks if it is players turn // validateMove checks if it is players turn
func validateMove(fi *models.FullInfo, ur models.UserRole) error { func validateMove(fi *models.FullInfo, ur models.UserRole) error {
if fi.State.Role != ur { if fi.State.Role != ur {

View File

@ -1,107 +0,0 @@
package handlers
import (
"encoding/json"
"gralias/models"
"os"
"testing"
)
func TestSaveState(t *testing.T) {
// Create test state
state := &models.UserState{
Username: "testuser",
RoomID: "testroom",
Team: models.UserTeamBlue,
Role: models.UserRoleMime,
}
// Save state
err := saveState(state.Username, state)
if err != nil {
t.Fatalf("saveState failed: %v", err)
}
// Load state
loadedState, err := loadState(state.Username)
if err != nil {
t.Fatalf("loadState failed: %v", err)
}
// Verify loaded state matches original
if loadedState.Username != state.Username {
t.Errorf("Username mismatch: got %s, want %s", loadedState.Username, state.Username)
}
if loadedState.RoomID != state.RoomID {
t.Errorf("RoomID mismatch: got %s, want %s", loadedState.RoomID, state.RoomID)
}
if loadedState.Team != state.Team {
t.Errorf("Team mismatch: got %s, want %s", loadedState.Team, state.Team)
}
if loadedState.Role != state.Role {
t.Errorf("Role mismatch: got %s, want %s", loadedState.Role, state.Role)
}
// Test JSON serialization/deserialization
data, err := json.Marshal(state)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
testMap := make(map[string][]byte)
testMap["testkey"] = data
// Create a temporary file
tmpFile, err := os.CreateTemp("", "test_store_*.json")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
tmpFileName := tmpFile.Name()
// defer os.Remove(tmpFileName)
// Write testMap to the temp file
fileData, err := json.Marshal(testMap)
if err != nil {
t.Fatalf("failed to marshal testMap: %v", err)
}
if err := os.WriteFile(tmpFileName, fileData, 0644); err != nil {
t.Fatalf("failed to write to temp file: %v", err)
}
// Read the temp file
readData, err := os.ReadFile(tmpFileName)
if err != nil {
t.Fatalf("failed to read temp file: %v", err)
}
// Unmarshal the data
var testMapRead map[string][]byte
if err := json.Unmarshal(readData, &testMapRead); err != nil {
t.Fatalf("failed to unmarshal testMap: %v", err)
}
// Get the state bytes from the map
stateBytes, ok := testMapRead["testkey"]
if !ok {
t.Fatalf("key 'testkey' not found in testMapRead")
}
// Unmarshal the state bytes
stateRead := &models.UserState{}
if err := json.Unmarshal(stateBytes, stateRead); err != nil {
t.Fatalf("failed to unmarshal state: %v", err)
}
// Compare the state
if stateRead.Username != state.Username {
t.Errorf("Username mismatch from file: got %s, want %s", stateRead.Username, state.Username)
}
if stateRead.RoomID != state.RoomID {
t.Errorf("RoomID mismatch from file: got %s, want %s", stateRead.RoomID, state.RoomID)
}
if stateRead.Team != state.Team {
t.Errorf("Team mismatch from file: got %s, want %s", stateRead.Team, state.Team)
}
if stateRead.Role != state.Role {
t.Errorf("Role mismatch from file: got %s, want %s", stateRead.Role, state.Role)
}
}

View File

@ -1,15 +1,15 @@
package handlers package handlers
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"gralias/models" "gralias/models"
"gralias/pkg/cache"
"gralias/utils" "gralias/utils"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -37,8 +37,7 @@ func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
cleanName := utils.RemoveSpacesFromStr(username) cleanName := utils.RemoveSpacesFromStr(username)
// allNames := getAllNames() allNames, err := repo.PlayerListNames(r.Context())
allNames, err := repo.PlayerListNames()
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
@ -74,9 +73,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, msg) abortWithError(w, msg)
return return
} }
password := r.PostFormValue("password")
var makeplayer bool
roomID := r.PostFormValue("room_id") roomID := r.PostFormValue("room_id")
// make sure username does not exists // make sure username does not exists
cleanName := utils.RemoveSpacesFromStr(username) cleanName := utils.RemoveSpacesFromStr(username)
clearPass := utils.RemoveSpacesFromStr(password)
// login user // login user
cookie, err := makeCookie(cleanName, r.RemoteAddr) cookie, err := makeCookie(cleanName, r.RemoteAddr)
if err != nil { if err != nil {
@ -84,13 +86,19 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
http.SetCookie(w, cookie)
// check if that user was already in db // check if that user was already in db
// userstate, err := loadState(cleanName) userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
userstate, err := repo.PlayerGetByName(cleanName)
if err != nil || userstate == nil { if err != nil || userstate == nil {
log.Debug("making new player", "error", err, "state", userstate)
userstate = models.InitPlayer(cleanName) 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{ fi := &models.FullInfo{
State: userstate, State: userstate,
} }
@ -103,17 +111,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// room.PlayerList = append(room.PlayerList, fi.State.Username)
fi.Room = room
fi.List = nil fi.List = nil
fi.State.RoomID = room.ID fi.State.RoomID = &room.ID
repo.PlayerSetRoomID(fi.State.Username, room.ID) if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
// repo.RoomUpdate() abortWithError(w, err.Error())
// save full info instead return
// if err := saveFullInfo(fi); err != nil { }
// abortWithError(w, err.Error())
// return
// }
} else { } else {
log.Debug("no room_id in login") log.Debug("no room_id in login")
// fi.List = listRooms(false) // fi.List = listRooms(false)
@ -123,17 +126,15 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
// save state to cache // save state to cache
// if err := saveState(cleanName, userstate); err != nil { if makeplayer {
if err := repo.PlayerUpdate(userstate); err != nil { userstate.Password = clearPass
// if err := saveFullInfo(fi); err != nil { if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
log.Error("failed to save state", "error", err) log.Error("failed to save state", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
} }
// if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil { }
// log.Error("failed to execute base template", "error", err)
// }
http.Redirect(w, r, "/", 302) http.Redirect(w, r, "/", 302)
} }
@ -142,11 +143,13 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
// Create a new random session token // Create a new random session token
// sessionToken := xid.New().String() // sessionToken := xid.New().String()
sessionToken := "sessionprefix_" + username sessionToken := "sessionprefix_" + username
expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second) // expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second)
// Set the token in the session map, along with the session information // Set the token in the session map, along with the session information
session := &models.Session{ session := &models.Session{
Username: username, Username: username,
Expiry: expiresAt, TokenKey: sessionToken,
UpdatedAt: time.Now(),
Lifetime: uint32(cfg.SessionLifetime / 60),
} }
cookieName := "session_token" cookieName := "session_token"
// hmac to protect cookies // hmac to protect cookies
@ -172,32 +175,17 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
cookie.Secure = false cookie.Secure = false
log.Info("changing cookie domain", "domain", cookie.Domain) log.Info("changing cookie domain", "domain", cookie.Domain)
} }
// set ctx? player, err := repo.PlayerGetByName(context.Background(), username)
// set user in session if err != nil || player == nil {
if err := cacheSetSession(sessionToken, session); err != nil { // make player first, since username is fk to players table
player = models.InitPlayer(username)
if err := repo.PlayerAdd(context.Background(), player); err != nil {
slog.Error("failed to create player", "username", username)
return nil, err
}
}
if err := repo.SessionCreate(context.Background(), session); err != nil {
return nil, err return nil, err
} }
return cookie, nil return cookie, nil
} }
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()) abortWithError(w, err.Error())
return return
} }
color, exists := fi.Room.WCMap[word] // color, exists := fi.Room.WCMap[word]
color, exists := fi.Room.FindColor(word)
if !exists { if !exists {
abortWithError(w, "word is not found") abortWithError(w, "word is not found")
return return
@ -60,7 +61,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
Color: color, Color: color,
Revealed: true, Revealed: true,
} }
fi.Room.RevealSpecificWord(word) revCardID := fi.Room.RevealSpecificWord(word)
if revCardID == 0 {
// error
abortWithError(w, "word has 0 id")
return
}
if err := repo.WordCardReveal(r.Context(), word, fi.Room.ID); err != nil {
abortWithError(w, err.Error())
return
}
fi.Room.UpdateCounter() fi.Room.UpdateCounter()
action := models.Action{ action := models.Action{
Actor: fi.State.Username, Actor: fi.State.Username,
@ -68,10 +78,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
WordColor: string(color), WordColor: string(color),
Action: models.ActionTypeGuess, Action: models.ActionTypeGuess,
Word: word, Word: word,
RoomID: fi.Room.ID,
}
if err := repo.ActionCreate(r.Context(), &action); err != nil {
abortWithError(w, err.Error())
return
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
// if opened card is of color of opp team, change turn // if opened card is of color of opp team, change turn
oppositeColor := fi.Room.GetOppositeTeamColor() oppositeColor := fi.Room.GetOppositeTeamColor()
var clearMarks bool
fi.Room.OpenedThisTurn++ fi.Room.OpenedThisTurn++
log.Debug("got show-color request", "word", word, "color", color, log.Debug("got show-color request", "word", word, "color", color,
"limit", fi.Room.ThisTurnLimit, "opened", fi.Room.OpenedThisTurn, "limit", fi.Room.ThisTurnLimit, "opened", fi.Room.OpenedThisTurn,
@ -83,7 +99,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.MimeDone = false fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0 fi.Room.ThisTurnLimit = 0
fi.Room.ClearMarks() clearMarks = true
StopTurnTimer(fi.Room.ID) StopTurnTimer(fi.Room.ID)
} }
switch string(color) { switch string(color) {
@ -94,6 +110,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = oppositeColor fi.Room.TeamWon = oppositeColor
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
@ -102,15 +119,16 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0 fi.Room.ThisTurnLimit = 0
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks() clearMarks = true
StopTurnTimer(fi.Room.ID) StopTurnTimer(fi.Room.ID)
case string(models.WordColorWhite), string(oppositeColor): case string(models.WordColorWhite), string(oppositeColor):
log.Debug("opened opposite color word", "room", fi.Room, "opposite-color", oppositeColor) log.Debug("opened white or opposite color word", "word", word, "opposite-color", oppositeColor)
// end turn // end turn
fi.Room.TeamTurn = oppositeColor fi.Room.TeamTurn = oppositeColor
fi.Room.MimeDone = false fi.Room.MimeDone = false
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.ThisTurnLimit = 0 fi.Room.ThisTurnLimit = 0
clearMarks = true
StopTurnTimer(fi.Room.ID) StopTurnTimer(fi.Room.ID)
// check if no cards left => game over // check if no cards left => game over
if fi.Room.BlueCounter == 0 { if fi.Room.BlueCounter == 0 {
@ -119,13 +137,13 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = "blue" fi.Room.TeamWon = "blue"
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorBlue, WordColor: models.WordColorBlue,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
} }
if fi.Room.RedCounter == 0 { if fi.Room.RedCounter == 0 {
// red won // red won
@ -133,13 +151,13 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = "red" fi.Room.TeamWon = "red"
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed, WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
fi.Room.ClearMarks()
} }
default: // same color as the team default: // same color as the team
// check if game over // check if game over
@ -148,16 +166,22 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
fi.Room.IsOver = true fi.Room.IsOver = true
fi.Room.TeamWon = fi.State.Team fi.Room.TeamWon = fi.State.Team
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: models.WordColorRed, WordColor: models.WordColorRed,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
}
}
if clearMarks {
fi.Room.ClearMarks() fi.Room.ClearMarks()
if err := repo.CardMarksRemoveByRoomID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove marks", "error", err, "room_id", fi.Room.ID)
} }
} }
if err := saveFullInfo(fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -186,8 +210,8 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
color, exists := fi.Room.WCMap[word] color, exists := fi.Room.FindColor(word)
log.Debug("got show-color request", "word", word, "color", color) log.Debug("got mark-card request", "word", word, "color", color)
if !exists { if !exists {
abortWithError(w, "word is not found") abortWithError(w, "word is not found")
return return
@ -205,23 +229,36 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
// Check if the current user already has an active mark on this card // Check if the current user already has an active mark on this card
found := false found := false
var newMarks []models.CardMark var newMarks []models.CardMark
for _, mark := range card.Mark { for _, mark := range card.Marks {
if mark.Username == fi.State.Username && mark.Active { if mark.Username == fi.State.Username {
found = true found = true
} else { } else {
newMarks = append(newMarks, mark) newMarks = append(newMarks, mark)
} }
} }
if !found { if !found {
newMarks = append(newMarks, models.CardMark{ cm := models.CardMark{
Username: fi.State.Username, Username: fi.State.Username,
Active: true, CardID: card.ID,
})
} }
fi.Room.Cards[i].Mark = newMarks newMarks = append(newMarks, cm)
if err := repo.CardMarksAdd(r.Context(), &cm); err != nil {
log.Error("failed to add mark", "error", err, "card", card)
abortWithError(w, "failed to add mark")
return
}
} else {
// if mark was found, it needs to be removed
if err := repo.CardMarksRemove(r.Context(), card.ID, fi.State.Username); err != nil {
log.Error("failed to remove mark", "error", err, "card", card)
abortWithError(w, "failed to remove mark")
return
}
}
fi.Room.Cards[i].Marks = newMarks
cardword = fi.Room.Cards[i] cardword = fi.Room.Cards[i]
} }
if err := saveFullInfo(fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -257,7 +294,14 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
botname := fmt.Sprintf("bot_%d", len(llmapi.SignalChanMap)+1) // what if many rooms? var botname string
maxID, err := repo.PlayerGetMaxID(r.Context())
if err != nil {
log.Warn("failed to get players max id")
botname = fmt.Sprintf("bot_%d", len(llmapi.SignalChanMap)+1) // what if many rooms?
} else {
botname = fmt.Sprintf("bot_%d", maxID+1) // what if many rooms?
}
_, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false) _, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false)
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())

View File

@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"gralias/models" "gralias/models"
@ -32,30 +31,20 @@ func HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
abortWithError(w, msg) abortWithError(w, msg)
return return
} }
ctx := context.WithValue(r.Context(), "current_room", room.ID) fi, err := getFullInfoByCtx(r.Context())
fi, err := getFullInfoByCtx(ctx)
if err != nil { if err != nil {
msg := "failed to get full info from ctx" msg := "failed to get full info from ctx"
log.Error(msg, "error", err) log.Error(msg, "error", err)
abortWithError(w, msg) abortWithError(w, msg)
return return
} }
fi.State.RoomID = room.ID fi.State.RoomID = &room.ID
fi.Room = room fi.Room = room
if err := repo.RoomCreate(r.Context(), room); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
log.Error("failed to set room id", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
if err := repo.PlayerSetRoomID(fi.State.Username, room.ID); err != nil {
abortWithError(w, err.Error())
return
}
// if err := saveFullInfo(fi); err != nil {
// msg := "failed to set current room to session"
// log.Error(msg, "error", err)
// abortWithError(w, msg)
// return
// }
notify(models.NotifyRoomListUpdate, "") notify(models.NotifyRoomListUpdate, "")
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
if err != nil { if err != nil {
@ -131,7 +120,7 @@ func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
fi.Room.ChangeTurn() fi.Room.ChangeTurn()
fi.Room.MimeDone = false fi.Room.MimeDone = false
StopTurnTimer(fi.Room.ID) StopTurnTimer(fi.Room.ID)
if err := saveFullInfo(fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -159,6 +148,22 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// Initialize transaction
ctx, tx, err := repo.InitTx(r.Context())
if err != nil {
log.Error("failed to init transaction", "error", err)
abortWithError(w, err.Error())
return
}
defer func() {
if r := recover(); r != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
panic(r)
}
}()
fi.Room.MimeDone = false
fi.Room.IsRunning = true fi.Room.IsRunning = true
fi.Room.IsOver = false fi.Room.IsOver = false
fi.Room.TeamTurn = "blue" fi.Room.TeamTurn = "blue"
@ -168,13 +173,53 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
fi.Room.UpdateCounter() fi.Room.UpdateCounter()
fi.Room.TeamWon = "" fi.Room.TeamWon = ""
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
CreatedAt: time.Now(),
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: string(fi.State.Team), WordColor: string(fi.State.Team),
Action: models.ActionTypeGameStarted, Action: models.ActionTypeGameStarted,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
if err := saveFullInfo(fi); err != nil { // Use the new context with transaction
// if err := saveFullInfo(ctx, fi); err != nil {
// if err := tx.Rollback(); err != nil {
// log.Error("failed to rollback transaction", "error", err)
// }
// abortWithError(w, err.Error())
// return
// }
// Save action history
if err := repo.ActionCreate(ctx, &action); err != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
log.Error("failed to save action", "error", err)
abortWithError(w, err.Error())
return
}
// Save word cards
for _, card := range fi.Room.Cards {
card.RoomID = fi.Room.ID // Ensure RoomID is set for each card
if err := repo.WordCardsCreate(ctx, &card); err != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
log.Error("failed to save word card", "error", err)
abortWithError(w, err.Error())
return
}
}
if err := repo.RoomUpdate(ctx, fi.Room); err != nil {
log.Error("failed to update room", "error", err)
// nolint: errcheck
tx.Rollback()
abortWithError(w, err.Error())
return
}
// Commit the transaction
if err := tx.Commit(); err != nil {
log.Error("failed to commit transaction", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -201,7 +246,7 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) { func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("id") roomID := r.URL.Query().Get("id")
room, err := repo.RoomGetByID(r.Context(), roomID) room, err := repo.RoomGetExtended(r.Context(), roomID)
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
@ -223,10 +268,11 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
return return
} }
// room.PlayerList = append(room.PlayerList, fi.State.Username) // room.PlayerList = append(room.PlayerList, fi.State.Username)
fi.State.RoomID = room.ID fi.State.RoomID = &room.ID
fi.Room = room fi.Room = room
fi.List = nil fi.List = nil
if err := saveFullInfo(fi); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
log.Error("failed to set room_id for player", "error", err, "username", fi.State.Username, "room_id", room.ID)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -278,6 +324,7 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
} }
// === // ===
action := models.Action{ action := models.Action{
RoomID: fi.Room.ID,
Actor: fi.State.Username, Actor: fi.State.Username,
ActorColor: string(fi.State.Team), ActorColor: string(fi.State.Team),
WordColor: string(fi.State.Team), WordColor: string(fi.State.Team),
@ -286,21 +333,25 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
Number: num, Number: num,
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
if err := repo.ActionCreate(r.Context(), &action); err != nil {
abortWithError(w, err.Error())
return
}
fi.Room.MimeDone = true fi.Room.MimeDone = true
fi.Room.ThisTurnLimit = uint8(guessLimitU64) + 1 fi.Room.ThisTurnLimit = uint8(guessLimitU64) + 1
if guessLimitU64 == 0 { if guessLimitU64 == 0 {
fi.Room.ThisTurnLimit = 9 fi.Room.ThisTurnLimit = 9
} }
fi.Room.OpenedThisTurn = 0 fi.Room.OpenedThisTurn = 0
fi.Room.Settings.TurnSecondsLeft = fi.Room.Settings.RoundTime StartTurnTimer(fi.Room.ID, fi.Room.Settings.RoundTime)
StartTurnTimer(fi.Room.ID, time.Duration(fi.Room.Settings.RoundTime)*time.Second)
log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit) log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit)
notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num) // notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
notifyBotIfNeeded(fi.Room) notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, clue+num)
if err := saveFullInfo(fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
notifyBotIfNeeded(fi.Room)
} }
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) { func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {

View File

@ -24,16 +24,11 @@ func init() {
Level: slog.LevelDebug, Level: slog.LevelDebug,
AddSource: true, AddSource: true,
})) }))
// memcache = cache.MemCache
cfg = config.LoadConfigOrDefault("") cfg = config.LoadConfigOrDefault("")
Notifier = broker.Notifier Notifier = broker.Notifier
// cache.MemCache.StartBackupRoutine(15 * time.Second) // Reduced backup interval // repo = repos.NewRepoProvider("sqlite3://../gralias.db")
// bot loader repo = repos.RP
// check the rooms if it has bot_{digits} in them, create bots if have
repo = repos.NewRepoProvider("sqlite3://../gralias.db")
recoverBots() recoverBots()
// if player has a roomID, but no team and role, try to recover
// recoverPlayers()
} }
func HandlePing(w http.ResponseWriter, r *http.Request) { func HandlePing(w http.ResponseWriter, r *http.Request) {
@ -48,8 +43,12 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
fi, _ := getFullInfoByCtx(r.Context()) fi, err := getFullInfoByCtx(r.Context())
if fi != nil && fi.Room != nil && fi.State != nil { if err != nil {
log.Error("failed to fetch fi", "error", err)
}
// there must be a better way
if fi != nil && fi.Room != nil && fi.Room.ID != "" && fi.State != nil {
fi.Room.UpdateCounter() fi.Room.UpdateCounter()
if fi.State.Role == "mime" { if fi.State.Role == "mime" {
fi.Room.MimeView() // there must be a better way fi.Room.MimeView() // there must be a better way
@ -89,29 +88,22 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
creatorLeft = true creatorLeft = true
} }
exitedRoom := fi.ExitRoom() exitedRoom := fi.ExitRoom()
// if err := saveRoom(exitedRoom); err != nil {
// abortWithError(w, err.Error())
// return
// }
if creatorLeft { if creatorLeft {
if err := repo.RoomDeleteByID(r.Context(), exitedRoom.ID); err != nil { if err := repo.RoomDeleteByID(r.Context(), exitedRoom.ID); err != nil {
log.Error("failed to remove room", "error", err) log.Error("failed to remove room", "error", err)
} }
// removeRoom(exitedRoom.ID)
// TODO: notify users if creator left
// and throw them away
notify(models.NotifyRoomListUpdate, "") notify(models.NotifyRoomListUpdate, "")
} }
// scary to update the whole room if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
log.Error("failed to exit room", "error", err)
abortWithError(w, err.Error())
return
}
if err := repo.RoomUpdate(r.Context(), exitedRoom); err != nil { if err := repo.RoomUpdate(r.Context(), exitedRoom); err != nil {
log.Error("failed to update room", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
if err := repo.PlayerExitRoom(fi.State.Username); err != nil {
abortWithError(w, err.Error())
return
}
// fi.List = listRooms(false)
fi.List, err = repo.RoomList(r.Context()) fi.List, err = repo.RoomList(r.Context())
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())

View File

@ -6,7 +6,6 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"gralias/models" "gralias/models"
"gralias/pkg/cache"
"net/http" "net/http"
) )
@ -29,8 +28,8 @@ func GetSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sessionCookie, err := r.Cookie(models.AuthCookie) sessionCookie, err := r.Cookie(models.AuthCookie)
if err != nil { if err != nil {
msg := "auth failed; failed to get session token from cookies" // msg := "auth failed; failed to get session token from cookies"
log.Debug(msg, "error", err) // log.Debug(msg, "error", err)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@ -61,17 +60,20 @@ func GetSession(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
userSession, err := cacheGetSession(sessionToken) userSession, err := repo.SessionByToken(r.Context(), sessionToken)
// userSession, err := cacheGetSession(sessionToken)
// log.Debug("userSession from cache", "us", userSession) // log.Debug("userSession from cache", "us", userSession)
if err != nil { if err != nil {
// msg := "auth failed; session does not exists" msg := "auth failed; session does not exists"
// err = errors.New(msg) log.Debug(msg, "error", err, "key", sessionToken)
// log.Debug(msg, "error", err)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
if userSession.IsExpired() { if userSession.IsExpired() {
cache.MemCache.RemoveKey(sessionToken) if err := repo.SessionDelete(r.Context(), sessionToken); err != nil {
log.Error("failed to delete session", "error", err)
}
// cache.MemCache.RemoveKey(sessionToken)
msg := "session is expired" msg := "session is expired"
log.Debug(msg, "error", err, "token", sessionToken) log.Debug(msg, "error", err, "token", sessionToken)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
@ -81,13 +83,13 @@ func GetSession(next http.Handler) http.Handler {
models.CtxUsernameKey, userSession.Username) models.CtxUsernameKey, userSession.Username)
ctx = context.WithValue(ctx, ctx = context.WithValue(ctx,
models.CtxSessionKey, userSession) models.CtxSessionKey, userSession)
if err := cacheSetSession(sessionToken, // if err := cacheSetSession(sessionToken,
userSession); err != nil { // userSession); err != nil {
msg := "failed to marshal user session" // msg := "failed to marshal user session"
log.Warn(msg, "error", err) // log.Warn(msg, "error", err)
next.ServeHTTP(w, r) // next.ServeHTTP(w, r)
return // return
} // }
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

View File

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

View File

@ -1,13 +1,13 @@
package llmapi package llmapi
import ( import (
"encoding/json" "context"
"errors" "errors"
"fmt" "fmt"
"gralias/broker" "gralias/broker"
"gralias/config" "gralias/config"
"gralias/models" "gralias/models"
"gralias/pkg/cache" "gralias/repos"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@ -19,12 +19,13 @@ import (
var ( var (
// botname -> channel // botname -> channel
repo = repos.RP
SignalChanMap = make(map[string]chan bool) SignalChanMap = make(map[string]chan bool)
DoneChanMap = make(map[string]chan bool) DoneChanMap = make(map[string]chan bool)
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0 // got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
MimePrompt = `we are playing alias;\nyou are a mime (player who gives a clue of one noun word and number of cards you expect them to open) of the %s team (people who would guess by your clue want open the %s cards);\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the game info in json:\n%s` 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` 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;` 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,24 +50,9 @@ func convertToSliceOfStrings(value any) ([]string, error) {
} }
} }
func (b *Bot) checkGuesses(tempMap map[string]any, room *models.Room) error {
guesses, err := convertToSliceOfStrings(tempMap["guesses"])
if err != nil {
b.log.Warn("failed to parse bot given guesses", "mimeResp", tempMap, "bot_name", b.BotName)
return err
}
for _, word := range guesses {
if err := b.checkGuess(word, room); err != nil {
// log error
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", word)
return err
}
}
return nil
}
func (b *Bot) checkGuess(word string, room *models.Room) error { func (b *Bot) checkGuess(word string, room *models.Room) error {
color, exists := room.WCMap[word] // color, exists := room.WCMap[word]
color, exists := room.FindColor(word)
b.log.Debug("bot trying to open card", "word", word, "color", b.log.Debug("bot trying to open card", "word", word, "color",
color, "exists", exists, "limit", room.ThisTurnLimit, color, "exists", exists, "limit", room.ThisTurnLimit,
"opened", room.OpenedThisTurn) "opened", room.OpenedThisTurn)
@ -74,8 +60,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
return fmt.Errorf("fn: checkGuess; %s does not exists", word) return fmt.Errorf("fn: checkGuess; %s does not exists", word)
} }
room.RevealSpecificWord(word) room.RevealSpecificWord(word)
if err := repo.WordCardReveal(context.Background(), word, room.ID); err != nil {
b.log.Error("failed to reveal word in db", "word", word, "color",
color, "exists", exists, "limit", room.ThisTurnLimit,
"opened", room.OpenedThisTurn)
return err
}
room.UpdateCounter() room.UpdateCounter()
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: b.Team, ActorColor: b.Team,
WordColor: string(color), WordColor: string(color),
@ -95,6 +88,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.MimeDone = false room.MimeDone = false
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
b.StopTurnTimer()
} }
switch string(color) { switch string(color) {
case string(models.WordColorBlack): case string(models.WordColorBlack):
@ -105,18 +99,21 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: string(b.Team), ActorColor: string(b.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
case string(models.WordColorWhite), string(oppositeColor): case string(models.WordColorWhite), string(oppositeColor):
// end turn // end turn
room.TeamTurn = oppositeColor room.TeamTurn = oppositeColor
room.MimeDone = false room.MimeDone = false
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
b.StopTurnTimer()
} }
// check if no cards left => game over // check if no cards left => game over
if room.BlueCounter == 0 { if room.BlueCounter == 0 {
@ -127,12 +124,14 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: string(b.Team), ActorColor: string(b.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
} }
if room.RedCounter == 0 { if room.RedCounter == 0 {
// red won // red won
@ -142,14 +141,31 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
room.OpenedThisTurn = 0 room.OpenedThisTurn = 0
room.ThisTurnLimit = 0 room.ThisTurnLimit = 0
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: string(b.Team), ActorColor: string(b.Team),
WordColor: models.WordColorBlack, WordColor: models.WordColorBlack,
Action: models.ActionTypeGameOver, Action: models.ActionTypeGameOver,
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
b.StopTurnTimer()
} }
if err := saveRoom(room); err != nil { ctx, tx, err := repo.InitTx(context.Background())
// nolint: errcheck
defer tx.Commit()
if err != nil {
b.log.Error("failed to init tx", "error", err)
}
if err := repo.ActionCreate(ctx, &action); err != nil {
// nolint: errcheck
tx.Rollback()
b.log.Error("failed to create action", "error", err, "action", action)
return err
}
if err := repo.RoomUpdate(ctx, room); err != nil {
// nolint: errcheck
tx.Rollback()
b.log.Error("failed to save room", "room", room) b.log.Error("failed to save room", "room", room)
err = fmt.Errorf("fn: checkGuess, failed to save room; err: %w", err) err = fmt.Errorf("fn: checkGuess, failed to save room; err: %w", err)
return err return err
@ -161,18 +177,31 @@ func (b *Bot) BotMove() {
// botJournalName := models.NotifyJournalPrefix + b.RoomID // botJournalName := models.NotifyJournalPrefix + b.RoomID
b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role) b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role)
// get room cards and actions // get room cards and actions
room, err := getRoomByID(b.RoomID) // room, err := getRoomByID(b.RoomID)
room, err := repo.RoomGetExtended(context.Background(), b.RoomID)
if err != nil { if err != nil {
b.log.Error("bot loop", "error", err) b.log.Error("bot loop", "error", err)
return return
} }
eventName := models.NotifyBacklogPrefix + room.ID // eventName := models.NotifyBacklogPrefix + room.ID
eventName := models.NotifyRoomUpdatePrefix + room.ID
eventPayload := "" eventPayload := ""
defer func() { // save room defer func() { // save room
// just incase, get the room once more
// room, err = repo.RoomGetExtended(context.Background(), b.RoomID)
// if err != nil {
// b.log.Error("bot loop", "error", err)
// return
// }
if err := saveRoom(room); err != nil { if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err) b.log.Error("failed to save room", "error", err)
return return
} }
if botName := room.WhichBotToMove(); botName != "" {
b.log.Debug("notifying bot", "name", botName)
SignalChanMap[botName] <- true
b.log.Debug("after sending the signal", "name", botName)
}
broker.Notifier.Notifier <- broker.NotificationEvent{ broker.Notifier.Notifier <- broker.NotificationEvent{
EventName: eventName, EventName: eventName,
Payload: eventPayload, Payload: eventPayload,
@ -184,13 +213,21 @@ func (b *Bot) BotMove() {
// call llm // call llm
llmResp, err := b.CallLLM(prompt) llmResp, err := b.CallLLM(prompt)
if err != nil { if err != nil {
room.LogJournal = append(room.LogJournal, b.BotName+" send call got error: "+err.Error()) 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) b.log.Error("bot loop", "error", err)
return return
} }
tempMap, err := b.LLMParser.ParseBytes(llmResp) tempMap, err := b.LLMParser.ParseBytes(llmResp)
if err != nil { if err != nil {
room.LogJournal = append(room.LogJournal, b.BotName+" parse resp got error: "+err.Error()) 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)) b.log.Error("bot loop", "error", err, "resp", string(llmResp))
return return
} }
@ -206,6 +243,7 @@ func (b *Bot) BotMove() {
return return
} }
action := models.Action{ action := models.Action{
RoomID: room.ID,
Actor: b.BotName, Actor: b.BotName,
ActorColor: b.Team, ActorColor: b.Team,
WordColor: b.Team, WordColor: b.Team,
@ -215,55 +253,73 @@ func (b *Bot) BotMove() {
} }
room.ActionHistory = append(room.ActionHistory, action) room.ActionHistory = append(room.ActionHistory, action)
room.MimeDone = true room.MimeDone = true
meant := fmt.Sprintf(b.BotName+" meant to open: %v", tempMap["words_I_mean_my_team_to_open"]) entry := fmt.Sprintf("meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
room.LogJournal = append(room.LogJournal, meant) lj := models.Journal{
Entry: entry,
Username: b.BotName,
RoomID: room.ID,
}
room.LogJournal = append(room.LogJournal, lj)
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
eventPayload = mimeResp.Clue + mimeResp.Number eventPayload = mimeResp.Clue + mimeResp.Number
guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8) guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8)
if err != nil { if err != nil {
b.log.Warn("failed to parse bot given limit", "mimeResp", mimeResp, "bot_name", b.BotName) b.log.Warn("failed to parse bot given limit", "mimeResp", mimeResp, "bot_name", b.BotName)
} }
room.OpenedThisTurn = 0 // in case it is not
room.ThisTurnLimit = uint8(guessLimitU64) room.ThisTurnLimit = uint8(guessLimitU64)
if room.ThisTurnLimit == 0 { if room.ThisTurnLimit == 0 {
b.log.Warn("turn limit is 0", "mimeResp", mimeResp) b.log.Warn("turn limit is 0", "mimeResp", mimeResp)
room.ThisTurnLimit = 9 room.ThisTurnLimit = 9
} }
if err := repo.ActionCreate(context.Background(), &action); err != nil {
b.log.Error("failed to create action", "error", err)
return
}
b.StartTurnTimer(room.Settings.RoundTime)
if err := saveRoom(room); err != nil { if err := saveRoom(room); err != nil {
b.log.Error("failed to save room", "error", err) b.log.Error("failed to save room", "error", err)
return return
} }
case models.UserRoleGuesser: case models.UserRoleGuesser:
// // deprecated
// if err := b.checkGuesses(tempMap, room); err != nil {
// b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName)
// continue
// }
guess, ok := tempMap["guess"].(string) guess, ok := tempMap["guess"].(string)
if !ok || guess == "" { if !ok || guess == "" {
b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName) b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName)
} }
if err := b.checkGuess(guess, room); err != nil { if err := b.checkGuess(guess, room); err != nil {
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err) b.log.Error("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
msg := fmt.Sprintf("failed to check guess; mimeResp: %v; bot_name: %s; guess: %s; error: %v", tempMap, b.BotName, guess, err) entry := fmt.Sprintf("failed to check guess; mimeResp: %v; guess: %s; error: %v", tempMap, guess, err)
room.LogJournal = append(room.LogJournal, msg) lj := models.Journal{
Entry: entry,
Username: b.BotName,
RoomID: room.ID,
}
room.LogJournal = append(room.LogJournal, lj)
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
} }
b.log.Info("guesser resp log", "guesserResp", tempMap) b.log.Info("guesser resp log", "guesserResp", tempMap)
couldBe, err := convertToSliceOfStrings(tempMap["could_be"]) couldBe, err := convertToSliceOfStrings(tempMap["could_be"])
if err != nil { if err != nil {
b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName) b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName)
} }
room.LogJournal = append(room.LogJournal, fmt.Sprintf("%s also considered this: %v", b.BotName, couldBe)) entry := fmt.Sprintf("%s guessed: %s; also considered this: %v", b.BotName, guess, couldBe)
eventName = models.NotifyRoomUpdatePrefix + room.ID lj := models.Journal{
eventPayload = "" Entry: entry,
// TODO: needs to decide if it wants to open the next cardword or end turn Username: b.BotName,
// or end turn on limit RoomID: room.ID,
}
room.LogJournal = append(room.LogJournal, lj)
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
b.log.Warn("failed to write to journal", "entry", lj)
}
default: default:
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap) b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
return return
} }
if botName := room.WhichBotToMove(); botName != "" {
b.log.Debug("notifying bot", "name", botName)
SignalChanMap[botName] <- true
}
} }
// StartBot // StartBot
@ -290,9 +346,32 @@ func RemoveBot(botName string, room *models.Room) error {
delete(SignalChanMap, botName) delete(SignalChanMap, botName)
// remove role from room // remove role from room
room.RemovePlayer(botName) room.RemovePlayer(botName)
slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
if err := repo.PlayerDelete(context.Background(), botName); err != nil {
slog.Error("failed to remove bot player", "name", botName, "room_id", room.ID)
return err
}
return saveRoom(room) return saveRoom(room)
} }
func RemoveBotNoRoom(botName string) error {
// 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 // EndBot
func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) { func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) {
@ -315,7 +394,8 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
bot.LLMParser = NewOpenRouterParser(bot.log) bot.LLMParser = NewOpenRouterParser(bot.log)
} }
// add to room // add to room
room, err := getRoomByID(bot.RoomID) // room, err := getRoomByID(bot.RoomID)
room, err := repo.RoomGetExtended(context.Background(), bot.RoomID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -359,9 +439,11 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
if err := saveRoom(room); err != nil { if err := saveRoom(room); err != nil {
return nil, err return nil, err
} }
if !recovery {
if err := saveBot(bot); err != nil { if err := saveBot(bot); err != nil {
return nil, err return nil, err
} }
}
// buffered channel to send to it in the same goroutine // buffered channel to send to it in the same goroutine
SignalChanMap[bot.BotName] = make(chan bool, 1) SignalChanMap[bot.BotName] = make(chan bool, 1)
DoneChanMap[bot.BotName] = make(chan bool, 1) DoneChanMap[bot.BotName] = make(chan bool, 1)
@ -370,35 +452,27 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
} }
func saveBot(bot *Bot) error { func saveBot(bot *Bot) error {
key := models.CacheBotPredix + bot.RoomID + bot.BotName // key := models.CacheBotPredix + bot.RoomID + bot.BotName
data, err := json.Marshal(bot) // data, err := json.Marshal(bot)
if err != nil { // if err != nil {
return err // return err
} // // }
cache.MemCache.Set(key, data) // cache.MemCache.Set(key, data)
return nil botPlayer := bot.ToPlayer()
} return repo.PlayerAdd(context.Background(), botPlayer)
func getRoomByID(roomID string) (*models.Room, error) {
roomBytes, err := cache.MemCache.Get(models.CacheRoomPrefix + roomID)
if err != nil {
return nil, err
}
resp := &models.Room{}
if err := json.Unmarshal(roomBytes, &resp); err != nil {
return nil, err
}
return resp, nil
} }
func saveRoom(room *models.Room) error { func saveRoom(room *models.Room) error {
key := models.CacheRoomPrefix + room.ID // key := models.CacheRoomPrefix + room.ID
data, err := json.Marshal(room) // data, err := json.Marshal(room)
if err != nil { // if err != nil {
return err // return err
} // }
cache.MemCache.Set(key, data) // cache.MemCache.Set(key, data)
return nil // ------------
// probably need to update other tables
// like word_cards or marks;
return repo.RoomUpdate(context.Background(), room)
} }
func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string { func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
@ -539,5 +613,5 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
return body, nil return body, nil
} }
// This line should not be reached because each error path returns in the loop. // This line should not be reached because each error path returns in the loop.
return nil, fmt.Errorf("unknown error in retry loop") return nil, errors.New("unknown error in retry loop")
} }

View File

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

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

View File

@ -20,11 +20,12 @@ CREATE TABLE rooms (
CREATE TABLE players ( CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT, -- nullable room_id TEXT, -- nullable
username TEXT NOT NULL, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue' team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime' role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime'
is_bot BOOLEAN NOT NULL DEFAULT FALSE, is_bot BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE word_cards ( CREATE TABLE word_cards (
@ -34,15 +35,15 @@ CREATE TABLE word_cards (
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
revealed BOOLEAN NOT NULL DEFAULT FALSE, revealed BOOLEAN NOT NULL DEFAULT FALSE,
mime_view BOOLEAN NOT NULL DEFAULT FALSE, mime_view BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE card_marks ( CREATE TABLE card_marks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
card_id INTEGER NOT NULL, card_id INTEGER NOT NULL,
username TEXT NOT NULL, username TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE, FOREIGN KEY (card_id) REFERENCES word_cards(id) ON DELETE CASCADE,
FOREIGN KEY (card_id) REFERENCES word_cards(id) FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
PRIMARY KEY (card_id, username)
); );
CREATE TABLE actions ( CREATE TABLE actions (
@ -55,7 +56,7 @@ CREATE TABLE actions (
word_color TEXT NOT NULL DEFAULT '', word_color TEXT NOT NULL DEFAULT '',
number_associated TEXT NOT NULL DEFAULT '', -- for clues number_associated TEXT NOT NULL DEFAULT '', -- for clues
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE settings ( CREATE TABLE settings (
@ -65,5 +66,41 @@ CREATE TABLE settings (
room_pass TEXT NOT NULL DEFAULT '', room_pass TEXT NOT NULL DEFAULT '',
turn_time INTEGER NOT NULL DEFAULT 60, -- seconds turn_time INTEGER NOT NULL DEFAULT 60, -- seconds
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE sessions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
lifetime INTEGER NOT NULL DEFAULT 3600,
token_key TEXT NOT NULL DEFAULT '' UNIQUE, -- encoded value
username TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
);
CREATE TABLE journal(
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
entry TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL,
room_id TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE player_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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

@ -6,20 +6,25 @@ import (
// each session contains the username of the user and the time at which it expires // each session contains the username of the user and the time at which it expires
type Session struct { type Session struct {
Username string ID uint32 `db:"id"`
CurrentRoom string // CurrentRoom string
Expiry time.Time // Expiry time.Time
UpdatedAt time.Time `db:"updated_at"`
Lifetime uint32 `db:"lifetime"` // minutes
TokenKey string `db:"token_key"`
Username string `db:"username"` // username is playerid
} }
// we'll use this method later to determine if the session has expired // we'll use this method later to determine if the session has expired
func (s Session) IsExpired() bool { func (s Session) IsExpired() bool {
return s.Expiry.Before(time.Now()) // return time.Now().After(s.UpdatedAt.Add(time.Minute * time.Duration(s.Lifetime)))
return false
} }
func ListUsernames(ss map[string]*Session) []string { // func ListUsernames(ss map[string]*Session) []string {
resp := make([]string, 0, len(ss)) // resp := make([]string, 0, len(ss))
for _, s := range ss { // for _, s := range ss {
resp = append(resp, s.Username) // resp = append(resp, s.Username)
} // }
return resp // return resp
} // }

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"gralias/utils" "gralias/utils"
"strings"
"time" "time"
"github.com/rs/xid" "github.com/rs/xid"
@ -15,6 +16,8 @@ type (
) )
const ( const (
// Context keys
TxContextKey = "tx"
// UserTeam // UserTeam
UserTeamBlue = "blue" UserTeamBlue = "blue"
UserTeamRed = "red" UserTeamRed = "red"
@ -96,14 +99,14 @@ type Action struct {
Word string `json:"word" db:"word"` Word string `json:"word" db:"word"`
WordColor string `json:"word_color" db:"word_color"` WordColor string `json:"word_color" db:"word_color"`
Number string `json:"number_associated" db:"number_associated"` Number string `json:"number_associated" db:"number_associated"`
CreatedAt time.Time `json:"created_at" db:"-"` CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedAtUnix int64 `db:"created_at"`
} }
type Player struct { type Player struct {
ID uint32 `json:"id" db:"id"` ID uint32 `json:"id" db:"id"`
RoomID string `json:"room_id" db:"room_id"` RoomID *string `json:"room_id" db:"room_id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password string `json:"-" db:"password"`
Team UserTeam `json:"team" db:"team"` Team UserTeam `json:"team" db:"team"`
Role UserRole `json:"role" db:"role"` Role UserRole `json:"role" db:"role"`
IsBot bool `json:"is_bot" db:"is_bot"` IsBot bool `json:"is_bot" db:"is_bot"`
@ -124,8 +127,31 @@ type BotPlayer struct {
} }
type CardMark struct { type CardMark struct {
Username string CardID uint32 `db:"card_id"`
Active bool Username string `db:"username"`
}
type Journal struct {
ID uint32 `db:"id"`
Username string `db:"username"`
RoomID string `db:"room_id"`
Entry string `db:"entry"`
CreatedAt time.Time `db:"created_at"`
}
type PlayerStats struct {
ID uint32 `db:"id"`
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 { type Room struct {
@ -148,16 +174,23 @@ type Room struct {
RedTeam Team `db:"-"` RedTeam Team `db:"-"`
BlueTeam Team `db:"-"` BlueTeam Team `db:"-"`
Cards []WordCard `db:"-"` Cards []WordCard `db:"-"`
WCMap map[string]WordColor `db:"-"`
BotMap map[string]BotPlayer `db:"-"` BotMap map[string]BotPlayer `db:"-"`
Mark CardMark `db:"-"` LogJournal []Journal `db:"-"`
LogJournal []string `db:"-"`
Settings GameSettings `db:"-"` Settings GameSettings `db:"-"`
} }
func (r *Room) FindColor(word string) (WordColor, bool) {
for _, card := range r.Cards {
if strings.EqualFold(card.Word, word) {
return card.Color, true
}
}
return "", false
}
func (r *Room) ClearMarks() { func (r *Room) ClearMarks() {
for i, _ := range r.Cards { for i := range r.Cards {
r.Cards[i].Mark = []CardMark{} r.Cards[i].Marks = []CardMark{}
} }
} }
@ -277,7 +310,7 @@ func getGuesser(m map[string]BotPlayer, team UserTeam) string {
func (r *Room) WhichBotToMove() string { func (r *Room) WhichBotToMove() string {
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn, fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning, "mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning,
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime) "blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime, "card-limit:", r.ThisTurnLimit, "opened:", r.OpenedThisTurn)
if !r.IsRunning { if !r.IsRunning {
return "" return ""
} }
@ -364,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 { for i, card := range r.Cards {
if card.Word == word { if card.Word == word {
r.Cards[i].Revealed = true r.Cards[i].Revealed = true
return card.ID
} }
} }
return 0
} }
type WordCard struct { type WordCard struct {
@ -378,16 +413,19 @@ type WordCard struct {
Word string `json:"word" db:"word"` Word string `json:"word" db:"word"`
Color WordColor `json:"color" db:"color"` Color WordColor `json:"color" db:"color"`
Revealed bool `json:"revealed" db:"revealed"` Revealed bool `json:"revealed" db:"revealed"`
Mime bool `json:"mime" db:"mime"` // user who sees that card is mime Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
Mark []CardMark `json:"marks" db:"-"` Marks []CardMark `json:"marks" db:"-"`
} }
// table: settings // table: settings
type GameSettings struct { type GameSettings struct {
ID uint32 `json:"id" db:"id"`
RoomID string `db:"room_id"`
Language string `json:"language" example:"en" form:"language" db:"language"` Language string `json:"language" example:"en" form:"language" db:"language"`
RoomPass string `json:"room_pass" db:"room_pass"` RoomPass string `json:"room_pass" db:"room_pass"`
TurnSecondsLeft uint32 `db:"-"`
RoundTime uint32 `json:"round_time" db:"round_time"` RoundTime uint32 `json:"round_time" db:"turn_time"`
CreatedAt time.Time `db:"created_at"`
} }
// ===== // =====
@ -427,7 +465,6 @@ type FullInfo struct {
} }
func (f *FullInfo) ExitRoom() *Room { func (f *FullInfo) ExitRoom() *Room {
// f.Room.PlayerList = utils.RemoveFromSlice(f.State.Username, f.Room.PlayerList)
f.Room.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers) f.Room.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers)
f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers) f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers)
if f.Room.RedTeam.Mime == f.State.Username { if f.Room.RedTeam.Mime == f.State.Username {
@ -436,8 +473,10 @@ func (f *FullInfo) ExitRoom() *Room {
if f.Room.BlueTeam.Mime == f.State.Username { if f.Room.BlueTeam.Mime == f.State.Username {
f.Room.BlueTeam.Mime = "" f.Room.BlueTeam.Mime = ""
} }
// f.State.ExitRoom() f.State.RoomID = nil
resp := f.Room resp := f.Room
f.Room = nil f.Room = nil
return resp return resp
} }
// =======

146
pkg/cache/impl.go vendored
View File

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

9
pkg/cache/main.go vendored
View File

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

View File

@ -4,43 +4,61 @@ import (
"context" "context"
"gralias/models" "gralias/models"
"time" "time"
"github.com/jmoiron/sqlx"
) )
type ActionsRepo interface { type ActionsRepo interface {
ListActions(ctx context.Context, roomID string) ([]models.Action, error) ActionList(ctx context.Context, roomID string) ([]models.Action, error)
CreateAction(ctx context.Context, roomID string, action *models.Action) error ActionCreate(ctx context.Context, action *models.Action) error
GetLastClue(ctx context.Context, roomID string) (*models.Action, error) ActionGetLastClue(ctx context.Context, roomID string) (*models.Action, error)
DeleteActionsByRoomID(ctx context.Context, roomID string) error ActionDeleteByRoomID(ctx context.Context, roomID string) error
ActionDeleteOrphaned(ctx context.Context) error
ActionGetLastTimeByRoomID(ctx context.Context, roomID string) (time.Time, error)
} }
func (p *RepoProvider) ListActions(ctx context.Context, roomID string) ([]models.Action, error) { func (p *RepoProvider) ActionList(ctx context.Context, roomID string) ([]models.Action, error) {
actions := []models.Action{} actions := []models.Action{}
err := p.DB.SelectContext(ctx, &actions, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? ORDER BY created_at ASC`, roomID) err := sqlx.SelectContext(ctx, p.DB, &actions, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? ORDER BY created_at ASC`, roomID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
for i := range actions {
actions[i].CreatedAt = time.Unix(0, actions[i].CreatedAtUnix)
}
return actions, nil return actions, nil
} }
func (p *RepoProvider) CreateAction(ctx context.Context, roomID string, a *models.Action) error { func (p *RepoProvider) ActionCreate(ctx context.Context, a *models.Action) error {
_, err := p.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()) 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 (?, ?, ?, ?, ?, ?, ?, ?)`, a.RoomID, a.Actor, a.ActorColor, a.Action, a.Word, a.WordColor, a.Number, time.Now())
return err return err
} }
func (p *RepoProvider) GetLastClue(ctx context.Context, roomID string) (*models.Action, error) { func (p *RepoProvider) ActionGetLastTimeByRoomID(ctx context.Context, roomID string) (time.Time, error) {
lastTime := time.Time{}
err := sqlx.GetContext(ctx, p.DB, &lastTime,
`SELECT created_at FROM actions WHERE room_id = ? ORDER BY created_at DESC LIMIT 1`, roomID)
if err != nil {
return lastTime, err
}
return lastTime, nil
}
func (p *RepoProvider) ActionGetLastClue(ctx context.Context, roomID string) (*models.Action, error) {
action := &models.Action{} action := &models.Action{}
err := p.DB.GetContext(ctx, action, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? AND action_type = 'gave_clue' ORDER BY created_at DESC LIMIT 1`, roomID) err := sqlx.GetContext(ctx, p.DB, action, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? AND action_type = 'gave_clue' ORDER BY created_at DESC LIMIT 1`, roomID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
action.CreatedAt = time.Unix(0, action.CreatedAtUnix)
return action, nil return action, nil
} }
func (p *RepoProvider) DeleteActionsByRoomID(ctx context.Context, roomID string) error { func (p *RepoProvider) ActionDeleteByRoomID(ctx context.Context, roomID string) error {
_, err := p.DB.ExecContext(ctx, `DELETE FROM actions WHERE room_id = ?`, roomID) 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 return err
} }

View File

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

44
repos/card_marks.go Normal file
View File

@ -0,0 +1,44 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
type CardMarksRepo interface {
CardMarksByCardID(ctx context.Context, cardID uint32) ([]models.CardMark, error)
CardMarksAdd(ctx context.Context, cm *models.CardMark) error
CardMarksRemove(ctx context.Context, cardID uint32, username string) error
CardMarksByRoomID(ctx context.Context, roomID string) ([]models.CardMark, error)
CardMarksRemoveByRoomID(ctx context.Context, roomID string) error
}
func (r *RepoProvider) CardMarksByCardID(ctx context.Context, cardID uint32) ([]models.CardMark, error) {
var cardMarks []models.CardMark
err := sqlx.SelectContext(ctx, getDB(ctx, r.DB), &cardMarks, "SELECT * FROM card_marks WHERE card_id = ?", cardID)
return cardMarks, err
}
func (r *RepoProvider) CardMarksAdd(ctx context.Context, cm *models.CardMark) error {
_, err := getDB(ctx, r.DB).ExecContext(ctx, "INSERT INTO card_marks (card_id, username) VALUES (?, ?)", cm.CardID, cm.Username)
return err
}
func (r *RepoProvider) CardMarksRemove(ctx context.Context, cardID uint32, username string) error {
db := getDB(ctx, r.DB)
_, err := db.ExecContext(ctx, "DELETE FROM card_marks WHERE card_id = ? AND username = ?", cardID, username)
return err
}
func (r *RepoProvider) CardMarksByRoomID(ctx context.Context, roomID string) ([]models.CardMark, error) {
var cardMarks []models.CardMark
err := sqlx.SelectContext(ctx, getDB(ctx, r.DB), &cardMarks, "SELECT * FROM card_marks WHERE card_id IN (select id from word_cards where room_id = ?)", roomID)
return cardMarks, err
}
func (r *RepoProvider) CardMarksRemoveByRoomID(ctx context.Context, roomID string) error {
db := getDB(ctx, r.DB)
_, err := db.ExecContext(ctx, "DELETE FROM card_marks WHERE 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

@ -1,8 +1,12 @@
package repos package repos
import ( import (
"context"
"gralias/config"
"log/slog" "log/slog"
"os" "os"
"sync"
"time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@ -12,20 +16,120 @@ type AllRepos interface {
RoomsRepo RoomsRepo
ActionsRepo ActionsRepo
PlayersRepo PlayersRepo
SessionsRepo
WordCardsRepo
SettingsRepo
CardMarksRepo
PlayerStatsRepo
JournalRepo
InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error)
Close()
} }
type RepoProvider struct { type RepoProvider struct {
DB *sqlx.DB DB *sqlx.DB
mu sync.RWMutex
pathToDB string
} }
func NewRepoProvider(pathToDB string) *RepoProvider { var RP AllRepos
func init() {
cfg := config.LoadConfigOrDefault("")
// sqlite3 has lock on write, so we need to have only one connection per whole app
// https://github.com/mattn/go-sqlite3/issues/274#issuecomment-232942571
RP = NewRepoProvider(cfg.DBPath)
}
func NewRepoProvider(pathToDB string) AllRepos {
db, err := sqlx.Connect("sqlite3", pathToDB) db, err := sqlx.Connect("sqlite3", pathToDB)
if err != nil { if err != nil {
slog.Error("Unable to connect to database", "error", err) slog.Error("Unable to connect to database", "error", err, "pathToDB", pathToDB)
os.Exit(1) os.Exit(1)
} }
stmts := []string{
"PRAGMA foreign_keys = ON;",
"PRAGMA busy_timeout=200;",
}
for _, stmt := range stmts {
_, err = db.Exec(stmt)
if err != nil {
slog.Error("Unable to enable foreign keys", "error", err)
os.Exit(1)
}
}
slog.Info("Successfully connected to database") slog.Info("Successfully connected to database")
return &RepoProvider{ // db.SetMaxOpenConns(2)
rp := &RepoProvider{
DB: db, DB: db,
pathToDB: pathToDB,
}
go rp.pingLoop()
return rp
}
func (rp *RepoProvider) pingLoop() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := rp.pingDB(); err != nil {
slog.Error("Database ping failed, attempting to reconnect...", "error", err)
rp.reconnect()
} }
} }
}
func (rp *RepoProvider) pingDB() error {
rp.mu.RLock()
defer rp.mu.RUnlock()
if rp.DB == nil {
return os.ErrClosed
}
return rp.DB.Ping()
}
func (rp *RepoProvider) reconnect() {
rp.mu.Lock()
defer rp.mu.Unlock()
// Double-check if connection is still down
if rp.DB != nil {
if err := rp.DB.Ping(); err == nil {
slog.Info("Database connection already re-established.")
return
}
// if ping fails, we continue to reconnect
rp.DB.Close() // close old connection
}
slog.Info("Reconnecting to database...")
db, err := sqlx.Connect("sqlite3", rp.pathToDB)
if err != nil {
slog.Error("Failed to reconnect to database", "error", err)
rp.DB = nil // make sure DB is nil if connection failed
return
}
rp.DB = db
slog.Info("Successfully reconnected to database")
}
func getDB(ctx context.Context, db *sqlx.DB) sqlx.ExtContext {
if tx, ok := ctx.Value("tx").(*sqlx.Tx); ok {
return tx
}
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,66 +2,87 @@ package repos
import ( import (
"context" "context"
"fmt"
"gralias/models" "gralias/models"
"github.com/jmoiron/sqlx"
) )
type PlayersRepo interface { type PlayersRepo interface {
PlayerGetByName(username string) (*models.Player, error) PlayerGetByName(ctx context.Context, username string) (*models.Player, error)
PlayerAdd(player *models.Player) error PlayerAdd(ctx context.Context, player *models.Player) error
PlayerUpdate(player *models.Player) error PlayerUpdate(ctx context.Context, player *models.Player) error
PlayerDelete(roomID, username string) error PlayerDelete(ctx context.Context, username string) error
PlayerSetRoomID(username, roomID string) error PlayerSetRoomID(ctx context.Context, roomID, username string) error
PlayerExitRoom(username string) error PlayerExitRoom(ctx context.Context, username string) error
PlayerListNames() ([]string, error) PlayerListNames(ctx context.Context) ([]string, error)
PlayerList(isBot bool) ([]models.Player, error) PlayerList(ctx context.Context, isBot bool) ([]models.Player, error)
PlayerListAll(ctx context.Context) ([]models.Player, error)
PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error)
PlayerGetMaxID(ctx context.Context) (uint32, error)
} }
func (p *RepoProvider) PlayerListNames() ([]string, error) { func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
var names []string var names []string
err := p.DB.SelectContext(context.Background(), &names, "SELECT username FROM players;") err := sqlx.SelectContext(ctx, p.DB, &names, "SELECT username FROM players;")
if err != nil { if err != nil {
return nil, err return nil, err
} }
return names, nil return names, nil
} }
func (p *RepoProvider) PlayerGetByName(username string) (*models.Player, error) { func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*models.Player, error) {
var player models.Player var player models.Player
err := p.DB.GetContext(context.Background(), &player, "SELECT id, room_id, username, team, role, is_bot FROM players WHERE username = ?", username) err := sqlx.GetContext(ctx, p.DB, &player, "SELECT id, room_id, username, team, role, is_bot, password FROM players WHERE username = ?", username)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if player.RoomID != nil && *player.RoomID == "" {
player.RoomID = nil
}
return &player, nil return &player, nil
} }
func (p *RepoProvider) PlayerAdd(player *models.Player) error { func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error {
_, err := p.DB.ExecContext(context.Background(), "INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)", db := getDB(ctx, p.DB)
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 return err
} }
func (p *RepoProvider) PlayerUpdate(player *models.Player) error { func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error {
_, err := p.DB.ExecContext(context.Background(), "UPDATE players SET room_id = ?, username = ?, team = ?, role = ?, is_bot = ? WHERE id = ?", db := getDB(ctx, p.DB)
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 return err
} }
func (p *RepoProvider) PlayerDelete(roomID, username string) error { func (p *RepoProvider) PlayerDelete(ctx context.Context, username string) error {
_, err := p.DB.ExecContext(context.Background(), "DELETE FROM players WHERE room_id = ? AND username = ?", roomID, username) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "DELETE FROM players WHERE username = ?", username)
return err return err
} }
func (p *RepoProvider) PlayerSetRoomID(username, roomID string) error { func (p *RepoProvider) PlayerSetRoomID(ctx context.Context, roomID, username string) error {
_, err := p.DB.ExecContext(context.Background(), "UPDATE players SET room_id = ? WHERE username = ?", roomID, username) db := getDB(ctx, p.DB)
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=null, team='', role='' WHERE username = ?", username)
return err return err
} }
func (p *RepoProvider) PlayerExitRoom(username string) error { func (p *RepoProvider) PlayerList(ctx context.Context, isBot bool) ([]models.Player, error) {
_, err := p.DB.ExecContext(context.Background(), "UPDATE players SET room_id = null WHERE username = ?", username)
return err
}
func (p *RepoProvider) PlayerList(isBot bool) ([]models.Player, error) {
var players []models.Player var players []models.Player
var query string var query string
if isBot { if isBot {
@ -69,7 +90,35 @@ func (p *RepoProvider) PlayerList(isBot bool) ([]models.Player, error) {
} else { } else {
query = "SELECT id, room_id, username, team, role, is_bot FROM players WHERE is_bot = false;" query = "SELECT id, room_id, username, team, role, is_bot FROM players WHERE is_bot = false;"
} }
err := p.DB.SelectContext(context.Background(), &players, query) err := sqlx.SelectContext(ctx, p.DB, &players, query)
if err != nil {
return nil, err
}
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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -1,12 +1,13 @@
package repos package repos
import ( import (
"context"
"gralias/models" "gralias/models"
"testing" "testing"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
) )
func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) { func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
@ -18,6 +19,7 @@ func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT, room_id TEXT,
username TEXT, username TEXT,
password TEXT NOT NULL DEFAULT '',
team TEXT, team TEXT,
role TEXT, role TEXT,
is_bot BOOLEAN is_bot BOOLEAN
@ -37,15 +39,16 @@ func TestPlayersRepo_AddPlayer(t *testing.T) {
repo := &RepoProvider{DB: db} repo := &RepoProvider{DB: db}
roomID := "test_room_player_1"
player := &models.Player{ player := &models.Player{
RoomID: "test_room_player_1", RoomID: &roomID,
Username: "test_player_1", Username: "test_player_1",
Team: "blue", Team: "blue",
Role: "player", Role: "player",
IsBot: false, IsBot: false,
} }
err := repo.AddPlayer(player) err := repo.PlayerAdd(context.Background(), player)
assert.NoError(t, err) assert.NoError(t, err)
var retrievedPlayer models.Player var retrievedPlayer models.Player
@ -60,8 +63,9 @@ func TestPlayersRepo_GetPlayer(t *testing.T) {
repo := &RepoProvider{DB: db} repo := &RepoProvider{DB: db}
roomID := "test_room_player_2"
player := &models.Player{ player := &models.Player{
RoomID: "test_room_player_2", RoomID: &roomID,
Username: "test_player_2", Username: "test_player_2",
Team: "red", Team: "red",
Role: "player", Role: "player",
@ -71,7 +75,7 @@ func TestPlayersRepo_GetPlayer(t *testing.T) {
_, err := db.Exec(`INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)`, player.RoomID, player.Username, player.Team, player.Role, player.IsBot) _, err := db.Exec(`INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)`, player.RoomID, player.Username, player.Team, player.Role, player.IsBot)
assert.NoError(t, err) assert.NoError(t, err)
retrievedPlayer, err := repo.GetPlayer(player.RoomID, player.Username) retrievedPlayer, err := repo.PlayerGetByName(context.Background(), player.Username)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, retrievedPlayer) assert.NotNil(t, retrievedPlayer)
assert.Equal(t, player.Username, retrievedPlayer.Username) assert.Equal(t, player.Username, retrievedPlayer.Username)
@ -83,8 +87,9 @@ func TestPlayersRepo_DeletePlayer(t *testing.T) {
repo := &RepoProvider{DB: db} repo := &RepoProvider{DB: db}
roomID := "test_room_player_3"
player := &models.Player{ player := &models.Player{
RoomID: "test_room_player_3", RoomID: &roomID,
Username: "test_player_3", Username: "test_player_3",
Team: "blue", Team: "blue",
Role: "player", Role: "player",
@ -94,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) _, err := db.Exec(`INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)`, player.RoomID, player.Username, player.Team, player.Role, player.IsBot)
assert.NoError(t, err) assert.NoError(t, err)
err = repo.DeletePlayer(player.RoomID, player.Username) err = repo.PlayerDelete(context.Background(), player.Username)
assert.NoError(t, err) assert.NoError(t, err)
var count int var count int
@ -102,3 +107,4 @@ func TestPlayersRepo_DeletePlayer(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 0, count) assert.Equal(t, 0, count)
} }

View File

@ -2,7 +2,10 @@ package repos
import ( import (
"context" "context"
"fmt"
"gralias/models" "gralias/models"
"github.com/jmoiron/sqlx"
) )
type RoomsRepo interface { type RoomsRepo interface {
@ -16,7 +19,7 @@ type RoomsRepo interface {
func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) { func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {
rooms := []*models.Room{} rooms := []*models.Room{}
err := p.DB.SelectContext(ctx, &rooms, `SELECT * FROM rooms`) err := sqlx.SelectContext(ctx, p.DB, &rooms, `SELECT * FROM rooms`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -25,42 +28,64 @@ func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {
func (p *RepoProvider) RoomGetByID(ctx context.Context, id string) (*models.Room, error) { func (p *RepoProvider) RoomGetByID(ctx context.Context, id string) (*models.Room, error) {
room := &models.Room{} room := &models.Room{}
err := p.DB.GetContext(ctx, room, `SELECT * FROM rooms WHERE id = ?`, id) err := sqlx.GetContext(ctx, p.DB, room, `SELECT * FROM rooms WHERE id = ?`, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
settings := &models.GameSettings{}
err = sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, id)
if err != nil {
return nil, err
}
room.Settings = *settings
return room, nil return room, nil
} }
func (p *RepoProvider) RoomCreate(ctx context.Context, r *models.Room) error { func (p *RepoProvider) RoomCreate(ctx context.Context, r *models.Room) error {
_, err := p.DB.ExecContext(ctx, `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, r.ID, r.CreatedAt, r.CreatorName, r.TeamTurn, r.ThisTurnLimit, r.OpenedThisTurn, r.BlueCounter, r.RedCounter, r.RedTurn, r.MimeDone, r.IsRunning, r.IsOver, r.TeamWon, r.RoomLink) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, r.ID, r.CreatedAt, r.CreatorName, r.TeamTurn, r.ThisTurnLimit, r.OpenedThisTurn, r.BlueCounter, r.RedCounter, r.RedTurn, r.MimeDone, r.IsRunning, r.IsOver, r.TeamWon, r.RoomLink)
if err != nil {
return err
}
_, err = db.ExecContext(ctx, `INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, r.ID, r.Settings.Language, r.Settings.RoomPass, r.Settings.RoundTime)
return err return err
} }
func (p *RepoProvider) RoomDeleteByID(ctx context.Context, id string) error { func (p *RepoProvider) RoomDeleteByID(ctx context.Context, id string) error {
_, err := p.DB.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, id) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, id)
return err return err
} }
func (p *RepoProvider) RoomUpdate(ctx context.Context, r *models.Room) error { func (p *RepoProvider) RoomUpdate(ctx context.Context, r *models.Room) error {
_, err := p.DB.ExecContext(ctx, `UPDATE rooms SET team_turn = ?, this_turn_limit = ?, opened_this_turn = ?, blue_counter = ?, red_counter = ?, red_turn = ?, mime_done = ?, = ?, is_running = ?, is_over = ?, team_won = ?, room_link = ? WHERE id = ?`, r.TeamTurn, r.ThisTurnLimit, r.OpenedThisTurn, r.BlueCounter, r.RedCounter, r.RedTurn, r.MimeDone, r.IsRunning, r.IsOver, r.TeamWon, r.RoomLink, r.ID) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `UPDATE rooms SET team_turn = ?, this_turn_limit = ?, opened_this_turn = ?, blue_counter = ?, red_counter = ?, red_turn = ?, mime_done = ?, is_running = ?, is_over = ?, team_won = ?, room_link = ? WHERE id = ?`, r.TeamTurn, r.ThisTurnLimit, r.OpenedThisTurn, r.BlueCounter, r.RedCounter, r.RedTurn, r.MimeDone, r.IsRunning, r.IsOver, r.TeamWon, r.RoomLink, r.ID)
if err != nil {
return err
}
_, err = db.ExecContext(ctx, `UPDATE settings SET language = ?, room_pass = ?, turn_time = ? WHERE room_id = ?`, r.Settings.Language, r.Settings.RoomPass, r.Settings.RoundTime, r.ID)
return err return err
} }
func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.Room, error) { func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.Room, error) {
room := &models.Room{} room := &models.Room{}
err := p.DB.GetContext(ctx, room, `SELECT * FROM rooms WHERE id = ?`, id) err := sqlx.GetContext(ctx, p.DB, room, `SELECT * FROM rooms WHERE id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get room; %w", err)
return nil, err return nil, err
} }
// Get players // Get players
players := []*models.Player{} players := []*models.Player{}
err = p.DB.SelectContext(ctx, &players, `SELECT * FROM players WHERE room_id = ?`, id) err = sqlx.SelectContext(ctx, p.DB, &players, `SELECT * FROM players WHERE room_id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get players; %w", err)
return nil, err return nil, err
} }
room.RedTeam.Color = string(models.UserTeamRed) room.RedTeam.Color = string(models.UserTeamRed)
room.BlueTeam.Color = string(models.UserTeamBlue) room.BlueTeam.Color = string(models.UserTeamBlue)
if room.BotMap == nil {
room.BotMap = make(map[string]models.BotPlayer)
}
for _, player := range players { for _, player := range players {
if player.Team == models.UserTeamRed { if player.Team == models.UserTeamRed {
if player.Role == models.UserRoleMime { if player.Role == models.UserRoleMime {
@ -76,9 +101,6 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
} }
} }
if player.IsBot { if player.IsBot {
if room.BotMap == nil {
room.BotMap = make(map[string]models.BotPlayer)
}
room.BotMap[player.Username] = models.BotPlayer{ room.BotMap[player.Username] = models.BotPlayer{
Role: player.Role, Role: player.Role,
Team: player.Team, Team: player.Team,
@ -87,24 +109,34 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
} }
// Get word cards // Get word cards
wordCards := []models.WordCard{} wordCards := []models.WordCard{}
err = p.DB.SelectContext(ctx, &wordCards, `SELECT * FROM word_cards WHERE room_id = ?`, id) err = sqlx.SelectContext(ctx, p.DB, &wordCards, `SELECT * FROM word_cards WHERE room_id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get cards; %w", err)
return nil, err return nil, err
} }
room.Cards = wordCards room.Cards = wordCards
// Get actions // Get actions
actions := []models.Action{} actions := []models.Action{}
err = p.DB.SelectContext(ctx, &actions, `SELECT * FROM actions WHERE room_id = ? ORDER BY created_at ASC`, id) err = sqlx.SelectContext(ctx, p.DB, &actions, `SELECT * FROM actions WHERE room_id = ? ORDER BY created_at ASC`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get actions; %w", err)
return nil, err return nil, err
} }
room.ActionHistory = actions room.ActionHistory = actions
// Get settings // Get settings
settings := &models.GameSettings{} settings := &models.GameSettings{}
err = p.DB.GetContext(ctx, settings, `SELECT * FROM settings WHERE room_id = ?`, id) err = sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, id)
if err != nil { if err != nil {
err = fmt.Errorf("failed to get settings; %w", err)
return nil, err return nil, err
} }
room.Settings = *settings room.Settings = *settings
// get log jounral
journals := []models.Journal{}
err = sqlx.SelectContext(ctx, p.DB, &journals, `SELECT id, created_at, entry, username, room_id FROM journal WHERE room_id = ? ORDER BY created_at ASC`, room.ID)
if err != nil {
return nil, err
}
room.LogJournal = journals
return room, nil return room, nil
} }

View File

@ -7,33 +7,94 @@ import (
"time" "time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
) )
func setupTestDB(t *testing.T) (*sqlx.DB, func()) { func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
db, err := sqlx.Connect("sqlite3", ":memory:") db, err := sqlx.Connect("sqlite3", ":memory:")
assert.NoError(t, err) assert.NoError(t, err)
// Enable foreign key constraints for SQLite
_, err = db.Exec("PRAGMA foreign_keys = ON;")
assert.NoError(t, err)
schema := ` schema := `
CREATE TABLE IF NOT EXISTS rooms ( CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
created_at DATETIME, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
creator_name TEXT, creator_name TEXT NOT NULL,
team_turn TEXT, team_turn TEXT NOT NULL DEFAULT '',
this_turn_limit INTEGER, this_turn_limit INTEGER NOT NULL DEFAULT 0,
opened_this_turn INTEGER, opened_this_turn INTEGER NOT NULL DEFAULT 0,
blue_counter INTEGER, blue_counter INTEGER NOT NULL DEFAULT 0,
red_counter INTEGER, red_counter INTEGER NOT NULL DEFAULT 0,
red_turn BOOLEAN, red_turn BOOLEAN NOT NULL DEFAULT FALSE,
mime_done BOOLEAN, mime_done BOOLEAN NOT NULL DEFAULT FALSE,
is_public BOOLEAN, is_running BOOLEAN NOT NULL DEFAULT FALSE,
is_running BOOLEAN, is_over BOOLEAN NOT NULL DEFAULT FALSE,
language TEXT, team_won TEXT NOT NULL DEFAULT '',
round_time INTEGER, room_link TEXT NOT NULL DEFAULT ''
is_over BOOLEAN, );
team_won TEXT,
room_pass TEXT 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,
language TEXT NOT NULL DEFAULT 'en',
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) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions(
id INTEGER PRIMARY KEY AUTOINCREMENT,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
lifetime INTEGER NOT NULL DEFAULT 3600,
token_key TEXT NOT NULL DEFAULT '' UNIQUE,
username TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
); );
` `
_, err = db.Exec(schema) _, err = db.Exec(schema)
@ -61,16 +122,18 @@ func TestRoomsRepo_CreateRoom(t *testing.T) {
RedCounter: 0, RedCounter: 0,
RedTurn: false, RedTurn: false,
MimeDone: false, MimeDone: false,
IsPublic: true,
IsRunning: false, IsRunning: false,
Language: "en",
RoundTime: 60,
IsOver: false, IsOver: false,
TeamWon: "", TeamWon: "",
RoomLink: "",
Settings: models.GameSettings{
Language: "en",
RoundTime: 60,
RoomPass: "", RoomPass: "",
},
} }
err := repo.CreateRoom(context.Background(), room) err := repo.RoomCreate(context.Background(), room)
assert.NoError(t, err) assert.NoError(t, err)
// Verify the room was created // Verify the room was created
@ -79,6 +142,14 @@ func TestRoomsRepo_CreateRoom(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, room.ID, retrievedRoom.ID) assert.Equal(t, room.ID, retrievedRoom.ID)
assert.Equal(t, room.CreatorName, retrievedRoom.CreatorName) assert.Equal(t, room.CreatorName, retrievedRoom.CreatorName)
var retrievedSettings models.GameSettings
err = db.Get(&retrievedSettings, "SELECT id, language, room_pass, turn_time FROM settings WHERE room_id = ?", room.ID)
assert.NoError(t, err)
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) { func TestRoomsRepo_GetRoomByID(t *testing.T) {
@ -98,24 +169,29 @@ func TestRoomsRepo_GetRoomByID(t *testing.T) {
RedCounter: 0, RedCounter: 0,
RedTurn: true, RedTurn: true,
MimeDone: false, MimeDone: false,
IsPublic: true,
IsRunning: false, IsRunning: false,
Language: "en",
RoundTime: 60,
IsOver: false, IsOver: false,
TeamWon: "", TeamWon: "",
RoomLink: "",
Settings: models.GameSettings{
Language: "en",
RoundTime: 60,
RoomPass: "", RoomPass: "",
},
} }
// Insert a room directly into the database for testing GetRoomByID // Insert a room directly into the database for testing GetRoomByID
_, 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_public, is_running, language, round_time, is_over, team_won, room_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsPublic, room.IsRunning, room.Language, room.RoundTime, room.IsOver, room.TeamWon, room.RoomPass) _, 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) assert.NoError(t, err)
retrievedRoom, err := repo.GetRoomByID(context.Background(), room.ID) retrievedRoom, err := repo.RoomGetByID(context.Background(), room.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, retrievedRoom) assert.NotNil(t, retrievedRoom)
assert.Equal(t, room.ID, retrievedRoom.ID) assert.Equal(t, room.ID, retrievedRoom.ID)
assert.Equal(t, room.CreatorName, retrievedRoom.CreatorName) assert.Equal(t, room.CreatorName, retrievedRoom.CreatorName)
assert.Equal(t, room.Settings.Language, retrievedRoom.Settings.Language)
} }
func TestRoomsRepo_ListRooms(t *testing.T) { func TestRoomsRepo_ListRooms(t *testing.T) {
@ -135,13 +211,15 @@ func TestRoomsRepo_ListRooms(t *testing.T) {
RedCounter: 0, RedCounter: 0,
RedTurn: false, RedTurn: false,
MimeDone: false, MimeDone: false,
IsPublic: true,
IsRunning: false, IsRunning: false,
Language: "en",
RoundTime: 60,
IsOver: false, IsOver: false,
TeamWon: "", TeamWon: "",
RoomLink: "",
Settings: models.GameSettings{
Language: "en",
RoundTime: 60,
RoomPass: "", RoomPass: "",
},
} }
room2 := &models.Room{ room2 := &models.Room{
ID: "list_room_2", ID: "list_room_2",
@ -154,21 +232,28 @@ func TestRoomsRepo_ListRooms(t *testing.T) {
RedCounter: 0, RedCounter: 0,
RedTurn: true, RedTurn: true,
MimeDone: false, MimeDone: false,
IsPublic: true,
IsRunning: false, IsRunning: false,
Language: "en",
RoundTime: 60,
IsOver: false, IsOver: false,
TeamWon: "", TeamWon: "",
RoomLink: "",
Settings: models.GameSettings{
Language: "en",
RoundTime: 60,
RoomPass: "", RoomPass: "",
},
} }
_, 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_public, is_running, language, round_time, is_over, team_won, room_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room1.ID, room1.CreatedAt, room1.CreatorName, room1.TeamTurn, room1.ThisTurnLimit, room1.OpenedThisTurn, room1.BlueCounter, room1.RedCounter, room1.RedTurn, room1.MimeDone, room1.IsPublic, room1.IsRunning, room1.Language, room1.RoundTime, room1.IsOver, room1.TeamWon, room1.RoomPass) _, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room1.ID, room1.CreatedAt, room1.CreatorName, room1.TeamTurn, room1.ThisTurnLimit, room1.OpenedThisTurn, room1.BlueCounter, room1.RedCounter, room1.RedTurn, room1.MimeDone, room1.IsRunning, room1.IsOver, room1.TeamWon, room1.RoomLink)
assert.NoError(t, err) assert.NoError(t, err)
_, 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_public, is_running, language, round_time, is_over, team_won, room_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room2.ID, room2.CreatedAt, room2.CreatorName, room2.TeamTurn, room2.ThisTurnLimit, room2.OpenedThisTurn, room2.BlueCounter, room2.RedCounter, room2.RedTurn, room2.MimeDone, room2.IsPublic, room2.IsRunning, room2.Language, room2.RoundTime, room2.IsOver, room2.TeamWon, room2.RoomPass) _, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room1.ID, room1.Settings.Language, room1.Settings.RoomPass, room1.Settings.RoundTime)
assert.NoError(t, err) assert.NoError(t, err)
rooms, err := repo.ListRooms(context.Background()) _, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room2.ID, room2.CreatedAt, room2.CreatorName, room2.TeamTurn, room2.ThisTurnLimit, room2.OpenedThisTurn, room2.BlueCounter, room2.RedCounter, room2.RedTurn, room2.MimeDone, room2.IsRunning, room2.IsOver, room2.TeamWon, room2.RoomLink)
assert.NoError(t, err)
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room2.ID, room2.Settings.Language, room2.Settings.RoomPass, room2.Settings.RoundTime)
assert.NoError(t, err)
rooms, err := repo.RoomList(context.Background())
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, rooms, 2) assert.Len(t, rooms, 2)
} }
@ -190,25 +275,48 @@ func TestRoomsRepo_DeleteRoomByID(t *testing.T) {
RedCounter: 0, RedCounter: 0,
RedTurn: false, RedTurn: false,
MimeDone: false, MimeDone: false,
IsPublic: true,
IsRunning: false, IsRunning: false,
Language: "en",
RoundTime: 60,
IsOver: false, IsOver: false,
TeamWon: "", TeamWon: "",
RoomLink: "",
Settings: models.GameSettings{
Language: "en",
RoundTime: 60,
RoomPass: "", RoomPass: "",
},
} }
_, 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_public, is_running, language, round_time, is_over, team_won, room_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsPublic, room.IsRunning, room.Language, room.RoundTime, room.IsOver, room.TeamWon, room.RoomPass) _, 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) assert.NoError(t, err)
err = repo.DeleteRoomByID(context.Background(), room.ID) // 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) assert.NoError(t, err)
var count int var count int
err = db.Get(&count, "SELECT COUNT(*) FROM rooms WHERE id = ?", room.ID) err = db.Get(&count, "SELECT COUNT(*) FROM rooms WHERE id = ?", room.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 0, count) assert.Equal(t, 0, count)
err = db.Get(&count, "SELECT COUNT(*) FROM 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) { func TestRoomsRepo_UpdateRoom(t *testing.T) {
@ -228,22 +336,28 @@ func TestRoomsRepo_UpdateRoom(t *testing.T) {
RedCounter: 0, RedCounter: 0,
RedTurn: false, RedTurn: false,
MimeDone: false, MimeDone: false,
IsPublic: true,
IsRunning: false, IsRunning: false,
Language: "en",
RoundTime: 60,
IsOver: false, IsOver: false,
TeamWon: "", TeamWon: "",
RoomLink: "",
Settings: models.GameSettings{
Language: "en",
RoundTime: 60,
RoomPass: "", RoomPass: "",
},
} }
_, 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_public, is_running, language, round_time, is_over, team_won, room_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsPublic, room.IsRunning, room.Language, room.RoundTime, room.IsOver, room.TeamWon, room.RoomPass) 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) assert.NoError(t, err)
room.TeamTurn = "red" room.TeamTurn = "red"
room.BlueCounter = 10 room.BlueCounter = 10
room.Settings.RoundTime = 120
err = repo.UpdateRoom(context.Background(), room) err = repo.RoomUpdate(context.Background(), room)
assert.NoError(t, err) assert.NoError(t, err)
var updatedRoom models.Room var updatedRoom models.Room
@ -251,4 +365,10 @@ func TestRoomsRepo_UpdateRoom(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, models.UserTeam("red"), updatedRoom.TeamTurn) assert.Equal(t, models.UserTeam("red"), updatedRoom.TeamTurn)
assert.Equal(t, uint8(10), updatedRoom.BlueCounter) assert.Equal(t, uint8(10), updatedRoom.BlueCounter)
var updatedSettings models.GameSettings
err = db.Get(&updatedSettings, "SELECT * FROM settings WHERE room_id = ?", room.ID)
assert.NoError(t, err)
} }

49
repos/session.go Normal file
View File

@ -0,0 +1,49 @@
package repos
import (
"context"
"gralias/models"
"time"
"github.com/jmoiron/sqlx"
)
type SessionsRepo interface {
SessionByToken(ctx context.Context, token string) (*models.Session, error)
SessionCreate(ctx context.Context, session *models.Session) error
SessionUpdate(ctx context.Context, session *models.Session) error
SessionDelete(ctx context.Context, token string) error
}
func (p *RepoProvider) SessionByToken(ctx context.Context, token string) (*models.Session, error) {
db := getDB(ctx, p.DB)
session := &models.Session{}
// The lifetime in the DB is in seconds, but in the model it is in minutes.
err := sqlx.GetContext(ctx, db, session, `SELECT id, updated_at, lifetime / 60 as lifetime, token_key, username FROM sessions WHERE token_key = ? LIMIT 1;`, token)
if err != nil {
return nil, err
}
return session, nil
}
func (p *RepoProvider) SessionCreate(ctx context.Context, session *models.Session) error {
db := getDB(ctx, p.DB)
// The lifetime in the model is in minutes, but in the DB it is in seconds.
_, err := db.ExecContext(ctx, `INSERT INTO sessions (updated_at, lifetime, token_key, username) VALUES (?, ?, ?, ?) ON CONFLICT (token_key) DO UPDATE SET updated_at=CURRENT_TIMESTAMP, lifetime=excluded.lifetime;`,
time.Now(), session.Lifetime*60, session.TokenKey, session.Username)
return err
}
func (p *RepoProvider) SessionUpdate(ctx context.Context, session *models.Session) error {
db := getDB(ctx, p.DB)
// The lifetime in the model is in minutes, but in the DB it is in seconds.
_, err := db.ExecContext(ctx, `UPDATE sessions SET updated_at = ?, lifetime = ? WHERE token_key = ?`,
time.Now(), session.Lifetime*60, session.TokenKey)
return err
}
func (p *RepoProvider) SessionDelete(ctx context.Context, token string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE token_key = ?`, token)
return err
}

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

@ -1,4 +1,6 @@
### feats ### feats
- implement transactional pattern in db write methods; +
- implement the db methods for sessions in repos/session.go; +
- auto close room if nothing is going on there (hmm) for ~1h; + - auto close room if nothing is going on there (hmm) for ~1h; +
- words database (file) load and form random 25 words; + - words database (file) load and form random 25 words; +
- invite link; + - invite link; +
@ -19,6 +21,8 @@
- redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; + - redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
- better styles and fluff; - better styles and fluff;
- common auth system between sites; - common auth system between sites;
- signup vs login;
- passwords (to room and to login);
=== ===
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right; - show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
- gameover to backlog; - gameover to backlog;
@ -26,8 +30,10 @@
- ended turn action to backlog; - ended turn action to backlog;
=== ===
- clear indication that model (llm) is thinking / answered; - clear indication that model (llm) is thinking / answered;
- possibly turn markings into parts of names of users (first three letters?); - possibly turn markings into parts of names of users (first three letters?); +
- at game creation list languages and support them at backend; - at game creation list languages and support them at backend; +
- sql ping goroutine with reconnect on fail; +
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words.
#### sse points #### sse points
- clue sse update; - clue sse update;
@ -58,10 +64,28 @@
- there is a clue window for a mime before game started; + - there is a clue window for a mime before game started; +
- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine) (did not reoccur so far); - sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine) (did not reoccur so far);
- invite link gets cutoff; - invite link gets cutoff;
- when llm guesses the word it is not removed from a pool of words making it keep guessing it; - when llm guesses the word it is not removed from a pool of words making it keep guessing it; +
- bot team does not loses their turn after white card (or limit); - bot team does not loses their turn after white card (or limit); +
- name check does not work; - name check does not work;
- game did not end when all blue cards were open; - game did not end when all blue cards were open; +
- bot ends a turn after guessing one word only; - bot ends a turn after guessing one word only; +
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue; +
-----------------
- card marks; +
- on server recover relaunch guess timer if needed;
- start new game: clear last clue; mimedone to false; unload old cards; +
- backlog shows white word with opposite color;
- bot actions are not recorded; +
- bot recieves opp-color clue because of it ^; +
- old cards are still around; +
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue; - bot mime makes a clue -> no update in the room for players; +
- red moves after bot gave (metal - 3) ended after two guesses (showed in logs, that opened 3. double click on the same card? or somewhere counter is not dropped?); the first guess moved counter to 2, in logs only two requests as expected; +
- bot mime gve a blue -> timer did not start; timer should be in third package, maybe in crons; +
- marks did not clear after end of the turn (feature?) +
- start new game satrted timer for a mime; (feature? in other cases mime has no timer);
- timer ended and went to 300;
- mime sees the clue input out of turn; (eh)
- there is a problem of two timers, they both could switch turn, but it is not easy to stop them from llmapi or handlers. +
- journal still does not work; +