Web app first pass

This commit is contained in:
Justin C. Miller
2024-09-03 00:55:35 -07:00
commit ad8e570205
13 changed files with 917 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
justthefrax

132
client.go Normal file
View File

@@ -0,0 +1,132 @@
package main
import (
"encoding/json"
"fmt"
"log"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second // Time allowed to write a message to the peer.
pongWait = 60 * time.Second // Time allowed to read the next pong message from the peer.
pingPeriod = (pongWait * 9) / 10 // Send pings to peer with this period. Must be less than pongWait.
maxMessageSize = 512 // Maximum message size allowed from peer.
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
type Clienter interface {
fmt.Stringer
getClient() *Client
handleMessage(env GameMessage) error
finish()
}
type Client struct {
game *Game
conn *websocket.Conn
outQueue chan []byte
}
func newClient(g *Game, conn *websocket.Conn) Client {
return Client{g, conn, make(chan []byte, 256)}
}
func (c *Client) send(data []byte) bool {
select {
case c.outQueue <- data:
return true
default:
close(c.outQueue)
return false
}
}
func (c *Client) closeQueue() {
close(c.outQueue)
}
func handleError(c Clienter, err error) {
log.Printf("%s error: %v", c, err)
errmsg := makeMessage("error", fmt.Sprintf("%v", err))
c.getClient().send(errmsg)
}
func readPump(c Clienter) {
client := c.getClient()
defer func() {
c.finish()
client.conn.Close()
}()
client.conn.SetReadLimit(maxMessageSize)
client.conn.SetReadDeadline(time.Now().Add(pongWait))
client.conn.SetPongHandler(func(string) error {
client.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, data, err := client.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("error: %v", err)
}
break
}
var envelope GameMessage
if err := json.Unmarshal(data, &envelope); err != nil {
handleError(c, fmt.Errorf("Unmarshal: %w", err))
}
if err := c.handleMessage(envelope); err != nil {
handleError(c, err)
}
}
}
func writePump(c Clienter) {
ticker := time.NewTicker(pingPeriod)
client := c.getClient()
defer func() {
ticker.Stop()
client.conn.Close()
}()
for {
select {
case message, ok := <-client.outQueue:
client.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// The game closed the channel.
client.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := client.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
client.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := client.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

118
game.go Normal file
View File

@@ -0,0 +1,118 @@
package main
import (
"fmt"
"log"
"math/rand/v2"
"strconv"
)
type Game struct {
players map[uint]*Player
hosts map[*Host]bool
playerToken uint64 // Verification token for players
hostToken uint64 // Verification token for the host
registerPlayer chan *Player // Register requests from players.
unregisterPlayer chan *Player // Unregister requests from players.
registerHost chan *Host // Register requests from hosts.
unregisterHost chan *Host // Unregister requests from hosts.
activePlayer *Player
}
func tokenize(token uint64) string {
return strconv.FormatUint(token, 36)
}
func newGame() *Game {
return &Game{
players: make(map[uint]*Player),
hosts: make(map[*Host]bool),
playerToken: rand.Uint64(),
hostToken: rand.Uint64(),
registerPlayer: make(chan *Player),
unregisterPlayer: make(chan *Player),
registerHost: make(chan *Host),
unregisterHost: make(chan *Host),
}
}
func (g *Game) checkPlayerToken(token string) bool {
return token == tokenize(g.playerToken)
}
func (g *Game) broadcastHosts(message []byte) {
for client := range g.hosts {
if !client.send(message) {
delete(g.hosts, client)
}
}
}
func (g *Game) broadcastPlayers(message []byte) {
for id := range g.players {
g.sendTo(id, message)
}
}
func (g *Game) sendTo(playerId uint, message []byte) error {
client, ok := g.players[playerId]
if !ok {
return fmt.Errorf("No Player %d", playerId)
}
if !client.send(message) {
delete(g.players, playerId)
return fmt.Errorf("Player %d disconnected", playerId)
}
return nil
}
func (g *Game) run() {
for {
select {
case player := <-g.registerPlayer:
g.players[player.Id] = player
log.Printf("Player %d (%s: %s) registered", player.Id, player.Team, player.Name)
join := makeMessage("join", player)
g.broadcastHosts(join)
case player := <-g.unregisterPlayer:
id := player.Id
if _, ok := g.players[id]; ok {
delete(g.players, id)
player.closeQueue()
}
log.Printf("Player %d (%s: %s) left", player.Id, player.Team, player.Name)
join := makeMessage("part", player)
g.broadcastHosts(join)
case host := <-g.registerHost:
g.hosts[host] = true
log.Println("Host joined")
for _, player := range g.players {
join := makeMessage("join", player)
g.broadcastHosts(join)
}
case host := <-g.unregisterHost:
if _, ok := g.hosts[host]; ok {
delete(g.hosts, host)
host.closeQueue()
}
log.Println("Host left")
}
}
}
func (g *Game) reveal(value bool) {
log.Printf("Revealing %v!", value)
reveal := makeMessage("reveal", value)
g.broadcastHosts(reveal)
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/justinian/justthefrax
go 1.22.4
require github.com/gorilla/websocket v1.5.3

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

58
home.html Normal file
View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
<head>
<title>just the frax!</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
<style>
:root {
--accent: #459991;
--accent-hover: #65e5d9;
}
form {
display: flex;
}
form div {
margin: 5px;
}
</style>
<script type="module">
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const tokenInput = document.getElementById('token');
tokenInput.value = token;
</script>
</head>
<body>
<header>
<h1>just the frax!</h1>
<p>A game of truth and lies.</p>
</header>
<main>
<h3>Join a Team</h3>
<form action="/player">
<input type="hidden" name="token" id="token"/>
<div>
<label for="name">Your Name</label>
<input type="text" name="name"/>
</div>
<div>
<label for="team">Your Team</label>
<select name="team">
<option>Seattle</option>
<option>SFBA</option>
</select>
</div>
<div>
<label for="go">&nbsp;</label>
<button id="go">Go!</button>
</div>
</form>
</main>
</body>
</html>

63
host.go Normal file
View File

@@ -0,0 +1,63 @@
package main
import (
"fmt"
"log"
"net/http"
)
type Host struct {
Client
}
func (h *Host) getClient() *Client { return &h.Client }
func (h *Host) String() string { return "Host" }
func (h *Host) finish() {
h.Client.game.unregisterHost <- h
}
type ActivateMessage struct {
Player uint `json:"player"`
Story string `json:"story"`
}
func (h *Host) handleMessage(env GameMessage) error {
game := h.Client.game
switch env.Type {
case "activate":
m, err := parseSubMessage[ActivateMessage](env.Message)
if err != nil {
return err
}
player, ok := game.players[m.Player]
if !ok {
return fmt.Errorf("No Player %d", m.Player)
}
player.activate(m.Story)
return nil
default:
return fmt.Errorf("Unknown message %s", env.Type)
}
}
func serveHostWs(game *Game, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
host := &Host{
Client: newClient(game, conn),
}
game.registerHost <- host
go writePump(host)
go readPump(host)
}

206
host.html Normal file
View File

@@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>just the frax!</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
<style>
:root {
--accent: #459991;
--accent-hover: #65e5d9;
}
.block {
border-radius: 10px;
border: thin gray solid;
margin: 10px;
margin-bottom: 5px;
padding: 5px 15px;
}
.warn {
background: orange;
color: black;
}
.title {
font-size: 1.2em;
font-weight: bold;
}
input {
width: 45%;
}
#players {
display: flex;
}
#warning {
display: none;
}
#players .block {
flex-grow: 1;
}
.team-list {
width: 100%;
button {
width: 100%;
}
}
</style>
</head>
<body>
<header>
<h1>just the frax!</h1>
<p>A game of truth and lies.</p>
</header>
<div class="block">
<div class="title">Status</div>
<div id="status-message"></div>
</div>
<div class="block warn" id="warning">
<div class="title">Error</div>
<p id="warning-message"></p>
</div>
<div id="players">
</div>
<div class="block">
<div class="title">Send Story</div>
<div>
<label for="recipient">Recipient</label>
<input type="text" id="recipient" readonly="true" value="None"/>
<textarea id="story-text"></textarea>
<button type="button" id="send-button">Send</send>
</div>
</div>
<script type="module">
const teams = new Map();
let recipient = null;
function updateRecipient(player) {
recipient = player;
const textarea = document.getElementById("recipient");
if (player)
textarea.value = player.name;
else
textarea.value = "None";
}
function updateTeamsView() {
const playersDiv = document.getElementById("players");
playersDiv.innerHTML = "";
teams.forEach((team, name) => {
const teamDiv = document.createElement("div");
teamDiv.classList.add("block");
teamDiv.innerHTML = `<div class='title'>Team ${name}</div>`;
const list = document.createElement("div");
list.classList.add("team-list");
team.forEach(player => {
const item = document.createElement("button");
item.innerHTML = player.name;
item.addEventListener("click", () => {
updateRecipient(player);
});
list.appendChild(item);
});
teamDiv.appendChild(list);
playersDiv.appendChild(teamDiv);
});
}
function addPlayer(player) {
if (!teams.has(player.team)) {
teams.set(player.team, new Map());
}
const team = teams.get(player.team);
team.set(player.id, player);
updateTeamsView();
}
function removePlayer(player) {
if (!teams.has(player.team))
return
const team = teams.get(player.team);
team.delete(player.id);
updateTeamsView();
}
function sendMessage(conn, type, message) {
const data = JSON.stringify({type, message});
console.log("Sending:", data);
conn.send(data);
}
function handleMessage(data, conn) {
console.warn("Got message data:", data);
const msg = JSON.parse(data)
switch (msg.type) {
case "error":
showError(msg.message);
break;
case "join":
addPlayer(msg.message);
break;
case "part":
removePlayer(msg.message);
break;
case "reveal":
break;
default:
showError(`Unknown message type: ${msg.type}`);
}
}
function showStatus(message) {
const statusDiv = document.getElementById('status-message');
statusDiv.innerHTML = message;
}
function showError(message) {
const warnDiv = document.getElementById('warning');
const warnMessage = document.getElementById('warning-message');
warnMessage.innerHTML = message;
warnDiv.style.display = "block";
}
showStatus("Connecting...");
const conn = new WebSocket("ws://" + document.location.host + "/host/ws");
conn.addEventListener("close", e => { showStatus("Disconnected."); });
conn.addEventListener("error", e => { showError("Websocket error."); });
conn.addEventListener("message", e => { handleMessage(e.data, conn); });
conn.addEventListener("open", e => { showStatus("Connected") });
const sendButton = document.getElementById('send-button');
sendButton.addEventListener("click", () => {
const textArea = document.getElementById('story-text');
const story = textArea.value;
sendMessage(conn, "activate", { player: recipient.id, story });
textArea.value = "";
updateRecipient(null);
});
</script>
</body>
</html>

43
main.go Normal file
View File

@@ -0,0 +1,43 @@
package main
import (
"flag"
"log"
"net/http"
)
var addr = flag.String("addr", ":8080", "http server address")
var host = flag.String("host", "localhost", "http server canonical hostname")
func servePages(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
http.ServeFile(w, r, "home.html")
case "/host":
http.ServeFile(w, r, "host.html")
case "/player":
http.ServeFile(w, r, "player.html")
default:
http.Error(w, "Not found", http.StatusNotFound)
}
}
func main() {
flag.Parse()
game := newGame()
log.Printf("Player link: http://%s%s/?token=%s", *host, *addr, tokenize(game.playerToken))
log.Printf("Host link: http://%s%s/host?token=%s", *host, *addr, tokenize(game.hostToken))
go game.run()
http.HandleFunc("/", servePages)
http.HandleFunc("/player/ws", func(w http.ResponseWriter, r *http.Request) {
servePlayerWs(game, w, r)
})
http.HandleFunc("/host/ws", func(w http.ResponseWriter, r *http.Request) {
serveHostWs(game, w, r)
})
log.Fatal(http.ListenAndServe(*addr, nil))
}

25
message.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"encoding/json"
"fmt"
)
type GameMessage struct {
Type string `json:"type"`
Message json.RawMessage `json:"message"`
}
func parseSubMessage[T any](data json.RawMessage) (*T, error) {
var message T
if err := json.Unmarshal(data, &message); err != nil {
return nil, fmt.Errorf("Unmarshal submessage: %w", err)
}
return &message, nil
}
func makeMessage[T any](name string, resp T) []byte {
payload, _ := json.Marshal(&resp)
data, _ := json.Marshal(&GameMessage{name, json.RawMessage(payload)})
return data
}

4
modd.conf Normal file
View File

@@ -0,0 +1,4 @@
**/*.go {
prep: go build
daemon: ./justthefrax
}

108
player.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"fmt"
"log"
"net/http"
)
var nextPlayerId uint = 1
type Player struct {
Client
Name string `json:"name"`
Team string `json:"team"`
Id uint `json:"id"`
}
func (p *Player) getClient() *Client { return &p.Client }
func (p *Player) String() string { return fmt.Sprintf("Player %d", p.Id) }
func (p *Player) handleMessage(env GameMessage) error {
game := p.Client.game
switch env.Type {
case "register":
m, err := parseSubMessage[RegisterMessage](env.Message)
if err != nil {
return err
}
return p.handleRegisterMessage(*m)
case "reveal":
value, err := parseSubMessage[bool](env.Message)
if err != nil {
return err
}
if game.activePlayer != p {
return fmt.Errorf("You are not the active player")
}
game.activePlayer = nil
game.reveal(*value)
return nil
default:
return fmt.Errorf("Unknown message")
}
}
func (p *Player) activate(story string) {
game := p.Client.game
game.activePlayer = p
log.Printf("Activating player %s", p.Name)
msg := makeMessage("card", story)
p.send(msg)
}
func (p *Player) finish() {
p.Client.game.unregisterPlayer <- p
}
type RegisterMessage struct {
Name string `json: "name"`
Team string `json: "team"`
Token string `json: "token"`
}
func (p *Player) handleRegisterMessage(m RegisterMessage) error {
if p.Name != "" || p.Team != "" {
return fmt.Errorf("Already registered as %s:%s", p.Team, p.Name)
}
if !p.game.checkPlayerToken(m.Token) {
return fmt.Errorf("Incorrect token")
}
p.Name = m.Name
p.Team = m.Team
p.game.registerPlayer <- p
log.Printf("Player %d is %s:%s", p.Id, p.Team, p.Name)
msg := makeMessage("registered", p)
p.send(msg)
return nil
}
func servePlayerWs(game *Game, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
id := nextPlayerId
nextPlayerId++
player := &Player{
Client: newClient(game, conn),
Id: id,
}
go writePump(player)
go readPump(player)
}

152
player.html Normal file
View File

@@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>just the frax!</title>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
<style>
:root {
--accent: #459991;
--accent-hover: #65e5d9;
}
.block {
border-radius: 10px;
border: thin gray solid;
margin: 10px;
margin-bottom: 5px;
padding: 5px 15px;
}
.warn {
background: orange;
color: black;
}
.block .title {
font-size: 1.2em;
font-weight: bold;
}
#card {
display: none;
}
button {
width: 10em;
color: white;
font-weight: bolder;
}
#reveal-false {
background: #99454D;
}
#warning {
display: none;
}
</style>
</head>
<body>
<header>
<h1>just the frax!</h1>
<p>A game of truth and lies.</p>
</header>
<div class="block">
<div class="title">Status</div>
<div id="status-message"></div>
</div>
<div class="block warn" id="warning">
<div class="title">Error</div>
<p id="warning-message"></p>
</div>
<div class="block" id="card">
<div class="title">Story Card</div>
<p>Read this part aloud verbatim, then answer questions:</p>
<blockquote id="card-content"></blockquote>
<p>When the host tells you to reveal, use the correct button:</p>
<button type="button" id="reveal-true">This is THE TRUTH</button>
<button type="button" id="reveal-false">This is A LIE</button>
</div>
<script type="module">
function sendMessage(conn, type, message) {
const data = JSON.stringify({type, message});
console.log("Sending:", data);
conn.send(data);
}
function register(conn) {
showStatus("Registering...");
const params = new URLSearchParams(window.location.search);
const name = params.get('name');
const team = params.get('team');
const token = params.get('token');
sendMessage(conn, "register", {name, team, token});
}
function handleMessage(data, conn) {
console.warn("Got message data:", data);
const msg = JSON.parse(data)
switch (msg.type) {
case "error":
showError(msg.message);
break;
case "registered":
const params = new URLSearchParams(window.location.search);
const name = params.get('name');
const team = params.get('team');
showStatus(`Signed in as ${name} [Team: ${team}]`)
break;
case "card":
const cardDiv = document.getElementById("card");
const cardContent = document.getElementById("card-content");
cardContent.innerHTML = msg.message;
cardDiv.style.display = "block";
break;
default:
showError(`Unknown message type: ${msg.type}`);
}
}
function showStatus(message) {
const statusDiv = document.getElementById('status-message');
statusDiv.innerHTML = message;
}
function showError(message) {
const warnDiv = document.getElementById('warning');
const warnMessage = document.getElementById('warning-message');
warnMessage.innerHTML = message;
warnDiv.style.display = "block";
}
showStatus("Connecting...");
const conn = new WebSocket("ws://" + document.location.host + "/player/ws");
conn.addEventListener("close", e => { showStatus("Disconnected."); });
conn.addEventListener("error", e => { showError("Websocket error."); });
conn.addEventListener("message", e => { handleMessage(e.data, conn); });
conn.addEventListener("open", e => { register(conn); });
const revealTrue = document.getElementById('reveal-true');
const revealFalse = document.getElementById('reveal-false');
[revealTrue, revealFalse].forEach(button => {
button.addEventListener("click", e => {
const value = (button === revealTrue);
const cardDiv = document.getElementById("card");
cardDiv.style.display = "none";
sendMessage(conn, "reveal", value);
});
});
</script>
</body>
</html>