// duo.go: co-op mode. Each player runs the program on their own PC, // reading their own Elden Ring; one acts as the "hub" (serves the overlay // to OBS) and the rest connect to it over WebSocket, pushing their count. // // The connection is ALWAYS opened by the peer toward the hub, which is // what's convenient with Tailscale: it's enough for the hub to have a // stable IP on the tailnet. package main import ( "encoding/json" "fmt" "log" "net/http" "strings" "sync" "sync/atomic" "time" ) // If a peer sends nothing for this long, we consider it disconnected. // The peer pushes once a second, so this is generous. const peerTimeout = 6 * time.Second // How long we keep showing the co-op layout after the partner disappears. // See coopMode() for why. const coopGrace = 2 * time.Minute // --------------------------- peer registry --------------------------- type playerView struct { Name string `json:"name"` Deaths int64 `json:"deaths"` BossFight bool `json:"bossFight"` PlayerLoaded bool `json:"playerLoaded"` Connected bool `json:"connected"` Self bool `json:"self"` } // peerEntry: WATCH OUT for identity. The name does NOT work as a key: // from the moment we read it off the character, it changes whenever the // player loads a different save, and indexing by name would spawn a new // player on every character switch while leaving the old one stuck on // screen. Identity is the id each instance sends (see clientID), stable // across reconnects and restarts; the name is only something we display. type peerEntry struct { key string // how we index it: the id, or "name:x" for an old client name string deaths int64 bossFight bool playerLoaded bool lastSeen time.Time everSeen bool reserved bool // slot reserved by config, not yet claimed } type peerRegistry struct { mu sync.Mutex peers map[string]*peerEntry order []string // order of appearance, so the overlay doesn't reshuffle forced bool // the config named a partner: co-op from startup latched bool // we've already seen a partner this run lastCoop time.Time // last time someone was connected } func newPeerRegistry() *peerRegistry { return &peerRegistry{peers: map[string]*peerEntry{}} } // declare reserves a partner's slot before they connect and locks in // co-op mode from startup. Optional: useful when you want the overlay at // its final size from minute zero instead of resizing once the other // player shows up. func (r *peerRegistry) declare(name string) { if name == "" { return } r.mu.Lock() defer r.mu.Unlock() key := "reserved:" + name if _, ok := r.peers[key]; !ok { r.peers[key] = &peerEntry{key: key, name: name, reserved: true} r.order = append(r.order, key) } r.forced = true } // coopMode decides whether the overlay shows one column or two. // // It latches on when a partner shows up and does NOT let go at the first // hiccup: if it fell back to solo mode every time the network dropped for // a second, the overlay would redraw live mid-stream. It only returns to // single-player once the other player has been gone for a good while, // which is the signal for "went to sleep", not "wifi hiccuped". func (r *peerRegistry) coopMode() bool { r.mu.Lock() defer r.mu.Unlock() if r.forced { return true } now := time.Now() for _, p := range r.peers { if p.everSeen && now.Sub(p.lastSeen) < peerTimeout { if !r.latched { r.latched = true log.Printf("a partner is connected: switching the overlay to co-op mode") } r.lastCoop = now return true } } if r.latched { if now.Sub(r.lastCoop) < coopGrace { return true } r.latched = false log.Printf("no partner has shown up in %v: going back to the single-player overlay", coopGrace) } return false } // keyFor: the id wins. If it came in empty (an old version on the other // end), fall back to the name, which is how it used to work. func keyFor(m peerMessage) string { if id := strings.TrimSpace(m.ID); id != "" { return "id:" + id } return "name:" + strings.ToLower(strings.TrimSpace(m.Name)) } // rekey moves an entry to another key while keeping its place in the // order, so the overlay doesn't reshuffle columns underneath it. func (r *peerRegistry) rekey(p *peerEntry, newKey string) { delete(r.peers, p.key) for i, k := range r.order { if k == p.key { r.order[i] = newKey break } } p.key = newKey r.peers[newKey] = p } func (r *peerRegistry) update(m peerMessage) { r.mu.Lock() defer r.mu.Unlock() key := keyFor(m) p := r.peers[key] // No entry of its own: if there's a slot reserved by config still // unclaimed, the first one to arrive takes it. Reserving it by exact // name wouldn't work, since the name sent is the character's. if p == nil { for _, k := range r.order { if e := r.peers[k]; e.reserved && !e.everSeen { e.reserved = false r.rekey(e, key) p = e break } } } if p == nil { p = &peerEntry{key: key} r.peers[key] = p r.order = append(r.order, key) } if n := strings.TrimSpace(m.Name); n != "" && n != p.name { if p.name != "" && p.everSeen { log.Printf("%s switched characters: now %s", p.name, n) } p.name = n } p.deaths = m.Deaths p.bossFight = m.BossFight p.playerLoaded = m.PlayerLoaded p.lastSeen = time.Now() p.everSeen = true } func (r *peerRegistry) views() []playerView { r.mu.Lock() defer r.mu.Unlock() now := time.Now() out := make([]playerView, 0, len(r.order)) for _, k := range r.order { p := r.peers[k] // Anyone who hasn't shown up in a long while leaves the overlay: // same threshold we use to fall back to single-player mode. if p.everSeen && now.Sub(p.lastSeen) > coopGrace { continue } out = append(out, playerView{ Name: p.name, Deaths: p.deaths, BossFight: p.bossFight, PlayerLoaded: p.playerLoaded, Connected: p.everSeen && now.Sub(p.lastSeen) < peerTimeout, Self: false, }) } return out } // ------------------------- message between the two ------------------------- // authChallenge is sent by the hub as soon as the connection opens. type authChallenge struct { Type string `json:"type"` // "challenge" Nonce string `json:"nonce"` } // authReply is the peer's response: proves it knows the token without // sending it. See auth.go. type authReply struct { Type string `json:"type"` // "auth" ID string `json:"id"` Proof string `json:"proof"` } type peerMessage struct { // ID identifies the installation, not the character: it's what lets a // character switch avoid spawning a new player in the overlay. ID string `json:"id,omitempty"` Type string `json:"type,omitempty"` // "state" Name string `json:"name"` Deaths int64 `json:"deaths"` BossFight bool `json:"bossFight"` PlayerLoaded bool `json:"playerLoaded"` } // ------------------------------- hub side ------------------------------- func (r *peerRegistry) wsHandler(token string) http.HandlerFunc { var active atomic.Int32 return func(w http.ResponseWriter, req *http.Request) { if n := active.Add(1); n > maxPeerConns { active.Add(-1) log.Printf("rejecting connection from %s: already %d connections open", req.RemoteAddr, maxPeerConns) http.Error(w, "too many connections", http.StatusServiceUnavailable) return } defer active.Add(-1) c, err := wsUpgrade(w, req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer c.Close() remote := c.RemoteAddr() // --- authentication: random challenge, the token never travels --- nonce, err := randomHex(nonceBytes) if err != nil { log.Printf("couldn't generate the challenge for %s: %v", remote, err) return } ch, _ := json.Marshal(authChallenge{Type: "challenge", Nonce: nonce}) if err := c.WriteText(ch); err != nil { return } c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second)) raw, err := c.ReadMessage() if err != nil { log.Printf("%s left without authenticating: %v", remote, err) return } var reply authReply if err := json.Unmarshal(raw, &reply); err != nil || reply.Type != "auth" { log.Printf("rejecting %s: didn't send a valid authentication response", remote) return } if !proofValid(token, nonce, reply.Proof) { log.Printf("rejecting %s: token doesn't match", remote) return } log.Printf("a partner authenticated from %s", remote) var who string for { c.SetReadDeadline(time.Now().Add(15 * time.Second)) raw, err := c.ReadMessage() if err != nil { if who != "" { log.Printf("%s disconnected (%s): %v", who, remote, err) } else { log.Printf("%s disconnected: %v", remote, err) } return } var m peerMessage if err := json.Unmarshal(raw, &m); err != nil { log.Printf("unreadable message from %s: %v", remote, err) continue } // The authenticated connection's id wins: a message declaring // a different id doesn't turn it into another player. if reply.ID != "" { m.ID = reply.ID } if strings.TrimSpace(m.Name) == "" { m.Name = "Player 2" } if who == "" { who = m.Name log.Printf("%s joined the shared counter", who) } r.update(m) } } } // ------------------------------ peer side ------------------------------ // resolvePeerConn decides which hub address, token, and certificate // fingerprint to use for the peer connection — see invite.go/pin.go. // // ok=false, err=nil means peer mode has nothing configured yet (no // invite, no manual hub/token/fingerprint): that's not an error, it's // today's "not set up" state, and the caller should keep running as a // local-only overlay instead of trying to connect anywhere. // // A non-nil err means something WAS configured but is broken: a bad // invite code, or only some of hub/token/fingerprint set by hand. This // never falls back to an unpinned connection — an incomplete // configuration is meant to fail loudly at startup, not connect insecurely. func resolvePeerConn(cfg config) (hub, token, fingerprint string, ok bool, err error) { if inv := strings.TrimSpace(cfg.Invite); inv != "" { code, derr := decodeInvite(inv) if derr != nil { return "", "", "", false, fmt.Errorf("invalid invite code: %w", derr) } hub = code.Host + ":" + code.Port if h := strings.TrimSpace(cfg.Hub); h != "" { hub = h // manual override of just the host:port, e.g. a wrong auto-detected host } return hub, code.Token, code.Fingerprint, true, nil } h := strings.TrimSpace(cfg.Hub) t := strings.TrimSpace(cfg.Token) f := strings.TrimSpace(cfg.Fingerprint) switch { case h == "" && t == "" && f == "": return "", "", "", false, nil case h == "" || t == "" || f == "": return "", "", "", false, fmt.Errorf("'hub', 'token', and 'fingerprint' all need to be set together in config.toml (or use 'invite' instead)") } return h, t, f, true, nil } // peerLoop keeps the connection to the hub alive and pushes local state // once a second. Retries forever: if the hub restarts mid-stream, it // reconnects without any intervention. // // hub/token/fingerprint come from resolvePeerConn, already validated as // complete by the caller — see main.go. func peerLoop(cfg config, hub, token, fingerprint string) { id := clientID() log.Printf("my client id is %s (switching characters doesn't create a new player on the other end)", id) cfg.clientID = id cfg.Token = token tlsCfg, err := pinnedClientTLSConfig(fingerprint) if err != nil { log.Fatalf("invalid peer fingerprint: %v", err) } for { c, err := wsDialTLS(hub, "/ws", 8*time.Second, tlsCfg) if err != nil { log.Printf("couldn't connect to the hub %s (%v) - retrying in 5s", hub, err) time.Sleep(5 * time.Second) continue } if err := authenticate(c, cfg); err != nil { c.Close() log.Printf("the hub didn't accept me (%v) - retrying in 5s", err) time.Sleep(5 * time.Second) continue } log.Printf("connected and authenticated with the hub %s", hub) err = pushLoop(c, cfg) c.Close() log.Printf("connection to the hub dropped (%v) - retrying in 5s", err) time.Sleep(5 * time.Second) } } // authenticate answers the hub's challenge. The token itself isn't sent: // an HMAC of the challenge, keyed by the token, is sent instead. func authenticate(c *wsConn, cfg config) error { c.SetReadDeadline(time.Now().Add(authTimeoutSeconds * time.Second)) raw, err := c.ReadMessage() if err != nil { return fmt.Errorf("the challenge never arrived: %w", err) } var ch authChallenge if err := json.Unmarshal(raw, &ch); err != nil || ch.Type != "challenge" || ch.Nonce == "" { return fmt.Errorf("couldn't make sense of the hub's challenge") } if strings.TrimSpace(cfg.Token) == "" { return fmt.Errorf("you're missing the token in config.toml: ask whoever runs the hub for it") } reply, _ := json.Marshal(authReply{ Type: "auth", ID: cfg.clientID, Proof: proofFor(cfg.Token, ch.Nonce), }) if err := c.WriteText(reply); err != nil { return err } c.SetReadDeadline(time.Time{}) return nil } func pushLoop(c *wsConn, cfg config) error { // A background reader: we don't expect messages from the hub, but we // still need to answer its pings and notice if it hangs up. readErr := make(chan error, 1) go func() { for { if _, err := c.ReadMessage(); err != nil { readErr <- err return } } }() tick := time.NewTicker(1 * time.Second) defer tick.Stop() for { select { case err := <-readErr: return err case <-tick.C: snap := st.snapshot() msg := peerMessage{ Type: "state", ID: cfg.clientID, Name: displayName(cfg), Deaths: snap.Total, BossFight: snap.BossFight, PlayerLoaded: snap.PlayerLoaded, } b, err := json.Marshal(msg) if err != nil { return err } if err := c.WriteText(b); err != nil { return err } } } }