Compare commits

..

58 Commits

Author SHA1 Message Date
a95dc82515 Fix: check user pass only if user exists 2025-07-09 15:39:11 +03:00
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
51 changed files with 2683 additions and 735 deletions

20
Dockerfile Normal file
View File

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

View File

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

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/tailwind.css"></script>
<link rel="stylesheet" href="/assets/style.css"/>
<script src="/assets/htmx.min.js"></script> <script src="/assets/htmx.min.js"></script>
<script src="/assets/htmx.sse.js"></script> <script src="/assets/htmx.sse.js"></script>
<script src="/assets/tailwind.css"></script> <script src="/assets/helpers.js"></script>
<link rel="stylesheet" href="/assets/style.css"/>
<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,10 +28,13 @@
</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 }}
{{end}} <span class="mx-0.5">{{.Username}}</span>
{{else}}
<span class="mx-0.5">{{slice .Username 0 3}}</span>
{{end}}
{{end}} {{end}}
</div> </div>
</div> </div>

View File

@ -3,9 +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>data: {{.}} {{.State}} {{.Room}}</p>
<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,8 +8,12 @@
<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>
</form> </form>
</div> </div>

View File

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

@ -48,18 +48,19 @@
<!-- Right Panel --> <!-- Right Panel -->
{{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)
}
}

View File

@ -24,63 +24,18 @@ func createRoom(ctx context.Context, req *models.RoomReq) (*models.Room, error)
return room, nil return room, nil
} }
// // DEPRECATED
// func saveRoom(room *models.Room) error {
// key := models.CacheRoomPrefix + room.ID
// data, err := json.Marshal(room)
// if err != nil {
// return err
// }
// memcache.Set(key, data)
// // do I need last action here? since room save is kind of an action on itself
// // time.Now().Add(time.Hour).Sub(room.LastActionTS)
// anHour := int64(216000) // 60 * 60 * 60
// memcache.Expire(key, anHour)
// return nil
// }
// func getRoomByID(roomID string) (*models.Room, error) {
// roomBytes, err := memcache.Get(models.CacheRoomPrefix + roomID)
// if err != nil {
// return nil, err
// }
// resp := &models.Room{}
// if err := json.Unmarshal(roomBytes, &resp); err != nil {
// return nil, err
// }
// return resp, nil
// }
// func removeRoom(roomID string) {
// key := models.CacheRoomPrefix + roomID
// memcache.RemoveKey(key)
// }
// context
// func getStateByCtx(ctx context.Context) (*models.UserState, error) {
// username, ok := ctx.Value(models.CtxUsernameKey).(string)
// if !ok {
// log.Debug("no username in ctx")
// return &models.UserState{}, errors.New("no username in ctx")
// }
// us, err := loadState(username)
// if err != nil {
// return &models.UserState{}, err
// }
// return us, nil
// }
// func dbCreate(fi *models.FullInfo) error{
// repo.CreateRoom()
// }
func saveFullInfo(ctx context.Context, fi *models.FullInfo) error { func saveFullInfo(ctx context.Context, fi *models.FullInfo) error {
// INFO: no transactions; so case is possible where first object is updated but the second is not // INFO: no transactions; so case is possible where first object is updated but the second is not
if fi.State == nil {
return errors.New("player is nil")
}
if err := repo.PlayerUpdate(ctx, fi.State); err != nil { if err := repo.PlayerUpdate(ctx, fi.State); err != nil {
return err return err
} }
log.Debug("saved user state", "state", fi.State) log.Debug("saved user state", "state", fi.State)
// save or update
// fi.Room.Cards
// fi.Room.WCMap
if err := repo.RoomUpdate(ctx, fi.Room); err != nil { if err := repo.RoomUpdate(ctx, fi.Room); err != nil {
return err return err
} }
@ -95,68 +50,62 @@ func notifyBotIfNeeded(room *models.Room) {
} }
} }
// cache
// func saveState(username string, state *models.UserState) error {
// key := models.CacheStatePrefix + username
// data, err := json.Marshal(state)
// if err != nil {
// return err
// }
// memcache.Set(key, data)
// return nil
// }
// func getAllNames() []string {
// names := []string{}
// // will not scale
// session := &models.Session{}
// // filter by key size only sessions
// for _, name := range wholeMemStore {
// // xid is 20 in len
// if len(k) != 20 {
// continue
// }
// if err := json.Unmarshal(v, &session); err != nil {
// log.Error("failed to unmarshal", "error", err)
// continue
// }
// names = append(names, session.Username)
// }
// return names
// }
// can room exists without state? I think no // can room exists without state? I think no
func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) { func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
resp := &models.FullInfo{} resp := &models.FullInfo{}
// state, err := getStateByCtx(ctx)
state, err := getPlayerByCtx(ctx) state, err := getPlayerByCtx(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp.State = state resp.State = state
if state.RoomID == nil { 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; // room was deleted; remove it from player;
log.Warn("failed to find room despite knowing room_id;", log.Warn("failed to find room despite knowing room_id;",
"room_id", state.RoomID) "room_id", state.RoomID, "error", err)
state.Team = models.UserTeamNone state.Team = models.UserTeamNone
state.Role = models.UserRoleNone state.Role = models.UserRoleNone
if err := repo.PlayerExitRoom(ctx, state.Username); err != nil { if err := repo.PlayerExitRoom(ctx, state.Username); err != nil {
log.Warn("failed to exit room", log.Warn("failed to exit room", "error", err,
"room_id", state.RoomID, "username", state.Username) "room_id", state.RoomID, "username", state.Username)
return resp, err return resp, err
} }
return nil, err return nil, err
} }
// get card_marks
if room.IsRunning && room.MimeDone {
if err := fillCardMarks(ctx, room); err != nil {
log.Warn("failed to fill card marks", "error", err,
"room_id", state.RoomID, "username", state.Username)
return nil, err
}
}
resp.Room = room resp.Room = room
return resp, nil return resp, nil
} }
func fillCardMarks(ctx context.Context, room *models.Room) error {
marks, err := repo.CardMarksByRoomID(ctx, room.ID)
if err != nil {
log.Warn("failed to fetch card marks by room_id", "room_id", room.ID, "error", err)
return err
}
for i, card := range room.Cards {
for _, mark := range marks {
if mark.CardID == card.ID {
room.Cards[i].Marks = append(room.Cards[i].Marks, mark)
}
}
}
return nil
}
func getPlayerByCtx(ctx context.Context) (*models.Player, error) { func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
username, ok := ctx.Value(models.CtxUsernameKey).(string) username, ok := ctx.Value(models.CtxUsernameKey).(string)
if !ok { if !ok {
@ -166,18 +115,6 @@ func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
return repo.PlayerGetByName(ctx, username) return repo.PlayerGetByName(ctx, username)
} }
// // DEPRECATED
// func leaveRole(fi *models.FullInfo) {
// fi.Room.RedTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.RedTeam.Guessers)
// fi.Room.BlueTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.BlueTeam.Guessers)
// if fi.Room.RedTeam.Mime == fi.State.Username {
// fi.Room.RedTeam.Mime = ""
// }
// if fi.Room.BlueTeam.Mime == fi.State.Username {
// fi.Room.BlueTeam.Mime = ""
// }
// }
func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) { func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) {
// get username // get username
fi, _ := getFullInfoByCtx(ctx) fi, _ := getFullInfoByCtx(ctx)
@ -233,26 +170,6 @@ func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error)
return fi, nil return fi, nil
} }
// get all rooms
// func listRooms(allRooms bool) []*models.Room {
// cacheMap := memcache.GetAll()
// publicRooms := []*models.Room{}
// // no way to know if room is public until unmarshal -_-;
// for key, value := range cacheMap {
// if strings.HasPrefix(key, models.CacheRoomPrefix) {
// room := &models.Room{}
// if err := json.Unmarshal(value, &room); err != nil {
// log.Warn("failed to unmarshal room", "error", err)
// continue
// }
// if room.IsPublic || allRooms {
// publicRooms = append(publicRooms, room)
// }
// }
// }
// return publicRooms
// }
// get bots // get bots
func listBots() []models.Player { func listBots() []models.Player {
bots, err := repo.PlayerList(context.Background(), true) bots, err := repo.PlayerList(context.Background(), true)
@ -271,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",
@ -283,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() {
@ -314,35 +232,6 @@ func recoverBot(bm models.Player) error {
return nil return nil
} }
// func recoverPlayers() {
// players := listPlayers()
// for playerName, playerMap := range players {
// if err := recoverPlayer(playerMap); err != nil {
// log.Warn("failed to recover player", "playerName", playerName, "error", err)
// }
// }
// }
// func recoverPlayer(pm map[string]string) error {
// // check if room still exists
// room, err := repo.RoomGetByID(context.Background(), pm["RoomID"])
// if err != nil {
// return fmt.Errorf("no such room: %s; err: %w", pm["RoomID"], err)
// }
// log.Debug("recovering player", "player", pm)
// role, team, ok := room.GetPlayerByName(pm["Username"])
// if !ok {
// return fmt.Errorf("failed to find player %s in the room %v", pm["Username"], room)
// }
// us := &models.Player{
// Username: pm["Username"],
// RoomID: pm["RoomID"],
// Team: team,
// Role: role,
// }
// return saveState(pm["Username"], us)
// }
// validateMove checks if it is players turn // validateMove checks if it is players turn
func validateMove(fi *models.FullInfo, ur models.UserRole) error { func validateMove(fi *models.FullInfo, ur models.UserRole) error {
if fi.State.Role != ur { if fi.State.Role != ur {

View File

@ -5,12 +5,11 @@ import (
"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"
@ -38,7 +37,6 @@ func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
return return
} }
cleanName := utils.RemoveSpacesFromStr(username) cleanName := utils.RemoveSpacesFromStr(username)
// allNames := getAllNames()
allNames, err := repo.PlayerListNames(r.Context()) allNames, err := repo.PlayerListNames(r.Context())
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
@ -75,10 +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 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 {
@ -86,15 +86,20 @@ 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(r.Context(), 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 makeplayer = true
} else {
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,
} }
@ -107,20 +112,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// room.PlayerList = append(room.PlayerList, fi.State.Username)
fi.Room = room
fi.List = nil fi.List = nil
fi.State.RoomID = &room.ID fi.State.RoomID = &room.ID
if err := repo.PlayerSetRoomID(r.Context(), fi.State.Username, room.ID); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// repo.RoomUpdate()
// save full info instead
// if err := saveFullInfo(r.Context(), fi); err != nil {
// abortWithError(w, err.Error())
// return
// }
} else { } else {
log.Debug("no room_id in login") log.Debug("no room_id in login")
// fi.List = listRooms(false) // fi.List = listRooms(false)
@ -130,19 +127,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 makeplayer {
userstate.Password = clearPass
if err := repo.PlayerAdd(r.Context(), userstate); err != nil { if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
// if err := saveFullInfo(r.Context(), fi); err != nil {
log.Error("failed to save state", "error", err) log.Error("failed to save state", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
} }
} }
// if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
// log.Error("failed to execute base template", "error", err)
// }
http.Redirect(w, r, "/", 302) http.Redirect(w, r, "/", 302)
} }
@ -183,36 +176,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)
if err := repo.SessionCreate(context.Background(), session); err != nil { if err != nil || player == nil {
return nil, err // make player first, since username is fk to players table
player = models.InitPlayer(username)
if err := repo.PlayerAdd(context.Background(), player); err != nil {
slog.Error("failed to create player", "username", username)
return nil, err
}
} }
// set user in session if err := repo.SessionCreate(context.Background(), session); err != nil {
if err := cacheSetSession(sessionToken, session); err != nil {
return nil, err return nil, err
} }
return cookie, nil return cookie, nil
} }
//nolint: unused
func cacheGetSession(key string) (*models.Session, error) {
userSessionB, err := cache.MemCache.Get(key)
if err != nil {
return nil, err
}
var us *models.Session
if err := json.Unmarshal(userSessionB, &us); err != nil {
return nil, err
}
return us, nil
}
func cacheSetSession(key string, session *models.Session) error {
sesb, err := json.Marshal(session)
if err != nil {
return err
}
cache.MemCache.Set(key, sesb)
cache.MemCache.Expire(key, cfg.SessionLifetime)
return nil
}

View File

@ -50,7 +50,8 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) 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,13 +166,19 @@ 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)
fi.Room.ClearMarks() }
}
if clearMarks {
fi.Room.ClearMarks()
if err := repo.CardMarksRemoveByRoomID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove marks", "error", err, "room_id", fi.Room.ID)
} }
} }
if err := saveFullInfo(r.Context(), fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
@ -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,20 +229,33 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
// Check if the current user already has an active mark on this card // Check if the current user already has an active mark on this card
found := false found := false
var newMarks []models.CardMark var newMarks []models.CardMark
for _, mark := range card.Mark { for _, mark := range card.Marks {
if mark.Username == fi.State.Username && mark.Active { if mark.Username == fi.State.Username {
found = true found = true
} else { } else {
newMarks = append(newMarks, mark) newMarks = append(newMarks, mark)
} }
} }
if !found { if !found {
newMarks = append(newMarks, models.CardMark{ cm := models.CardMark{
Username: fi.State.Username, Username: fi.State.Username,
Active: true, CardID: card.ID,
}) }
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].Mark = newMarks fi.Room.Cards[i].Marks = newMarks
cardword = fi.Room.Cards[i] cardword = fi.Room.Cards[i]
} }
if err := saveFullInfo(r.Context(), fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
@ -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

@ -40,22 +40,11 @@ func HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
} }
fi.State.RoomID = &room.ID fi.State.RoomID = &room.ID
fi.Room = room fi.Room = room
if err := repo.RoomCreate(r.Context(), room); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
log.Error("failed to create a room", "error", err)
abortWithError(w, err.Error())
return
}
if err := repo.PlayerSetRoomID(r.Context(), fi.State.Username, room.ID); err != nil {
log.Error("failed to set room id", "error", err) log.Error("failed to set room id", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// if err := saveFullInfo(r.Context(), fi); err != nil {
// msg := "failed to set current room to session"
// log.Error(msg, "error", err)
// abortWithError(w, msg)
// return
// }
notify(models.NotifyRoomListUpdate, "") notify(models.NotifyRoomListUpdate, "")
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
if err != nil { if err != nil {
@ -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(r.Context(), fi); err != nil { // Use the new context with transaction
// if err := saveFullInfo(ctx, fi); err != nil {
// if err := tx.Rollback(); err != nil {
// log.Error("failed to rollback transaction", "error", err)
// }
// abortWithError(w, err.Error())
// return
// }
// Save action history
if err := repo.ActionCreate(ctx, &action); err != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
log.Error("failed to save action", "error", err)
abortWithError(w, err.Error())
return
}
// Save word cards
for _, card := range fi.Room.Cards {
card.RoomID = fi.Room.ID // Ensure RoomID is set for each card
if err := repo.WordCardsCreate(ctx, &card); err != nil {
if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err)
}
log.Error("failed to save word card", "error", err)
abortWithError(w, err.Error())
return
}
}
if err := repo.RoomUpdate(ctx, fi.Room); err != nil {
log.Error("failed to update room", "error", err)
// nolint: errcheck
tx.Rollback()
abortWithError(w, err.Error())
return
}
// Commit the transaction
if err := tx.Commit(); err != nil {
log.Error("failed to commit transaction", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -201,7 +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
@ -226,7 +271,8 @@ func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
fi.State.RoomID = &room.ID fi.State.RoomID = &room.ID
fi.Room = room fi.Room = room
fi.List = nil fi.List = nil
if err := saveFullInfo(r.Context(), fi); err != nil { if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
log.Error("failed to set room_id for player", "error", err, "username", fi.State.Username, "room_id", room.ID)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -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(r.Context(), fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
notifyBotIfNeeded(fi.Room)
} }
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) { func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {

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) {
@ -52,7 +47,8 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Error("failed to fetch fi", "error", err) log.Error("failed to fetch fi", "error", err)
} }
if fi != nil && fi.Room != nil && fi.State != nil { // 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
@ -92,32 +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
fiToSave := &models.FullInfo{
Room: exitedRoom,
}
if err := saveFullInfo(r.Context(), fiToSave); err != nil {
abortWithError(w, err.Error())
return
}
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil { if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
log.Error("failed to exit room", "error", err)
abortWithError(w, err.Error())
return
}
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
} }
// 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
} }
@ -71,7 +70,10 @@ func GetSession(next http.Handler) http.Handler {
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

@ -3,70 +3,37 @@ package handlers
import ( import (
"context" "context"
"gralias/models" "gralias/models"
"gralias/timer"
"log/slog"
"strconv" "strconv"
"sync"
"time"
) )
type roomTimer struct { func StartTurnTimer(roomID string, timeLeft uint32) {
ticker *time.Ticker logger := slog.Default().With("room_id", roomID)
done chan bool
}
var ( onTurnEnd := func(ctx context.Context, roomID string) {
timers = make(map[string]*roomTimer) room, err := repo.RoomGetByID(context.Background(), roomID)
mu sync.Mutex if err != nil {
) logger.Error("failed to get room by id", "error", err)
return
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)
if err != nil {
log.Error("failed to get room by id", "error", err)
StopTurnTimer(roomID)
return
}
if room.Settings.TurnSecondsLeft <= 0 {
log.Info("turn time is over", "room_id", roomID)
room.ChangeTurn()
room.MimeDone = false
if err := repo.RoomUpdate(context.Background(), room); err != nil {
log.Error("failed to save room", "error", err)
}
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.TurnSecondsLeft), 10))
notifyBotIfNeeded(room)
StopTurnTimer(roomID)
return
}
room.Settings.TurnSecondsLeft--
// if err := saveRoom(room); err != nil {
// log.Error("failed to save room", "error", err)
// }
notify(models.NotifyRoomUpdatePrefix+room.ID, "")
}
} }
}() logger.Info("turn time is over")
room.ChangeTurn()
room.MimeDone = false
if err := repo.RoomUpdate(context.Background(), room); err != nil {
logger.Error("failed to save room", "error", err)
}
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
notifyBotIfNeeded(room)
}
onTick := func(ctx context.Context, roomID string, currentLeft uint32) {
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10))
}
timer.StartTurnTimer(context.Background(), roomID, timeLeft, onTurnEnd, onTick, logger)
} }
func StopTurnTimer(roomID string) { 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,25 +50,9 @@ func convertToSliceOfStrings(value any) ([]string, error) {
} }
} }
//nolint: unused
func (b *Bot) checkGuesses(tempMap map[string]any, room *models.Room) error {
guesses, err := convertToSliceOfStrings(tempMap["guesses"])
if err != nil {
b.log.Warn("failed to parse bot given guesses", "mimeResp", tempMap, "bot_name", b.BotName)
return err
}
for _, word := range guesses {
if err := b.checkGuess(word, room); err != nil {
// log error
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", word)
return err
}
}
return nil
}
func (b *Bot) checkGuess(word string, room *models.Room) error { func (b *Bot) checkGuess(word string, room *models.Room) error {
color, exists := room.WCMap[word] // color, exists := room.WCMap[word]
color, exists := room.FindColor(word)
b.log.Debug("bot trying to open card", "word", word, "color", b.log.Debug("bot trying to open card", "word", word, "color",
color, "exists", exists, "limit", room.ThisTurnLimit, color, "exists", exists, "limit", room.ThisTurnLimit,
"opened", room.OpenedThisTurn) "opened", room.OpenedThisTurn)
@ -75,8 +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),
@ -96,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):
@ -106,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 {
@ -128,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
@ -143,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
@ -162,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,
@ -185,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
} }
@ -207,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,
@ -216,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
@ -291,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) {
@ -316,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
} }
@ -360,8 +439,10 @@ 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 err := saveBot(bot); err != nil { if !recovery {
return nil, err if err := saveBot(bot); err != nil {
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)
@ -371,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 {

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

19
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"
@ -22,10 +23,10 @@ func init() {
func ListenToRequests(port string) *http.Server { func ListenToRequests(port string) *http.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
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/"))
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs)) mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs))
@ -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

@ -21,10 +21,11 @@ CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id TEXT, -- nullable room_id TEXT, -- nullable
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL DEFAULT '',
team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue' team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime' role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime'
is_bot BOOLEAN NOT NULL DEFAULT FALSE, is_bot BOOLEAN NOT NULL DEFAULT FALSE,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE word_cards ( CREATE TABLE word_cards (
@ -34,15 +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,7 +66,7 @@ CREATE TABLE settings (
room_pass TEXT NOT NULL DEFAULT '', room_pass TEXT NOT NULL DEFAULT '',
turn_time INTEGER NOT NULL DEFAULT 60, -- seconds turn_time INTEGER NOT NULL DEFAULT 60, -- seconds
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES rooms(id) FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
); );
CREATE TABLE sessions( CREATE TABLE sessions(
@ -74,5 +75,32 @@ CREATE TABLE sessions(
lifetime INTEGER NOT NULL DEFAULT 3600, lifetime INTEGER NOT NULL DEFAULT 3600,
token_key TEXT NOT NULL DEFAULT '' UNIQUE, -- encoded value token_key TEXT NOT NULL DEFAULT '' UNIQUE, -- encoded value
username TEXT NOT NULL, username TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
); );
CREATE TABLE journal(
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
entry TEXT NOT NULL DEFAULT '',
username TEXT NOT NULL,
room_id TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
);
CREATE TABLE player_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_username TEXT NOT NULL UNIQUE,
games_played INTEGER NOT NULL DEFAULT 0,
games_won INTEGER NOT NULL DEFAULT 0,
games_lost INTEGER NOT NULL DEFAULT 0,
opened_opposite_words INTEGER NOT NULL DEFAULT 0,
opened_white_words INTEGER NOT NULL DEFAULT 0,
opened_black_words INTEGER NOT NULL DEFAULT 0,
mime_winrate REAL NOT NULL DEFAULT 0.0,
guesser_winrate REAL NOT NULL DEFAULT 0.0,
played_as_mime INTEGER NOT NULL DEFAULT 0,
played_as_guesser INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (player_username) REFERENCES players(username) ON DELETE CASCADE
);

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"gralias/utils" "gralias/utils"
"strings"
"time" "time"
"github.com/rs/xid" "github.com/rs/xid"
@ -90,22 +91,22 @@ type Team struct {
} }
type Action struct { type Action 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"`
Actor string `json:"actor" db:"actor"` Actor string `json:"actor" db:"actor"`
ActorColor string `json:"actor_color" db:"actor_color"` ActorColor string `json:"actor_color" db:"actor_color"`
Action string `json:"action_type" db:"action_type"` Action string `json:"action_type" db:"action_type"`
Word string `json:"word" db:"word"` Word string `json:"word" db:"word"`
WordColor string `json:"word_color" db:"word_color"` WordColor string `json:"word_color" db:"word_color"`
Number string `json:"number_associated" db:"number_associated"` Number string `json:"number_associated" db:"number_associated"`
CreatedAt time.Time `json:"created_at" db:"-"` CreatedAt time.Time `json:"created_at" db:"created_at"`
CreatedAtUnix int64 `db:"created_at"`
} }
type Player struct { type Player struct {
ID uint32 `json:"id" db:"id"` ID uint32 `json:"id" db:"id"`
RoomID *string `json:"room_id" db:"room_id"` RoomID *string `json:"room_id" db:"room_id"`
Username string `json:"username" db:"username"` Username string `json:"username" db:"username"`
Password string `json:"-" db:"password"`
Team UserTeam `json:"team" db:"team"` Team UserTeam `json:"team" db:"team"`
Role UserRole `json:"role" db:"role"` Role UserRole `json:"role" db:"role"`
IsBot bool `json:"is_bot" db:"is_bot"` IsBot bool `json:"is_bot" db:"is_bot"`
@ -126,8 +127,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 {
@ -150,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{}
} }
} }
@ -279,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 ""
} }
@ -366,12 +397,14 @@ func (r *Room) GuesserView() {
} }
} }
func (r *Room) RevealSpecificWord(word string) { func (r *Room) RevealSpecificWord(word string) uint32 {
for i, card := range r.Cards { 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 {
@ -380,19 +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"` ID uint32 `json:"id" db:"id"`
RoomID string `db:"room_id"` RoomID string `db:"room_id"`
Language string `json:"language" example:"en" form:"language" db:"language"` Language string `json:"language" example:"en" form:"language" db:"language"`
RoomPass string `json:"room_pass" db:"room_pass"` RoomPass string `json:"room_pass" db:"room_pass"`
TurnSecondsLeft uint32 `db:"-"`
RoundTime uint32 `json:"round_time" db:"turn_time"` RoundTime uint32 `json:"round_time" db:"turn_time"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
} }
// ===== // =====
@ -432,7 +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 {
@ -441,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

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

View File

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

44
repos/card_marks.go Normal file
View File

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

35
repos/journal.go Normal file
View File

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

View File

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

View File

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

42
repos/player_stats.go Normal file
View File

@ -0,0 +1,42 @@
package repos
import (
"context"
"gralias/models"
"github.com/jmoiron/sqlx"
)
type PlayerStatsRepo interface {
GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error)
UpdatePlayerStats(ctx context.Context, stats *models.PlayerStats) error
CreatePlayerStats(ctx context.Context, username string) error
}
func (p *RepoProvider) GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error) {
stats := &models.PlayerStats{}
err := sqlx.GetContext(ctx, p.DB, stats, "SELECT * FROM player_stats WHERE player_username = ?", username)
return stats, err
}
func (p *RepoProvider) UpdatePlayerStats(ctx context.Context, stats *models.PlayerStats) error {
_, err := p.DB.NamedExecContext(ctx, `UPDATE player_stats SET
games_played = :games_played,
games_won = :games_won,
games_lost = :games_lost,
opened_opposite_words = :opened_opposite_words,
opened_white_words = :opened_white_words,
opened_black_words = :opened_black_words,
mime_winrate = :mime_winrate,
guesser_winrate = :guesser_winrate,
played_as_mime = :played_as_mime,
played_as_guesser = :played_as_guesser
WHERE player_username = :player_username`, stats)
return err
}
func (p *RepoProvider) CreatePlayerStats(ctx context.Context, username string) error {
db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "INSERT INTO player_stats (player_username) VALUES (?)", username)
return err
}

View File

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

View File

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

View File

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

35
repos/settings.go Normal file
View File

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

57
repos/settings_test.go Normal file
View File

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

59
repos/word_cards.go Normal file
View File

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

496
repos/word_cards_test.go Normal file
View File

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

View File

83
timer/timer.go Normal file
View File

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

View File

@ -21,6 +21,8 @@
- redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; + - redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
- better styles and fluff; - better styles and fluff;
- common auth system between sites; - common auth system between sites;
- signup vs login;
- passwords (to room and to login);
=== ===
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right; - show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
- gameover to backlog; - gameover to backlog;
@ -28,9 +30,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; - 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;
@ -61,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; +