// duo.go: modo cooperativo. Cada jugador corre el programa en su propia // PC leyendo su propio Elden Ring; uno hace de "hub" (sirve el overlay a // OBS) y el resto se conectan a el por WebSocket y le empujan su contador. // // La conexion la abre SIEMPRE el peer hacia el hub, que es lo comodo con // Tailscale: alcanza con que el hub tenga una IP estable en la tailnet. package main import ( "encoding/json" "fmt" "log" "net/http" "strings" "sync" "sync/atomic" "time" ) // Si un peer no manda nada en este tiempo, lo damos por desconectado. // El peer empuja una vez por segundo, asi que es holgado. const peerTimeout = 6 * time.Second // Cuanto seguimos mostrando la interfaz de coop despues de que el // compañero desaparece. Ver coopMode() para el porque. const coopGrace = 2 * time.Minute // --------------------------- registro de peers --------------------------- 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: OJO con la identidad. El nombre NO sirve como clave: desde // que lo leemos del personaje, cambia cuando el jugador carga otra // partida, y si indexaramos por nombre cada cambio de personaje crearia // un jugador nuevo y dejaria el anterior colgado en pantalla. La // identidad es el id que manda cada instancia (ver clientID), estable // entre reconexiones y reinicios; el nombre es solo algo que mostramos. type peerEntry struct { key string // como lo indexamos: el id, o "name:x" si es viejo name string deaths int64 bossFight bool playerLoaded bool lastSeen time.Time everSeen bool reserved bool // lugar apartado por config, todavia sin dueño } type peerRegistry struct { mu sync.Mutex peers map[string]*peerEntry order []string // orden de aparicion, para que el overlay no baile forced bool // el config nombro un compañero: coop desde el arranque latched bool // ya vimos un compañero en esta corrida lastCoop time.Time // ultima vez que hubo alguien conectado } func newPeerRegistry() *peerRegistry { return &peerRegistry{peers: map[string]*peerEntry{}} } // declare reserva el lugar de un compañero antes de que se conecte y // fija el modo coop desde el arranque. Es opcional: sirve cuando querés // que el overlay tenga el tamaño definitivo desde el minuto cero en vez // de cambiar cuando el otro aparece. 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 decide si el overlay va en dos columnas o en una. // // Se engancha cuando aparece un compañero y NO se suelta al primer // bache: si volviera a modo solo cada vez que se corta la red un // segundo, el overlay se redibujaria en vivo en pleno stream. Recien // vuelve al modo de un jugador cuando el otro estuvo ausente un buen // rato, que es la señal de "se fue a dormir", no de "se le colgo el wifi". 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("hay un compañero conectado: paso el overlay a modo coop") } r.lastCoop = now return true } } if r.latched { if now.Sub(r.lastCoop) < coopGrace { return true } r.latched = false log.Printf("hace %v que no aparece ningun compañero: vuelvo al overlay de un jugador", coopGrace) } return false } // keyFor: el id manda. Si viniera vacio (una version vieja del otro // lado) caemos al nombre, que es lo que habia antes. func keyFor(m peerMessage) string { if id := strings.TrimSpace(m.ID); id != "" { return "id:" + id } return "name:" + strings.ToLower(strings.TrimSpace(m.Name)) } // rekey mueve una entrada a otra clave conservando su lugar en el orden, // para que el overlay no reordene columnas por debajo. 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] // Sin entrada propia: si hay un lugar apartado por config todavia sin // dueño, lo toma el primero que llega. Apartarlo por nombre exacto no // serviria, porque el nombre que manda es el del personaje. 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 cambio de personaje: ahora es %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] // Los que hace mucho que no aparecen se van del overlay: es el // mismo umbral con el que volvemos al modo de un jugador. 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 } // ------------------------- mensaje entre los dos ------------------------- // authChallenge lo manda el hub apenas se abre la conexion. type authChallenge struct { Type string `json:"type"` // "challenge" Nonce string `json:"nonce"` } // authReply es la respuesta del peer: prueba que conoce el token sin // mandarlo. Ver auth.go. type authReply struct { Type string `json:"type"` // "auth" ID string `json:"id"` Proof string `json:"proof"` } type peerMessage struct { // ID identifica a la instalacion, no al personaje: es lo que permite // que cambiar de personaje no cree un jugador nuevo en el 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"` } // ------------------------------- lado hub ------------------------------- func (r *peerRegistry) wsHandler(token string) http.HandlerFunc { var activas atomic.Int32 return func(w http.ResponseWriter, req *http.Request) { if n := activas.Add(1); n > maxPeerConns { activas.Add(-1) log.Printf("rechazo conexion de %s: ya hay %d conexiones abiertas", req.RemoteAddr, maxPeerConns) http.Error(w, "too many connections", http.StatusServiceUnavailable) return } defer activas.Add(-1) c, err := wsUpgrade(w, req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } defer c.Close() remote := c.RemoteAddr() // --- autenticacion: desafio al azar, el token no viaja --- nonce, err := randomHex(nonceBytes) if err != nil { log.Printf("no pude generar el desafio para %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 se fue sin autenticarse: %v", remote, err) return } var reply authReply if err := json.Unmarshal(raw, &reply); err != nil || reply.Type != "auth" { log.Printf("rechazo %s: no mando una respuesta de autenticacion valida", remote) return } if !proofValid(token, nonce, reply.Proof) { log.Printf("rechazo %s: el token no coincide", remote) return } log.Printf("se autentico un compañero desde %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("se desconecto %s (%s): %v", who, remote, err) } else { log.Printf("se desconecto %s: %v", remote, err) } return } var m peerMessage if err := json.Unmarshal(raw, &m); err != nil { log.Printf("mensaje ilegible de %s: %v", remote, err) continue } // El id de la conexion autenticada manda: que un mensaje // declare otro id no lo convierte en otro jugador. if reply.ID != "" { m.ID = reply.ID } if strings.TrimSpace(m.Name) == "" { m.Name = "Jugador 2" } if who == "" { who = m.Name log.Printf("%s entro al contador compartido", who) } r.update(m) } } } // ------------------------------ lado peer ------------------------------ // peerLoop mantiene viva la conexion con el hub y le empuja el estado // local una vez por segundo. Reintenta solo para siempre: si el hub se // reinicia en medio del stream, se vuelve a enganchar sin tocar nada. func peerLoop(cfg config) { id := clientID() log.Printf("mi id de cliente es %s (cambiar de personaje no crea un jugador nuevo del otro lado)", id) cfg.clientID = id for { c, err := wsDial(cfg.Hub, "/ws", 8*time.Second) if err != nil { log.Printf("no me pude conectar al hub %s (%v) - reintento en 5s", cfg.Hub, err) time.Sleep(5 * time.Second) continue } if err := authenticate(c, cfg); err != nil { c.Close() log.Printf("el hub no me acepto (%v) - reintento en 5s", err) time.Sleep(5 * time.Second) continue } log.Printf("conectado y autenticado con el hub %s", cfg.Hub) err = pushLoop(c, cfg) c.Close() log.Printf("se corto la conexion con el hub (%v) - reintento en 5s", err) time.Sleep(5 * time.Second) } } // authenticate responde el desafio del hub. El token no se manda: se // manda un HMAC del desafio hecho con el token. 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("no llego el desafio: %w", err) } var ch authChallenge if err := json.Unmarshal(raw, &ch); err != nil || ch.Type != "challenge" || ch.Nonce == "" { return fmt.Errorf("el desafio del hub no se entiende") } if strings.TrimSpace(cfg.Token) == "" { return fmt.Errorf("te falta el token en config.toml: pedíselo a quien corre el hub") } 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 { // Un lector en segundo plano: no esperamos mensajes del hub, pero hay // que atender sus pings y enterarnos si corta. 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: nombreMostrado(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 } } } }