320 lines
7.5 KiB
Go
320 lines
7.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// ---- Helpers ----
|
|
|
|
func randHex(n int) string {
|
|
b := make([]byte, n)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func msNow() int64 { return time.Now().UnixMilli() }
|
|
|
|
// voteThreshold returns the number of votes required to trigger pending_removal
|
|
// for a non-#1 entry. = min(3, ceil(n/2)), minimum 1.
|
|
func voteThreshold(n int) int {
|
|
half := (n + 1) / 2
|
|
if half > 3 {
|
|
return 3
|
|
}
|
|
if half < 1 {
|
|
return 1
|
|
}
|
|
return half
|
|
}
|
|
|
|
func sanitize(s string, maxRunes int) string {
|
|
runes := []rune(s)
|
|
if len(runes) > maxRunes {
|
|
runes = runes[:maxRunes]
|
|
}
|
|
out := make([]rune, 0, len(runes))
|
|
for _, r := range runes {
|
|
if r != 0 && utf8.ValidRune(r) {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
// ---- Data model ----
|
|
|
|
type Entry struct {
|
|
ID string
|
|
Number int
|
|
Name string
|
|
JoinedAt int64 // Unix ms
|
|
Status string // "active" | "pending_removal"
|
|
PendingBy []int
|
|
PendingAt *int64 // Unix ms
|
|
token string
|
|
cancelFn context.CancelFunc
|
|
}
|
|
|
|
// pub returns the public (broadcast-safe) view of an entry.
|
|
type entryPub struct {
|
|
ID string `json:"id"`
|
|
Number int `json:"number"`
|
|
Name string `json:"name,omitempty"`
|
|
JoinedAt int64 `json:"joinedAt"`
|
|
Status string `json:"status"`
|
|
PendingBy []int `json:"pendingBy,omitempty"`
|
|
PendingAt *int64 `json:"pendingAt,omitempty"`
|
|
}
|
|
|
|
func (e *Entry) pub() entryPub {
|
|
return entryPub{
|
|
ID: e.ID,
|
|
Number: e.Number,
|
|
Name: e.Name,
|
|
JoinedAt: e.JoinedAt,
|
|
Status: e.Status,
|
|
PendingBy: e.PendingBy,
|
|
PendingAt: e.PendingAt,
|
|
}
|
|
}
|
|
|
|
// ---- Queue state ----
|
|
|
|
type QueueState struct {
|
|
Name string // display name, set at creation
|
|
Entries []*Entry
|
|
Counter int
|
|
CompletedSlots []float64 // seconds; only self-removals, not voted-out entries
|
|
}
|
|
|
|
// stateJSON serialises the full public state for broadcast.
|
|
func (q *QueueState) stateJSON() []byte {
|
|
views := make([]entryPub, len(q.Entries))
|
|
for i, e := range q.Entries {
|
|
views[i] = e.pub()
|
|
}
|
|
b, _ := json.Marshal(map[string]interface{}{
|
|
"type": "state",
|
|
"name": q.Name,
|
|
"entries": views,
|
|
"counter": q.Counter,
|
|
"avgDuration": q.avgDuration(),
|
|
})
|
|
return b
|
|
}
|
|
|
|
// avgDuration returns the average slot duration in seconds, or nil if <3 samples.
|
|
func (q *QueueState) avgDuration() interface{} {
|
|
if len(q.CompletedSlots) < 3 {
|
|
return nil
|
|
}
|
|
var sum float64
|
|
for _, d := range q.CompletedSlots {
|
|
sum += d
|
|
}
|
|
return sum / float64(len(q.CompletedSlots))
|
|
}
|
|
|
|
func (q *QueueState) findByToken(token string) *Entry {
|
|
for _, e := range q.Entries {
|
|
if e.token == token {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (q *QueueState) findByID(id string) *Entry {
|
|
for _, e := range q.Entries {
|
|
if e.ID == id {
|
|
return e
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (q *QueueState) removeEntry(id string) {
|
|
for i, e := range q.Entries {
|
|
if e.ID == id {
|
|
q.Entries = append(q.Entries[:i], q.Entries[i+1:]...)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// findVoteTarget returns the Number of the entry the given voter is targeting,
|
|
// or 0 if they have no active vote.
|
|
func (q *QueueState) findVoteTarget(voterNumber int) int {
|
|
for _, e := range q.Entries {
|
|
for _, n := range e.PendingBy {
|
|
if n == voterNumber {
|
|
return e.Number
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ---- Mutations ----
|
|
|
|
// join creates a new entry and returns it with its secret token.
|
|
func (q *QueueState) join(name string) (*Entry, string) {
|
|
name = sanitize(name, 50)
|
|
token := randHex(16)
|
|
q.Counter++
|
|
e := &Entry{
|
|
ID: randHex(8),
|
|
Number: q.Counter,
|
|
Name: name,
|
|
JoinedAt: msNow(),
|
|
Status: "active",
|
|
token: token,
|
|
}
|
|
q.Entries = append(q.Entries, e)
|
|
return e, token
|
|
}
|
|
|
|
// selfRemove removes the entry owned by token, recording the slot duration.
|
|
func (q *QueueState) selfRemove(token string) bool {
|
|
e := q.findByToken(token)
|
|
if e == nil {
|
|
return false
|
|
}
|
|
dur := float64(msNow()-e.JoinedAt) / 1000.0
|
|
q.CompletedSlots = append(q.CompletedSlots, dur)
|
|
if e.cancelFn != nil {
|
|
e.cancelFn()
|
|
}
|
|
removedNum := e.Number
|
|
q.removeEntry(e.ID)
|
|
q.cleanupVotes(removedNum)
|
|
return true
|
|
}
|
|
|
|
// startPending transitions an entry into pending_removal and launches the 60s timer.
|
|
// When the timer fires it sends the entry ID to timerC.
|
|
func (q *QueueState) startPending(e *Entry, timerC chan<- string) {
|
|
now := msNow()
|
|
e.Status = "pending_removal"
|
|
e.PendingAt = &now
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
e.cancelFn = cancel
|
|
id := e.ID
|
|
go func() {
|
|
<-ctx.Done()
|
|
cancel()
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
select {
|
|
case timerC <- id:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// markDone marks the #1 entry as pending_removal on behalf of byNumber.
|
|
// Returns false if the queue shifted or entry is already pending.
|
|
func (q *QueueState) markDone(byNumber int, entryID string, timerC chan<- string) bool {
|
|
if len(q.Entries) == 0 {
|
|
return false
|
|
}
|
|
target := q.Entries[0]
|
|
if target.ID != entryID {
|
|
return false // queue shifted since the client clicked
|
|
}
|
|
if target.Status == "pending_removal" {
|
|
return false
|
|
}
|
|
target.PendingBy = []int{byNumber}
|
|
q.startPending(target, timerC)
|
|
return true
|
|
}
|
|
|
|
// voteRemove adds a vote against a non-#1 entry.
|
|
// Returns "ok", "already_voting", or "noop".
|
|
func (q *QueueState) voteRemove(voterNumber int, targetID string, timerC chan<- string) string {
|
|
target := q.findByID(targetID)
|
|
if target == nil {
|
|
return "noop"
|
|
}
|
|
// Cannot vote on own entry
|
|
if target.Number == voterNumber {
|
|
return "noop"
|
|
}
|
|
// Cannot vote on #1 (use markDone instead)
|
|
if len(q.Entries) > 0 && q.Entries[0].ID == targetID {
|
|
return "noop"
|
|
}
|
|
// One active vote per user across the whole queue
|
|
if existing := q.findVoteTarget(voterNumber); existing != 0 {
|
|
return "already_voting"
|
|
}
|
|
target.PendingBy = append(target.PendingBy, voterNumber)
|
|
if len(target.PendingBy) >= voteThreshold(len(q.Entries)) {
|
|
q.startPending(target, timerC)
|
|
}
|
|
return "ok"
|
|
}
|
|
|
|
// restore cancels pending_removal for the entry owned by token.
|
|
func (q *QueueState) restore(token string) bool {
|
|
e := q.findByToken(token)
|
|
if e == nil || e.Status != "pending_removal" {
|
|
return false
|
|
}
|
|
if e.cancelFn != nil {
|
|
e.cancelFn()
|
|
e.cancelFn = nil
|
|
}
|
|
e.Status = "active"
|
|
e.PendingAt = nil
|
|
e.PendingBy = nil
|
|
return true
|
|
}
|
|
|
|
// timerRemove is called when the 60s timer fires for an entry.
|
|
func (q *QueueState) timerRemove(entryID string) bool {
|
|
e := q.findByID(entryID)
|
|
if e == nil || e.Status != "pending_removal" {
|
|
return false // already restored
|
|
}
|
|
removedNum := e.Number
|
|
q.removeEntry(entryID)
|
|
q.cleanupVotes(removedNum)
|
|
return true
|
|
}
|
|
|
|
// cleanupVotes removes a departed entry's number from all PendingBy lists.
|
|
// For entries in pending_removal with remaining votes below threshold, restores them.
|
|
// Called AFTER the entry has been removed from q.Entries so threshold uses updated count.
|
|
func (q *QueueState) cleanupVotes(removedNumber int) {
|
|
thresh := voteThreshold(len(q.Entries))
|
|
for _, e := range q.Entries {
|
|
for i, n := range e.PendingBy {
|
|
if n == removedNumber {
|
|
e.PendingBy = append(e.PendingBy[:i], e.PendingBy[i+1:]...)
|
|
// Restore only if votes remain but are now below threshold (Case B).
|
|
// If PendingBy is empty after removal (Case A marker left), let the
|
|
// 60s timer run its course — the action was already taken.
|
|
if e.Status == "pending_removal" &&
|
|
len(e.PendingBy) > 0 &&
|
|
len(e.PendingBy) < thresh {
|
|
if e.cancelFn != nil {
|
|
e.cancelFn()
|
|
e.cancelFn = nil
|
|
}
|
|
e.Status = "active"
|
|
e.PendingAt = nil
|
|
e.PendingBy = nil
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|