Compare commits
140 Commits
d18855cd49
...
master
Author | SHA1 | Date | |
---|---|---|---|
56845e6141 | |||
3e9a93fbb1 | |||
3af3657c7a | |||
0e2baa1a0f | |||
a4dc8f4bbb | |||
2a2bf4e23d | |||
705881f1ea | |||
6be365473c | |||
058d501774 | |||
c2d6812230 | |||
71a2d9d747 | |||
83215f5c14 | |||
6ca8afd13d | |||
8b81e2e2c4 | |||
c9196d3202 | |||
788c4efd9e | |||
66d7a633c8 | |||
9e058b04e0 | |||
873c35ab08 | |||
3fa0d608de | |||
d8338fe382 | |||
2a593739ae | |||
c82439d43a | |||
e02554b181 | |||
130ed3763b | |||
a438d5b665 | |||
8d159baad7 | |||
3e0d24f5f8 | |||
9973546aad | |||
b66f9c4c06 | |||
76bae3693a | |||
8bf719ae4c | |||
95a55a8213 | |||
86574bf69c | |||
e989590e74 | |||
70f83f1002 | |||
83d3a19d05 | |||
42348ff625 | |||
de889bb8d9 | |||
036def3819 | |||
e6177df5d8 | |||
a51668e321 | |||
87a0f870e8 | |||
288e74c95b | |||
42c1f461b0 | |||
643a9a035a | |||
e375d7c689 | |||
598d141818 | |||
849fcba974 | |||
5b666e042a | |||
41124c51fa | |||
f08aadc557 | |||
86063a4f7e | |||
72053281e2 | |||
c5f04d348f | |||
86b1ecf82e | |||
12fe92b261 | |||
661a320fb5 | |||
83513b6c6a | |||
840b85887b | |||
c085e0dca1 | |||
2fcc36be51 | |||
23cbde0f7a | |||
07c4d9295d | |||
f7ebee8fb0 | |||
66794b7895 | |||
1d51697c1a | |||
2db1c246a4 | |||
d4daa02155 | |||
30e322d9c6 | |||
fa25679624 | |||
bddaa912de | |||
4d45a886d2 | |||
60d62773b8 | |||
74b10b8395 | |||
6934b724ae | |||
09e2b2f4b6 | |||
7fab000d20 | |||
9ebce0a7d0 | |||
e8507463e0 | |||
6dcc3f0309 | |||
c155654d5f | |||
31f721cd43 | |||
6b750cd34b | |||
ad8f1aaee2 | |||
59cccbbe8e | |||
2342c56aed | |||
24f940f175 | |||
734088d96d | |||
0c94811632 | |||
f752d2a162 | |||
da8131a0a4 | |||
f2aee1469b | |||
cf5643227b | |||
becd3aca02 | |||
e654f6f456 | |||
321b79b258 | |||
35e215e26f | |||
416cc63ec0 | |||
2f4891473b | |||
6c9c86f02b | |||
45761446e5 | |||
2b4bf2ec29 | |||
e20118acea | |||
659c8dbbec | |||
8baf03595e | |||
3cc2ecb93d | |||
21948b23f4 | |||
b20f7ac6b7 | |||
3ade7310a7 | |||
86ef35160c | |||
7bd8e8af06 | |||
3eb54cffff | |||
5e92523dcd | |||
ca9b077070 | |||
5dbb80121d | |||
e335bf9dc8 | |||
06203ab39e | |||
0fbc106f9a | |||
8d85d0612c | |||
444f10ea7e | |||
b135356d3f | |||
5cf1f1199e | |||
8b68aee884 | |||
8b9c440ae5 | |||
78b48b8c71 | |||
1dcf013766 | |||
bd4e2431bf | |||
c8ce2a6727 | |||
8705f6a425 | |||
3aa0c15ff5 | |||
56c94c3987 | |||
322743e33d | |||
8b768f919b | |||
722da6d4fa | |||
990d8660b3 | |||
ad8982a112 | |||
985956b384 | |||
12f158b5a5 | |||
cd1100d4b1 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +1,6 @@
|
|||||||
.aider*
|
.aider*
|
||||||
golias
|
golias
|
||||||
|
gralias
|
||||||
store.json
|
store.json
|
||||||
|
config.toml
|
||||||
|
gralias.db
|
||||||
|
42
.golangci.yml
Normal file
42
.golangci.yml
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
version: "2"
|
||||||
|
run:
|
||||||
|
concurrency: 2
|
||||||
|
tests: false
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- bodyclose
|
||||||
|
- errcheck
|
||||||
|
- fatcontext
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- perfsprint
|
||||||
|
- prealloc
|
||||||
|
- unused
|
||||||
|
settings:
|
||||||
|
funlen:
|
||||||
|
lines: 80
|
||||||
|
statements: 50
|
||||||
|
lll:
|
||||||
|
line-length: 80
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
presets:
|
||||||
|
- comments
|
||||||
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
|
- std-error-handling
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
formatters:
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
17
Makefile
17
Makefile
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
run:
|
run:
|
||||||
go build
|
go build
|
||||||
./golias start
|
./gralias start
|
||||||
|
|
||||||
init:
|
init:
|
||||||
go mod init
|
go mod init
|
||||||
@ -26,10 +26,19 @@ gen:
|
|||||||
go generate ./...
|
go generate ./...
|
||||||
|
|
||||||
build-container:
|
build-container:
|
||||||
docker build -t golias:master .
|
docker build -t gralias:master .
|
||||||
|
|
||||||
stop-container:
|
stop-container:
|
||||||
docker rm -f golias 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=golias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:9000:9000 -d golias:master
|
docker run --name=gralias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:3000:3000 -d gralias:master
|
||||||
|
|
||||||
|
migrate-up:
|
||||||
|
migrate -database 'sqlite3://gralias.db' -path migrations up
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
migrate -database 'sqlite3://gralias.db' -path migrations down
|
||||||
|
|
||||||
|
install-migrate:
|
||||||
|
go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||||
|
2
assets/htmx.min.js
vendored
2
assets/htmx.min.js
vendored
File diff suppressed because one or more lines are too long
@ -6,350 +6,285 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
|
/** @type {import("../htmx").HtmxInternalApi} */
|
||||||
|
var api
|
||||||
|
|
||||||
/** @type {import("../htmx").HtmxInternalApi} */
|
htmx.defineExtension('sse', {
|
||||||
var api;
|
|
||||||
|
|
||||||
htmx.defineExtension("sse", {
|
/**
|
||||||
|
* Init saves the provided reference to the internal HTMX API.
|
||||||
|
*
|
||||||
|
* @param {import("../htmx").HtmxInternalApi} api
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
init: function(apiRef) {
|
||||||
|
// store a reference to the internal API.
|
||||||
|
api = apiRef
|
||||||
|
|
||||||
/**
|
// set a function in the public API for creating new EventSource objects
|
||||||
* Init saves the provided reference to the internal HTMX API.
|
if (htmx.createEventSource == undefined) {
|
||||||
*
|
htmx.createEventSource = createEventSource
|
||||||
* @param {import("../htmx").HtmxInternalApi} api
|
}
|
||||||
* @returns void
|
},
|
||||||
*/
|
|
||||||
init: function(apiRef) {
|
|
||||||
// store a reference to the internal API.
|
|
||||||
api = apiRef;
|
|
||||||
|
|
||||||
// set a function in the public API for creating new EventSource objects
|
getSelectors: function() {
|
||||||
if (htmx.createEventSource == undefined) {
|
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||||
htmx.createEventSource = createEventSource;
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* onEvent handles all events passed to this extension.
|
* onEvent handles all events passed to this extension.
|
||||||
*
|
*
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {Event} evt
|
* @param {Event} evt
|
||||||
* @returns void
|
* @returns void
|
||||||
*/
|
*/
|
||||||
onEvent: function(name, evt) {
|
onEvent: function(name, evt) {
|
||||||
|
var parent = evt.target || evt.detail.elt
|
||||||
|
switch (name) {
|
||||||
|
case 'htmx:beforeCleanupElement':
|
||||||
|
var internalData = api.getInternalData(parent)
|
||||||
|
// Try to remove remove an EventSource when elements are removed
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
if (source) {
|
||||||
|
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeReplaced',
|
||||||
|
})
|
||||||
|
internalData.sseEventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
switch (name) {
|
return
|
||||||
|
|
||||||
case "htmx:beforeCleanupElement":
|
// Try to create EventSources when elements are processed
|
||||||
var internalData = api.getInternalData(evt.target)
|
case 'htmx:afterProcessNode':
|
||||||
// Try to remove remove an EventSource when elements are removed
|
ensureEventSourceOnElement(parent)
|
||||||
if (internalData.sseEventSource) {
|
}
|
||||||
internalData.sseEventSource.close();
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
return;
|
/// ////////////////////////////////////////////
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
/// ////////////////////////////////////////////
|
||||||
|
|
||||||
// Try to create EventSources when elements are processed
|
/**
|
||||||
case "htmx:afterProcessNode":
|
* createEventSource is the default method for creating new EventSource objects.
|
||||||
ensureEventSourceOnElement(evt.target);
|
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||||
registerSSE(evt.target);
|
*
|
||||||
}
|
* @param {string} url
|
||||||
}
|
* @returns EventSource
|
||||||
});
|
*/
|
||||||
|
function createEventSource(url) {
|
||||||
|
return new EventSource(url, { withCredentials: true })
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////
|
/**
|
||||||
// HELPER FUNCTIONS
|
* registerSSE looks for attributes that can contain sse events, right
|
||||||
///////////////////////////////////////////////
|
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||||
|
* the closest event source
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
*/
|
||||||
|
function registerSSE(elt) {
|
||||||
|
// Add message handlers for every `sse-swap` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||||
|
var sseEventNames = sseSwapAttr.split(',')
|
||||||
|
|
||||||
|
for (var i = 0; i < sseEventNames.length; i++) {
|
||||||
|
const sseEventName = sseEventNames[i].trim()
|
||||||
|
const listener = function(event) {
|
||||||
|
// If the source is missing then close SSE
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the body no longer contains the element, remove the listener
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(sseEventName, listener)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// swap the response into the DOM and trigger a notification
|
||||||
|
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
swap(elt, event.data)
|
||||||
|
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(sseEventName, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||||
|
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||||
|
// Find closest existing event source
|
||||||
|
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||||
|
if (sourceElement == null) {
|
||||||
|
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||||
|
return null // no eventsource in parentage, orphaned element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set internalData and source
|
||||||
|
var internalData = api.getInternalData(sourceElement)
|
||||||
|
var source = internalData.sseEventSource
|
||||||
|
|
||||||
|
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||||
|
triggerSpecs.forEach(function(ts) {
|
||||||
|
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var listener = function (event) {
|
||||||
|
if (maybeCloseSSESource(sourceElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||||
|
}
|
||||||
|
// Trigger events to be handled by the rest of htmx
|
||||||
|
htmx.trigger(elt, ts.trigger, event)
|
||||||
|
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the new listener
|
||||||
|
api.getInternalData(elt).sseEventListener = listener
|
||||||
|
source.addEventListener(ts.trigger.slice(4), listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||||
|
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||||
|
* is created and stored in the element's internalData.
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @param {number} retryCount
|
||||||
|
* @returns {EventSource | null}
|
||||||
|
*/
|
||||||
|
function ensureEventSourceOnElement(elt, retryCount) {
|
||||||
|
if (elt == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle extension source creation attribute
|
||||||
|
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||||
|
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||||
|
if (sseURL == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureEventSource(elt, sseURL, retryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSSE(elt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEventSource(elt, url, retryCount) {
|
||||||
|
var source = htmx.createEventSource(url)
|
||||||
|
|
||||||
|
source.onerror = function(err) {
|
||||||
|
// Log an error event
|
||||||
|
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||||
|
|
||||||
|
// If parent no longer exists in the document, then clean up this EventSource
|
||||||
|
if (maybeCloseSSESource(elt)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to reconnect the EventSource
|
||||||
|
if (source.readyState === EventSource.CLOSED) {
|
||||||
|
retryCount = retryCount || 0
|
||||||
|
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||||
|
var timeout = retryCount * 500
|
||||||
|
window.setTimeout(function() {
|
||||||
|
ensureEventSourceOnElement(elt, retryCount)
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onopen = function(evt) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||||
|
|
||||||
|
if (retryCount && retryCount > 0) {
|
||||||
|
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||||
|
for (let i = 0; i < childrenToFix.length; i++) {
|
||||||
|
registerSSE(childrenToFix[i])
|
||||||
|
}
|
||||||
|
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||||
|
retryCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.getInternalData(elt).sseEventSource = source
|
||||||
|
|
||||||
|
|
||||||
/**
|
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||||
* createEventSource is the default method for creating new EventSource objects.
|
if (closeAttribute) {
|
||||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
// close eventsource when this message is received
|
||||||
*
|
source.addEventListener(closeAttribute, function() {
|
||||||
* @param {string} url
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
* @returns EventSource
|
source,
|
||||||
*/
|
type: 'message',
|
||||||
function createEventSource(url) {
|
})
|
||||||
return new EventSource(url, { withCredentials: true });
|
source.close()
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function splitOnWhitespace(trigger) {
|
/**
|
||||||
return trigger.trim().split(/\s+/);
|
* maybeCloseSSESource confirms that the parent element still exists.
|
||||||
}
|
* If not, then any associated SSE source is closed and the function returns true.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} elt
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function maybeCloseSSESource(elt) {
|
||||||
|
if (!api.bodyContains(elt)) {
|
||||||
|
var source = api.getInternalData(elt).sseEventSource
|
||||||
|
if (source != undefined) {
|
||||||
|
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||||
|
source,
|
||||||
|
type: 'nodeMissing',
|
||||||
|
})
|
||||||
|
source.close()
|
||||||
|
// source = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function getLegacySSEURL(elt) {
|
|
||||||
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
|
|
||||||
if (legacySSEValue) {
|
|
||||||
var values = splitOnWhitespace(legacySSEValue);
|
|
||||||
for (var i = 0; i < values.length; i++) {
|
|
||||||
var value = values[i].split(/:(.+)/);
|
|
||||||
if (value[0] === "connect") {
|
|
||||||
return value[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLegacySSESwaps(elt) {
|
/**
|
||||||
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
|
* @param {HTMLElement} elt
|
||||||
var returnArr = [];
|
* @param {string} content
|
||||||
if (legacySSEValue != null) {
|
*/
|
||||||
var values = splitOnWhitespace(legacySSEValue);
|
function swap(elt, content) {
|
||||||
for (var i = 0; i < values.length; i++) {
|
api.withExtensions(elt, function(extension) {
|
||||||
var value = values[i].split(/:(.+)/);
|
content = extension.transformResponse(content, null, elt)
|
||||||
if (value[0] === "swap") {
|
})
|
||||||
returnArr.push(value[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return returnArr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
var swapSpec = api.getSwapSpecification(elt)
|
||||||
* registerSSE looks for attributes that can contain sse events, right
|
var target = api.getTarget(elt)
|
||||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
api.swap(target, content, swapSpec)
|
||||||
* the closest event source
|
}
|
||||||
*
|
|
||||||
* @param {HTMLElement} elt
|
|
||||||
*/
|
|
||||||
function registerSSE(elt) {
|
|
||||||
// Find closest existing event source
|
|
||||||
var sourceElement = api.getClosestMatch(elt, hasEventSource);
|
|
||||||
if (sourceElement == null) {
|
|
||||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
|
||||||
return null; // no eventsource in parentage, orphaned element
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set internalData and source
|
|
||||||
var internalData = api.getInternalData(sourceElement);
|
|
||||||
var source = internalData.sseEventSource;
|
|
||||||
|
|
||||||
// Add message handlers for every `sse-swap` attribute
|
function hasEventSource(node) {
|
||||||
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
|
return api.getInternalData(node).sseEventSource != null
|
||||||
|
}
|
||||||
var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
|
})()
|
||||||
if (sseSwapAttr) {
|
|
||||||
var sseEventNames = sseSwapAttr.split(",");
|
|
||||||
} else {
|
|
||||||
var sseEventNames = getLegacySSESwaps(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < sseEventNames.length; i++) {
|
|
||||||
var sseEventName = sseEventNames[i].trim();
|
|
||||||
var listener = function(event) {
|
|
||||||
|
|
||||||
// If the source is missing then close SSE
|
|
||||||
if (maybeCloseSSESource(sourceElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the body no longer contains the element, remove the listener
|
|
||||||
if (!api.bodyContains(child)) {
|
|
||||||
source.removeEventListener(sseEventName, listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
// swap the response into the DOM and trigger a notification
|
|
||||||
swap(child, event.data);
|
|
||||||
api.triggerEvent(elt, "htmx:sseMessage", event);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register the new listener
|
|
||||||
api.getInternalData(child).sseEventListener = listener;
|
|
||||||
source.addEventListener(sseEventName, listener);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
|
||||||
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
|
|
||||||
|
|
||||||
var sseEventName = api.getAttributeValue(child, "hx-trigger");
|
|
||||||
if (sseEventName == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process hx-triggers for events with the "sse:" prefix
|
|
||||||
if (sseEventName.slice(0, 4) != "sse:") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove the sse: prefix from here on out
|
|
||||||
sseEventName = sseEventName.substr(4);
|
|
||||||
|
|
||||||
var listener = function() {
|
|
||||||
if (maybeCloseSSESource(sourceElement)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!api.bodyContains(child)) {
|
|
||||||
source.removeEventListener(sseEventName, listener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
|
||||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
|
||||||
* is created and stored in the element's internalData.
|
|
||||||
* @param {HTMLElement} elt
|
|
||||||
* @param {number} retryCount
|
|
||||||
* @returns {EventSource | null}
|
|
||||||
*/
|
|
||||||
function ensureEventSourceOnElement(elt, retryCount) {
|
|
||||||
|
|
||||||
if (elt == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// handle extension source creation attribute
|
|
||||||
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
|
|
||||||
var sseURL = api.getAttributeValue(child, "sse-connect");
|
|
||||||
if (sseURL == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureEventSource(child, sseURL, retryCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
// handle legacy sse, remove for HTMX2
|
|
||||||
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
|
|
||||||
var sseURL = getLegacySSEURL(child);
|
|
||||||
if (sseURL == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureEventSource(child, sseURL, retryCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureEventSource(elt, url, retryCount) {
|
|
||||||
var source = htmx.createEventSource(url);
|
|
||||||
|
|
||||||
source.onerror = function(err) {
|
|
||||||
|
|
||||||
// Log an error event
|
|
||||||
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
|
|
||||||
|
|
||||||
// If parent no longer exists in the document, then clean up this EventSource
|
|
||||||
if (maybeCloseSSESource(elt)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, try to reconnect the EventSource
|
|
||||||
if (source.readyState === EventSource.CLOSED) {
|
|
||||||
retryCount = retryCount || 0;
|
|
||||||
var timeout = Math.random() * (2 ^ retryCount) * 500;
|
|
||||||
window.setTimeout(function() {
|
|
||||||
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
|
|
||||||
}, timeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
source.onopen = function(evt) {
|
|
||||||
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
|
|
||||||
}
|
|
||||||
|
|
||||||
api.getInternalData(elt).sseEventSource = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* maybeCloseSSESource confirms that the parent element still exists.
|
|
||||||
* If not, then any associated SSE source is closed and the function returns true.
|
|
||||||
*
|
|
||||||
* @param {HTMLElement} elt
|
|
||||||
* @returns boolean
|
|
||||||
*/
|
|
||||||
function maybeCloseSSESource(elt) {
|
|
||||||
if (!api.bodyContains(elt)) {
|
|
||||||
var source = api.getInternalData(elt).sseEventSource;
|
|
||||||
if (source != undefined) {
|
|
||||||
source.close();
|
|
||||||
// source = null
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
|
||||||
*
|
|
||||||
* @param {HTMLElement} elt
|
|
||||||
* @param {string} attributeName
|
|
||||||
*/
|
|
||||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
|
||||||
|
|
||||||
var result = [];
|
|
||||||
|
|
||||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
|
||||||
if (api.hasAttribute(elt, attributeName)) {
|
|
||||||
result.push(elt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search all child nodes that match the requested attribute
|
|
||||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
|
|
||||||
result.push(node);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {HTMLElement} elt
|
|
||||||
* @param {string} content
|
|
||||||
*/
|
|
||||||
function swap(elt, content) {
|
|
||||||
|
|
||||||
api.withExtensions(elt, function(extension) {
|
|
||||||
content = extension.transformResponse(content, null, elt);
|
|
||||||
});
|
|
||||||
|
|
||||||
var swapSpec = api.getSwapSpecification(elt);
|
|
||||||
var target = api.getTarget(elt);
|
|
||||||
var settleInfo = api.makeSettleInfo(elt);
|
|
||||||
|
|
||||||
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
|
|
||||||
|
|
||||||
settleInfo.elts.forEach(function(elt) {
|
|
||||||
if (elt.classList) {
|
|
||||||
elt.classList.add(htmx.config.settlingClass);
|
|
||||||
}
|
|
||||||
api.triggerEvent(elt, 'htmx:beforeSettle');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle settle tasks (with delay if requested)
|
|
||||||
if (swapSpec.settleDelay > 0) {
|
|
||||||
setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
|
|
||||||
} else {
|
|
||||||
doSettle(settleInfo)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* doSettle mirrors much of the functionality in htmx that
|
|
||||||
* settles elements after their content has been swapped.
|
|
||||||
* TODO: this should be published by htmx, and not duplicated here
|
|
||||||
* @param {import("../htmx").HtmxSettleInfo} settleInfo
|
|
||||||
* @returns () => void
|
|
||||||
*/
|
|
||||||
function doSettle(settleInfo) {
|
|
||||||
|
|
||||||
return function() {
|
|
||||||
settleInfo.tasks.forEach(function(task) {
|
|
||||||
task.call();
|
|
||||||
});
|
|
||||||
|
|
||||||
settleInfo.elts.forEach(function(elt) {
|
|
||||||
if (elt.classList) {
|
|
||||||
elt.classList.remove(htmx.config.settlingClass);
|
|
||||||
}
|
|
||||||
api.triggerEvent(elt, 'htmx:afterSettle');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasEventSource(node) {
|
|
||||||
return api.getInternalData(node).sseEventSource != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
@ -45,3 +45,25 @@ tr{
|
|||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
background-color: darkorange;
|
background-color: darkorange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-1rem);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOutUp {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
8
assets/tailwind.css
Normal file
8
assets/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
3338
assets/words/en_nouns.txt
Normal file
3338
assets/words/en_nouns.txt
Normal file
File diff suppressed because it is too large
Load Diff
3305
assets/words/ru_nouns.txt
Normal file
3305
assets/words/ru_nouns.txt
Normal file
File diff suppressed because it is too large
Load Diff
103
broker/sse.go
Normal file
103
broker/sse.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package broker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// the amount of time to wait when pushing a message to
|
||||||
|
// a slow client or a client that closed after `range clients` started.
|
||||||
|
const patience time.Duration = time.Second * 1
|
||||||
|
|
||||||
|
type (
|
||||||
|
NotificationEvent struct {
|
||||||
|
EventName string
|
||||||
|
Payload string
|
||||||
|
}
|
||||||
|
NotifierChan chan NotificationEvent
|
||||||
|
Broker struct {
|
||||||
|
// Events are pushed to this channel by the main events-gathering routine
|
||||||
|
Notifier NotifierChan
|
||||||
|
// New client connections
|
||||||
|
newClients chan NotifierChan
|
||||||
|
// Closed client connections
|
||||||
|
closingClients chan NotifierChan
|
||||||
|
// Client connections registry
|
||||||
|
clients map[NotifierChan]struct{}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewBroker() (broker *Broker) {
|
||||||
|
// Instantiate a broker
|
||||||
|
return &Broker{
|
||||||
|
Notifier: make(NotifierChan, 1),
|
||||||
|
newClients: make(chan NotifierChan),
|
||||||
|
closingClients: make(chan NotifierChan),
|
||||||
|
clients: make(map[NotifierChan]struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Notifier *Broker
|
||||||
|
|
||||||
|
// for use in different packages
|
||||||
|
func init() {
|
||||||
|
Notifier = NewBroker()
|
||||||
|
go Notifier.Listen()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (broker *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Headers (keep these as-is)
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
messageChan := make(NotifierChan)
|
||||||
|
broker.newClients <- messageChan
|
||||||
|
defer func() { broker.closingClients <- messageChan }()
|
||||||
|
ctx := r.Context()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Client disconnected
|
||||||
|
return
|
||||||
|
case event := <-messageChan:
|
||||||
|
_, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.EventName, event.Payload)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
// Client disconnected
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for new notifications and redistribute them to clients
|
||||||
|
func (broker *Broker) Listen() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case s := <-broker.newClients:
|
||||||
|
// A new client has connected.
|
||||||
|
// Register their message channel
|
||||||
|
broker.clients[s] = struct{}{}
|
||||||
|
slog.Info("Client added", "clients listening", len(broker.clients))
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
27
components/actionhistory.html
Normal file
27
components/actionhistory.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{{define "actionhistory"}}
|
||||||
|
<div id="actionHistoryContainer" class="overflow-y-auto max-h-96 border-2 border-gray-300 p-4 rounded-lg space-y-2">
|
||||||
|
Backlog:
|
||||||
|
{{range .}}
|
||||||
|
<div class="flex items-center justify-between p-2 rounded">
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
<span class="text-{{.ActorColor}}-600">{{.Actor}}:</span>
|
||||||
|
<span class="text-gray-600">{{.Action}}:</span>
|
||||||
|
<span class="text-{{.WordColor}}-500 font-medium">{{.Word}}</span>
|
||||||
|
{{if .Number}}
|
||||||
|
<span class="text-gray-400">- {{.Number}}</span>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
if (!window.actionHistoryScrollSet) {
|
||||||
|
htmx.onLoad(function(target) {
|
||||||
|
if (target.id === 'actionHistoryContainer') {
|
||||||
|
target.scrollTop = target.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.actionHistoryScrollSet = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
31
components/addbotbtn.html
Normal file
31
components/addbotbtn.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{{ define "addbot" }}
|
||||||
|
{{$botName := ""}}
|
||||||
|
<div>
|
||||||
|
{{$botName = .Room.FindBotByTeamRole "blue" "mime"}}
|
||||||
|
{{ if eq .Room.BlueTeam.Mime "" }}
|
||||||
|
<button hx-get="/add-bot?team=blue&role=mime" hx-target="#addbot" class="bg-blue-400 text-black px-1 py-1 rounded">Add Bot Mime</button>
|
||||||
|
{{ else if ne $botName "" }}
|
||||||
|
<button hx-get="/remove-bot?bot={{$botName}}" hx-target="#addbot" class="bg-blue-400 text-black px-1 py-1 rounded">Remove {{$botName}}</button>
|
||||||
|
{{ end }}
|
||||||
|
{{$botName = .Room.FindBotByTeamRole "red" "mime"}}
|
||||||
|
{{ if eq .Room.RedTeam.Mime "" }}
|
||||||
|
<button hx-get="/add-bot?team=red&role=mime" hx-target="#addbot" class="bg-red-400 text-black px-1 py-1 rounded">Add Bot Mime</button>
|
||||||
|
{{ else if ne $botName "" }}
|
||||||
|
<button hx-get="/remove-bot?bot={{$botName}}" hx-target="#addbot" class="bg-red-400 text-black px-1 py-1 rounded">Remove {{$botName}}</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{$botName = .Room.FindBotByTeamRole "blue" "guesser"}}
|
||||||
|
<div>
|
||||||
|
{{ if not .Room.BlueTeam.Guessers }}
|
||||||
|
<button hx-get="/add-bot?team=blue&role=guesser" hx-target="#addbot" class="bg-blue-300 text-black px-1 py-1 rounded">Add Bot Guesser</button>
|
||||||
|
{{ else if ne $botName "" }}
|
||||||
|
<button hx-get="/remove-bot?bot={{$botName}}" hx-target="#addbot" class="bg-blue-300 text-black px-1 py-1 rounded">Remove {{$botName}}</button>
|
||||||
|
{{ end }}
|
||||||
|
{{$botName = .Room.FindBotByTeamRole "red" "guesser"}}
|
||||||
|
{{ if not .Room.RedTeam.Guessers }}
|
||||||
|
<button hx-get="/add-bot?team=red&role=guesser" hx-target="#addbot" class="bg-red-300 text-black px-1 py-1 rounded">Add Bot Guesser</button>
|
||||||
|
{{ else if ne $botName "" }}
|
||||||
|
<button hx-get="/remove-bot?bot={{$botName}}" hx-target="#addbot" class="bg-red-300 text-black px-1 py-1 rounded">Remove {{$botName}}</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
50
components/base.html
Normal file
50
components/base.html
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{{define "base"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Alias</title>
|
||||||
|
<script src="/assets/helpers.js"></script>
|
||||||
|
<script src="/assets/htmx.min.js"></script>
|
||||||
|
<script src="/assets/htmx.sse.js"></script>
|
||||||
|
<script src="/assets/tailwind.css"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css"/>
|
||||||
|
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/>
|
||||||
|
<link rel="icon" sizes="64x64" href="/assets/favicon/wolfhead_negated.ico"/>
|
||||||
|
<style type="text/css">
|
||||||
|
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>
|
||||||
|
<body>
|
||||||
|
<div id="ancestor" hx-ext="sse" sse-connect="/sub/sse">
|
||||||
|
{{template "main" .}}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
9
components/cardcounter.html
Normal file
9
components/cardcounter.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{{define "cardcounter"}}
|
||||||
|
<div class="flex justify-center space-x-2">
|
||||||
|
<p class="text-blue-400">Blue cards left: {{.BlueCounter}} </p>
|
||||||
|
<p class="text-red-400">Red cards left: {{.RedCounter}} </p>
|
||||||
|
<hr>
|
||||||
|
<p>Limit of cards to open: {{.ThisTurnLimit}} </p>
|
||||||
|
<p>Opened this turn: {{.OpenedThisTurn}} </p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
10
components/cardtable.html
Normal file
10
components/cardtable.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{{define "cardtable"}}
|
||||||
|
<!-- Center Panel -->
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||||
|
{{range .Cards}}
|
||||||
|
{{template "cardword" .}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
39
components/cardword.html
Normal file
39
components/cardword.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{{define "cardword"}}
|
||||||
|
{{if .Revealed}}
|
||||||
|
{{if eq .Color "amber"}}
|
||||||
|
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border-8 border-stine-400 p-4 min-w-[100px] text-center text-white cursor-pointer"
|
||||||
|
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border-8 border-stone-400 p-4 min-w-[100px] text-center text-white cursor-pointer"
|
||||||
|
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else if .Mime}}
|
||||||
|
{{if eq .Color "amber"}}
|
||||||
|
<div id="card-{{.Word}}" class="bg-{{.Color}}-100 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
|
||||||
|
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div id="card-{{.Word}}" class="bg-{{.Color}}-400 border border-stone-400 p-4 rounded-lg min-w-[100px] text-center text-white cursor-pointer"
|
||||||
|
style="text-shadow: 0 2px 4px rgba(0,0,0,0.9);"> {{.Word}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
<div id="card-{{.Word}}" class="bg-stone-400 border border-gray-500 rounded-lg min-w-[100px] cursor-pointer flex flex-col h-full">
|
||||||
|
<div class="flex-grow text-center p-4 flex items-center justify-center text-white"
|
||||||
|
style="text-shadow: 0 2px 4px rgba(0,0,0,0.8);"
|
||||||
|
hx-get="/word/show-color?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
|
||||||
|
{{.Word}}
|
||||||
|
</div>
|
||||||
|
<div class="h-6 bg-stone-600 rounded-b flex items-center justify-center text-white text-sm cursor-pointer"
|
||||||
|
hx-get="/mark-card?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
|
||||||
|
{{range .Mark}}
|
||||||
|
{{if .Active}}
|
||||||
|
<span class="mx-0.5">X</span>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
22
components/createroomform.html
Normal file
22
components/createroomform.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{{define "createform"}}
|
||||||
|
{{if .}}
|
||||||
|
<div id="create-room" class="create-room-div">
|
||||||
|
Create a room <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>
|
||||||
|
<form hx-post="/room-create" hx-target="#ancestor">
|
||||||
|
<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/>
|
||||||
|
<label For="language">Language:</label><br/>
|
||||||
|
<input type="text" id="language" name="language" class="text-center text-white" value="en"/><br/>
|
||||||
|
<label For="password">Password:</label><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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
9
components/error.html
Normal file
9
components/error.html
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{{define "error"}}
|
||||||
|
<a href="/">
|
||||||
|
<div id=errorbox class="bg-orange-100 border-l-4 border-orange-500 text-orange-700 p-4" role="alert">
|
||||||
|
<p class="font-bold">An error from server</p>
|
||||||
|
<p>{{.}}</p>
|
||||||
|
<p>Click this banner to return to main page.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
@ -1,33 +1,22 @@
|
|||||||
{{define "main"}}
|
{{define "main"}}
|
||||||
<!DOCTYPE html>
|
{{ if not . }}
|
||||||
<html lang="en">
|
{{template "login"}}
|
||||||
<head>
|
{{ else if ne .LinkLogin "" }}
|
||||||
<meta charset="UTF-8">
|
{{template "linklogin" .LinkLogin}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
{{ else if not .State.RoomID }}
|
||||||
<title>Word Colors</title>
|
<div id="hello-user">
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
<p>data: {{.}} {{.State}} {{.Room}}</p>
|
||||||
</head>
|
<p>Hello {{.State.Username}}</p>
|
||||||
<body>
|
|
||||||
<div id=ancestor>
|
|
||||||
{{template "login"}}
|
|
||||||
<h1>Word Color Cards</h1>
|
|
||||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap; padding: 1rem;">
|
|
||||||
{{range $word, $color := .}}
|
|
||||||
<div style="
|
|
||||||
background-color: {{$color}};
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
min-width: 100px;
|
|
||||||
text-align: center;
|
|
||||||
color: white;
|
|
||||||
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
|
|
||||||
">
|
|
||||||
{{$word}}
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
<div id="create-room" class="create-room-div">
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
<div>
|
||||||
</html>
|
{{template "roomlist" .List}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div id="room">
|
||||||
|
{{template "room" .}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
16
components/linklogin.html
Normal file
16
components/linklogin.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{{define "linklogin"}}
|
||||||
|
<div id="logindiv">
|
||||||
|
You're about to join room#{{.}}; but first!
|
||||||
|
<form class="space-y-6" hx-post="/login" hx-target="#ancestor">
|
||||||
|
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
|
||||||
|
<input type="hidden" name="room_id" value={{.}}>
|
||||||
|
</div>
|
||||||
|
<div id="login_notice">this name looks available</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
@ -4,7 +4,7 @@
|
|||||||
<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</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 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 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>
|
||||||
|
21
components/mimeclue.html
Normal file
21
components/mimeclue.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{{define "mimeclue"}}
|
||||||
|
<div class="flex gap-4 w-full">
|
||||||
|
<form class="space-y-6" hx-post="/give-clue">
|
||||||
|
<input type="text"
|
||||||
|
class="flex-1 px-4 py-2 text-lg border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
name="clue"
|
||||||
|
placeholder="Enter clue..."
|
||||||
|
required>
|
||||||
|
<input type="number"
|
||||||
|
class="w-24 px-4 py-2 text-lg border-2 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Number"
|
||||||
|
name="number"
|
||||||
|
min="0"
|
||||||
|
max="9"
|
||||||
|
required>
|
||||||
|
<button type="submit" class="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
17
components/namecheck.html
Normal file
17
components/namecheck.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{{define "namecheck"}}
|
||||||
|
{{ if eq . 0 }}
|
||||||
|
<div id="login_notice">this name looks available</div>
|
||||||
|
{{ else if eq . 1 }}
|
||||||
|
<a href="/">
|
||||||
|
<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>This Name is already taken. You won't be able to login with it until other person leaves.</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{ else }}
|
||||||
|
<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>This Name is already taken. You won't be able to login with it until other person leaves.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
14
components/playerlist.html
Normal file
14
components/playerlist.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{{define "teamlist"}}
|
||||||
|
<div>
|
||||||
|
<div class="playerlist border border-gray-300 text-{{.Color}}-500 rounded mb-2">
|
||||||
|
<p class="border">Guessers</p>
|
||||||
|
{{range .Guessers}}
|
||||||
|
<p>{{.}}</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="playerlist border border-gray-300 rounded mb-2 text-{{.Color}}-700">
|
||||||
|
<p class="border">Mime</p>
|
||||||
|
<p>{{.Mime}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
89
components/room.html
Normal file
89
components/room.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
{{define "room"}}
|
||||||
|
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}" class=space-y-2>
|
||||||
|
<div id="meta">
|
||||||
|
<p>Hello {{.State.Username}};</p>
|
||||||
|
<p>Room created by {{.Room.CreatorName}};</p>
|
||||||
|
<p>Room link:</p>
|
||||||
|
<p><input id="roomlink" readonly="" onclick="copyText()" value="{{.Room.RoomLink}}" class="bg-amber-100 text-black px-1 py-1 rounded"></input></p>
|
||||||
|
<p>Game is running: {{.Room.IsRunning}}</p>
|
||||||
|
<p>
|
||||||
|
{{if and (eq .State.Username .Room.CreatorName) (not .Room.IsRunning)}}
|
||||||
|
<button hx-get="/start-game" hx-target="#room" class="bg-amber-100 text-black px-4 py-2 rounded">Start Game</button>
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
{{if .Room.IsOver}}
|
||||||
|
<p>GAME OVER; team <span class="text-{{.Room.TeamWon}}-500">{{.Room.TeamWon}}</span> won! 🧚</p>
|
||||||
|
{{end}}
|
||||||
|
<p>
|
||||||
|
{{if eq .State.Team ""}}
|
||||||
|
join the team!
|
||||||
|
{{else}}
|
||||||
|
you're on the team <span class="text-{{.State.Team}}-500">{{.State.Team}}</span>!
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
{{if .Room.IsRunning}}
|
||||||
|
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</p>
|
||||||
|
{{template "turntimer" .Room}}
|
||||||
|
{{if .Room.MimeDone}}
|
||||||
|
<p class="text-{{.Room.TeamTurn}}-500 text-xl">Waiting for guessers</p>
|
||||||
|
<p class="text-{{.Room.TeamTurn}}-500 text-xl">Given Clue: "{{.Room.FetchLastClueWord}}"</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-{{.Room.TeamTurn}}-500 text-xl">Waiting for mime</p>
|
||||||
|
{{end}}
|
||||||
|
{{template "cardcounter" .Room}}
|
||||||
|
{{end}}
|
||||||
|
<div id="addbot">
|
||||||
|
{{if and (eq .State.Username .Room.CreatorName) (not .Room.IsRunning)}}
|
||||||
|
{{template "addbot" .}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<!-- Left Panel -->
|
||||||
|
{{template "teamlist" .Room.BlueTeam}}
|
||||||
|
{{if not .Room.IsRunning}}
|
||||||
|
{{template "teampew" .}}
|
||||||
|
{{end}}
|
||||||
|
<!-- Right Panel -->
|
||||||
|
{{template "teamlist" .Room.RedTeam}}
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div id="systembox" style="overflow-y: auto; max-height: 100px;">
|
||||||
|
Server says: <br>
|
||||||
|
<ul>
|
||||||
|
{{range .Room.LogJournal}}
|
||||||
|
<li>{{.}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div sse-swap="journal_{{.Room.ID}}">
|
||||||
|
bot thoughts
|
||||||
|
<div>
|
||||||
|
<div id="cardtable">
|
||||||
|
{{template "cardtable" .Room}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{if .Room.IsRunning}}
|
||||||
|
{{if and (eq .State.Role "guesser") (eq .State.Team .Room.TeamTurn)}}
|
||||||
|
<button hx-get="/end-turn" hx-target="#room" class="bg-amber-100 text-black px-4 py-2 rounded">End Turn</button>
|
||||||
|
{{else if and (eq .State.Role "mime") (not .Room.MimeDone)}}
|
||||||
|
{{template "mimeclue"}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{if and (eq .State.Username .Room.CreatorName) (.Room.IsRunning)}}
|
||||||
|
<button hx-get="/renotify-bot" hx-swap="none" class="bg-gray-100 text-black px-1 py-1 rounded">Btn in case llm call failed</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div hx-get="/actionhistory" hx-trigger="sse:backlog_{{.Room.ID}}">
|
||||||
|
{{template "actionhistory" .Room.ActionHistory}}
|
||||||
|
</div>
|
||||||
|
{{if not .Room.IsRunning}}
|
||||||
|
<div id="exitbtn">
|
||||||
|
<button button id="exit-room-btn" type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/exit" hx-target="#ancestor">Exit Room</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
24
components/roomlist.html
Normal file
24
components/roomlist.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{{define "roomlist"}}
|
||||||
|
<div id="roomlist" hx-get="/" hx-trigger="sse:roomlistupdate" hx-target="#ancestor">
|
||||||
|
{{range .}}
|
||||||
|
<p>
|
||||||
|
{{.ID}}
|
||||||
|
</p>
|
||||||
|
<div hx-get="/room-join?id={{.ID}}" hx-target="#ancestor" class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="room-info">
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Created {{.CreatedAt.Format "2 Jan 2006 15:04"}} by
|
||||||
|
<span class="font-medium text-gray-700">{{.CreatorName}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex items-center gap-3">
|
||||||
|
<span class="px-2 py-1 text-xs font-medium rounded-full {{if .IsRunning}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-600{{end}}">
|
||||||
|
{{if .IsRunning}}Game Active{{else}}Waiting Room{{end}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
46
components/teampew.html
Normal file
46
components/teampew.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
{{define "teampew"}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl mb-4">Join Blue Team</h2>
|
||||||
|
<form hx-post="/join-team" hx-target="#ancestor">
|
||||||
|
<input type="hidden" name="team" value="blue">
|
||||||
|
<div class="mb-1">
|
||||||
|
{{if and (eq .State.Role "guesser") (eq .State.Team "blue")}}
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" name="role" value="guesser" class="w-full bg-blue-500 text-white py-1 px-2 rounded">
|
||||||
|
Join as Guesser
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if eq .Room.BlueTeam.Mime ""}}
|
||||||
|
<div>
|
||||||
|
<button type="submit" name="role" value="mime" class="w-full bg-blue-700 text-white py-1 px-2 rounded">
|
||||||
|
Join as Mime
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl mb-4">Join Red Team</h2>
|
||||||
|
<form hx-post="/join-team" hx-target="#ancestor">
|
||||||
|
<input type="hidden" name="team" value="red">
|
||||||
|
<div class="mb-1">
|
||||||
|
{{if and (eq .State.Role "guesser") (eq .State.Team "red")}}
|
||||||
|
{{else}}
|
||||||
|
<button type="submit" name="role" value="guesser" class="w-full bg-red-500 text-white py-1 px-2 rounded">
|
||||||
|
Join as Guesser
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if eq .Room.RedTeam.Mime ""}}
|
||||||
|
<div>
|
||||||
|
<button type="submit" name="role" value="mime" class="w-full bg-red-700 text-white py-1 px-2 rounded">
|
||||||
|
Join as Mime
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
5
components/turntimer.html
Normal file
5
components/turntimer.html
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{{define "turntimer"}}
|
||||||
|
<div>
|
||||||
|
Timer: <span sse-swap="turntimer_{{.ID}}">no time to lose</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
12
config.example.toml
Normal file
12
config.example.toml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
BASE_URL = "https://localhost:3000"
|
||||||
|
SESSION_LIFETIME_SECONDS = 30000
|
||||||
|
COOKIE_SECRET = "test"
|
||||||
|
DB_PATH = "sqlite3://gralias.db"
|
||||||
|
|
||||||
|
[SERVICE]
|
||||||
|
HOST = "localhost"
|
||||||
|
PORT = "3000"
|
||||||
|
|
||||||
|
[LLM]
|
||||||
|
TOKEN = ""
|
||||||
|
URL = "https://api.deepseek.com/beta"
|
@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
@ -9,9 +10,10 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
ServerConfig ServerConfig `toml:"SERVICE"`
|
ServerConfig ServerConfig `toml:"SERVICE"`
|
||||||
BaseURL string `toml:"BASE_URL"`
|
BaseURL string `toml:"BASE_URL"`
|
||||||
SessionLifetime int `toml:"SESSION_LIFETIME_SECONDS"`
|
SessionLifetime int64 `toml:"SESSION_LIFETIME_SECONDS"`
|
||||||
DBURI string `toml:"DBURI"`
|
|
||||||
CookieSecret string `toml:"COOKIE_SECRET"`
|
CookieSecret string `toml:"COOKIE_SECRET"`
|
||||||
|
LLMConfig LLMConfig `toml:"LLM"`
|
||||||
|
DBPath string `toml:"DB_PATH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
@ -19,6 +21,11 @@ type ServerConfig struct {
|
|||||||
Port string `toml:"PORT"`
|
Port string `toml:"PORT"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LLMConfig struct {
|
||||||
|
URL string `toml:"URL"`
|
||||||
|
TOKEN string `toml:"TOKEN"`
|
||||||
|
}
|
||||||
|
|
||||||
func LoadConfigOrDefault(fn string) *Config {
|
func LoadConfigOrDefault(fn string) *Config {
|
||||||
if fn == "" {
|
if fn == "" {
|
||||||
fn = "config.toml"
|
fn = "config.toml"
|
||||||
@ -28,8 +35,12 @@ func LoadConfigOrDefault(fn string) *Config {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("failed to read config from file, loading default", "error", err)
|
slog.Warn("failed to read config from file, loading default", "error", err)
|
||||||
config.BaseURL = "https://localhost:3000"
|
config.BaseURL = "https://localhost:3000"
|
||||||
config.SessionLifetime = 300
|
config.SessionLifetime = 30000
|
||||||
config.CookieSecret = "test"
|
config.CookieSecret = "test"
|
||||||
|
config.ServerConfig.Host = "localhost"
|
||||||
|
config.ServerConfig.Port = "3000"
|
||||||
|
config.DBPath = "sqlite3://gralias.db"
|
||||||
}
|
}
|
||||||
|
fmt.Printf("config debug; config.LLMConfig.URL: %s\n", config.LLMConfig.URL)
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
135
crons/main.go
Normal file
135
crons/main.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package crons
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/repos"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CronManager struct {
|
||||||
|
repo repos.AllRepos
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCronManager(repo repos.AllRepos, log *slog.Logger) *CronManager {
|
||||||
|
return &CronManager{
|
||||||
|
repo: repo,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *CronManager) Start() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
cm.CleanupRooms()
|
||||||
|
cm.CleanupActions()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *CronManager) CleanupRooms() {
|
||||||
|
ctx, tx, err := cm.repo.InitTx(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
cm.log.Error("failed to init transaction", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction", "err", err)
|
||||||
|
}
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
rooms, err := cm.repo.RoomList(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cm.log.Error("failed to get rooms list", "err", err)
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, room := range rooms {
|
||||||
|
players, err := cm.repo.PlayerListByRoom(ctx, room.ID)
|
||||||
|
if err != nil {
|
||||||
|
cm.log.Error("failed to get players for room", "room_id", room.ID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(players) == 0 {
|
||||||
|
cm.log.Info("deleting empty room", "room_id", room.ID)
|
||||||
|
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
|
||||||
|
cm.log.Error("failed to delete empty room", "room_id", room.ID, "err", err)
|
||||||
|
}
|
||||||
|
if err := cm.repo.SettingsDeleteByRoomID(ctx, room.ID); err != nil {
|
||||||
|
cm.log.Error("failed to delete settings for empty room", "room_id", room.ID, "err", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
creatorInRoom := false
|
||||||
|
for _, player := range players {
|
||||||
|
if player.Username == room.CreatorName {
|
||||||
|
creatorInRoom = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !creatorInRoom {
|
||||||
|
cm.log.Info("deleting room because creator left", "room_id", room.ID)
|
||||||
|
for _, player := range players {
|
||||||
|
if player.IsBot {
|
||||||
|
if err := cm.repo.PlayerDelete(ctx, room.ID, player.Username); err != nil {
|
||||||
|
cm.log.Error("failed to delete bot player", "room_id", room.ID, "username", player.Username, "err", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := cm.repo.PlayerExitRoom(ctx, player.Username); err != nil {
|
||||||
|
cm.log.Error("failed to update player room", "room_id", room.ID, "username", player.Username, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := cm.repo.RoomDeleteByID(ctx, room.ID); err != nil {
|
||||||
|
cm.log.Error("failed to delete room after creator left", "room_id", room.ID, "err", err)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
cm.log.Error("failed to commit transaction", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cm *CronManager) CleanupActions() {
|
||||||
|
ctx, tx, err := cm.repo.InitTx(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
cm.log.Error("failed to init transaction for actions cleanup", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := cm.repo.ActionDeleteOrphaned(ctx); err != nil {
|
||||||
|
cm.log.Error("failed to delete orphaned actions", "err", err)
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
cm.log.Error("failed to rollback transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
cm.log.Error("failed to commit transaction for actions cleanup", "err", err)
|
||||||
|
}
|
||||||
|
}
|
13
go.mod
13
go.mod
@ -1,10 +1,17 @@
|
|||||||
module golias
|
module gralias
|
||||||
|
|
||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
honnef.co/go/tools v0.6.1
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
|
github.com/rs/xid v1.6.0
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/tools v0.30.0 // indirect
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
27
go.sum
27
go.sum
@ -1,6 +1,25 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
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=
|
||||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
|
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/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||||
|
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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
294
handlers/actions.go
Normal file
294
handlers/actions.go
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"gralias/broker"
|
||||||
|
"gralias/llmapi"
|
||||||
|
"gralias/models"
|
||||||
|
"gralias/wordloader"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createRoom(ctx context.Context, req *models.RoomReq) (*models.Room, error) {
|
||||||
|
creator, ok := ctx.Value(models.CtxUsernameKey).(string)
|
||||||
|
if !ok {
|
||||||
|
err := errors.New("failed to extract user from ctx")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
room := req.CreateRoom(creator)
|
||||||
|
room.RoomLink = cfg.BaseURL + "/room-join?id=" + room.ID
|
||||||
|
if err := repo.RoomCreate(ctx, room); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return room, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveFullInfo(ctx context.Context, fi *models.FullInfo) error {
|
||||||
|
// INFO: no transactions; so case is possible where first object is updated but the second is not
|
||||||
|
if err := repo.PlayerUpdate(ctx, fi.State); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Debug("saved user state", "state", fi.State)
|
||||||
|
// save or update
|
||||||
|
// fi.Room.Cards
|
||||||
|
// fi.Room.WCMap
|
||||||
|
if err := repo.RoomUpdate(ctx, fi.Room); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyBotIfNeeded(room *models.Room) {
|
||||||
|
if botName := room.WhichBotToMove(); botName != "" {
|
||||||
|
log.Debug("got botname", "name", botName, "channel_len", len(llmapi.SignalChanMap[botName]))
|
||||||
|
llmapi.SignalChanMap[botName] <- true
|
||||||
|
log.Debug("after sending signal", "name", botName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// can room exists without state? I think no
|
||||||
|
func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
|
||||||
|
resp := &models.FullInfo{}
|
||||||
|
state, err := getPlayerByCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.State = state
|
||||||
|
if state.RoomID == nil || *state.RoomID == "" {
|
||||||
|
log.Debug("returning state without room", "username", state.Username)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
// room, err := getRoomByID(state.RoomID)
|
||||||
|
room, err := repo.RoomGetExtended(ctx, *state.RoomID)
|
||||||
|
// room, err := repo.RoomGetByID(ctx, *state.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
// room was deleted; remove it from player;
|
||||||
|
log.Warn("failed to find room despite knowing room_id;",
|
||||||
|
"room_id", state.RoomID)
|
||||||
|
state.Team = models.UserTeamNone
|
||||||
|
state.Role = models.UserRoleNone
|
||||||
|
if err := repo.PlayerExitRoom(ctx, state.Username); err != nil {
|
||||||
|
log.Warn("failed to exit room",
|
||||||
|
"room_id", state.RoomID, "username", state.Username)
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp.Room = room
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPlayerByCtx(ctx context.Context) (*models.Player, error) {
|
||||||
|
username, ok := ctx.Value(models.CtxUsernameKey).(string)
|
||||||
|
if !ok {
|
||||||
|
log.Debug("no username in ctx")
|
||||||
|
return &models.Player{}, errors.New("no username in ctx")
|
||||||
|
}
|
||||||
|
return repo.PlayerGetByName(ctx, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// // DEPRECATED
|
||||||
|
// func leaveRole(fi *models.FullInfo) {
|
||||||
|
// fi.Room.RedTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.RedTeam.Guessers)
|
||||||
|
// fi.Room.BlueTeam.Guessers = utils.RemoveFromSlice(fi.State.Username, fi.Room.BlueTeam.Guessers)
|
||||||
|
// if fi.Room.RedTeam.Mime == fi.State.Username {
|
||||||
|
// fi.Room.RedTeam.Mime = ""
|
||||||
|
// }
|
||||||
|
// if fi.Room.BlueTeam.Mime == fi.State.Username {
|
||||||
|
// fi.Room.BlueTeam.Mime = ""
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) {
|
||||||
|
// get username
|
||||||
|
fi, _ := getFullInfoByCtx(ctx)
|
||||||
|
fi.Room.RemovePlayer(fi.State.Username)
|
||||||
|
// get room
|
||||||
|
if role == "mime" {
|
||||||
|
if team == "blue" {
|
||||||
|
if fi.Room.BlueTeam.Mime != "" {
|
||||||
|
// error: alredy taken
|
||||||
|
err := errors.New("Mime role already taken!")
|
||||||
|
return fi, err
|
||||||
|
}
|
||||||
|
fi.Room.BlueTeam.Mime = fi.State.Username
|
||||||
|
fi.Room.BlueTeam.Color = "blue"
|
||||||
|
fi.State.Team = "blue"
|
||||||
|
fi.State.Role = "mime"
|
||||||
|
} else if team == "red" {
|
||||||
|
if fi.Room.RedTeam.Mime != "" {
|
||||||
|
// error: alredy taken
|
||||||
|
err := errors.New("Mime role already taken!")
|
||||||
|
return fi, err
|
||||||
|
}
|
||||||
|
fi.Room.RedTeam.Mime = fi.State.Username
|
||||||
|
fi.Room.RedTeam.Color = "red"
|
||||||
|
fi.State.Team = "red"
|
||||||
|
fi.State.Role = "mime"
|
||||||
|
} else {
|
||||||
|
err := errors.New("uknown team:" + team)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if role == "guesser" {
|
||||||
|
if team == "blue" {
|
||||||
|
fi.Room.BlueTeam.Guessers = append(fi.Room.BlueTeam.Guessers, fi.State.Username)
|
||||||
|
fi.Room.BlueTeam.Color = "blue"
|
||||||
|
fi.State.Team = "blue"
|
||||||
|
fi.State.Role = "guesser"
|
||||||
|
} else if team == "red" {
|
||||||
|
fi.Room.RedTeam.Guessers = append(fi.Room.RedTeam.Guessers, fi.State.Username)
|
||||||
|
fi.Room.RedTeam.Color = "red"
|
||||||
|
fi.State.Team = "red"
|
||||||
|
fi.State.Role = "guesser"
|
||||||
|
} else {
|
||||||
|
err := errors.New("uknown team:" + team)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err := errors.New("uknown role:" + role)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := saveFullInfo(ctx, fi); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fi, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all rooms
|
||||||
|
// func listRooms(allRooms bool) []*models.Room {
|
||||||
|
// cacheMap := memcache.GetAll()
|
||||||
|
// publicRooms := []*models.Room{}
|
||||||
|
// // no way to know if room is public until unmarshal -_-;
|
||||||
|
// for key, value := range cacheMap {
|
||||||
|
// if strings.HasPrefix(key, models.CacheRoomPrefix) {
|
||||||
|
// room := &models.Room{}
|
||||||
|
// if err := json.Unmarshal(value, &room); err != nil {
|
||||||
|
// log.Warn("failed to unmarshal room", "error", err)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// if room.IsPublic || allRooms {
|
||||||
|
// publicRooms = append(publicRooms, room)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return publicRooms
|
||||||
|
// }
|
||||||
|
|
||||||
|
// get bots
|
||||||
|
func listBots() []models.Player {
|
||||||
|
bots, err := repo.PlayerList(context.Background(), true)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to fetch bots from db", "error", err)
|
||||||
|
}
|
||||||
|
return bots
|
||||||
|
}
|
||||||
|
|
||||||
|
// get players
|
||||||
|
func notify(event, msg string) {
|
||||||
|
Notifier.Notifier <- broker.NotificationEvent{
|
||||||
|
EventName: event,
|
||||||
|
Payload: msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCards(room *models.Room) {
|
||||||
|
// store it somewhere
|
||||||
|
wordMap := map[string]string{
|
||||||
|
"en": "assets/words/en_nouns.txt",
|
||||||
|
"ru": "assets/words/ru_nouns.txt",
|
||||||
|
}
|
||||||
|
wl := wordloader.InitDefaultLoader(wordMap[room.Settings.Language])
|
||||||
|
cards, err := wl.Load()
|
||||||
|
if err != nil {
|
||||||
|
// no logger
|
||||||
|
fmt.Println("failed to load cards", "error", err)
|
||||||
|
}
|
||||||
|
room.Cards = cards
|
||||||
|
// room.WCMap = make(map[string]models.WordColor)
|
||||||
|
// for _, card := range room.Cards {
|
||||||
|
// room.WCMap[card.Word] = card.Color
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverBots() {
|
||||||
|
bots := listBots()
|
||||||
|
for _, bot := range bots {
|
||||||
|
if err := recoverBot(bot); err != nil {
|
||||||
|
log.Warn("failed to recover bot", "botName", bot.Username, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recoverBot(bm models.Player) error {
|
||||||
|
// check if room still exists
|
||||||
|
if bm.RoomID == nil {
|
||||||
|
return errors.New("bot has no room id")
|
||||||
|
}
|
||||||
|
if _, err := repo.RoomGetByID(context.Background(), *bm.RoomID); err != nil {
|
||||||
|
return fmt.Errorf("no such room: %s; err: %w", *bm.RoomID, err)
|
||||||
|
}
|
||||||
|
log.Debug("recovering bot", "bot", bm)
|
||||||
|
_, err := llmapi.NewBot(string(bm.Role), string(bm.Team), bm.Username, *bm.RoomID, cfg, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// func recoverPlayers() {
|
||||||
|
// players := listPlayers()
|
||||||
|
// for playerName, playerMap := range players {
|
||||||
|
// if err := recoverPlayer(playerMap); err != nil {
|
||||||
|
// log.Warn("failed to recover player", "playerName", playerName, "error", err)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func recoverPlayer(pm map[string]string) error {
|
||||||
|
// // check if room still exists
|
||||||
|
// room, err := repo.RoomGetByID(context.Background(), pm["RoomID"])
|
||||||
|
// if err != nil {
|
||||||
|
// return fmt.Errorf("no such room: %s; err: %w", pm["RoomID"], err)
|
||||||
|
// }
|
||||||
|
// log.Debug("recovering player", "player", pm)
|
||||||
|
// role, team, ok := room.GetPlayerByName(pm["Username"])
|
||||||
|
// if !ok {
|
||||||
|
// return fmt.Errorf("failed to find player %s in the room %v", pm["Username"], room)
|
||||||
|
// }
|
||||||
|
// us := &models.Player{
|
||||||
|
// Username: pm["Username"],
|
||||||
|
// RoomID: pm["RoomID"],
|
||||||
|
// Team: team,
|
||||||
|
// Role: role,
|
||||||
|
// }
|
||||||
|
// return saveState(pm["Username"], us)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// validateMove checks if it is players turn
|
||||||
|
func validateMove(fi *models.FullInfo, ur models.UserRole) error {
|
||||||
|
if fi.State.Role != ur {
|
||||||
|
err := fmt.Errorf("need to be %s to make that action", ur)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// whos move it is?
|
||||||
|
if fi.State.Team != models.UserTeam(fi.Room.TeamTurn) {
|
||||||
|
err := errors.New("not your team's move")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch ur {
|
||||||
|
case models.UserRoleGuesser:
|
||||||
|
if !fi.Room.MimeDone {
|
||||||
|
err := errors.New("wait for the mime to give a clue")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case models.UserRoleMime:
|
||||||
|
if fi.Room.MimeDone {
|
||||||
|
err := errors.New("clue was already given")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("uknown user role: %s", ur)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
172
handlers/auth.go
172
handlers/auth.go
@ -1,25 +1,34 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"fmt"
|
||||||
"golias/models"
|
"gralias/models"
|
||||||
"golias/utils"
|
"gralias/utils"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func abortWithError(w http.ResponseWriter, msg string) {
|
func abortWithError(w http.ResponseWriter, msg string) {
|
||||||
|
w.WriteHeader(200) // must be 200 for htmx to replace components
|
||||||
tmpl := template.Must(template.ParseGlob("components/*.html"))
|
tmpl := template.Must(template.ParseGlob("components/*.html"))
|
||||||
tmpl.ExecuteTemplate(w, "error", msg)
|
if err := tmpl.ExecuteTemplate(w, "error", msg); err != nil {
|
||||||
|
log.Error("failed to execute error template", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
r.ParseForm()
|
if err := r.ParseForm(); err != nil {
|
||||||
|
log.Error("failed to parse form", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
username := r.PostFormValue("username")
|
username := r.PostFormValue("username")
|
||||||
if username == "" {
|
if username == "" {
|
||||||
msg := "username not provided"
|
msg := "username not provided"
|
||||||
@ -27,9 +36,48 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
abortWithError(w, msg)
|
abortWithError(w, msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
cleanName := utils.RemoveSpacesFromStr(username)
|
||||||
|
// allNames := getAllNames()
|
||||||
|
allNames, err := repo.PlayerListNames(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("names check", "taken_names", allNames, "trying_name", cleanName)
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if utils.StrInSlice(cleanName, allNames) {
|
||||||
|
err := fmt.Errorf("name: %s already taken", cleanName)
|
||||||
|
log.Warn("already taken", "error", err)
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "namecheck", 2); err != nil {
|
||||||
|
log.Error("failed to execute namecheck template", "error", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "namecheck", 0); err != nil {
|
||||||
|
log.Error("failed to execute namecheck template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := r.PostFormValue("username")
|
||||||
|
if username == "" {
|
||||||
|
msg := "username not provided"
|
||||||
|
log.Error(msg)
|
||||||
|
abortWithError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var makeplayer bool
|
||||||
|
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)
|
||||||
// TODO: create user in db
|
|
||||||
// login user
|
// login user
|
||||||
cookie, err := makeCookie(cleanName, r.RemoteAddr)
|
cookie, err := makeCookie(cleanName, r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -38,24 +86,76 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
tmpl, err := template.ParseGlob("components/*.html")
|
// check if that user was already in db
|
||||||
if err != nil {
|
// userstate, err := loadState(cleanName)
|
||||||
abortWithError(w, err.Error())
|
userstate, err := repo.PlayerGetByName(r.Context(), cleanName)
|
||||||
return
|
if err != nil || userstate == nil {
|
||||||
|
userstate = models.InitPlayer(cleanName)
|
||||||
|
makeplayer = true
|
||||||
}
|
}
|
||||||
tmpl.ExecuteTemplate(w, "main", nil)
|
fi := &models.FullInfo{
|
||||||
|
State: userstate,
|
||||||
|
}
|
||||||
|
// check if room_id provided and exists
|
||||||
|
if roomID != "" {
|
||||||
|
log.Debug("got room_id in login", "room_id", roomID)
|
||||||
|
// room, err := getRoomByID(roomID)
|
||||||
|
room, err := repo.RoomGetByID(r.Context(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// room.PlayerList = append(room.PlayerList, fi.State.Username)
|
||||||
|
fi.Room = room
|
||||||
|
fi.List = nil
|
||||||
|
fi.State.RoomID = &room.ID
|
||||||
|
if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// repo.RoomUpdate()
|
||||||
|
// save full info instead
|
||||||
|
// if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
|
// abortWithError(w, err.Error())
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
} else {
|
||||||
|
log.Debug("no room_id in login")
|
||||||
|
// fi.List = listRooms(false)
|
||||||
|
fi.List, err = repo.RoomList(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// save state to cache
|
||||||
|
// if err := saveState(cleanName, userstate); err != nil {
|
||||||
|
if makeplayer {
|
||||||
|
if err := repo.PlayerAdd(r.Context(), userstate); err != nil {
|
||||||
|
// if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
|
log.Error("failed to save state", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||||
|
// log.Error("failed to execute base template", "error", err)
|
||||||
|
// }
|
||||||
|
http.Redirect(w, r, "/", 302)
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCookie(username string, remote string) (*http.Cookie, error) {
|
func makeCookie(username string, remote string) (*http.Cookie, error) {
|
||||||
// secret
|
// secret
|
||||||
// Create a new random session token
|
// Create a new random session token
|
||||||
// sessionToken := xid.New().String()
|
// sessionToken := xid.New().String()
|
||||||
sessionToken := "token"
|
sessionToken := "sessionprefix_" + username
|
||||||
expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second)
|
// expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second)
|
||||||
// Set the token in the session map, along with the session information
|
// Set the token in the session map, along with the session information
|
||||||
session := &models.Session{
|
session := &models.Session{
|
||||||
Username: username,
|
Username: username,
|
||||||
Expiry: expiresAt,
|
TokenKey: sessionToken,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Lifetime: uint32(cfg.SessionLifetime / 60),
|
||||||
}
|
}
|
||||||
cookieName := "session_token"
|
cookieName := "session_token"
|
||||||
// hmac to protect cookies
|
// hmac to protect cookies
|
||||||
@ -72,43 +172,23 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
|
|||||||
Secure: true,
|
Secure: true,
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteNoneMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
Domain: cfg.ServerConfig.Host,
|
|
||||||
}
|
}
|
||||||
log.Info("check remote addr for cookie set",
|
log.Info("check remote addr for cookie set",
|
||||||
"remote", remote, "session", session)
|
"remote", remote, "session", session)
|
||||||
if strings.Contains(remote, "192.168.0") {
|
if strings.Contains(remote, "192.168.0") {
|
||||||
// no idea what is going on
|
cookie.Domain = "192.168.0.100"
|
||||||
// cookie.Domain = "192.168.0.15"
|
cookie.SameSite = http.SameSiteLaxMode
|
||||||
cookie.Domain = "home.host"
|
cookie.Secure = false
|
||||||
log.Info("changing cookie domain", "domain", cookie.Domain)
|
log.Info("changing cookie domain", "domain", cookie.Domain)
|
||||||
}
|
}
|
||||||
// set ctx?
|
// make player first, since username is fk to players table
|
||||||
// set user in session
|
player := models.InitPlayer(username)
|
||||||
if err := cacheSetSession(sessionToken, session); err != nil {
|
if err := repo.PlayerAdd(context.Background(), player); err != nil {
|
||||||
|
slog.Error("failed to create player", "username", username)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := repo.SessionCreate(context.Background(), session); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return cookie, nil
|
return cookie, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cacheGetSession(key string) (*models.Session, error) {
|
|
||||||
userSessionB, err := memcache.Get(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var us *models.Session
|
|
||||||
if err := json.Unmarshal(userSessionB, &us); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return us, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cacheSetSession(key string, session *models.Session) error {
|
|
||||||
sesb, err := json.Marshal(session)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
memcache.Set(key, sesb)
|
|
||||||
// expire in 10 min
|
|
||||||
memcache.Expire(key, 10*60)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
299
handlers/elements.go
Normal file
299
handlers/elements.go
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"gralias/llmapi"
|
||||||
|
"gralias/models"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleShowCreateForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
show := true
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "createform", show); err != nil {
|
||||||
|
log.Error("failed to execute createform template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleHideCreateForm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
show := false
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "createform", show); err != nil {
|
||||||
|
log.Error("failed to execute createform template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleShowColor(w http.ResponseWriter, r *http.Request) {
|
||||||
|
word := r.URL.Query().Get("word")
|
||||||
|
ctx := r.Context()
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err := getFullInfoByCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// color, exists := fi.Room.WCMap[word]
|
||||||
|
color, exists := fi.Room.FindColor(word)
|
||||||
|
if !exists {
|
||||||
|
abortWithError(w, "word is not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cardword := models.WordCard{
|
||||||
|
Word: word,
|
||||||
|
Color: color,
|
||||||
|
Revealed: true,
|
||||||
|
}
|
||||||
|
revCardID := fi.Room.RevealSpecificWord(word)
|
||||||
|
if revCardID == 0 {
|
||||||
|
// error
|
||||||
|
abortWithError(w, "word has 0 id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := repo.WordCardReveal(r.Context(), word, fi.Room.ID); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi.Room.UpdateCounter()
|
||||||
|
action := models.Action{
|
||||||
|
Actor: fi.State.Username,
|
||||||
|
ActorColor: string(fi.State.Team),
|
||||||
|
WordColor: string(color),
|
||||||
|
Action: models.ActionTypeGuess,
|
||||||
|
Word: word,
|
||||||
|
RoomID: fi.Room.ID,
|
||||||
|
}
|
||||||
|
if err := repo.ActionCreate(r.Context(), &action); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
|
// if opened card is of color of opp team, change turn
|
||||||
|
oppositeColor := fi.Room.GetOppositeTeamColor()
|
||||||
|
fi.Room.OpenedThisTurn++
|
||||||
|
log.Debug("got show-color request", "word", word, "color", color,
|
||||||
|
"limit", fi.Room.ThisTurnLimit, "opened", fi.Room.OpenedThisTurn,
|
||||||
|
"team-turn", fi.Room.TeamTurn, "opposite-color", oppositeColor)
|
||||||
|
if fi.Room.OpenedThisTurn >= fi.Room.ThisTurnLimit {
|
||||||
|
log.Debug("reached limit", "room", fi.Room)
|
||||||
|
// end turn
|
||||||
|
fi.Room.TeamTurn = oppositeColor
|
||||||
|
fi.Room.MimeDone = false
|
||||||
|
fi.Room.OpenedThisTurn = 0
|
||||||
|
fi.Room.ThisTurnLimit = 0
|
||||||
|
fi.Room.ClearMarks()
|
||||||
|
StopTurnTimer(fi.Room.ID)
|
||||||
|
}
|
||||||
|
switch string(color) {
|
||||||
|
case string(models.WordColorBlack):
|
||||||
|
log.Debug("opened black word", "room", fi.Room)
|
||||||
|
// game over
|
||||||
|
fi.Room.IsRunning = false
|
||||||
|
fi.Room.IsOver = true
|
||||||
|
fi.Room.TeamWon = oppositeColor
|
||||||
|
action := models.Action{
|
||||||
|
Actor: fi.State.Username,
|
||||||
|
ActorColor: string(fi.State.Team),
|
||||||
|
WordColor: models.WordColorBlack,
|
||||||
|
Action: models.ActionTypeGameOver,
|
||||||
|
}
|
||||||
|
fi.Room.OpenedThisTurn = 0
|
||||||
|
fi.Room.ThisTurnLimit = 0
|
||||||
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
|
fi.Room.ClearMarks()
|
||||||
|
StopTurnTimer(fi.Room.ID)
|
||||||
|
case string(models.WordColorWhite), string(oppositeColor):
|
||||||
|
log.Debug("opened opposite color word", "room", fi.Room, "opposite-color", oppositeColor)
|
||||||
|
// end turn
|
||||||
|
fi.Room.TeamTurn = oppositeColor
|
||||||
|
fi.Room.MimeDone = false
|
||||||
|
fi.Room.OpenedThisTurn = 0
|
||||||
|
fi.Room.ThisTurnLimit = 0
|
||||||
|
StopTurnTimer(fi.Room.ID)
|
||||||
|
// check if no cards left => game over
|
||||||
|
if fi.Room.BlueCounter == 0 {
|
||||||
|
// blue won
|
||||||
|
fi.Room.IsRunning = false
|
||||||
|
fi.Room.IsOver = true
|
||||||
|
fi.Room.TeamWon = "blue"
|
||||||
|
action := models.Action{
|
||||||
|
Actor: fi.State.Username,
|
||||||
|
ActorColor: string(fi.State.Team),
|
||||||
|
WordColor: models.WordColorBlue,
|
||||||
|
Action: models.ActionTypeGameOver,
|
||||||
|
}
|
||||||
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
|
fi.Room.ClearMarks()
|
||||||
|
}
|
||||||
|
if fi.Room.RedCounter == 0 {
|
||||||
|
// red won
|
||||||
|
fi.Room.IsRunning = false
|
||||||
|
fi.Room.IsOver = true
|
||||||
|
fi.Room.TeamWon = "red"
|
||||||
|
action := models.Action{
|
||||||
|
Actor: fi.State.Username,
|
||||||
|
ActorColor: string(fi.State.Team),
|
||||||
|
WordColor: models.WordColorRed,
|
||||||
|
Action: models.ActionTypeGameOver,
|
||||||
|
}
|
||||||
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
|
fi.Room.ClearMarks()
|
||||||
|
}
|
||||||
|
default: // same color as the team
|
||||||
|
// check if game over
|
||||||
|
if fi.Room.RedCounter == 0 || fi.Room.BlueCounter == 0 {
|
||||||
|
fi.Room.IsRunning = false
|
||||||
|
fi.Room.IsOver = true
|
||||||
|
fi.Room.TeamWon = fi.State.Team
|
||||||
|
action := models.Action{
|
||||||
|
Actor: fi.State.Username,
|
||||||
|
ActorColor: string(fi.State.Team),
|
||||||
|
WordColor: models.WordColorRed,
|
||||||
|
Action: models.ActionTypeGameOver,
|
||||||
|
}
|
||||||
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
|
fi.Room.ClearMarks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// get mime bot for opp team and notify it
|
||||||
|
notifyBotIfNeeded(fi.Room)
|
||||||
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "cardword", cardword); err != nil {
|
||||||
|
log.Error("failed to execute cardword template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleMarkCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
word := r.URL.Query().Get("word")
|
||||||
|
ctx := r.Context()
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err := getFullInfoByCtx(ctx)
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateMove(fi, models.UserRoleGuesser); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// color, exists := fi.Room.WCMap[word]
|
||||||
|
color, exists := fi.Room.FindColor(word)
|
||||||
|
log.Debug("got mark-card request", "word", word, "color", color)
|
||||||
|
if !exists {
|
||||||
|
abortWithError(w, "word is not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cardword := models.WordCard{}
|
||||||
|
// check if card already was revealed
|
||||||
|
for i, card := range fi.Room.Cards {
|
||||||
|
if !strings.EqualFold(card.Word, word) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if card.Revealed {
|
||||||
|
abortWithError(w, "cannot mark already revealed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Check if the current user already has an active mark on this card
|
||||||
|
found := false
|
||||||
|
var newMarks []models.CardMark
|
||||||
|
for _, mark := range card.Mark {
|
||||||
|
if mark.Username == fi.State.Username && mark.Active {
|
||||||
|
found = true
|
||||||
|
} else {
|
||||||
|
newMarks = append(newMarks, mark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
newMarks = append(newMarks, models.CardMark{
|
||||||
|
Username: fi.State.Username,
|
||||||
|
Active: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fi.Room.Cards[i].Mark = newMarks
|
||||||
|
cardword = fi.Room.Cards[i]
|
||||||
|
}
|
||||||
|
if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "cardword", cardword); err != nil {
|
||||||
|
log.Error("failed to execute cardword template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleActionHistory(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, "actionhistory", fi.Room.ActionHistory); err != nil {
|
||||||
|
log.Error("failed to execute actionhistory template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleAddBot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// get team; // get role; make up a name
|
||||||
|
team := r.URL.Query().Get("team")
|
||||||
|
role := r.URL.Query().Get("role")
|
||||||
|
log.Debug("got add-bot request", "team", team, "role", role)
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
botname := fmt.Sprintf("bot_%d", len(llmapi.SignalChanMap)+1) // what if many rooms?
|
||||||
|
_, err = llmapi.NewBot(role, team, botname, fi.Room.ID, cfg, false)
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// go bot.StartBot()
|
||||||
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRemoveBot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
botName := r.URL.Query().Get("bot")
|
||||||
|
log.Debug("got remove-bot request", "bot_name", botName)
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := llmapi.RemoveBot(botName, fi.Room); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
|
}
|
357
handlers/game.go
Normal file
357
handlers/game.go
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"gralias/models"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
turnTimeStr := r.PostFormValue("game_time")
|
||||||
|
ttU64, err := strconv.ParseUint(turnTimeStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("failed to parse turn time", "game_time", turnTimeStr)
|
||||||
|
}
|
||||||
|
// parse payload
|
||||||
|
payload := &models.RoomReq{
|
||||||
|
RoomPass: r.PostFormValue("room_pass"),
|
||||||
|
Language: r.PostFormValue("language"),
|
||||||
|
RoundTime: uint32(ttU64),
|
||||||
|
}
|
||||||
|
// create a room
|
||||||
|
room, err := createRoom(r.Context(), payload)
|
||||||
|
if err != nil {
|
||||||
|
msg := "failed to create a room"
|
||||||
|
log.Error(msg, "error", err)
|
||||||
|
abortWithError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
msg := "failed to get full info from ctx"
|
||||||
|
log.Error(msg, "error", err)
|
||||||
|
abortWithError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi.State.RoomID = &room.ID
|
||||||
|
fi.Room = room
|
||||||
|
if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
|
||||||
|
log.Error("failed to set room id", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify(models.NotifyRoomListUpdate, "")
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||||
|
log.Error("failed to execute base template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleJoinTeam(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
log.Error("failed to parse form", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
team := r.PostFormValue("team")
|
||||||
|
role := r.PostFormValue("role")
|
||||||
|
if team == "" || role == "" {
|
||||||
|
msg := "missing team or role"
|
||||||
|
log.Error(msg)
|
||||||
|
abortWithError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// get username
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fi.Room.IsRunning && role == "mime" {
|
||||||
|
err = errors.New("cannot join as mime when game is running")
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err = joinTeam(r.Context(), role, team)
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// reveal all cards
|
||||||
|
if fi.State.Role == "mime" {
|
||||||
|
fi.Room.MimeView() // there must be a better way
|
||||||
|
} else {
|
||||||
|
fi.Room.GuesserView()
|
||||||
|
}
|
||||||
|
// return html
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||||
|
log.Error("failed to execute base template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleEndTurn(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// get username
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// check if one who pressed it is from the team who has the turn
|
||||||
|
if fi.Room.TeamTurn != fi.State.Team {
|
||||||
|
msg := fmt.Sprintln("unexpected team turn:" + fi.Room.TeamTurn)
|
||||||
|
abortWithError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi.Room.ChangeTurn()
|
||||||
|
fi.Room.MimeDone = false
|
||||||
|
StopTurnTimer(fi.Room.ID)
|
||||||
|
if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// return html
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyBotIfNeeded(fi.Room)
|
||||||
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||||
|
log.Error("failed to execute base template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleStartGame(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// check if enough players
|
||||||
|
if err := fi.Room.CanStart(); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Initialize transaction
|
||||||
|
ctx, tx, err := repo.InitTx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to init transaction", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
log.Error("failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
panic(r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fi.Room.IsRunning = true
|
||||||
|
fi.Room.IsOver = false
|
||||||
|
fi.Room.TeamTurn = "blue"
|
||||||
|
fi.Room.OpenedThisTurn = 0
|
||||||
|
fi.Room.ThisTurnLimit = 0
|
||||||
|
loadCards(fi.Room)
|
||||||
|
fi.Room.UpdateCounter()
|
||||||
|
fi.Room.TeamWon = ""
|
||||||
|
action := models.Action{
|
||||||
|
Actor: fi.State.Username,
|
||||||
|
ActorColor: string(fi.State.Team),
|
||||||
|
WordColor: string(fi.State.Team),
|
||||||
|
Action: models.ActionTypeGameStarted,
|
||||||
|
}
|
||||||
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
|
// Use the new context with transaction
|
||||||
|
if err := saveFullInfo(ctx, fi); err != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
log.Error("failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Save action history
|
||||||
|
action.RoomID = fi.Room.ID
|
||||||
|
action.CreatedAt = time.Now()
|
||||||
|
if err := repo.ActionCreate(ctx, &action); err != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
log.Error("failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
log.Error("failed to save action", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Save word cards
|
||||||
|
for _, card := range fi.Room.Cards {
|
||||||
|
card.RoomID = fi.Room.ID // Ensure RoomID is set for each card
|
||||||
|
if err := repo.WordCardsCreate(ctx, &card); err != nil {
|
||||||
|
if err := tx.Rollback(); err != nil {
|
||||||
|
log.Error("failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
log.Error("failed to save word card", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit the transaction
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
log.Error("failed to commit transaction", "error", err)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// reveal all cards
|
||||||
|
if fi.State.Role == "mime" {
|
||||||
|
fi.Room.MimeView()
|
||||||
|
} else {
|
||||||
|
fi.Room.GuesserView()
|
||||||
|
}
|
||||||
|
// return html
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyBotIfNeeded(fi.Room)
|
||||||
|
// to update only the room that should be updated
|
||||||
|
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||||
|
// notify(models.NotifyBacklogPrefix+fi.Room.ID, "game started")
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
|
||||||
|
log.Error("failed to execute room template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleJoinRoom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
roomID := r.URL.Query().Get("id")
|
||||||
|
room, err := repo.RoomGetExtended(r.Context(), roomID)
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
// INFO: if non-loggined user join: prompt to login
|
||||||
|
fi = &models.FullInfo{}
|
||||||
|
fi.LinkLogin = roomID
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||||
|
log.Error("failed to execute base template", "error", err)
|
||||||
|
}
|
||||||
|
// abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// room.PlayerList = append(room.PlayerList, fi.State.Username)
|
||||||
|
fi.State.RoomID = &room.ID
|
||||||
|
fi.Room = room
|
||||||
|
fi.List = nil
|
||||||
|
if err := repo.PlayerSetRoomID(r.Context(), room.ID, fi.State.Username); err != nil {
|
||||||
|
log.Error("failed to set room_id for player", "error", err, "username", fi.State.Username, "room_id", room.ID)
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "room", fi); err != nil {
|
||||||
|
log.Error("failed to execute room template", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGiveClue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clue := r.PostFormValue("clue")
|
||||||
|
num := r.PostFormValue("number")
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guessLimitU64, err := strconv.ParseUint(num, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// validations ===
|
||||||
|
if fi.State.Team != models.UserTeam(fi.Room.TeamTurn) {
|
||||||
|
err = errors.New("not your team's move")
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fi.State.Role != "mime" {
|
||||||
|
err = errors.New("need to be mime to open the card")
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fi.Room.MimeDone {
|
||||||
|
// team already have a clue
|
||||||
|
abortWithError(w, "your team already has a clue")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// check if the clue is the same as one of the existing words
|
||||||
|
for _, card := range fi.Room.Cards {
|
||||||
|
if strings.EqualFold(card.Word, clue) {
|
||||||
|
msg := fmt.Sprintf("cannot use existing word (%s) as a clue", clue)
|
||||||
|
abortWithError(w, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ===
|
||||||
|
action := models.Action{
|
||||||
|
RoomID: fi.Room.ID,
|
||||||
|
Actor: fi.State.Username,
|
||||||
|
ActorColor: string(fi.State.Team),
|
||||||
|
WordColor: string(fi.State.Team),
|
||||||
|
Action: models.ActionTypeClue,
|
||||||
|
Word: clue,
|
||||||
|
Number: num,
|
||||||
|
}
|
||||||
|
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||||
|
if err := repo.ActionCreate(r.Context(), &action); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi.Room.MimeDone = true
|
||||||
|
fi.Room.ThisTurnLimit = uint8(guessLimitU64) + 1
|
||||||
|
if guessLimitU64 == 0 {
|
||||||
|
fi.Room.ThisTurnLimit = 9
|
||||||
|
}
|
||||||
|
fi.Room.OpenedThisTurn = 0
|
||||||
|
StartTurnTimer(fi.Room.ID, fi.Room.Settings.RoundTime)
|
||||||
|
log.Debug("given clue", "clue", clue, "limit", fi.Room.ThisTurnLimit)
|
||||||
|
notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
|
||||||
|
notifyBotIfNeeded(fi.Room)
|
||||||
|
if err := saveFullInfo(r.Context(), fi); err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRenotifyBot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notifyBotIfNeeded(fi.Room)
|
||||||
|
}
|
@ -1,31 +1,45 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"gralias/broker"
|
||||||
|
"gralias/config"
|
||||||
|
"gralias/models"
|
||||||
|
"gralias/repos"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var log *slog.Logger
|
var (
|
||||||
|
log *slog.Logger
|
||||||
|
cfg *config.Config
|
||||||
|
// memcache cache.Cache
|
||||||
|
Notifier *broker.Broker
|
||||||
|
repo repos.AllRepos
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
log = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
Level: slog.LevelDebug,
|
Level: slog.LevelDebug,
|
||||||
AddSource: true,
|
AddSource: true,
|
||||||
}))
|
}))
|
||||||
}
|
// memcache = cache.MemCache
|
||||||
|
cfg = config.LoadConfigOrDefault("")
|
||||||
var roundWords = map[string]string{
|
Notifier = broker.Notifier
|
||||||
"hamster": "blue",
|
// cache.MemCache.StartBackupRoutine(15 * time.Second) // Reduced backup interval
|
||||||
"child": "red",
|
// bot loader
|
||||||
"wheel": "white",
|
// check the rooms if it has bot_{digits} in them, create bots if have
|
||||||
"condition": "black",
|
repo = repos.NewRepoProvider("sqlite3://../gralias.db")
|
||||||
"test": "white",
|
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) {
|
||||||
w.Write([]byte("pong"))
|
if _, err := w.Write([]byte("pong")); err != nil {
|
||||||
|
log.Error("failed to write ping response", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleHome(w http.ResponseWriter, r *http.Request) {
|
func HandleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -34,5 +48,83 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
|
|||||||
abortWithError(w, err.Error())
|
abortWithError(w, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tmpl.ExecuteTemplate(w, "main", roundWords)
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to fetch fi", "error", err)
|
||||||
|
}
|
||||||
|
// there must be a better way
|
||||||
|
if fi != nil && fi.Room != nil && fi.Room.ID != "" && fi.State != nil {
|
||||||
|
fi.Room.UpdateCounter()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||||
|
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleExit(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tmpl, err := template.ParseGlob("components/*.html")
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi, err := getFullInfoByCtx(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if fi.Room.IsRunning {
|
||||||
|
abortWithError(w, "cannot leave when game is running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var creatorLeft bool
|
||||||
|
if fi.Room.CreatorName == fi.State.Username {
|
||||||
|
creatorLeft = true
|
||||||
|
}
|
||||||
|
exitedRoom := fi.ExitRoom()
|
||||||
|
// if err := saveRoom(exitedRoom); err != nil {
|
||||||
|
// abortWithError(w, err.Error())
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
if creatorLeft {
|
||||||
|
if err := repo.RoomDeleteByID(r.Context(), exitedRoom.ID); err != nil {
|
||||||
|
log.Error("failed to remove room", "error", err)
|
||||||
|
}
|
||||||
|
// removeRoom(exitedRoom.ID)
|
||||||
|
// TODO: notify users if creator left
|
||||||
|
// and throw them away
|
||||||
|
notify(models.NotifyRoomListUpdate, "")
|
||||||
|
}
|
||||||
|
// scary to update the whole room
|
||||||
|
fiToSave := &models.FullInfo{
|
||||||
|
Room: exitedRoom,
|
||||||
|
}
|
||||||
|
if err := 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())
|
||||||
|
if err != nil {
|
||||||
|
abortWithError(w, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||||
|
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,50 +5,28 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"gralias/models"
|
||||||
"golias/config"
|
|
||||||
"golias/pkg/cache"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
cfg config.Config
|
|
||||||
memcache cache.Cache
|
|
||||||
)
|
|
||||||
|
|
||||||
// responseWriterWrapper wraps http.ResponseWriter to capture status code
|
|
||||||
type responseWriterWrapper struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
status int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *responseWriterWrapper) WriteHeader(status int) {
|
|
||||||
w.status = status
|
|
||||||
w.ResponseWriter.WriteHeader(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LogRequests logs all HTTP requests with method, path and duration
|
// LogRequests logs all HTTP requests with method, path and duration
|
||||||
func LogRequests(next http.Handler) http.Handler {
|
func LogRequests(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
// start := time.Now()
|
||||||
// Wrap response writer to capture status code
|
// Wrap response writer to capture status code
|
||||||
ww := &responseWriterWrapper{ResponseWriter: w}
|
next.ServeHTTP(w, r)
|
||||||
next.ServeHTTP(ww, r)
|
// duration := time.Since(start)
|
||||||
duration := time.Since(start)
|
// log.Debug("request completed",
|
||||||
log.Debug("request completed",
|
// "method", r.Method,
|
||||||
"method", r.Method,
|
// "path", r.URL.RequestURI(),
|
||||||
"path", r.URL.Path,
|
// "duration", duration.String(),
|
||||||
"status", ww.status,
|
// )
|
||||||
"duration", duration.String(),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSession(next http.Handler) http.Handler {
|
func GetSession(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
cookieName := "session_token"
|
sessionCookie, err := r.Cookie(models.AuthCookie)
|
||||||
sessionCookie, err := r.Cookie(cookieName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := "auth failed; failed to get session token from cookies"
|
msg := "auth failed; failed to get session token from cookies"
|
||||||
log.Debug(msg, "error", err)
|
log.Debug(msg, "error", err)
|
||||||
@ -74,7 +52,7 @@ func GetSession(next http.Handler) http.Handler {
|
|||||||
sessionToken := cookieValue[sha256.Size:]
|
sessionToken := cookieValue[sha256.Size:]
|
||||||
//verify signature
|
//verify signature
|
||||||
mac := hmac.New(sha256.New, []byte(cfg.CookieSecret))
|
mac := hmac.New(sha256.New, []byte(cfg.CookieSecret))
|
||||||
mac.Write([]byte(cookieName))
|
mac.Write([]byte(models.AuthCookie))
|
||||||
mac.Write([]byte(sessionToken))
|
mac.Write([]byte(sessionToken))
|
||||||
expectedSignature := mac.Sum(nil)
|
expectedSignature := mac.Sum(nil)
|
||||||
if !hmac.Equal([]byte(signature), expectedSignature) {
|
if !hmac.Equal([]byte(signature), expectedSignature) {
|
||||||
@ -82,30 +60,36 @@ func GetSession(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userSession, err := cacheGetSession(sessionToken)
|
userSession, err := repo.SessionByToken(r.Context(), sessionToken)
|
||||||
|
// userSession, err := cacheGetSession(sessionToken)
|
||||||
|
// log.Debug("userSession from cache", "us", userSession)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := "auth failed; session does not exists"
|
msg := "auth failed; session does not exists"
|
||||||
err = errors.New(msg)
|
log.Debug(msg, "error", err, "key", sessionToken)
|
||||||
log.Debug(msg, "error", err)
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if userSession.IsExpired() {
|
if userSession.IsExpired() {
|
||||||
memcache.RemoveKey(sessionToken)
|
if err := repo.SessionDelete(r.Context(), sessionToken); err != nil {
|
||||||
|
log.Error("failed to delete session", "error", err)
|
||||||
|
}
|
||||||
|
// cache.MemCache.RemoveKey(sessionToken)
|
||||||
msg := "session is expired"
|
msg := "session is expired"
|
||||||
log.Debug(msg, "error", err, "token", sessionToken)
|
log.Debug(msg, "error", err, "token", sessionToken)
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(),
|
ctx := context.WithValue(r.Context(),
|
||||||
"username", userSession.Username)
|
models.CtxUsernameKey, userSession.Username)
|
||||||
if err := cacheSetSession(sessionToken,
|
ctx = context.WithValue(ctx,
|
||||||
userSession); err != nil {
|
models.CtxSessionKey, userSession)
|
||||||
msg := "failed to marshal user session"
|
// if err := cacheSetSession(sessionToken,
|
||||||
log.Warn(msg, "error", err)
|
// userSession); err != nil {
|
||||||
next.ServeHTTP(w, r)
|
// msg := "failed to marshal user session"
|
||||||
return
|
// log.Warn(msg, "error", err)
|
||||||
}
|
// next.ServeHTTP(w, r)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
0
handlers/sqlite:gralias.db
Normal file
0
handlers/sqlite:gralias.db
Normal file
71
handlers/timer.go
Normal file
71
handlers/timer.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"log/slog"
|
||||||
|
"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) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if _, exists := timers[roomID]; exists {
|
||||||
|
slog.Debug("trying to launch already running timer", "room_id", roomID)
|
||||||
|
return // Timer already running
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(1 * time.Second)
|
||||||
|
done := make(chan bool)
|
||||||
|
timers[roomID] = &roomTimer{ticker: ticker, done: done}
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func StopTurnTimer(roomID string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if timer, exists := timers[roomID]; exists {
|
||||||
|
timer.ticker.Stop()
|
||||||
|
close(timer.done)
|
||||||
|
delete(timers, roomID)
|
||||||
|
}
|
||||||
|
}
|
547
llmapi/main.go
Normal file
547
llmapi/main.go
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
package llmapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"gralias/broker"
|
||||||
|
"gralias/config"
|
||||||
|
"gralias/models"
|
||||||
|
"gralias/repos"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// botname -> channel
|
||||||
|
repo = repos.NewRepoProvider("sqlite3://../gralias.db")
|
||||||
|
SignalChanMap = make(map[string]chan bool)
|
||||||
|
DoneChanMap = make(map[string]chan bool)
|
||||||
|
// got prompt: control character (\\u0000-\\u001F) found while parsing a string at line 4 column 0
|
||||||
|
MimePrompt = `we are playing alias;\nyou are a mime (player who gives a clue of one noun word and number of cards you expect them to open) of the %s team (people who would guess by your clue want open the %s cards);\nplease return your clue, number of cards to open and what words you mean them to find using that clue in json like:\n{\n\"clue\": \"one-word-noun\",\n\"number\": \"number-from-0-to-9\",\n\"words_I_mean_my_team_to_open\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the game info in json:\n%s`
|
||||||
|
GuesserPrompt = `we are playing alias;\nyou are to guess words of the %s team (you want open %s cards) by given clue and a number of meant guesses;\nplease return your guesses and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guesses\": [\"word1\", \"word2\", ...],\n\"could_be\": [\"this\", \"that\", ...]\n}\nthe team who openes all their cards first wins.\nplease return json only.\nunopen Blue cards left: %d;\nunopen Red cards left: %d;\nhere is the cards (and other info), you need to choose revealed==false words:\n%s`
|
||||||
|
GuesserSimplePrompt = `we are playing game of alias;\n you were given a clue: \"%s\";\nplease return your guess and words that could be meant by the clue, but you do not wish to open yet, in json like:\n{\n\"guess\": \"most_relevant_word_to_the_clue\",\n\"could_be\": [\"this\", \"that\", ...]\n}\nhere is the words that left:\n%v`
|
||||||
|
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;`
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertToSliceOfStrings(value any) ([]string, error) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []string:
|
||||||
|
// Directly return if it's already []string
|
||||||
|
return v, nil
|
||||||
|
case []interface{}:
|
||||||
|
// Convert each element to string
|
||||||
|
result := make([]string, len(v))
|
||||||
|
for i, item := range v {
|
||||||
|
str, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("element at index %d is not a string (got %T)", i, item)
|
||||||
|
}
|
||||||
|
result[i] = str
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported type: %T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nolint: unused
|
||||||
|
func (b *Bot) checkGuesses(tempMap map[string]any, room *models.Room) error {
|
||||||
|
guesses, err := convertToSliceOfStrings(tempMap["guesses"])
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("failed to parse bot given guesses", "mimeResp", tempMap, "bot_name", b.BotName)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, word := range guesses {
|
||||||
|
if err := b.checkGuess(word, room); err != nil {
|
||||||
|
// log error
|
||||||
|
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", word)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) checkGuess(word string, room *models.Room) error {
|
||||||
|
// color, exists := room.WCMap[word]
|
||||||
|
color, exists := room.FindColor(word)
|
||||||
|
b.log.Debug("bot trying to open card", "word", word, "color",
|
||||||
|
color, "exists", exists, "limit", room.ThisTurnLimit,
|
||||||
|
"opened", room.OpenedThisTurn)
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("fn: checkGuess; %s does not exists", word)
|
||||||
|
}
|
||||||
|
room.RevealSpecificWord(word)
|
||||||
|
room.UpdateCounter()
|
||||||
|
action := models.Action{
|
||||||
|
Actor: b.BotName,
|
||||||
|
ActorColor: b.Team,
|
||||||
|
WordColor: string(color),
|
||||||
|
Action: models.ActionTypeGuess,
|
||||||
|
Word: word,
|
||||||
|
}
|
||||||
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
// if opened card is of color of opp team, change turn
|
||||||
|
oppositeColor := room.GetOppositeTeamColor()
|
||||||
|
room.OpenedThisTurn++
|
||||||
|
if room.OpenedThisTurn >= room.ThisTurnLimit {
|
||||||
|
b.log.Debug("ending turn on limit", "word", word, "color",
|
||||||
|
color, "exists", exists, "limit", room.ThisTurnLimit,
|
||||||
|
"opened", room.OpenedThisTurn)
|
||||||
|
// end turn
|
||||||
|
room.TeamTurn = oppositeColor
|
||||||
|
room.MimeDone = false
|
||||||
|
room.OpenedThisTurn = 0
|
||||||
|
room.ThisTurnLimit = 0
|
||||||
|
}
|
||||||
|
switch string(color) {
|
||||||
|
case string(models.WordColorBlack):
|
||||||
|
// game over
|
||||||
|
room.IsRunning = false
|
||||||
|
room.IsOver = true
|
||||||
|
room.TeamWon = oppositeColor
|
||||||
|
room.OpenedThisTurn = 0
|
||||||
|
room.ThisTurnLimit = 0
|
||||||
|
action := models.Action{
|
||||||
|
Actor: b.BotName,
|
||||||
|
ActorColor: string(b.Team),
|
||||||
|
WordColor: models.WordColorBlack,
|
||||||
|
Action: models.ActionTypeGameOver,
|
||||||
|
}
|
||||||
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
case string(models.WordColorWhite), string(oppositeColor):
|
||||||
|
// end turn
|
||||||
|
room.TeamTurn = oppositeColor
|
||||||
|
room.MimeDone = false
|
||||||
|
room.OpenedThisTurn = 0
|
||||||
|
room.ThisTurnLimit = 0
|
||||||
|
}
|
||||||
|
// check if no cards left => game over
|
||||||
|
if room.BlueCounter == 0 {
|
||||||
|
// blue won
|
||||||
|
room.IsRunning = false
|
||||||
|
room.IsOver = true
|
||||||
|
room.TeamWon = "blue"
|
||||||
|
room.OpenedThisTurn = 0
|
||||||
|
room.ThisTurnLimit = 0
|
||||||
|
action := models.Action{
|
||||||
|
Actor: b.BotName,
|
||||||
|
ActorColor: string(b.Team),
|
||||||
|
WordColor: models.WordColorBlack,
|
||||||
|
Action: models.ActionTypeGameOver,
|
||||||
|
}
|
||||||
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
}
|
||||||
|
if room.RedCounter == 0 {
|
||||||
|
// red won
|
||||||
|
room.IsRunning = false
|
||||||
|
room.IsOver = true
|
||||||
|
room.TeamWon = "red"
|
||||||
|
room.OpenedThisTurn = 0
|
||||||
|
room.ThisTurnLimit = 0
|
||||||
|
action := models.Action{
|
||||||
|
Actor: b.BotName,
|
||||||
|
ActorColor: string(b.Team),
|
||||||
|
WordColor: models.WordColorBlack,
|
||||||
|
Action: models.ActionTypeGameOver,
|
||||||
|
}
|
||||||
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
}
|
||||||
|
if err := saveRoom(room); err != nil {
|
||||||
|
b.log.Error("failed to save room", "room", room)
|
||||||
|
err = fmt.Errorf("fn: checkGuess, failed to save room; err: %w", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) BotMove() {
|
||||||
|
// botJournalName := models.NotifyJournalPrefix + b.RoomID
|
||||||
|
b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role)
|
||||||
|
// get room cards and actions
|
||||||
|
// room, err := getRoomByID(b.RoomID)
|
||||||
|
room, err := repo.RoomGetExtended(context.Background(), b.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
b.log.Error("bot loop", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventName := models.NotifyBacklogPrefix + room.ID
|
||||||
|
eventPayload := ""
|
||||||
|
defer func() { // save room
|
||||||
|
if err := saveRoom(room); err != nil {
|
||||||
|
b.log.Error("failed to save room", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
broker.Notifier.Notifier <- broker.NotificationEvent{
|
||||||
|
EventName: eventName,
|
||||||
|
Payload: eventPayload,
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// form prompt
|
||||||
|
prompt := b.BuildPrompt(room)
|
||||||
|
b.log.Debug("got prompt", "prompt", prompt)
|
||||||
|
// call llm
|
||||||
|
llmResp, err := b.CallLLM(prompt)
|
||||||
|
if err != nil {
|
||||||
|
room.LogJournal = append(room.LogJournal, b.BotName+" send call got error: "+err.Error())
|
||||||
|
b.log.Error("bot loop", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tempMap, err := b.LLMParser.ParseBytes(llmResp)
|
||||||
|
if err != nil {
|
||||||
|
room.LogJournal = append(room.LogJournal, b.BotName+" parse resp got error: "+err.Error())
|
||||||
|
b.log.Error("bot loop", "error", err, "resp", string(llmResp))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch b.Role {
|
||||||
|
case models.UserRoleMime:
|
||||||
|
mimeResp := MimeResp{}
|
||||||
|
b.log.Info("mime resp log", "mimeResp", tempMap)
|
||||||
|
mimeResp.Clue = strings.ToLower(tempMap["clue"].(string))
|
||||||
|
var ok bool
|
||||||
|
mimeResp.Number, ok = tempMap["number"].(string)
|
||||||
|
if !ok {
|
||||||
|
b.log.Debug("failed to convert the clue number", "tesp", tempMap, "bot_name", b.BotName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
action := models.Action{
|
||||||
|
Actor: b.BotName,
|
||||||
|
ActorColor: b.Team,
|
||||||
|
WordColor: b.Team,
|
||||||
|
Action: models.ActionTypeClue,
|
||||||
|
Word: mimeResp.Clue,
|
||||||
|
Number: mimeResp.Number,
|
||||||
|
}
|
||||||
|
room.ActionHistory = append(room.ActionHistory, action)
|
||||||
|
room.MimeDone = true
|
||||||
|
meant := fmt.Sprintf(b.BotName+" meant to open: %v", tempMap["words_I_mean_my_team_to_open"])
|
||||||
|
room.LogJournal = append(room.LogJournal, meant)
|
||||||
|
eventPayload = mimeResp.Clue + mimeResp.Number
|
||||||
|
guessLimitU64, err := strconv.ParseUint(mimeResp.Number, 10, 8)
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("failed to parse bot given limit", "mimeResp", mimeResp, "bot_name", b.BotName)
|
||||||
|
}
|
||||||
|
room.ThisTurnLimit = uint8(guessLimitU64)
|
||||||
|
if room.ThisTurnLimit == 0 {
|
||||||
|
b.log.Warn("turn limit is 0", "mimeResp", mimeResp)
|
||||||
|
room.ThisTurnLimit = 9
|
||||||
|
}
|
||||||
|
if err := saveRoom(room); err != nil {
|
||||||
|
b.log.Error("failed to save room", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case models.UserRoleGuesser:
|
||||||
|
// // deprecated
|
||||||
|
// if err := b.checkGuesses(tempMap, room); err != nil {
|
||||||
|
// b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
guess, ok := tempMap["guess"].(string)
|
||||||
|
if !ok || guess == "" {
|
||||||
|
b.log.Warn("failed to parse guess", "mimeResp", tempMap, "bot_name", b.BotName)
|
||||||
|
}
|
||||||
|
if err := b.checkGuess(guess, room); err != nil {
|
||||||
|
b.log.Warn("failed to check guess", "mimeResp", tempMap, "bot_name", b.BotName, "guess", guess, "error", err)
|
||||||
|
msg := fmt.Sprintf("failed to check guess; mimeResp: %v; bot_name: %s; guess: %s; error: %v", tempMap, b.BotName, guess, err)
|
||||||
|
room.LogJournal = append(room.LogJournal, msg)
|
||||||
|
}
|
||||||
|
b.log.Info("guesser resp log", "guesserResp", tempMap)
|
||||||
|
couldBe, err := convertToSliceOfStrings(tempMap["could_be"])
|
||||||
|
if err != nil {
|
||||||
|
b.log.Warn("failed to parse could_be", "bot_resp", tempMap, "bot_name", b.BotName)
|
||||||
|
}
|
||||||
|
room.LogJournal = append(room.LogJournal, fmt.Sprintf("%s also considered this: %v", b.BotName, couldBe))
|
||||||
|
eventName = models.NotifyRoomUpdatePrefix + room.ID
|
||||||
|
eventPayload = ""
|
||||||
|
// TODO: needs to decide if it wants to open the next cardword or end turn
|
||||||
|
// or end turn on limit
|
||||||
|
default:
|
||||||
|
b.log.Error("unexpected role", "role", b.Role, "resp-map", tempMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if botName := room.WhichBotToMove(); botName != "" {
|
||||||
|
b.log.Debug("notifying bot", "name", botName)
|
||||||
|
SignalChanMap[botName] <- true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBot
|
||||||
|
func (b *Bot) StartBot() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-SignalChanMap[b.BotName]:
|
||||||
|
b.BotMove()
|
||||||
|
case <-DoneChanMap[b.BotName]:
|
||||||
|
b.log.Debug("got done signal", "bot-name", b.BotName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveBot(botName string, room *models.Room) error {
|
||||||
|
// channels
|
||||||
|
DoneChanMap[botName] <- true
|
||||||
|
close(DoneChanMap[botName])
|
||||||
|
close(SignalChanMap[botName])
|
||||||
|
// maps
|
||||||
|
delete(room.BotMap, botName)
|
||||||
|
delete(DoneChanMap, botName)
|
||||||
|
delete(SignalChanMap, botName)
|
||||||
|
// remove role from room
|
||||||
|
room.RemovePlayer(botName)
|
||||||
|
slog.Debug("removing bot player", "name", botName, "room_id", room.ID, "room", room)
|
||||||
|
if err := repo.PlayerDelete(context.Background(), room.ID, botName); err != nil {
|
||||||
|
slog.Error("failed to remove bot player", "name", botName, "room_id", room.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return saveRoom(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EndBot
|
||||||
|
|
||||||
|
func NewBot(role, team, name, roomID string, cfg *config.Config, recovery bool) (*Bot, error) {
|
||||||
|
bot := &Bot{
|
||||||
|
Role: role,
|
||||||
|
RoomID: roomID,
|
||||||
|
BotName: name,
|
||||||
|
Team: team,
|
||||||
|
cfg: cfg,
|
||||||
|
log: slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
AddSource: true,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
// there might be a better way
|
||||||
|
bot.LLMParser = NewLCPRespParser(bot.log)
|
||||||
|
if strings.Contains(cfg.LLMConfig.URL, "api.deepseek.com") {
|
||||||
|
bot.LLMParser = NewDeepSeekParser(bot.log)
|
||||||
|
} else if strings.Contains(cfg.LLMConfig.URL, "openrouter.ai") {
|
||||||
|
bot.LLMParser = NewOpenRouterParser(bot.log)
|
||||||
|
}
|
||||||
|
// add to room
|
||||||
|
// room, err := getRoomByID(bot.RoomID)
|
||||||
|
room, err := repo.RoomGetExtended(context.Background(), bot.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// check if not running
|
||||||
|
if role == "mime" && room.IsRunning && !recovery {
|
||||||
|
return nil, errors.New("cannot join after game started")
|
||||||
|
}
|
||||||
|
// room.PlayerList = append(room.PlayerList, name)
|
||||||
|
bp := models.BotPlayer{
|
||||||
|
Role: models.StrToUserRole(role),
|
||||||
|
Team: models.StrToUserTeam(team),
|
||||||
|
}
|
||||||
|
// check if already has the same team-role bot
|
||||||
|
// only one is allowed
|
||||||
|
for n, p := range room.BotMap {
|
||||||
|
if p.Role == bp.Role && p.Team == bp.Team && !recovery {
|
||||||
|
return nil, fmt.Errorf("already has such bot with name: %s", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room.BotMap[name] = bp
|
||||||
|
switch team {
|
||||||
|
case "red":
|
||||||
|
if role == "mime" {
|
||||||
|
room.RedTeam.Mime = name
|
||||||
|
} else if role == "guesser" {
|
||||||
|
room.RedTeam.Guessers = []string{name}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("uknown role: %s", role)
|
||||||
|
}
|
||||||
|
case "blue":
|
||||||
|
if role == "mime" {
|
||||||
|
room.BlueTeam.Mime = name
|
||||||
|
} else if role == "guesser" {
|
||||||
|
room.BlueTeam.Guessers = []string{name}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("uknown role: %s", role)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("uknown team: %s", team)
|
||||||
|
}
|
||||||
|
if err := saveRoom(room); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !recovery {
|
||||||
|
if err := saveBot(bot); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// buffered channel to send to it in the same goroutine
|
||||||
|
SignalChanMap[bot.BotName] = make(chan bool, 1)
|
||||||
|
DoneChanMap[bot.BotName] = make(chan bool, 1)
|
||||||
|
go bot.StartBot() // run bot routine
|
||||||
|
return bot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBot(bot *Bot) error {
|
||||||
|
// key := models.CacheBotPredix + bot.RoomID + bot.BotName
|
||||||
|
// data, err := json.Marshal(bot)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// // }
|
||||||
|
// cache.MemCache.Set(key, data)
|
||||||
|
botPlayer := bot.ToPlayer()
|
||||||
|
return repo.PlayerAdd(context.Background(), botPlayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveRoom(room *models.Room) error {
|
||||||
|
// key := models.CacheRoomPrefix + room.ID
|
||||||
|
// data, err := json.Marshal(room)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// cache.MemCache.Set(key, data)
|
||||||
|
// ------------
|
||||||
|
// probably need to update other tables
|
||||||
|
// like word_cards or marks;
|
||||||
|
return repo.RoomUpdate(context.Background(), room)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) BuildSimpleGuesserPrompt(room *models.Room) string {
|
||||||
|
// find not last action, but the clue
|
||||||
|
// clue := room.ActionHistory[len(room.ActionHistory)-1].Word
|
||||||
|
clueAction, err := room.FetchLastClue()
|
||||||
|
if err != nil {
|
||||||
|
b.log.Error("failed to fetch last clue", "error", err, "room", room, "bot_name", b.BotName)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// number := room.ActionHistory[len(room.ActionHistory)-1].Number
|
||||||
|
words := make([]string, len(room.Cards))
|
||||||
|
for i, card := range room.Cards {
|
||||||
|
if card.Revealed { // skipped already opened
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
words[i] = card.Word
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(GuesserSimplePrompt, clueAction.Word, words)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) BuildSimpleMimePrompt(room *models.Room) string {
|
||||||
|
ourwords := []string{}
|
||||||
|
theirwords := []string{}
|
||||||
|
blackWord := ""
|
||||||
|
for _, card := range room.Cards {
|
||||||
|
if card.Revealed { // skipped already opened
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch card.Color {
|
||||||
|
case models.WordColorBlack:
|
||||||
|
blackWord = card.Word
|
||||||
|
case models.WordColorBlue:
|
||||||
|
if b.Team == models.UserTeamBlue {
|
||||||
|
ourwords = append(ourwords, card.Word)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
theirwords = append(theirwords, card.Word)
|
||||||
|
case models.WordColorRed:
|
||||||
|
if b.Team == models.UserTeamRed {
|
||||||
|
ourwords = append(ourwords, card.Word)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
theirwords = append(theirwords, card.Word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(MimeSimplePrompt, ourwords, theirwords, blackWord, room.BlueCounter, room.RedCounter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) BuildPrompt(room *models.Room) string {
|
||||||
|
if b.Role == "" {
|
||||||
|
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 {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
if b.Role == models.UserRoleGuesser {
|
||||||
|
// return fmt.Sprintf(GuesserPrompt, b.Team, b.Team, room.BlueCounter, room.RedCounter, escapedData)
|
||||||
|
return b.BuildSimpleGuesserPrompt(room)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) CallLLM(prompt string) ([]byte, error) {
|
||||||
|
method := "POST"
|
||||||
|
// Generate the payload once as bytes
|
||||||
|
payloadReader := b.LLMParser.MakePayload(prompt)
|
||||||
|
client := &http.Client{}
|
||||||
|
maxRetries := 6
|
||||||
|
baseDelay := 2 // seconds
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
// Create a new request for the attempt
|
||||||
|
req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payloadReader)
|
||||||
|
if err != nil {
|
||||||
|
if attempt == maxRetries-1 {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
req.Header.Add("Accept", "application/json")
|
||||||
|
req.Header.Add("Authorization", "Bearer "+b.cfg.LLMConfig.TOKEN)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if attempt == maxRetries-1 {
|
||||||
|
return nil, fmt.Errorf("http request failed: %w", err)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
time.Sleep(delay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
if attempt == maxRetries-1 {
|
||||||
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
time.Sleep(delay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check status code
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
|
||||||
|
if attempt == maxRetries-1 {
|
||||||
|
return nil, fmt.Errorf("after %d retries, still got status %d", maxRetries, resp.StatusCode)
|
||||||
|
}
|
||||||
|
b.log.Warn("retriable status code; will retry", "code", resp.StatusCode, "attempt", attempt)
|
||||||
|
delay := time.Duration((baseDelay * (1 << attempt))) * time.Second
|
||||||
|
time.Sleep(delay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// For non-retriable errors, return immediately
|
||||||
|
return nil, fmt.Errorf("non-retriable status %d, body: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
// Success
|
||||||
|
b.log.Debug("llm resp", "body", string(body), "url", b.cfg.LLMConfig.URL, "attempt", attempt)
|
||||||
|
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")
|
||||||
|
}
|
97
llmapi/models.go
Normal file
97
llmapi/models.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package llmapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gralias/config"
|
||||||
|
"gralias/models"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpenRouterResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int `json:"created"`
|
||||||
|
Choices []struct {
|
||||||
|
Logprobs any `json:"logprobs"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
NativeFinishReason string `json:"native_finish_reason"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Refusal any `json:"refusal"`
|
||||||
|
Reasoning any `json:"reasoning"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DSResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Choices []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Created int `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
SystemFingerprint string `json:"system_fingerprint"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMResp struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Tokens []any `json:"tokens"`
|
||||||
|
IDSlot int `json:"id_slot"`
|
||||||
|
Stop bool `json:"stop"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
TokensPredicted int `json:"tokens_predicted"`
|
||||||
|
TokensEvaluated int `json:"tokens_evaluated"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
HasNewLine bool `json:"has_new_line"`
|
||||||
|
Truncated bool `json:"truncated"`
|
||||||
|
StopType string `json:"stop_type"`
|
||||||
|
StoppingWord string `json:"stopping_word"`
|
||||||
|
TokensCached int `json:"tokens_cached"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MimeResp struct {
|
||||||
|
Clue string `json:"clue"`
|
||||||
|
Number string `json:"number"`
|
||||||
|
Answer []string `json:"words_I_mean_my_team_to_open"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GusserResp struct {
|
||||||
|
Guesses []string `json:"guesses"`
|
||||||
|
CouldBe []string `json:"could_be"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Team string `json:"team"`
|
||||||
|
cfg *config.Config `json:"-"`
|
||||||
|
RoomID string `json:"room_id"` // can we get a room from here?
|
||||||
|
BotName string `json:"bot_name"`
|
||||||
|
log *slog.Logger `json:"-"`
|
||||||
|
LLMParser RespParser `json:"-"`
|
||||||
|
// channels for communicaton
|
||||||
|
// channels are not serializable
|
||||||
|
// SignalsCh chan bool
|
||||||
|
// DoneCh chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) ToPlayer() *models.Player {
|
||||||
|
return &models.Player{
|
||||||
|
Role: models.StrToUserRole(b.Role),
|
||||||
|
Team: models.StrToUserTeam(b.Team),
|
||||||
|
RoomID: &b.RoomID,
|
||||||
|
Username: b.BotName,
|
||||||
|
IsBot: true,
|
||||||
|
}
|
||||||
|
}
|
185
llmapi/parser.go
Normal file
185
llmapi/parser.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
package llmapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RespParser interface {
|
||||||
|
ParseBytes(body []byte) (map[string]any, error)
|
||||||
|
MakePayload(prompt string) io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepSeekParser: deepseek implementation of RespParser
|
||||||
|
type deepSeekParser struct {
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDeepSeekParser(log *slog.Logger) *deepSeekParser {
|
||||||
|
return &deepSeekParser{log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *deepSeekParser) ParseBytes(body []byte) (map[string]any, error) {
|
||||||
|
// parsing logic here
|
||||||
|
dsResp := DSResp{}
|
||||||
|
if err := json.Unmarshal(body, &dsResp); err != nil {
|
||||||
|
p.log.Error("failed to unmarshall", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(dsResp.Choices) == 0 {
|
||||||
|
p.log.Error("empty choices", "dsResp", dsResp)
|
||||||
|
err := errors.New("empty choices in dsResp")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text := dsResp.Choices[0].Text
|
||||||
|
li := strings.Index(text, "{")
|
||||||
|
ri := strings.LastIndex(text, "}")
|
||||||
|
if li < 0 || ri < 1 {
|
||||||
|
p.log.Error("not a json", "msg", text)
|
||||||
|
err := fmt.Errorf("fn: ParseBytes, not a json; data: %s", text)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sj := text[li : ri+1]
|
||||||
|
respMap := make(map[string]any)
|
||||||
|
if err := json.Unmarshal([]byte(sj), &respMap); err != nil {
|
||||||
|
p.log.Error("failed to unmarshal response", "error", err, "string-json", sj)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return respMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *deepSeekParser) MakePayload(prompt string) io.Reader {
|
||||||
|
return strings.NewReader(fmt.Sprintf(`{
|
||||||
|
"model": "deepseek-chat",
|
||||||
|
"prompt": "%s",
|
||||||
|
"echo": false,
|
||||||
|
"frequency_penalty": 0,
|
||||||
|
"logprobs": 0,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"presence_penalty": 0,
|
||||||
|
"stop": null,
|
||||||
|
"stream": false,
|
||||||
|
"stream_options": null,
|
||||||
|
"suffix": null,
|
||||||
|
"temperature": 1,
|
||||||
|
"top_p": 1
|
||||||
|
}`, prompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// llama.cpp implementation of RespParser
|
||||||
|
type lcpRespParser struct {
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLCPRespParser(log *slog.Logger) *lcpRespParser {
|
||||||
|
return &lcpRespParser{log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *lcpRespParser) ParseBytes(body []byte) (map[string]any, error) {
|
||||||
|
// parsing logic here
|
||||||
|
resp := LLMResp{}
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
p.log.Error("failed to unmarshal", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text := resp.Content
|
||||||
|
li := strings.Index(text, "{")
|
||||||
|
ri := strings.LastIndex(text, "}")
|
||||||
|
if li < 0 || ri < 1 {
|
||||||
|
p.log.Error("not a json", "msg", text)
|
||||||
|
err := fmt.Errorf("fn: ParseBytes, not a json; data: %s", text)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sj := text[li : ri+1]
|
||||||
|
respMap := make(map[string]any)
|
||||||
|
if err := json.Unmarshal([]byte(sj), &respMap); err != nil {
|
||||||
|
p.log.Error("failed to unmarshal response", "error", err, "string-json", sj)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return respMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *lcpRespParser) MakePayload(prompt string) io.Reader {
|
||||||
|
return strings.NewReader(fmt.Sprintf(`{
|
||||||
|
"model": "local-model",
|
||||||
|
"prompt": "%s",
|
||||||
|
"frequency_penalty": 0,
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"stop": null,
|
||||||
|
"stream": false,
|
||||||
|
"temperature": 0.4,
|
||||||
|
"top_p": 1
|
||||||
|
}`, prompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
type openRouterParser struct {
|
||||||
|
log *slog.Logger
|
||||||
|
modelIndex uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenRouterParser(log *slog.Logger) *openRouterParser {
|
||||||
|
return &openRouterParser{
|
||||||
|
log: log,
|
||||||
|
modelIndex: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *openRouterParser) ParseBytes(body []byte) (map[string]any, error) {
|
||||||
|
// parsing logic here
|
||||||
|
resp := OpenRouterResp{}
|
||||||
|
if err := json.Unmarshal(body, &resp); err != nil {
|
||||||
|
p.log.Error("failed to unmarshal", "error", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
p.log.Error("empty choices", "resp", resp)
|
||||||
|
err := errors.New("empty choices in resp")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
text := resp.Choices[0].Message.Content
|
||||||
|
li := strings.Index(text, "{")
|
||||||
|
ri := strings.LastIndex(text, "}")
|
||||||
|
if li < 0 || ri < 1 {
|
||||||
|
p.log.Error("not a json", "msg", text)
|
||||||
|
err := fmt.Errorf("fn: ParseBytes, not a json; data: %s", text)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sj := text[li : ri+1]
|
||||||
|
respMap := make(map[string]any)
|
||||||
|
if err := json.Unmarshal([]byte(sj), &respMap); err != nil {
|
||||||
|
p.log.Error("failed to unmarshal response", "error", err, "string-json", sj)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return respMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *openRouterParser) MakePayload(prompt string) io.Reader {
|
||||||
|
// Models to rotate through
|
||||||
|
models := []string{
|
||||||
|
"google/gemini-2.0-flash-exp:free",
|
||||||
|
"deepseek/deepseek-chat-v3-0324:free",
|
||||||
|
"mistralai/mistral-small-3.2-24b-instruct:free",
|
||||||
|
"qwen/qwen3-14b:free",
|
||||||
|
"deepseek/deepseek-r1:free",
|
||||||
|
"google/gemma-3-27b-it:free",
|
||||||
|
"meta-llama/llama-3.3-70b-instruct:free",
|
||||||
|
}
|
||||||
|
// Get next model index using atomic addition for thread safety
|
||||||
|
p.modelIndex++
|
||||||
|
model := models[int(p.modelIndex)%len(models)]
|
||||||
|
strPayload := fmt.Sprintf(`{
|
||||||
|
"model": "%s",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "%s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`, model, prompt)
|
||||||
|
p.log.Debug("made openrouter payload", "model", model, "payload", strPayload)
|
||||||
|
return strings.NewReader(strPayload)
|
||||||
|
}
|
78
main.go
78
main.go
@ -1,36 +1,82 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"context"
|
||||||
"golias/handlers"
|
"gralias/config"
|
||||||
|
"gralias/crons"
|
||||||
|
"gralias/handlers"
|
||||||
|
"gralias/repos"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: add config as param
|
var cfg *config.Config
|
||||||
func ListenToRequests(port string) error {
|
|
||||||
|
func init() {
|
||||||
|
cfg = config.LoadConfigOrDefault("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListenToRequests(port string) *http.Server {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Handler: handlers.LogRequests(handlers.GetSession(mux)),
|
Handler: handlers.LogRequests(handlers.GetSession(mux)),
|
||||||
Addr: port,
|
Addr: ":" + port,
|
||||||
ReadTimeout: time.Second * 5,
|
ReadTimeout: time.Second * 5, // TODO: to cfg
|
||||||
WriteTimeout: time.Second * 5,
|
WriteTimeout: 0, // sse streaming
|
||||||
}
|
}
|
||||||
|
|
||||||
fs := http.FileServer(http.Dir("assets/"))
|
fs := http.FileServer(http.Dir("assets/"))
|
||||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs))
|
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs))
|
||||||
|
//
|
||||||
mux.HandleFunc("GET /ping", handlers.HandlePing)
|
mux.HandleFunc("GET /ping", handlers.HandlePing)
|
||||||
mux.HandleFunc("GET /", handlers.HandleHome)
|
mux.HandleFunc("GET /", handlers.HandleHome)
|
||||||
fmt.Println("Listening", "addr", port)
|
mux.HandleFunc("POST /login", handlers.HandleFrontLogin)
|
||||||
return server.ListenAndServe()
|
mux.HandleFunc("POST /join-team", handlers.HandleJoinTeam)
|
||||||
|
mux.HandleFunc("GET /end-turn", handlers.HandleEndTurn)
|
||||||
|
mux.HandleFunc("POST /room-create", handlers.HandleCreateRoom)
|
||||||
|
mux.HandleFunc("GET /start-game", handlers.HandleStartGame)
|
||||||
|
mux.HandleFunc("GET /room-join", handlers.HandleJoinRoom)
|
||||||
|
mux.HandleFunc("POST /give-clue", handlers.HandleGiveClue)
|
||||||
|
mux.HandleFunc("GET /room/exit", handlers.HandleExit)
|
||||||
|
//elements
|
||||||
|
mux.HandleFunc("GET /actionhistory", handlers.HandleActionHistory)
|
||||||
|
mux.HandleFunc("GET /room/createform", handlers.HandleShowCreateForm)
|
||||||
|
mux.HandleFunc("GET /room/hideform", handlers.HandleHideCreateForm)
|
||||||
|
mux.HandleFunc("GET /word/show-color", handlers.HandleShowColor)
|
||||||
|
mux.HandleFunc("POST /check/name", handlers.HandleNameCheck)
|
||||||
|
mux.HandleFunc("GET /add-bot", handlers.HandleAddBot)
|
||||||
|
mux.HandleFunc("GET /remove-bot", handlers.HandleRemoveBot)
|
||||||
|
mux.HandleFunc("GET /mark-card", handlers.HandleMarkCard)
|
||||||
|
// special
|
||||||
|
mux.HandleFunc("GET /renotify-bot", handlers.HandleRenotifyBot)
|
||||||
|
// sse
|
||||||
|
mux.Handle("GET /sub/sse", handlers.Notifier)
|
||||||
|
slog.Info("Listening", "addr", port)
|
||||||
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
port := ":3000"
|
// Setup graceful shutdown
|
||||||
fmt.Printf("Starting server on %s\n", port)
|
stop := make(chan os.Signal, 1)
|
||||||
err := ListenToRequests(port)
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
if err != nil {
|
repo := repos.NewRepoProvider(cfg.DBPath)
|
||||||
panic(err)
|
defer repo.Close()
|
||||||
|
cm := crons.NewCronManager(repo, slog.Default())
|
||||||
|
cm.Start()
|
||||||
|
server := ListenToRequests(cfg.ServerConfig.Port)
|
||||||
|
go func() {
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
<-stop
|
||||||
|
slog.Info("Shutting down server...")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(ctx); err != nil {
|
||||||
|
slog.Error("server shutdown failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
7
migrations/001_initial_schema.down.sql
Normal file
7
migrations/001_initial_schema.down.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- migrations/001_initial_schema.down.sql
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS actions;
|
||||||
|
DROP TABLE IF EXISTS card_marks;
|
||||||
|
DROP TABLE IF EXISTS word_cards;
|
||||||
|
DROP TABLE IF EXISTS players;
|
||||||
|
DROP TABLE IF EXISTS rooms;
|
79
migrations/001_initial_schema.up.sql
Normal file
79
migrations/001_initial_schema.up.sql
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
-- migrations/001_initial_schema.up.sql
|
||||||
|
|
||||||
|
CREATE TABLE rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL,
|
||||||
|
team_turn TEXT NOT NULL DEFAULT '',
|
||||||
|
this_turn_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
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
|
||||||
|
);
|
@ -6,20 +6,25 @@ import (
|
|||||||
|
|
||||||
// each session contains the username of the user and the time at which it expires
|
// each session contains the username of the user and the time at which it expires
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Username string
|
ID uint32 `db:"id"`
|
||||||
CurrentRoom string
|
// CurrentRoom string
|
||||||
Expiry time.Time
|
// Expiry time.Time
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
Lifetime uint32 `db:"lifetime"` // minutes
|
||||||
|
TokenKey string `db:"token_key"`
|
||||||
|
Username string `db:"username"` // username is playerid
|
||||||
}
|
}
|
||||||
|
|
||||||
// we'll use this method later to determine if the session has expired
|
// we'll use this method later to determine if the session has expired
|
||||||
func (s Session) IsExpired() bool {
|
func (s Session) IsExpired() bool {
|
||||||
return s.Expiry.Before(time.Now())
|
// return time.Now().After(s.UpdatedAt.Add(time.Minute * time.Duration(s.Lifetime)))
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListUsernames(ss map[string]*Session) []string {
|
// func ListUsernames(ss map[string]*Session) []string {
|
||||||
resp := make([]string, 0, len(ss))
|
// resp := make([]string, 0, len(ss))
|
||||||
for _, s := range ss {
|
// for _, s := range ss {
|
||||||
resp = append(resp, s.Username)
|
// resp = append(resp, s.Username)
|
||||||
}
|
// }
|
||||||
return resp
|
// return resp
|
||||||
}
|
// }
|
||||||
|
18
models/keys.go
Normal file
18
models/keys.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
var (
|
||||||
|
AuthCookie = "session_token"
|
||||||
|
CtxRoomIDKey = "current_room"
|
||||||
|
CtxUsernameKey = "username"
|
||||||
|
CtxSessionKey = "session"
|
||||||
|
// cache
|
||||||
|
CacheRoomPrefix = "room#"
|
||||||
|
CacheStatePrefix = "state-"
|
||||||
|
CacheBotPredix = "botkey_"
|
||||||
|
// sse
|
||||||
|
NotifyRoomListUpdate = "roomlistupdate"
|
||||||
|
NotifyRoomUpdatePrefix = "roomupdate_"
|
||||||
|
NotifyBacklogPrefix = "backlog_"
|
||||||
|
NotifyJournalPrefix = "journal_"
|
||||||
|
NotifyTurnTimerPrefix = "turntimer_"
|
||||||
|
)
|
501
models/main.go
501
models/main.go
@ -1,69 +1,460 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"gralias/utils"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/xid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
UserTeam string
|
||||||
|
UserRole string
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Context keys
|
||||||
|
TxContextKey = "tx"
|
||||||
|
// UserTeam
|
||||||
|
UserTeamBlue = "blue"
|
||||||
|
UserTeamRed = "red"
|
||||||
|
UserTeamNone = ""
|
||||||
|
//UserRole
|
||||||
|
UserRoleMime = "mime"
|
||||||
|
UserRoleGuesser = "guesser"
|
||||||
|
UserRoleNone = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
func StrToUserTeam(s string) UserTeam {
|
||||||
|
switch s {
|
||||||
|
case "blue":
|
||||||
|
return UserTeamBlue
|
||||||
|
case "red":
|
||||||
|
return UserTeamRed
|
||||||
|
default:
|
||||||
|
return UserTeamNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func StrToUserRole(s string) UserRole {
|
||||||
|
switch s {
|
||||||
|
case "mime":
|
||||||
|
return UserRoleMime
|
||||||
|
case "guesser":
|
||||||
|
return UserRoleGuesser
|
||||||
|
default:
|
||||||
|
return UserRoleNone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type WordColor string
|
type WordColor string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
WordColorWhite = "white"
|
WordColorWhite = "amber"
|
||||||
WordColorBlue = "blue"
|
WordColorBlue = "blue"
|
||||||
WordColorRed = "red"
|
WordColorRed = "red"
|
||||||
WordColorBlack = "black"
|
WordColorBlack = "black"
|
||||||
|
WordColorUknown = "stone" // beige
|
||||||
)
|
)
|
||||||
|
|
||||||
type Room struct {
|
type ActionType string
|
||||||
ID string `json:"id" db:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
|
||||||
RoomName string `json:"room_name"`
|
|
||||||
RoomPass string `json:"room_pass"`
|
|
||||||
RoomLink string
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
PlayerList []string `json:"player_list"`
|
|
||||||
RedMime string
|
|
||||||
BlueMime string
|
|
||||||
RedGuessers []string
|
|
||||||
BlueGuessers []string
|
|
||||||
Cards []WordCard
|
|
||||||
GameSettings *GameSettings `json:"settings"`
|
|
||||||
Result uint8 // 0 for unknown; 1 is win for red; 2 if for blue;
|
|
||||||
}
|
|
||||||
|
|
||||||
type WordCard struct {
|
const (
|
||||||
Word string
|
ActionTypeClue = "gave_clue"
|
||||||
Color WordColor
|
ActionTypeGuess = "guessed"
|
||||||
Revealed bool
|
ActionTypeGameOver = "game_over"
|
||||||
}
|
ActionTypeGameStarted = "game_started"
|
||||||
|
)
|
||||||
|
|
||||||
type RoomPublic struct {
|
func StrToWordColor(s string) WordColor {
|
||||||
ID string `json:"id" db:"id"`
|
switch s {
|
||||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
case "amber", "white":
|
||||||
PlayerList []string `json:"player_list"`
|
return WordColorWhite
|
||||||
CreatorName string `json:"creator_name"`
|
case "blue":
|
||||||
GameSettings *GameSettings `json:"settings"`
|
return WordColorBlue
|
||||||
RedMime string
|
case "red":
|
||||||
BlueMime string
|
return WordColorRed
|
||||||
RedGuessers []string
|
case "black":
|
||||||
BlueGuessers []string
|
return WordColorBlack
|
||||||
}
|
default:
|
||||||
|
return WordColorUknown
|
||||||
func (r Room) ToPublic() RoomPublic {
|
|
||||||
return RoomPublic{
|
|
||||||
ID: r.ID,
|
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
PlayerList: r.PlayerList,
|
|
||||||
GameSettings: r.GameSettings,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
RedMime: r.RedMime,
|
|
||||||
BlueMime: r.BlueMime,
|
|
||||||
RedGuessers: r.RedGuessers,
|
|
||||||
BlueGuessers: r.BlueGuessers,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameSettings struct {
|
type Team struct {
|
||||||
IsRunning bool `json:"is_running"`
|
Guessers []string
|
||||||
Language string `json:"language" example:"en" form:"language"`
|
Mime string
|
||||||
RoundTime int32 `json:"round_time"`
|
Color string
|
||||||
ProgressPct uint32 `json:"progress_pct"`
|
}
|
||||||
IsOver bool
|
|
||||||
|
type Action struct {
|
||||||
|
ID uint32 `json:"id" db:"id"`
|
||||||
|
RoomID string `json:"room_id" db:"room_id"`
|
||||||
|
Actor string `json:"actor" db:"actor"`
|
||||||
|
ActorColor string `json:"actor_color" db:"actor_color"`
|
||||||
|
Action string `json:"action_type" db:"action_type"`
|
||||||
|
Word string `json:"word" db:"word"`
|
||||||
|
WordColor string `json:"word_color" db:"word_color"`
|
||||||
|
Number string `json:"number_associated" db:"number_associated"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
ID uint32 `json:"id" db:"id"`
|
||||||
|
RoomID *string `json:"room_id" db:"room_id"`
|
||||||
|
Username string `json:"username" db:"username"`
|
||||||
|
Team UserTeam `json:"team" db:"team"`
|
||||||
|
Role UserRole `json:"role" db:"role"`
|
||||||
|
IsBot bool `json:"is_bot" db:"is_bot"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitPlayer(username string) *Player {
|
||||||
|
return &Player{
|
||||||
|
// last id + 1?
|
||||||
|
Username: username,
|
||||||
|
Team: UserTeamNone,
|
||||||
|
Role: UserRoleNone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BotPlayer struct {
|
||||||
|
Role UserRole // gueeser | mime
|
||||||
|
Team UserTeam
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardMark struct {
|
||||||
|
CardID uint32 `db:"card_id"`
|
||||||
|
Username string `db:"username"`
|
||||||
|
Active bool `db:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Room struct {
|
||||||
|
ID string `json:"id" db:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
|
CreatorName string `json:"creator_name" db:"creator_name"`
|
||||||
|
TeamTurn UserTeam `db:"team_turn"`
|
||||||
|
ThisTurnLimit uint8 `db:"this_turn_limit"`
|
||||||
|
OpenedThisTurn uint8 `db:"opened_this_turn"`
|
||||||
|
BlueCounter uint8 `db:"blue_counter"`
|
||||||
|
RedCounter uint8 `db:"red_counter"`
|
||||||
|
RedTurn bool `db:"red_turn"`
|
||||||
|
MimeDone bool `db:"mime_done"`
|
||||||
|
IsRunning bool `json:"is_running" db:"is_running"`
|
||||||
|
IsOver bool `db:"is_over"`
|
||||||
|
TeamWon UserTeam `db:"team_won"`
|
||||||
|
RoomLink string `db:"room_link"`
|
||||||
|
// fields not in db
|
||||||
|
ActionHistory []Action `db:"-"`
|
||||||
|
RedTeam Team `db:"-"`
|
||||||
|
BlueTeam Team `db:"-"`
|
||||||
|
Cards []WordCard `db:"-"`
|
||||||
|
// WCMap map[string]WordColor `db:"-"`
|
||||||
|
BotMap map[string]BotPlayer `db:"-"`
|
||||||
|
Mark CardMark `db:"-"`
|
||||||
|
LogJournal []string `db:"-"`
|
||||||
|
Settings GameSettings `db:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) FindColor(word string) (WordColor, bool) {
|
||||||
|
for _, card := range r.Cards {
|
||||||
|
if strings.EqualFold(card.Word, word) {
|
||||||
|
return card.Color, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) ClearMarks() {
|
||||||
|
for i, _ := range r.Cards {
|
||||||
|
r.Cards[i].Mark = []CardMark{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) RemovePlayer(username string) {
|
||||||
|
r.RedTeam.Guessers = utils.RemoveFromSlice(username, r.RedTeam.Guessers)
|
||||||
|
r.BlueTeam.Guessers = utils.RemoveFromSlice(username, r.BlueTeam.Guessers)
|
||||||
|
if r.RedTeam.Mime == username {
|
||||||
|
r.RedTeam.Mime = ""
|
||||||
|
}
|
||||||
|
if r.BlueTeam.Mime == username {
|
||||||
|
r.BlueTeam.Mime = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindBotByTeamRole returns bot name if found; otherwise empty string
|
||||||
|
func (r *Room) FindBotByTeamRole(team, role string) string {
|
||||||
|
for bn, b := range r.BotMap {
|
||||||
|
if b.Role == StrToUserRole(role) && b.Team == StrToUserTeam(team) {
|
||||||
|
return bn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) FetchLastClue() (*Action, error) {
|
||||||
|
for i := len(r.ActionHistory) - 1; i >= 0; i-- {
|
||||||
|
if r.ActionHistory[i].Action == string(ActionTypeClue) {
|
||||||
|
return &r.ActionHistory[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errors.New("no clue in history")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) FetchLastClueWord() string {
|
||||||
|
for i := len(r.ActionHistory) - 1; i >= 0; i-- {
|
||||||
|
if r.ActionHistory[i].Action == string(ActionTypeClue) {
|
||||||
|
return r.ActionHistory[i].Word
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) GetPlayerByName(name string) (role UserRole, team UserTeam, found bool) {
|
||||||
|
if r.RedTeam.Mime == name {
|
||||||
|
return "mime", "red", true
|
||||||
|
}
|
||||||
|
if r.BlueTeam.Mime == name {
|
||||||
|
return "mime", "blue", true
|
||||||
|
}
|
||||||
|
for _, guesser := range r.RedTeam.Guessers {
|
||||||
|
if guesser == name {
|
||||||
|
return "guesser", "red", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, guesser := range r.BlueTeam.Guessers {
|
||||||
|
if guesser == name {
|
||||||
|
return "guesser", "blue", true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) GetPlayerInfoByName(name string) *BotPlayer {
|
||||||
|
bp := &BotPlayer{}
|
||||||
|
if r.RedTeam.Mime == name {
|
||||||
|
bp.Role = UserRoleMime
|
||||||
|
bp.Team = UserTeamRed
|
||||||
|
}
|
||||||
|
if r.BlueTeam.Mime == name {
|
||||||
|
bp.Role = UserRoleMime
|
||||||
|
bp.Team = UserTeamBlue
|
||||||
|
}
|
||||||
|
for _, guesser := range r.RedTeam.Guessers {
|
||||||
|
if guesser == name {
|
||||||
|
bp.Role = UserRoleGuesser
|
||||||
|
bp.Team = UserTeamRed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, guesser := range r.BlueTeam.Guessers {
|
||||||
|
if guesser == name {
|
||||||
|
bp.Role = UserRoleGuesser
|
||||||
|
bp.Team = UserTeamBlue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) CanStart() error {
|
||||||
|
if r.IsRunning {
|
||||||
|
return errors.New("cannot start; game is already running")
|
||||||
|
}
|
||||||
|
if r.RedTeam.Mime == "" {
|
||||||
|
return errors.New("cannot start; red team has no mime")
|
||||||
|
}
|
||||||
|
if r.BlueTeam.Mime == "" {
|
||||||
|
return errors.New("cannot start; blue team has no mime")
|
||||||
|
}
|
||||||
|
if len(r.RedTeam.Guessers) == 0 {
|
||||||
|
return errors.New("cannot start; red team has no guessers")
|
||||||
|
}
|
||||||
|
if len(r.BlueTeam.Guessers) == 0 {
|
||||||
|
return errors.New("cannot start; blue team has no guessers")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGuesser(m map[string]BotPlayer, team UserTeam) string {
|
||||||
|
for k, v := range m {
|
||||||
|
if v.Team == team && v.Role == UserRoleGuesser {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// WhichBotToMove returns bot name that have to move or empty string
|
||||||
|
func (r *Room) WhichBotToMove() string {
|
||||||
|
fmt.Println("looking for bot to move", "team-turn:", r.TeamTurn,
|
||||||
|
"mime-done:", r.MimeDone, "bot-map:", r.BotMap, "is_running:", r.IsRunning,
|
||||||
|
"blueMime:", r.BlueTeam.Mime, "redMime:", r.RedTeam.Mime)
|
||||||
|
if !r.IsRunning {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch r.TeamTurn {
|
||||||
|
case UserTeamBlue:
|
||||||
|
if !r.MimeDone {
|
||||||
|
_, ok := r.BotMap[r.BlueTeam.Mime]
|
||||||
|
if ok {
|
||||||
|
return r.BlueTeam.Mime
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return getGuesser(r.BotMap, UserTeamBlue)
|
||||||
|
}
|
||||||
|
case UserTeamRed:
|
||||||
|
if !r.MimeDone {
|
||||||
|
_, ok := r.BotMap[r.RedTeam.Mime]
|
||||||
|
if ok {
|
||||||
|
return r.RedTeam.Mime
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return getGuesser(r.BotMap, UserTeamRed)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// how did we got here?
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) GetOppositeTeamColor() UserTeam {
|
||||||
|
switch r.TeamTurn {
|
||||||
|
case UserTeamRed:
|
||||||
|
return UserTeamBlue
|
||||||
|
case UserTeamBlue:
|
||||||
|
return UserTeamRed
|
||||||
|
}
|
||||||
|
return UserTeamNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) UpdateCounter() {
|
||||||
|
redCounter := uint8(0)
|
||||||
|
blueCounter := uint8(0)
|
||||||
|
for _, card := range r.Cards {
|
||||||
|
if card.Revealed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch card.Color {
|
||||||
|
case WordColorRed:
|
||||||
|
redCounter++
|
||||||
|
case WordColorBlue:
|
||||||
|
blueCounter++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.RedCounter = redCounter
|
||||||
|
r.BlueCounter = blueCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) ChangeTurn() {
|
||||||
|
switch r.TeamTurn {
|
||||||
|
case "blue":
|
||||||
|
r.TeamTurn = "red"
|
||||||
|
case "red":
|
||||||
|
r.TeamTurn = "blue"
|
||||||
|
default:
|
||||||
|
r.TeamTurn = "blue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) RevealAllCards() {
|
||||||
|
for i := range r.Cards {
|
||||||
|
r.Cards[i].Revealed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) MimeView() {
|
||||||
|
for i := range r.Cards {
|
||||||
|
r.Cards[i].Mime = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) GuesserView() {
|
||||||
|
for i := range r.Cards {
|
||||||
|
r.Cards[i].Mime = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) RevealSpecificWord(word string) uint32 {
|
||||||
|
for i, card := range r.Cards {
|
||||||
|
if card.Word == word {
|
||||||
|
r.Cards[i].Revealed = true
|
||||||
|
return card.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type WordCard struct {
|
||||||
|
ID uint32 `json:"id" db:"id"`
|
||||||
|
RoomID string `json:"room_id" db:"room_id"`
|
||||||
|
Word string `json:"word" db:"word"`
|
||||||
|
Color WordColor `json:"color" db:"color"`
|
||||||
|
Revealed bool `json:"revealed" db:"revealed"`
|
||||||
|
Mime bool `json:"mime" db:"mime_view"` // user who sees that card is mime
|
||||||
|
Mark []CardMark `json:"marks" db:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// table: settings
|
||||||
|
type GameSettings struct {
|
||||||
|
ID uint32 `json:"id" db:"id"`
|
||||||
|
RoomID string `db:"room_id"`
|
||||||
|
Language string `json:"language" example:"en" form:"language" db:"language"`
|
||||||
|
RoomPass string `json:"room_pass" db:"room_pass"`
|
||||||
|
|
||||||
|
RoundTime uint32 `json:"round_time" db:"turn_time"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// =====
|
||||||
|
|
||||||
|
type RoomReq struct {
|
||||||
|
// is not user or not unique
|
||||||
|
RoomPass string `json:"room_pass" form:"room_pass"`
|
||||||
|
// GameSettings
|
||||||
|
Language string `json:"language" form:"language"`
|
||||||
|
RoundTime uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rr *RoomReq) CreateRoom(creator string) *Room {
|
||||||
|
roomID := xid.New().String()
|
||||||
|
settings := GameSettings{
|
||||||
|
Language: rr.Language,
|
||||||
|
RoundTime: rr.RoundTime,
|
||||||
|
RoomPass: rr.RoomPass,
|
||||||
|
}
|
||||||
|
return &Room{
|
||||||
|
ID: roomID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: creator,
|
||||||
|
Settings: settings,
|
||||||
|
BotMap: make(map[string]BotPlayer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====
|
||||||
|
|
||||||
|
type FullInfo struct {
|
||||||
|
// State *UserState
|
||||||
|
State *Player
|
||||||
|
Room *Room
|
||||||
|
List []*Room
|
||||||
|
LinkLogin string // room_id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FullInfo) ExitRoom() *Room {
|
||||||
|
// f.Room.PlayerList = utils.RemoveFromSlice(f.State.Username, f.Room.PlayerList)
|
||||||
|
f.Room.RedTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.RedTeam.Guessers)
|
||||||
|
f.Room.BlueTeam.Guessers = utils.RemoveFromSlice(f.State.Username, f.Room.BlueTeam.Guessers)
|
||||||
|
if f.Room.RedTeam.Mime == f.State.Username {
|
||||||
|
f.Room.RedTeam.Mime = ""
|
||||||
|
}
|
||||||
|
if f.Room.BlueTeam.Mime == f.State.Username {
|
||||||
|
f.Room.BlueTeam.Mime = ""
|
||||||
|
}
|
||||||
|
// f.State.ExitRoom()
|
||||||
|
resp := f.Room
|
||||||
|
f.Room = nil
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
144
pkg/cache/impl.go
vendored
144
pkg/cache/impl.go
vendored
@ -1,144 +0,0 @@
|
|||||||
package cache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const storeFileName = "store.json"
|
|
||||||
|
|
||||||
// var MemCache Cache
|
|
||||||
var (
|
|
||||||
MemCache *MemoryCache
|
|
||||||
)
|
|
||||||
|
|
||||||
func readJSON(fileName string) (map[string][]byte, error) {
|
|
||||||
data := make(map[string][]byte)
|
|
||||||
file, err := os.Open(fileName)
|
|
||||||
if err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
decoder := json.NewDecoder(file)
|
|
||||||
if err := decoder.Decode(&data); err != nil {
|
|
||||||
return data, err
|
|
||||||
}
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
data, err := readJSON(storeFileName)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to load store from file")
|
|
||||||
}
|
|
||||||
MemCache = &MemoryCache{
|
|
||||||
data: data,
|
|
||||||
timeMap: make(map[string]time.Time),
|
|
||||||
lock: &sync.RWMutex{},
|
|
||||||
}
|
|
||||||
MemCache.StartExpiryRoutine(time.Minute)
|
|
||||||
MemCache.StartBackupRoutine(time.Minute)
|
|
||||||
}
|
|
||||||
|
|
||||||
type MemoryCache struct {
|
|
||||||
data map[string][]byte
|
|
||||||
timeMap map[string]time.Time
|
|
||||||
lock *sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a value by key from the cache
|
|
||||||
func (mc *MemoryCache) Get(key string) (value []byte, err error) {
|
|
||||||
var ok bool
|
|
||||||
mc.lock.RLock()
|
|
||||||
if value, ok = mc.data[key]; !ok {
|
|
||||||
err = fmt.Errorf("not found data in mc for the key: %v", key)
|
|
||||||
}
|
|
||||||
mc.lock.RUnlock()
|
|
||||||
return value, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update a single value in the cache
|
|
||||||
func (mc *MemoryCache) Set(key string, value []byte) {
|
|
||||||
// no async writing
|
|
||||||
mc.lock.Lock()
|
|
||||||
mc.data[key] = value
|
|
||||||
mc.lock.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCache) Expire(key string, exp int64) {
|
|
||||||
mc.lock.RLock()
|
|
||||||
mc.timeMap[key] = time.Now().Add(time.Duration(exp) * time.Second)
|
|
||||||
mc.lock.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCache) GetAll() (resp map[string][]byte) {
|
|
||||||
resp = make(map[string][]byte)
|
|
||||||
mc.lock.RLock()
|
|
||||||
for k, v := range mc.data {
|
|
||||||
resp[k] = v
|
|
||||||
}
|
|
||||||
mc.lock.RUnlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCache) GetAllTime() (resp map[string]time.Time) {
|
|
||||||
resp = make(map[string]time.Time)
|
|
||||||
mc.lock.RLock()
|
|
||||||
for k, v := range mc.timeMap {
|
|
||||||
resp[k] = v
|
|
||||||
}
|
|
||||||
mc.lock.RUnlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCache) RemoveKey(key string) {
|
|
||||||
mc.lock.RLock()
|
|
||||||
delete(mc.data, key)
|
|
||||||
delete(mc.timeMap, key)
|
|
||||||
mc.lock.RUnlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCache) StartExpiryRoutine(n time.Duration) {
|
|
||||||
ticker := time.NewTicker(n)
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
<-ticker.C
|
|
||||||
// get all
|
|
||||||
timeData := mc.GetAllTime()
|
|
||||||
// check time
|
|
||||||
currentTS := time.Now()
|
|
||||||
for k, ts := range timeData {
|
|
||||||
if ts.Before(currentTS) {
|
|
||||||
// delete exp keys
|
|
||||||
mc.RemoveKey(k)
|
|
||||||
slog.Debug("remove by expiry", "key", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (mc *MemoryCache) StartBackupRoutine(n time.Duration) {
|
|
||||||
ticker := time.NewTicker(n)
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
<-ticker.C
|
|
||||||
// get all
|
|
||||||
data := mc.GetAll()
|
|
||||||
jsonString, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("failed to marshal", "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
err = os.WriteFile(storeFileName, jsonString, os.ModePerm)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("failed to write", "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
9
pkg/cache/main.go
vendored
9
pkg/cache/main.go
vendored
@ -1,9 +0,0 @@
|
|||||||
package cache
|
|
||||||
|
|
||||||
type Cache interface {
|
|
||||||
Get(key string) ([]byte, error)
|
|
||||||
Set(key string, value []byte)
|
|
||||||
Expire(key string, exp int64)
|
|
||||||
GetAll() (resp map[string][]byte)
|
|
||||||
RemoveKey(key string)
|
|
||||||
}
|
|
52
repos/actions.go
Normal file
52
repos/actions.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ActionsRepo interface {
|
||||||
|
ActionList(ctx context.Context, roomID string) ([]models.Action, error)
|
||||||
|
ActionCreate(ctx context.Context, action *models.Action) error
|
||||||
|
ActionGetLastClue(ctx context.Context, roomID string) (*models.Action, error)
|
||||||
|
ActionDeleteByRoomID(ctx context.Context, roomID string) error
|
||||||
|
ActionDeleteOrphaned(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) ActionList(ctx context.Context, roomID string) ([]models.Action, error) {
|
||||||
|
actions := []models.Action{}
|
||||||
|
err := sqlx.SelectContext(ctx, p.DB, &actions, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? ORDER BY created_at ASC`, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) ActionCreate(ctx context.Context, a *models.Action) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, a.RoomID, a.Actor, a.ActorColor, a.Action, a.Word, a.WordColor, a.Number, a.CreatedAt.UnixNano())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) ActionGetLastClue(ctx context.Context, roomID string) (*models.Action, error) {
|
||||||
|
action := &models.Action{}
|
||||||
|
err := sqlx.GetContext(ctx, p.DB, action, `SELECT actor, actor_color, action_type, word, word_color, number_associated, created_at FROM actions WHERE room_id = ? AND action_type = 'gave_clue' ORDER BY created_at DESC LIMIT 1`, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) ActionDeleteByRoomID(ctx context.Context, roomID string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id = ?`, roomID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) ActionDeleteOrphaned(ctx context.Context) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM actions WHERE room_id NOT IN (SELECT id FROM rooms)`)
|
||||||
|
return err
|
||||||
|
}
|
180
repos/actions_test.go
Normal file
180
repos/actions_test.go
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupActionsTestDB(t *testing.T) (*sqlx.DB, func()) {
|
||||||
|
db, err := sqlx.Connect("sqlite3", ":memory:")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS actions (
|
||||||
|
room_id TEXT,
|
||||||
|
actor TEXT,
|
||||||
|
actor_color TEXT,
|
||||||
|
action_type TEXT,
|
||||||
|
word TEXT,
|
||||||
|
word_color TEXT,
|
||||||
|
number_associated TEXT,
|
||||||
|
created_at TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
return db, func() {
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsRepo_ActionCreate(t *testing.T) {
|
||||||
|
db, teardown := setupActionsTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
roomID := "test_room_actions_1"
|
||||||
|
action := &models.Action{
|
||||||
|
Actor: "player1",
|
||||||
|
ActorColor: "blue",
|
||||||
|
Action: "gave_clue",
|
||||||
|
Word: "apple",
|
||||||
|
WordColor: "red",
|
||||||
|
Number: "3",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
RoomID: roomID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.ActionCreate(context.Background(), action)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var retrievedAction models.Action
|
||||||
|
err = db.Get(&retrievedAction, "SELECT * FROM actions WHERE room_id = ? AND actor = ?", roomID, action.Actor)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, action.Word, retrievedAction.Word)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsRepo_ListActions(t *testing.T) {
|
||||||
|
db, teardown := setupActionsTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
roomID := "test_room_actions_2"
|
||||||
|
action1 := &models.Action{
|
||||||
|
Actor: "player1",
|
||||||
|
ActorColor: "blue",
|
||||||
|
Action: "gave_clue",
|
||||||
|
Word: "apple",
|
||||||
|
WordColor: "red",
|
||||||
|
Number: "3",
|
||||||
|
RoomID: roomID,
|
||||||
|
CreatedAt: time.Now().Add(-2 * time.Second),
|
||||||
|
}
|
||||||
|
action2 := &models.Action{
|
||||||
|
Actor: "player2",
|
||||||
|
ActorColor: "red",
|
||||||
|
Action: "guessed",
|
||||||
|
Word: "banana",
|
||||||
|
WordColor: "blue",
|
||||||
|
Number: "0",
|
||||||
|
RoomID: roomID,
|
||||||
|
CreatedAt: time.Now().Add(-1 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action2.Actor, action2.ActorColor, action2.Action, action2.Word, action2.WordColor, action2.Number, action2.CreatedAt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
actions, err := repo.ActionList(context.Background(), roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, actions, 2)
|
||||||
|
assert.Equal(t, action1.Word, actions[0].Word)
|
||||||
|
assert.Equal(t, action2.Word, actions[1].Word)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsRepo_GetLastClue(t *testing.T) {
|
||||||
|
db, teardown := setupActionsTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
roomID := "test_room_actions_3"
|
||||||
|
action1 := &models.Action{
|
||||||
|
Actor: "player1",
|
||||||
|
ActorColor: "blue",
|
||||||
|
Action: "gave_clue",
|
||||||
|
Word: "apple",
|
||||||
|
WordColor: "red",
|
||||||
|
Number: "3",
|
||||||
|
CreatedAt: time.Now().Add(-3 * time.Second),
|
||||||
|
}
|
||||||
|
action2 := &models.Action{
|
||||||
|
Actor: "player2",
|
||||||
|
ActorColor: "red",
|
||||||
|
Action: "gave_clue",
|
||||||
|
Word: "banana",
|
||||||
|
WordColor: "blue",
|
||||||
|
Number: "2",
|
||||||
|
CreatedAt: time.Now().Add(-2 * time.Second),
|
||||||
|
}
|
||||||
|
// Non-clue action
|
||||||
|
action3 := &models.Action{
|
||||||
|
Actor: "player3",
|
||||||
|
ActorColor: "blue",
|
||||||
|
Action: "guessed",
|
||||||
|
Word: "orange",
|
||||||
|
WordColor: "blue",
|
||||||
|
Number: "0",
|
||||||
|
CreatedAt: time.Now().Add(-1 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action2.Actor, action2.ActorColor, action2.Action, action2.Word, action2.WordColor, action2.Number, action2.CreatedAt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action3.Actor, action3.ActorColor, action3.Action, action3.Word, action3.WordColor, action3.Number, action3.CreatedAt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
lastClue, err := repo.ActionGetLastClue(context.Background(), roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, lastClue)
|
||||||
|
assert.Equal(t, action2.Word, lastClue.Word)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActionsRepo_DeleteActionsByRoomID(t *testing.T) {
|
||||||
|
db, teardown := setupActionsTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
roomID := "test_room_actions_4"
|
||||||
|
action1 := &models.Action{
|
||||||
|
Actor: "player1",
|
||||||
|
ActorColor: "blue",
|
||||||
|
Action: "gave_clue",
|
||||||
|
Word: "apple",
|
||||||
|
WordColor: "red",
|
||||||
|
Number: "3",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`INSERT INTO actions (room_id, actor, actor_color, action_type, word, word_color, number_associated, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, roomID, action1.Actor, action1.ActorColor, action1.Action, action1.Word, action1.WordColor, action1.Number, action1.CreatedAt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.ActionDeleteByRoomID(context.Background(), roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM actions WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
116
repos/main.go
Normal file
116
repos/main.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AllRepos interface {
|
||||||
|
RoomsRepo
|
||||||
|
ActionsRepo
|
||||||
|
PlayersRepo
|
||||||
|
SessionsRepo
|
||||||
|
WordCardsRepo
|
||||||
|
SettingsRepo
|
||||||
|
InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepoProvider struct {
|
||||||
|
DB *sqlx.DB
|
||||||
|
mu sync.RWMutex
|
||||||
|
pathToDB string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRepoProvider(pathToDB string) *RepoProvider {
|
||||||
|
db, err := sqlx.Connect("sqlite3", pathToDB)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Unable to connect to database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Unable to enable foreign keys", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("Successfully connected to database")
|
||||||
|
rp := &RepoProvider{
|
||||||
|
DB: db,
|
||||||
|
pathToDB: pathToDB,
|
||||||
|
}
|
||||||
|
|
||||||
|
go rp.pingLoop()
|
||||||
|
|
||||||
|
return rp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rp *RepoProvider) pingLoop() {
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
if err := rp.pingDB(); err != nil {
|
||||||
|
slog.Error("Database ping failed, attempting to reconnect...", "error", err)
|
||||||
|
rp.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rp *RepoProvider) pingDB() error {
|
||||||
|
rp.mu.RLock()
|
||||||
|
defer rp.mu.RUnlock()
|
||||||
|
if rp.DB == nil {
|
||||||
|
return os.ErrClosed
|
||||||
|
}
|
||||||
|
return rp.DB.Ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rp *RepoProvider) reconnect() {
|
||||||
|
rp.mu.Lock()
|
||||||
|
defer rp.mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check if connection is still down
|
||||||
|
if rp.DB != nil {
|
||||||
|
if err := rp.DB.Ping(); err == nil {
|
||||||
|
slog.Info("Database connection already re-established.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// if ping fails, we continue to reconnect
|
||||||
|
rp.DB.Close() // close old connection
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Reconnecting to database...")
|
||||||
|
db, err := sqlx.Connect("sqlite3", rp.pathToDB)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to reconnect to database", "error", err)
|
||||||
|
rp.DB = nil // make sure DB is nil if connection failed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rp.DB = db
|
||||||
|
slog.Info("Successfully reconnected to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDB(ctx context.Context, db *sqlx.DB) sqlx.ExtContext {
|
||||||
|
if tx, ok := ctx.Value("tx").(*sqlx.Tx); ok {
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) InitTx(ctx context.Context) (context.Context, *sqlx.Tx, error) {
|
||||||
|
tx, err := p.DB.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, "tx", tx), tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) Close() {
|
||||||
|
p.DB.Close()
|
||||||
|
}
|
24
repos/main_test.go
Normal file
24
repos/main_test.go
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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")
|
||||||
|
}
|
105
repos/players.go
Normal file
105
repos/players.go
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"gralias/models"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlayersRepo interface {
|
||||||
|
PlayerGetByName(ctx context.Context, username string) (*models.Player, error)
|
||||||
|
PlayerAdd(ctx context.Context, player *models.Player) error
|
||||||
|
PlayerUpdate(ctx context.Context, player *models.Player) error
|
||||||
|
PlayerDelete(ctx context.Context, roomID, username string) error
|
||||||
|
PlayerSetRoomID(ctx context.Context, roomID, username string) error
|
||||||
|
PlayerExitRoom(ctx context.Context, username string) error
|
||||||
|
PlayerListNames(ctx context.Context) ([]string, error)
|
||||||
|
PlayerList(ctx context.Context, isBot bool) ([]models.Player, error)
|
||||||
|
PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerListNames(ctx context.Context) ([]string, error) {
|
||||||
|
var names []string
|
||||||
|
err := sqlx.SelectContext(ctx, p.DB, &names, "SELECT username FROM players;")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerGetByName(ctx context.Context, username string) (*models.Player, error) {
|
||||||
|
var player models.Player
|
||||||
|
err := sqlx.GetContext(ctx, p.DB, &player, "SELECT id, room_id, username, team, role, is_bot FROM players WHERE username = ?", username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if player.RoomID != nil && *player.RoomID == "" {
|
||||||
|
player.RoomID = nil
|
||||||
|
}
|
||||||
|
return &player, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerAdd(ctx context.Context, player *models.Player) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
player.RoomID, player.Username, player.Team, player.Role, player.IsBot)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerUpdate(ctx context.Context, player *models.Player) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "UPDATE players SET team = ?, role = ? WHERE username = ?;",
|
||||||
|
player.Team, player.Role, player.Username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerDelete(ctx context.Context, roomID, username string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "DELETE FROM players WHERE room_id = ? AND username = ?", roomID, username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerSetRoomID(ctx context.Context, roomID, username string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
res, err := db.ExecContext(ctx, "UPDATE players SET room_id = ? WHERE username = ?", roomID, username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return fmt.Errorf("failed to set room_id (%s) for player (%s)", roomID, username)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerExitRoom(ctx context.Context, username string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, "UPDATE players SET room_id=null, team='', role='' WHERE username = ?", username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerList(ctx context.Context, isBot bool) ([]models.Player, error) {
|
||||||
|
var players []models.Player
|
||||||
|
var query string
|
||||||
|
if isBot {
|
||||||
|
query = "SELECT id, room_id, username, team, role, is_bot FROM players WHERE is_bot = true;"
|
||||||
|
} else {
|
||||||
|
query = "SELECT id, room_id, username, team, role, is_bot FROM players WHERE is_bot = false;"
|
||||||
|
}
|
||||||
|
err := sqlx.SelectContext(ctx, p.DB, &players, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) PlayerListByRoom(ctx context.Context, roomID string) ([]models.Player, error) {
|
||||||
|
var players []models.Player
|
||||||
|
err := sqlx.SelectContext(ctx, p.DB, &players, "SELECT id, room_id, username, team, role, is_bot FROM players WHERE room_id = ?", roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return players, nil
|
||||||
|
}
|
108
repos/players_test.go
Normal file
108
repos/players_test.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupPlayersTestDB(t *testing.T) (*sqlx.DB, func()) {
|
||||||
|
db, err := sqlx.Connect("sqlite3", ":memory:")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT,
|
||||||
|
username TEXT,
|
||||||
|
team TEXT,
|
||||||
|
role TEXT,
|
||||||
|
is_bot BOOLEAN
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
return db, func() {
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayersRepo_AddPlayer(t *testing.T) {
|
||||||
|
db, teardown := setupPlayersTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
roomID := "test_room_player_1"
|
||||||
|
player := &models.Player{
|
||||||
|
RoomID: &roomID,
|
||||||
|
Username: "test_player_1",
|
||||||
|
Team: "blue",
|
||||||
|
Role: "player",
|
||||||
|
IsBot: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.PlayerAdd(context.Background(), player)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var retrievedPlayer models.Player
|
||||||
|
err = db.Get(&retrievedPlayer, "SELECT * FROM players WHERE room_id = ? AND username = ?", player.RoomID, player.Username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, player.Username, retrievedPlayer.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayersRepo_GetPlayer(t *testing.T) {
|
||||||
|
db, teardown := setupPlayersTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
roomID := "test_room_player_2"
|
||||||
|
player := &models.Player{
|
||||||
|
RoomID: &roomID,
|
||||||
|
Username: "test_player_2",
|
||||||
|
Team: "red",
|
||||||
|
Role: "player",
|
||||||
|
IsBot: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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)
|
||||||
|
|
||||||
|
retrievedPlayer, err := repo.PlayerGetByName(context.Background(), player.Username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, retrievedPlayer)
|
||||||
|
assert.Equal(t, player.Username, retrievedPlayer.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlayersRepo_DeletePlayer(t *testing.T) {
|
||||||
|
db, teardown := setupPlayersTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
roomID := "test_room_player_3"
|
||||||
|
player := &models.Player{
|
||||||
|
RoomID: &roomID,
|
||||||
|
Username: "test_player_3",
|
||||||
|
Team: "blue",
|
||||||
|
Role: "player",
|
||||||
|
IsBot: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(`INSERT INTO players (room_id, username, team, role, is_bot) VALUES (?, ?, ?, ?, ?)`, player.RoomID, player.Username, player.Team, player.Role, player.IsBot)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.PlayerDelete(context.Background(), *player.RoomID, player.Username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM players WHERE room_id = ? AND username = ?", player.RoomID, player.Username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
135
repos/rooms.go
Normal file
135
repos/rooms.go
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"gralias/models"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RoomsRepo interface {
|
||||||
|
RoomList(ctx context.Context) ([]*models.Room, error)
|
||||||
|
RoomGetByID(ctx context.Context, id string) (*models.Room, error)
|
||||||
|
RoomGetExtended(ctx context.Context, id string) (*models.Room, error)
|
||||||
|
RoomCreate(ctx context.Context, room *models.Room) error
|
||||||
|
RoomDeleteByID(ctx context.Context, id string) error
|
||||||
|
RoomUpdate(ctx context.Context, room *models.Room) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomList(ctx context.Context) ([]*models.Room, error) {
|
||||||
|
rooms := []*models.Room{}
|
||||||
|
err := sqlx.SelectContext(ctx, p.DB, &rooms, `SELECT * FROM rooms`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rooms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomGetByID(ctx context.Context, id string) (*models.Room, error) {
|
||||||
|
room := &models.Room{}
|
||||||
|
err := sqlx.GetContext(ctx, p.DB, room, `SELECT * FROM rooms WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
settings := &models.GameSettings{}
|
||||||
|
err = sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
room.Settings = *settings
|
||||||
|
return room, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomCreate(ctx context.Context, r *models.Room) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, r.ID, r.CreatedAt, r.CreatorName, r.TeamTurn, r.ThisTurnLimit, r.OpenedThisTurn, r.BlueCounter, r.RedCounter, r.RedTurn, r.MimeDone, r.IsRunning, r.IsOver, r.TeamWon, r.RoomLink)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.ExecContext(ctx, `INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, r.ID, r.Settings.Language, r.Settings.RoomPass, r.Settings.RoundTime)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomDeleteByID(ctx context.Context, id string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomUpdate(ctx context.Context, r *models.Room) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `UPDATE rooms SET team_turn = ?, this_turn_limit = ?, opened_this_turn = ?, blue_counter = ?, red_counter = ?, red_turn = ?, mime_done = ?, is_running = ?, is_over = ?, team_won = ?, room_link = ? WHERE id = ?`, r.TeamTurn, r.ThisTurnLimit, r.OpenedThisTurn, r.BlueCounter, r.RedCounter, r.RedTurn, r.MimeDone, r.IsRunning, r.IsOver, r.TeamWon, r.RoomLink, r.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = db.ExecContext(ctx, `UPDATE settings SET language = ?, room_pass = ?, turn_time = ? WHERE room_id = ?`, r.Settings.Language, r.Settings.RoomPass, r.Settings.RoundTime, r.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) RoomGetExtended(ctx context.Context, id string) (*models.Room, error) {
|
||||||
|
room := &models.Room{}
|
||||||
|
err := sqlx.GetContext(ctx, p.DB, room, `SELECT * FROM rooms WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to get room; %w", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Get players
|
||||||
|
players := []*models.Player{}
|
||||||
|
err = sqlx.SelectContext(ctx, p.DB, &players, `SELECT * FROM players WHERE room_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to get players; %w", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
room.RedTeam.Color = string(models.UserTeamRed)
|
||||||
|
room.BlueTeam.Color = string(models.UserTeamBlue)
|
||||||
|
if room.BotMap == nil {
|
||||||
|
room.BotMap = make(map[string]models.BotPlayer)
|
||||||
|
}
|
||||||
|
for _, player := range players {
|
||||||
|
if player.Team == models.UserTeamRed {
|
||||||
|
if player.Role == models.UserRoleMime {
|
||||||
|
room.RedTeam.Mime = player.Username
|
||||||
|
} else {
|
||||||
|
room.RedTeam.Guessers = append(room.RedTeam.Guessers, player.Username)
|
||||||
|
}
|
||||||
|
} else if player.Team == models.UserTeamBlue {
|
||||||
|
if player.Role == models.UserRoleMime {
|
||||||
|
room.BlueTeam.Mime = player.Username
|
||||||
|
} else {
|
||||||
|
room.BlueTeam.Guessers = append(room.BlueTeam.Guessers, player.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if player.IsBot {
|
||||||
|
room.BotMap[player.Username] = models.BotPlayer{
|
||||||
|
Role: player.Role,
|
||||||
|
Team: player.Team,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Get word cards
|
||||||
|
wordCards := []models.WordCard{}
|
||||||
|
err = sqlx.SelectContext(ctx, p.DB, &wordCards, `SELECT * FROM word_cards WHERE room_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to get cards; %w", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
room.Cards = wordCards
|
||||||
|
// Get actions
|
||||||
|
actions := []models.Action{}
|
||||||
|
err = sqlx.SelectContext(ctx, p.DB, &actions, `SELECT * FROM actions WHERE room_id = ? ORDER BY created_at ASC`, id)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to get actions; %w", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
room.ActionHistory = actions
|
||||||
|
// Get settings
|
||||||
|
settings := &models.GameSettings{}
|
||||||
|
err = sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("failed to get settings; %w", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
room.Settings = *settings
|
||||||
|
return room, nil
|
||||||
|
}
|
376
repos/rooms_test.go
Normal file
376
repos/rooms_test.go
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestDB(t *testing.T) (*sqlx.DB, func()) {
|
||||||
|
db, err := sqlx.Connect("sqlite3", ":memory:")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Enable foreign key constraints for SQLite
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL,
|
||||||
|
team_turn TEXT NOT NULL DEFAULT '',
|
||||||
|
this_turn_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
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 IF NOT EXISTS players (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
team TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT '',
|
||||||
|
is_bot BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS card_marks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
card_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
FOREIGN KEY (card_id) REFERENCES word_cards(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS actions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
actor_color TEXT NOT NULL DEFAULT '',
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL DEFAULT '',
|
||||||
|
word_color TEXT NOT NULL DEFAULT '',
|
||||||
|
number_associated TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
language TEXT NOT NULL DEFAULT 'en',
|
||||||
|
room_pass TEXT NOT NULL DEFAULT '',
|
||||||
|
turn_time INTEGER NOT NULL DEFAULT 60,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
lifetime INTEGER NOT NULL DEFAULT 3600,
|
||||||
|
token_key TEXT NOT NULL DEFAULT '' UNIQUE,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (username) REFERENCES players(username) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
return db, func() {
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomsRepo_CreateRoom(t *testing.T) {
|
||||||
|
db, teardown := setupTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
room := &models.Room{
|
||||||
|
ID: "test_room_1",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: "test_creator",
|
||||||
|
TeamTurn: "blue",
|
||||||
|
ThisTurnLimit: 5,
|
||||||
|
OpenedThisTurn: 0,
|
||||||
|
BlueCounter: 0,
|
||||||
|
RedCounter: 0,
|
||||||
|
RedTurn: false,
|
||||||
|
MimeDone: false,
|
||||||
|
IsRunning: false,
|
||||||
|
IsOver: false,
|
||||||
|
TeamWon: "",
|
||||||
|
RoomLink: "",
|
||||||
|
Settings: models.GameSettings{
|
||||||
|
Language: "en",
|
||||||
|
RoundTime: 60,
|
||||||
|
RoomPass: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.RoomCreate(context.Background(), room)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the room was created
|
||||||
|
var retrievedRoom models.Room
|
||||||
|
err = db.Get(&retrievedRoom, "SELECT * FROM rooms WHERE id = ?", room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, room.ID, retrievedRoom.ID)
|
||||||
|
assert.Equal(t, room.CreatorName, retrievedRoom.CreatorName)
|
||||||
|
|
||||||
|
var retrievedSettings models.GameSettings
|
||||||
|
err = db.Get(&retrievedSettings, "SELECT id, language, room_pass, turn_time FROM settings WHERE room_id = ?", room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, room.Settings.Language, retrievedSettings.Language)
|
||||||
|
assert.Equal(t, room.Settings.RoundTime, retrievedSettings.RoundTime)
|
||||||
|
assert.Equal(t, room.Settings.RoomPass, retrievedSettings.RoomPass)
|
||||||
|
assert.Equal(t, room.Settings.RoundTime, retrievedSettings.RoundTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomsRepo_GetRoomByID(t *testing.T) {
|
||||||
|
db, teardown := setupTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
room := &models.Room{
|
||||||
|
ID: "test_room_2",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: "test_creator_2",
|
||||||
|
TeamTurn: "red",
|
||||||
|
ThisTurnLimit: 5,
|
||||||
|
OpenedThisTurn: 0,
|
||||||
|
BlueCounter: 0,
|
||||||
|
RedCounter: 0,
|
||||||
|
RedTurn: true,
|
||||||
|
MimeDone: false,
|
||||||
|
IsRunning: false,
|
||||||
|
IsOver: false,
|
||||||
|
TeamWon: "",
|
||||||
|
RoomLink: "",
|
||||||
|
Settings: models.GameSettings{
|
||||||
|
Language: "en",
|
||||||
|
RoundTime: 60,
|
||||||
|
RoomPass: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert a room directly into the database for testing GetRoomByID
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsRunning, room.IsOver, room.TeamWon, room.RoomLink)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room.ID, room.Settings.Language, room.Settings.RoomPass, room.Settings.RoundTime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
retrievedRoom, err := repo.RoomGetByID(context.Background(), room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, retrievedRoom)
|
||||||
|
assert.Equal(t, room.ID, retrievedRoom.ID)
|
||||||
|
assert.Equal(t, room.CreatorName, retrievedRoom.CreatorName)
|
||||||
|
assert.Equal(t, room.Settings.Language, retrievedRoom.Settings.Language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomsRepo_ListRooms(t *testing.T) {
|
||||||
|
db, teardown := setupTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
room1 := &models.Room{
|
||||||
|
ID: "list_room_1",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: "list_creator_1",
|
||||||
|
TeamTurn: "blue",
|
||||||
|
ThisTurnLimit: 5,
|
||||||
|
OpenedThisTurn: 0,
|
||||||
|
BlueCounter: 0,
|
||||||
|
RedCounter: 0,
|
||||||
|
RedTurn: false,
|
||||||
|
MimeDone: false,
|
||||||
|
IsRunning: false,
|
||||||
|
IsOver: false,
|
||||||
|
TeamWon: "",
|
||||||
|
RoomLink: "",
|
||||||
|
Settings: models.GameSettings{
|
||||||
|
Language: "en",
|
||||||
|
RoundTime: 60,
|
||||||
|
RoomPass: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
room2 := &models.Room{
|
||||||
|
ID: "list_room_2",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: "list_creator_2",
|
||||||
|
TeamTurn: "red",
|
||||||
|
ThisTurnLimit: 5,
|
||||||
|
OpenedThisTurn: 0,
|
||||||
|
BlueCounter: 0,
|
||||||
|
RedCounter: 0,
|
||||||
|
RedTurn: true,
|
||||||
|
MimeDone: false,
|
||||||
|
IsRunning: false,
|
||||||
|
IsOver: false,
|
||||||
|
TeamWon: "",
|
||||||
|
RoomLink: "",
|
||||||
|
Settings: models.GameSettings{
|
||||||
|
Language: "en",
|
||||||
|
RoundTime: 60,
|
||||||
|
RoomPass: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(`INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room1.ID, room1.CreatedAt, room1.CreatorName, room1.TeamTurn, room1.ThisTurnLimit, room1.OpenedThisTurn, room1.BlueCounter, room1.RedCounter, room1.RedTurn, room1.MimeDone, room1.IsRunning, room1.IsOver, room1.TeamWon, room1.RoomLink)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room1.ID, room1.Settings.Language, room1.Settings.RoomPass, room1.Settings.RoundTime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room2.ID, room2.CreatedAt, room2.CreatorName, room2.TeamTurn, room2.ThisTurnLimit, room2.OpenedThisTurn, room2.BlueCounter, room2.RedCounter, room2.RedTurn, room2.MimeDone, room2.IsRunning, room2.IsOver, room2.TeamWon, room2.RoomLink)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?)`, room2.ID, room2.Settings.Language, room2.Settings.RoomPass, room2.Settings.RoundTime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
rooms, err := repo.RoomList(context.Background())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, rooms, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomsRepo_DeleteRoomByID(t *testing.T) {
|
||||||
|
db, teardown := setupTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
room := &models.Room{
|
||||||
|
ID: "delete_room_1",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: "delete_creator_1",
|
||||||
|
TeamTurn: "blue",
|
||||||
|
ThisTurnLimit: 5,
|
||||||
|
OpenedThisTurn: 0,
|
||||||
|
BlueCounter: 0,
|
||||||
|
RedCounter: 0,
|
||||||
|
RedTurn: false,
|
||||||
|
MimeDone: false,
|
||||||
|
IsRunning: false,
|
||||||
|
IsOver: false,
|
||||||
|
TeamWon: "",
|
||||||
|
RoomLink: "",
|
||||||
|
Settings: models.GameSettings{
|
||||||
|
Language: "en",
|
||||||
|
RoundTime: 60,
|
||||||
|
RoomPass: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(`INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsRunning, room.IsOver, room.TeamWon, room.RoomLink)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?, ?)`, room.ID, room.Settings.Language, room.Settings.RoomPass, room.Settings.RoundTime, room.Settings.TurnSecondsLeft)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Insert a word card for the room
|
||||||
|
wordCard := &models.WordCard{
|
||||||
|
RoomID: room.ID,
|
||||||
|
Word: "test_word",
|
||||||
|
Color: models.WordColorBlue,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, wordCard.RoomID, wordCard.Word, wordCard.Color, wordCard.Revealed, wordCard.Mime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = repo.RoomDeleteByID(context.Background(), room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM rooms WHERE id = ?", room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM settings WHERE room_id = ?", room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomsRepo_UpdateRoom(t *testing.T) {
|
||||||
|
db, teardown := setupTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
room := &models.Room{
|
||||||
|
ID: "update_room_1",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: "update_creator_1",
|
||||||
|
TeamTurn: "blue",
|
||||||
|
ThisTurnLimit: 5,
|
||||||
|
OpenedThisTurn: 0,
|
||||||
|
BlueCounter: 0,
|
||||||
|
RedCounter: 0,
|
||||||
|
RedTurn: false,
|
||||||
|
MimeDone: false,
|
||||||
|
IsRunning: false,
|
||||||
|
IsOver: false,
|
||||||
|
TeamWon: "",
|
||||||
|
RoomLink: "",
|
||||||
|
Settings: models.GameSettings{
|
||||||
|
Language: "en",
|
||||||
|
RoundTime: 60,
|
||||||
|
RoomPass: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Exec(`INSERT INTO rooms (id, created_at, creator_name, team_turn, this_turn_limit, opened_this_turn, blue_counter, red_counter, red_turn, mime_done, is_running, is_over, team_won, room_link) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName, room.TeamTurn, room.ThisTurnLimit, room.OpenedThisTurn, room.BlueCounter, room.RedCounter, room.RedTurn, room.MimeDone, room.IsRunning, room.IsOver, room.TeamWon, room.RoomLink)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time) VALUES (?, ?, ?, ?, ?)`, room.ID, room.Settings.Language, room.Settings.RoomPass, room.Settings.RoundTime, room.Settings.TurnSecondsLeft)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
room.TeamTurn = "red"
|
||||||
|
room.BlueCounter = 10
|
||||||
|
room.Settings.RoundTime = 120
|
||||||
|
|
||||||
|
|
||||||
|
err = repo.RoomUpdate(context.Background(), room)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var updatedRoom models.Room
|
||||||
|
err = db.Get(&updatedRoom, "SELECT * FROM rooms WHERE id = ?", room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, models.UserTeam("red"), updatedRoom.TeamTurn)
|
||||||
|
assert.Equal(t, uint8(10), updatedRoom.BlueCounter)
|
||||||
|
|
||||||
|
var updatedSettings models.GameSettings
|
||||||
|
err = db.Get(&updatedSettings, "SELECT * FROM settings WHERE room_id = ?", room.ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, uint32(120), updatedSettings.RoundTime)
|
||||||
|
assert.Equal(t, uint32(30), updatedSettings.TurnSecondsLeft)
|
||||||
|
}
|
49
repos/session.go
Normal file
49
repos/session.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SessionsRepo interface {
|
||||||
|
SessionByToken(ctx context.Context, token string) (*models.Session, error)
|
||||||
|
SessionCreate(ctx context.Context, session *models.Session) error
|
||||||
|
SessionUpdate(ctx context.Context, session *models.Session) error
|
||||||
|
SessionDelete(ctx context.Context, token string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) SessionByToken(ctx context.Context, token string) (*models.Session, error) {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
session := &models.Session{}
|
||||||
|
// The lifetime in the DB is in seconds, but in the model it is in minutes.
|
||||||
|
err := sqlx.GetContext(ctx, db, session, `SELECT id, updated_at, lifetime / 60 as lifetime, token_key, username FROM sessions WHERE token_key = ? LIMIT 1;`, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) SessionCreate(ctx context.Context, session *models.Session) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
// The lifetime in the model is in minutes, but in the DB it is in seconds.
|
||||||
|
_, err := db.ExecContext(ctx, `INSERT INTO sessions (updated_at, lifetime, token_key, username) VALUES (?, ?, ?, ?) ON CONFLICT (token_key) DO UPDATE SET updated_at=CURRENT_TIMESTAMP, lifetime=excluded.lifetime;`,
|
||||||
|
time.Now(), session.Lifetime*60, session.TokenKey, session.Username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) SessionUpdate(ctx context.Context, session *models.Session) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
// The lifetime in the model is in minutes, but in the DB it is in seconds.
|
||||||
|
_, err := db.ExecContext(ctx, `UPDATE sessions SET updated_at = ?, lifetime = ? WHERE token_key = ?`,
|
||||||
|
time.Now(), session.Lifetime*60, session.TokenKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) SessionDelete(ctx context.Context, token string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM sessions WHERE token_key = ?`, token)
|
||||||
|
return err
|
||||||
|
}
|
35
repos/settings.go
Normal file
35
repos/settings.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsRepo interface {
|
||||||
|
SettingsGetByRoomID(ctx context.Context, roomID string) (*models.GameSettings, error)
|
||||||
|
SettingsUpdate(ctx context.Context, settings *models.GameSettings) error
|
||||||
|
SettingsDeleteByRoomID(ctx context.Context, roomID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) SettingsGetByRoomID(ctx context.Context, roomID string) (*models.GameSettings, error) {
|
||||||
|
settings := &models.GameSettings{}
|
||||||
|
err := sqlx.GetContext(ctx, p.DB, settings, `SELECT * FROM settings WHERE room_id = ?`, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) SettingsUpdate(ctx context.Context, s *models.GameSettings) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `UPDATE settings SET language = ?, room_pass = ?, turn_time = ? WHERE room_id = ?`, s.Language, s.RoomPass, s.RoundTime, s.RoomID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) SettingsDeleteByRoomID(ctx context.Context, roomID string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM settings WHERE room_id = ?`, roomID)
|
||||||
|
return err
|
||||||
|
}
|
57
repos/settings_test.go
Normal file
57
repos/settings_test.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsRepo_SettingsUpdate(t *testing.T) {
|
||||||
|
db, teardown := setupTestDB(t)
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
|
||||||
|
// Create a dummy room first
|
||||||
|
room := &models.Room{
|
||||||
|
ID: "test_room_settings",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatorName: "test_creator",
|
||||||
|
}
|
||||||
|
_, err := db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, room.ID, room.CreatedAt, room.CreatorName)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
settings := &models.GameSettings{
|
||||||
|
RoomID: "test_room_settings",
|
||||||
|
Language: "en",
|
||||||
|
RoomPass: "pass123",
|
||||||
|
RoundTime: 60,
|
||||||
|
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert initial settings
|
||||||
|
_, err = db.Exec(`INSERT INTO settings (room_id, language, room_pass, turn_time, created_at) VALUES (?, ?, ?, ?, ?)`, settings.RoomID, settings.Language, settings.RoomPass, settings.RoundTime, settings.CreatedAt)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Update settings
|
||||||
|
settings.RoundTime = 120
|
||||||
|
|
||||||
|
settings.Language = "ru"
|
||||||
|
|
||||||
|
err = repo.SettingsUpdate(context.Background(), settings)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify updated settings
|
||||||
|
var updatedSettings models.GameSettings
|
||||||
|
err = db.Get(&updatedSettings, "SELECT * FROM settings WHERE room_id = ?", settings.RoomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, uint32(120), updatedSettings.RoundTime)
|
||||||
|
|
||||||
|
assert.Equal(t, "ru", updatedSettings.Language)
|
||||||
|
}
|
59
repos/word_cards.go
Normal file
59
repos/word_cards.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WordCardsRepo interface {
|
||||||
|
WordCardsCreate(ctx context.Context, card *models.WordCard) error
|
||||||
|
WordCardsGetByRoomID(ctx context.Context, roomID string) ([]models.WordCard, error)
|
||||||
|
WordCardGetByWordAndRoomID(ctx context.Context, word, roomID string) (*models.WordCard, error)
|
||||||
|
WordCardReveal(ctx context.Context, word, roomID string) error
|
||||||
|
WordCardsRevealAll(ctx context.Context, roomID string) error
|
||||||
|
WordCardsDeleteByRoomID(ctx context.Context, roomID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) WordCardsCreate(ctx context.Context, card *models.WordCard) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card.RoomID, card.Word, card.Color, card.Revealed, card.Mime)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) WordCardsGetByRoomID(ctx context.Context, roomID string) ([]models.WordCard, error) {
|
||||||
|
cards := []models.WordCard{}
|
||||||
|
err := sqlx.SelectContext(ctx, p.DB, &cards, `SELECT * FROM word_cards WHERE room_id = ?`, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return cards, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) WordCardGetByWordAndRoomID(ctx context.Context, word, roomID string) (*models.WordCard, error) {
|
||||||
|
card := &models.WordCard{}
|
||||||
|
err := sqlx.GetContext(ctx, p.DB, card, `SELECT * FROM word_cards WHERE word = ? AND room_id = ?`, word, roomID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) WordCardReveal(ctx context.Context, word, roomID string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `UPDATE word_cards SET revealed = TRUE WHERE word = ? AND room_id = ?`, word, roomID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) WordCardsRevealAll(ctx context.Context, roomID string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `UPDATE word_cards SET revealed = TRUE WHERE room_id = ?`, roomID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RepoProvider) WordCardsDeleteByRoomID(ctx context.Context, roomID string) error {
|
||||||
|
db := getDB(ctx, p.DB)
|
||||||
|
_, err := db.ExecContext(ctx, `DELETE FROM word_cards WHERE room_id = ?`, roomID)
|
||||||
|
return err
|
||||||
|
}
|
496
repos/word_cards_test.go
Normal file
496
repos/word_cards_test.go
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
package repos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"gralias/models"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWordCardsRepo_Create(t *testing.T) {
|
||||||
|
// Setup temporary file-based SQLite database for this test
|
||||||
|
tempFile, err := os.CreateTemp("", "test_db_*.db")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
db, err := sqlx.Connect("sqlite3", tempFile.Name())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Enable foreign key constraints
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Apply schema
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
roomID := "test_room_1"
|
||||||
|
// Insert a room first, as word_cards has a foreign key to rooms
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test single card creation
|
||||||
|
card1 := &models.WordCard{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "apple",
|
||||||
|
Color: models.WordColorRed,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
_, err = db.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card1.RoomID, card1.Word, card1.Color, card1.Revealed, card1.Mime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, count)
|
||||||
|
|
||||||
|
// Test batch card creation with transaction commit
|
||||||
|
tx, err := db.BeginTxx(ctx, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
card2 := &models.WordCard{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "banana",
|
||||||
|
Color: models.WordColorBlue,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
card3 := &models.WordCard{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "cherry",
|
||||||
|
Color: models.WordColorBlack,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card2.RoomID, card2.Word, card2.Color, card2.Revealed, card2.Mime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = tx.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card3.RoomID, card3.Word, card3.Color, card3.Revealed, card3.Mime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Before commit, count should not reflect changes if using a transaction context
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, count) // Should still be 1 if not committed
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// After commit, count should reflect changes
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, count)
|
||||||
|
|
||||||
|
// Test transaction rollback
|
||||||
|
tx2, err := db.BeginTxx(ctx, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
card4 := &models.WordCard{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "date",
|
||||||
|
Color: models.WordColorWhite,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
_, err = tx2.Exec(`INSERT INTO word_cards (room_id, word, color, revealed, mime_view) VALUES (?, ?, ?, ?, ?)`, card4.RoomID, card4.Word, card4.Color, card4.Revealed, card4.Mime)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = tx2.Rollback()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// After rollback, count should not reflect changes
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 3, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
func TestWordCardsRepo_GetByWordAndRoomID(t *testing.T) {
|
||||||
|
// Setup temporary file-based SQLite database for this test
|
||||||
|
tempFile, err := os.CreateTemp("", "test_db_*.db")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
db, err := sqlx.Connect("sqlite3", tempFile.Name())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Enable foreign key constraints
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Apply schema
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
roomID := "test_room_3"
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
card := &models.WordCard{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "gamma",
|
||||||
|
Color: models.WordColorRed,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
err = repo.WordCardsCreate(ctx, card)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
retrievedCard, err := repo.WordCardGetByWordAndRoomID(ctx, "gamma", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, retrievedCard)
|
||||||
|
assert.Equal(t, "gamma", retrievedCard.Word)
|
||||||
|
assert.Equal(t, roomID, retrievedCard.RoomID)
|
||||||
|
|
||||||
|
// Test non-existent card
|
||||||
|
_, err = repo.WordCardGetByWordAndRoomID(ctx, "non_existent", roomID)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordCardsRepo_Reveal(t *testing.T) {
|
||||||
|
// Setup temporary file-based SQLite database for this test
|
||||||
|
tempFile, err := os.CreateTemp("", "test_db_*.db")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
db, err := sqlx.Connect("sqlite3", tempFile.Name())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Enable foreign key constraints
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Apply schema
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
roomID := "test_room_4"
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
card := &models.WordCard{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "delta",
|
||||||
|
Color: models.WordColorRed,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
err = repo.WordCardsCreate(ctx, card)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
var revealed bool
|
||||||
|
err = db.Get(&revealed, "SELECT revealed FROM word_cards WHERE word = ? AND room_id = ?", "delta", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, revealed)
|
||||||
|
|
||||||
|
// Reveal the card
|
||||||
|
err = repo.WordCardReveal(ctx, "delta", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify revealed state
|
||||||
|
err = db.Get(&revealed, "SELECT revealed FROM word_cards WHERE word = ? AND room_id = ?", "delta", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, revealed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordCardsRepo_RevealAll(t *testing.T) {
|
||||||
|
// Setup temporary file-based SQLite database for this test
|
||||||
|
tempFile, err := os.CreateTemp("", "test_db_*.db")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
db, err := sqlx.Connect("sqlite3", tempFile.Name())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Enable foreign key constraints
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Apply schema
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
roomID := "test_room_5"
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
cardsToInsert := []*models.WordCard{
|
||||||
|
{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "echo",
|
||||||
|
Color: models.WordColorRed,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RoomID: roomID,
|
||||||
|
Color: models.WordColorBlue,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, card := range cardsToInsert {
|
||||||
|
err = repo.WordCardsCreate(ctx, card)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
var count int
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ? AND revealed = FALSE", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, count)
|
||||||
|
|
||||||
|
// Reveal all cards
|
||||||
|
err = repo.WordCardsRevealAll(ctx, roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify all cards are revealed
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ? AND revealed = FALSE", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordCardsRepo_DeleteByRoomID(t *testing.T) {
|
||||||
|
// Setup temporary file-based SQLite database for this test
|
||||||
|
tempFile, err := os.CreateTemp("", "test_db_*.db")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
db, err := sqlx.Connect("sqlite3", tempFile.Name())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Enable foreign key constraints
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Apply schema
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
roomID := "test_room_6"
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
cardsToInsert := []*models.WordCard{
|
||||||
|
{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "golf",
|
||||||
|
Color: models.WordColorRed,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "hotel",
|
||||||
|
Color: models.WordColorBlue,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, card := range cardsToInsert {
|
||||||
|
err = repo.WordCardsCreate(ctx, card)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify initial state
|
||||||
|
var count int
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, count)
|
||||||
|
|
||||||
|
// Delete cards by room ID
|
||||||
|
err = repo.WordCardsDeleteByRoomID(ctx, roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify cards are deleted
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordCardsRepo_CascadeDeleteRoom(t *testing.T) {
|
||||||
|
// Setup temporary file-based SQLite database for this test
|
||||||
|
tempFile, err := os.CreateTemp("", "test_db_*.db")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
tempFile.Close()
|
||||||
|
defer os.Remove(tempFile.Name())
|
||||||
|
|
||||||
|
db, err := sqlx.Connect("sqlite3", tempFile.Name())
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Enable foreign key constraints
|
||||||
|
_, err = db.Exec("PRAGMA foreign_keys = ON;")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Apply schema
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
creator_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS word_cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
revealed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mime_view BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`
|
||||||
|
_, err = db.Exec(schema)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
repo := &RepoProvider{DB: db}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
roomID := "test_room_7"
|
||||||
|
_, err = db.Exec(`INSERT INTO rooms (id, created_at, creator_name) VALUES (?, ?, ?)`, roomID, time.Now(), "test_creator")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
card := &models.WordCard{
|
||||||
|
RoomID: roomID,
|
||||||
|
Word: "india",
|
||||||
|
Color: models.WordColorRed,
|
||||||
|
Revealed: false,
|
||||||
|
Mime: false,
|
||||||
|
}
|
||||||
|
err = repo.WordCardsCreate(ctx, card)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, count)
|
||||||
|
|
||||||
|
_, err = db.Exec(`DELETE FROM rooms WHERE id = ?`, roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = db.Get(&count, "SELECT COUNT(*) FROM word_cards WHERE room_id = ?", roomID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
74
todos.md
Normal file
74
todos.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
### feats
|
||||||
|
- implement transactional pattern in db write methods; +
|
||||||
|
- implement the db methods for sessions in repos/session.go; +
|
||||||
|
- auto close room if nothing is going on there (hmm) for ~1h; +
|
||||||
|
- words database (file) load and form random 25 words; +
|
||||||
|
- invite link; +
|
||||||
|
- login with invite link; +
|
||||||
|
- there three places for bot to check if its its move: start-game; end-turn, after mime gave clue; +
|
||||||
|
- remove bot button (if game is not running, or bot already added); +
|
||||||
|
- hide clue input for mime when it's not their turn; +
|
||||||
|
- needs resend to llm btn; +
|
||||||
|
- check if clue word is the same as one of the cards and return err if it is; +
|
||||||
|
- autoscroll down backlog on update; +
|
||||||
|
- instead of guessing all words at ones, ask only for 1 word to be open. +
|
||||||
|
- ways to remove bots from teams; +
|
||||||
|
- mark cards (instead of opening them (right click?); +
|
||||||
|
- on end of turn clear all the marks; +
|
||||||
|
- different files for each supported lang; +
|
||||||
|
- sse div to bot thinking; +
|
||||||
|
- simplify mime prompt; +
|
||||||
|
- redo card .revealed use: it should mean that card is revealed for everybody, while mime should be able to see cards as is; +
|
||||||
|
- better styles and fluff;
|
||||||
|
- common auth system between sites;
|
||||||
|
===
|
||||||
|
- 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;
|
||||||
|
- cleanup backlog after new game is started;
|
||||||
|
- ended turn action to backlog;
|
||||||
|
===
|
||||||
|
- clear indication that model (llm) is thinking / answered;
|
||||||
|
- possibly turn markings into parts of names of users (first three letters?);
|
||||||
|
- at game creation list languages and support them at backend;
|
||||||
|
- sql ping goroutine with reconnect on fail;
|
||||||
|
|
||||||
|
#### sse points
|
||||||
|
- clue sse update;
|
||||||
|
- join team;
|
||||||
|
- start game;
|
||||||
|
- end turn;
|
||||||
|
- open card;
|
||||||
|
- game over;
|
||||||
|
- timer per turn (when added);
|
||||||
|
|
||||||
|
|
||||||
|
### issues
|
||||||
|
- after the game started (isrunning) players should be able join guessers, but not switch team, or join as a mime; +
|
||||||
|
- guessers should not be able to open more cards, than mime gave them +1 (auto end turn); +
|
||||||
|
- mime rejoined the room: does not see colors; state save in store.json has empty role and team +
|
||||||
|
- restart bot routines after server restart; +
|
||||||
|
- remove verbs from word file; +
|
||||||
|
- if mime joins another role, he stays as mime (before game start); +
|
||||||
|
- guesser llm makes up words, likely the prompt should be more clear; +
|
||||||
|
- remove bot does not remove for player roles in the room; +
|
||||||
|
- guesser did not have same number of guesses (move ended after 1 guess); show how much guesses left on the page (red after blue); +
|
||||||
|
- 0 should mean without limit; +
|
||||||
|
- remove join as mime button if there is a mime already on that team (rewrite teampew templ); +
|
||||||
|
- bot clues to lowercase; +
|
||||||
|
- guesser bot no request after game restart; (not a bug)
|
||||||
|
- openrouter 429 errors (retry); +
|
||||||
|
- retry call to llm (if 400|429|4xx); +
|
||||||
|
- there is a clue window for a mime before game started; +
|
||||||
|
- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine) (did not reoccur so far);
|
||||||
|
- invite link gets cutoff;
|
||||||
|
- when llm guesses the word it is not removed from a pool of words making it keep guessing it;
|
||||||
|
- bot team does not loses their turn after white card (or limit);
|
||||||
|
- name check does not work;
|
||||||
|
- game did not end when all blue cards were open;
|
||||||
|
- bot ends a turn after guessing one word only;
|
||||||
|
- sync writing to json cache; what happens now: timer (or other side routine) overwrites old room, while mime making clue;
|
||||||
|
-----------------
|
||||||
|
- instant timer over;
|
||||||
|
- bot does not react;
|
||||||
|
{"time":"2025-07-04T16:01:57.755750352+03:00","level":"INFO","source":{"function":"gralias/handlers.StartTurnTimer.func1","file":"/home/grail/projects/web/gralias/handlers/timer.go","line":43},"msg":"turn time is over","room_id":"d1jt16sg3nfp9p4thgl0"}
|
||||||
|
looking for bot to move team-turn: red mime-done: false bot-map: map[] is_running: true blueMime: redMime:
|
@ -22,3 +22,16 @@ func StrInSlice(key string, sl []string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func RemoveFromSlice(key string, sl []string) []string {
|
||||||
|
if !StrInSlice(key, sl) {
|
||||||
|
return sl
|
||||||
|
}
|
||||||
|
resp := []string{}
|
||||||
|
for _, el := range sl {
|
||||||
|
if el != key {
|
||||||
|
resp = append(resp, el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
80
wordloader/wordloader.go
Normal file
80
wordloader/wordloader.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package wordloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gralias/models"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Loader struct {
|
||||||
|
FilePath string
|
||||||
|
BlackCount uint8
|
||||||
|
WhiteCount uint8
|
||||||
|
RedCount uint8
|
||||||
|
BlueCount uint8
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitDefaultLoader(fpath string) *Loader {
|
||||||
|
return &Loader{
|
||||||
|
FilePath: fpath,
|
||||||
|
BlackCount: 1,
|
||||||
|
WhiteCount: 7,
|
||||||
|
RedCount: 8,
|
||||||
|
BlueCount: 9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loader) WholeCount() uint8 {
|
||||||
|
return l.BlackCount + l.WhiteCount + l.RedCount + l.BlueCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loader) Load() ([]models.WordCard, error) {
|
||||||
|
data, err := os.ReadFile(l.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
rand.Shuffle(len(lines), func(i, j int) {
|
||||||
|
lines[i], lines[j] = lines[j], lines[i]
|
||||||
|
})
|
||||||
|
words := lines[:int(l.WholeCount())]
|
||||||
|
cards := make([]models.WordCard, l.WholeCount())
|
||||||
|
for i, word := range words {
|
||||||
|
if l.BlackCount > 0 {
|
||||||
|
cards[i] = models.WordCard{
|
||||||
|
Word: word,
|
||||||
|
Color: models.WordColorBlack,
|
||||||
|
}
|
||||||
|
l.BlackCount--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if l.WhiteCount > 0 {
|
||||||
|
cards[i] = models.WordCard{
|
||||||
|
Word: word,
|
||||||
|
Color: models.WordColorWhite,
|
||||||
|
}
|
||||||
|
l.WhiteCount--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if l.RedCount > 0 {
|
||||||
|
cards[i] = models.WordCard{
|
||||||
|
Word: word,
|
||||||
|
Color: models.WordColorRed,
|
||||||
|
}
|
||||||
|
l.RedCount--
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if l.BlueCount > 0 {
|
||||||
|
cards[i] = models.WordCard{
|
||||||
|
Word: word,
|
||||||
|
Color: models.WordColorBlue,
|
||||||
|
}
|
||||||
|
l.BlueCount--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rand.Shuffle(len(cards), func(i, j int) {
|
||||||
|
cards[i], cards[j] = cards[j], cards[i]
|
||||||
|
})
|
||||||
|
return cards, nil
|
||||||
|
}
|
Reference in New Issue
Block a user