Compare commits
59 Commits
d18855cd49
...
master
Author | SHA1 | Date | |
---|---|---|---|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
.aider*
|
||||
golias
|
||||
store.json
|
||||
config.toml
|
||||
|
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$
|
2
Makefile
2
Makefile
@ -32,4 +32,4 @@ stop-container:
|
||||
docker rm -f golias 2>/dev/null && echo "old container removed"
|
||||
|
||||
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=golias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:3000:3000 -d golias:master
|
||||
|
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() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
htmx.defineExtension('sse', {
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource
|
||||
}
|
||||
},
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource;
|
||||
}
|
||||
},
|
||||
getSelectors: function() {
|
||||
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
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":
|
||||
var internalData = api.getInternalData(evt.target)
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
if (internalData.sseEventSource) {
|
||||
internalData.sseEventSource.close();
|
||||
}
|
||||
// Try to create EventSources when elements are processed
|
||||
case 'htmx:afterProcessNode':
|
||||
ensureEventSourceOnElement(parent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return;
|
||||
/// ////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
/// ////////////////////////////////////////////
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case "htmx:afterProcessNode":
|
||||
ensureEventSourceOnElement(evt.target);
|
||||
registerSSE(evt.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @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
|
||||
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||
if (closeAttribute) {
|
||||
// close eventsource when this message is received
|
||||
source.addEventListener(closeAttribute, function() {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'message',
|
||||
})
|
||||
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");
|
||||
var returnArr = [];
|
||||
if (legacySSEValue != null) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "swap") {
|
||||
returnArr.push(value[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return returnArr;
|
||||
}
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
api.withExtensions(elt, function(extension) {
|
||||
content = extension.transformResponse(content, null, elt)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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
|
||||
}
|
||||
var swapSpec = api.getSwapSpecification(elt)
|
||||
var target = api.getTarget(elt)
|
||||
api.swap(target, content, swapSpec)
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement);
|
||||
var source = internalData.sseEventSource;
|
||||
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
})();
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null
|
||||
}
|
||||
})()
|
||||
|
@ -45,3 +45,25 @@ tr{
|
||||
border: 1px solid black;
|
||||
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
5909
assets/words/en_nouns.txt
Normal file
5909
assets/words/en_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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
17
components/actionhistory.html
Normal file
17
components/actionhistory.html
Normal file
@ -0,0 +1,17 @@
|
||||
{{define "actionhistory"}}
|
||||
<div 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>
|
||||
{{end}}
|
10
components/addbotbtn.html
Normal file
10
components/addbotbtn.html
Normal file
@ -0,0 +1,10 @@
|
||||
{{ define "addbot" }}
|
||||
<div>
|
||||
<button hx-get="/add-bot?team=blue&role=mime" hx-target="#addbot" class="bg-blue-400 text-black px-4 py-2 rounded">Add Bot Mime</button>
|
||||
<button hx-get="/add-bot?team=red&role=mime" hx-target="#addbot" class="bg-red-400 text-black px-4 py-2 rounded">Add Bot Mime</button>
|
||||
</div>
|
||||
<div>
|
||||
<button hx-get="/add-bot?team=blue&role=guesser" hx-target="#addbot" class="bg-blue-300 text-black px-4 py-2 rounded">Add Bot Guesser</button>
|
||||
<button hx-get="/add-bot?team=red&role=guesser" hx-target="#addbot" class="bg-red-300 text-black px-4 py-2 rounded">Add Bot Guesser</button>
|
||||
</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}}
|
6
components/cardcounter.html
Normal file
6
components/cardcounter.html
Normal file
@ -0,0 +1,6 @@
|
||||
{{define "cardcounter"}}
|
||||
<div class="flex justify-center">
|
||||
<p>Blue cards left: {{.BlueCounter}}</p>
|
||||
<p>Red cards left: {{.RedCounter}}</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}}
|
29
components/cardword.html
Normal file
29
components/cardword.html
Normal file
@ -0,0 +1,29 @@
|
||||
{{define "cardword"}}
|
||||
{{if .Revealed}}
|
||||
<div id="card-{{.Word}}" 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);
|
||||
cursor: pointer;"> {{.Word}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div id="card-{{.Word}}" style="
|
||||
background-color: #e4d5b7;
|
||||
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);
|
||||
cursor: pointer;"
|
||||
hx-get="/word/show-color?word={{.Word}}" hx-trigger="click" hx-swap="outerHTML transition:true swap:.05s">
|
||||
{{.Word}}
|
||||
</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">Game Time:</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,26 @@
|
||||
{{define "main"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Word Colors</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
||||
</head>
|
||||
<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}}
|
||||
Start of main temp
|
||||
{{ if not . }}
|
||||
login temp
|
||||
{{template "login"}}
|
||||
{{ else if ne .LinkLogin "" }}
|
||||
got to linklogin
|
||||
{{template "linklogin" .LinkLogin}}
|
||||
{{ else if eq .State.RoomID "" }}
|
||||
empty state roomid
|
||||
<div id="hello-user">
|
||||
<p>Hello {{.State.Username}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<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>
|
||||
<div>
|
||||
{{template "roomlist" .List}}
|
||||
</div>
|
||||
{{else}}
|
||||
else
|
||||
<div id="room">
|
||||
{{template "room" .}}
|
||||
</div>
|
||||
{{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>
|
||||
<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 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 id="login_notice">this name looks available</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}}
|
13
components/playerlist.html
Normal file
13
components/playerlist.html
Normal file
@ -0,0 +1,13 @@
|
||||
{{define "teamlist"}}
|
||||
<div class="playerlist border border-gray-300 text-{{.Color}}-500 rounded mb-2">
|
||||
<p class=border>Guessers</p>
|
||||
{{range .Guessers}}
|
||||
<p>{{.}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<hr />
|
||||
<div class="playerlist border border-gray-300 rounded mb-2 text-{{.Color}}-700">
|
||||
<p class=border>Mime</p>
|
||||
<p>{{.Mime}}</p>
|
||||
</div>
|
||||
{{end}}
|
68
components/room.html
Normal file
68
components/room.html
Normal file
@ -0,0 +1,68 @@
|
||||
{{define "room"}}
|
||||
<div id="interier" hx-get="/" hx-trigger="sse:roomupdate_{{.State.RoomID}}">
|
||||
<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}}"></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>
|
||||
{{else}}
|
||||
<p>Turn of the <span class="text-{{.Room.TeamTurn}}-500">{{.Room.TeamTurn}}</span> team</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}}
|
||||
{{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">
|
||||
<!-- Left Panel -->
|
||||
{{template "teamlist" .Room.BlueTeam}}
|
||||
{{if and (ne .State.Team "blue") (not .Room.IsRunning)}}
|
||||
{{template "teampew" "blue"}}
|
||||
{{end}}
|
||||
<!-- Right Panel -->
|
||||
{{if and (ne .State.Team "red") (not .Room.IsRunning)}}
|
||||
{{template "teampew" "red"}}
|
||||
{{end}}
|
||||
{{template "teamlist" .Room.RedTeam}}
|
||||
</div>
|
||||
<hr />
|
||||
<div id="cardtable">
|
||||
{{template "cardtable" .Room}}
|
||||
</div>
|
||||
<div>
|
||||
{{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}}
|
||||
</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 class="room-item mb-3 p-4 border rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div class="flex justify-between items-center">
|
||||
<div hx-get="/room-join?id={{.ID}}" hx-target="#ancestor" 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 .IsGameRunning}}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}}
|
18
components/teampew.html
Normal file
18
components/teampew.html
Normal file
@ -0,0 +1,18 @@
|
||||
{{define "teampew"}}
|
||||
<div>
|
||||
<h2 class="text-xl mb-4">Join {{.}} Team</h2>
|
||||
<form hx-post="/join-team" hx-target="#ancestor">
|
||||
<input type="hidden" name="team" value="{{.}}">
|
||||
<div class="mb-1">
|
||||
<button type="submit" name="role" value="guesser" class="w-full bg-{{.}}-500 text-white py-2 px-4 rounded">
|
||||
Join as Guesser
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" name="role" value="mime" class="w-full bg-{{.}}-700 text-white py-2 px-4 rounded">
|
||||
Join as Mime
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
11
config.example.toml
Normal file
11
config.example.toml
Normal file
@ -0,0 +1,11 @@
|
||||
BASE_URL = "https://localhost:3000"
|
||||
SESSION_LIFETIME_SECONDS = 30000
|
||||
COOKIE_SECRET = "test"
|
||||
|
||||
[SERVICE]
|
||||
HOST = "localhost"
|
||||
PORT = "3000"
|
||||
|
||||
[LLM]
|
||||
TOKEN = ""
|
||||
URL = "https://api.deepseek.com/beta"
|
@ -10,8 +10,8 @@ type Config struct {
|
||||
ServerConfig ServerConfig `toml:"SERVICE"`
|
||||
BaseURL string `toml:"BASE_URL"`
|
||||
SessionLifetime int `toml:"SESSION_LIFETIME_SECONDS"`
|
||||
DBURI string `toml:"DBURI"`
|
||||
CookieSecret string `toml:"COOKIE_SECRET"`
|
||||
LLMConfig LLMConfig `toml:"LLM"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@ -19,6 +19,11 @@ type ServerConfig struct {
|
||||
Port string `toml:"PORT"`
|
||||
}
|
||||
|
||||
type LLMConfig struct {
|
||||
URL string `toml:"LLM_URL"`
|
||||
TOKEN string `toml:"LLM_TOKEN"`
|
||||
}
|
||||
|
||||
func LoadConfigOrDefault(fn string) *Config {
|
||||
if fn == "" {
|
||||
fn = "config.toml"
|
||||
@ -28,8 +33,10 @@ func LoadConfigOrDefault(fn string) *Config {
|
||||
if err != nil {
|
||||
slog.Warn("failed to read config from file, loading default", "error", err)
|
||||
config.BaseURL = "https://localhost:3000"
|
||||
config.SessionLifetime = 300
|
||||
config.SessionLifetime = 30000
|
||||
config.CookieSecret = "test"
|
||||
config.ServerConfig.Host = "localhost"
|
||||
config.ServerConfig.Port = "3000"
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
4
go.mod
4
go.mod
@ -4,7 +4,5 @@ go 1.24
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
honnef.co/go/tools v0.6.1
|
||||
github.com/rs/xid v1.6.0
|
||||
)
|
||||
|
||||
require golang.org/x/tools v0.30.0 // indirect
|
||||
|
6
go.sum
6
go.sum
@ -1,6 +1,4 @@
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
|
||||
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
|
282
handlers/actions.go
Normal file
282
handlers/actions.go
Normal file
@ -0,0 +1,282 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golias/broker"
|
||||
"golias/llmapi"
|
||||
"golias/models"
|
||||
"golias/utils"
|
||||
"golias/wordloader"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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 := saveRoom(room); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return room, nil
|
||||
}
|
||||
|
||||
func saveRoom(room *models.Room) error {
|
||||
key := models.CacheRoomPrefix + room.ID
|
||||
data, err := json.Marshal(room)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memcache.Set(key, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRoomByID(roomID string) (*models.Room, error) {
|
||||
roomBytes, err := memcache.Get(models.CacheRoomPrefix + roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &models.Room{}
|
||||
if err := json.Unmarshal(roomBytes, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func removeRoom(roomID string) {
|
||||
key := models.CacheRoomPrefix + roomID
|
||||
memcache.RemoveKey(key)
|
||||
}
|
||||
|
||||
// context
|
||||
|
||||
func getStateByCtx(ctx context.Context) (*models.UserState, error) {
|
||||
username, ok := ctx.Value(models.CtxUsernameKey).(string)
|
||||
if !ok {
|
||||
log.Debug("no username in ctx")
|
||||
return &models.UserState{}, errors.New("no username in ctx")
|
||||
}
|
||||
us, err := loadState(username)
|
||||
if err != nil {
|
||||
return &models.UserState{}, err
|
||||
}
|
||||
return us, nil
|
||||
}
|
||||
|
||||
func saveFullInfo(fi *models.FullInfo) error {
|
||||
// INFO: unfortunately working no transactions; so case are possible where first object is updated but the second is not
|
||||
if err := saveState(fi.State.Username, fi.State); err != nil {
|
||||
return err
|
||||
}
|
||||
// if fi.Room == nil { // can be null on exit
|
||||
// return nil
|
||||
// }
|
||||
if err := saveRoom(fi.Room); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func notifyBotIfNeeded(fi *models.FullInfo) {
|
||||
if botName := fi.Room.WhichBotToMove(); botName != "" {
|
||||
// // get bot from memcache
|
||||
// bot, err := loadBot(botName, fi.Room.ID)
|
||||
// if err != nil {
|
||||
// log.Error("failed to load bot", "bot_name", botName, "room_id", fi.Room.ID)
|
||||
// // abortWithError(w, err.Error())
|
||||
// // return
|
||||
// }
|
||||
// send signal to bot
|
||||
llmapi.SignalChanMap[botName] <- true
|
||||
}
|
||||
log.Debug("no bot", "room_id", fi.Room.ID)
|
||||
}
|
||||
|
||||
// cache
|
||||
|
||||
func saveState(username string, state *models.UserState) error {
|
||||
key := models.CacheStatePrefix + username
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memcache.Set(key, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadState(username string) (*models.UserState, error) {
|
||||
key := models.CacheStatePrefix + username
|
||||
data, err := memcache.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &models.UserState{}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func loadBot(botName, roomID string) (*llmapi.Bot, error) {
|
||||
key := "botkey_" + roomID + botName
|
||||
data, err := memcache.Get(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &llmapi.Bot{}
|
||||
if err := json.Unmarshal(data, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func getAllNames() []string {
|
||||
names := []string{}
|
||||
// will not scale
|
||||
wholeMemStore := memcache.GetAll()
|
||||
session := &models.Session{}
|
||||
// filter by key size only sessions
|
||||
for k, v := range wholeMemStore {
|
||||
// xid is 20 in len
|
||||
if len(k) != 20 {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal(v, &session); err != nil {
|
||||
log.Error("failed to unmarshal", "error", err)
|
||||
continue
|
||||
}
|
||||
names = append(names, session.Username)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// can room exists without state? I think no
|
||||
func getFullInfoByCtx(ctx context.Context) (*models.FullInfo, error) {
|
||||
resp := &models.FullInfo{}
|
||||
state, err := getStateByCtx(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.State = state
|
||||
if state.RoomID == "" {
|
||||
return resp, nil
|
||||
}
|
||||
room, err := getRoomByID(state.RoomID)
|
||||
if err != nil {
|
||||
log.Warn("failed to find room despite knowing room_id;",
|
||||
"room_id", state.RoomID)
|
||||
return nil, err
|
||||
}
|
||||
resp.Room = room
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func joinTeam(ctx context.Context, role, team string) (*models.FullInfo, error) {
|
||||
// get username
|
||||
fi, _ := getFullInfoByCtx(ctx)
|
||||
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)
|
||||
// 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"
|
||||
if fi.Room.RedTeam.Mime == fi.State.Username {
|
||||
fi.Room.RedTeam.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"
|
||||
if fi.Room.BlueTeam.Mime == fi.State.Username {
|
||||
fi.Room.BlueTeam.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(fi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
// get all rooms
|
||||
func listPublicRooms() []*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
|
||||
}
|
||||
log.Debug("consider room for list", "room", room, "key", key)
|
||||
if room.IsPublic {
|
||||
publicRooms = append(publicRooms, room)
|
||||
}
|
||||
}
|
||||
}
|
||||
return publicRooms
|
||||
}
|
||||
|
||||
func notify(event, msg string) {
|
||||
Notifier.Notifier <- broker.NotificationEvent{
|
||||
EventName: event,
|
||||
Payload: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func loadCards(room *models.Room) {
|
||||
wl := wordloader.InitDefaultLoader("assets/words/en_nouns.txt")
|
||||
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
|
||||
}
|
||||
}
|
107
handlers/auth.go
107
handlers/auth.go
@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golias/models"
|
||||
"golias/utils"
|
||||
"html/template"
|
||||
@ -14,12 +15,19 @@ import (
|
||||
)
|
||||
|
||||
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.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) {
|
||||
r.ParseForm()
|
||||
func HandleNameCheck(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
|
||||
}
|
||||
username := r.PostFormValue("username")
|
||||
if username == "" {
|
||||
msg := "username not provided"
|
||||
@ -27,9 +35,42 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
||||
abortWithError(w, msg)
|
||||
return
|
||||
}
|
||||
cleanName := utils.RemoveSpacesFromStr(username)
|
||||
allNames := getAllNames()
|
||||
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
|
||||
}
|
||||
roomID := r.PostFormValue("room_id")
|
||||
// make sure username does not exists
|
||||
cleanName := utils.RemoveSpacesFromStr(username)
|
||||
// TODO: create user in db
|
||||
// login user
|
||||
cookie, err := makeCookie(cleanName, r.RemoteAddr)
|
||||
if err != nil {
|
||||
@ -43,14 +84,48 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "main", nil)
|
||||
userstate := models.InitState(cleanName)
|
||||
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)
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
room.PlayerList = append(room.PlayerList, fi.State.Username)
|
||||
fi.State.RoomID = room.ID
|
||||
fi.Room = room
|
||||
fi.List = nil
|
||||
// save full info instead
|
||||
if err := saveFullInfo(fi); err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.Debug("no room_id in login")
|
||||
fi.List = listPublicRooms()
|
||||
// save state to cache
|
||||
if err := saveState(cleanName, userstate); err != nil {
|
||||
// if err := saveFullInfo(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)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCookie(username string, remote string) (*http.Cookie, error) {
|
||||
// secret
|
||||
// Create a new random session token
|
||||
// sessionToken := xid.New().String()
|
||||
sessionToken := "token"
|
||||
sessionToken := "sessionprefix_" + username
|
||||
expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second)
|
||||
// Set the token in the session map, along with the session information
|
||||
session := &models.Session{
|
||||
@ -77,9 +152,9 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
|
||||
log.Info("check remote addr for cookie set",
|
||||
"remote", remote, "session", session)
|
||||
if strings.Contains(remote, "192.168.0") {
|
||||
// no idea what is going on
|
||||
// cookie.Domain = "192.168.0.15"
|
||||
cookie.Domain = "home.host"
|
||||
// cookie.Domain = "192.168.0.101"
|
||||
cookie.Domain = ""
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
log.Info("changing cookie domain", "domain", cookie.Domain)
|
||||
}
|
||||
// set ctx?
|
||||
@ -108,7 +183,17 @@ func cacheSetSession(key string, session *models.Session) error {
|
||||
return err
|
||||
}
|
||||
memcache.Set(key, sesb)
|
||||
// expire in 10 min
|
||||
memcache.Expire(key, 10*60)
|
||||
// TODO: to config
|
||||
memcache.Expire(key, 60*60)
|
||||
return nil
|
||||
}
|
||||
|
||||
// unused
|
||||
// func updateRoomInSession(ctx context.Context, roomID string) (context.Context, error) {
|
||||
// s, ok := ctx.Value(models.CtxSessionKey).(*models.Session)
|
||||
// if !ok {
|
||||
// return context.TODO(), errors.New("failed to extract session from ctx")
|
||||
// }
|
||||
// s.CurrentRoom = roomID
|
||||
// return context.WithValue(ctx, "session", s), nil
|
||||
// }
|
||||
|
157
handlers/elements.go
Normal file
157
handlers/elements.go
Normal file
@ -0,0 +1,157 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"golias/llmapi"
|
||||
"golias/models"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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 fi.State.Role != "guesser" {
|
||||
err = errors.New("need to be guesser to open the card")
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
// whos move it is?
|
||||
if fi.State.Team != models.UserTeam(fi.Room.TeamTurn) {
|
||||
err = errors.New("not your team's move")
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if !fi.Room.MimeDone {
|
||||
abortWithError(w, "wait for the clue")
|
||||
return
|
||||
}
|
||||
color, exists := fi.Room.WCMap[word]
|
||||
log.Debug("got show-color request", "word", word, "color", color)
|
||||
if !exists {
|
||||
abortWithError(w, "word is not found")
|
||||
return
|
||||
}
|
||||
cardword := models.WordCard{
|
||||
Word: word,
|
||||
Color: color,
|
||||
Revealed: true,
|
||||
}
|
||||
fi.Room.RevealSpecificWord(word)
|
||||
fi.Room.UpdateCounter()
|
||||
action := models.Action{
|
||||
Actor: fi.State.Username,
|
||||
ActorColor: string(fi.State.Team),
|
||||
WordColor: string(color),
|
||||
Action: "guessed",
|
||||
Word: word,
|
||||
}
|
||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||
// if opened card is of color of opp team, change turn
|
||||
oppositeColor := fi.Room.GetOppositeTeamColor()
|
||||
switch string(color) {
|
||||
case "black":
|
||||
// game over
|
||||
fi.Room.IsRunning = false
|
||||
fi.Room.IsOver = true
|
||||
fi.Room.TeamWon = oppositeColor
|
||||
case "white", string(oppositeColor):
|
||||
// end turn
|
||||
fi.Room.TeamTurn = oppositeColor
|
||||
fi.Room.MimeDone = false
|
||||
}
|
||||
// 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"
|
||||
}
|
||||
if fi.Room.RedCounter == 0 {
|
||||
// red won
|
||||
fi.Room.IsRunning = false
|
||||
fi.Room.IsOver = true
|
||||
fi.Room.TeamWon = "red"
|
||||
}
|
||||
if err := saveFullInfo(fi); err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
// get mime bot for opp team and notify it
|
||||
notifyBotIfNeeded(fi)
|
||||
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
|
||||
}
|
||||
// TODO: what if bot exists already?
|
||||
// control number and names of bots
|
||||
bot, err := llmapi.NewBot(role, team, "bot1", fi.Room.ID, cfg)
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
go bot.StartBot()
|
||||
notify(models.NotifyRoomUpdatePrefix+fi.Room.ID, "")
|
||||
}
|
258
handlers/game.go
Normal file
258
handlers/game.go
Normal file
@ -0,0 +1,258 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golias/models"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||
// parse payload
|
||||
payload := &models.RoomReq{
|
||||
RoomPass: r.PostFormValue("room_pass"),
|
||||
RoomName: r.PostFormValue("room_name"),
|
||||
}
|
||||
// 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
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "current_room", room.ID)
|
||||
fi, err := getFullInfoByCtx(ctx)
|
||||
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
|
||||
fi.Room.IsPublic = true // hardcode for local test; move to form
|
||||
if err := saveFullInfo(fi); err != nil {
|
||||
msg := "failed to set current room to session"
|
||||
log.Error(msg, "error", err)
|
||||
abortWithError(w, msg)
|
||||
return
|
||||
}
|
||||
notify(models.NotifyRoomListUpdate, "")
|
||||
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 role == "mime" {
|
||||
fi.Room.RevealAllCards()
|
||||
}
|
||||
// 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
|
||||
if err := saveFullInfo(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)
|
||||
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
|
||||
}
|
||||
// TODO: check if enough players; also button should be hidden if already running
|
||||
fi.Room.IsRunning = true
|
||||
fi.Room.IsOver = false
|
||||
fi.Room.TeamTurn = "blue"
|
||||
// fi.Room.LoadTestCards()
|
||||
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: "game started",
|
||||
// Word: clue,
|
||||
// Number: num,
|
||||
}
|
||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||
if err := saveFullInfo(fi); err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
// reveal all cards
|
||||
if fi.State.Role == "mime" {
|
||||
fi.Room.RevealAllCards()
|
||||
}
|
||||
// return html
|
||||
tmpl, err := template.ParseGlob("components/*.html")
|
||||
if err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
notifyBotIfNeeded(fi)
|
||||
// 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 := getRoomByID(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 := saveFullInfo(fi); err != nil {
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// ===
|
||||
action := models.Action{
|
||||
Actor: fi.State.Username,
|
||||
ActorColor: string(fi.State.Team),
|
||||
WordColor: string(fi.State.Team),
|
||||
Action: "gave clue",
|
||||
Word: clue,
|
||||
Number: num,
|
||||
}
|
||||
fi.Room.ActionHistory = append(fi.Room.ActionHistory, action)
|
||||
fi.Room.MimeDone = true
|
||||
notify(models.NotifyBacklogPrefix+fi.Room.ID, clue+num)
|
||||
if err := saveFullInfo(fi); err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
@ -1,31 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"golias/broker"
|
||||
"golias/config"
|
||||
"golias/models"
|
||||
"golias/pkg/cache"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
var log *slog.Logger
|
||||
var (
|
||||
log *slog.Logger
|
||||
cfg *config.Config
|
||||
memcache cache.Cache
|
||||
Notifier *broker.Broker
|
||||
)
|
||||
|
||||
func init() {
|
||||
log = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
AddSource: true,
|
||||
}))
|
||||
}
|
||||
|
||||
var roundWords = map[string]string{
|
||||
"hamster": "blue",
|
||||
"child": "red",
|
||||
"wheel": "white",
|
||||
"condition": "black",
|
||||
"test": "white",
|
||||
memcache = cache.MemCache
|
||||
cfg = config.LoadConfigOrDefault("")
|
||||
Notifier = broker.Notifier
|
||||
// go Notifier.Listen()
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -34,5 +41,57 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
tmpl.ExecuteTemplate(w, "main", roundWords)
|
||||
fi, _ := getFullInfoByCtx(r.Context())
|
||||
if fi != nil && fi.Room != nil && fi.State != nil {
|
||||
if fi.State.Role == "mime" {
|
||||
fi.Room.RevealAllCards()
|
||||
} else {
|
||||
fi.Room.UpdateCounter()
|
||||
}
|
||||
}
|
||||
if fi != nil && fi.Room == nil {
|
||||
fi.List = listPublicRooms()
|
||||
}
|
||||
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 len(exitedRoom.PlayerList) == 0 || creatorLeft {
|
||||
removeRoom(exitedRoom.ID)
|
||||
// TODO: notify users if creator left
|
||||
notify(models.NotifyRoomListUpdate, "")
|
||||
}
|
||||
if err := saveState(fi.State.Username, fi.State); err != nil {
|
||||
abortWithError(w, err.Error())
|
||||
return
|
||||
}
|
||||
fi.List = listPublicRooms()
|
||||
if err := tmpl.ExecuteTemplate(w, "base", fi); err != nil {
|
||||
log.Error("failed to exec templ;", "error", err, "templ", "base")
|
||||
}
|
||||
}
|
||||
|
@ -5,48 +5,28 @@ import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"golias/config"
|
||||
"golias/pkg/cache"
|
||||
"golias/models"
|
||||
"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
|
||||
func LogRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
// start := time.Now()
|
||||
// Wrap response writer to capture status code
|
||||
ww := &responseWriterWrapper{ResponseWriter: w}
|
||||
next.ServeHTTP(ww, r)
|
||||
duration := time.Since(start)
|
||||
log.Debug("request completed",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.status,
|
||||
"duration", duration.String(),
|
||||
)
|
||||
next.ServeHTTP(w, r)
|
||||
// duration := time.Since(start)
|
||||
// log.Debug("request completed",
|
||||
// "method", r.Method,
|
||||
// "path", r.URL.RequestURI(),
|
||||
// "duration", duration.String(),
|
||||
// )
|
||||
})
|
||||
}
|
||||
|
||||
func GetSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: move
|
||||
cookieName := "session_token"
|
||||
sessionCookie, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
@ -83,10 +63,11 @@ func GetSession(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
userSession, err := cacheGetSession(sessionToken)
|
||||
// log.Debug("userSession from cache", "us", userSession)
|
||||
if err != nil {
|
||||
msg := "auth failed; session does not exists"
|
||||
err = errors.New(msg)
|
||||
log.Debug(msg, "error", err)
|
||||
// msg := "auth failed; session does not exists"
|
||||
// err = errors.New(msg)
|
||||
// log.Debug(msg, "error", err)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@ -98,7 +79,9 @@ func GetSession(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(),
|
||||
"username", userSession.Username)
|
||||
models.CtxUsernameKey, userSession.Username)
|
||||
ctx = context.WithValue(ctx,
|
||||
models.CtxSessionKey, userSession)
|
||||
if err := cacheSetSession(sessionToken,
|
||||
userSession); err != nil {
|
||||
msg := "failed to marshal user session"
|
||||
|
235
llmapi/main.go
Normal file
235
llmapi/main.go
Normal file
@ -0,0 +1,235 @@
|
||||
package llmapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golias/config"
|
||||
"golias/models"
|
||||
"golias/pkg/cache"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: config for url and token
|
||||
// completion prompt
|
||||
// MIME: llm needs to know all the cards, colors and previous actions
|
||||
// GUESSER: llm needs to know all the cards and previous actions
|
||||
|
||||
// channels are not serializable
|
||||
|
||||
var (
|
||||
// botname -> channel
|
||||
SignalChanMap = make(map[string]chan bool)
|
||||
DoneChanMap = make(map[string]chan bool)
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
Role string // gueeser | mime
|
||||
Team string
|
||||
cfg *config.Config
|
||||
RoomID string // can we get a room from here?
|
||||
BotName string
|
||||
log *slog.Logger
|
||||
// channels for communicaton
|
||||
// channels are not serializable
|
||||
// SignalsCh chan bool
|
||||
// DoneCh chan bool
|
||||
}
|
||||
|
||||
// StartBot
|
||||
func (b *Bot) StartBot() {
|
||||
for {
|
||||
select {
|
||||
case <-SignalChanMap[b.BotName]:
|
||||
b.log.Debug("got signal", "bot-team", b.Team, "bot-role", b.Role)
|
||||
// get room cards and actions
|
||||
room, err := getRoomByID(b.RoomID)
|
||||
if err != nil {
|
||||
b.log.Error("bot loop", "error", err)
|
||||
return
|
||||
}
|
||||
// form prompt
|
||||
prompt := b.BuildPrompt(room)
|
||||
b.log.Debug("got prompt", "prompt", prompt)
|
||||
// call llm
|
||||
if err := b.CallLLM(prompt); err != nil {
|
||||
b.log.Error("bot loop", "error", err)
|
||||
return
|
||||
}
|
||||
// parse response
|
||||
// if mime -> give clue
|
||||
// if guesser -> open card (does opening one card prompting new loop?)
|
||||
// send notification to sse broker
|
||||
case <-DoneChanMap[b.BotName]:
|
||||
b.log.Debug("got done signal", "bot-name", b.BotName)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EndBot
|
||||
|
||||
func NewBot(role, team, name, roomID string, cfg *config.Config) (*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,
|
||||
})),
|
||||
}
|
||||
// add to room
|
||||
room, err := getRoomByID(bot.RoomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// check if not running
|
||||
if role == "mime" && room.IsRunning {
|
||||
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),
|
||||
}
|
||||
room.BotMap[name] = bp
|
||||
switch team {
|
||||
case "red":
|
||||
if role == "mime" {
|
||||
room.RedTeam.Mime = name
|
||||
} else if role == "guesser" {
|
||||
room.RedTeam.Guessers = append(room.RedTeam.Guessers, 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 = append(room.BlueTeam.Guessers, 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 err := saveBot(bot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
SignalChanMap[bot.BotName] = make(chan bool)
|
||||
DoneChanMap[bot.BotName] = make(chan bool)
|
||||
go bot.StartBot() // run bot routine
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
func saveBot(bot *Bot) error {
|
||||
key := "botkey_" + bot.RoomID + bot.BotName
|
||||
data, err := json.Marshal(bot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cache.MemCache.Set(key, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRoomByID(roomID string) (*models.Room, error) {
|
||||
roomBytes, err := cache.MemCache.Get(models.CacheRoomPrefix + roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := &models.Room{}
|
||||
if err := json.Unmarshal(roomBytes, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func saveRoom(room *models.Room) error {
|
||||
key := models.CacheRoomPrefix + room.ID
|
||||
data, err := json.Marshal(room)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cache.MemCache.Set(key, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if b.Role == models.UserRoleGuesser {
|
||||
copiedCards := []models.WordCard{}
|
||||
copy(copiedCards, room.Cards)
|
||||
for i, card := range copiedCards {
|
||||
if !card.Revealed {
|
||||
copiedCards[i].Color = models.WordColorUknown
|
||||
}
|
||||
}
|
||||
toText["cards"] = copiedCards
|
||||
}
|
||||
data, err := json.MarshalIndent(toText, "", " ")
|
||||
if err != nil {
|
||||
// log
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (b *Bot) CallLLM(prompt string) error {
|
||||
method := "POST"
|
||||
payload := 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))
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest(method, b.cfg.LLMConfig.URL, payload)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Add("Authorization", "Bearer "+b.cfg.LLMConfig.TOKEN)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(body))
|
||||
return nil
|
||||
}
|
35
main.go
35
main.go
@ -1,8 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"golias/handlers"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
@ -11,24 +11,41 @@ import (
|
||||
func ListenToRequests(port string) error {
|
||||
mux := http.NewServeMux()
|
||||
server := &http.Server{
|
||||
Handler: handlers.LogRequests(handlers.GetSession(mux)),
|
||||
Addr: port,
|
||||
ReadTimeout: time.Second * 5,
|
||||
WriteTimeout: time.Second * 5,
|
||||
Handler: handlers.LogRequests(handlers.GetSession(mux)),
|
||||
Addr: port,
|
||||
ReadTimeout: time.Second * 5,
|
||||
// WriteTimeout: time.Second * 5,
|
||||
WriteTimeout: 0, // sse streaming
|
||||
}
|
||||
|
||||
fs := http.FileServer(http.Dir("assets/"))
|
||||
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs))
|
||||
|
||||
//
|
||||
mux.HandleFunc("GET /ping", handlers.HandlePing)
|
||||
mux.HandleFunc("GET /", handlers.HandleHome)
|
||||
fmt.Println("Listening", "addr", port)
|
||||
mux.HandleFunc("POST /login", handlers.HandleFrontLogin)
|
||||
// mux.HandleFunc("GET /room", handlers.HandleRoomEnter)
|
||||
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)
|
||||
// sse
|
||||
mux.Handle("GET /sub/sse", handlers.Notifier)
|
||||
slog.Info("Listening", "addr", port)
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := ":3000"
|
||||
fmt.Printf("Starting server on %s\n", port)
|
||||
err := ListenToRequests(port)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
14
models/keys.go
Normal file
14
models/keys.go
Normal file
@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
var (
|
||||
CtxRoomIDKey = "current_room"
|
||||
CtxUsernameKey = "username"
|
||||
CtxSessionKey = "session"
|
||||
// cache
|
||||
CacheRoomPrefix = "room#"
|
||||
CacheStatePrefix = "state-"
|
||||
// sse
|
||||
NotifyRoomListUpdate = "roomlistupdate"
|
||||
NotifyRoomUpdatePrefix = "roomupdate_"
|
||||
NotifyBacklogPrefix = "backlog_"
|
||||
)
|
295
models/main.go
295
models/main.go
@ -1,63 +1,215 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"golias/utils"
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
||||
type WordColor string
|
||||
|
||||
const (
|
||||
WordColorWhite = "white"
|
||||
WordColorBlue = "blue"
|
||||
WordColorRed = "red"
|
||||
WordColorBlack = "black"
|
||||
WordColorWhite = "white"
|
||||
WordColorBlue = "blue"
|
||||
WordColorRed = "red"
|
||||
WordColorBlack = "black"
|
||||
WordColorUknown = "beige"
|
||||
)
|
||||
|
||||
func StrToWordColor(s string) WordColor {
|
||||
switch s {
|
||||
case "white":
|
||||
return WordColorWhite
|
||||
case "blue":
|
||||
return WordColorBlue
|
||||
case "red":
|
||||
return WordColorRed
|
||||
case "black":
|
||||
return WordColorBlack
|
||||
default:
|
||||
return WordColorUknown
|
||||
}
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
Guessers []string
|
||||
Mime string
|
||||
Color string
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Actor string
|
||||
ActorColor string
|
||||
Action WordColor // clue | guess
|
||||
Word string
|
||||
WordColor string
|
||||
Number string // for clue
|
||||
}
|
||||
|
||||
type BotPlayer struct {
|
||||
Role UserRole // gueeser | mime
|
||||
Team UserTeam
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
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;
|
||||
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"`
|
||||
ActionHistory []Action
|
||||
TeamTurn UserTeam
|
||||
RedTeam Team
|
||||
BlueTeam Team
|
||||
Cards []WordCard
|
||||
WCMap map[string]WordColor
|
||||
BotMap map[string]BotPlayer // key is bot name
|
||||
Result uint8 // 0 for unknown; 1 is win for red; 2 if for blue;
|
||||
BlueCounter uint8
|
||||
RedCounter uint8
|
||||
RedTurn bool // false is blue turn
|
||||
MimeDone bool
|
||||
IsPublic bool
|
||||
// GameSettings *GameSettings `json:"settings"`
|
||||
IsRunning bool `json:"is_running"`
|
||||
Language string `json:"language" example:"en" form:"language"`
|
||||
RoundTime int32 `json:"round_time"`
|
||||
// ProgressPct uint32 `json:"progress_pct"`
|
||||
IsOver bool
|
||||
TeamWon UserTeam // blue | red
|
||||
}
|
||||
|
||||
// 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)
|
||||
if !r.IsRunning {
|
||||
return ""
|
||||
}
|
||||
switch r.TeamTurn {
|
||||
case UserTeamBlue:
|
||||
if !r.MimeDone {
|
||||
_, ok := r.BotMap[r.BlueTeam.Mime]
|
||||
if ok {
|
||||
return r.BlueTeam.Mime
|
||||
}
|
||||
}
|
||||
// check gussers
|
||||
case UserTeamRed:
|
||||
if !r.MimeDone {
|
||||
_, ok := r.BotMap[r.RedTeam.Mime]
|
||||
if ok {
|
||||
return r.RedTeam.Mime
|
||||
}
|
||||
}
|
||||
// check gussers
|
||||
default:
|
||||
// how did we got here?
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Room) GetOppositeTeamColor() UserTeam {
|
||||
switch r.TeamTurn {
|
||||
case "red":
|
||||
return UserTeamBlue
|
||||
case "blue":
|
||||
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) LoadTestCards() {
|
||||
// // TODO: pass room settings
|
||||
// // TODO: map language to path
|
||||
// wl := wordloader.InitDefaultLoader("assets/words/en_nouns.txt")
|
||||
// cards, err := wl.Load()
|
||||
// if err != nil {
|
||||
// // no logger
|
||||
// fmt.Println("failed to load cards", "error", err)
|
||||
// }
|
||||
// r.Cards = cards
|
||||
// // cards := []WordCard{
|
||||
// // {Word: "hamster", Color: "blue"},
|
||||
// // {Word: "child", Color: "red"},
|
||||
// // {Word: "wheel", Color: "white"},
|
||||
// // {Word: "condition", Color: "black"},
|
||||
// // {Word: "test", Color: "white"},
|
||||
// // {Word: "ball", Color: "blue"},
|
||||
// // {Word: "violin", Color: "red"},
|
||||
// // {Word: "rat", Color: "white"},
|
||||
// // {Word: "perplexity", Color: "blue"},
|
||||
// // {Word: "notion", Color: "red"},
|
||||
// // {Word: "guitar", Color: "blue"},
|
||||
// // {Word: "ocean", Color: "blue"},
|
||||
// // {Word: "moon", Color: "blue"},
|
||||
// // {Word: "coffee", Color: "blue"},
|
||||
// // {Word: "mountain", Color: "blue"},
|
||||
// // {Word: "book", Color: "blue"},
|
||||
// // {Word: "camera", Color: "blue"},
|
||||
// // {Word: "apple", Color: "red"},
|
||||
// // {Word: "fire", Color: "red"},
|
||||
// // {Word: "rose", Color: "red"},
|
||||
// // {Word: "sun", Color: "red"},
|
||||
// // {Word: "cherry", Color: "red"},
|
||||
// // {Word: "heart", Color: "red"},
|
||||
// // {Word: "tomato", Color: "red"},
|
||||
// // {Word: "cloud", Color: "white"},
|
||||
// // }
|
||||
// // r.Cards = cards
|
||||
// }
|
||||
|
||||
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) RevealSpecificWord(word string) {
|
||||
for i, card := range r.Cards {
|
||||
if card.Word == word {
|
||||
r.Cards[i].Revealed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WordCard struct {
|
||||
Word string
|
||||
Color WordColor
|
||||
Revealed bool
|
||||
}
|
||||
|
||||
type RoomPublic struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
PlayerList []string `json:"player_list"`
|
||||
CreatorName string `json:"creator_name"`
|
||||
GameSettings *GameSettings `json:"settings"`
|
||||
RedMime string
|
||||
BlueMime string
|
||||
RedGuessers []string
|
||||
BlueGuessers []string
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
Word string `json:"word"`
|
||||
Color WordColor `json:"color"`
|
||||
Revealed bool `json:"revealed"`
|
||||
}
|
||||
|
||||
type GameSettings struct {
|
||||
@ -67,3 +219,50 @@ type GameSettings struct {
|
||||
ProgressPct uint32 `json:"progress_pct"`
|
||||
IsOver bool
|
||||
}
|
||||
|
||||
// =====
|
||||
|
||||
type RoomReq struct {
|
||||
// is not user or not unique
|
||||
RoomPass string `json:"room_pass" form:"room_pass"`
|
||||
RoomName string `json:"room_name" form:"room_name"`
|
||||
// GameSettings
|
||||
}
|
||||
|
||||
func (rr *RoomReq) CreateRoom(creator string) *Room {
|
||||
roomID := xid.New().String()
|
||||
return &Room{
|
||||
// RoomName: ,
|
||||
RoomPass: rr.RoomPass,
|
||||
ID: roomID,
|
||||
CreatedAt: time.Now(),
|
||||
PlayerList: []string{creator},
|
||||
CreatorName: creator,
|
||||
BotMap: make(map[string]BotPlayer),
|
||||
}
|
||||
}
|
||||
|
||||
// ====
|
||||
|
||||
type FullInfo struct {
|
||||
State *UserState
|
||||
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
|
||||
}
|
||||
|
113
models/state.go
Normal file
113
models/state.go
Normal file
@ -0,0 +1,113 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type (
|
||||
UserTeam string
|
||||
UserRole string
|
||||
)
|
||||
|
||||
const (
|
||||
// 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 UserState struct {
|
||||
Username string
|
||||
RoomID string
|
||||
Team UserTeam
|
||||
Role UserRole
|
||||
}
|
||||
|
||||
func (u *UserState) ExitRoom() {
|
||||
u.RoomID = ""
|
||||
u.Team = UserTeamNone
|
||||
u.Role = UserRoleNone
|
||||
}
|
||||
|
||||
func MakeTestState(creatorName string) *FullInfo {
|
||||
cards := []WordCard{
|
||||
{Word: "hamster", Color: "blue"},
|
||||
{Word: "child", Color: "red"},
|
||||
{Word: "wheel", Color: "white"},
|
||||
{Word: "condition", Color: "black"},
|
||||
{Word: "test", Color: "white"},
|
||||
{Word: "ball", Color: "blue"},
|
||||
{Word: "violin", Color: "red"},
|
||||
{Word: "rat", Color: "white"},
|
||||
{Word: "perplexity", Color: "blue"},
|
||||
{Word: "notion", Color: "red"},
|
||||
{Word: "guitar", Color: "blue"},
|
||||
{Word: "ocean", Color: "blue"},
|
||||
{Word: "moon", Color: "blue"},
|
||||
{Word: "coffee", Color: "blue"},
|
||||
{Word: "mountain", Color: "blue"},
|
||||
{Word: "book", Color: "blue"},
|
||||
{Word: "camera", Color: "blue"},
|
||||
{Word: "apple", Color: "red"},
|
||||
{Word: "fire", Color: "red"},
|
||||
{Word: "rose", Color: "red"},
|
||||
{Word: "sun", Color: "red"},
|
||||
{Word: "cherry", Color: "red"},
|
||||
{Word: "heart", Color: "red"},
|
||||
{Word: "tomato", Color: "red"},
|
||||
{Word: "cloud", Color: "white"},
|
||||
}
|
||||
redTeam := Team{Guessers: []string{"Adam", "Eve"}, Mime: "Serpent", Color: "red"}
|
||||
blueTeam := Team{Guessers: []string{"Abel", "Kain"}, Color: "blue"}
|
||||
room := &Room{
|
||||
ID: "test-id",
|
||||
CreatedAt: time.Now(),
|
||||
CreatorName: creatorName,
|
||||
Cards: cards,
|
||||
RedTeam: redTeam,
|
||||
BlueTeam: blueTeam,
|
||||
TeamTurn: "blue",
|
||||
}
|
||||
us := &UserState{
|
||||
Username: creatorName,
|
||||
Team: UserTeamNone,
|
||||
Role: UserRoleNone,
|
||||
RoomID: "test-id",
|
||||
}
|
||||
return &FullInfo{
|
||||
State: us,
|
||||
Room: room,
|
||||
}
|
||||
}
|
||||
|
||||
func InitState(username string) *UserState {
|
||||
return &UserState{
|
||||
Username: username,
|
||||
Team: UserTeamNone,
|
||||
Role: UserRoleNone,
|
||||
}
|
||||
}
|
26
todos.md
Normal file
26
todos.md
Normal file
@ -0,0 +1,26 @@
|
||||
### feats
|
||||
- auto close room if nothing is going on there (hmm) for ~1h;
|
||||
- words database (file) load and form random 25 words;
|
||||
- different files for each supported lang;
|
||||
- mark cards (instead of opening them (right click?);
|
||||
- invite link;
|
||||
- login with invite link;
|
||||
- add html icons of whos turn it is (like an image of big ? when mime is thinking);
|
||||
- there two places for bot to check if its its move: start-game; end-turn;
|
||||
- remove bot button (if game is not running);
|
||||
|
||||
#### 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;
|
||||
- cleanup backlog after new game is started;
|
||||
- guessers should not be able to open more cards, than mime gave them +1;
|
||||
- sse hangs / fails connection which causes to wait for cards to open a few seconds (on local machine);
|
@ -22,3 +22,16 @@ func StrInSlice(key string, sl []string) bool {
|
||||
}
|
||||
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 (
|
||||
"golias/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: "black",
|
||||
}
|
||||
l.BlackCount--
|
||||
continue
|
||||
}
|
||||
if l.WhiteCount > 0 {
|
||||
cards[i] = models.WordCard{
|
||||
Word: word,
|
||||
Color: "white",
|
||||
}
|
||||
l.WhiteCount--
|
||||
continue
|
||||
}
|
||||
if l.RedCount > 0 {
|
||||
cards[i] = models.WordCard{
|
||||
Word: word,
|
||||
Color: "red",
|
||||
}
|
||||
l.RedCount--
|
||||
continue
|
||||
}
|
||||
if l.BlueCount > 0 {
|
||||
cards[i] = models.WordCard{
|
||||
Word: word,
|
||||
Color: "blue",
|
||||
}
|
||||
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