@ -0,0 +1 @@ | |||||
node_modules |
@ -0,0 +1,21 @@ | |||||
MIT License | |||||
Copyright (c) 2019 Levi Olson | |||||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||||
of this software and associated documentation files (the "Software"), to deal | |||||
in the Software without restriction, including without limitation the rights | |||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||||
copies of the Software, and to permit persons to whom the Software is | |||||
furnished to do so, subject to the following conditions: | |||||
The above copyright notice and this permission notice shall be included in all | |||||
copies or substantial portions of the Software. | |||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||||
SOFTWARE. |
@ -0,0 +1,34 @@ | |||||
# TicTacToe API Interface | |||||
A Golang Server that opens a unique socket for two users to play TicTacToe online. | |||||
## Firewall | |||||
sudo ufw status | |||||
sudo ufw allow OpenSSH | |||||
sudo ufw allow https | |||||
sudo ufw enable | |||||
## Install Go | |||||
curl -C - https://dl.google.com/go/go1.11.4.linux-amd64.tar.gz -o go1.11.4.linux-amd64.tar.gz | |||||
tar -C /usr/local -xzf go1.11.4.linux-amd64.tar.gz | |||||
echo "export PATH=$PATH:/usr/local/go/bin" | |||||
go version | |||||
## Letsencrypt | |||||
sudo add-apt-repository ppa:certbot/certbot | |||||
sudo apt-get update | |||||
sudo apt-get install python-certbot-nginx | |||||
certbot certonly --standalone -d weather.l3vi.co | |||||
## Systemd | |||||
sudo cp tictactoe-api.service /lib/systemd/system/. | |||||
ls -al /lib/systemd/system | |||||
sudo chmod 755 /lib/systemd/system/tictactoe-api.service | |||||
sudo systemctl enable tictactoe-api.service | |||||
sudo systemctl start tictactoe-api.service | |||||
sudo journalctl -f -u tictactoe-api | |||||
# or | |||||
sudo systemctl status tictactoe-api.service |
@ -0,0 +1,261 @@ | |||||
package cache | |||||
import ( | |||||
"fmt" | |||||
"runtime" | |||||
"time" | |||||
uuid "github.com/satori/go.uuid" | |||||
) | |||||
// Expired returns true if the item has expired | |||||
func (item Item) Expired() bool { | |||||
if item.Expiration == 0 { | |||||
return false | |||||
} | |||||
return time.Now().UnixNano() > item.Expiration | |||||
} | |||||
// NewCache returns a simple in-memory cache | |||||
func NewCache(defaultExp, cleanupInterval time.Duration) *Cache { | |||||
items := make(map[uuid.UUID]Item) | |||||
return newCacheWithJanitor(defaultExp, cleanupInterval, items) | |||||
} | |||||
func newCacheWithJanitor(defaultExp, cleanupInterval time.Duration, items map[uuid.UUID]Item) *Cache { | |||||
cache := newCache(defaultExp, items) | |||||
Cache := &Cache{cache} | |||||
if cleanupInterval > 0 { | |||||
runJanitor(cache, cleanupInterval) | |||||
runtime.SetFinalizer(Cache, stopJanitor) | |||||
} | |||||
return Cache | |||||
} | |||||
func newCache(de time.Duration, m map[uuid.UUID]Item) *cache { | |||||
if de == 0 { | |||||
de = -1 | |||||
} | |||||
c := &cache{ | |||||
defaultExpiration: de, | |||||
items: m, | |||||
} | |||||
return c | |||||
} | |||||
// Add an item to the cache, replacing any existing item. If the duration is 0 | |||||
// (DefaultExpiration), the cache's default expiration time is used. If it is -1 | |||||
// (NoExpiration), the item never expires. | |||||
func (c *cache) Set(k uuid.UUID, x interface{}, d time.Duration) { | |||||
// "Inlining" of set | |||||
var e int64 | |||||
if d == DefaultExpiration { | |||||
d = c.defaultExpiration | |||||
} | |||||
if d > 0 { | |||||
e = time.Now().Add(d).UnixNano() | |||||
} | |||||
c.mu.Lock() | |||||
c.items[k] = Item{ | |||||
Object: x, | |||||
Expiration: e, | |||||
} | |||||
// TODO: Calls to mu.Unlock are currently not deferred because defer | |||||
// adds ~200 ns (as of go1.) | |||||
c.mu.Unlock() | |||||
} | |||||
func (c *cache) set(k uuid.UUID, x interface{}, d time.Duration) { | |||||
var e int64 | |||||
if d == DefaultExpiration { | |||||
d = c.defaultExpiration | |||||
} | |||||
if d > 0 { | |||||
e = time.Now().Add(d).UnixNano() | |||||
} | |||||
c.items[k] = Item{ | |||||
Object: x, | |||||
Expiration: e, | |||||
} | |||||
} | |||||
// Add an item to the cache, replacing any existing item, using the default | |||||
// expiration. | |||||
func (c *cache) SetDefault(k uuid.UUID, x interface{}) { | |||||
c.Set(k, x, DefaultExpiration) | |||||
} | |||||
// Add an item to the cache only if an item doesn't already exist for the given | |||||
// key, or if the existing item has expired. Returns an error otherwise. | |||||
func (c *cache) Add(k uuid.UUID, x interface{}, d time.Duration) error { | |||||
c.mu.Lock() | |||||
_, found := c.get(k) | |||||
if found { | |||||
c.mu.Unlock() | |||||
return fmt.Errorf("Item %s already exists", k) | |||||
} | |||||
c.set(k, x, d) | |||||
c.mu.Unlock() | |||||
return nil | |||||
} | |||||
// Set a new value for the cache key only if it already exists, and the existing | |||||
// item hasn't expired. Returns an error otherwise. | |||||
func (c *cache) Replace(k uuid.UUID, x interface{}, d time.Duration) error { | |||||
c.mu.Lock() | |||||
_, found := c.get(k) | |||||
if !found { | |||||
c.mu.Unlock() | |||||
return fmt.Errorf("Item %s doesn't exist", k) | |||||
} | |||||
c.set(k, x, d) | |||||
c.mu.Unlock() | |||||
return nil | |||||
} | |||||
// Get an item from the cache. Returns the item or nil, and a bool indicating | |||||
// whether the key was found. | |||||
func (c *cache) Get(k uuid.UUID) (interface{}, bool) { | |||||
c.mu.RLock() | |||||
// "Inlining" of get and Expired | |||||
item, found := c.items[k] | |||||
if !found { | |||||
c.mu.RUnlock() | |||||
return nil, false | |||||
} | |||||
if item.Expiration > 0 { | |||||
if time.Now().UnixNano() > item.Expiration { | |||||
c.mu.RUnlock() | |||||
return nil, false | |||||
} | |||||
} | |||||
c.mu.RUnlock() | |||||
return item.Object, true | |||||
} | |||||
// GetWithExpiration returns an item and its expiration time from the cache. | |||||
// It returns the item or nil, the expiration time if one is set (if the item | |||||
// never expires a zero value for time.Time is returned), and a bool indicating | |||||
// whether the key was found. | |||||
func (c *cache) GetWithExpiration(k uuid.UUID) (interface{}, time.Time, bool) { | |||||
c.mu.RLock() | |||||
// "Inlining" of get and Expired | |||||
item, found := c.items[k] | |||||
if !found { | |||||
c.mu.RUnlock() | |||||
return nil, time.Time{}, false | |||||
} | |||||
if item.Expiration > 0 { | |||||
if time.Now().UnixNano() > item.Expiration { | |||||
c.mu.RUnlock() | |||||
return nil, time.Time{}, false | |||||
} | |||||
// Return the item and the expiration time | |||||
c.mu.RUnlock() | |||||
return item.Object, time.Unix(0, item.Expiration), true | |||||
} | |||||
// If expiration <= 0 (i.e. no expiration time set) then return the item | |||||
// and a zeroed time.Time | |||||
c.mu.RUnlock() | |||||
return item.Object, time.Time{}, true | |||||
} | |||||
func (c *cache) get(k uuid.UUID) (interface{}, bool) { | |||||
item, found := c.items[k] | |||||
if !found { | |||||
return nil, false | |||||
} | |||||
// "Inlining" of Expired | |||||
if item.Expiration > 0 { | |||||
if time.Now().UnixNano() > item.Expiration { | |||||
return nil, false | |||||
} | |||||
} | |||||
return item.Object, true | |||||
} | |||||
func (c *cache) delete(k uuid.UUID) (interface{}, bool) { | |||||
if c.onEvicted != nil { | |||||
if v, found := c.items[k]; found { | |||||
delete(c.items, k) | |||||
return v.Object, true | |||||
} | |||||
} | |||||
delete(c.items, k) | |||||
return nil, false | |||||
} | |||||
type keyAndValue struct { | |||||
key uuid.UUID | |||||
value interface{} | |||||
} | |||||
// Delete all expired items from the cache. | |||||
func (c *cache) DeleteExpired() { | |||||
var evictedItems []keyAndValue | |||||
now := time.Now().UnixNano() | |||||
c.mu.Lock() | |||||
for k, v := range c.items { | |||||
// "Inlining" of expired | |||||
if v.Expiration > 0 && now > v.Expiration { | |||||
ov, evicted := c.delete(k) | |||||
if evicted { | |||||
evictedItems = append(evictedItems, keyAndValue{k, ov}) | |||||
} | |||||
} | |||||
} | |||||
c.mu.Unlock() | |||||
for _, v := range evictedItems { | |||||
c.onEvicted(v.key, v.value) | |||||
} | |||||
} | |||||
// Sets an (optional) function that is called with the key and value when an | |||||
// item is evicted from the cache. (Including when it is deleted manually, but | |||||
// not when it is overwritten.) Set to nil to disable. | |||||
func (c *cache) OnEvicted(f func(uuid.UUID, interface{})) { | |||||
c.mu.Lock() | |||||
c.onEvicted = f | |||||
c.mu.Unlock() | |||||
} | |||||
// Delete all items from the cache. | |||||
func (c *cache) Flush() { | |||||
c.mu.Lock() | |||||
c.items = map[uuid.UUID]Item{} | |||||
c.mu.Unlock() | |||||
} | |||||
type janitor struct { | |||||
Interval time.Duration | |||||
stop chan bool | |||||
} | |||||
func (j *janitor) Run(c *cache) { | |||||
ticker := time.NewTicker(j.Interval) | |||||
for { | |||||
select { | |||||
case <-ticker.C: | |||||
c.DeleteExpired() | |||||
case <-j.stop: | |||||
ticker.Stop() | |||||
return | |||||
} | |||||
} | |||||
} | |||||
func stopJanitor(c *Cache) { | |||||
c.janitor.stop <- true | |||||
} | |||||
func runJanitor(c *cache, ci time.Duration) { | |||||
j := &janitor{ | |||||
Interval: ci, | |||||
stop: make(chan bool), | |||||
} | |||||
c.janitor = j | |||||
go j.Run(c) | |||||
} |
@ -0,0 +1,36 @@ | |||||
package cache | |||||
import ( | |||||
"sync" | |||||
"time" | |||||
uuid "github.com/satori/go.uuid" | |||||
) | |||||
// Item for caching | |||||
type Item struct { | |||||
Object interface{} | |||||
Expiration int64 | |||||
} | |||||
// Cache is a simple in-memory cache for storing things | |||||
type Cache struct { | |||||
*cache | |||||
} | |||||
type cache struct { | |||||
defaultExpiration time.Duration | |||||
items map[uuid.UUID]Item | |||||
mu sync.RWMutex | |||||
onEvicted func(uuid.UUID, interface{}) | |||||
janitor *janitor | |||||
} | |||||
const ( | |||||
// NoExpiration For use with functions that take an expiration time. | |||||
NoExpiration time.Duration = -1 | |||||
// DefaultExpiration For use with functions that take an expiration time. | |||||
// Equivalent to passing in the same expiration duration as was given to | |||||
// New() or NewFrom() when the cache was created (e.g. 5 minutes.) | |||||
DefaultExpiration time.Duration = time.Hour * 1 | |||||
) |
@ -0,0 +1,137 @@ | |||||
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. | |||||
// Use of this source code is governed by a BSD-style | |||||
// license that can be found in the LICENSE file. | |||||
package main | |||||
import ( | |||||
"bytes" | |||||
"log" | |||||
"net/http" | |||||
"time" | |||||
"github.com/gorilla/websocket" | |||||
) | |||||
const ( | |||||
// Time allowed to write a message to the peer. | |||||
writeWait = 10 * time.Second | |||||
// Time allowed to read the next pong message from the peer. | |||||
pongWait = 60 * time.Second | |||||
// Send pings to peer with this period. Must be less than pongWait. | |||||
pingPeriod = (pongWait * 9) / 10 | |||||
// Maximum message size allowed from peer. | |||||
maxMessageSize = 512 | |||||
) | |||||
var ( | |||||
newline = []byte{'\n'} | |||||
space = []byte{' '} | |||||
) | |||||
var upgrader = websocket.Upgrader{ | |||||
ReadBufferSize: 1024, | |||||
WriteBufferSize: 1024, | |||||
} | |||||
// Client is a middleman between the websocket connection and the hub. | |||||
type Client struct { | |||||
hub *Hub | |||||
// The websocket connection. | |||||
conn *websocket.Conn | |||||
// Buffered channel of outbound messages. | |||||
send chan []byte | |||||
} | |||||
// readPump pumps messages from the websocket connection to the hub. | |||||
// | |||||
// The application runs readPump in a per-connection goroutine. The application | |||||
// ensures that there is at most one reader on a connection by executing all | |||||
// reads from this goroutine. | |||||
func (c *Client) readPump() { | |||||
defer func() { | |||||
c.hub.unregister <- c | |||||
c.conn.Close() | |||||
}() | |||||
c.conn.SetReadLimit(maxMessageSize) | |||||
c.conn.SetReadDeadline(time.Now().Add(pongWait)) | |||||
c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil }) | |||||
for { | |||||
_, message, err := c.conn.ReadMessage() | |||||
if err != nil { | |||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { | |||||
log.Printf("error: %v", err) | |||||
} | |||||
break | |||||
} | |||||
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) | |||||
c.hub.broadcast <- message | |||||
} | |||||
} | |||||
// writePump pumps messages from the hub to the websocket connection. | |||||
// | |||||
// A goroutine running writePump is started for each connection. The | |||||
// application ensures that there is at most one writer to a connection by | |||||
// executing all writes from this goroutine. | |||||
func (c *Client) writePump() { | |||||
ticker := time.NewTicker(pingPeriod) | |||||
defer func() { | |||||
ticker.Stop() | |||||
c.conn.Close() | |||||
}() | |||||
for { | |||||
select { | |||||
case message, ok := <-c.send: | |||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | |||||
if !ok { | |||||
// The hub closed the channel. | |||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{}) | |||||
return | |||||
} | |||||
w, err := c.conn.NextWriter(websocket.TextMessage) | |||||
if err != nil { | |||||
return | |||||
} | |||||
w.Write(message) | |||||
// Add queued chat messages to the current websocket message. | |||||
n := len(c.send) | |||||
for i := 0; i < n; i++ { | |||||
w.Write(newline) | |||||
w.Write(<-c.send) | |||||
} | |||||
if err := w.Close(); err != nil { | |||||
return | |||||
} | |||||
case <-ticker.C: | |||||
c.conn.SetWriteDeadline(time.Now().Add(writeWait)) | |||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { | |||||
return | |||||
} | |||||
} | |||||
} | |||||
} | |||||
// serveWs handles websocket requests from the peer. | |||||
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) { | |||||
conn, err := upgrader.Upgrade(w, r, nil) | |||||
if err != nil { | |||||
log.Println(err) | |||||
return | |||||
} | |||||
client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} | |||||
client.hub.register <- client | |||||
// Allow collection of memory referenced by the caller by doing all work in | |||||
// new goroutines. | |||||
go client.writePump() | |||||
go client.readPump() | |||||
} |
@ -0,0 +1,56 @@ | |||||
<!DOCTYPE html> | |||||
<html> | |||||
<head> | |||||
<meta charset="utf-8" /> | |||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |||||
<title>TicTacToe :: {{ .title }}</title> | |||||
<meta name="viewport" content="width=device-width, initial-scale=1"> | |||||
<link rel="stylesheet" type="text/css" media="screen" href="/static/main.css" /> | |||||
</head> | |||||
<body> | |||||
<div class=wrapper> | |||||
<h1>TicTacToe</h1> | |||||
<h2 id=player class=hide></h2> | |||||
<form id=startGameForm> | |||||
<label for=name id=nameLabel>To start a new game, enter your name:</label> | |||||
<input type=text name=name id=name placeholder="Enter your name/nickname" size=64> | |||||
<button type=submit id=start>Start a new game</button> | |||||
</form> | |||||
<div id=instructions class=hide> | |||||
<p>Share this link with a friend to begin:</p> | |||||
<div class=link> | |||||
<div class=tooltip> | |||||
<span class=tooltiptext id=tooltip>Copy</span> | |||||
<input type=text id=shareLink onclick="ui.Copy(this)" onmouseout="ui.TooltipBlur()" readonly=readonly> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
<div id=status class=hide></div> | |||||
<div id=waiting class=hide>Waiting for opponent</div> | |||||
<div id=turn class=hide>Your Turn</div> | |||||
<div id=winner class=hide>You WIN!!!</div> | |||||
</div> | |||||
<div id=gameTable class=hide> | |||||
<table> | |||||
<tr> | |||||
<td id=pos_0 class=tile>T</td> | |||||
<td id=pos_1 class=tile>I</td> | |||||
<td id=pos_2 class=tile>C</td> | |||||
</tr> | |||||
<tr> | |||||
<td id=pos_3 class=tile>T</td> | |||||
<td id=pos_4 class=tile>A</td> | |||||
<td id=pos_5 class=tile>C</td> | |||||
</tr> | |||||
<tr> | |||||
<td id=pos_6 class=tile>T</td> | |||||
<td id=pos_7 class=tile>O</td> | |||||
<td id=pos_8 class=tile>E</td> | |||||
</tr> | |||||
</table> | |||||
</div> | |||||
<script src="/static/bundle.js"></script> | |||||
</body> | |||||
</html> |
@ -0,0 +1,44 @@ | |||||
<!DOCTYPE html> | |||||
<html> | |||||
<head> | |||||
<meta charset="utf-8" /> | |||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> | |||||
<title>TicTacToe :: {{ .title }}</title> | |||||
<meta name="viewport" content="width=device-width, initial-scale=1"> | |||||
<link rel="stylesheet" type="text/css" media="screen" href="/static/main.css" /> | |||||
</head> | |||||
<body> | |||||
<div class=wrapper> | |||||
<h1>TicTacToe</h1> | |||||
<h2 id=player class=hide></h2> | |||||
<form id=joinGameForm> | |||||
<label for=name id=nameLabel>To join, enter your name:</label> | |||||
<input type=text name=name id=name placeholder="Enter your name/nickname" size=64> | |||||
<button type=submit id=join>Join Game</button> | |||||
</form> | |||||
<div id=status class=hide></div> | |||||
<div id=turn class=hide></div> | |||||
<div id=winner class=hide>You WIN!!!</div> | |||||
</div> | |||||
<div id=gameTable class=hide> | |||||
<table> | |||||
<tr> | |||||
<td id=pos_0 class=tile>T</td> | |||||
<td id=pos_1 class=tile>I</td> | |||||
<td id=pos_2 class=tile>C</td> | |||||
</tr> | |||||
<tr> | |||||
<td id=pos_3 class=tile>T</td> | |||||
<td id=pos_4 class=tile>A</td> | |||||
<td id=pos_5 class=tile>C</td> | |||||
</tr> | |||||
<tr> | |||||
<td id=pos_6 class=tile>T</td> | |||||
<td id=pos_7 class=tile>O</td> | |||||
<td id=pos_8 class=tile>E</td> | |||||
</tr> | |||||
</table> | |||||
</div> | |||||
<script src="/static/bundle.js"></script> | |||||
</body> | |||||
</html> |
@ -0,0 +1,78 @@ | |||||
@import url(https://fonts.googleapis.com/css?family=Lato); | |||||
input#shareLink { | |||||
font-family: monospace; | |||||
cursor: pointer; | |||||
width: 400px; } | |||||
span#copySuccess { | |||||
display: none; | |||||
color: green; | |||||
font-weight: bold; } | |||||
span#copyError { | |||||
display: none; | |||||
color: red; | |||||
font-weight: bold; } | |||||
.link { | |||||
color: blue; } | |||||
#status { | |||||
font: normal normal normal 16px/20px 'Lato', sans-serif; | |||||
color: red; } | |||||
#turn, #waiting { | |||||
font: normal normal normal 16px/20px 'Lato', sans-serif; } | |||||
#gameTable tr td { | |||||
border-bottom: 1px solid #333; | |||||
border-right: 1px solid #333; | |||||
height: 160px; | |||||
width: 160px; | |||||
text-align: center; } | |||||
#gameTable tr td:last-child { | |||||
border-right: none; } | |||||
#gameTable tr td.dim { | |||||
opacity: 0.3; } | |||||
#gameTable tr td.dim.highlight { | |||||
opacity: 1; } | |||||
#gameTable tr td.highlight { | |||||
opacity: 1; } | |||||
#gameTable tr:last-child td { | |||||
border-bottom: none; } | |||||
.hide { | |||||
display: none; } | |||||
.tooltip { | |||||
position: relative; | |||||
display: inline-block; } | |||||
.tooltip .tooltiptext { | |||||
visibility: hidden; | |||||
width: 140px; | |||||
background-color: #555; | |||||
color: #fff; | |||||
text-align: center; | |||||
border-radius: 6px; | |||||
padding: 5px; | |||||
position: absolute; | |||||
z-index: 1; | |||||
bottom: 150%; | |||||
left: 50%; | |||||
margin-left: -75px; | |||||
opacity: 0; | |||||
transition: opacity 0.3s; } | |||||
.tooltip .tooltiptext::after { | |||||
content: ""; | |||||
position: absolute; | |||||
top: 100%; | |||||
left: 50%; | |||||
margin-left: -5px; | |||||
border-width: 5px; | |||||
border-style: solid; | |||||
border-color: #555 transparent transparent transparent; } | |||||
.tooltip:hover .tooltiptext { | |||||
visibility: visible; | |||||
opacity: 1; } | |||||
@ -0,0 +1,53 @@ | |||||
// Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. | |||||
// Use of this source code is governed by a BSD-style | |||||
// license that can be found in the LICENSE file. | |||||
package main | |||||
// Hub maintains the set of active clients and broadcasts messages to the | |||||
// clients. | |||||
type Hub struct { | |||||
// Registered clients. | |||||
clients map[*Client]bool | |||||
// Inbound messages from the clients. | |||||
broadcast chan []byte | |||||
// Register requests from the clients. | |||||
register chan *Client | |||||
// Unregister requests from clients. | |||||
unregister chan *Client | |||||
} | |||||
func newHub() *Hub { | |||||
return &Hub{ | |||||
broadcast: make(chan []byte), | |||||
register: make(chan *Client), | |||||
unregister: make(chan *Client), | |||||
clients: make(map[*Client]bool), | |||||
} | |||||
} | |||||
func (h *Hub) run() { | |||||
for { | |||||
select { | |||||
case client := <-h.register: | |||||
h.clients[client] = true | |||||
case client := <-h.unregister: | |||||
if _, ok := h.clients[client]; ok { | |||||
delete(h.clients, client) | |||||
close(client.send) | |||||
} | |||||
case message := <-h.broadcast: | |||||
for client := range h.clients { | |||||
select { | |||||
case client.send <- message: | |||||
default: | |||||
close(client.send) | |||||
delete(h.clients, client) | |||||
} | |||||
} | |||||
} | |||||
} | |||||
} |
@ -0,0 +1,128 @@ | |||||
package main | |||||
import ( | |||||
"log" | |||||
"net/http" | |||||
"os" | |||||
"time" | |||||
cache "tictactoe-api/cache" | |||||
"github.com/gin-contrib/multitemplate" | |||||
"github.com/gin-gonic/autotls" | |||||
"github.com/gin-gonic/gin" | |||||
uuid "github.com/satori/go.uuid" | |||||
) | |||||
func setupRender() multitemplate.Renderer { | |||||
r := multitemplate.NewRenderer() | |||||
r.AddFromFiles("index", "dist/index.html") | |||||
r.AddFromFiles("join", "dist/join.html") | |||||
return r | |||||
} | |||||
func main() { | |||||
hub := newHub() | |||||
go hub.run() | |||||
cache := cache.NewCache(time.Minute*10, time.Minute) | |||||
router := gin.Default() | |||||
router.HTMLRender = setupRender() | |||||
router.Static("/static", "dist") | |||||
router.GET("/favicon.ico", func(c *gin.Context) { | |||||
c.File("dist/favicon.ico") | |||||
}) | |||||
router.GET("/ping", func(c *gin.Context) { | |||||
c.String(http.StatusOK, "pong") | |||||
}) | |||||
router.Any("/ws", func(c *gin.Context) { | |||||
serveWs(hub, c.Writer, c.Request) | |||||
}) | |||||
// | |||||
// | |||||
// | |||||
router.GET("/join/:gameUUID", func(c *gin.Context) { | |||||
c.HTML(http.StatusOK, "join", gin.H{ | |||||
"title": "Play Game", | |||||
}) | |||||
}) | |||||
router.GET("/game", func(c *gin.Context) { | |||||
c.HTML(http.StatusOK, "index", gin.H{ | |||||
"title": "Start New Game", | |||||
}) | |||||
}) | |||||
router.POST("/game", func(c *gin.Context) { | |||||
var game Game | |||||
err := c.BindJSON(&game) | |||||
if err != nil { | |||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | |||||
return | |||||
} | |||||
game.Turn = game.Players[0].UUID | |||||
cache.Set(game.UUID, game, 0) | |||||
c.JSON(http.StatusOK, game) | |||||
}) | |||||
router.GET("/game/:gameUUID", func(c *gin.Context) { | |||||
gameUUID, err := uuid.FromString(c.Params.ByName("gameUUID")) | |||||
if err != nil { | |||||
log.Printf("UUID Parse Failed: %s\n", err) | |||||
} | |||||
game, found := cache.Get(gameUUID) | |||||
if !found { | |||||
c.Status(http.StatusNotFound) | |||||
return | |||||
} | |||||
c.JSON(http.StatusOK, game) | |||||
}) | |||||
router.POST("/game/:gameUUID/player/:playerUUID", func(c *gin.Context) { | |||||
gameUUID, err := uuid.FromString(c.Params.ByName("gameUUID")) | |||||
if err != nil { | |||||
log.Printf("UUID Game Parse Failed: %s\n", err) | |||||
} | |||||
playerUUID, err := uuid.FromString(c.Params.ByName("playerUUID")) | |||||
if err != nil { | |||||
log.Printf("UUID Player Parse Failed: %s\n", err) | |||||
} | |||||
game, found := cache.Get(gameUUID) | |||||
if !found { | |||||
c.Status(http.StatusNotFound) | |||||
return | |||||
} | |||||
_game := game.(Game) | |||||
var player Player | |||||
err = c.BindJSON(&player) | |||||
if err != nil { | |||||
log.Printf("Error: %s\n", err) | |||||
} | |||||
if len(_game.Players) > 1 { | |||||
c.JSON(http.StatusBadRequest, gin.H{"error": "TicTacToe can only be played with two players."}) | |||||
return | |||||
} | |||||
_game.Players = append(_game.Players, &Player{ | |||||
UUID: &playerUUID, | |||||
Name: player.Name, | |||||
}) | |||||
cache.Set(gameUUID, _game, 0) | |||||
c.JSON(http.StatusOK, _game) | |||||
}) | |||||
// | |||||
// | |||||
// | |||||
if os.Getenv("GIN_MODE") == "release" { | |||||
log.Fatal(autotls.Run(router, "tictactoe.l3vi.co")) | |||||
} else { | |||||
log.Fatal(router.Run(":80")) | |||||
} | |||||
} |
@ -0,0 +1,35 @@ | |||||
{ | |||||
"name": "tictactoe-api", | |||||
"version": "1.0.0", | |||||
"description": "A Golang Server that opens a unique socket for two users to play TicTacToe online.", | |||||
"private": true, | |||||
"scripts": { | |||||
"build": "webpack --watch" | |||||
}, | |||||
"babel": { | |||||
"presets": [ | |||||
[ | |||||
"@babel/preset-env", | |||||
{ | |||||
"targets": { | |||||
"esmodules": true | |||||
} | |||||
} | |||||
] | |||||
] | |||||
}, | |||||
"author": "Levi Olson", | |||||
"license": "MIT", | |||||
"devDependencies": { | |||||
"@babel/cli": "^7.2.3", | |||||
"@babel/core": "^7.2.2", | |||||
"@babel/preset-env": "^7.2.3", | |||||
"css-loader": "^2.1.0", | |||||
"mini-css-extract-plugin": "^0.5.0", | |||||
"node-sass": "^4.11.0", | |||||
"sass-loader": "^7.1.0", | |||||
"style-loader": "^0.23.1", | |||||
"webpack": "^4.28.1", | |||||
"webpack-cli": "^3.2.1" | |||||
} | |||||
} |
@ -0,0 +1,116 @@ | |||||
'use strict' | |||||
import * as HTTP from './services.js' | |||||
import { timingSafeEqual } from 'crypto'; | |||||
class BaseUtils { | |||||
constructor() { | |||||
this.id = this.getUUID() | |||||
} | |||||
getUUID () { | |||||
function s4 () { | |||||
return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) | |||||
} | |||||
return (s4() + s4() + '-' + s4() + '-4' + s4().slice(1) + '-8' + s4().slice(1) + '-' + s4() + s4() + s4()).toUpperCase() | |||||
} | |||||
setId (id) { | |||||
this.id = id | |||||
return this | |||||
} | |||||
setCookie (cname, cvalue) { | |||||
document.cookie = cname + "=" + cvalue + ";path=/" | |||||
return this | |||||
} | |||||
getCookie (cname) { | |||||
let name = cname + "=" | |||||
let decodedCookie = decodeURIComponent(document.cookie) | |||||
let ca = decodedCookie.split(';') | |||||
for (let i = 0; i < ca.length; i++) { | |||||
let c = ca[i] | |||||
while (c.charAt(0) == ' ') { | |||||
c = c.substring(1) | |||||
} | |||||
if (c.indexOf(name) == 0) { | |||||
return c.substring(name.length, c.length) | |||||
} | |||||
} | |||||
return "" | |||||
} | |||||
} | |||||
export class Player extends BaseUtils { | |||||
constructor(id, name) { | |||||
super() | |||||
if (id) | |||||
this.id = id | |||||
if (name) | |||||
this.name = name | |||||
return this | |||||
} | |||||
setId (id) { | |||||
this.id = id | |||||
return this | |||||
} | |||||
getId () { | |||||
return this.id | |||||
} | |||||
setName (name) { | |||||
this.name = name | |||||
return this | |||||
} | |||||
getName () { | |||||
return this.name | |||||
} | |||||
loadFromCookies() { | |||||
this.setId(this.getCookie("player_id") || this.getId()) | |||||
this.setName(this.getCookie("player_name")) | |||||
return this | |||||
} | |||||
setCookies() { | |||||
this.setCookie("player_id", this.id) | |||||
this.setCookie("player_name", this.name) | |||||
return this | |||||
} | |||||
} | |||||
export class Game extends BaseUtils { | |||||
constructor(player) { | |||||
super() | |||||
this.setId(this.getGameIdFromUrl() || this.id) | |||||
this.players = [player] | |||||
this.turn = player.uuid | |||||
this.winner = undefined | |||||
this.draw = undefined | |||||
this.matrix = [ | |||||
null, null, null, | |||||
null, null, null, | |||||
null, null, null | |||||
] | |||||
this.blocked = false | |||||
return this | |||||
} | |||||
getId () { | |||||
return this.id | |||||
} | |||||
getGameIdFromUrl() { | |||||
console.debug("getGameIdFromUrl") | |||||
let path = document.location.pathname.split("/") | |||||
console.debug(path) | |||||
if (path && path.length > 1) { | |||||
let uuid = path[path.length - 1] || "" | |||||
console.debug(uuid) | |||||
let matches = uuid.match("^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$") | |||||
return matches && matches.length ? path[path.length - 1] : undefined | |||||
} | |||||
return undefined | |||||
} | |||||
getOpponentsName(myId) { | |||||
let opponent = this.players.filter(x => x.id !== myId)[0] | |||||
console.debug("getOpponentsName()", opponent.name) | |||||
return opponent.name | |||||
} | |||||
setOpponentsTurn(myId) { | |||||
let opponent = this.players.filter(x => x.id !== myId)[0] | |||||
console.debug("setOpponentsTurn()", opponent.id) | |||||
this.turn = opponent.id | |||||
return this | |||||
} | |||||
} |
@ -0,0 +1,375 @@ | |||||
'use strict' | |||||
import {Player, Game} from "./classes.js" | |||||
import * as HTTP from "./services.js" | |||||
import style from './style.scss' | |||||
let conn | |||||
let gameOver = false | |||||
let player = new Player() | |||||
player.loadFromCookies() | |||||
player.setCookies() | |||||
let game = new Game(player) | |||||
console.debug(player) | |||||
console.debug(game) | |||||
/** | |||||
* Initialize DOM variables | |||||
*/ | |||||
let name = document.getElementById('name') | |||||
let nameLabel = document.getElementById('nameLabel') | |||||
let playerH1 = document.getElementById('player') | |||||
let status = document.getElementById('status') | |||||
let waiting = document.getElementById('waiting') | |||||
let turn = document.getElementById('turn') | |||||
let winner = document.getElementById('winner') | |||||
let draw = document.getElementById('draw') | |||||
let gameTable = document.getElementById('gameTable') | |||||
let instructions = document.getElementById('instructions') | |||||
let input = document.getElementById('shareLink') | |||||
let tooltip = document.getElementById("tooltip") | |||||
let startGameForm = document.getElementById('startGameForm') | |||||
let joinGameForm = document.getElementById('joinGameForm') | |||||
let opponentTileColor = "#992222" | |||||
let tileColor = "#229922" | |||||
/** | |||||
* Setup | |||||
*/ | |||||
function _setup() { | |||||
if (player.getName()) { | |||||
name.value = player.getName() | |||||
name.classList.add("hide") | |||||
nameLabel.classList.add("hide") | |||||
playerH1.innerText = "Hi " + player.getName() | |||||
playerH1.classList.remove("hide") | |||||
} | |||||
} | |||||
_setup() | |||||
export function Copy (context) { | |||||
console.debug(context.innerText) | |||||
context.select() | |||||
document.execCommand("Copy") | |||||
tooltip.innerHTML = "Copied!" | |||||
} | |||||
export function TooltipBlur () { | |||||
tooltip.innerHTML = "Copy" | |||||
} | |||||
if (window.WebSocket) { | |||||
conn = new WebSocket("ws://" + document.location.host + "/ws") | |||||
conn.onclose = function (evt) { | |||||
console.debug("Websocket Closed") | |||||
} | |||||
conn.onmessage = function (evt) { | |||||
let messages = evt.data.split('\n') | |||||
for (var i = 0; i < messages.length; i++) { | |||||
let data = messages[i] | |||||
try { | |||||
data = JSON.parse(data) | |||||
} catch (e) { | |||||
console.error("Error parsing", data) | |||||
} | |||||
console.debug("Message", data) | |||||
if (data.sender === player.getId()) { | |||||
console.debug("My own message received and ignored") | |||||
return | |||||
} | |||||
switch(data.event) { | |||||
case "joining": | |||||
console.debug("Event: joining") | |||||
instructions.classList.add("hide") | |||||
game.players.push(data.player) | |||||
console.debug(game) | |||||
_yourTurn() | |||||
_showGame() | |||||
break | |||||
case "move": | |||||
console.debug("Event: move") | |||||
game.matrix[data.index] = data.player | |||||
_updateTiles(data.index) | |||||
_yourTurn() | |||||
console.debug(game.matrix) | |||||
break | |||||
case "winner": | |||||
console.debug("Event: winner") | |||||
game.matrix[data.index] = data.player | |||||
_updateTiles(data.index) | |||||
gameOver = true | |||||
status.classList.add("hide") | |||||
turn.classList.add("hide") | |||||
winner.innerText = "You lose... " + game.getOpponentsName(player.getId()) + " Wins!" | |||||
winner.classList.remove("hide") | |||||
_highlightBoard(data.positions) | |||||
_dimBoard() | |||||
if (startGameForm) | |||||
startGameForm.classList.remove("hide") | |||||
break | |||||
case "draw": | |||||
console.debug("Event: draw") | |||||
game.matrix[data.index] = data.player | |||||
_updateTiles(data.index) | |||||
gameOver = true | |||||
status.classList.add("hide") | |||||
turn.classList.add("hide") | |||||
winner.innerText = "Its a draw!" | |||||
winner.classList.remove("hide") | |||||
_dimBoard() | |||||
if (startGameForm) | |||||
startGameForm.classList.remove("hide") | |||||
break | |||||
} | |||||
} | |||||
} | |||||
} else { | |||||
console.error("TicTacToe can only be played in a browser that supports a WebSocket connection.") | |||||
} | |||||
/** | |||||
* BEGIN Document Listeners | |||||
*/ | |||||
if (startGameForm) { | |||||
startGameForm.addEventListener('submit', event => { | |||||
event.preventDefault() | |||||
if (!_validateForm()) { | |||||
return false | |||||
} | |||||
_createGame().then(resp => { | |||||
startGameForm.classList.add("hide") | |||||
console.debug("_createGame() success", resp) | |||||
game.turn = resp.turn | |||||
game.matrix = resp.matrix | |||||
console.debug("Game:", game) | |||||
input.value = "http://localhost/join/" + game.getId() | |||||
instructions.classList.remove("hide") | |||||
_waiting() | |||||
// _yourTurn() | |||||
// _showGame() | |||||
}).catch(err => { | |||||
console.debug("_createGame() error", err) | |||||
status.innerText = err.message.error | |||||
status.classList.remove("hide") | |||||
}) | |||||
}) | |||||
} | |||||
if (joinGameForm) { | |||||
joinGameForm.addEventListener('submit', event => { | |||||
event.preventDefault() | |||||
if (!_validateForm()) { | |||||
return false | |||||
} | |||||
_addPlayer().then(resp => { | |||||
joinGameForm.classList.add("hide") | |||||
console.debug("_addPlayer() success", resp) | |||||
conn.send(JSON.stringify({ | |||||
sender: player.getId(), | |||||
game_id: game.getId(), | |||||
event: "joining", | |||||
player: { | |||||
id: player.getId(), | |||||
name: player.getName() | |||||
} | |||||
})) | |||||
game.players.push(new Player(resp.players[0].id, resp.players[0].name)) | |||||
_theirTurn() | |||||
_showGame() | |||||
}).catch(err => { | |||||
console.debug("_addPlayer() error", err) | |||||
status.innerText = err.message.error | |||||
status.classList.remove("hide") | |||||
}) | |||||
}) | |||||
} | |||||
if (gameTable) { | |||||
document.querySelectorAll('.tile').forEach(element => { | |||||
element.addEventListener('click', event => { | |||||
if (gameOver) { | |||||
console.debug("Game over") | |||||
event.preventDefault() | |||||
return | |||||
} | |||||
let pos = event.target.id.split('_')[1] | |||||
// validate its my turn | |||||
if (game.turn !== player.getId()) { | |||||
console.debug("Not my turn") | |||||
event.preventDefault() | |||||
return | |||||
} | |||||
// validate position available | |||||
if (game.matrix[pos]) { | |||||
console.debug("Tile not free") | |||||
event.preventDefault() | |||||
return | |||||
} | |||||
// set move | |||||
game.matrix[pos] = player.getId() | |||||
// show tile selected | |||||
event.target.style.backgroundColor = tileColor | |||||
// calculate if 3 tiles in a row | |||||
let three = _threeInARow() | |||||
if (three) { | |||||
console.debug("You win!") | |||||
_highlightBoard(three) | |||||
_dimBoard() | |||||
gameOver = true | |||||
winner.innerText = "You Win!!!" | |||||
winner.classList.remove("hide") | |||||
status.classList.add("hide") | |||||
turn.classList.add("hide") | |||||
conn.send(JSON.stringify({ | |||||
sender: player.getId(), | |||||
game_id: game.getId(), | |||||
event: "winner", | |||||
index: pos, | |||||
player: player.getId(), | |||||
winner: player.getId(), | |||||
positions: three | |||||
})) | |||||
if (startGameForm) | |||||
startGameForm.classList.remove("hide") | |||||
event.preventDefault() | |||||
return | |||||
} | |||||
if (_draw()) { | |||||
console.debug("Draw!") | |||||
_dimBoard() | |||||
gameOver = true | |||||
winner.innerText = "Its a draw!" | |||||
winner.classList.remove("hide") | |||||
status.classList.add("hide") | |||||
turn.classList.add("hide") | |||||
conn.send(JSON.stringify({ | |||||
sender: player.getId(), | |||||
game_id: game.getId(), | |||||
event: "draw", | |||||
index: pos, | |||||
player: player.getId(), | |||||
winner: player.getId() | |||||
})) | |||||
if (startGameForm) | |||||
startGameForm.classList.remove("hide") | |||||
event.preventDefault() | |||||
return | |||||
} | |||||
// send move via socket | |||||
conn.send(JSON.stringify({ | |||||
sender: player.getId(), | |||||
game_id: game.getId(), | |||||
event: "move", | |||||
index: pos, | |||||
player: player.getId() | |||||
})) | |||||
_theirTurn() | |||||
}) | |||||
}) | |||||
} | |||||
/** | |||||
* END Document Listeners | |||||
*/ | |||||
function _validateForm() { | |||||
if (!name.value) { | |||||
status.innerText = "Name is required." | |||||
status.classList.remove("hide") | |||||
return false | |||||
} | |||||
player.setName(name.value) | |||||
player.setCookies() | |||||
status.classList.add("hide") | |||||
_greeting() | |||||
return true | |||||
} | |||||
function _getUrl() { | |||||
return document.location.protocol + '//' + document.location.host | |||||
} | |||||
function _greeting() { | |||||
if (player.getName()) { | |||||
playerH1.innerText = "Hi " + player.getName() | |||||
playerH1.classList.remove("hide") | |||||
} | |||||
} | |||||
function _createGame() { | |||||
let url = _getUrl() | |||||
return HTTP.POST(`${url}/game`, JSON.stringify(game)) | |||||
} | |||||
function _addPlayer() { | |||||
let url = _getUrl() | |||||
let gid = game.getId() | |||||
let pid = player.getId() | |||||
return HTTP.POST(`${url}/game/${gid}/player/${pid}`, JSON.stringify({ name: document.getElementById('name').value })) | |||||
} | |||||
function _showGame() { | |||||
gameTable.classList.remove("hide") | |||||
} | |||||
function _waiting() { | |||||
waiting.innerText = "Waiting for opponent" | |||||
waiting.classList.remove("hide") | |||||
} | |||||
function _yourTurn () { | |||||
if (waiting) | |||||
waiting.classList.add("hide") | |||||
turn.innerText = "Your Turn" | |||||
turn.classList.remove("hide") | |||||
game.turn = player.getId() | |||||
} | |||||
function _theirTurn () { | |||||
game.setOpponentsTurn(player.getId()) | |||||
turn.innerText = _possessivizer(game.getOpponentsName(player.getId())) + " Turn" | |||||
turn.classList.remove("hide") | |||||
} | |||||
function _possessivizer(name) { | |||||
if (name.charAt(name.length - 1) === "s") { | |||||
return name + "'" | |||||
} else { | |||||
return name + "'s" | |||||
} | |||||
} | |||||
function _updateTiles(index) { | |||||
document.getElementById('pos_' + index).style.backgroundColor = opponentTileColor | |||||
} | |||||
function _threeInARow() { | |||||
for (let i = 0; i <= 8; i) { | |||||
if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 1] && game.matrix[i] === game.matrix[i + 2]) ) { | |||||
return [i, i + 1, i + 2] | |||||
} | |||||
i = i + 3 | |||||
} | |||||
for (let i = 0; i <= 2; i++) { | |||||
if (game.matrix[i] && (game.matrix[i] === game.matrix[i + 3] && game.matrix[i] === game.matrix[i + 6]) ) { | |||||
return [i, i + 3, i + 6] | |||||
} | |||||
} | |||||
if (game.matrix[0] && (game.matrix[0] === game.matrix[4] && game.matrix[0] === game.matrix[8])) { | |||||
return [0, 4, 8] | |||||
} | |||||
if (game.matrix[2] && (game.matrix[2] === game.matrix[4] && game.matrix[2] === game.matrix[6])) { | |||||
return [2, 4, 6] | |||||
} | |||||
return false | |||||
} | |||||
function _draw() { | |||||
for (let i = 0; i < game.matrix.length; i++) { | |||||
if (!game.matrix[i]) { | |||||
return false | |||||
} | |||||
} | |||||
return true | |||||
} | |||||
function _dimBoard() { | |||||
document.querySelectorAll('td').forEach(el => el.classList.add("dim")) | |||||
} | |||||
function _highlightBoard (positions) { | |||||
for (let pos of positions) { | |||||
document.querySelector('#pos_' + pos).classList.add("highlight") | |||||
} | |||||
} |
@ -0,0 +1,41 @@ | |||||
'use strict' | |||||
// GET HTTP call | |||||
export function GET (theUrl, callback) { | |||||
var req = new XMLHttpRequest() | |||||
req.onreadystatechange = function () { | |||||
if (req.readyState == 4 && req.status == 200) | |||||
callback(req.responseText) | |||||
} | |||||
req.open("GET", theUrl, true) | |||||
req.send(null) | |||||
} | |||||
// POST HTTP call | |||||
export function POST (theUrl, data) { | |||||
return new Promise((resolve, reject) => { | |||||
let req = new XMLHttpRequest() | |||||
req.onreadystatechange = function () { | |||||
if (req.readyState == 4 && req.status == 200) { | |||||
resolve(_parseOrNot(req.responseText)) | |||||
} else if (req.readyState == 4) { | |||||
reject({ status: req.status, message: _parseOrNot(req.responseText) }) | |||||
} | |||||
} | |||||
req.open("POST", theUrl, true) | |||||
if (data) { | |||||
req.setRequestHeader("Content-Type", "application/json") | |||||
} | |||||
req.send(data) | |||||
}) | |||||
} | |||||
function _parseOrNot(str) { | |||||
let data = str | |||||
try { | |||||
data = JSON.parse(str) | |||||
} catch (e) { | |||||
console.debug("Error Parsing Response JSON:", str) | |||||
} | |||||
return data | |||||
} |
@ -0,0 +1,96 @@ | |||||
@import url('https://fonts.googleapis.com/css?family=Lato'); | |||||
input#shareLink { | |||||
font-family: monospace; | |||||
cursor: pointer; | |||||
width: 400px; | |||||
} | |||||
span { | |||||
&#copySuccess { | |||||
display: none; | |||||
color: green; | |||||
font-weight: bold; | |||||
} | |||||
&#copyError { | |||||
display: none; | |||||
color: red; | |||||
font-weight: bold; | |||||
} | |||||
} | |||||
.link { | |||||
color: blue; | |||||
} | |||||
#status { | |||||
font: normal normal normal 16px/20px 'Lato', sans-serif; | |||||
color: red; | |||||
} | |||||
#turn, #waiting { | |||||
font: normal normal normal 16px/20px 'Lato', sans-serif; | |||||
} | |||||
#gameTable { | |||||
tr { | |||||
td { | |||||
border-bottom: 1px solid #333; | |||||
border-right: 1px solid #333; | |||||
height: 160px; | |||||
width: 160px; | |||||
text-align: center; | |||||
&:last-child { | |||||
border-right: none; | |||||
} | |||||
&.dim { | |||||
opacity: 0.3; | |||||
&.highlight { | |||||
opacity: 1; | |||||
} | |||||
} | |||||
&.highlight { | |||||
opacity: 1; | |||||
} | |||||
} | |||||
&:last-child { | |||||
td { | |||||
border-bottom: none; | |||||
} | |||||
} | |||||
} | |||||
} | |||||
.hide { | |||||
display: none; | |||||
} | |||||
.tooltip { | |||||
position: relative; | |||||
display: inline-block; | |||||
.tooltiptext { | |||||
visibility: hidden; | |||||
width: 140px; | |||||
background-color: #555; | |||||
color: #fff; | |||||
text-align: center; | |||||
border-radius: 6px; | |||||
padding: 5px; | |||||
position: absolute; | |||||
z-index: 1; | |||||
bottom: 150%; | |||||
left: 50%; | |||||
margin-left: -75px; | |||||
opacity: 0; | |||||
transition: opacity 0.3s; | |||||
&::after { | |||||
content: ""; | |||||
position: absolute; | |||||
top: 100%; | |||||
left: 50%; | |||||
margin-left: -5px; | |||||
border-width: 5px; | |||||
border-style: solid; | |||||
border-color: #555 transparent transparent transparent; | |||||
} | |||||
} | |||||
&:hover .tooltiptext { | |||||
visibility: visible; | |||||
opacity: 1; | |||||
} | |||||
} |
@ -0,0 +1,21 @@ | |||||
package main | |||||
import ( | |||||
uuid "github.com/satori/go.uuid" | |||||
) | |||||
// Game object for binding with JSON POST body | |||||
type Game struct { | |||||
UUID uuid.UUID `json:"id" binding:"required"` | |||||
Players []*Player `json:"players"` | |||||
Turn *uuid.UUID `json:"turn"` | |||||
Draw *bool `json:"draw,omitempty"` | |||||
Winner *uuid.UUID `json:"winner,omitempty"` | |||||
Matrix [9]*uuid.UUID `json:"matrix" binding:"min=9,max=9"` | |||||
} | |||||
// Player object for binding with JSON POST body | |||||
type Player struct { | |||||
UUID *uuid.UUID `json:"id"` | |||||
Name string `json:"name,omitempty"` | |||||
} |
@ -0,0 +1,29 @@ | |||||
[Unit] | |||||
Description=TicTacToe API service | |||||
ConditionPathExists=/root/go/src/tictactoe-api | |||||
After=network.target | |||||
[Service] | |||||
Type=simple | |||||
User=root | |||||
Group=root | |||||
LimitNOFILE=1024 | |||||
Environment="GIN_MODE=release" | |||||
Restart=on-failure | |||||
RestartSec=10 | |||||
WorkingDirectory=/root/go/src/tictactoe-api | |||||
ExecStart=/bin/bash -c "/root/go/src/tictactoe-api/tictactoe-api" | |||||
# make sure log directory exists and owned by syslog | |||||
PermissionsStartOnly=true | |||||
ExecStartPre=/bin/mkdir -p /var/log/tictactoeapi | |||||
ExecStartPre=/bin/chown syslog:adm /var/log/tictactoeapi | |||||
ExecStartPre=/bin/chmod 755 /var/log/tictactoeapi | |||||
StandardOutput=syslog | |||||
StandardError=syslog | |||||
SyslogIdentifier=tictactoeapi | |||||
[Install] | |||||
WantedBy=multi-user.target |
@ -0,0 +1,33 @@ | |||||
const path = require('path'); | |||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin"); | |||||
module.exports = { | |||||
mode: 'development', | |||||
entry: ['./src/index.js'], | |||||
output: { | |||||
filename: 'bundle.js', | |||||
path: path.resolve(__dirname, 'dist'), | |||||
library: 'ui' | |||||
}, | |||||
module: { | |||||
rules: [ | |||||
{ | |||||
test: /\.(sa|sc|c)ss$/, | |||||
use: [ | |||||
MiniCssExtractPlugin.loader, | |||||
'css-loader', | |||||
'sass-loader', | |||||
], | |||||
} | |||||
] | |||||
}, | |||||
target: "web", | |||||
plugins: [ | |||||
new MiniCssExtractPlugin({ | |||||
// Options similar to the same options in webpackOptions.output | |||||
// both options are optional | |||||
filename: '[name].css', | |||||
chunkFilename: '[id].css', | |||||
}) | |||||
] | |||||
}; |