Compare commits
67 Commits
5b24378956
...
enha/sse-t
Author | SHA1 | Date | |
---|---|---|---|
![]() |
817d69c425 | ||
![]() |
acc3f11ee3 | ||
![]() |
9fc36eb7ea | ||
![]() |
123d6c240f | ||
![]() |
6951ec0535 | ||
![]() |
ad44dc0642 | ||
![]() |
155aa1b2cb | ||
![]() |
757586ea22 | ||
![]() |
8f9865db3f | ||
![]() |
a934d07be3 | ||
![]() |
acf1386c73 | ||
![]() |
f01fc12510 | ||
![]() |
329f849a72 | ||
![]() |
134b7b6262 | ||
![]() |
ea27d35254 | ||
![]() |
9a949757f2 | ||
![]() |
7beccb84a2 | ||
![]() |
3cb43d5129 | ||
![]() |
566d645230 | ||
![]() |
b64c3a4eab | ||
![]() |
d41ed9d822 | ||
![]() |
37fe76456e | ||
![]() |
d056c4a07e | ||
![]() |
89572e8fb5 | ||
![]() |
8040586043 | ||
![]() |
8392a764a2 | ||
![]() |
ff6fed073e | ||
![]() |
6f83d98799 | ||
![]() |
c946c07542 | ||
![]() |
85edb2d0ce | ||
![]() |
a03253593c | ||
![]() |
5ba97d3423 | ||
![]() |
d4c57c3262 | ||
![]() |
a9c9837b7c | ||
![]() |
a95dc82515 | ||
![]() |
2180f14850 | ||
![]() |
502317507b | ||
![]() |
50d042a19d | ||
![]() |
881a01bad0 | ||
![]() |
82b3692919 | ||
![]() |
4be52d8a33 | ||
![]() |
49f7642937 | ||
![]() |
8f6a093ea1 | ||
![]() |
587adfbbda | ||
![]() |
9a0e8d01ba | ||
![]() |
ce5d55cc13 | ||
![]() |
7ed430d8d7 | ||
![]() |
723f335f0f | ||
![]() |
fe21c3e927 | ||
![]() |
22ddc88d82 | ||
![]() |
75651d7f76 | ||
![]() |
2751b6b9dc | ||
![]() |
a2c5f17e30 | ||
![]() |
7ae255cc04 | ||
![]() |
a796b5b5de | ||
![]() |
718c9c10be | ||
![]() |
a131183729 | ||
![]() |
357f42c354 | ||
![]() |
e84941d593 | ||
![]() |
a38472a685 | ||
![]() |
a685686b32 | ||
![]() |
9900ebd3dd | ||
![]() |
f97d91ac74 | ||
![]() |
e9b9b9e559 | ||
![]() |
f46cbff602 | ||
![]() |
6ad251fc47 | ||
![]() |
27e31603da |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ gralias
|
|||||||
store.json
|
store.json
|
||||||
config.toml
|
config.toml
|
||||||
gralias.db
|
gralias.db
|
||||||
|
traces.log
|
||||||
|
20
Dockerfile
Normal file
20
Dockerfile
Normal 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"]
|
5
Makefile
5
Makefile
@@ -1,6 +1,6 @@
|
|||||||
.PHONY: all init deps install test lint run stop
|
.PHONY: all init deps install test lint run stop
|
||||||
|
|
||||||
run:
|
run: migrate-up
|
||||||
go build
|
go build
|
||||||
./gralias start
|
./gralias start
|
||||||
|
|
||||||
@@ -32,7 +32,8 @@ stop-container:
|
|||||||
docker rm -f gralias 2>/dev/null && echo "old container removed"
|
docker rm -f gralias 2>/dev/null && echo "old container removed"
|
||||||
|
|
||||||
run-container: stop-container
|
run-container: stop-container
|
||||||
docker run --name=gralias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:3000:3000 -d gralias:master
|
migrate -database 'sqlite3://gralias.db' -path migrations up
|
||||||
|
docker run --name=gralias -v $(CURDIR)/gralias.db:/app/gralias.db -p 0.0.0.0:3003:3000 -d gralias:master
|
||||||
|
|
||||||
migrate-up:
|
migrate-up:
|
||||||
migrate -database 'sqlite3://gralias.db' -path migrations up
|
migrate -database 'sqlite3://gralias.db' -path migrations up
|
||||||
|
BIN
assets/helpers.js.gz
Normal file
BIN
assets/helpers.js.gz
Normal file
Binary file not shown.
2
assets/htmx.min.js
vendored
2
assets/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
BIN
assets/htmx.min.js.gz
Normal file
BIN
assets/htmx.min.js.gz
Normal file
Binary file not shown.
BIN
assets/htmx.sse.js.gz
Normal file
BIN
assets/htmx.sse.js.gz
Normal file
Binary file not shown.
691
assets/output.css
Normal file
691
assets/output.css
Normal file
@@ -0,0 +1,691 @@
|
|||||||
|
/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
|
||||||
|
@layer properties;
|
||||||
|
@layer theme, base, components, utilities;
|
||||||
|
@layer theme {
|
||||||
|
:root, :host {
|
||||||
|
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||||
|
'Noto Color Emoji';
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||||
|
monospace;
|
||||||
|
--color-red-300: oklch(80.8% 0.114 19.571);
|
||||||
|
--color-red-400: oklch(70.4% 0.191 22.216);
|
||||||
|
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||||
|
--color-red-700: oklch(50.5% 0.213 27.518);
|
||||||
|
--color-orange-100: oklch(95.4% 0.038 75.164);
|
||||||
|
--color-orange-500: oklch(70.5% 0.213 47.604);
|
||||||
|
--color-orange-700: oklch(55.3% 0.195 38.402);
|
||||||
|
--color-amber-100: oklch(96.2% 0.059 95.617);
|
||||||
|
--color-green-600: oklch(62.7% 0.194 149.214);
|
||||||
|
--color-green-700: oklch(52.7% 0.154 150.069);
|
||||||
|
--color-blue-300: oklch(80.9% 0.105 251.813);
|
||||||
|
--color-blue-400: oklch(70.7% 0.165 254.624);
|
||||||
|
--color-blue-500: oklch(62.3% 0.214 259.815);
|
||||||
|
--color-blue-600: oklch(54.6% 0.245 262.881);
|
||||||
|
--color-blue-700: oklch(48.8% 0.243 264.376);
|
||||||
|
--color-indigo-500: oklch(58.5% 0.233 277.117);
|
||||||
|
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||||
|
--color-gray-100: oklch(96.7% 0.003 264.542);
|
||||||
|
--color-gray-300: oklch(87.2% 0.01 258.338);
|
||||||
|
--color-gray-600: oklch(44.6% 0.03 256.802);
|
||||||
|
--color-gray-900: oklch(21% 0.034 264.665);
|
||||||
|
--color-stone-400: oklch(70.9% 0.01 56.259);
|
||||||
|
--color-black: #000;
|
||||||
|
--color-white: #fff;
|
||||||
|
--spacing: 0.25rem;
|
||||||
|
--text-sm: 0.875rem;
|
||||||
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
|
--text-lg: 1.125rem;
|
||||||
|
--text-lg--line-height: calc(1.75 / 1.125);
|
||||||
|
--text-xl: 1.25rem;
|
||||||
|
--text-xl--line-height: calc(1.75 / 1.25);
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--default-transition-duration: 150ms;
|
||||||
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--default-font-family: var(--font-sans);
|
||||||
|
--default-mono-font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer base {
|
||||||
|
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0 solid;
|
||||||
|
}
|
||||||
|
html, :host {
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
tab-size: 4;
|
||||||
|
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
|
||||||
|
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||||
|
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
height: 0;
|
||||||
|
color: inherit;
|
||||||
|
border-top-width: 1px;
|
||||||
|
}
|
||||||
|
abbr:where([title]) {
|
||||||
|
-webkit-text-decoration: underline dotted;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
-webkit-text-decoration: inherit;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
b, strong {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
code, kbd, samp, pre {
|
||||||
|
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||||
|
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||||
|
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
small {
|
||||||
|
font-size: 80%;
|
||||||
|
}
|
||||||
|
sub, sup {
|
||||||
|
font-size: 75%;
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
sub {
|
||||||
|
bottom: -0.25em;
|
||||||
|
}
|
||||||
|
sup {
|
||||||
|
top: -0.5em;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
text-indent: 0;
|
||||||
|
border-color: inherit;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
:-moz-focusring {
|
||||||
|
outline: auto;
|
||||||
|
}
|
||||||
|
progress {
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: list-item;
|
||||||
|
}
|
||||||
|
ol, ul, menu {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
img, svg, video, canvas, audio, iframe, embed, object {
|
||||||
|
display: block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
img, video {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||||
|
font: inherit;
|
||||||
|
font-feature-settings: inherit;
|
||||||
|
font-variation-settings: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
:where(select:is([multiple], [size])) optgroup {
|
||||||
|
font-weight: bolder;
|
||||||
|
}
|
||||||
|
:where(select:is([multiple], [size])) optgroup option {
|
||||||
|
padding-inline-start: 20px;
|
||||||
|
}
|
||||||
|
::file-selector-button {
|
||||||
|
margin-inline-end: 4px;
|
||||||
|
}
|
||||||
|
::placeholder {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||||
|
::placeholder {
|
||||||
|
color: currentcolor;
|
||||||
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
|
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
::-webkit-search-decoration {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
::-webkit-date-and-time-value {
|
||||||
|
min-height: 1lh;
|
||||||
|
text-align: inherit;
|
||||||
|
}
|
||||||
|
::-webkit-datetime-edit {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
::-webkit-datetime-edit-fields-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
:-moz-ui-invalid {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
|
||||||
|
appearance: button;
|
||||||
|
}
|
||||||
|
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
[hidden]:where(:not([hidden='until-found'])) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer utilities {
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.mb-1 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.max-h-96 {
|
||||||
|
max-height: calc(var(--spacing) * 96);
|
||||||
|
}
|
||||||
|
.w-24 {
|
||||||
|
width: calc(var(--spacing) * 24);
|
||||||
|
}
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.min-w-\[100px\] {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.gap-2 {
|
||||||
|
gap: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.gap-4 {
|
||||||
|
gap: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
.space-y-2 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.space-y-4 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.space-y-6 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
margin-block-start: calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));
|
||||||
|
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.space-x-2 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-x-reverse: 0;
|
||||||
|
margin-inline-start: calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));
|
||||||
|
margin-inline-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.space-x-4 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-space-x-reverse: 0;
|
||||||
|
margin-inline-start: calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));
|
||||||
|
margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overflow-y-auto {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
.rounded-md {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
.border {
|
||||||
|
border-style: var(--tw-border-style);
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
.border-0 {
|
||||||
|
border-style: var(--tw-border-style);
|
||||||
|
border-width: 0px;
|
||||||
|
}
|
||||||
|
.border-2 {
|
||||||
|
border-style: var(--tw-border-style);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
.border-l-4 {
|
||||||
|
border-left-style: var(--tw-border-style);
|
||||||
|
border-left-width: 4px;
|
||||||
|
}
|
||||||
|
.border-gray-300 {
|
||||||
|
border-color: var(--color-gray-300);
|
||||||
|
}
|
||||||
|
.border-orange-500 {
|
||||||
|
border-color: var(--color-orange-500);
|
||||||
|
}
|
||||||
|
.border-stone-400 {
|
||||||
|
border-color: var(--color-stone-400);
|
||||||
|
}
|
||||||
|
.bg-amber-100 {
|
||||||
|
background-color: var(--color-amber-100);
|
||||||
|
}
|
||||||
|
.bg-blue-300 {
|
||||||
|
background-color: var(--color-blue-300);
|
||||||
|
}
|
||||||
|
.bg-blue-400 {
|
||||||
|
background-color: var(--color-blue-400);
|
||||||
|
}
|
||||||
|
.bg-blue-500 {
|
||||||
|
background-color: var(--color-blue-500);
|
||||||
|
}
|
||||||
|
.bg-blue-700 {
|
||||||
|
background-color: var(--color-blue-700);
|
||||||
|
}
|
||||||
|
.bg-gray-100 {
|
||||||
|
background-color: var(--color-gray-100);
|
||||||
|
}
|
||||||
|
.bg-green-600 {
|
||||||
|
background-color: var(--color-green-600);
|
||||||
|
}
|
||||||
|
.bg-indigo-600 {
|
||||||
|
background-color: var(--color-indigo-600);
|
||||||
|
}
|
||||||
|
.bg-orange-100 {
|
||||||
|
background-color: var(--color-orange-100);
|
||||||
|
}
|
||||||
|
.bg-red-300 {
|
||||||
|
background-color: var(--color-red-300);
|
||||||
|
}
|
||||||
|
.bg-red-400 {
|
||||||
|
background-color: var(--color-red-400);
|
||||||
|
}
|
||||||
|
.bg-red-500 {
|
||||||
|
background-color: var(--color-red-500);
|
||||||
|
}
|
||||||
|
.bg-red-700 {
|
||||||
|
background-color: var(--color-red-700);
|
||||||
|
}
|
||||||
|
.bg-white {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
}
|
||||||
|
.p-2 {
|
||||||
|
padding: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.p-4 {
|
||||||
|
padding: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
.px-1 {
|
||||||
|
padding-inline: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
|
.px-2 {
|
||||||
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.px-3 {
|
||||||
|
padding-inline: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
|
.px-4 {
|
||||||
|
padding-inline: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
|
.px-6 {
|
||||||
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
.py-1 {
|
||||||
|
padding-block: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
|
.py-1\.5 {
|
||||||
|
padding-block: calc(var(--spacing) * 1.5);
|
||||||
|
}
|
||||||
|
.py-2 {
|
||||||
|
padding-block: calc(var(--spacing) * 2);
|
||||||
|
}
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.font-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.text-lg {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
|
}
|
||||||
|
.text-xl {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
line-height: var(--tw-leading, var(--text-xl--line-height));
|
||||||
|
}
|
||||||
|
.leading-6 {
|
||||||
|
--tw-leading: calc(var(--spacing) * 6);
|
||||||
|
line-height: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
.font-bold {
|
||||||
|
--tw-font-weight: var(--font-weight-bold);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
.font-medium {
|
||||||
|
--tw-font-weight: var(--font-weight-medium);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
.font-semibold {
|
||||||
|
--tw-font-weight: var(--font-weight-semibold);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
.text-black {
|
||||||
|
color: var(--color-black);
|
||||||
|
}
|
||||||
|
.text-blue-400 {
|
||||||
|
color: var(--color-blue-400);
|
||||||
|
}
|
||||||
|
.text-blue-500 {
|
||||||
|
color: var(--color-blue-500);
|
||||||
|
}
|
||||||
|
.text-blue-600 {
|
||||||
|
color: var(--color-blue-600);
|
||||||
|
}
|
||||||
|
.text-blue-700 {
|
||||||
|
color: var(--color-blue-700);
|
||||||
|
}
|
||||||
|
.text-gray-600 {
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
}
|
||||||
|
.text-gray-900 {
|
||||||
|
color: var(--color-gray-900);
|
||||||
|
}
|
||||||
|
.text-orange-700 {
|
||||||
|
color: var(--color-orange-700);
|
||||||
|
}
|
||||||
|
.text-red-400 {
|
||||||
|
color: var(--color-red-400);
|
||||||
|
}
|
||||||
|
.text-red-500 {
|
||||||
|
color: var(--color-red-500);
|
||||||
|
}
|
||||||
|
.text-red-700 {
|
||||||
|
color: var(--color-red-700);
|
||||||
|
}
|
||||||
|
.text-white {
|
||||||
|
color: var(--color-white);
|
||||||
|
}
|
||||||
|
.shadow-sm {
|
||||||
|
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||||
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
|
}
|
||||||
|
.ring-1 {
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||||
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
|
}
|
||||||
|
.ring-gray-300 {
|
||||||
|
--tw-ring-color: var(--color-gray-300);
|
||||||
|
}
|
||||||
|
.transition-colors {
|
||||||
|
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to;
|
||||||
|
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||||
|
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||||
|
}
|
||||||
|
.ring-inset {
|
||||||
|
--tw-ring-inset: inset;
|
||||||
|
}
|
||||||
|
.placeholder\:text-gray-300 {
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-gray-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hover\:bg-green-700 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-green-700);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hover\:bg-indigo-500 {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
background-color: var(--color-indigo-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus\:border-blue-500 {
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--color-blue-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus\:ring-2 {
|
||||||
|
&:focus {
|
||||||
|
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||||
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus\:ring-blue-500 {
|
||||||
|
&:focus {
|
||||||
|
--tw-ring-color: var(--color-blue-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus\:ring-indigo-600 {
|
||||||
|
&:focus {
|
||||||
|
--tw-ring-color: var(--color-indigo-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus\:ring-inset {
|
||||||
|
&:focus {
|
||||||
|
--tw-ring-inset: inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus-visible\:outline {
|
||||||
|
&:focus-visible {
|
||||||
|
outline-style: var(--tw-outline-style);
|
||||||
|
outline-width: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus-visible\:outline-2 {
|
||||||
|
&:focus-visible {
|
||||||
|
outline-style: var(--tw-outline-style);
|
||||||
|
outline-width: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus-visible\:outline-offset-2 {
|
||||||
|
&:focus-visible {
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focus-visible\:outline-indigo-600 {
|
||||||
|
&:focus-visible {
|
||||||
|
outline-color: var(--color-indigo-600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:grid-cols-5 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:text-sm {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--tw-leading, var(--text-sm--line-height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sm\:leading-6 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
--tw-leading: calc(var(--spacing) * 6);
|
||||||
|
line-height: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@property --tw-space-y-reverse {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
@property --tw-space-x-reverse {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
|
@property --tw-border-style {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: solid;
|
||||||
|
}
|
||||||
|
@property --tw-leading {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-font-weight {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-shadow {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0 0 #0000;
|
||||||
|
}
|
||||||
|
@property --tw-shadow-color {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-shadow-alpha {
|
||||||
|
syntax: "<percentage>";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 100%;
|
||||||
|
}
|
||||||
|
@property --tw-inset-shadow {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0 0 #0000;
|
||||||
|
}
|
||||||
|
@property --tw-inset-shadow-color {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-inset-shadow-alpha {
|
||||||
|
syntax: "<percentage>";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 100%;
|
||||||
|
}
|
||||||
|
@property --tw-ring-color {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-ring-shadow {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0 0 #0000;
|
||||||
|
}
|
||||||
|
@property --tw-inset-ring-color {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-inset-ring-shadow {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0 0 #0000;
|
||||||
|
}
|
||||||
|
@property --tw-ring-inset {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
@property --tw-ring-offset-width {
|
||||||
|
syntax: "<length>";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0px;
|
||||||
|
}
|
||||||
|
@property --tw-ring-offset-color {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: #fff;
|
||||||
|
}
|
||||||
|
@property --tw-ring-offset-shadow {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0 0 #0000;
|
||||||
|
}
|
||||||
|
@property --tw-outline-style {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: solid;
|
||||||
|
}
|
||||||
|
@layer properties {
|
||||||
|
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||||
|
*, ::before, ::after, ::backdrop {
|
||||||
|
--tw-space-y-reverse: 0;
|
||||||
|
--tw-space-x-reverse: 0;
|
||||||
|
--tw-border-style: solid;
|
||||||
|
--tw-leading: initial;
|
||||||
|
--tw-font-weight: initial;
|
||||||
|
--tw-shadow: 0 0 #0000;
|
||||||
|
--tw-shadow-color: initial;
|
||||||
|
--tw-shadow-alpha: 100%;
|
||||||
|
--tw-inset-shadow: 0 0 #0000;
|
||||||
|
--tw-inset-shadow-color: initial;
|
||||||
|
--tw-inset-shadow-alpha: 100%;
|
||||||
|
--tw-ring-color: initial;
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-inset-ring-color: initial;
|
||||||
|
--tw-inset-ring-shadow: 0 0 #0000;
|
||||||
|
--tw-ring-inset: initial;
|
||||||
|
--tw-ring-offset-width: 0px;
|
||||||
|
--tw-ring-offset-color: #fff;
|
||||||
|
--tw-ring-offset-shadow: 0 0 #0000;
|
||||||
|
--tw-outline-style: solid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
BIN
assets/output.css.gz
Normal file
BIN
assets/output.css.gz
Normal file
Binary file not shown.
@@ -1,7 +1,6 @@
|
|||||||
body{
|
body{
|
||||||
background-color: #0C1616FF;
|
background-color: #0C1616FF;
|
||||||
color: #8896b2;
|
color: #8896b2;
|
||||||
max-width: 800px;
|
|
||||||
min-width: 0px;
|
min-width: 0px;
|
||||||
margin: 2em auto !important;
|
margin: 2em auto !important;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -12,12 +11,6 @@ body{
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
a{
|
|
||||||
color: #00a2e7;
|
|
||||||
}
|
|
||||||
a:visited{
|
|
||||||
color: #ca1a70;
|
|
||||||
}
|
|
||||||
table{
|
table{
|
||||||
border-collapse: separate !important;
|
border-collapse: separate !important;
|
||||||
border-spacing: 10px 10px;
|
border-spacing: 10px 10px;
|
||||||
|
BIN
assets/style.css.gz
Normal file
BIN
assets/style.css.gz
Normal file
Binary file not shown.
BIN
assets/tailwind.css.gz
Normal file
BIN
assets/tailwind.css.gz
Normal file
Binary file not shown.
@@ -140,7 +140,6 @@
|
|||||||
патриот
|
патриот
|
||||||
мечеть
|
мечеть
|
||||||
ярость
|
ярость
|
||||||
юго-запад
|
|
||||||
цикл
|
цикл
|
||||||
роман
|
роман
|
||||||
джей
|
джей
|
||||||
@@ -269,7 +268,6 @@
|
|||||||
облегчение
|
облегчение
|
||||||
налогообложение
|
налогообложение
|
||||||
враг
|
враг
|
||||||
свободный человек
|
|
||||||
инцест
|
инцест
|
||||||
демографический
|
демографический
|
||||||
ипотека
|
ипотека
|
||||||
@@ -295,7 +293,6 @@
|
|||||||
профессор
|
профессор
|
||||||
путешествие
|
путешествие
|
||||||
невинный
|
невинный
|
||||||
несчастный случай
|
|
||||||
стих
|
стих
|
||||||
владение
|
владение
|
||||||
движение
|
движение
|
||||||
@@ -305,7 +302,6 @@
|
|||||||
кадр
|
кадр
|
||||||
указание
|
указание
|
||||||
пенни
|
пенни
|
||||||
как подпись
|
|
||||||
простота
|
простота
|
||||||
холодильник
|
холодильник
|
||||||
разнообразие
|
разнообразие
|
||||||
@@ -329,7 +325,6 @@
|
|||||||
рождение
|
рождение
|
||||||
нож
|
нож
|
||||||
значение
|
значение
|
||||||
молочные продукты
|
|
||||||
имущество
|
имущество
|
||||||
песок
|
песок
|
||||||
убийство
|
убийство
|
||||||
@@ -369,7 +364,6 @@
|
|||||||
свинина
|
свинина
|
||||||
конкурс
|
конкурс
|
||||||
ясный
|
ясный
|
||||||
внешний вид
|
|
||||||
ограничение
|
ограничение
|
||||||
назад
|
назад
|
||||||
поражение
|
поражение
|
||||||
@@ -400,7 +394,6 @@
|
|||||||
единообразие
|
единообразие
|
||||||
рацион
|
рацион
|
||||||
нести
|
нести
|
||||||
судебный процесс
|
|
||||||
слияние
|
слияние
|
||||||
дополнительно
|
дополнительно
|
||||||
диаграмма
|
диаграмма
|
||||||
@@ -422,10 +415,7 @@
|
|||||||
воздействие
|
воздействие
|
||||||
автор
|
автор
|
||||||
упряжь
|
упряжь
|
||||||
конц ert
|
|
||||||
текстиль
|
текстиль
|
||||||
окружающая среда
|
|
||||||
в целом
|
|
||||||
лидер
|
лидер
|
||||||
измерение
|
измерение
|
||||||
компания
|
компания
|
||||||
@@ -626,7 +616,6 @@ hank
|
|||||||
спрей
|
спрей
|
||||||
завтра
|
завтра
|
||||||
девушка
|
девушка
|
||||||
не нравится
|
|
||||||
обед
|
обед
|
||||||
такси
|
такси
|
||||||
внутри
|
внутри
|
||||||
@@ -726,7 +715,6 @@ hank
|
|||||||
фильм
|
фильм
|
||||||
отношение
|
отношение
|
||||||
добавить
|
добавить
|
||||||
лунный свет
|
|
||||||
бледный
|
бледный
|
||||||
аромат
|
аромат
|
||||||
змея
|
змея
|
||||||
@@ -933,7 +921,6 @@ hank
|
|||||||
очарование
|
очарование
|
||||||
слово
|
слово
|
||||||
пероверхий
|
пероверхий
|
||||||
моющее средство
|
|
||||||
трейлер
|
трейлер
|
||||||
болезнь
|
болезнь
|
||||||
азот
|
азот
|
||||||
@@ -997,7 +984,6 @@ hank
|
|||||||
актер
|
актер
|
||||||
любовь
|
любовь
|
||||||
группа
|
группа
|
||||||
восход солнца
|
|
||||||
удача
|
удача
|
||||||
агентство
|
агентство
|
||||||
милосердие
|
милосердие
|
||||||
@@ -1265,7 +1251,6 @@ fallout
|
|||||||
пенсия
|
пенсия
|
||||||
персонал
|
персонал
|
||||||
корреляция
|
корреляция
|
||||||
солнечный свет
|
|
||||||
портативный
|
портативный
|
||||||
голова
|
голова
|
||||||
картофель
|
картофель
|
||||||
@@ -1277,7 +1262,6 @@ fallout
|
|||||||
эмансипация
|
эмансипация
|
||||||
дискриминация
|
дискриминация
|
||||||
восстановление
|
восстановление
|
||||||
из-за
|
|
||||||
портрет
|
портрет
|
||||||
приобретение
|
приобретение
|
||||||
сталь
|
сталь
|
||||||
@@ -1333,7 +1317,6 @@ palfrey
|
|||||||
уход
|
уход
|
||||||
контракт
|
контракт
|
||||||
прогресс
|
прогресс
|
||||||
центр города
|
|
||||||
соглашение
|
соглашение
|
||||||
авеню
|
авеню
|
||||||
утопия
|
утопия
|
||||||
@@ -1370,7 +1353,6 @@ palfrey
|
|||||||
провод
|
провод
|
||||||
технология
|
технология
|
||||||
вера
|
вера
|
||||||
вертикально t
|
|
||||||
акции
|
акции
|
||||||
цена
|
цена
|
||||||
канал
|
канал
|
||||||
@@ -1410,7 +1392,6 @@ palfrey
|
|||||||
проповедь
|
проповедь
|
||||||
празднование
|
празднование
|
||||||
sba
|
sba
|
||||||
учебная программа
|
|
||||||
рынок
|
рынок
|
||||||
пуля
|
пуля
|
||||||
устная
|
устная
|
||||||
@@ -1453,7 +1434,6 @@ sba
|
|||||||
ненависть
|
ненависть
|
||||||
самовывоз
|
самовывоз
|
||||||
скидка
|
скидка
|
||||||
т estament
|
|
||||||
администратор
|
администратор
|
||||||
бить
|
бить
|
||||||
наклон
|
наклон
|
||||||
@@ -1493,7 +1473,6 @@ sba
|
|||||||
трек
|
трек
|
||||||
архитектура
|
архитектура
|
||||||
ракета
|
ракета
|
||||||
сообщение унификация
|
|
||||||
вероятный
|
вероятный
|
||||||
преемственность
|
преемственность
|
||||||
токен
|
токен
|
||||||
@@ -1510,7 +1489,6 @@ sba
|
|||||||
антиквариат
|
антиквариат
|
||||||
рукав
|
рукав
|
||||||
обследование
|
обследование
|
||||||
дикая местность
|
|
||||||
остановка
|
остановка
|
||||||
касание
|
касание
|
||||||
ассоциация
|
ассоциация
|
||||||
@@ -1534,7 +1512,6 @@ sba
|
|||||||
повышение
|
повышение
|
||||||
раковина
|
раковина
|
||||||
стоимость
|
стоимость
|
||||||
дисков ery
|
|
||||||
fly
|
fly
|
||||||
warren
|
warren
|
||||||
overhead
|
overhead
|
||||||
@@ -1574,7 +1551,6 @@ paper
|
|||||||
over
|
over
|
||||||
complement
|
complement
|
||||||
nursery
|
nursery
|
||||||
arrange ment
|
|
||||||
консерватизм
|
консерватизм
|
||||||
индивидуальный
|
индивидуальный
|
||||||
грант
|
грант
|
||||||
@@ -1610,7 +1586,6 @@ arrange ment
|
|||||||
звезда
|
звезда
|
||||||
улучшение
|
улучшение
|
||||||
объект
|
объект
|
||||||
постоянный nt
|
|
||||||
pat
|
pat
|
||||||
ковёр
|
ковёр
|
||||||
разделение
|
разделение
|
||||||
@@ -1657,7 +1632,6 @@ pat
|
|||||||
шедевр
|
шедевр
|
||||||
семя
|
семя
|
||||||
нет
|
нет
|
||||||
розничная торговля
|
|
||||||
валун
|
валун
|
||||||
десятилетие
|
десятилетие
|
||||||
коррупция
|
коррупция
|
||||||
@@ -1685,14 +1659,12 @@ pat
|
|||||||
посвящение
|
посвящение
|
||||||
духи
|
духи
|
||||||
спальня
|
спальня
|
||||||
консервный завод
|
|
||||||
ржавчина
|
ржавчина
|
||||||
профессиональный
|
профессиональный
|
||||||
кремль
|
кремль
|
||||||
свобода
|
свобода
|
||||||
лагуна
|
лагуна
|
||||||
конфиденциальность
|
конфиденциальность
|
||||||
attenda nt
|
|
||||||
диффузия
|
диффузия
|
||||||
светский
|
светский
|
||||||
резина
|
резина
|
||||||
@@ -1733,11 +1705,9 @@ attenda nt
|
|||||||
республиканец
|
республиканец
|
||||||
уголок
|
уголок
|
||||||
куст
|
куст
|
||||||
dru г
|
|
||||||
персона
|
персона
|
||||||
грудь
|
грудь
|
||||||
латунь
|
латунь
|
||||||
сточные воды
|
|
||||||
старший
|
старший
|
||||||
лимон
|
лимон
|
||||||
стандарт
|
стандарт
|
||||||
@@ -1774,7 +1744,6 @@ dru г
|
|||||||
ощущение
|
ощущение
|
||||||
завершение
|
завершение
|
||||||
влияние
|
влияние
|
||||||
ge neration
|
|
||||||
поиск
|
поиск
|
||||||
гнев
|
гнев
|
||||||
рыба
|
рыба
|
||||||
@@ -1791,14 +1760,12 @@ ge neration
|
|||||||
секрет
|
секрет
|
||||||
влажный
|
влажный
|
||||||
артерия
|
артерия
|
||||||
рабочая сила
|
|
||||||
уступать
|
уступать
|
||||||
сам
|
сам
|
||||||
койка
|
койка
|
||||||
счет
|
счет
|
||||||
потерянный
|
потерянный
|
||||||
приятель
|
приятель
|
||||||
кто-то
|
|
||||||
специалист
|
специалист
|
||||||
поэт
|
поэт
|
||||||
главный
|
главный
|
||||||
@@ -1819,7 +1786,6 @@ ge neration
|
|||||||
план
|
план
|
||||||
журнал
|
журнал
|
||||||
денди
|
денди
|
||||||
ve rtical
|
|
||||||
дождь
|
дождь
|
||||||
револьвер
|
револьвер
|
||||||
вперед
|
вперед
|
||||||
@@ -1920,7 +1886,6 @@ magnum
|
|||||||
любитель
|
любитель
|
||||||
концепция
|
концепция
|
||||||
империя
|
империя
|
||||||
дикая природа
|
|
||||||
кукуруза
|
кукуруза
|
||||||
горчица
|
горчица
|
||||||
компромисс
|
компромисс
|
||||||
@@ -1940,7 +1905,6 @@ magnum
|
|||||||
сигарета
|
сигарета
|
||||||
прирост
|
прирост
|
||||||
имитация
|
имитация
|
||||||
рем ote
|
|
||||||
тьма
|
тьма
|
||||||
длиннее
|
длиннее
|
||||||
эмоция
|
эмоция
|
||||||
@@ -1980,12 +1944,10 @@ magnum
|
|||||||
призрак
|
призрак
|
||||||
грабли
|
грабли
|
||||||
джерси
|
джерси
|
||||||
пресер vation
|
|
||||||
обещание
|
обещание
|
||||||
уровень
|
уровень
|
||||||
зарплата
|
зарплата
|
||||||
трава
|
трава
|
||||||
девственная плева
|
|
||||||
хватка
|
хватка
|
||||||
менеджер
|
менеджер
|
||||||
потряс
|
потряс
|
||||||
@@ -1998,7 +1960,6 @@ magnum
|
|||||||
игрок
|
игрок
|
||||||
носильщик
|
носильщик
|
||||||
сэм
|
сэм
|
||||||
единственное число
|
|
||||||
текст
|
текст
|
||||||
язык
|
язык
|
||||||
пиломатериалы
|
пиломатериалы
|
||||||
@@ -2020,7 +1981,6 @@ magnum
|
|||||||
композитор
|
композитор
|
||||||
окисление
|
окисление
|
||||||
кислород
|
кислород
|
||||||
voi ce
|
|
||||||
прогрессивный
|
прогрессивный
|
||||||
коммунизм
|
коммунизм
|
||||||
толпа
|
толпа
|
||||||
@@ -2032,7 +1992,6 @@ voi ce
|
|||||||
юрист
|
юрист
|
||||||
театр
|
театр
|
||||||
смелый
|
смелый
|
||||||
подводная лодка
|
|
||||||
стоимость
|
стоимость
|
||||||
словарь
|
словарь
|
||||||
знак
|
знак
|
||||||
@@ -2061,10 +2020,8 @@ voi ce
|
|||||||
убийца
|
убийца
|
||||||
бутылка
|
бутылка
|
||||||
день
|
день
|
||||||
все еще
|
|
||||||
конгрессмен
|
конгрессмен
|
||||||
спикер
|
спикер
|
||||||
резо fance
|
|
||||||
общественный
|
общественный
|
||||||
самолет
|
самолет
|
||||||
гребень
|
гребень
|
||||||
@@ -2100,13 +2057,11 @@ voi ce
|
|||||||
идеальный
|
идеальный
|
||||||
пан
|
пан
|
||||||
динамический
|
динамический
|
||||||
подъездная дорога
|
|
||||||
резерв
|
резерв
|
||||||
обслуживание
|
обслуживание
|
||||||
гражданин
|
гражданин
|
||||||
ювенальный
|
ювенальный
|
||||||
степень
|
степень
|
||||||
молитва r
|
|
||||||
gin
|
gin
|
||||||
hogan
|
hogan
|
||||||
еда
|
еда
|
||||||
@@ -2129,7 +2084,6 @@ hogan
|
|||||||
диаметр
|
диаметр
|
||||||
сестра
|
сестра
|
||||||
производитель
|
производитель
|
||||||
точка зрения
|
|
||||||
кальций
|
кальций
|
||||||
винт
|
винт
|
||||||
шахта
|
шахта
|
||||||
@@ -2147,11 +2101,9 @@ hogan
|
|||||||
истинный
|
истинный
|
||||||
пена
|
пена
|
||||||
теология
|
теология
|
||||||
работа по делу
|
|
||||||
польский
|
польский
|
||||||
армия
|
армия
|
||||||
отклонить
|
отклонить
|
||||||
петух ail
|
|
||||||
low
|
low
|
||||||
пропаганда
|
пропаганда
|
||||||
характер
|
характер
|
||||||
@@ -2234,7 +2186,6 @@ low
|
|||||||
представитель
|
представитель
|
||||||
вход
|
вход
|
||||||
патент
|
патент
|
||||||
среда обитания
|
|
||||||
дренаж
|
дренаж
|
||||||
мост
|
мост
|
||||||
период
|
период
|
||||||
@@ -2275,7 +2226,6 @@ low
|
|||||||
соседство
|
соседство
|
||||||
пик
|
пик
|
||||||
кен
|
кен
|
||||||
вор th
|
|
||||||
предварительный
|
предварительный
|
||||||
злой
|
злой
|
||||||
экран
|
экран
|
||||||
@@ -2299,7 +2249,6 @@ low
|
|||||||
кабина
|
кабина
|
||||||
решимость
|
решимость
|
||||||
нельсон
|
нельсон
|
||||||
кто-то
|
|
||||||
двигатель
|
двигатель
|
||||||
количество
|
количество
|
||||||
лекция
|
лекция
|
||||||
@@ -2316,7 +2265,6 @@ low
|
|||||||
компульсивный
|
компульсивный
|
||||||
франклин
|
франклин
|
||||||
ракушка
|
ракушка
|
||||||
голубой e
|
|
||||||
статуя
|
статуя
|
||||||
люкс
|
люкс
|
||||||
бар
|
бар
|
||||||
@@ -2359,49 +2307,45 @@ low
|
|||||||
наблюдение
|
наблюдение
|
||||||
порода
|
порода
|
||||||
фунт
|
фунт
|
||||||
владелец p
|
стебель
|
||||||
stem
|
депо
|
||||||
depot
|
бессмертие
|
||||||
immortality
|
ответ
|
||||||
answer
|
бог
|
||||||
god
|
расположение
|
||||||
disposition
|
документ
|
||||||
document
|
великий
|
||||||
grand
|
упражнение
|
||||||
exercise
|
пчела
|
||||||
bee
|
репутация
|
||||||
reputation
|
удовольствие
|
||||||
pleasure
|
комиссия
|
||||||
commission
|
поощрение
|
||||||
encouragement
|
жидкость
|
||||||
fluid
|
туман
|
||||||
fog
|
шепот
|
||||||
whisper
|
панель
|
||||||
panel
|
поле
|
||||||
field
|
специальный
|
||||||
special
|
продолжение
|
||||||
continuation
|
младенец
|
||||||
infant
|
кульминация
|
||||||
climax
|
химия
|
||||||
chemistry
|
способность
|
||||||
capability
|
доход
|
||||||
income
|
пыль
|
||||||
dust
|
упал
|
||||||
fell
|
игра
|
||||||
keelson
|
убежище
|
||||||
game
|
средний
|
||||||
shelter
|
гомер
|
||||||
medium
|
волы
|
||||||
homer
|
маленький
|
||||||
aged
|
туристический
|
||||||
oxen
|
переулок
|
||||||
little
|
лечение
|
||||||
tourist
|
арка
|
||||||
lane
|
электричество
|
||||||
cure
|
|
||||||
arch
|
|
||||||
electricity
|
|
||||||
sta rt
|
|
||||||
амбар
|
амбар
|
||||||
почва
|
почва
|
||||||
популярность
|
популярность
|
||||||
@@ -2419,12 +2363,10 @@ sta rt
|
|||||||
губа
|
губа
|
||||||
консультация
|
консультация
|
||||||
клевер
|
клевер
|
||||||
в сторону
|
|
||||||
независимый
|
независимый
|
||||||
принятие
|
принятие
|
||||||
принцип
|
принцип
|
||||||
рациональный
|
рациональный
|
||||||
северо-восток
|
|
||||||
участие
|
участие
|
||||||
одиночный
|
одиночный
|
||||||
фантастика
|
фантастика
|
||||||
@@ -2440,7 +2382,6 @@ sta rt
|
|||||||
параллельный
|
параллельный
|
||||||
внутренний
|
внутренний
|
||||||
схватывание
|
схватывание
|
||||||
v itality
|
|
||||||
театральный
|
театральный
|
||||||
банда
|
банда
|
||||||
усыновление
|
усыновление
|
||||||
@@ -2466,7 +2407,6 @@ v itality
|
|||||||
блондин
|
блондин
|
||||||
униформа
|
униформа
|
||||||
пара
|
пара
|
||||||
железная дорога
|
|
||||||
поддержка
|
поддержка
|
||||||
история
|
история
|
||||||
разоружение
|
разоружение
|
||||||
@@ -2484,7 +2424,6 @@ v itality
|
|||||||
душа
|
душа
|
||||||
стремительный
|
стремительный
|
||||||
идеал
|
идеал
|
||||||
день рождения
|
|
||||||
ларек
|
ларек
|
||||||
речь
|
речь
|
||||||
свечение
|
свечение
|
||||||
@@ -2606,7 +2545,6 @@ v itality
|
|||||||
клуб
|
клуб
|
||||||
генератор
|
генератор
|
||||||
сказать
|
сказать
|
||||||
fa shion
|
|
||||||
тест
|
тест
|
||||||
моб
|
моб
|
||||||
масло
|
масло
|
||||||
@@ -2618,7 +2556,6 @@ fa shion
|
|||||||
управление
|
управление
|
||||||
забор
|
забор
|
||||||
ребенок
|
ребенок
|
||||||
летучая мышь
|
|
||||||
форд
|
форд
|
||||||
нерв
|
нерв
|
||||||
хвост
|
хвост
|
||||||
@@ -2730,7 +2667,6 @@ wtv
|
|||||||
волнение
|
волнение
|
||||||
цитата
|
цитата
|
||||||
лоб
|
лоб
|
||||||
wa x
|
|
||||||
mckinley
|
mckinley
|
||||||
телевидение
|
телевидение
|
||||||
может
|
может
|
||||||
@@ -2770,7 +2706,6 @@ mckinley
|
|||||||
фонтан
|
фонтан
|
||||||
исполнение
|
исполнение
|
||||||
отдел
|
отдел
|
||||||
st способный
|
|
||||||
деревня
|
деревня
|
||||||
посол
|
посол
|
||||||
читать
|
читать
|
||||||
@@ -2789,7 +2724,6 @@ st способный
|
|||||||
грабеж
|
грабеж
|
||||||
связанный
|
связанный
|
||||||
фриз
|
фриз
|
||||||
берег реки
|
|
||||||
выборы
|
выборы
|
||||||
демократия
|
демократия
|
||||||
оркестр
|
оркестр
|
||||||
@@ -2798,7 +2732,6 @@ st способный
|
|||||||
разум
|
разум
|
||||||
коп
|
коп
|
||||||
промах
|
промах
|
||||||
список битв
|
|
||||||
этнический
|
этнический
|
||||||
камера
|
камера
|
||||||
регулярный
|
регулярный
|
||||||
@@ -2969,7 +2902,6 @@ net
|
|||||||
размах
|
размах
|
||||||
безопасность
|
безопасность
|
||||||
тост
|
тост
|
||||||
ри ce
|
|
||||||
память
|
память
|
||||||
трубка
|
трубка
|
||||||
ткань
|
ткань
|
||||||
@@ -3255,8 +3187,6 @@ net
|
|||||||
горничная
|
горничная
|
||||||
мясо
|
мясо
|
||||||
социология
|
социология
|
||||||
северо-запад
|
|
||||||
желчь ery
|
|
||||||
гора
|
гора
|
||||||
могила
|
могила
|
||||||
релевантность
|
релевантность
|
||||||
|
@@ -1,15 +1,17 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// the amount of time to wait when pushing a message to
|
// the amount of time to wait when pushing a message to
|
||||||
// a slow client or a client that closed after `range clients` started.
|
// a slow client or a client that closed after `range clients` started.
|
||||||
const patience time.Duration = time.Second * 1
|
// const patience time.Duration = time.Second * 1
|
||||||
|
|
||||||
type (
|
type (
|
||||||
NotificationEvent struct {
|
NotificationEvent struct {
|
||||||
@@ -19,23 +21,23 @@ type (
|
|||||||
NotifierChan chan NotificationEvent
|
NotifierChan chan NotificationEvent
|
||||||
Broker struct {
|
Broker struct {
|
||||||
// Events are pushed to this channel by the main events-gathering routine
|
// Events are pushed to this channel by the main events-gathering routine
|
||||||
Notifier NotifierChan
|
Notifier NotifierChan
|
||||||
// New client connections
|
log *slog.Logger
|
||||||
newClients chan NotifierChan
|
addClient chan NotifierChan
|
||||||
// Closed client connections
|
clients map[NotifierChan]struct{}
|
||||||
closingClients chan NotifierChan
|
|
||||||
// Client connections registry
|
|
||||||
clients map[NotifierChan]struct{}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewBroker() (broker *Broker) {
|
func NewBroker() (broker *Broker) {
|
||||||
// Instantiate a broker
|
// Instantiate a broker
|
||||||
return &Broker{
|
return &Broker{
|
||||||
Notifier: make(NotifierChan, 1),
|
Notifier: make(NotifierChan, 100),
|
||||||
newClients: make(chan NotifierChan),
|
log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
closingClients: make(chan NotifierChan),
|
Level: slog.LevelDebug,
|
||||||
clients: make(map[NotifierChan]struct{}),
|
AddSource: true,
|
||||||
|
})),
|
||||||
|
addClient: make(chan NotifierChan, 10),
|
||||||
|
clients: map[NotifierChan]struct{}{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,6 @@ var Notifier *Broker
|
|||||||
// for use in different packages
|
// for use in different packages
|
||||||
func init() {
|
func init() {
|
||||||
Notifier = NewBroker()
|
Notifier = NewBroker()
|
||||||
go Notifier.Listen()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -52,52 +53,60 @@ func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "text/event-stream")
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
w.Header().Set("Connection", "keep-alive")
|
w.Header().Set("Connection", "keep-alive")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
// w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
messageChan := make(NotifierChan)
|
origin := r.Header.Get("Origin")
|
||||||
broker.newClients <- messageChan
|
if origin == "" {
|
||||||
defer func() { broker.closingClients <- messageChan }()
|
origin = "*" // Fallback for non-browser clients
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
msgChan := make(NotifierChan, 10)
|
||||||
|
broker.addClient <- msgChan
|
||||||
|
// browser can close sse on its own; ping every 2s to prevent
|
||||||
|
heartbeat := time.NewTicker(8 * time.Second)
|
||||||
|
defer heartbeat.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
broker.log.Debug("broker: got ctx done")
|
||||||
// Client disconnected
|
// Client disconnected
|
||||||
return
|
return
|
||||||
case event := <-messageChan:
|
case event := <-broker.Notifier:
|
||||||
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
|
broker.log.Debug("got event", "event", event)
|
||||||
if err != nil {
|
for i := 0; i < 10; i++ { // Repeat 3 times
|
||||||
fmt.Println(err)
|
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
|
||||||
// Client disconnected
|
if err != nil {
|
||||||
return
|
broker.log.Error("write failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
// Short delay between sends (non-blocking)
|
||||||
|
select {
|
||||||
|
case <-time.After(20 * time.Millisecond): // Adjust delay as needed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-heartbeat.C:
|
||||||
|
// Send SSE heartbeat comment
|
||||||
|
if _, err := fmt.Fprint(w, ":\n\n"); err != nil {
|
||||||
|
broker.log.Error("failed to write heartbeat", "error", err)
|
||||||
|
return // Client disconnected
|
||||||
}
|
}
|
||||||
w.(http.Flusher).Flush()
|
w.(http.Flusher).Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for new notifications and redistribute them to clients
|
func (broker *Broker) Listen(ctx context.Context) {
|
||||||
func (broker *Broker) Listen() {
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case s := <-broker.newClients:
|
case <-ctx.Done():
|
||||||
// A new client has connected.
|
return
|
||||||
// Register their message channel
|
case clientChan := <-broker.addClient:
|
||||||
broker.clients[s] = struct{}{}
|
// mutex
|
||||||
slog.Info("Client added", "clients listening", len(broker.clients))
|
broker.clients[clientChan] = struct{}{}
|
||||||
case s := <-broker.closingClients:
|
|
||||||
// A client has dettached and we want to
|
|
||||||
// stop sending them messages.
|
|
||||||
delete(broker.clients, s)
|
|
||||||
slog.Info("Client removed", "clients listening", len(broker.clients))
|
|
||||||
case event := <-broker.Notifier:
|
|
||||||
// We got a new event from the outside!
|
|
||||||
// Send event to all connected clients
|
|
||||||
for clientMessageChan := range broker.clients {
|
|
||||||
select {
|
|
||||||
case clientMessageChan <- event:
|
|
||||||
case <-time.After(patience):
|
|
||||||
slog.Info("Client was skipped", "clients listening", len(broker.clients))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -18,7 +18,7 @@
|
|||||||
if (!window.actionHistoryScrollSet) {
|
if (!window.actionHistoryScrollSet) {
|
||||||
htmx.onLoad(function(target) {
|
htmx.onLoad(function(target) {
|
||||||
if (target.id === 'actionHistoryContainer') {
|
if (target.id === 'actionHistoryContainer') {
|
||||||
target.scrollTop = target.scrollHeight;
|
target.scrollToBottom();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.actionHistoryScrollSet = true;
|
window.actionHistoryScrollSet = true;
|
||||||
|
@@ -3,47 +3,25 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Alias</title>
|
<title>Alias</title>
|
||||||
<script src="/assets/helpers.js"></script>
|
<script src="/assets/tailwind.css"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css"/>
|
||||||
<script src="/assets/htmx.min.js"></script>
|
<script src="/assets/htmx.min.js"></script>
|
||||||
<script src="/assets/htmx.sse.js"></script>
|
<script src="/assets/htmx.sse.js"></script>
|
||||||
<script src="/assets/tailwind.css"></script>
|
<script src="/assets/helpers.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/style.css"/>
|
|
||||||
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
|
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
|
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
|
||||||
<style type="text/css">
|
|
||||||
body{
|
|
||||||
background-color: #0C1616FF;
|
|
||||||
color: #8896b2;
|
|
||||||
max-width: 1000px;
|
|
||||||
min-width: 0;
|
|
||||||
margin: 2em auto !important;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-size: 16px;
|
|
||||||
font-family: Open Sans,Arial;
|
|
||||||
text-align: center;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
a{
|
|
||||||
color: #00a2e7;
|
|
||||||
}
|
|
||||||
a:visited{
|
|
||||||
color: #ca1a70;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
border-collapse: separate !important;
|
|
||||||
border-spacing: 10px 10px;
|
|
||||||
border: 1px solid white;
|
|
||||||
}
|
|
||||||
tr{
|
|
||||||
border: 1px solid white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse">
|
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse">
|
||||||
{{template "main" .}}
|
<script type="text/javascript">
|
||||||
|
document.body.addEventListener('htmx:sseError', function (e) {
|
||||||
|
// do something before the event data is swapped in
|
||||||
|
console.log(e)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<div id="main-content">
|
||||||
|
{{template "main" .}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -29,7 +29,12 @@
|
|||||||
<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 .Marks}}
|
{{range .Marks}}
|
||||||
|
{{ $length := len .Username }}
|
||||||
|
{{ if lt $length 3 }}
|
||||||
<span class="mx-0.5">{{.Username}}</span>
|
<span class="mx-0.5">{{.Username}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="mx-0.5">{{slice .Username 0 3}}</span>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -4,14 +4,19 @@
|
|||||||
Create a room <br/>
|
Create a room <br/>
|
||||||
or<br/>
|
or<br/>
|
||||||
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button>
|
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button>
|
||||||
<form hx-post="/room-create" hx-target="#ancestor">
|
<form hx-post="/room-create" hx-target="#main-content">
|
||||||
<label For="game_time">Turn Seconds:</label><br/>
|
<label For="game_time">Turn Seconds:</label><br/>
|
||||||
<input type="number" id="game_time" name="game_time" class="text-center text-white" value="300"/><br/>
|
<input type="number" id="game_time" name="game_time" class="text-center text-white" value="300"/><br/>
|
||||||
<label For="language">Language:</label><br/>
|
<label For="language">Language:</label><br/>
|
||||||
<input type="text" id="language" name="language" class="text-center text-white" value="en"/><br/>
|
<div>
|
||||||
<label For="password">Password:</label><br/>
|
<select class="form-select text-white text-center bg-gray-900" id="languages" name="language">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="ru">Russian</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label For="room_pass">Password:</label><br/>
|
||||||
<input type="text" id="password" name="room_pass" class="text-center text-white" value="" placeholder="Leave empty for open room"/><br/>
|
<input type="text" id="password" name="room_pass" class="text-center text-white" value="" placeholder="Leave empty for open room"/><br/>
|
||||||
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" type="submit" >Create Room</button>
|
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" type="submit" >Create Room</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
@@ -4,9 +4,16 @@
|
|||||||
{{ else if ne .LinkLogin "" }}
|
{{ else if ne .LinkLogin "" }}
|
||||||
{{template "linklogin" .LinkLogin}}
|
{{template "linklogin" .LinkLogin}}
|
||||||
{{ else if not .State.RoomID }}
|
{{ else if not .State.RoomID }}
|
||||||
<div id="hello-user">
|
<div id="hello-user" class="grid grid-cols-3 items-center text-xl py-2">
|
||||||
<p>data: {{.}} {{.State}} {{.Room}}</p>
|
<div class="text-left">
|
||||||
<p>Hello {{.State.Username}}</p>
|
<a href="/stats" class="bg-indigo-600 text-white font-semibold text-sm px-4 py-2 rounded hover:bg-indigo-500 visited:text-white">
|
||||||
|
stats
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-center">Hello {{.State.Username}}</p>
|
||||||
|
<div class="text-right">
|
||||||
|
<a href="/signout"><button class="bg-indigo-600 text-white font-semibold text-sm px-4 py-2 rounded hover:bg-indigo-500">signout</button></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="create-room" class="create-room-div">
|
<div id="create-room" class="create-room-div">
|
||||||
<button button id="create-form-btn" type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/createform" hx-swap="outerHTML">SHOW ROOM CREATE FORM</button>
|
<button button id="create-form-btn" type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/createform" hx-swap="outerHTML">SHOW ROOM CREATE FORM</button>
|
||||||
@@ -15,6 +22,7 @@
|
|||||||
{{template "roomlist" .List}}
|
{{template "roomlist" .List}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
<div id="sse-listener" sse-connect="/sub/sse" hx-trigger="sse:roomupdate_{{.State.RoomID}}" hx-get="/room" hx-target="#room-interier" hx-swap="none" style="display:none;"></div>
|
||||||
<div id="room">
|
<div id="room">
|
||||||
{{template "room" .}}
|
{{template "room" .}}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,15 +1,19 @@
|
|||||||
{{define "linklogin"}}
|
{{define "linklogin"}}
|
||||||
<div id="logindiv">
|
<div id="logindiv">
|
||||||
You're about to join room#{{.}}; but first!
|
You're about to join room#{{.}}; but first!
|
||||||
<form class="space-y-6" hx-post="/login" hx-target="#ancestor">
|
<form class="space-y-6" hx-post="/login" hx-target="#main-content">
|
||||||
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label>
|
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
||||||
<input type="hidden" name="room_id" value={{.}}>
|
<input type="hidden" name="room_id" value={{.}}>
|
||||||
</div>
|
</div>
|
||||||
<div id="login_notice">this name looks available</div>
|
<div id="login_notice">this name looks available</div>
|
||||||
|
<label For="password" class="block text-sm font-medium leading-6 text-white-900">password</label>
|
||||||
|
<div>
|
||||||
|
<input id="password" name="password" type="password" class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
|
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,13 +1,17 @@
|
|||||||
{{define "login"}}
|
{{define "login"}}
|
||||||
<div id="logindiv">
|
<div id="logindiv">
|
||||||
<form class="space-y-6" hx-post="/login" hx-target="#ancestor">
|
<form class="space-y-6" hx-post="/login" hx-target="#main-content">
|
||||||
<div>
|
<div>
|
||||||
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label>
|
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username (signup|login)</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
||||||
</div>
|
</div>
|
||||||
<div id="login_notice">this name looks available</div>
|
<div id="login_notice">this name looks available</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label For="password" class="block text-sm font-medium leading-6 text-white-900">password</label>
|
||||||
|
<input id="password" name="password" type="password" class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
|
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -5,13 +5,13 @@
|
|||||||
<a href="/">
|
<a href="/">
|
||||||
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
|
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
|
||||||
<p class="font-bold">Be Warned</p>
|
<p class="font-bold">Be Warned</p>
|
||||||
<p>This Name is already taken. You won't be able to login with it until other person leaves.</p>
|
<p>This Name is already taken. But if it's yours, you should know the password.</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
|
<div id="login_notice" class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
|
||||||
<p class="font-bold">Be Warned</p>
|
<p class="font-bold">Be Warned</p>
|
||||||
<p>This Name is already taken. You won't be able to login with it until other person leaves.</p>
|
<p>This Name is already taken. But if it's yours, you should know the password.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
{{define "room"}}
|
{{define "room"}}
|
||||||
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}" class=space-y-2>
|
<div id="room-interier" class=space-y-2>
|
||||||
<div id="meta">
|
<div id="headwrapper" class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
|
||||||
|
<div id="meta" class="md:col-span-1 border-2 rounded-lg text-center space-y-2">
|
||||||
<p>Hello {{.State.Username}};</p>
|
<p>Hello {{.State.Username}};</p>
|
||||||
<p>Room created by {{.Room.CreatorName}};</p>
|
<p>Room created by {{.Room.CreatorName}};</p>
|
||||||
<p>Room link:</p>
|
<p>Room link:</p>
|
||||||
@@ -16,13 +17,13 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<p>
|
<p>
|
||||||
{{if eq .State.Team ""}}
|
{{if eq .State.Team ""}}
|
||||||
join the team!
|
you don't have a role! join the team ->
|
||||||
{{else}}
|
{{else}}
|
||||||
you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>!
|
you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>!
|
||||||
{{end}}
|
{{end}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<div id="infopatch" class="md:col-span-3">
|
||||||
{{if .Room.IsRunning}}
|
{{if .Room.IsRunning}}
|
||||||
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p>
|
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p>
|
||||||
{{template "turntimer" .Room}}
|
{{template "turntimer" .Room}}
|
||||||
@@ -48,21 +49,26 @@
|
|||||||
<!-- Right Panel -->
|
<!-- Right Panel -->
|
||||||
{{template "teamlist" .Room.RedTeam}}
|
{{template "teamlist" .Room.RedTeam}}
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
</div>
|
||||||
<div id="systembox" style="overflow-y: auto; max-height: 100px;">
|
</div>
|
||||||
Server says: <br>
|
<hr/>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-5 md:gap-4">
|
||||||
|
<div hx-get="/actionhistory" class="md:col-span-1">
|
||||||
|
{{template "actionhistory" .Room.ActionHistory}}
|
||||||
|
</div>
|
||||||
|
<div id="cardtable" class="md:col-span-3">
|
||||||
|
{{template "cardtable" .Room}}
|
||||||
|
</div>
|
||||||
|
<div class="hidden md:block md:col-span-1"></div> <!-- Spacer -->
|
||||||
|
</div>
|
||||||
|
<div id="systembox" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
|
||||||
|
bot thought: <br>
|
||||||
<ul>
|
<ul>
|
||||||
{{range .Room.LogJournal}}
|
{{range .Room.LogJournal}}
|
||||||
<li>{{.}}</li>
|
<li>{{.Username}}: {{.Entry}}</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div sse-swap="journal_{{.Room.ID}}">
|
|
||||||
bot thoughts
|
|
||||||
<div>
|
|
||||||
<div id="cardtable">
|
|
||||||
{{template "cardtable" .Room}}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
{{if .Room.IsRunning}}
|
{{if .Room.IsRunning}}
|
||||||
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}}
|
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}}
|
||||||
@@ -73,16 +79,13 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{if and (eq .State.Username .Room.CreatorName) (.Room.IsRunning)}}
|
{{if and (eq .State.Username .Room.CreatorName) (.Room.BotFailed)}}
|
||||||
<button hx-get="/renotify-bot" hx-swap="none" class="bg-gray-100 text-black px-1 py-1 rounded">Btn in case llm call failed</button>
|
<button hx-get="/renotify-bot" hx-swap="none" class="bg-gray-100 text-black px-1 py-1 rounded">Btn in case llm call failed</button>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
|
|
||||||
{{template "actionhistory" .Room.ActionHistory}}
|
|
||||||
</div>
|
|
||||||
{{if not .Room.IsRunning}}
|
{{if not .Room.IsRunning}}
|
||||||
<div id="exitbtn">
|
<div id="exitbtn">
|
||||||
<button button id="exit-room-btn" type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/exit" hx-target="#ancestor">Exit Room</button>
|
<button button id="exit-room-btn" type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/exit" hx-target="#main-content">Exit Room</button>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
{{define "roomlist"}}
|
{{define "roomlist"}}
|
||||||
<div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#ancestor">
|
<div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#main-content">
|
||||||
{{range .}}
|
{{range .}}
|
||||||
<p>
|
<div hx-get="/room-join?id={{.ID}}" hx-target="#main-content" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
{{.ID}}
|
|
||||||
</p>
|
|
||||||
<div hx-get="/room-join?id={{.ID}}" hx-target="#ancestor" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<div class="room-info">
|
<div class="room-info">
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
|
55
components/stats.html
Normal file
55
components/stats.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{{define "stats"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Alias</title>
|
||||||
|
<script src="/assets/tailwind.css"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css"/>
|
||||||
|
<script src="/assets/htmx.min.js"></script>
|
||||||
|
<script src="/assets/htmx.sse.js"></script>
|
||||||
|
<script src="/assets/helpers.js"></script>
|
||||||
|
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="main-content">
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<h1 class="text-2xl font-bold mb-4">Player Leaderboard</h1>
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="/" class="bg-indigo-600 text-white font-semibold text-sm px-4 py-2 rounded hover:bg-indigo-500 visited:text-white">
|
||||||
|
back home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full bg-white">
|
||||||
|
<thead class="bg-gray-800 text-white">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-4">Player</th>
|
||||||
|
<th class="py-2 px-4">Rating</th>
|
||||||
|
<th class="py-2 px-4">Games Played</th>
|
||||||
|
<th class="py-2 px-4">Games Won</th>
|
||||||
|
<th class="py-2 px-4">Games Lost</th>
|
||||||
|
<th class="py-2 px-4">Mime Winrate</th>
|
||||||
|
<th class="py-2 px-4">Guesser Winrate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-gray-700">
|
||||||
|
{{range .}}
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 px-4 border">{{.Username}}</td>
|
||||||
|
<td class="py-2 px-4 border">{{.Rating}}</td>
|
||||||
|
<td class="py-2 px-4 border">{{.GamesPlayed}}</td>
|
||||||
|
<td class="py-2 px-4 border">{{.GamesWon}}</td>
|
||||||
|
<td class="py-2 px-4 border">{{.GamesLost}}</td>
|
||||||
|
<td class="py-2 px-4 border">{{printf "%.2f" .MimeWinrate}}</td>
|
||||||
|
<td class="py-2 px-4 border">{{printf "%.2f" .GuesserWinrate}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
@@ -2,7 +2,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl mb-4">Join Blue Team</h2>
|
<h2 class="text-xl mb-4">Join Blue Team</h2>
|
||||||
<form hx-post="/join-team" hx-target="#ancestor">
|
<form hx-post="/join-team" hx-target="#main-content">
|
||||||
<input type="hidden" name="team" value="blue">
|
<input type="hidden" name="team" value="blue">
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
{{if and (eq .State.Role "guesser") (eq .State.Team "blue")}}
|
{{if and (eq .State.Role "guesser") (eq .State.Team "blue")}}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl mb-4">Join Red Team</h2>
|
<h2 class="text-xl mb-4">Join Red Team</h2>
|
||||||
<form hx-post="/join-team" hx-target="#ancestor">
|
<form hx-post="/join-team" hx-target="#main-content">
|
||||||
<input type="hidden" name="team" value="red">
|
<input type="hidden" name="team" value="red">
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
{{if and (eq .State.Role "guesser") (eq .State.Team "red")}}
|
{{if and (eq .State.Role "guesser") (eq .State.Team "red")}}
|
||||||
|
@@ -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"
|
||||||
|
@@ -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
|
||||||
|
103
crons/main.go
103
crons/main.go
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"gralias/broker"
|
||||||
|
"gralias/models"
|
||||||
"gralias/repos"
|
"gralias/repos"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,7 +29,8 @@ func (cm *CronManager) Start() {
|
|||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
cm.CleanupRooms()
|
cm.CleanupRooms()
|
||||||
cm.CleanupActions()
|
cm.CleanupActions()
|
||||||
cm.CleanupInactiveRooms()
|
cm.CleanupPlayersRoom()
|
||||||
|
ticker.Reset(30 * time.Second)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -46,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)
|
||||||
@@ -55,14 +57,13 @@ func (cm *CronManager) CleanupRooms() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
roomListChange := false
|
||||||
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 {
|
||||||
@@ -71,9 +72,9 @@ func (cm *CronManager) CleanupRooms() {
|
|||||||
if err := cm.repo.SettingsDeleteByRoomID(ctx, room.ID); err != nil {
|
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)
|
cm.log.Error("failed to delete settings for empty room", "room_id", room.ID, "err", err)
|
||||||
}
|
}
|
||||||
|
roomListChange = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
creatorInRoom := false
|
creatorInRoom := false
|
||||||
for _, player := range players {
|
for _, player := range players {
|
||||||
if player.Username == room.CreatorName {
|
if player.Username == room.CreatorName {
|
||||||
@@ -81,12 +82,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 {
|
||||||
@@ -96,17 +117,25 @@ 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 {
|
if err := cm.repo.SettingsDeleteByRoomID(ctx, room.ID); err != nil {
|
||||||
cm.log.Error("failed to delete settings after creator left", "room_id", room.ID, "err", err)
|
cm.log.Error("failed to delete settings for room", "room_id", room.ID, "reason", reason, "err", err)
|
||||||
}
|
}
|
||||||
|
roomListChange = true
|
||||||
|
// Move to the next room
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
cm.log.Error("failed to commit transaction", "err", err)
|
cm.log.Error("failed to commit transaction", "err", err)
|
||||||
}
|
}
|
||||||
|
if roomListChange {
|
||||||
|
broker.Notifier.Notifier <- broker.NotificationEvent{
|
||||||
|
EventName: models.NotifyRoomListUpdate,
|
||||||
|
Payload: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CronManager) CleanupActions() {
|
func (cm *CronManager) CleanupActions() {
|
||||||
@@ -123,7 +152,6 @@ func (cm *CronManager) CleanupActions() {
|
|||||||
panic(r)
|
panic(r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := cm.repo.ActionDeleteOrphaned(ctx); err != nil {
|
if err := cm.repo.ActionDeleteOrphaned(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 {
|
||||||
@@ -131,56 +159,7 @@ func (cm *CronManager) CleanupActions() {
|
|||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cm *CronManager) CleanupInactiveRooms() {
|
|
||||||
ctx, tx, err := cm.repo.InitTx(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
cm.log.Error("failed to init transaction for inactive rooms 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 inactive rooms cleanup", "err", err)
|
|
||||||
}
|
|
||||||
panic(r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
rooms, err := cm.repo.RoomList(ctx)
|
|
||||||
if err != nil {
|
|
||||||
cm.log.Error("failed to get rooms list for inactive rooms cleanup", "err", err)
|
|
||||||
if err := tx.Rollback(); err != nil {
|
|
||||||
cm.log.Error("failed to rollback transaction for inactive rooms cleanup", "err", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, room := range rooms {
|
|
||||||
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)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if lastActionTime.IsZero() && time.Since(room.CreatedAt) > time.Hour {
|
|
||||||
cm.log.Info("deleting inactive room (no actions)", "room_id", room.ID, "created_at", room.CreatedAt)
|
|
||||||
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
|
|
||||||
cm.log.Error("failed to delete inactive room (no actions)", "room_id", room.ID, "err", err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !lastActionTime.IsZero() && time.Since(lastActionTime) > time.Hour && time.Since(room.CreatedAt) > time.Hour {
|
|
||||||
cm.log.Info("deleting inactive room (last action older than 1 hour)", "room_id", room.ID, "last_action_time", lastActionTime)
|
|
||||||
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
|
|
||||||
cm.log.Error("failed to delete inactive room (last action older than 1 hour)", "room_id", room.ID, "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
cm.log.Error("failed to commit transaction for inactive rooms cleanup", "err", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
74
crons/players.go
Normal file
74
crons/players.go
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
package crons
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/llmapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (cm *CronManager) CleanupPlayersRoom() {
|
||||||
|
ctx, tx, err := cm.repo.InitTx(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
cm.log.Error("failed to init transaction for actions cleanup", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
players, err := cm.repo.PlayerListAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cm.log.Error("failed to list players", "err", err)
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// get all rooms to have only one req
|
||||||
|
rooms, err := cm.repo.RoomList(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cm.log.Error("failed to list rooms", "err", err)
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, player := range players {
|
||||||
|
found := false
|
||||||
|
for _, room := range rooms {
|
||||||
|
// check if room exists
|
||||||
|
if player.RoomID != nil && room.ID == *player.RoomID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
cm.log.Debug("player routine; not found room", "username", player.Username)
|
||||||
|
if !player.IsBot && player.RoomID != nil {
|
||||||
|
// delete roomid from player
|
||||||
|
if err := cm.repo.PlayerExitRoom(ctx, player.Username); err != nil {
|
||||||
|
cm.log.Error("failed to unset room", "err", err)
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if player.IsBot {
|
||||||
|
cm.log.Debug("trying to remove bot", "name", player.Username)
|
||||||
|
// delete player and stop the bot
|
||||||
|
if err := llmapi.RemoveBotNoRoom(player.Username); err != nil {
|
||||||
|
cm.log.Error("failed to remove bot", "err", err)
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
cm.log.Error("failed to commit transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
}
|
8
docker-compose.yml
Normal file
8
docker-compose.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
jaeger:
|
||||||
|
image: jaegertracing/all-in-one:latest
|
||||||
|
ports:
|
||||||
|
- "6831:6831/udp"
|
||||||
|
- "14268:14268"
|
||||||
|
- "16686:16686"
|
13
go.mod
13
go.mod
@@ -12,6 +12,19 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
27
go.sum
27
go.sum
@@ -4,8 +4,17 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
|
|||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
@@ -19,6 +28,24 @@ github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
|||||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
|
||||||
|
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0 h1:SNhVp/9q4Go/XHBkQ1/d5u9P/U+L1yaGPoi0x+mStaI=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.37.0/go.mod h1:tx8OOlGH6R4kLV67YaYO44GFXloEjGPZuMjEkaaqIp4=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
@@ -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
|
||||||
}
|
}
|
||||||
@@ -56,7 +59,7 @@ func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
|
|||||||
}
|
}
|
||||||
resp.State = state
|
resp.State = state
|
||||||
if state.RoomID == nil || *state.RoomID == "" {
|
if state.RoomID == nil || *state.RoomID == "" {
|
||||||
log.Debug("returning state without room", "username", state.Username)
|
// log.Debug("returning state without room", "username", state.Username)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
// room, err := getRoomByID(state.RoomID)
|
// room, err := getRoomByID(state.RoomID)
|
||||||
@@ -88,12 +91,17 @@ func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fillCardMarks(ctx context.Context, room *models.Room) error {
|
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 i, card := range room.Cards {
|
||||||
marks, err := repo.CardMarksByCardID(ctx, card.ID)
|
for _, mark := range marks {
|
||||||
if err != nil {
|
if mark.CardID == card.ID {
|
||||||
return err
|
room.Cards[i].Marks = append(room.Cards[i].Marks, mark)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
room.Cards[i].Marks = marks
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -107,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)
|
||||||
@@ -174,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)
|
||||||
@@ -214,6 +190,9 @@ func notify(event, msg string) {
|
|||||||
func loadCards(room *models.Room) {
|
func loadCards(room *models.Room) {
|
||||||
// remove old cards
|
// remove old cards
|
||||||
room.Cards = []models.WordCard{}
|
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",
|
||||||
@@ -226,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() {
|
||||||
@@ -257,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 {
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
@@ -9,7 +8,6 @@ import (
|
|||||||
"gralias/models"
|
"gralias/models"
|
||||||
"gralias/utils"
|
"gralias/utils"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -37,7 +35,6 @@ func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cleanName := utils.RemoveSpacesFromStr(username)
|
cleanName := utils.RemoveSpacesFromStr(username)
|
||||||
// allNames := getAllNames()
|
|
||||||
allNames, err := repo.PlayerListNames(r.Context())
|
allNames, err := repo.PlayerListNames(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
@@ -74,25 +71,33 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
abortWithError(w, msg)
|
abortWithError(w, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
password := r.PostFormValue("password")
|
||||||
var makeplayer bool
|
var makeplayer bool
|
||||||
roomID := r.PostFormValue("room_id")
|
roomID := r.PostFormValue("room_id")
|
||||||
// make sure username does not exists
|
// make sure username does not exists
|
||||||
cleanName := utils.RemoveSpacesFromStr(username)
|
cleanName := utils.RemoveSpacesFromStr(username)
|
||||||
|
clearPass := utils.RemoveSpacesFromStr(password)
|
||||||
|
// check if that user was already in db
|
||||||
|
userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
|
||||||
|
if err != nil || userstate == nil {
|
||||||
|
log.Debug("making new player", "error", err, "state", userstate, "clean_name", cleanName)
|
||||||
|
userstate = models.InitPlayer(cleanName)
|
||||||
|
makeplayer = true
|
||||||
|
} else {
|
||||||
|
if userstate.Password != clearPass {
|
||||||
|
log.Error("wrong password", "username", cleanName, "password", clearPass)
|
||||||
|
abortWithError(w, "wrong password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
// login user
|
// login user
|
||||||
cookie, err := makeCookie(cleanName, r.RemoteAddr)
|
cookie, session, err := makeCookie(cleanName, r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("failed to login", "error", err)
|
log.Error("failed to login", "error", err)
|
||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
// check if that user was already in db
|
|
||||||
// userstate, err := loadState(cleanName)
|
|
||||||
userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
|
|
||||||
if err != nil || userstate == nil {
|
|
||||||
userstate = models.InitPlayer(cleanName)
|
|
||||||
makeplayer = true
|
|
||||||
}
|
|
||||||
fi := &models.FullInfo{
|
fi := &models.FullInfo{
|
||||||
State: userstate,
|
State: userstate,
|
||||||
}
|
}
|
||||||
@@ -105,20 +110,12 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// room.PlayerList = append(room.PlayerList, fi.State.Username)
|
|
||||||
fi.Room = room
|
|
||||||
fi.List = nil
|
fi.List = nil
|
||||||
fi.State.RoomID = &room.ID
|
fi.State.RoomID = &room.ID
|
||||||
if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); 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,23 +125,24 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// save state to cache
|
// save state to cache
|
||||||
// if err := saveState(cleanName, userstate); err != nil {
|
|
||||||
if makeplayer {
|
if makeplayer {
|
||||||
|
userstate.Password = clearPass
|
||||||
if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
|
if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
|
||||||
// if err := saveFullInfo(r.Context(), fi); err != nil {
|
|
||||||
log.Error("failed to save state", "error", err)
|
log.Error("failed to save state", "error", err)
|
||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
if err := repo.SessionCreate(r.Context(), session); err != nil {
|
||||||
// log.Error("failed to execute base template", "error", err)
|
log.Error("failed to save session", "error", err)
|
||||||
// }
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/", 302)
|
http.Redirect(w, r, "/", 302)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCookie(username string, remote string) (*http.Cookie, error) {
|
func makeCookie(username string, remote string) (*http.Cookie, *models.Session, error) {
|
||||||
// secret
|
// secret
|
||||||
// Create a new random session token
|
// Create a new random session token
|
||||||
// sessionToken := xid.New().String()
|
// sessionToken := xid.New().String()
|
||||||
@@ -181,14 +179,37 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
|
|||||||
cookie.Secure = false
|
cookie.Secure = false
|
||||||
log.Info("changing cookie domain", "domain", cookie.Domain)
|
log.Info("changing cookie domain", "domain", cookie.Domain)
|
||||||
}
|
}
|
||||||
// make player first, since username is fk to players table
|
// player, err := repo.PlayerGetByName(context.Background(), username)
|
||||||
player := models.InitPlayer(username)
|
// if err != nil || player == nil {
|
||||||
if err := repo.PlayerAdd(context.Background(), player); err != nil {
|
// // make player first, since username is fk to players table
|
||||||
slog.Error("failed to create player", "username", username)
|
// player = models.InitPlayer(username)
|
||||||
return nil, err
|
// if err := repo.PlayerAdd(context.Background(), player); err != nil {
|
||||||
}
|
// slog.Error("failed to create player", "username", username)
|
||||||
if err := repo.SessionCreate(context.Background(), session); err != nil {
|
// return nil, err
|
||||||
return nil, err
|
// }
|
||||||
}
|
// }
|
||||||
return cookie, nil
|
// if err := repo.SessionCreate(context.Background(), session); err != nil {
|
||||||
|
// return nil, err
|
||||||
|
// }
|
||||||
|
return cookie, session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSignout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "session_token",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
}
|
||||||
|
cookie.Secure = true
|
||||||
|
cookie.SameSite = http.SameSiteNoneMode
|
||||||
|
if strings.Contains(r.RemoteAddr, "192.168.0") {
|
||||||
|
cookie.Domain = "192.168.0.100"
|
||||||
|
cookie.SameSite = http.SameSiteLaxMode
|
||||||
|
cookie.Secure = false
|
||||||
|
log.Info("changing cookie domain for signout", "domain", cookie.Domain)
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
}
|
}
|
||||||
|
@@ -42,15 +42,15 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fi, err := getFullInfoByCtx(ctx)
|
fi, err := getFullInfoByCtx(ctx)
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
|
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
|
||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// color, exists := fi.Room.WCMap[word]
|
|
||||||
color, exists := fi.Room.FindColor(word)
|
color, exists := fi.Room.FindColor(word)
|
||||||
if !exists {
|
if !exists {
|
||||||
abortWithError(w, "word is not found")
|
abortWithError(w, "word is not found")
|
||||||
@@ -71,6 +71,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
updateStatsOnCardReveal(r.Context(), fi.State, color)
|
||||||
fi.Room.UpdateCounter()
|
fi.Room.UpdateCounter()
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
Actor: fi.State.Username,
|
Actor: fi.State.Username,
|
||||||
@@ -87,6 +88,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
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,
|
||||||
@@ -98,7 +100,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
fi.Room.MimeDone = false
|
fi.Room.MimeDone = false
|
||||||
fi.Room.OpenedThisTurn = 0
|
fi.Room.OpenedThisTurn = 0
|
||||||
fi.Room.ThisTurnLimit = 0
|
fi.Room.ThisTurnLimit = 0
|
||||||
fi.Room.ClearMarks()
|
clearMarks = true
|
||||||
StopTurnTimer(fi.Room.ID)
|
StopTurnTimer(fi.Room.ID)
|
||||||
}
|
}
|
||||||
switch string(color) {
|
switch string(color) {
|
||||||
@@ -109,6 +111,7 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
fi.Room.IsOver = true
|
fi.Room.IsOver = true
|
||||||
fi.Room.TeamWon = oppositeColor
|
fi.Room.TeamWon = oppositeColor
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: fi.Room.ID,
|
||||||
Actor: fi.State.Username,
|
Actor: fi.State.Username,
|
||||||
ActorColor: string(fi.State.Team),
|
ActorColor: string(fi.State.Team),
|
||||||
WordColor: models.WordColorBlack,
|
WordColor: models.WordColorBlack,
|
||||||
@@ -117,8 +120,9 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
fi.Room.OpenedThisTurn = 0
|
fi.Room.OpenedThisTurn = 0
|
||||||
fi.Room.ThisTurnLimit = 0
|
fi.Room.ThisTurnLimit = 0
|
||||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
fi.Room.ClearMarks()
|
clearMarks = true
|
||||||
StopTurnTimer(fi.Room.ID)
|
StopTurnTimer(fi.Room.ID)
|
||||||
|
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||||
case string(models.WordColorWhite), string(oppositeColor):
|
case string(models.WordColorWhite), string(oppositeColor):
|
||||||
log.Debug("opened white or opposite color word", "word", word, "opposite-color", oppositeColor)
|
log.Debug("opened white or opposite color word", "word", word, "opposite-color", oppositeColor)
|
||||||
// end turn
|
// end turn
|
||||||
@@ -126,6 +130,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
|
||||||
|
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 {
|
||||||
@@ -134,13 +139,14 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
fi.Room.IsOver = true
|
fi.Room.IsOver = true
|
||||||
fi.Room.TeamWon = "blue"
|
fi.Room.TeamWon = "blue"
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: fi.Room.ID,
|
||||||
Actor: fi.State.Username,
|
Actor: fi.State.Username,
|
||||||
ActorColor: string(fi.State.Team),
|
ActorColor: string(fi.State.Team),
|
||||||
WordColor: models.WordColorBlue,
|
WordColor: models.WordColorBlue,
|
||||||
Action: models.ActionTypeGameOver,
|
Action: models.ActionTypeGameOver,
|
||||||
}
|
}
|
||||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
fi.Room.ClearMarks()
|
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||||
}
|
}
|
||||||
if fi.Room.RedCounter == 0 {
|
if fi.Room.RedCounter == 0 {
|
||||||
// red won
|
// red won
|
||||||
@@ -148,13 +154,14 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
fi.Room.IsOver = true
|
fi.Room.IsOver = true
|
||||||
fi.Room.TeamWon = "red"
|
fi.Room.TeamWon = "red"
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: fi.Room.ID,
|
||||||
Actor: fi.State.Username,
|
Actor: fi.State.Username,
|
||||||
ActorColor: string(fi.State.Team),
|
ActorColor: string(fi.State.Team),
|
||||||
WordColor: models.WordColorRed,
|
WordColor: models.WordColorRed,
|
||||||
Action: models.ActionTypeGameOver,
|
Action: models.ActionTypeGameOver,
|
||||||
}
|
}
|
||||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
fi.Room.ClearMarks()
|
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||||
}
|
}
|
||||||
default: // same color as the team
|
default: // same color as the team
|
||||||
// check if game over
|
// check if game over
|
||||||
@@ -163,13 +170,20 @@ func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
|||||||
fi.Room.IsOver = true
|
fi.Room.IsOver = true
|
||||||
fi.Room.TeamWon = fi.State.Team
|
fi.Room.TeamWon = fi.State.Team
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: fi.Room.ID,
|
||||||
Actor: fi.State.Username,
|
Actor: fi.State.Username,
|
||||||
ActorColor: string(fi.State.Team),
|
ActorColor: string(fi.State.Team),
|
||||||
WordColor: models.WordColorRed,
|
WordColor: models.WordColorRed,
|
||||||
Action: models.ActionTypeGameOver,
|
Action: models.ActionTypeGameOver,
|
||||||
}
|
}
|
||||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
fi.Room.ClearMarks()
|
updateStatsOnGameOver(r.Context(), fi.Room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clearMarks {
|
||||||
|
fi.Room.ClearMarks()
|
||||||
|
if err := repo.CardMarksRemoveByRoomID(r.Context(), fi.Room.ID); err != nil {
|
||||||
|
log.Error("failed to remove marks", "error", err, "room_id", fi.Room.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := saveFullInfo(r.Context(), fi); err != nil {
|
if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
@@ -193,8 +207,9 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fi, err := getFullInfoByCtx(ctx)
|
fi, err := getFullInfoByCtx(ctx)
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
|
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
|
||||||
@@ -239,7 +254,7 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// TODO: if mark was found, it needs to be removed
|
// if mark was found, it needs to be removed
|
||||||
if err := repo.CardMarksRemove(r.Context(), card.ID, fi.State.Username); err != nil {
|
if err := repo.CardMarksRemove(r.Context(), card.ID, fi.State.Username); err != nil {
|
||||||
log.Error("failed to remove mark", "error", err, "card", card)
|
log.Error("failed to remove mark", "error", err, "card", card)
|
||||||
abortWithError(w, "failed to remove mark")
|
abortWithError(w, "failed to remove mark")
|
||||||
@@ -261,8 +276,9 @@ func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func HandleActionHistory(w http.ResponseWriter, r *http.Request) {
|
func HandleActionHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmpl, err := template.ParseGlob("components/*.html")
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
@@ -279,13 +295,21 @@ func HandleAddBot(w http.ResponseWriter, r *http.Request) {
|
|||||||
// get team; // get role; make up a name
|
// get team; // get role; make up a name
|
||||||
team := r.URL.Query().Get("team")
|
team := r.URL.Query().Get("team")
|
||||||
role := r.URL.Query().Get("role")
|
role := r.URL.Query().Get("role")
|
||||||
log.Debug("got add-bot request", "team", team, "role", role)
|
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
botname := fmt.Sprintf("bot_%d", len(llmapi.SignalChanMap)+1) // what if many rooms?
|
var botname string
|
||||||
|
maxID, err := repo.PlayerGetMaxID(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("failed to get players max id")
|
||||||
|
botname = fmt.Sprintf("bot_%d", len(llmapi.SignalChanMap)+1) // what if many rooms?
|
||||||
|
} else {
|
||||||
|
botname = fmt.Sprintf("bot_%d", maxID+1) // what if many rooms?
|
||||||
|
}
|
||||||
|
log.Debug("got add-bot request", "team", team, "role", role, "max_id", maxID, "botname", botname, "error", err)
|
||||||
_, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false)
|
_, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
@@ -299,8 +323,9 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
|
|||||||
botName := r.URL.Query().Get("bot")
|
botName := r.URL.Query().Get("bot")
|
||||||
log.Debug("got remove-bot request", "bot_name", botName)
|
log.Debug("got remove-bot request", "bot_name", botName)
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := llmapi.RemoveBot(botName, fi.Room); err != nil {
|
if err := llmapi.RemoveBot(botName, fi.Room); err != nil {
|
||||||
@@ -309,3 +334,19 @@ func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HandleGetRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
|
||||||
|
log.Error("failed to execute template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -72,8 +72,13 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
// get username
|
// get username
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fi.Room == nil {
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if fi.Room.IsRunning && role == "mime" {
|
if fi.Room.IsRunning && role == "mime" {
|
||||||
@@ -107,8 +112,9 @@ func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
|
|||||||
func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
|
func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
|
||||||
// get username
|
// get username
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// check if one who pressed it is from the team who has the turn
|
// check if one who pressed it is from the team who has the turn
|
||||||
@@ -139,8 +145,9 @@ func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func HandleStartGame(w http.ResponseWriter, r *http.Request) {
|
func HandleStartGame(w http.ResponseWriter, r *http.Request) {
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// check if enough players
|
// check if enough players
|
||||||
@@ -173,6 +180,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),
|
||||||
@@ -188,8 +197,6 @@ func HandleStartGame(w http.ResponseWriter, r *http.Request) {
|
|||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
// Save action history
|
// Save action history
|
||||||
action.RoomID = fi.Room.ID
|
|
||||||
action.CreatedAt = time.Now()
|
|
||||||
if err := repo.ActionCreate(ctx, &action); err != nil {
|
if err := repo.ActionCreate(ctx, &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)
|
||||||
@@ -289,8 +296,9 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
|
|||||||
clue := r.PostFormValue("clue")
|
clue := r.PostFormValue("clue")
|
||||||
num := r.PostFormValue("number")
|
num := r.PostFormValue("number")
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guessLimitU64, err := strconv.ParseUint(num, 10, 8)
|
guessLimitU64, err := strconv.ParseUint(num, 10, 8)
|
||||||
@@ -345,18 +353,20 @@ func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
|
|||||||
fi.Room.OpenedThisTurn = 0
|
fi.Room.OpenedThisTurn = 0
|
||||||
StartTurnTimer(fi.Room.ID, fi.Room.Settings.RoundTime)
|
StartTurnTimer(fi.Room.ID, fi.Room.Settings.RoundTime)
|
||||||
log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit)
|
log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit)
|
||||||
notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
|
// notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
|
||||||
notifyBotIfNeeded(fi.Room)
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, clue+num)
|
||||||
if err := saveFullInfo(r.Context(), fi); err != nil {
|
if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
notifyBotIfNeeded(fi.Room)
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {
|
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notifyBotIfNeeded(fi.Room)
|
notifyBotIfNeeded(fi.Room)
|
||||||
|
@@ -24,16 +24,11 @@ func init() {
|
|||||||
Level: slog.LevelDebug,
|
Level: slog.LevelDebug,
|
||||||
AddSource: true,
|
AddSource: true,
|
||||||
}))
|
}))
|
||||||
// memcache = cache.MemCache
|
|
||||||
cfg = config.LoadConfigOrDefault("")
|
cfg = config.LoadConfigOrDefault("")
|
||||||
Notifier = broker.Notifier
|
Notifier = broker.Notifier
|
||||||
// cache.MemCache.StartBackupRoutine(15 * time.Second) // Reduced backup interval
|
// repo = repos.NewRepoProvider("sqlite3://../gralias.db")
|
||||||
// bot loader
|
repo = repos.RP
|
||||||
// check the rooms if it has bot_{digits} in them, create bots if have
|
|
||||||
repo = repos.NewRepoProvider("sqlite3://../gralias.db")
|
|
||||||
recoverBots()
|
recoverBots()
|
||||||
// if player has a roomID, but no team and role, try to recover
|
|
||||||
// recoverPlayers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandlePing(w http.ResponseWriter, r *http.Request) {
|
func HandlePing(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -80,45 +75,48 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fi, err := getFullInfoByCtx(r.Context())
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
if err != nil {
|
if err != nil || fi == nil {
|
||||||
abortWithError(w, err.Error())
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if fi.Room.IsRunning {
|
if fi.Room.IsRunning {
|
||||||
abortWithError(w, "cannot leave when game is running")
|
abortWithError(w, "cannot leave when game is running")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var creatorLeft bool
|
// if creator leaves, remove all players from room and delete room
|
||||||
if fi.Room.CreatorName == fi.State.Username {
|
if fi.Room.CreatorName == fi.State.Username {
|
||||||
creatorLeft = true
|
players, err := repo.PlayerListByRoom(r.Context(), fi.Room.ID)
|
||||||
}
|
if err != nil {
|
||||||
exitedRoom := fi.ExitRoom()
|
log.Error("failed to list players in room", "error", err)
|
||||||
// if err := saveRoom(exitedRoom); err != nil {
|
abortWithError(w, err.Error())
|
||||||
// abortWithError(w, err.Error())
|
return
|
||||||
// return
|
}
|
||||||
// }
|
for _, p := range players {
|
||||||
if creatorLeft {
|
if p.IsBot {
|
||||||
if err := repo.RoomDeleteByID(r.Context(), exitedRoom.ID); err != nil {
|
if err := repo.PlayerDelete(r.Context(), p.Username); err != nil {
|
||||||
|
log.Error("failed to delete bot", "error", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := repo.PlayerExitRoom(r.Context(), p.Username); err != nil {
|
||||||
|
log.Error("failed to exit room", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repo.RoomDeleteByID(r.Context(), fi.Room.ID); err != nil {
|
||||||
log.Error("failed to remove room", "error", err)
|
log.Error("failed to remove room", "error", err)
|
||||||
}
|
}
|
||||||
// removeRoom(exitedRoom.ID)
|
|
||||||
// TODO: notify users if creator left
|
|
||||||
// and throw them away
|
|
||||||
notify(models.NotifyRoomListUpdate, "")
|
notify(models.NotifyRoomListUpdate, "")
|
||||||
|
} else {
|
||||||
|
// if regular player leaves, just exit room
|
||||||
|
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
|
||||||
|
log.Error("failed to exit room", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// scary to update the whole room
|
fi.Room = nil
|
||||||
fiToSave := &models.FullInfo{
|
fi.State.RoomID = nil
|
||||||
Room: exitedRoom,
|
|
||||||
}
|
|
||||||
if err := repo.PlayerExitRoom(r.Context(), fi.State.Username); err != nil {
|
|
||||||
abortWithError(w, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := saveFullInfo(r.Context(), fiToSave); err != nil {
|
|
||||||
abortWithError(w, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// fi.List = listRooms(false)
|
|
||||||
fi.List, err = repo.RoomList(r.Context())
|
fi.List, err = repo.RoomList(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
@@ -128,3 +126,44 @@ func HandleExit(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HandleStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Debug("got stats call")
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats, err := repo.GetAllPlayerStats(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to get all player stats", "error", err)
|
||||||
|
abortWithError(w, "failed to retrieve player stats")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil || fi == nil {
|
||||||
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// there must be a better way
|
||||||
|
if fi != nil && fi.Room != nil && fi.Room.ID != "" && fi.State != nil {
|
||||||
|
fi.Room.UpdateCounter()
|
||||||
|
if fi.State.Role == "mime" {
|
||||||
|
fi.Room.MimeView() // there must be a better way
|
||||||
|
} else {
|
||||||
|
fi.Room.GuesserView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fi != nil && fi.Room == nil {
|
||||||
|
rooms, err := repo.RoomList(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to list rooms;", "error", err)
|
||||||
|
}
|
||||||
|
fi.List = rooms
|
||||||
|
}
|
||||||
|
fi.List = nil
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "stats", stats); err != nil {
|
||||||
|
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -61,10 +61,8 @@ func GetSession(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
userSession, err := repo.SessionByToken(r.Context(), sessionToken)
|
userSession, err := repo.SessionByToken(r.Context(), sessionToken)
|
||||||
// userSession, err := cacheGetSession(sessionToken)
|
|
||||||
// log.Debug("userSession from cache", "us", userSession)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := "auth failed; session does not exists"
|
msg := "auth failed; session does not exist"
|
||||||
log.Debug(msg, "error", err, "key", sessionToken)
|
log.Debug(msg, "error", err, "key", sessionToken)
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
@@ -83,13 +81,6 @@ func GetSession(next http.Handler) http.Handler {
|
|||||||
models.CtxUsernameKey, userSession.Username)
|
models.CtxUsernameKey, userSession.Username)
|
||||||
ctx = context.WithValue(ctx,
|
ctx = context.WithValue(ctx,
|
||||||
models.CtxSessionKey, userSession)
|
models.CtxSessionKey, userSession)
|
||||||
// if err := cacheSetSession(sessionToken,
|
|
||||||
// userSession); err != nil {
|
|
||||||
// msg := "failed to marshal user session"
|
|
||||||
// log.Warn(msg, "error", err)
|
|
||||||
// next.ServeHTTP(w, r)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
80
handlers/stats.go
Normal file
80
handlers/stats.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updateStatsOnCardReveal updates player stats when a card is revealed.
|
||||||
|
func updateStatsOnCardReveal(ctx context.Context, player *models.Player, cardColor models.WordColor) {
|
||||||
|
if player.IsBot {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stats, err := repo.GetPlayerStats(ctx, player.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to get player stats for card reveal update", "username", player.Username, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playerTeamColorStr := string(player.Team)
|
||||||
|
switch cardColor {
|
||||||
|
case models.WordColorBlack:
|
||||||
|
stats.OpenedBlackWords++
|
||||||
|
case models.WordColorWhite:
|
||||||
|
stats.OpenedWhiteWords++
|
||||||
|
default:
|
||||||
|
if string(cardColor) != playerTeamColorStr {
|
||||||
|
stats.OpenedOppositeWords++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repo.UpdatePlayerStats(ctx, stats); err != nil {
|
||||||
|
log.Error("failed to update player stats on card reveal", "username", player.Username, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateStatsOnGameOver updates stats for all players in a room when a game ends.
|
||||||
|
func updateStatsOnGameOver(ctx context.Context, room *models.Room) {
|
||||||
|
// Get all players in the room
|
||||||
|
players, err := repo.PlayerListByRoom(ctx, room.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to list players by room for stats update", "room_id", room.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, player := range players {
|
||||||
|
if player.IsBot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats, err := repo.GetPlayerStats(ctx, player.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to get player stats for game over update", "username", player.Username, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats.GamesPlayed++
|
||||||
|
if player.Team == room.TeamWon {
|
||||||
|
stats.GamesWon++
|
||||||
|
} else {
|
||||||
|
stats.GamesLost++
|
||||||
|
}
|
||||||
|
if player.Role == models.UserRoleMime {
|
||||||
|
stats.PlayedAsMime++
|
||||||
|
if stats.PlayedAsMime > 0 {
|
||||||
|
gamesWonAsMime := stats.MimeWinrate * float32(stats.PlayedAsMime-1)
|
||||||
|
if player.Team == room.TeamWon {
|
||||||
|
gamesWonAsMime++
|
||||||
|
}
|
||||||
|
stats.MimeWinrate = gamesWonAsMime / float32(stats.PlayedAsMime)
|
||||||
|
}
|
||||||
|
} else if player.Role == models.UserRoleGuesser {
|
||||||
|
stats.PlayedAsGuesser++
|
||||||
|
if stats.PlayedAsGuesser > 0 {
|
||||||
|
gamesWonAsGuesser := stats.GuesserWinrate * float32(stats.PlayedAsGuesser-1)
|
||||||
|
if player.Team == room.TeamWon {
|
||||||
|
gamesWonAsGuesser++
|
||||||
|
}
|
||||||
|
stats.GuesserWinrate = gamesWonAsGuesser / float32(stats.PlayedAsGuesser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repo.UpdatePlayerStats(ctx, stats); err != nil {
|
||||||
|
log.Error("failed to update player stats on game over", "username", player.Username, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -3,69 +3,37 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"gralias/models"
|
"gralias/models"
|
||||||
|
"gralias/timer"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type roomTimer struct {
|
|
||||||
ticker *time.Ticker
|
|
||||||
done chan bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
timers = make(map[string]*roomTimer)
|
|
||||||
mu sync.Mutex
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartTurnTimer(roomID string, timeLeft uint32) {
|
func StartTurnTimer(roomID string, timeLeft uint32) {
|
||||||
mu.Lock()
|
logger := slog.Default().With("room_id", roomID)
|
||||||
defer mu.Unlock()
|
|
||||||
if _, exists := timers[roomID]; exists {
|
onTurnEnd := func(ctx context.Context, roomID string) {
|
||||||
slog.Debug("trying to launch already running timer", "room_id", roomID)
|
room, err := repo.RoomGetByID(context.Background(), roomID)
|
||||||
return // Timer already running
|
if err != nil {
|
||||||
}
|
logger.Error("failed to get room by id", "error", err)
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
return
|
||||||
done := make(chan bool)
|
|
||||||
timers[roomID] = &roomTimer{ticker: ticker, done: done}
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
if timeLeft <= 0 {
|
|
||||||
room, err := repo.RoomGetByID(context.Background(), roomID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("failed to get room by id", "error", err)
|
|
||||||
StopTurnTimer(roomID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Info("turn time is over", "room_id", roomID)
|
|
||||||
room.ChangeTurn()
|
|
||||||
room.MimeDone = false
|
|
||||||
if err := repo.RoomUpdate(context.Background(), room); err != nil {
|
|
||||||
log.Error("failed to save room", "error", err)
|
|
||||||
}
|
|
||||||
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
|
|
||||||
notifyBotIfNeeded(room)
|
|
||||||
StopTurnTimer(roomID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timeLeft--
|
|
||||||
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(timeLeft), 10))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}()
|
logger.Info("turn time is over")
|
||||||
|
room.ChangeTurn()
|
||||||
|
room.MimeDone = false
|
||||||
|
if err := repo.RoomUpdate(context.Background(), room); err != nil {
|
||||||
|
logger.Error("failed to save room", "error", err)
|
||||||
|
}
|
||||||
|
notify(models.NotifyTurnTimerPrefix+room.ID, strconv.FormatUint(uint64(room.Settings.RoundTime), 10))
|
||||||
|
notifyBotIfNeeded(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
onTick := func(ctx context.Context, roomID string, currentLeft uint32) {
|
||||||
|
notify(models.NotifyTurnTimerPrefix+roomID, strconv.FormatUint(uint64(currentLeft), 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.StartTurnTimer(context.Background(), roomID, timeLeft, onTurnEnd, onTick, logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
func StopTurnTimer(roomID string) {
|
func StopTurnTimer(roomID string) {
|
||||||
mu.Lock()
|
timer.StopTurnTimer(roomID)
|
||||||
defer mu.Unlock()
|
}
|
||||||
if timer, exists := timers[roomID]; exists {
|
|
||||||
timer.ticker.Stop()
|
|
||||||
close(timer.done)
|
|
||||||
delete(timers, roomID)
|
|
||||||
}
|
|
||||||
}
|
|
245
llmapi/main.go
245
llmapi/main.go
@@ -14,19 +14,21 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
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)
|
||||||
|
mapMutex = &sync.RWMutex{}
|
||||||
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
|
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
|
||||||
MimePrompt = `we are playing alias;\nyou are a mime (player who gives a clue of one noun word and number of cards you expect them to open) of the %s team (people who would guess by your clue want open the %s cards);\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the game info in json:\n%s`
|
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that you can choose from:\n%v`
|
||||||
GuesserPrompt = `we are playing alias;\nyou are to guess words of the %s team (you want open %s cards) by given clue and a number of meant guesses;\nplease return your guesses and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guesses\": [\"word1\", \"word2\", ...],\n\"could_be\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the cards (and other info), you need to choose revealed==false words:\n%s`
|
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.`
|
||||||
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that left:\n%v`
|
GuesserSimplePromptRU = `мы играем в alias;\n тебе дана подсказка (clue): \"%s\";\nпожалуйста, верни свою догадку (guess), а также слова, что тоже подходят к подсказке, но ты меньше в них уверен, в формате json; пример:\n{\n\"guess\": \"отгадка\",\n\"could_be\": [\"слово1\", \"слово2\", ...]\n}\nвот список слов из которых нужно выбрать:\n%v`
|
||||||
MimeSimplePrompt = `we are playing alias;\nyou are to give one word clue and a number of words you mean your team to open; your team words: %v;\nhere are the words of opposite team you want to avoid: %v;\nand here is a black word that is critical not to pick: %s;\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;`
|
MimeSimplePromptRU = `мы играем в alias;\nтебе нужно дать подсказку одним словом и число слов, что ты подразумевал этой подсказкой; слова твоей комманды: %v;\nслова противоположной комманды, что ты хочешь избежать: %v;\nи вот ЧЕРНОЕ СЛОВО, открыв которое твоя комманда проиграет игру: %s;\nпожалуйста, верни подсказку (одним словом) и количество слов, что ты подразумеваешь в формате json; пример:\n{\n\"clue\": \"подсказка\",\n\"number\": \"число-от-0-до-9-as-string\",\n\"words_I_mean_my_team_to_open\": [\"слово1\", \"слово2\", ...]\n}\nпожалуйста верни только json.`
|
||||||
)
|
)
|
||||||
|
|
||||||
func convertToSliceOfStrings(value any) ([]string, error) {
|
func convertToSliceOfStrings(value any) ([]string, error) {
|
||||||
@@ -50,23 +52,6 @@ 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)
|
color, exists := room.FindColor(word)
|
||||||
@@ -77,8 +62,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
|||||||
return fmt.Errorf("fn: checkGuess; %s does not exists", word)
|
return fmt.Errorf("fn: checkGuess; %s does not exists", word)
|
||||||
}
|
}
|
||||||
room.RevealSpecificWord(word)
|
room.RevealSpecificWord(word)
|
||||||
|
if err := repo.WordCardReveal(context.Background(), word, room.ID); err != nil {
|
||||||
|
b.log.Error("failed to reveal word in db", "word", word, "color",
|
||||||
|
color, "exists", exists, "limit", room.ThisTurnLimit,
|
||||||
|
"opened", room.OpenedThisTurn)
|
||||||
|
return err
|
||||||
|
}
|
||||||
room.UpdateCounter()
|
room.UpdateCounter()
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: room.ID,
|
||||||
Actor: b.BotName,
|
Actor: b.BotName,
|
||||||
ActorColor: b.Team,
|
ActorColor: b.Team,
|
||||||
WordColor: string(color),
|
WordColor: string(color),
|
||||||
@@ -98,6 +90,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
|||||||
room.MimeDone = false
|
room.MimeDone = false
|
||||||
room.OpenedThisTurn = 0
|
room.OpenedThisTurn = 0
|
||||||
room.ThisTurnLimit = 0
|
room.ThisTurnLimit = 0
|
||||||
|
b.StopTurnTimer()
|
||||||
}
|
}
|
||||||
switch string(color) {
|
switch string(color) {
|
||||||
case string(models.WordColorBlack):
|
case string(models.WordColorBlack):
|
||||||
@@ -108,18 +101,22 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
|||||||
room.OpenedThisTurn = 0
|
room.OpenedThisTurn = 0
|
||||||
room.ThisTurnLimit = 0
|
room.ThisTurnLimit = 0
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: room.ID,
|
||||||
Actor: b.BotName,
|
Actor: b.BotName,
|
||||||
ActorColor: string(b.Team),
|
ActorColor: string(b.Team),
|
||||||
WordColor: models.WordColorBlack,
|
WordColor: models.WordColorBlack,
|
||||||
Action: models.ActionTypeGameOver,
|
Action: models.ActionTypeGameOver,
|
||||||
}
|
}
|
||||||
room.ActionHistory = append(room.ActionHistory, action)
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
b.StopTurnTimer()
|
||||||
|
updateStatsOnGameOver(context.Background(), room)
|
||||||
case string(models.WordColorWhite), string(oppositeColor):
|
case string(models.WordColorWhite), string(oppositeColor):
|
||||||
// end turn
|
// end turn
|
||||||
room.TeamTurn = oppositeColor
|
room.TeamTurn = oppositeColor
|
||||||
room.MimeDone = false
|
room.MimeDone = false
|
||||||
room.OpenedThisTurn = 0
|
room.OpenedThisTurn = 0
|
||||||
room.ThisTurnLimit = 0
|
room.ThisTurnLimit = 0
|
||||||
|
b.StopTurnTimer()
|
||||||
}
|
}
|
||||||
// check if no cards left => game over
|
// check if no cards left => game over
|
||||||
if room.BlueCounter == 0 {
|
if room.BlueCounter == 0 {
|
||||||
@@ -130,12 +127,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
|||||||
room.OpenedThisTurn = 0
|
room.OpenedThisTurn = 0
|
||||||
room.ThisTurnLimit = 0
|
room.ThisTurnLimit = 0
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: room.ID,
|
||||||
Actor: b.BotName,
|
Actor: b.BotName,
|
||||||
ActorColor: string(b.Team),
|
ActorColor: string(b.Team),
|
||||||
WordColor: models.WordColorBlack,
|
WordColor: models.WordColorBlack,
|
||||||
Action: models.ActionTypeGameOver,
|
Action: models.ActionTypeGameOver,
|
||||||
}
|
}
|
||||||
room.ActionHistory = append(room.ActionHistory, action)
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
b.StopTurnTimer()
|
||||||
|
updateStatsOnGameOver(context.Background(), room)
|
||||||
}
|
}
|
||||||
if room.RedCounter == 0 {
|
if room.RedCounter == 0 {
|
||||||
// red won
|
// red won
|
||||||
@@ -145,12 +145,15 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
|||||||
room.OpenedThisTurn = 0
|
room.OpenedThisTurn = 0
|
||||||
room.ThisTurnLimit = 0
|
room.ThisTurnLimit = 0
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: room.ID,
|
||||||
Actor: b.BotName,
|
Actor: b.BotName,
|
||||||
ActorColor: string(b.Team),
|
ActorColor: string(b.Team),
|
||||||
WordColor: models.WordColorBlack,
|
WordColor: models.WordColorBlack,
|
||||||
Action: models.ActionTypeGameOver,
|
Action: models.ActionTypeGameOver,
|
||||||
}
|
}
|
||||||
room.ActionHistory = append(room.ActionHistory, action)
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
b.StopTurnTimer()
|
||||||
|
updateStatsOnGameOver(context.Background(), room)
|
||||||
}
|
}
|
||||||
ctx, tx, err := repo.InitTx(context.Background())
|
ctx, tx, err := repo.InitTx(context.Background())
|
||||||
// nolint: errcheck
|
// nolint: errcheck
|
||||||
@@ -164,6 +167,7 @@ func (b *Bot) checkGuess(word string, room *models.Room) error {
|
|||||||
b.log.Error("failed to create action", "error", err, "action", action)
|
b.log.Error("failed to create action", "error", err, "action", action)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := repo.RoomUpdate(ctx, room); err != nil {
|
if err := repo.RoomUpdate(ctx, room); err != nil {
|
||||||
// nolint: errcheck
|
// nolint: errcheck
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -184,13 +188,34 @@ 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
|
if room.BotFailed {
|
||||||
|
if err := repo.RoomUnSetBotFailed(context.Background(), room.ID); err != nil {
|
||||||
|
b.log.Error("failed to unset bot failed bool", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eventName := models.NotifyBacklogPrefix + room.ID
|
||||||
|
eventName := models.NotifyRoomUpdatePrefix + room.ID
|
||||||
eventPayload := ""
|
eventPayload := ""
|
||||||
defer func() { // save room
|
defer func() { // save room
|
||||||
|
// just incase, get the room once more
|
||||||
|
// room, err = repo.RoomGetExtended(context.Background(), b.RoomID)
|
||||||
|
// if err != nil {
|
||||||
|
// b.log.Error("bot loop", "error", err)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
if err := saveRoom(room); err != nil {
|
if err := saveRoom(room); err != nil {
|
||||||
b.log.Error("failed to save room", "error", err)
|
b.log.Error("failed to save room", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if botName := room.WhichBotToMove(); botName != "" {
|
||||||
|
b.log.Debug("notifying bot", "name", botName)
|
||||||
|
mapMutex.RLock()
|
||||||
|
if sigChan, ok := SignalChanMap[botName]; ok {
|
||||||
|
sigChan <- true
|
||||||
|
}
|
||||||
|
mapMutex.RUnlock()
|
||||||
|
b.log.Debug("after sending the signal", "name", botName)
|
||||||
|
}
|
||||||
broker.Notifier.Notifier <- broker.NotificationEvent{
|
broker.Notifier.Notifier <- broker.NotificationEvent{
|
||||||
EventName: eventName,
|
EventName: eventName,
|
||||||
Payload: eventPayload,
|
Payload: eventPayload,
|
||||||
@@ -202,13 +227,30 @@ func (b *Bot) BotMove() {
|
|||||||
// call llm
|
// call llm
|
||||||
llmResp, err := b.CallLLM(prompt)
|
llmResp, err := b.CallLLM(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
room.LogJournal = append(room.LogJournal, b.BotName+" send call got error: "+err.Error())
|
lj := models.Journal{
|
||||||
|
Entry: fmt.Sprintf("bot '%s' exceeded attempts to call llm;", b.BotName),
|
||||||
|
Username: b.BotName,
|
||||||
|
RoomID: b.RoomID,
|
||||||
|
}
|
||||||
|
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||||
|
b.log.Warn("failed to write to journal", "entry", lj)
|
||||||
|
}
|
||||||
b.log.Error("bot loop", "error", err)
|
b.log.Error("bot loop", "error", err)
|
||||||
|
if err := repo.RoomSetBotFailed(context.Background(), room.ID); err != nil {
|
||||||
|
b.log.Error("failed to set bot failed bool", "error", err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tempMap, err := b.LLMParser.ParseBytes(llmResp)
|
tempMap, err := b.LLMParser.ParseBytes(llmResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
room.LogJournal = append(room.LogJournal, b.BotName+" parse resp got error: "+err.Error())
|
lj := models.Journal{
|
||||||
|
Entry: fmt.Sprintf("bot '%s' parsing resp failed;", b.BotName),
|
||||||
|
Username: b.BotName,
|
||||||
|
RoomID: b.RoomID,
|
||||||
|
}
|
||||||
|
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||||
|
b.log.Warn("failed to write to journal", "entry", lj)
|
||||||
|
}
|
||||||
b.log.Error("bot loop", "error", err, "resp", string(llmResp))
|
b.log.Error("bot loop", "error", err, "resp", string(llmResp))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,6 +259,22 @@ func (b *Bot) BotMove() {
|
|||||||
mimeResp := MimeResp{}
|
mimeResp := MimeResp{}
|
||||||
b.log.Info("mime resp log", "mimeResp", tempMap)
|
b.log.Info("mime resp log", "mimeResp", tempMap)
|
||||||
mimeResp.Clue = strings.ToLower(tempMap["clue"].(string))
|
mimeResp.Clue = strings.ToLower(tempMap["clue"].(string))
|
||||||
|
for _, card := range room.Cards {
|
||||||
|
if strings.ToLower(card.Word) == mimeResp.Clue {
|
||||||
|
b.log.Warn("bot-mime clue is one of the words on the board; retrying", "clue", mimeResp.Clue, "bot", b.BotName)
|
||||||
|
entry := fmt.Sprintf("bot-mime '%s' gave a clue '%s' which is one of the words on the board. retrying.", b.BotName, mimeResp.Clue)
|
||||||
|
lj := models.Journal{
|
||||||
|
Entry: entry,
|
||||||
|
Username: b.BotName,
|
||||||
|
RoomID: room.ID,
|
||||||
|
}
|
||||||
|
room.LogJournal = append(room.LogJournal, lj)
|
||||||
|
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||||
|
b.log.Warn("failed to write to journal", "entry", lj)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
var ok bool
|
var ok bool
|
||||||
mimeResp.Number, ok = tempMap["number"].(string)
|
mimeResp.Number, ok = tempMap["number"].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -224,6 +282,7 @@ func (b *Bot) BotMove() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
action := models.Action{
|
action := models.Action{
|
||||||
|
RoomID: room.ID,
|
||||||
Actor: b.BotName,
|
Actor: b.BotName,
|
||||||
ActorColor: b.Team,
|
ActorColor: b.Team,
|
||||||
WordColor: b.Team,
|
WordColor: b.Team,
|
||||||
@@ -233,13 +292,22 @@ 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)
|
||||||
@@ -249,6 +317,7 @@ func (b *Bot) BotMove() {
|
|||||||
b.log.Error("failed to create action", "error", err)
|
b.log.Error("failed to create action", "error", err)
|
||||||
return
|
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
|
||||||
@@ -259,37 +328,56 @@ func (b *Bot) BotMove() {
|
|||||||
b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName)
|
b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName)
|
||||||
}
|
}
|
||||||
if err := b.checkGuess(guess, room); err != nil {
|
if err := b.checkGuess(guess, room); err != nil {
|
||||||
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
|
b.log.Error("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
|
||||||
msg := fmt.Sprintf("failed to check guess; mimeResp: %v; bot_name: %s; guess: %s; error: %v", tempMap, b.BotName, guess, err)
|
entry := fmt.Sprintf("failed to check guess; mimeResp: %v; guess: %s; error: %v", tempMap, guess, err)
|
||||||
room.LogJournal = append(room.LogJournal, msg)
|
lj := models.Journal{
|
||||||
|
Entry: entry,
|
||||||
|
Username: b.BotName,
|
||||||
|
RoomID: room.ID,
|
||||||
|
}
|
||||||
|
room.LogJournal = append(room.LogJournal, lj)
|
||||||
|
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||||
|
b.log.Warn("failed to write to journal", "entry", lj)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
b.log.Info("guesser resp log", "guesserResp", tempMap)
|
b.log.Info("guesser resp log", "guesserResp", tempMap)
|
||||||
couldBe, err := convertToSliceOfStrings(tempMap["could_be"])
|
couldBe, err := convertToSliceOfStrings(tempMap["could_be"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName)
|
b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName)
|
||||||
}
|
}
|
||||||
room.LogJournal = append(room.LogJournal, fmt.Sprintf("%s also considered this: %v", b.BotName, couldBe))
|
entry := fmt.Sprintf("%s guessed: %s; also considered this: %v", b.BotName, guess, couldBe)
|
||||||
eventName = models.NotifyRoomUpdatePrefix + room.ID
|
lj := models.Journal{
|
||||||
eventPayload = ""
|
Entry: entry,
|
||||||
// TODO: needs to decide if it wants to open the next cardword or end turn
|
Username: b.BotName,
|
||||||
// or end turn on limit
|
RoomID: room.ID,
|
||||||
|
}
|
||||||
|
room.LogJournal = append(room.LogJournal, lj)
|
||||||
|
if err := repo.JournalCreate(context.Background(), &lj); err != nil {
|
||||||
|
b.log.Warn("failed to write to journal", "entry", lj)
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
|
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if botName := room.WhichBotToMove(); botName != "" {
|
|
||||||
b.log.Debug("notifying bot", "name", botName)
|
|
||||||
SignalChanMap[botName] <- true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartBot
|
// StartBot
|
||||||
func (b *Bot) StartBot() {
|
func (b *Bot) StartBot() {
|
||||||
|
mapMutex.Lock()
|
||||||
|
signalChan, sOk := SignalChanMap[b.BotName]
|
||||||
|
doneChan, dOk := DoneChanMap[b.BotName]
|
||||||
|
mapMutex.Unlock()
|
||||||
|
|
||||||
|
if !sOk || !dOk {
|
||||||
|
b.log.Error("bot channels not found in map", "bot-name", b.BotName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-SignalChanMap[b.BotName]:
|
case <-signalChan:
|
||||||
b.BotMove()
|
b.BotMove()
|
||||||
case <-DoneChanMap[b.BotName]:
|
case <-doneChan:
|
||||||
b.log.Debug("got done signal", "bot-name", b.BotName)
|
b.log.Debug("got done signal", "bot-name", b.BotName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -297,24 +385,51 @@ func (b *Bot) StartBot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RemoveBot(botName string, room *models.Room) error {
|
func RemoveBot(botName string, room *models.Room) error {
|
||||||
|
mapMutex.Lock()
|
||||||
// channels
|
// channels
|
||||||
DoneChanMap[botName] <- true
|
if doneChan, ok := DoneChanMap[botName]; ok {
|
||||||
close(DoneChanMap[botName])
|
doneChan <- true
|
||||||
close(SignalChanMap[botName])
|
close(doneChan)
|
||||||
|
}
|
||||||
|
if signalChan, ok := SignalChanMap[botName]; ok {
|
||||||
|
close(signalChan)
|
||||||
|
}
|
||||||
// maps
|
// maps
|
||||||
delete(room.BotMap, botName)
|
|
||||||
delete(DoneChanMap, botName)
|
delete(DoneChanMap, botName)
|
||||||
delete(SignalChanMap, botName)
|
delete(SignalChanMap, botName)
|
||||||
|
mapMutex.Unlock()
|
||||||
|
|
||||||
|
delete(room.BotMap, botName)
|
||||||
// remove role from room
|
// remove role from room
|
||||||
room.RemovePlayer(botName)
|
room.RemovePlayer(botName)
|
||||||
slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
|
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 {
|
||||||
|
mapMutex.Lock()
|
||||||
|
// channels
|
||||||
|
dc, ok := DoneChanMap[botName]
|
||||||
|
if ok {
|
||||||
|
dc <- true
|
||||||
|
close(DoneChanMap[botName])
|
||||||
|
}
|
||||||
|
sc, ok := SignalChanMap[botName]
|
||||||
|
if ok {
|
||||||
|
close(sc)
|
||||||
|
}
|
||||||
|
// maps
|
||||||
|
delete(DoneChanMap, botName)
|
||||||
|
delete(SignalChanMap, botName)
|
||||||
|
mapMutex.Unlock()
|
||||||
|
// remove role from room
|
||||||
|
return repo.PlayerDelete(context.Background(), botName)
|
||||||
|
}
|
||||||
|
|
||||||
// EndBot
|
// EndBot
|
||||||
|
|
||||||
func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) {
|
func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) {
|
||||||
@@ -387,9 +502,13 @@ func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
bot.log.Debug("before adding to ch map", "name", bot.BotName)
|
||||||
// buffered channel to send to it in the same goroutine
|
// buffered channel to send to it in the same goroutine
|
||||||
|
mapMutex.Lock()
|
||||||
SignalChanMap[bot.BotName] = make(chan bool, 1)
|
SignalChanMap[bot.BotName] = make(chan bool, 1)
|
||||||
DoneChanMap[bot.BotName] = make(chan bool, 1)
|
DoneChanMap[bot.BotName] = make(chan bool, 1)
|
||||||
|
mapMutex.Unlock()
|
||||||
|
bot.log.Debug("after adding to ch map", "name", bot.BotName)
|
||||||
go bot.StartBot() // run bot routine
|
go bot.StartBot() // run bot routine
|
||||||
return bot, nil
|
return bot, nil
|
||||||
}
|
}
|
||||||
@@ -434,6 +553,9 @@ func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
|
|||||||
}
|
}
|
||||||
words[i] = card.Word
|
words[i] = card.Word
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(room.Settings.Language, "ru") {
|
||||||
|
return fmt.Sprintf(MimeSimplePromptRU, clueAction.Word, words)
|
||||||
|
}
|
||||||
return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
|
return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,34 +584,20 @@ func (b *Bot) BuildSimpleMimePrompt(room *models.Room) string {
|
|||||||
theirwords = append(theirwords, card.Word)
|
theirwords = append(theirwords, card.Word)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord, room.BlueCounter, room.RedCounter)
|
if strings.EqualFold(room.Settings.Language, "ru") {
|
||||||
|
return fmt.Sprintf(MimeSimplePromptRU, ourwords, theirwords, blackWord)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) BuildPrompt(room *models.Room) string {
|
func (b *Bot) BuildPrompt(room *models.Room) string {
|
||||||
if b.Role == "" {
|
if b.Role == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// toText := make(map[string]any)
|
|
||||||
// toText["backlog"] = room.ActionHistory
|
|
||||||
// // mime sees all colors;
|
|
||||||
// // guesser sees only revealed ones
|
|
||||||
// if b.Role == models.UserRoleMime {
|
|
||||||
// toText["cards"] = room.Cards
|
|
||||||
// }
|
|
||||||
// data, err := json.Marshal(toText)
|
|
||||||
// if err != nil {
|
|
||||||
// b.log.Error("failed to marshal", "error", err)
|
|
||||||
// return ""
|
|
||||||
// }
|
|
||||||
// Escape the JSON string for inclusion in another JSON field
|
|
||||||
// escapedData := strings.ReplaceAll(string(data), `"`, `\"`)
|
|
||||||
if b.Role == models.UserRoleMime {
|
if b.Role == models.UserRoleMime {
|
||||||
// return fmt.Sprintf(MimeSimplePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
|
||||||
// return fmt.Sprintf(MimePrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
|
||||||
return b.BuildSimpleMimePrompt(room)
|
return b.BuildSimpleMimePrompt(room)
|
||||||
}
|
}
|
||||||
if b.Role == models.UserRoleGuesser {
|
if b.Role == models.UserRoleGuesser {
|
||||||
// return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
|
||||||
return b.BuildSimpleGuesserPrompt(room)
|
return b.BuildSimpleGuesserPrompt(room)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -507,7 +615,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
|||||||
req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payloadReader)
|
req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payloadReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if attempt == maxRetries-1 {
|
if attempt == maxRetries-1 {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("LLM call failed after %d retries on request creation: %w", maxRetries, err)
|
||||||
}
|
}
|
||||||
b.log.Error("failed to make new request; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
b.log.Error("failed to make new request; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||||
time.Sleep(time.Duration(baseDelay) * time.Second * time.Duration(attempt+1))
|
time.Sleep(time.Duration(baseDelay) * time.Second * time.Duration(attempt+1))
|
||||||
@@ -519,7 +627,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
|||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if attempt == maxRetries-1 {
|
if attempt == maxRetries-1 {
|
||||||
return nil, fmt.Errorf("http request failed: %w", err)
|
return nil, fmt.Errorf("LLM call failed after %d retries on client.Do: %w", maxRetries, err)
|
||||||
}
|
}
|
||||||
b.log.Error("http request failed; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
b.log.Error("http request failed; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||||
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
||||||
@@ -530,7 +638,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
|||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if attempt == maxRetries-1 {
|
if attempt == maxRetries-1 {
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
return nil, fmt.Errorf("LLM call failed after %d retries on reading body: %w", maxRetries, err)
|
||||||
}
|
}
|
||||||
b.log.Error("failed to read response body; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
b.log.Error("failed to read response body; will retry", "error", err, "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||||
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
delay := time.Duration(baseDelay*(attempt+1)) * time.Second
|
||||||
@@ -540,7 +648,7 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
|||||||
// Check status code
|
// Check status code
|
||||||
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
||||||
if attempt == maxRetries-1 {
|
if attempt == maxRetries-1 {
|
||||||
return nil, fmt.Errorf("after %d retries, still got status %d", maxRetries, resp.StatusCode)
|
return nil, fmt.Errorf("LLM call failed after %d retries, got status %d", maxRetries, resp.StatusCode)
|
||||||
}
|
}
|
||||||
b.log.Warn("retriable status code; will retry", "code", resp.StatusCode, "attempt", attempt)
|
b.log.Warn("retriable status code; will retry", "code", resp.StatusCode, "attempt", attempt)
|
||||||
delay := time.Duration((baseDelay * (1 << attempt))) * time.Second
|
delay := time.Duration((baseDelay * (1 << attempt))) * time.Second
|
||||||
@@ -555,6 +663,5 @@ func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
|||||||
b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
// This line should not be reached because each error path returns in the loop.
|
|
||||||
return nil, errors.New("unknown error in retry loop")
|
return nil, errors.New("unknown error in retry loop")
|
||||||
}
|
}
|
||||||
|
70
llmapi/stats.go
Normal file
70
llmapi/stats.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package llmapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var log *slog.Logger
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
log = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
AddSource: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateStatsOnGameOver updates stats for all players in a room when a game ends.
|
||||||
|
func updateStatsOnGameOver(ctx context.Context, room *models.Room) {
|
||||||
|
// Get all players in the room
|
||||||
|
players, err := repo.PlayerListByRoom(ctx, room.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to list players by room for stats update", "room_id", room.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, player := range players {
|
||||||
|
if player.IsBot {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err := repo.GetPlayerStats(ctx, player.Username)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to get player stats for game over update", "username", player.Username, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.GamesPlayed++
|
||||||
|
if player.Team == room.TeamWon {
|
||||||
|
stats.GamesWon++
|
||||||
|
} else {
|
||||||
|
stats.GamesLost++
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.Role == models.UserRoleMime {
|
||||||
|
stats.PlayedAsMime++
|
||||||
|
if stats.PlayedAsMime > 0 {
|
||||||
|
gamesWonAsMime := stats.MimeWinrate * float32(stats.PlayedAsMime-1)
|
||||||
|
if player.Team == room.TeamWon {
|
||||||
|
gamesWonAsMime++
|
||||||
|
}
|
||||||
|
stats.MimeWinrate = gamesWonAsMime / float32(stats.PlayedAsMime)
|
||||||
|
}
|
||||||
|
} else if player.Role == models.UserRoleGuesser {
|
||||||
|
stats.PlayedAsGuesser++
|
||||||
|
if stats.PlayedAsGuesser > 0 {
|
||||||
|
gamesWonAsGuesser := stats.GuesserWinrate * float32(stats.PlayedAsGuesser-1)
|
||||||
|
if player.Team == room.TeamWon {
|
||||||
|
gamesWonAsGuesser++
|
||||||
|
}
|
||||||
|
stats.GuesserWinrate = gamesWonAsGuesser / float32(stats.PlayedAsGuesser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.UpdatePlayerStats(ctx, stats); err != nil {
|
||||||
|
log.Error("failed to update player stats on game over", "username", player.Username, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
llmapi/timer.go
Normal file
49
llmapi/timer.go
Normal 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)
|
||||||
|
}
|
75
main.go
75
main.go
@@ -6,10 +6,14 @@ import (
|
|||||||
"gralias/crons"
|
"gralias/crons"
|
||||||
"gralias/handlers"
|
"gralias/handlers"
|
||||||
"gralias/repos"
|
"gralias/repos"
|
||||||
|
"gralias/telemetry"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
_ "net/http/pprof"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -20,20 +24,58 @@ func init() {
|
|||||||
cfg = config.LoadConfigOrDefault("")
|
cfg = config.LoadConfigOrDefault("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GzipFileServer serves pre-compressed .gz files if available
|
||||||
|
func GzipFileServer(root http.FileSystem) http.Handler {
|
||||||
|
fs := http.FileServer(root)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if client accepts gzip
|
||||||
|
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
// Check for .gz version of the file
|
||||||
|
gzPath := r.URL.Path + ".gz"
|
||||||
|
if file, err := root.Open(gzPath); err == nil {
|
||||||
|
file.Close()
|
||||||
|
// Set headers for gzip
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
w.Header().Set("Content-Type", getContentType(r.URL.Path))
|
||||||
|
r.URL.Path = gzPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set correct Content-Type
|
||||||
|
func getContentType(path string) string {
|
||||||
|
switch filepath.Ext(path) {
|
||||||
|
case ".css":
|
||||||
|
return "text/css"
|
||||||
|
case ".js":
|
||||||
|
return "application/javascript"
|
||||||
|
default:
|
||||||
|
return "" // http.FileServer will detect it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ListenToRequests(port string) *http.Server {
|
func ListenToRequests(port string) *http.Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
var handler http.Handler = mux
|
||||||
|
handler = handlers.LogRequests(handlers.GetSession(handler))
|
||||||
|
handler = telemetry.OtelMiddleware(handler)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: handlers.LogRequests(handlers.GetSession(mux)),
|
Handler: handler,
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
// ReadTimeout: time.Second * 5, // does this timeout conflict with sse connection?
|
// ReadTimeout: time.Second * 5, // does this timeout conflict with sse connection?
|
||||||
WriteTimeout: 0, // sse streaming
|
WriteTimeout: 0, // sse streaming
|
||||||
}
|
}
|
||||||
fs := http.FileServer(http.Dir("assets/"))
|
// fs := http.FileServer(http.Dir("assets/"))
|
||||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs))
|
fs := http.Dir("assets/")
|
||||||
|
mux.Handle("GET /assets/", http.StripPrefix("/assets/", GzipFileServer(fs)))
|
||||||
//
|
//
|
||||||
mux.HandleFunc("GET /ping", handlers.HandlePing)
|
mux.HandleFunc("GET /ping", handlers.HandlePing)
|
||||||
mux.HandleFunc("GET /", handlers.HandleHome)
|
mux.HandleFunc("GET /", handlers.HandleHome)
|
||||||
|
mux.HandleFunc("GET /stats", handlers.HandleStats)
|
||||||
mux.HandleFunc("POST /login", handlers.HandleFrontLogin)
|
mux.HandleFunc("POST /login", handlers.HandleFrontLogin)
|
||||||
|
mux.HandleFunc("GET /signout", handlers.HandleSignout)
|
||||||
mux.HandleFunc("POST /join-team", handlers.HandleJoinTeam)
|
mux.HandleFunc("POST /join-team", handlers.HandleJoinTeam)
|
||||||
mux.HandleFunc("GET /end-turn", handlers.HandleEndTurn)
|
mux.HandleFunc("GET /end-turn", handlers.HandleEndTurn)
|
||||||
mux.HandleFunc("POST /room-create", handlers.HandleCreateRoom)
|
mux.HandleFunc("POST /room-create", handlers.HandleCreateRoom)
|
||||||
@@ -50,6 +92,7 @@ func ListenToRequests(port string) *http.Server {
|
|||||||
mux.HandleFunc("GET /add-bot", handlers.HandleAddBot)
|
mux.HandleFunc("GET /add-bot", handlers.HandleAddBot)
|
||||||
mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot)
|
mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot)
|
||||||
mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard)
|
mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard)
|
||||||
|
mux.HandleFunc("GET /room", handlers.HandleGetRoom)
|
||||||
// special
|
// special
|
||||||
mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot)
|
mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot)
|
||||||
// sse
|
// sse
|
||||||
@@ -59,24 +102,44 @@ func ListenToRequests(port string) *http.Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
shutdown := telemetry.InitTracer()
|
||||||
|
defer shutdown()
|
||||||
// Setup graceful shutdown
|
// Setup graceful shutdown
|
||||||
stop := make(chan os.Signal, 1)
|
stop := make(chan os.Signal, 1)
|
||||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
repo := repos.NewRepoProvider(cfg.DBPath)
|
// repo := repos.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()
|
||||||
server := ListenToRequests(cfg.ServerConfig.Port)
|
server := ListenToRequests(cfg.ServerConfig.Port)
|
||||||
|
pprofPort := "6060"
|
||||||
|
pprofServer := &http.Server{
|
||||||
|
Addr: ":" + pprofPort,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("Pprof server listening", "addr", pprofPort)
|
||||||
|
if err := pprofServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("Pprof server failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
<-stop
|
<-stop
|
||||||
slog.Info("Shutting down server...")
|
slog.Info("Shutting down servers...")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := server.Shutdown(ctx); err != nil {
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
slog.Error("server shutdown failed", "error", err)
|
slog.Error("Main server shutdown failed", "error", err)
|
||||||
|
}
|
||||||
|
if err := pprofServer.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("Pprof server shutdown failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,6 +13,7 @@ CREATE TABLE rooms (
|
|||||||
mime_done BOOLEAN NOT NULL DEFAULT FALSE,
|
mime_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
is_running BOOLEAN NOT NULL DEFAULT FALSE,
|
is_running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
is_over BOOLEAN NOT NULL DEFAULT FALSE,
|
is_over BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
bot_failed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
team_won TEXT NOT NULL DEFAULT '',
|
team_won TEXT NOT NULL DEFAULT '',
|
||||||
room_link TEXT NOT NULL DEFAULT ''
|
room_link TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
@@ -21,6 +22,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,
|
||||||
@@ -76,3 +78,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,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
games_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
games_won INTEGER NOT NULL DEFAULT 0,
|
||||||
|
games_lost INTEGER NOT NULL DEFAULT 0,
|
||||||
|
opened_opposite_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
opened_white_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
opened_black_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mime_winrate REAL NOT NULL DEFAULT 0.0,
|
||||||
|
guesser_winrate REAL NOT NULL DEFAULT 0.0,
|
||||||
|
played_as_mime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
played_as_guesser INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
3
migrations/002_add_stats_elo.down.sql
Normal file
3
migrations/002_add_stats_elo.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TRIGGER IF EXISTS update_player_rating;
|
||||||
|
|
||||||
|
ALTER TABLE DROP COLUMN rating;
|
20
migrations/002_add_stats_elo.up.sql
Normal file
20
migrations/002_add_stats_elo.up.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE player_stats
|
||||||
|
ADD COLUMN rating REAL NOT NULL DEFAULT 1000.0;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_player_rating
|
||||||
|
AFTER UPDATE OF games_played, games_won ON player_stats
|
||||||
|
WHEN NEW.games_played = OLD.games_played + 1
|
||||||
|
BEGIN
|
||||||
|
UPDATE player_stats
|
||||||
|
SET rating = OLD.rating +
|
||||||
|
32.0 * (
|
||||||
|
CASE
|
||||||
|
WHEN NEW.games_won = OLD.games_won + 1
|
||||||
|
THEN 1.0 - 0.5 -- Win term: 0.5
|
||||||
|
ELSE 0.0 - 0.5 -- Loss term: -0.5
|
||||||
|
END
|
||||||
|
) +
|
||||||
|
0.05 * (1000.0 - OLD.rating)
|
||||||
|
WHERE id = OLD.id;
|
||||||
|
END;
|
||||||
|
|
@@ -106,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"`
|
||||||
@@ -130,6 +131,30 @@ type CardMark struct {
|
|||||||
Username string `db:"username"`
|
Username string `db:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Journal struct {
|
||||||
|
ID uint32 `db:"id"`
|
||||||
|
Username string `db:"username"`
|
||||||
|
RoomID string `db:"room_id"`
|
||||||
|
Entry string `db:"entry"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerStats struct {
|
||||||
|
ID uint32 `db:"id"`
|
||||||
|
Username string `db:"username"`
|
||||||
|
GamesPlayed int `db:"games_played"`
|
||||||
|
GamesWon int `db:"games_won"`
|
||||||
|
GamesLost int `db:"games_lost"`
|
||||||
|
OpenedOppositeWords int `db:"opened_opposite_words"`
|
||||||
|
OpenedWhiteWords int `db:"opened_white_words"`
|
||||||
|
OpenedBlackWords int `db:"opened_black_words"`
|
||||||
|
MimeWinrate float32 `db:"mime_winrate"`
|
||||||
|
GuesserWinrate float32 `db:"guesser_winrate"`
|
||||||
|
PlayedAsMime int `db:"played_as_mime"`
|
||||||
|
PlayedAsGuesser int `db:"played_as_guesser"`
|
||||||
|
Rating float32 `db:"rating"`
|
||||||
|
}
|
||||||
|
|
||||||
type Room struct {
|
type Room struct {
|
||||||
ID string `json:"id" db:"id"`
|
ID string `json:"id" db:"id"`
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
@@ -151,8 +176,10 @@ type Room struct {
|
|||||||
BlueTeam Team `db:"-"`
|
BlueTeam Team `db:"-"`
|
||||||
Cards []WordCard `db:"-"`
|
Cards []WordCard `db:"-"`
|
||||||
BotMap map[string]BotPlayer `db:"-"`
|
BotMap map[string]BotPlayer `db:"-"`
|
||||||
LogJournal []string `db:"-"`
|
LogJournal []Journal `db:"-"`
|
||||||
Settings GameSettings `db:"-"`
|
Settings GameSettings `db:"-"`
|
||||||
|
//
|
||||||
|
BotFailed bool `db:"bot_failed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Room) FindColor(word string) (WordColor, bool) {
|
func (r *Room) FindColor(word string) (WordColor, bool) {
|
||||||
@@ -165,7 +192,7 @@ func (r *Room) FindColor(word string) (WordColor, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Room) ClearMarks() {
|
func (r *Room) ClearMarks() {
|
||||||
for i, _ := range r.Cards {
|
for i := range r.Cards {
|
||||||
r.Cards[i].Marks = []CardMark{}
|
r.Cards[i].Marks = []CardMark{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,7 +313,7 @@ func getGuesser(m map[string]BotPlayer, team UserTeam) string {
|
|||||||
func (r *Room) WhichBotToMove() string {
|
func (r *Room) WhichBotToMove() string {
|
||||||
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
|
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
|
||||||
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning,
|
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning,
|
||||||
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime)
|
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime, "card-limit:", r.ThisTurnLimit, "opened:", r.OpenedThisTurn)
|
||||||
if !r.IsRunning {
|
if !r.IsRunning {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -441,7 +468,6 @@ type FullInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *FullInfo) ExitRoom() *Room {
|
func (f *FullInfo) ExitRoom() *Room {
|
||||||
// f.Room.PlayerList = utils.RemoveFromSlice(f.State.Username, f.Room.PlayerList)
|
|
||||||
f.Room.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers)
|
f.Room.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers)
|
||||||
f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers)
|
f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers)
|
||||||
if f.Room.RedTeam.Mime == f.State.Username {
|
if f.Room.RedTeam.Mime == f.State.Username {
|
||||||
@@ -450,8 +476,10 @@ func (f *FullInfo) ExitRoom() *Room {
|
|||||||
if f.Room.BlueTeam.Mime == f.State.Username {
|
if f.Room.BlueTeam.Mime == f.State.Username {
|
||||||
f.Room.BlueTeam.Mime = ""
|
f.Room.BlueTeam.Mime = ""
|
||||||
}
|
}
|
||||||
// f.State.ExitRoom()
|
f.State.RoomID = nil
|
||||||
resp := f.Room
|
resp := f.Room
|
||||||
f.Room = nil
|
f.Room = nil
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =======
|
||||||
|
@@ -12,6 +12,7 @@ type CardMarksRepo interface {
|
|||||||
CardMarksAdd(ctx context.Context, cm *models.CardMark) error
|
CardMarksAdd(ctx context.Context, cm *models.CardMark) error
|
||||||
CardMarksRemove(ctx context.Context, cardID uint32, username string) error
|
CardMarksRemove(ctx context.Context, cardID uint32, username string) error
|
||||||
CardMarksByRoomID(ctx context.Context, roomID string) ([]models.CardMark, 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) {
|
func (r *RepoProvider) CardMarksByCardID(ctx context.Context, cardID uint32) ([]models.CardMark, error) {
|
||||||
@@ -36,3 +37,8 @@ func (r *RepoProvider) CardMarksByRoomID(ctx context.Context, roomID string) ([]
|
|||||||
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)
|
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
|
return cardMarks, err
|
||||||
}
|
}
|
||||||
|
func (r *RepoProvider) CardMarksRemoveByRoomID(ctx context.Context, roomID string) error {
|
||||||
|
db := getDB(ctx, r.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "DELETE FROM card_marks WHERE card_id IN (select id from word_cards where room_id = ?);", roomID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
35
repos/journal.go
Normal file
35
repos/journal.go
Normal 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
|
||||||
|
}
|
@@ -2,6 +2,7 @@ package repos
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"gralias/config"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -19,7 +20,10 @@ type AllRepos interface {
|
|||||||
WordCardsRepo
|
WordCardsRepo
|
||||||
SettingsRepo
|
SettingsRepo
|
||||||
CardMarksRepo
|
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 {
|
||||||
@@ -28,25 +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)
|
||||||
}
|
}
|
||||||
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
stmts := []string{
|
||||||
if err != nil {
|
"PRAGMA foreign_keys = ON;",
|
||||||
slog.Error("Unable to enable foreign keys", "error", err)
|
"PRAGMA busy_timeout=200;",
|
||||||
os.Exit(1)
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,24 +0,0 @@
|
|||||||
package repos
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewRepoProvider(t *testing.T) {
|
|
||||||
// Create a temporary SQLite database file for testing
|
|
||||||
tmpDBFile := "./test_gralias.db"
|
|
||||||
defer os.Remove(tmpDBFile) // Clean up the temporary file after the test
|
|
||||||
|
|
||||||
// Initialize a new RepoProvider
|
|
||||||
repoProvider := NewRepoProvider(tmpDBFile)
|
|
||||||
|
|
||||||
// Assert that the DB connection is not nil
|
|
||||||
assert.NotNil(t, repoProvider.DB, "DB connection should not be nil")
|
|
||||||
|
|
||||||
// Close the database connection
|
|
||||||
err := repoProvider.DB.Close()
|
|
||||||
assert.NoError(t, err, "Error closing database connection")
|
|
||||||
}
|
|
50
repos/player_stats.go
Normal file
50
repos/player_stats.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlayerStatsRepo interface {
|
||||||
|
GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error)
|
||||||
|
GetAllPlayerStats(ctx context.Context) ([]*models.PlayerStats, error)
|
||||||
|
UpdatePlayerStats(ctx context.Context, stats *models.PlayerStats) error
|
||||||
|
CreatePlayerStats(ctx context.Context, username string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) GetPlayerStats(ctx context.Context, username string) (*models.PlayerStats, error) {
|
||||||
|
stats := &models.PlayerStats{}
|
||||||
|
err := sqlx.GetContext(ctx, p.DB, stats, "SELECT * FROM player_stats WHERE username = ?", username)
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) GetAllPlayerStats(ctx context.Context) ([]*models.PlayerStats, error) {
|
||||||
|
var stats []*models.PlayerStats
|
||||||
|
err := sqlx.SelectContext(ctx, p.DB, &stats, "SELECT * FROM player_stats ORDER BY games_won DESC")
|
||||||
|
return stats, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) UpdatePlayerStats(ctx context.Context, stats *models.PlayerStats) error {
|
||||||
|
_, err := p.DB.NamedExecContext(ctx, `UPDATE player_stats SET
|
||||||
|
games_played = :games_played,
|
||||||
|
games_won = :games_won,
|
||||||
|
games_lost = :games_lost,
|
||||||
|
opened_opposite_words = :opened_opposite_words,
|
||||||
|
opened_white_words = :opened_white_words,
|
||||||
|
opened_black_words = :opened_black_words,
|
||||||
|
mime_winrate = :mime_winrate,
|
||||||
|
guesser_winrate = :guesser_winrate,
|
||||||
|
played_as_mime = :played_as_mime,
|
||||||
|
played_as_guesser = :played_as_guesser,
|
||||||
|
rating = :rating
|
||||||
|
WHERE username = :username`, stats)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) CreatePlayerStats(ctx context.Context, username string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "INSERT INTO player_stats (username) VALUES (?)", username)
|
||||||
|
return err
|
||||||
|
}
|
@@ -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, roomID, username 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,9 +45,15 @@ 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
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !player.IsBot {
|
||||||
|
return p.CreatePlayerStats(ctx, player.Username)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error {
|
func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error {
|
||||||
@@ -55,9 +63,9 @@ func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player)
|
|||||||
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 +103,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)
|
||||||
|
@@ -6,25 +6,147 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
|
func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
|
||||||
db, err := sqlx.Connect("sqlite3", ":memory:")
|
db, err := sqlx.Connect("sqlite3", ":memory:")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
schema := `
|
// Load schema from migration files
|
||||||
CREATE TABLE IF NOT EXISTS players (
|
schema001 := `
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
-- migrations/001_initial_schema.up.sql
|
||||||
room_id TEXT,
|
|
||||||
username TEXT,
|
CREATE TABLE rooms (
|
||||||
team TEXT,
|
id TEXT PRIMARY KEY,
|
||||||
role TEXT,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
is_bot BOOLEAN
|
creator_name TEXT NOT NULL,
|
||||||
);
|
team_turn TEXT NOT NULL DEFAULT '',
|
||||||
`
|
this_turn_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
_, err = db.Exec(schema)
|
opened_this_turn INTEGER NOT NULL DEFAULT 0,
|
||||||
|
blue_counter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
red_counter INTEGER NOT NULL DEFAULT 0,
|
||||||
|
red_turn BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_over BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
team_won TEXT NOT NULL DEFAULT '',
|
||||||
|
room_link TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE players (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT, -- nullable
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL DEFAULT '',
|
||||||
|
team TEXT NOT NULL DEFAULT '', -- 'red' or 'blue'
|
||||||
|
role TEXT NOT NULL DEFAULT '', -- 'guesser' or 'mime'
|
||||||
|
is_bot BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE card_marks (
|
||||||
|
card_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES word_cards(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (card_id, username)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE actions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
actor_color TEXT NOT NULL DEFAULT '',
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL DEFAULT '',
|
||||||
|
word_color TEXT NOT NULL DEFAULT '',
|
||||||
|
number_associated TEXT NOT NULL DEFAULT '', -- for clues
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
room_pass TEXT NOT NULL DEFAULT '',
|
||||||
|
turn_time INTEGER NOT NULL DEFAULT 60, -- seconds
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE sessions(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
lifetime INTEGER NOT NULL DEFAULT 3600,
|
||||||
|
token_key TEXT NOT NULL DEFAULT '' UNIQUE, -- encoded value
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE journal(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
entry TEXT NOT NULL DEFAULT '',
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE player_stats (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
games_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
games_won INTEGER NOT NULL DEFAULT 0,
|
||||||
|
games_lost INTEGER NOT NULL DEFAULT 0,
|
||||||
|
opened_opposite_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
opened_white_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
opened_black_words INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mime_winrate REAL NOT NULL DEFAULT 0.0,
|
||||||
|
guesser_winrate REAL NOT NULL DEFAULT 0.0,
|
||||||
|
played_as_mime INTEGER NOT NULL DEFAULT 0,
|
||||||
|
played_as_guesser INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema001)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
schema002 := `
|
||||||
|
ALTER TABLE player_stats
|
||||||
|
ADD COLUMN rating REAL NOT NULL DEFAULT 1000.0;
|
||||||
|
|
||||||
|
CREATE TRIGGER update_player_rating
|
||||||
|
AFTER UPDATE OF games_played, games_won ON player_stats
|
||||||
|
WHEN NEW.games_played = OLD.games_played + 1
|
||||||
|
BEGIN
|
||||||
|
UPDATE player_stats
|
||||||
|
SET rating = OLD.rating +
|
||||||
|
32.0 * (
|
||||||
|
CASE
|
||||||
|
WHEN NEW.games_won = OLD.games_won + 1
|
||||||
|
THEN 1.0 - 0.5 -- Win term: 0.5
|
||||||
|
ELSE 0.0 - 0.5 -- Loss term: -0.5
|
||||||
|
END
|
||||||
|
) +
|
||||||
|
0.05 * (1000.0 - OLD.rating)
|
||||||
|
WHERE id = OLD.id;
|
||||||
|
END;
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema002)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
return db, func() {
|
return db, func() {
|
||||||
@@ -32,6 +154,39 @@ func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlayerStatsRatingUpdate(t *testing.T) {
|
||||||
|
db, teardown := setupPlayersTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
username := "test_player_rating"
|
||||||
|
_, err := db.Exec(`INSERT INTO players (username) VALUES (?)`, username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Exec(`INSERT INTO player_stats (username, games_played, games_won, rating) VALUES (?, 0, 0, 1000.0)`, username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Simulate a win
|
||||||
|
_, err = db.Exec(`UPDATE player_stats SET games_played = 1, games_won = 1 WHERE username = ?`, username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var ratingAfterWin float64
|
||||||
|
err = db.Get(&ratingAfterWin, `SELECT rating FROM player_stats WHERE username = ?`, username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// Expected: 1000 + 32 * (1 - 0.5) + 0.05 * (1000 - 1000) = 1000 + 16 = 1016
|
||||||
|
assert.InDelta(t, 1016.0, ratingAfterWin, 0.001)
|
||||||
|
|
||||||
|
// Simulate a loss
|
||||||
|
_, err = db.Exec(`UPDATE player_stats SET games_played = 2, games_won = 1 WHERE username = ?`, username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var ratingAfterLoss float64
|
||||||
|
err = db.Get(&ratingAfterLoss, `SELECT rating FROM player_stats WHERE username = ?`, username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// Expected: 1016 + 32 * (0 - 0.5) + 0.05 * (1000 - 1016) = 1016 - 16 + 0.05 * (-16) = 1000 - 0.8 = 999.2
|
||||||
|
assert.InDelta(t, 999.2, ratingAfterLoss, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func TestPlayersRepo_AddPlayer(t *testing.T) {
|
func TestPlayersRepo_AddPlayer(t *testing.T) {
|
||||||
db, teardown := setupPlayersTestDB(t)
|
db, teardown := setupPlayersTestDB(t)
|
||||||
defer teardown()
|
defer teardown()
|
||||||
@@ -98,11 +253,11 @@ 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
|
||||||
err = db.Get(&count, "SELECT COUNT(*) FROM players WHERE room_id = ? AND username = ?", player.RoomID, player.Username)
|
err = db.Get(&count, "SELECT COUNT(*) FROM players WHERE room_id = ? AND username = ?", player.RoomID, player.Username)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, 0, count)
|
assert.Equal(t, 0, count)
|
||||||
}
|
}
|
||||||
|
@@ -15,6 +15,20 @@ type RoomsRepo interface {
|
|||||||
RoomCreate(ctx context.Context, room *models.Room) error
|
RoomCreate(ctx context.Context, room *models.Room) error
|
||||||
RoomDeleteByID(ctx context.Context, id string) error
|
RoomDeleteByID(ctx context.Context, id string) error
|
||||||
RoomUpdate(ctx context.Context, room *models.Room) error
|
RoomUpdate(ctx context.Context, room *models.Room) error
|
||||||
|
RoomSetBotFailed(ctx context.Context, roomID string) error
|
||||||
|
RoomUnSetBotFailed(ctx context.Context, roomID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomSetBotFailed(ctx context.Context, roomID string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = true WHERE id = ?", roomID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomUnSetBotFailed(ctx context.Context, roomID string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "UPDATE rooms SET bot_failed = false WHERE id = ?", roomID)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {
|
func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {
|
||||||
@@ -131,5 +145,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
|
||||||
}
|
}
|
||||||
|
63
telemetry/telemetry.go
Normal file
63
telemetry/telemetry.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package telemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/exporters/jaeger"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newJaegerExporter creates a new Jaeger exporter.
|
||||||
|
func newJaegerExporter() (sdktrace.SpanExporter, error) {
|
||||||
|
return jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint("http://localhost:14268/api/traces")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTracerProvider creates a new tracer provider.
|
||||||
|
func NewTracerProvider(exp sdktrace.SpanExporter) *sdktrace.TracerProvider {
|
||||||
|
res := resource.NewWithAttributes(
|
||||||
|
semconv.SchemaURL,
|
||||||
|
semconv.ServiceName("gralias"),
|
||||||
|
semconv.ServiceVersion("v0.1.0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tracerProvider := sdktrace.NewTracerProvider(
|
||||||
|
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||||
|
sdktrace.WithResource(res),
|
||||||
|
sdktrace.WithBatcher(exp),
|
||||||
|
)
|
||||||
|
return tracerProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
// OtelMiddleware wraps the provided http.Handler with OpenTelemetry tracing.
|
||||||
|
func OtelMiddleware(handler http.Handler) http.Handler {
|
||||||
|
return otelhttp.NewHandler(handler, "http.server",
|
||||||
|
otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string {
|
||||||
|
return r.URL.Path
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitTracer() func() {
|
||||||
|
exp, err := newJaegerExporter()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create exporter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tp := NewTracerProvider(exp)
|
||||||
|
otel.SetTracerProvider(tp)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if err := tp.Shutdown(context.Background()); err != nil {
|
||||||
|
log.Printf("Error shutting down tracer provider: %v", err)
|
||||||
|
}
|
||||||
|
if err := exp.Shutdown(context.Background()); err != nil {
|
||||||
|
log.Printf("Error shutting down exporter: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
83
timer/timer.go
Normal file
83
timer/timer.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
41
todos.md
41
todos.md
@@ -21,6 +21,8 @@
|
|||||||
- redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
|
- redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
|
||||||
- better styles and fluff;
|
- better styles and fluff;
|
||||||
- common auth system between sites;
|
- common auth system between sites;
|
||||||
|
- signup vs login; +
|
||||||
|
- passwords (to room and to login); +
|
||||||
===
|
===
|
||||||
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
|
- show in backlog (and with that in prompt to llm) how many cards are left to open, also additional comment: if guess was right;
|
||||||
- gameover to backlog;
|
- gameover to backlog;
|
||||||
@@ -28,9 +30,12 @@
|
|||||||
- ended turn action to backlog;
|
- ended turn action to backlog;
|
||||||
===
|
===
|
||||||
- clear indication that model (llm) is thinking / answered;
|
- clear indication that model (llm) is thinking / answered;
|
||||||
- possibly turn markings into parts of names of users (first three letters?);
|
- possibly turn markings into parts of names of users (first three letters?); +
|
||||||
- at game creation list languages and support them at backend;
|
- at game creation list languages and support them at backend; +
|
||||||
- sql ping goroutine with reconnect on fail;
|
- sql ping goroutine with reconnect on fail; +
|
||||||
|
- player stats: played games, lost, won, rating elo, opened opposite words, opened white words, opened black words. +
|
||||||
|
- at the end of the game, all colors should be revealed;
|
||||||
|
- tracing;
|
||||||
|
|
||||||
#### sse points
|
#### sse points
|
||||||
- clue sse update;
|
- clue sse update;
|
||||||
@@ -61,16 +66,30 @@
|
|||||||
- there is a clue window for a mime before game started; +
|
- there is a clue window for a mime before game started; +
|
||||||
- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine) (did not reoccur so far);
|
- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine) (did not reoccur so far);
|
||||||
- invite link gets cutoff;
|
- invite link gets cutoff;
|
||||||
- when llm guesses the word it is not removed from a pool of words making it keep guessing it;
|
- when llm guesses the word it is not removed from a pool of words making it keep guessing it; +
|
||||||
- bot team does not loses their turn after white card (or limit);
|
- bot team does not loses their turn after white card (or limit); +
|
||||||
- name check does not work;
|
- name check does not work;
|
||||||
- game did not end when all blue cards were open;
|
- game did not end when all blue cards were open; +
|
||||||
- bot ends a turn after guessing one word only;
|
- bot ends a turn after guessing one word only; +
|
||||||
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue;
|
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue; +
|
||||||
-----------------
|
-----------------
|
||||||
- card marks; +
|
- card marks; +
|
||||||
- on server recover relaunch guess timer if needed;
|
- on server recover relaunch guess timer if needed;
|
||||||
- start new game: clear last clue; mimedone to false; unload old cards;
|
- start new game: clear last clue; mimedone to false; unload old cards; +
|
||||||
- backlog shows white word with opposite color;
|
- backlog shows white word with opposite color;
|
||||||
- bot actions are not recorder;
|
- bot actions are not recorded; +
|
||||||
- bot recieves opp-color clue because of it ^;
|
- bot recieves opp-color clue because of it ^; +
|
||||||
|
- old cards are still around; +
|
||||||
|
|
||||||
|
- bot mime makes a clue -> no update in the room for players; +
|
||||||
|
- red moves after bot gave (metal - 3) ended after two guesses (showed in logs, that opened 3. double click on the same card? or somewhere counter is not dropped?); the first guess moved counter to 2, in logs only two requests as expected; +
|
||||||
|
- bot mime gve a blue -> timer did not start; timer should be in third package, maybe in crons; +
|
||||||
|
- marks did not clear after end of the turn (feature?) +
|
||||||
|
|
||||||
|
- start new game satrted timer for a mime; (feature? in other cases mime has no timer);
|
||||||
|
- timer ended and went to 300;
|
||||||
|
- mime sees the clue input out of turn; (eh)
|
||||||
|
- there is a problem of two timers, they both could switch turn, but it is not easy to stop them from llmapi or handlers. +
|
||||||
|
- journal still does not work; +
|
||||||
|
- lose/win game; then exit room (while being the creator), then press to stats -> cannot find session in db, although cookie in place and session in db; +
|
||||||
|
- exit endpoints delets player from db; +
|
||||||
|
Reference in New Issue
Block a user