Compare commits

...

57 Commits

Author SHA1 Message Date
c155654d5f Fix: notify bot 2025-05-22 16:47:49 +03:00
31f721cd43 Chore: linter complains 2025-05-22 07:49:07 +03:00
6b750cd34b Feat: add example config 2025-05-22 07:23:18 +03:00
ad8f1aaee2 Feat: enter room: login with link 2025-05-21 09:22:29 +03:00
59cccbbe8e Feat: which bot to move 2025-05-20 16:16:57 +03:00
2342c56aed Fix: show color; add bot [WIP] 2025-05-20 13:10:09 +03:00
24f940f175 Enha: mime move check; start game backlog msg 2025-05-18 09:34:43 +03:00
734088d96d Feat: room-link copy 2025-05-16 16:34:55 +03:00
0c94811632 Feat: word loader 2025-05-13 07:49:16 +03:00
f752d2a162 Enha: wait for mime clue 2025-05-12 19:41:34 +03:00
da8131a0a4 Feat: roomlist update 2025-05-12 18:25:52 +03:00
f2aee1469b Feat: add exit room 2025-05-11 13:59:25 +03:00
cf5643227b Feat: add game over 2025-05-11 10:03:18 +03:00
becd3aca02 Feat: add backlog 2025-05-11 09:31:03 +03:00
e654f6f456 Enha: attempt to do modal popup of clue; must be a better way 2025-05-10 16:38:27 +03:00
321b79b258 Feat: card counter 2025-05-10 14:35:24 +03:00
35e215e26f Enha: sse update on actions 2025-05-10 11:03:23 +03:00
416cc63ec0 Fix: sse changes 2025-05-10 09:01:51 +03:00
2f4891473b Feat: clue template for mime 2025-05-09 15:29:49 +03:00
6c9c86f02b Feat: roomlist & join room 2025-05-09 14:39:33 +03:00
45761446e5 Feat: start game 2025-05-09 11:54:01 +03:00
2b4bf2ec29 Feat: create room 2025-05-09 11:24:33 +03:00
e20118acea Feat: start game btn; add todos.md 2025-05-09 09:28:30 +03:00
659c8dbbec Enha: cookies for local dev 2025-05-09 08:01:25 +03:00
8baf03595e Feat: end turn endpoint 2025-05-08 20:04:09 +03:00
3cc2ecb93d Feat: switch team; team model 2025-05-08 15:36:25 +03:00
21948b23f4 Feat: join team 2025-05-08 12:31:44 +03:00
b20f7ac6b7 Enha: state to hold room_id instead of whole room 2025-05-08 10:25:38 +03:00
3ade7310a7 Enha: responsive styles 2025-05-07 11:36:13 +03:00
86ef35160c Fix: pass username in ctx; more test words 2025-05-07 06:56:17 +03:00
7bd8e8af06 Dep: htmx version update 2025-05-07 06:30:20 +03:00
3eb54cffff Enha: load state instead of room 2025-05-07 06:06:21 +03:00
5e92523dcd Feat: save/load state 2025-05-05 06:55:33 +03:00
ca9b077070 Feat: add namecheck and tailwind css 2025-05-04 11:32:39 +03:00
5dbb80121d Feat: add error template 2025-05-04 08:47:22 +03:00
e335bf9dc8 Enha: split onto more templates 2025-05-04 08:37:17 +03:00
06203ab39e Chore: better log 2025-05-04 08:12:23 +03:00
0fbc106f9a Feat: add sse broker 2025-05-03 13:18:51 +03:00
8d85d0612c fix: handle error in getRoomByID 2025-05-03 12:16:49 +03:00
444f10ea7e feat: retrieve session and room in HandleShowColor 2025-05-03 12:16:43 +03:00
b135356d3f Enha: leave one room model for now 2025-05-03 10:22:50 +03:00
5cf1f1199e Feat: components chain 2025-05-03 09:18:05 +03:00
8b68aee884 fix: remove AI comment from state.go 2025-05-03 08:55:54 +03:00
8b9c440ae5 test: add MakeTestState helper function 2025-05-03 08:55:48 +03:00
78b48b8c71 fix: update template color logic to match model constants 2025-05-03 08:33:00 +03:00
1dcf013766 feat: add room layout with team buttons and word grid using HTMX 2025-05-03 08:26:52 +03:00
bd4e2431bf Chore: touch file for room templ 2025-05-03 08:20:24 +03:00
c8ce2a6727 feat: add StrToUserRole function 2025-05-03 08:06:32 +03:00
8705f6a425 refactor: add UserTeam and UserRole types, update StrToUserTeam 2025-05-03 08:06:26 +03:00
3aa0c15ff5 fix: complete switch in StrToUserTeam and remove AI comments 2025-05-03 07:57:34 +03:00
56c94c3987 feat: add models/state.go 2025-05-03 07:57:22 +03:00
322743e33d Feat: show colors by pressing words 2025-05-02 15:21:28 +03:00
8b768f919b feat: complete StrToWordColor switch with color cases 2025-05-02 14:43:09 +03:00
722da6d4fa feat: add StrToWordColor function stub 2025-05-02 14:43:05 +03:00
990d8660b3 feat: include URL params in request logging 2025-05-02 14:31:22 +03:00
ad8982a112 style: add comment for including URL params in log path 2025-05-02 14:31:17 +03:00
985956b384 feat: add click-to-reveal color cards with HTMX 2025-05-02 14:18:19 +03:00
44 changed files with 8250 additions and 538 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.aider* .aider*
golias golias
store.json store.json
config.toml

42
.golangci.yml Normal file
View 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$

View File

@ -32,4 +32,4 @@ stop-container:
docker rm -f golias 2>/dev/null && echo "old container removed" docker rm -f golias 2>/dev/null && echo "old container removed"
run-container: stop-container run-container: stop-container
docker run --name=golias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:9000:9000 -d golias:master docker run --name=golias -v $(CURDIR)/store.json:/root/store.json -p 0.0.0.0:3000:3000 -d golias:master

2
assets/htmx.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -6,350 +6,285 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions
*/ */
(function() { (function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api
/** @type {import("../htmx").HtmxInternalApi} */ htmx.defineExtension('sse', {
var api;
htmx.defineExtension("sse", { /**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef
/** // set a function in the public API for creating new EventSource objects
* Init saves the provided reference to the internal HTMX API. if (htmx.createEventSource == undefined) {
* htmx.createEventSource = createEventSource
* @param {import("../htmx").HtmxInternalApi} api }
* @returns void },
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef;
// set a function in the public API for creating new EventSource objects getSelectors: function() {
if (htmx.createEventSource == undefined) { return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
htmx.createEventSource = createEventSource; },
}
},
/** /**
* onEvent handles all events passed to this extension. * onEvent handles all events passed to this extension.
* *
* @param {string} name * @param {string} name
* @param {Event} evt * @param {Event} evt
* @returns void * @returns void
*/ */
onEvent: function(name, evt) { onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}
switch (name) { return
case "htmx:beforeCleanupElement": // Try to create EventSources when elements are processed
var internalData = api.getInternalData(evt.target) case 'htmx:afterProcessNode':
// Try to remove remove an EventSource when elements are removed ensureEventSourceOnElement(parent)
if (internalData.sseEventSource) { }
internalData.sseEventSource.close(); }
} })
return; /// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////
// Try to create EventSources when elements are processed /**
case "htmx:afterProcessNode": * createEventSource is the default method for creating new EventSource objects.
ensureEventSourceOnElement(evt.target); * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
registerSSE(evt.target); *
} * @param {string} url
} * @returns EventSource
}); */
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}
/////////////////////////////////////////////// /**
// HELPER FUNCTIONS * registerSSE looks for attributes that can contain sse events, right
/////////////////////////////////////////////// * now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')
for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}
// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}
// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource
var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}
var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}
// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}
// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}
ensureEventSource(elt, sseURL, retryCount)
}
registerSSE(elt)
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}
source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })
if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}
api.getInternalData(elt).sseEventSource = source
/** var closeAttribute = api.getAttributeValue(elt, "sse-close");
* createEventSource is the default method for creating new EventSource objects. if (closeAttribute) {
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. // close eventsource when this message is received
* source.addEventListener(closeAttribute, function() {
* @param {string} url api.triggerEvent(elt, 'htmx:sseClose', {
* @returns EventSource source,
*/ type: 'message',
function createEventSource(url) { })
return new EventSource(url, { withCredentials: true }); source.close()
} });
}
}
function splitOnWhitespace(trigger) { /**
return trigger.trim().split(/\s+/); * maybeCloseSSESource confirms that the parent element still exists.
} * If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}
function getLegacySSEURL(elt) {
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
if (legacySSEValue) {
var values = splitOnWhitespace(legacySSEValue);
for (var i = 0; i < values.length; i++) {
var value = values[i].split(/:(.+)/);
if (value[0] === "connect") {
return value[1];
}
}
}
}
function getLegacySSESwaps(elt) { /**
var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); * @param {HTMLElement} elt
var returnArr = []; * @param {string} content
if (legacySSEValue != null) { */
var values = splitOnWhitespace(legacySSEValue); function swap(elt, content) {
for (var i = 0; i < values.length; i++) { api.withExtensions(elt, function(extension) {
var value = values[i].split(/:(.+)/); content = extension.transformResponse(content, null, elt)
if (value[0] === "swap") { })
returnArr.push(value[1]);
}
}
}
return returnArr;
}
/** var swapSpec = api.getSwapSpecification(elt)
* registerSSE looks for attributes that can contain sse events, right var target = api.getTarget(elt)
* now hx-trigger and sse-swap and adds listeners based on these attributes too api.swap(target, content, swapSpec)
* the closest event source }
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource);
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null; // no eventsource in parentage, orphaned element
}
// Set internalData and source
var internalData = api.getInternalData(sourceElement);
var source = internalData.sseEventSource;
// Add message handlers for every `sse-swap` attribute function hasEventSource(node) {
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { return api.getInternalData(node).sseEventSource != null
}
var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); })()
if (sseSwapAttr) {
var sseEventNames = sseSwapAttr.split(",");
} else {
var sseEventNames = getLegacySSESwaps(child);
}
for (var i = 0; i < sseEventNames.length; i++) {
var sseEventName = sseEventNames[i].trim();
var listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return;
}
// If the body no longer contains the element, remove the listener
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
// swap the response into the DOM and trigger a notification
swap(child, event.data);
api.triggerEvent(elt, "htmx:sseMessage", event);
};
// Register the new listener
api.getInternalData(child).sseEventListener = listener;
source.addEventListener(sseEventName, listener);
}
});
// Add message handlers for every `hx-trigger="sse:*"` attribute
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
var sseEventName = api.getAttributeValue(child, "hx-trigger");
if (sseEventName == null) {
return;
}
// Only process hx-triggers for events with the "sse:" prefix
if (sseEventName.slice(0, 4) != "sse:") {
return;
}
// remove the sse: prefix from here on out
sseEventName = sseEventName.substr(4);
var listener = function() {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(child)) {
source.removeEventListener(sseEventName, listener);
}
}
});
}
/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null;
}
// handle extension source creation attribute
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
var sseURL = api.getAttributeValue(child, "sse-connect");
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
// handle legacy sse, remove for HTMX2
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
var sseURL = getLegacySSEURL(child);
if (sseURL == null) {
return;
}
ensureEventSource(child, sseURL, retryCount);
});
}
function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url);
source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return;
}
// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0;
var timeout = Math.random() * (2 ^ retryCount) * 500;
window.setTimeout(function() {
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
}, timeout);
}
};
source.onopen = function(evt) {
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
}
api.getInternalData(elt).sseEventSource = source;
}
/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource;
if (source != undefined) {
source.close();
// source = null
return true;
}
}
return false;
}
/**
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
*
* @param {HTMLElement} elt
* @param {string} attributeName
*/
function queryAttributeOnThisOrChildren(elt, attributeName) {
var result = [];
// If the parent element also contains the requested attribute, then add it to the results too.
if (api.hasAttribute(elt, attributeName)) {
result.push(elt);
}
// Search all child nodes that match the requested attribute
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
result.push(node);
});
return result;
}
/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt);
});
var swapSpec = api.getSwapSpecification(elt);
var target = api.getTarget(elt);
var settleInfo = api.makeSettleInfo(elt);
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.add(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:beforeSettle');
});
// Handle settle tasks (with delay if requested)
if (swapSpec.settleDelay > 0) {
setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
} else {
doSettle(settleInfo)();
}
}
/**
* doSettle mirrors much of the functionality in htmx that
* settles elements after their content has been swapped.
* TODO: this should be published by htmx, and not duplicated here
* @param {import("../htmx").HtmxSettleInfo} settleInfo
* @returns () => void
*/
function doSettle(settleInfo) {
return function() {
settleInfo.tasks.forEach(function(task) {
task.call();
});
settleInfo.elts.forEach(function(elt) {
if (elt.classList) {
elt.classList.remove(htmx.config.settlingClass);
}
api.triggerEvent(elt, 'htmx:afterSettle');
});
}
}
function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null;
}
})();

View File

@ -45,3 +45,25 @@ tr{
border: 1px solid black; border: 1px solid black;
background-color: darkorange; background-color: darkorange;
} }
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeOutUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-1rem);
}
}

8
assets/tailwind.css Normal file

File diff suppressed because one or more lines are too long

5909
assets/words/en_nouns.txt Normal file

File diff suppressed because it is too large Load Diff

103
broker/sse.go Normal file
View 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))
}
}
}
}
}

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

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

View File

@ -4,15 +4,13 @@
Create a room <br/> Create a room <br/>
or<br/> or<br/>
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button> <button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/hideform" hx-target=".create-room-div" >Hide Form</button>
<form hx-post="/room/create" hx-target="#ancestor"> <form hx-post="/room-create" hx-target="#ancestor">
<label For="room_name">Room Name</label><br/>
<input type="text" id="room_name" name="room_name" class="text-center text-black" value={utils.MakeDefaultRoomName(utils.GetUsername(c))}/><br/>
<label For="game_time">Game Time:</label><br/> <label For="game_time">Game Time:</label><br/>
<input type="number" id="game_time" name="game_time" class="text-center text-black" value="300"/><br/> <input type="number" id="game_time" name="game_time" class="text-center text-white" value="300"/><br/>
<label For="language">Language:</label><br/> <label For="language">Language:</label><br/>
<input type="text" id="language" name="language" class="text-center text-black" value="en"/><br/> <input type="text" id="language" name="language" class="text-center text-white" value="en"/><br/>
<label For="password">Password:</label><br/> <label For="password">Password:</label><br/>
<input type="text" id="password" name="room_pass" class="text-center text-black" value="" placeholder="Leave empty for open room"/><br/> <input type="text" id="password" name="room_pass" class="text-center text-white" value="" placeholder="Leave empty for open room"/><br/>
<button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" type="submit" >Create Room</button> <button button class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" type="submit" >Create Room</button>
</form> </form>
</div> </div>

9
components/error.html Normal file
View 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}}

View File

@ -1,69 +1,26 @@
{{define "main"}} {{define "main"}}
<!DOCTYPE html> Start of main temp
<html lang="en"> {{ if not . }}
<head> login temp
<title>Word Colors</title> {{template "login"}}
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script> {{ else if ne .LinkLogin "" }}
<script src="/assets/htmx.min.js"></script> got to linklogin
<script src="/assets/htmx.sse.js"></script> {{template "linklogin" .LinkLogin}}
<link rel="stylesheet" href="/assets/style.css"/> {{ else if eq .State.RoomID "" }}
<meta charset="utf-8" name="viewport" content="width=device-width,initial-scale=1"/> empty state roomid
<link rel="icon" sizes="64x64" href="favicon.ico"/> <div id="hello-user">
<style type="text/css"> <p>Hello {{.State.Username}}</p>
body{
background-color: #0C1616FF;
color: #8896b2;
max-width: 800px;
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>
{{template "login"}}
<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>
<h1>Word Color Cards</h1>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; padding: 1rem;">
{{range $word, $color := .}}
<div style="
background-color: {{$color}};
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
min-width: 100px;
text-align: center;
color: white;
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
">
{{$word}}
</div> </div>
{{end}} <div id="create-room" class="create-room-div">
</div> <button button id="create-form-btn" type="submit" class="justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" hx-get="/room/createform" hx-swap="outerHTML">SHOW ROOM CREATE FORM</button>
</div> </div>
</body> <div>
</html> {{template "roomlist" .List}}
</div>
{{else}}
else
<div id="room">
{{template "room" .}}
</div>
{{end}}
{{end}} {{end}}

16
components/linklogin.html Normal file
View 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}}

View File

@ -4,7 +4,7 @@
<div> <div>
<label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label> <label For="username" class="block text-sm font-medium leading-6 text-white-900">tell us your username</label>
<div class="mt-2"> <div class="mt-2">
<input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/> <input id="username" name="username" hx-target="#login_notice" hx-swap="outerHTML" hx-post="/check/name" hx-trigger="input changed delay:400ms" autocomplete="username" required class="block w-full rounded-md border-0 bg-white py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 text-center"/>
</div> </div>
<div id="login_notice">this name looks available</div> <div id="login_notice">this name looks available</div>
</div> </div>

21
components/mimeclue.html Normal file
View 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
View 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}}

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

View File

@ -10,8 +10,8 @@ type Config struct {
ServerConfig ServerConfig `toml:"SERVICE"` ServerConfig ServerConfig `toml:"SERVICE"`
BaseURL string `toml:"BASE_URL"` BaseURL string `toml:"BASE_URL"`
SessionLifetime int `toml:"SESSION_LIFETIME_SECONDS"` SessionLifetime int `toml:"SESSION_LIFETIME_SECONDS"`
DBURI string `toml:"DBURI"`
CookieSecret string `toml:"COOKIE_SECRET"` CookieSecret string `toml:"COOKIE_SECRET"`
LLMConfig LLMConfig `toml:"LLM"`
} }
type ServerConfig struct { type ServerConfig struct {
@ -19,6 +19,11 @@ type ServerConfig struct {
Port string `toml:"PORT"` Port string `toml:"PORT"`
} }
type LLMConfig struct {
URL string `toml:"LLM_URL"`
TOKEN string `toml:"LLM_TOKEN"`
}
func LoadConfigOrDefault(fn string) *Config { func LoadConfigOrDefault(fn string) *Config {
if fn == "" { if fn == "" {
fn = "config.toml" fn = "config.toml"
@ -28,8 +33,10 @@ func LoadConfigOrDefault(fn string) *Config {
if err != nil { if err != nil {
slog.Warn("failed to read config from file, loading default", "error", err) slog.Warn("failed to read config from file, loading default", "error", err)
config.BaseURL = "https://localhost:3000" config.BaseURL = "https://localhost:3000"
config.SessionLifetime = 300 config.SessionLifetime = 30000
config.CookieSecret = "test" config.CookieSecret = "test"
config.ServerConfig.Host = "localhost"
config.ServerConfig.Port = "3000"
} }
return config return config
} }

4
go.mod
View File

@ -4,7 +4,5 @@ go 1.24
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
honnef.co/go/tools v0.6.1 github.com/rs/xid v1.6.0
) )
require golang.org/x/tools v0.30.0 // indirect

6
go.sum
View File

@ -1,6 +1,4 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=

View File

@ -2,9 +2,281 @@ package handlers
import ( import (
"context" "context"
"encoding/json"
"errors"
"fmt"
"golias/broker"
"golias/llmapi"
"golias/models" "golias/models"
"golias/utils"
"golias/wordloader"
"strings"
) )
func createRoom(ctx context.Context, req *models.RoomReq) (*models.RoomPublic, error) { func createRoom(ctx context.Context, req *models.RoomReq) (*models.Room, error) {
return nil, nil 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
}
} }

View File

@ -1,12 +1,11 @@
package handlers package handlers
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "fmt"
"golias/models" "golias/models"
"golias/utils" "golias/utils"
"html/template" "html/template"
@ -16,12 +15,19 @@ import (
) )
func abortWithError(w http.ResponseWriter, msg string) { func abortWithError(w http.ResponseWriter, msg string) {
w.WriteHeader(200) // must be 200 for htmx to replace components
tmpl := template.Must(template.ParseGlob("components/*.html")) tmpl := template.Must(template.ParseGlob("components/*.html"))
tmpl.ExecuteTemplate(w, "error", msg) if err := tmpl.ExecuteTemplate(w, "error", msg); err != nil {
log.Error("failed to execute error template", "error", err)
}
} }
func HandleFrontLogin(w http.ResponseWriter, r *http.Request) { func HandleNameCheck(w http.ResponseWriter, r *http.Request) {
r.ParseForm() if err := r.ParseForm(); err != nil {
log.Error("failed to parse form", "error", err)
abortWithError(w, err.Error())
return
}
username := r.PostFormValue("username") username := r.PostFormValue("username")
if username == "" { if username == "" {
msg := "username not provided" msg := "username not provided"
@ -29,9 +35,42 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, msg) abortWithError(w, msg)
return 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 // make sure username does not exists
cleanName := utils.RemoveSpacesFromStr(username) cleanName := utils.RemoveSpacesFromStr(username)
// TODO: create user in db
// login user // login user
cookie, err := makeCookie(cleanName, r.RemoteAddr) cookie, err := makeCookie(cleanName, r.RemoteAddr)
if err != nil { if err != nil {
@ -45,14 +84,48 @@ func HandleFrontLogin(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return 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) { func makeCookie(username string, remote string) (*http.Cookie, error) {
// secret // secret
// Create a new random session token // Create a new random session token
// sessionToken := xid.New().String() // sessionToken := xid.New().String()
sessionToken := "token" sessionToken := "sessionprefix_" + username
expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second) expiresAt := time.Now().Add(time.Duration(cfg.SessionLifetime) * time.Second)
// Set the token in the session map, along with the session information // Set the token in the session map, along with the session information
session := &models.Session{ session := &models.Session{
@ -79,9 +152,9 @@ func makeCookie(username string, remote string) (*http.Cookie, error) {
log.Info("check remote addr for cookie set", log.Info("check remote addr for cookie set",
"remote", remote, "session", session) "remote", remote, "session", session)
if strings.Contains(remote, "192.168.0") { if strings.Contains(remote, "192.168.0") {
// no idea what is going on // cookie.Domain = "192.168.0.101"
// cookie.Domain = "192.168.0.15" cookie.Domain = ""
cookie.Domain = "home.host" cookie.SameSite = http.SameSiteLaxMode
log.Info("changing cookie domain", "domain", cookie.Domain) log.Info("changing cookie domain", "domain", cookie.Domain)
} }
// set ctx? // set ctx?
@ -110,16 +183,17 @@ func cacheSetSession(key string, session *models.Session) error {
return err return err
} }
memcache.Set(key, sesb) memcache.Set(key, sesb)
// expire in 10 min // TODO: to config
memcache.Expire(key, 10*60) memcache.Expire(key, 60*60)
return nil return nil
} }
func updateRoomInSession(ctx context.Context, roomID string) (context.Context, error) { // unused
s, ok := ctx.Value("session").(models.Session) // func updateRoomInSession(ctx context.Context, roomID string) (context.Context, error) {
if !ok { // s, ok := ctx.Value(models.CtxSessionKey).(*models.Session)
return context.TODO(), errors.New("failed to extract session from ctx") // if !ok {
} // return context.TODO(), errors.New("failed to extract session from ctx")
s.CurrentRoom = roomID // }
return context.WithValue(ctx, "session", s), nil // s.CurrentRoom = roomID
} // return context.WithValue(ctx, "session", s), nil
// }

View File

@ -1,6 +1,9 @@
package handlers package handlers
import ( import (
"errors"
"golias/llmapi"
"golias/models"
"html/template" "html/template"
"net/http" "net/http"
) )
@ -12,7 +15,9 @@ func HandleShowCreateForm(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
tmpl.ExecuteTemplate(w, "createform", show) 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) { func HandleHideCreateForm(w http.ResponseWriter, r *http.Request) {
@ -22,5 +27,131 @@ func HandleHideCreateForm(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
tmpl.ExecuteTemplate(w, "createform", show) 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, "")
} }

View File

@ -2,6 +2,8 @@ package handlers
import ( import (
"context" "context"
"errors"
"fmt"
"golias/models" "golias/models"
"html/template" "html/template"
"net/http" "net/http"
@ -22,23 +24,235 @@ func HandleCreateRoom(w http.ResponseWriter, r *http.Request) {
return return
} }
ctx := context.WithValue(r.Context(), "current_room", room.ID) ctx := context.WithValue(r.Context(), "current_room", room.ID)
ctx, err = updateRoomInSession(ctx, room.ID) fi, err := getFullInfoByCtx(ctx)
if err != nil { 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" msg := "failed to set current room to session"
log.Error(msg, "error", err) log.Error(msg, "error", err)
abortWithError(w, msg) abortWithError(w, msg)
return return
} }
// send msg of created room notify(models.NotifyRoomListUpdate, "")
// h.Broker.Notifier <- broker.NotificationEvent{ tmpl, err := template.ParseGlob("components/*.html")
// EventName: models.MsgRoomListUpdate, if err != nil {
// Payload: fmt.Sprintf("%s created a room named %s", r.CreatorName, r.RoomName), 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 // return html
tmpl, err := template.ParseGlob("components/*.html") tmpl, err := template.ParseGlob("components/*.html")
if err != nil { if err != nil {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return return
} }
tmpl.ExecuteTemplate(w, "main", nil) 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
}
} }

View File

@ -1,7 +1,9 @@
package handlers package handlers
import ( import (
"golias/broker"
"golias/config" "golias/config"
"golias/models"
"golias/pkg/cache" "golias/pkg/cache"
"html/template" "html/template"
"log/slog" "log/slog"
@ -13,6 +15,7 @@ var (
log *slog.Logger log *slog.Logger
cfg *config.Config cfg *config.Config
memcache cache.Cache memcache cache.Cache
Notifier *broker.Broker
) )
func init() { func init() {
@ -22,18 +25,14 @@ func init() {
})) }))
memcache = cache.MemCache memcache = cache.MemCache
cfg = config.LoadConfigOrDefault("") cfg = config.LoadConfigOrDefault("")
} Notifier = broker.Notifier
// go Notifier.Listen()
var roundWords = map[string]string{
"hamster": "blue",
"child": "red",
"wheel": "white",
"condition": "black",
"test": "white",
} }
func HandlePing(w http.ResponseWriter, r *http.Request) { func HandlePing(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong")) if _, err := w.Write([]byte("pong")); err != nil {
log.Error("failed to write ping response", "error", err)
}
} }
func HandleHome(w http.ResponseWriter, r *http.Request) { func HandleHome(w http.ResponseWriter, r *http.Request) {
@ -42,5 +41,57 @@ func HandleHome(w http.ResponseWriter, r *http.Request) {
abortWithError(w, err.Error()) abortWithError(w, err.Error())
return 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")
}
} }

View File

@ -5,41 +5,28 @@ import (
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"errors" "golias/models"
"net/http" "net/http"
"time"
) )
// responseWriterWrapper wraps http.ResponseWriter to capture status code
type responseWriterWrapper struct {
http.ResponseWriter
status int
}
func (w *responseWriterWrapper) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
// LogRequests logs all HTTP requests with method, path and duration // LogRequests logs all HTTP requests with method, path and duration
func LogRequests(next http.Handler) http.Handler { func LogRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() // start := time.Now()
// Wrap response writer to capture status code // Wrap response writer to capture status code
ww := &responseWriterWrapper{ResponseWriter: w} next.ServeHTTP(w, r)
next.ServeHTTP(ww, r) // duration := time.Since(start)
duration := time.Since(start) // log.Debug("request completed",
log.Debug("request completed", // "method", r.Method,
"method", r.Method, // "path", r.URL.RequestURI(),
"path", r.URL.Path, // "duration", duration.String(),
"status", ww.status, // )
"duration", duration.String(),
)
}) })
} }
func GetSession(next http.Handler) http.Handler { func GetSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: move
cookieName := "session_token" cookieName := "session_token"
sessionCookie, err := r.Cookie(cookieName) sessionCookie, err := r.Cookie(cookieName)
if err != nil { if err != nil {
@ -76,10 +63,11 @@ func GetSession(next http.Handler) http.Handler {
return return
} }
userSession, err := cacheGetSession(sessionToken) userSession, err := cacheGetSession(sessionToken)
// log.Debug("userSession from cache", "us", userSession)
if err != nil { if err != nil {
msg := "auth failed; session does not exists" // msg := "auth failed; session does not exists"
err = errors.New(msg) // err = errors.New(msg)
log.Debug(msg, "error", err) // log.Debug(msg, "error", err)
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
@ -91,9 +79,9 @@ func GetSession(next http.Handler) http.Handler {
return return
} }
ctx := context.WithValue(r.Context(), ctx := context.WithValue(r.Context(),
"username", userSession.Username) models.CtxUsernameKey, userSession.Username)
ctx = context.WithValue(r.Context(), ctx = context.WithValue(ctx,
"session", userSession) models.CtxSessionKey, userSession)
if err := cacheSetSession(sessionToken, if err := cacheSetSession(sessionToken,
userSession); err != nil { userSession); err != nil {
msg := "failed to marshal user session" msg := "failed to marshal user session"

235
llmapi/main.go Normal file
View 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
}

26
main.go
View File

@ -11,21 +11,35 @@ import (
func ListenToRequests(port string) error { func ListenToRequests(port string) error {
mux := http.NewServeMux() mux := http.NewServeMux()
server := &http.Server{ server := &http.Server{
Handler: handlers.LogRequests(handlers.GetSession(mux)), Handler: handlers.LogRequests(handlers.GetSession(mux)),
Addr: port, Addr: port,
ReadTimeout: time.Second * 5, ReadTimeout: time.Second * 5,
WriteTimeout: time.Second * 5, // WriteTimeout: time.Second * 5,
WriteTimeout: 0, // sse streaming
} }
fs := http.FileServer(http.Dir("assets/")) fs := http.FileServer(http.Dir("assets/"))
mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs)) mux.Handle("GET /assets/", http.StripPrefix("/assets/", fs))
//
mux.HandleFunc("GET /ping", handlers.HandlePing) mux.HandleFunc("GET /ping", handlers.HandlePing)
mux.HandleFunc("GET /", handlers.HandleHome) mux.HandleFunc("GET /", handlers.HandleHome)
mux.HandleFunc("POST /login", handlers.HandleFrontLogin) 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 //elements
mux.HandleFunc("GET /actionhistory", handlers.HandleActionHistory)
mux.HandleFunc("GET /room/createform", handlers.HandleShowCreateForm) mux.HandleFunc("GET /room/createform", handlers.HandleShowCreateForm)
mux.HandleFunc("GET /room/hideform", handlers.HandleHideCreateForm) 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) slog.Info("Listening", "addr", port)
return server.ListenAndServe() return server.ListenAndServe()
} }

14
models/keys.go Normal file
View 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_"
)

View File

@ -1,63 +1,215 @@
package models package models
import "time" import (
"fmt"
"golias/utils"
"time"
"github.com/rs/xid"
)
type WordColor string type WordColor string
const ( const (
WordColorWhite = "white" WordColorWhite = "white"
WordColorBlue = "blue" WordColorBlue = "blue"
WordColorRed = "red" WordColorRed = "red"
WordColorBlack = "black" 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 { type Room struct {
ID string `json:"id" db:"id"` ID string `json:"id" db:"id"`
CreatedAt time.Time `json:"created_at" db:"created_at"` CreatedAt time.Time `json:"created_at" db:"created_at"`
RoomName string `json:"room_name"` // RoomName string `json:"room_name"`
RoomPass string `json:"room_pass"` RoomPass string `json:"room_pass"`
RoomLink string RoomLink string
CreatorName string `json:"creator_name"` CreatorName string `json:"creator_name"`
PlayerList []string `json:"player_list"` PlayerList []string `json:"player_list"`
RedMime string ActionHistory []Action
BlueMime string TeamTurn UserTeam
RedGuessers []string RedTeam Team
BlueGuessers []string BlueTeam Team
Cards []WordCard Cards []WordCard
GameSettings *GameSettings `json:"settings"` WCMap map[string]WordColor
Result uint8 // 0 for unknown; 1 is win for red; 2 if for blue; 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 { type WordCard struct {
Word string Word string `json:"word"`
Color WordColor Color WordColor `json:"color"`
Revealed bool Revealed bool `json:"revealed"`
}
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,
}
} }
type GameSettings struct { type GameSettings struct {
@ -76,3 +228,41 @@ type RoomReq struct {
RoomName string `json:"room_name" form:"room_name"` RoomName string `json:"room_name" form:"room_name"`
// GameSettings // 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
View 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
View 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);

View File

@ -22,3 +22,16 @@ func StrInSlice(key string, sl []string) bool {
} }
return false return false
} }
func RemoveFromSlice(key string, sl []string) []string {
if !StrInSlice(key, sl) {
return sl
}
resp := []string{}
for _, el := range sl {
if el != key {
resp = append(resp, el)
}
}
return resp
}

80
wordloader/wordloader.go Normal file
View 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
}