Compare commits

...

46 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
45 changed files with 1740 additions and 402 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/htmx.min.js"></script>
<script src="/assets/htmx.sse.js"></script>
<script src="/assets/tailwind.css"></script> <script src="/assets/tailwind.css"></script>
<link rel="stylesheet" href="/assets/style.css"/> <link rel="stylesheet" href="/assets/style.css"/>
<script src="/assets/htmx.min.js"></script>
<script src="/assets/htmx.sse.js"></script>
<script src="/assets/helpers.js"></script>
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/> <meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/> <link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
<style type="text/css"> <style type="text/css">

View File

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

View File

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

View File

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

View File

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

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,7 +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 = "sqlite3://gralias.db" DB_PATH = "gralias.db"
[SERVICE] [SERVICE]
HOST = "localhost" HOST = "localhost"

View File

@ -39,7 +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 = "sqlite3://gralias.db" 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

View File

@ -2,6 +2,10 @@ package crons
import ( import (
"context" "context"
"database/sql"
"errors"
"gralias/broker"
"gralias/models"
"gralias/repos" "gralias/repos"
"log/slog" "log/slog"
"time" "time"
@ -25,6 +29,8 @@ func (cm *CronManager) Start() {
for range ticker.C { for range ticker.C {
cm.CleanupRooms() cm.CleanupRooms()
cm.CleanupActions() cm.CleanupActions()
cm.CleanupPlayersRoom()
ticker.Reset(30 * time.Second)
} }
}() }()
} }
@ -43,7 +49,6 @@ func (cm *CronManager) CleanupRooms() {
panic(r) panic(r)
} }
}() }()
rooms, err := cm.repo.RoomList(ctx) rooms, err := cm.repo.RoomList(ctx)
if err != nil { if err != nil {
cm.log.Error("failed to get rooms list", "err", err) cm.log.Error("failed to get rooms list", "err", err)
@ -52,22 +57,22 @@ func (cm *CronManager) CleanupRooms() {
} }
return return
} }
for _, room := range rooms { for _, room := range rooms {
players, err := cm.repo.PlayerListByRoom(ctx, room.ID) players, err := cm.repo.PlayerListByRoom(ctx, room.ID)
if err != nil { if err != nil {
cm.log.Error("failed to get players for room", "room_id", room.ID, "err", err) cm.log.Error("failed to get players for room", "room_id", room.ID, "err", err)
continue continue
} }
if len(players) == 0 { if len(players) == 0 {
cm.log.Info("deleting empty room", "room_id", room.ID) cm.log.Info("deleting empty room", "room_id", room.ID)
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil { if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
cm.log.Error("failed to delete empty room", "room_id", room.ID, "err", err) 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 continue
} }
creatorInRoom := false creatorInRoom := false
for _, player := range players { for _, player := range players {
if player.Username == room.CreatorName { if player.Username == room.CreatorName {
@ -75,12 +80,32 @@ func (cm *CronManager) CleanupRooms() {
break break
} }
} }
isInactive := false
if !creatorInRoom { // If the creator is in the room and the room is more than one hour old, check for inactivity
cm.log.Info("deleting room because creator left", "room_id", room.ID) 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 { for _, player := range players {
if player.IsBot { if player.IsBot {
if err := cm.repo.PlayerDelete(ctx, room.ID, player.Username); err != nil { if err := cm.repo.PlayerDelete(ctx, room.ID); err != nil {
cm.log.Error("failed to delete bot player", "room_id", room.ID, "username", player.Username, "err", err) cm.log.Error("failed to delete bot player", "room_id", room.ID, "username", player.Username, "err", err)
} }
} else { } else {
@ -90,14 +115,22 @@ func (cm *CronManager) CleanupRooms() {
} }
} }
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil { if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
cm.log.Error("failed to delete room after creator left", "room_id", room.ID, "err", err) cm.log.Error("failed to delete room", "room_id", room.ID, "reason", reason, "err", err)
}
if err := cm.repo.SettingsDeleteByRoomID(ctx, room.ID); err != nil {
cm.log.Error("failed to delete settings for room", "room_id", room.ID, "reason", reason, "err", err)
}
// Move to the next room
continue
} }
} }
}
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
cm.log.Error("failed to commit transaction", "err", err) cm.log.Error("failed to commit transaction", "err", err)
} }
broker.Notifier.Notifier <- broker.NotificationEvent{
EventName: models.NotifyRoomListUpdate,
Payload: "",
}
} }
func (cm *CronManager) CleanupActions() { func (cm *CronManager) CleanupActions() {
@ -114,17 +147,14 @@ func (cm *CronManager) CleanupActions() {
panic(r) panic(r)
} }
}() }()
if err := cm.repo.ActionDeleteOrphaned(ctx); err != nil {
if err := cm.repo.ActionsDeleteOrphaned(ctx); err != nil {
cm.log.Error("failed to delete orphaned actions", "err", err) cm.log.Error("failed to delete orphaned actions", "err", err)
if err := tx.Rollback(); err != nil { if err := tx.Rollback(); err != nil {
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err) cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
} }
return return
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
cm.log.Error("failed to commit transaction for actions cleanup", "err", err) 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

@ -26,6 +26,9 @@ func createRoom(ctx context.Context, req *models.RoomReq) (*models.Room, error)
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
} }
@ -65,20 +68,44 @@ func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
if err != nil { if err != nil {
// room was deleted; remove it from player; // room was deleted; remove it from player;
log.Warn("failed to find room despite knowing room_id;", log.Warn("failed to find room despite knowing room_id;",
"room_id", state.RoomID) "room_id", state.RoomID, "error", err)
state.Team = models.UserTeamNone state.Team = models.UserTeamNone
state.Role = models.UserRoleNone state.Role = models.UserRoleNone
if err := repo.PlayerExitRoom(ctx, state.Username); err != nil { if err := repo.PlayerExitRoom(ctx, state.Username); err != nil {
log.Warn("failed to exit room", log.Warn("failed to exit room", "error", err,
"room_id", state.RoomID, "username", state.Username) "room_id", state.RoomID, "username", state.Username)
return resp, err return resp, err
} }
return nil, err return nil, err
} }
// get card_marks
if room.IsRunning && room.MimeDone {
if err := fillCardMarks(ctx, room); err != nil {
log.Warn("failed to fill card marks", "error", err,
"room_id", state.RoomID, "username", state.Username)
return nil, err
}
}
resp.Room = room resp.Room = room
return resp, nil return resp, nil
} }
func fillCardMarks(ctx context.Context, room *models.Room) error {
marks, err := repo.CardMarksByRoomID(ctx, room.ID)
if err != nil {
log.Warn("failed to fetch card marks by room_id", "room_id", room.ID, "error", err)
return err
}
for i, card := range room.Cards {
for _, mark := range marks {
if mark.CardID == card.ID {
room.Cards[i].Marks = append(room.Cards[i].Marks, mark)
}
}
}
return nil
}
func getPlayerByCtx(ctx context.Context) (*models.Player, error) { func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
username, ok := ctx.Value(models.CtxUsernameKey).(string) username, ok := ctx.Value(models.CtxUsernameKey).(string)
if !ok { if !ok {
@ -88,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)
@ -155,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)
@ -193,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",
@ -205,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() {
@ -236,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

@ -9,6 +9,7 @@ import (
"gralias/models" "gralias/models"
"gralias/utils" "gralias/utils"
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -36,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())
@ -73,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 {
@ -84,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,
} }
@ -105,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)
@ -128,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)
} }
@ -181,7 +176,15 @@ 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 != nil || player == nil {
// make player first, since username is fk to players table
player = models.InitPlayer(username)
if err := repo.PlayerAdd(context.Background(), player); err != nil {
slog.Error("failed to create player", "username", username)
return nil, err
}
}
if err := repo.SessionCreate(context.Background(), session); err != nil { if err := repo.SessionCreate(context.Background(), session); err != nil {
return nil, err return nil, err
} }

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)
}
}
if clearMarks {
fi.Room.ClearMarks() fi.Room.ClearMarks()
if err := repo.CardMarksRemoveByRoomID(r.Context(), fi.Room.ID); err != nil {
log.Error("failed to remove marks", "error", err, "room_id", fi.Room.ID)
} }
} }
if err := saveFullInfo(r.Context(), fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
@ -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,
})
} }
fi.Room.Cards[i].Mark = newMarks newMarks = append(newMarks, cm)
if err := repo.CardMarksAdd(r.Context(), &cm); err != nil {
log.Error("failed to add mark", "error", err, "card", card)
abortWithError(w, "failed to add mark")
return
}
} else {
// if mark was found, it needs to be removed
if err := repo.CardMarksRemove(r.Context(), card.ID, fi.State.Username); err != nil {
log.Error("failed to remove mark", "error", err, "card", card)
abortWithError(w, "failed to remove mark")
return
}
}
fi.Room.Cards[i].Marks = newMarks
cardword = fi.Room.Cards[i] cardword = fi.Room.Cards[i]
} }
if err := saveFullInfo(r.Context(), fi); err != nil { if err := saveFullInfo(r.Context(), fi); err != nil {
@ -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 {
@ -174,6 +163,7 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
panic(r) 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"
@ -183,6 +173,8 @@ 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),
@ -190,17 +182,15 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
} }
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action) fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
// Use the new context with transaction // Use the new context with transaction
if err := saveFullInfo(ctx, fi); err != nil { // if err := saveFullInfo(ctx, fi); err != nil {
if err := tx.Rollback(); err != nil { // if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err) // log.Error("failed to rollback transaction", "error", err)
} // }
abortWithError(w, err.Error()) // abortWithError(w, err.Error())
return // return
} // }
// Save action history // Save action history
action.RoomID = fi.Room.ID if err := repo.ActionCreate(ctx, &action); err != nil {
action.CreatedAt = time.Now()
if err := repo.CreateAction(ctx, fi.Room.ID, &action); err != nil {
if err := tx.Rollback(); err != nil { if err := tx.Rollback(); err != nil {
log.Error("failed to rollback transaction", "error", err) log.Error("failed to rollback transaction", "error", err)
} }
@ -220,14 +210,19 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
return 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 // Commit the transaction
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
log.Error("failed to commit transaction", "error", err) log.Error("failed to commit transaction", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// reveal all cards // reveal all cards
if fi.State.Role == "mime" { if fi.State.Role == "mime" {
fi.Room.MimeView() fi.Room.MimeView()
@ -338,7 +333,7 @@ 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.CreateAction(r.Context(), &action); err != nil { if err := repo.ActionCreate(r.Context(), &action); err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
@ -348,15 +343,15 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
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) {
@ -93,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 := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil { if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
log.Error("failed to exit room", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
if err := saveFullInfo(r.Context(), fiToSave); err != nil { if err := repo.RoomUpdate(r.Context(), exitedRoom); err != nil {
log.Error("failed to update room", "error", err)
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
// 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

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

View File

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

View File

@ -19,13 +19,13 @@ import (
var ( var (
// botname -> channel // botname -> channel
repo = repos.NewRepoProvider("sqlite3://../gralias.db") 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;`
) )
@ -50,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)
@ -76,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),
@ -97,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):
@ -107,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 {
@ -129,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
@ -144,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
@ -169,13 +183,25 @@ func (b *Bot) BotMove() {
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,
@ -187,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
} }
@ -209,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,
@ -218,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
@ -294,13 +347,31 @@ func RemoveBot(botName string, room *models.Room) error {
// 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) slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
if err := repo.PlayerDelete(context.Background(), room.ID, botName); err != nil { if err := repo.PlayerDelete(context.Background(), botName); err != nil {
slog.Error("failed to remove bot player", "name", botName, "room_id", room.ID) slog.Error("failed to remove bot player", "name", botName, "room_id", room.ID)
return err 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) {

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

View File

@ -25,7 +25,7 @@ func ListenToRequests(port string) *http.Server {
server := &http.Server{ server := &http.Server{
Handler: handlers.LogRequests(handlers.GetSession(mux)), Handler: handlers.LogRequests(handlers.GetSession(mux)),
Addr: ":" + port, Addr: ":" + port,
ReadTimeout: time.Second * 5, // TODO: to cfg // ReadTimeout: time.Second * 5, // does this timeout conflict with sse connection?
WriteTimeout: 0, // sse streaming WriteTimeout: 0, // sse streaming
} }
fs := http.FileServer(http.Dir("assets/")) fs := http.FileServer(http.Dir("assets/"))
@ -62,7 +62,8 @@ 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.NewRepoProvider(cfg.DBPath)
repo := repos.RP
defer repo.Close() defer repo.Close()
cm := crons.NewCronManager(repo, slog.Default()) cm := crons.NewCronManager(repo, slog.Default())
cm.Start() cm.Start()

View File

@ -21,6 +21,7 @@ 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,
@ -40,7 +41,6 @@ CREATE TABLE word_cards (
CREATE TABLE card_marks ( CREATE TABLE card_marks (
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) ON DELETE CASCADE,
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE, FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
PRIMARY KEY (card_id, username) PRIMARY KEY (card_id, username)
@ -77,3 +77,30 @@ CREATE TABLE sessions(
username TEXT NOT NULL, username TEXT NOT NULL,
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE 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"
@ -105,6 +106,7 @@ 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"`
@ -127,7 +129,29 @@ type BotPlayer struct {
type CardMark struct { type CardMark struct {
CardID uint32 `db:"card_id"` CardID uint32 `db:"card_id"`
Username string `db:"username"` Username string `db:"username"`
Active bool `db:"active"` }
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 {
@ -381,7 +414,7 @@ type WordCard struct {
Color WordColor `json:"color" db:"color"` Color WordColor `json:"color" db:"color"`
Revealed bool `json:"revealed" db:"revealed"` Revealed bool `json:"revealed" db:"revealed"`
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
Mark []CardMark `json:"marks" db:"-"` Marks []CardMark `json:"marks" db:"-"`
} }
// table: settings // table: settings
@ -390,7 +423,7 @@ type GameSettings struct {
RoomID string `db:"room_id"` RoomID string `db:"room_id"`
Language string `json:"language" example:"en" form:"language" db:"language"` Language string `json:"language" example:"en" form:"language" db:"language"`
RoomPass string `json:"room_pass" db:"room_pass"` RoomPass string `json:"room_pass" db:"room_pass"`
TurnSecondsLeft uint32 `db:"-"`
RoundTime uint32 `json:"round_time" db:"turn_time"` RoundTime uint32 `json:"round_time" db:"turn_time"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
} }
@ -432,7 +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
} }
// =======

View File

@ -3,19 +3,21 @@ package repos
import ( import (
"context" "context"
"gralias/models" "gralias/models"
"time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
type ActionsRepo interface { type ActionsRepo interface {
ListActions(ctx context.Context, roomID string) ([]models.Action, error) ActionList(ctx context.Context, roomID string) ([]models.Action, error)
CreateAction(ctx context.Context, 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
ActionsDeleteOrphaned(ctx context.Context) 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 {
@ -24,13 +26,23 @@ func (p *RepoProvider) ListActions(ctx context.Context, roomID string) ([]models
return actions, nil return actions, nil
} }
func (p *RepoProvider) CreateAction(ctx context.Context, 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 (?, ?, ?, ?, ?, ?, ?, ?)`, a.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 {
@ -39,13 +51,13 @@ func (p *RepoProvider) GetLastClue(ctx context.Context, roomID string) (*models.
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) ActionsDeleteOrphaned(ctx context.Context) error { func (p *RepoProvider) ActionDeleteOrphaned(ctx context.Context) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id NOT IN (SELECT id FROM rooms)`) _, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id NOT IN (SELECT id FROM rooms)`)
return err return err

View File

@ -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,6 +86,7 @@ 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),
} }
@ -92,7 +95,7 @@ func TestActionsRepo_ListActions(t *testing.T) {
_, 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) _, 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)
@ -142,7 +145,7 @@ func TestActionsRepo_GetLastClue(t *testing.T) {
_, 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) _, 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)
@ -167,7 +170,7 @@ func TestActionsRepo_DeleteActionsByRoomID(t *testing.T) {
_, 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) _, 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
@ -175,4 +178,3 @@ func TestActionsRepo_DeleteActionsByRoomID(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 0, count) assert.Equal(t, 0, count)
} }

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"
@ -17,7 +18,12 @@ type AllRepos interface {
PlayersRepo PlayersRepo
SessionsRepo SessionsRepo
WordCardsRepo WordCardsRepo
SettingsRepo
CardMarksRepo
PlayerStatsRepo
JournalRepo
InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error) InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error)
Close()
} }
type RepoProvider struct { type RepoProvider struct {
@ -26,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
} }

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

@ -12,12 +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) 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) {
@ -31,7 +33,7 @@ func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*models.Player, error) { 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
} }
@ -43,21 +45,21 @@ func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*m
func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error { func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error {
db := getDB(ctx, p.DB) db := getDB(ctx, p.DB)
_, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)", _, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot, password) VALUES (?, ?, ?, ?, ?, ?)",
player.RoomID, player.Username, player.Team, player.Role, player.IsBot) player.RoomID, player.Username, player.Team, player.Role, player.IsBot, player.Password)
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 team = ?, role = ? WHERE username = ?", _, err := db.ExecContext(ctx, "UPDATE players SET team = ?, role = ? WHERE username = ?;",
player.Team, player.Role, player.Username) 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
} }
@ -95,6 +97,25 @@ 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) { func (p *RepoProvider) PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error) {
var players []models.Player 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) err := sqlx.SelectContext(ctx, p.DB, &players, "SELECT id, room_id, username, team, role, is_bot FROM players WHERE room_id = ?", roomID)

View File

@ -6,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

@ -131,5 +131,12 @@ func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.
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,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 setupTestDB(t *testing.T) (*sqlx.DB, func()) { func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
@ -149,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) {
@ -346,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)
@ -367,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)
}

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